Initial commit

This commit is contained in:
Porawit Dongwang
2026-09-16 23:20:08 +07:00
commit 0041668dbb
32577 changed files with 3687927 additions and 0 deletions
@@ -0,0 +1,85 @@
<?php
namespace App\Helpers;
/**
* Class Argon2Hasher
* Enterprise Argon2id Password Hashing & Password Policy Guard
*
* @package App\Helpers
*/
class Argon2Hasher
{
/**
* Hash a plaintext password using Argon2id
*
* @param string $password Plaintext password
* @return string Hashed password
*/
public static function hash(string $password): string
{
return password_hash($password, PASSWORD_ARGON2ID, [
'memory_cost' => 65536, // 64 MB
'time_cost' => 4, // 4 passes
'threads' => 1,
]);
}
/**
* Verify plaintext password against Argon2id hash
*
* @param string $password Plaintext password
* @param string $hash Hashed password stored in database
* @return bool
*/
public static function verify(string $password, string $hash): bool
{
if (empty($hash)) return false;
return password_verify($password, $hash);
}
/**
* Check if password needs rehash due to upgraded algorithm or parameters
*
* @param string $hash
* @return bool
*/
public static function needsRehash(string $hash): bool
{
return password_needs_rehash($hash, PASSWORD_ARGON2ID, [
'memory_cost' => 65536,
'time_cost' => 4,
'threads' => 1,
]);
}
/**
* Validate Password Strength against Enterprise Security Policy
*
* @param string $password
* @return array ['valid' => bool, 'errors' => array]
*/
public static function validatePolicy(string $password): array
{
$errors = [];
if (strlen($password) < 8) {
$errors[] = "รหัสผ่านต้องมีความยาวอย่างน้อย 8 ตัวอักษร";
}
if (!preg_match('/[A-Z]/', $password)) {
$errors[] = "รหัสผ่านต้องมีตัวอักษรภาษาอังกฤษพิมพ์ใหญ่อย่างน้อย 1 ตัว (A-Z)";
}
if (!preg_match('/[a-z]/', $password)) {
$errors[] = "รหัสผ่านต้องมีตัวอักษรภาษาอังกฤษพิมพ์เล็กอย่างน้อย 1 ตัว (a-z)";
}
if (!preg_match('/[0-9]/', $password)) {
$errors[] = "รหัสผ่านต้องมีตัวเลขอย่างน้อย 1 ตัว (0-9)";
}
if (!preg_match('/[\W_]/', $password)) {
$errors[] = "รหัสผ่านต้องมีอักขระพิเศษอย่างน้อย 1 ตัว (!@#$%^&* เป็นต้น)";
}
return [
'valid' => empty($errors),
'errors' => $errors,
];
}
}
@@ -0,0 +1,162 @@
<?php
namespace App\Helpers;
/**
* Class EscPosPrinter
* Enterprise Thermal Printer ESC/POS Command Builder (Queue Tickets & Tax Invoices)
*
* @package App\Helpers
*/
class EscPosPrinter
{
private string $buffer = "";
private string $ip;
private int $port;
// ESC/POS Command Constants
const ESC = "\x1B";
const GS = "\x1D";
const LF = "\x0A";
public function __construct(string $ip = '192.168.1.200', int $port = 9100)
{
$this->ip = $ip;
$this->port = $port;
$this->init();
}
/**
* Initialize Printer
*/
public function init(): self
{
$this->buffer .= self::ESC . "@"; // Reset
return $this;
}
/**
* Set text alignment
* @param string $align 'left' | 'center' | 'right'
*/
public function setAlign(string $align): self
{
$val = 0;
if ($align === 'center') $val = 1;
if ($align === 'right') $val = 2;
$this->buffer .= self::ESC . "a" . chr($val);
return $this;
}
/**
* Set text size
* @param int $width 1-8
* @param int $height 1-8
*/
public function setSize(int $width = 1, int $height = 1): self
{
$w = ($width - 1) << 4;
$h = ($height - 1);
$this->buffer .= self::GS . "!" . chr($w | $h);
return $this;
}
/**
* Set font emphasis (Bold)
*/
public function setBold(bool $enabled = true): self
{
$this->buffer .= self::ESC . "E" . chr($enabled ? 1 : 0);
return $this;
}
/**
* Print text line with Line Feed
*/
public function text(string $text): self
{
// ในระบบ Thermal Printer ไทยมักใช้ TIS-620 หรือ CP874
// แปลง UTF-8 เป็น CP874
$converted = @iconv('UTF-8', 'CP874//IGNORE', $text);
$this->buffer .= ($converted !== false ? $converted : $text) . self::LF;
return $this;
}
/**
* Print Horizontal Line separator (80mm = 48 chars)
*/
public function line(): self
{
$this->text(str_repeat('-', 48));
return $this;
}
/**
* Feed paper and cut
*/
public function cut(): self
{
$this->buffer .= self::LF . self::LF . self::LF;
$this->buffer .= self::GS . "V" . chr(66) . chr(0); // Partial cut
return $this;
}
/**
* Get RAW buffer for WebUSB / Browser Print
*/
public function getBuffer(): string
{
return $this->buffer;
}
/**
* Get Base64 encoded buffer for JSON API transfer to POS Frontend
*/
public function getBase64Buffer(): string
{
return base64_encode($this->buffer);
}
/**
* Send command directly to Network LAN Printer via Socket
* @return bool
*/
public function sendToPrinter(): bool
{
$fp = @fsockopen($this->ip, $this->port, $errno, $errstr, 2.0);
if (!$fp) {
error_log("ESC/POS Printer Connect Failed ({$this->ip}:{$this->port}) - {$errstr} ({$errno})");
return false;
}
fwrite($fp, $this->buffer);
fclose($fp);
return true;
}
/**
* Build Queue Ticket Buffer (ใบคิวสำหรับผู้ป่วย)
*/
public static function buildQueueTicket(array $queueData): string
{
$printer = new self();
$printer->setAlign('center')
->setSize(2, 2)->setBold(true)->text("ใบคิวนวดแผนไทย")
->setSize(1, 1)->setBold(false)->text($queueData['branch_name'] ?? "TTMQMS Center")
->line()
->setSize(3, 3)->setBold(true)->text($queueData['queue_no'])
->setSize(1, 1)->setBold(false)->text("ประเภทคิว: " . ($queueData['priority'] ?? 'Normal'))
->line()
->setAlign('left')
->text("ผู้รับบริการ: " . ($queueData['patient_name'] ?? '-'))
->text("บริการ: " . ($queueData['service_name'] ?? '-'))
->text("หมอนวด: " . ($queueData['therapist_name'] ?? 'รอจัดสรร (AI Queue)'))
->text("ห้องนวด: " . ($queueData['room_no'] ?? '-'))
->line()
->setAlign('center')
->text("เวลาออกคิว: " . ($queueData['checkin_time'] ?? date('Y-m-d H:i:s')))
->text("โปรดรอเรียกคิวที่หน้าจอ TV Display")
->text("*** ขอบพระคุณที่ใช้บริการ ***")
->cut();
return $printer->getBase64Buffer();
}
}
@@ -0,0 +1,112 @@
<?php
namespace App\Helpers;
/**
* Class Response
* Standardized JSON REST API Response Builder (PSR-7 Style)
*
* @package App\Helpers
*/
class Response
{
/**
* Send raw JSON response
*
* @param mixed $data
* @param int $status HTTP Status Code
* @param array $headers Additional headers
*/
public static function json($data, int $status = 200, array $headers = []): void
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
header('X-Powered-By: TTMQMS Enterprise API Engine v2.0');
foreach ($headers as $key => $val) {
header("{$key}: {$val}");
}
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
exit();
}
/**
* Send API Success Response
*
* @param string $message
* @param mixed $data
* @param int $status
*/
public static function success(string $message = 'Success', $data = null, int $status = 200): void
{
self::json([
'success' => true,
'status_code' => $status,
'message' => $message,
'data' => $data,
'timestamp' => date('Y-m-d H:i:s'),
], $status);
}
/**
* Send API Error Response
*
* @param string $message
* @param int $status
* @param array $errors
*/
public static function error(string $message = 'Error occurred', int $status = 400, array $errors = []): void
{
self::json([
'success' => false,
'status_code' => $status,
'error' => [
'message' => $message,
'details' => $errors,
],
'timestamp' => date('Y-m-d H:i:s'),
], $status);
}
/**
* Send Paginated API Response
*
* @param array $items
* @param int $total
* @param int $page
* @param int $limit
* @param string $message
*/
public static function paginate(array $items, int $total, int $page = 1, int $limit = 20, string $message = 'Data retrieved successfully'): void
{
$totalPages = $limit > 0 ? (int)ceil($total / $limit) : 1;
self::json([
'success' => true,
'status_code' => 200,
'message' => $message,
'data' => $items,
'pagination' => [
'total_items' => $total,
'current_page' => $page,
'items_per_page' => $limit,
'total_pages' => $totalPages,
'has_next' => $page < $totalPages,
'has_prev' => $page > 1,
],
'timestamp' => date('Y-m-d H:i:s'),
], 200);
}
/**
* Redirect to URL (For Frontend Views)
*
* @param string $url
* @param int $status
*/
public static function redirect(string $url, int $status = 302): void
{
http_response_code($status);
header("Location: {$url}");
exit();
}
}
@@ -0,0 +1,152 @@
<?php
namespace App\Helpers;
/**
* Class Security
* Enterprise Security Utilities (JWT Encoder/Decoder, XSS Sanitizer, CSRF, CSP)
*
* @package App\Helpers
*/
class Security
{
/**
* Sanitize String against XSS (Cross-Site Scripting)
*
* @param string|null $input
* @return string
*/
public static function clean(?string $input): string
{
if ($input === null) return '';
return htmlspecialchars(trim($input), ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
/**
* Recursive XSS Sanitize for Array or Object
*
* @param mixed $data
* @return mixed
*/
public static function cleanArray($data)
{
if (is_array($data)) {
return array_map([self::class, 'cleanArray'], $data);
}
if (is_string($data)) {
return self::clean($data);
}
return $data;
}
/**
* Generate CSRF Token and store in Session
*
* @return string
*/
public static function generateCsrfToken(): string
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
/**
* Verify CSRF Token
*
* @param string|null $token
* @return bool
*/
public static function verifyCsrfToken(?string $token): bool
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
if (empty($_SESSION['csrf_token']) || empty($token)) {
return false;
}
return hash_equals($_SESSION['csrf_token'], $token);
}
/**
* Generate JSON Web Token (JWT RFC7519)
*
* @param array $payload
* @param string $secret
* @param int $ttl Seconds
* @return string
*/
public static function generateJwt(array $payload, string $secret, int $ttl = 3600): string
{
$header = json_encode(['typ' => 'JWT', 'alg' => 'HS256']);
$payload['iat'] = time();
$payload['exp'] = time() + $ttl;
$payload['iss'] = 'https://ttmqms.hospital.local';
$base64UrlHeader = self::base64UrlEncode($header);
$base64UrlPayload = self::base64UrlEncode(json_encode($payload));
$signature = hash_hmac('sha256', $base64UrlHeader . "." . $base64UrlPayload, $secret, true);
$base64UrlSignature = self::base64UrlEncode($signature);
return $base64UrlHeader . "." . $base64UrlPayload . "." . $base64UrlSignature;
}
/**
* Verify and Decode JWT Token
*
* @param string $token
* @param string $secret
* @return array|null Payload array if valid, null if invalid or expired
*/
public static function decodeJwt(string $token, string $secret): ?array
{
$parts = explode('.', $token);
if (count($parts) !== 3) return null;
[$header64, $payload64, $sig64] = $parts;
$signature = self::base64UrlDecode($sig64);
$expectedSig = hash_hmac('sha256', $header64 . "." . $payload64, $secret, true);
if (!hash_equals($expectedSig, $signature)) {
return null; // Signature mismatch (Tampered)
}
$payload = json_decode(self::base64UrlDecode($payload64), true);
if (!$payload || !isset($payload['exp']) || $payload['exp'] < time()) {
return null; // Token expired
}
return $payload;
}
private static function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
private static function base64UrlDecode(string $data): string
{
return base64_decode(strtr($data, '-_', '+/'));
}
/**
* Get Client IP Address (Supporting Proxy X-Forwarded-For)
*/
public static function getClientIp(): string
{
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
return $_SERVER['HTTP_CLIENT_IP'];
}
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
return trim($ips[0]);
}
return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
}
}
@@ -0,0 +1,145 @@
<?php
namespace App\Helpers;
/**
* Class Validator
* Enterprise Data Validation Engine (Input & API Request Guard)
*
* @package App\Helpers
*/
class Validator
{
private array $errors = [];
private array $data;
public function __construct(array $data)
{
$this->data = $data;
}
/**
* Create and run validation rules
*
* @param array $data
* @param array $rules Rule definitions e.g. ['national_id' => 'required|cid_13']
* @return self
*/
public static function make(array $data, array $rules): self
{
$validator = new self($data);
foreach ($rules as $field => $ruleStr) {
$ruleList = explode('|', $ruleStr);
foreach ($ruleList as $rule) {
if (strpos((string)$rule, ':') !== false) {
[$ruleName, $param] = explode(':', $rule, 2);
$validator->applyRule($field, $ruleName, $param);
} else {
$validator->applyRule($field, $rule, null);
}
}
}
return $validator;
}
private function applyRule(string $field, string $rule, ?string $param): void
{
$val = $this->data[$field] ?? null;
if ($rule === 'required' && ($val === null || trim((string)$val) === '')) {
$this->addError($field, "ฟิลด์ {$field} เป็นข้อมูลที่ต้องระบุ (Required)");
return;
}
if ($val === null || trim((string)$val) === '') {
return; // ข้ามการตรวจสอบอื่นถ้าค่าเป็นว่างและไม่ได้เป็น required
}
switch ($rule) {
case 'cid_13':
if (!preg_match('/^[0-9]{13}$/', (string)$val)) {
$this->addError($field, "เลขบัตรประชาชนต้องเป็นตัวเลข 13 หลักเท่านั้น");
} elseif (!$this->verifyThaiIDChecksum((string)$val)) {
$this->addError($field, "เลขบัตรประชาชน 13 หลักไม่ถูกต้องตามสูตรคำนวณ Checksum");
}
break;
case 'numeric':
if (!is_numeric($val)) {
$this->addError($field, "ฟิลด์ {$field} ต้องเป็นตัวเลขเท่านั้น");
}
break;
case 'email':
if (!filter_var($val, FILTER_VALIDATE_EMAIL)) {
$this->addError($field, "รูปแบบอีเมลไม่ถูกต้อง");
}
break;
case 'min':
if (strlen((string)$val) < (int)$param) {
$this->addError($field, "ฟิลด์ {$field} ต้องมีความยาวอย่างน้อย {$param} ตัวอักษร");
}
break;
case 'max':
if (strlen((string)$val) > (int)$param) {
$this->addError($field, "ฟิลด์ {$field} ต้องมีความยาวไม่เกิน {$param} ตัวอักษร");
}
break;
case 'in':
$allowed = explode(',', (string)$param);
if (!in_array($val, $allowed, true)) {
$this->addError($field, "ค่าในฟิลด์ {$field} ไม่ถูกต้อง (ต้องเป็น: " . implode(', ', $allowed) . ")");
}
break;
case 'date':
if (!strtotime((string)$val)) {
$this->addError($field, "ฟิลด์ {$field} ต้องเป็นวันที่ที่ถูกต้อง (YYYY-MM-DD)");
}
break;
case 'time':
if (!preg_match('/^(0[0-9]|1[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/', (string)$val)) {
$this->addError($field, "ฟิลด์ {$field} ต้องเป็นเวลาที่ถูกต้อง (HH:MM หรือ HH:MM:SS)");
}
break;
case 'vas_score':
if (!is_numeric($val) || (int)$val < 0 || (int)$val > 10) {
$this->addError($field, "ระดับความปวด VAS Score ต้องเป็นตัวเลขระหว่าง 0 ถึง 10");
}
break;
}
}
/**
* ตรวจสอบ Checksum เลขบัตรประชาชนไทย 13 หลัก ตามหลักโมดูโล 11
*/
private function verifyThaiIDChecksum(string $cid): bool
{
if (strlen($cid) !== 13) return false;
$sum = 0;
for ($i = 0; $i < 12; $i++) {
$sum += (int)$cid[$i] * (13 - $i);
}
$checkDigit = (11 - ($sum % 11)) % 10;
return (int)$cid[12] === $checkDigit;
}
private function addError(string $field, string $msg): void
{
$this->errors[$field][] = $msg;
}
public function fails(): bool
{
return !empty($this->errors);
}
public function getErrors(): array
{
return $this->errors;
}
}