From 618045540a8762ddec9c7097cb42f896ef804fdb Mon Sep 17 00:00:00 2001 From: nok Date: Mon, 3 Aug 2026 11:17:41 +0700 Subject: [PATCH] fix login and configurations --- app/config.example.php | 27 +++++ app/login/api/engine/login_confirm.php | 51 ++++++--- app/login/api/engine/login_otp.php | 15 ++- app/login/api/engine/request_new_otp.php | 15 ++- index.php | 2 +- nodejs/logs/scheduler-error.log | 18 +++ nodejs/logs/scheduler.log | 50 +++++++++ nodejs/logs/socket-error.log | 0 nodejs/logs/socket.log | 137 +++++++++++++++++++++++ nodejs/package-lock.json | 22 ++-- setup.php | 58 +++++++++- 11 files changed, 364 insertions(+), 31 deletions(-) create mode 100644 nodejs/logs/scheduler-error.log create mode 100644 nodejs/logs/scheduler.log create mode 100644 nodejs/logs/socket-error.log create mode 100644 nodejs/logs/socket.log diff --git a/app/config.example.php b/app/config.example.php index dc93995..571886e 100644 --- a/app/config.example.php +++ b/app/config.example.php @@ -22,6 +22,33 @@ if(true){ $time_zone = "Asia/Bangkok"; } +// ── Real-time Node.js server ───────────────────────────────────────────────── +// Base URL the browser uses to reach nodejs/server.js (Socket.IO). Must match +// PORT in nodejs/.env. +if (!defined('NODE_PUBLIC_URL')) { + define('NODE_PUBLIC_URL', 'http://localhost:3000'); +} +// Server-to-server calls (PHP → Node /emit, and Node scheduler → PHP cron +// endpoints) authenticate with this shared secret — must match EMIT_SECRET +// in nodejs/.env exactly. +if (!defined('NODE_EMIT_URL')) { + define('NODE_EMIT_URL', 'http://127.0.0.1:3000/emit'); +} +if (!defined('NODE_EMIT_SECRET')) { + define('NODE_EMIT_SECRET', 'YOUR_NODE_EMIT_SECRET'); // must match nodejs/.env EMIT_SECRET +} + +// ── Usage packages ─────────────────────────────────────────────────────────── +// Keyed by company_list.package (defaults to 'starter'). Read by UsageGuard to +// enforce daily/weekly action limits and which features lock once exceeded. +$packages = [ + 'starter' => [ + 'daily_limit' => 30, + 'weekly_limit' => 100, + 'lock_on_limit' => ['dashboard'], + ], +]; + // unique key — used for SMTP password encryption, keep consistent across deploys diff --git a/app/login/api/engine/login_confirm.php b/app/login/api/engine/login_confirm.php index 20443ec..c4daaf7 100644 --- a/app/login/api/engine/login_confirm.php +++ b/app/login/api/engine/login_confirm.php @@ -24,9 +24,10 @@ * Fail either → return "Wrong OTP! Please try again." * 5. On success: * a. Concurrent-session check — if the account already has a session_token - * set and session_last_seen is within the last hour, the login is blocked - * with "already signed in on another device." A stale or NULL token allows - * the login (user closed browser without logging out, or used back.php). + * set and session_last_seen is within SESSION_ACTIVE_GRACE_SECONDS + * (see below), the login is blocked with "already signed in on another + * device." A stale or NULL token allows the login (previous session + * expired naturally, or the user used back.php to log out explicitly). * b. session_regenerate_id(true) — prevents session fixation attack by * issuing a new session ID and deleting the old one. * c. Generate a fresh CSRF token and store in session. @@ -109,22 +110,46 @@ if (empty($_SESSION['skip_otp'])) { // ── Step 4b: Concurrent session check ──────────────────────────────────────── // Block the login if this account already has an active session. -// "Active" = session_token is set AND session_last_seen is within the last hour. -// A stale last_seen (user closed browser without logging out) expires after 1 h, -// matching the PHP session GC maxlifetime configured in session.php. +// "Active" = session_token is set AND session_last_seen is within the last +// SESSION_ACTIVE_GRACE_SECONDS. db_auth.php refreshes session_last_seen on every +// authenticated request (throttled to once per 60s), so a session that is truly +// still in use on another device keeps re-touching this timestamp well within +// the grace window below. A session that has actually ended — browser/tab closed, +// cookie lost, PHP session GC'd — stops refreshing it and goes stale quickly. +// +// This window must stay well above the 60s throttle in db_auth.php (otherwise a +// live second session could go stale between its own refreshes and let a login +// through) but short enough that a real re-login isn't blocked for long after the +// previous session actually ended. It intentionally does NOT match PHP's +// session.gc_maxlifetime (3600s) — that timeout is about when PHP reclaims the +// session file on disk, not about how quickly this check should stop treating a +// dead session as "still active". // An explicit logout clears session_token to NULL, so back.php bypasses this. +// +// The staleness comparison is done entirely in SQL (session_last_seen vs MySQL's +// own NOW()), not in PHP. session_last_seen is written with MySQL's NOW(), and +// the MySQL server here runs on Asia/Bangkok time while PHP's default timezone is +// UTC (config.php's $time_zone is never applied via date_default_timezone_set()). +// Pulling the timestamp into PHP and comparing with strtotime()/time() silently +// misreads that Bangkok wall-clock string as UTC — 7 hours in the future — which +// made idle_seconds permanently negative and this check block every login, +// regardless of window size. Comparing inside MySQL sidesteps the mismatch +// without touching PHP's global timezone (which would ripple into every other +// date()/time() call in the app). +define('SESSION_ACTIVE_GRACE_SECONDS', 120); + $sth_active = $pdo1->prepare( - "SELECT session_token, session_last_seen FROM user WHERE user_id = :uid LIMIT 1" + "SELECT session_token, + (session_last_seen IS NOT NULL + AND session_last_seen > (NOW() - INTERVAL " . SESSION_ACTIVE_GRACE_SECONDS . " SECOND)) AS is_active + FROM user WHERE user_id = :uid LIMIT 1" ); $sth_active->execute([':uid' => $user_id]); $active_row = $sth_active->fetch(PDO::FETCH_ASSOC); -if (!empty($active_row['session_token']) && !empty($active_row['session_last_seen'])) { - $idle_seconds = time() - strtotime($active_row['session_last_seen']); - if ($idle_seconds < 3600) { - $answer['message'] = 'This account is currently signed in on another device. Please sign out from that session first.'; - exit(json_encode($answer)); - } +if (!empty($active_row['session_token']) && !empty($active_row['is_active'])) { + $answer['message'] = 'This account is currently signed in on another device. Please sign out from that session first.'; + exit(json_encode($answer)); } // ── Step 4c: Claim session ──────────────────────────────────────────────────── diff --git a/app/login/api/engine/login_otp.php b/app/login/api/engine/login_otp.php index 313e2c6..46c4994 100644 --- a/app/login/api/engine/login_otp.php +++ b/app/login/api/engine/login_otp.php @@ -342,7 +342,20 @@ if (password_verify(trim($data["password"]), $temp["password"])) { "company_id" => $default_company, "smtp" => $smtp_config, "subject" => "One Time Password (OTP) For reference number " . $reference_number, - "message" => "Your OTP is " . $otp . " for reference number " . $reference_number, + "message" => implode("\n", [ + "Dear WMS user,", + "", + "You requested a One-Time Password (OTP) to log in to WMS.", + "", + "Please use the OTP below to complete your request:", + "• OTP code: " . $otp, + "• Reference number: " . $reference_number, + "", + "Please note:", + "• This code will expire in 3 minutes. Please complete your action promptly.", + "• Do not share this code with anyone to keep your account secure.", + "• If you did not request this code, please ignore this email.", + ]), "channel_name" => "WMS LOGIN OTP", "to" => $user_email, "key" => $pinkey, diff --git a/app/login/api/engine/request_new_otp.php b/app/login/api/engine/request_new_otp.php index 1bdd312..460be3a 100644 --- a/app/login/api/engine/request_new_otp.php +++ b/app/login/api/engine/request_new_otp.php @@ -115,7 +115,20 @@ if (password_verify(trim($data["password"]), $temp["password"])) { "company_id" => 0, "smtp" => $SMTP, "subject" => "One Time Password (OTP) For reference number " . $reference_number, - "message" => "Your OTP is " . $otp . " for reference number " . $reference_number, + "message" => implode("\n", [ + "Dear WMS user,", + "", + "You requested a One-Time Password (OTP) to log in to WMS.", + "", + "Please use the OTP below to complete your request:", + "• OTP code: " . $otp, + "• Reference number: " . $reference_number, + "", + "Please note:", + "• This code will expire in 3 minutes. Please complete your action promptly.", + "• Do not share this code with anyone to keep your account secure.", + "• If you did not request this code, please ignore this email.", + ]), "channel_name" => "WMS LOGIN OTP ", "to" => $user_email, "key" => $pinkey, diff --git a/index.php b/index.php index 4ee5f69..6bc473f 100644 --- a/index.php +++ b/index.php @@ -1,4 +1,4 @@ =10.2.0" @@ -929,13 +929,13 @@ } }, "node_modules/socket.io-adapter": { - "version": "2.5.7", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.7.tgz", - "integrity": "sha512-e0LyK91f3cUxTmv95/KzoLg47+zF+s/sbxRGDNsyG4dmIP8ZSX8ax6byOxfJXeNNtS/8AZlfD+uP7gBeR7DLlg==", + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", "license": "MIT", "dependencies": { "debug": "~4.4.1", - "ws": "~8.20.1" + "ws": "~8.21.0" } }, "node_modules/socket.io-adapter/node_modules/debug": { @@ -1085,9 +1085,9 @@ } }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/setup.php b/setup.php index 9ac71e0..dc21eab 100644 --- a/setup.php +++ b/setup.php @@ -269,7 +269,7 @@ run($pdo, " CREATE TABLE IF NOT EXISTS `md_bin` ( `id` int(11) NOT NULL AUTO_INCREMENT, `company_id` int(11) NOT NULL, - `td_stock_id` int(11) NOT NULL DEFAULT 0, + `td_stock_id` int(11) DEFAULT NULL, `warehouse` int(11) NOT NULL, `storage_id` int(11) NOT NULL DEFAULT 0, `zone` varchar(10) NOT NULL, @@ -283,6 +283,15 @@ CREATE TABLE IF NOT EXISTS `md_bin` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ", 'md_bin'); +// Column made nullable — WarehouseManager::releaseBin() does `SET td_stock_id = +// NULL` to mark a bin empty (mirroring product_sku on the same row), but the +// column was NOT NULL DEFAULT 0. Under strict SQL mode this made releaseBin() +// fail with "Column 'td_stock_id' cannot be null" on every supplier return, +// every fully-depleted stock-out, and every stock transfer. Every read site +// (getBinStock(), occupyBin()) already treats 0 and NULL as equivalent "empty" +// (`if (!$td_stock_id)`), so this is a safe, behavior-preserving widening. +run($pdo, "ALTER TABLE `md_bin` MODIFY `td_stock_id` int(11) DEFAULT NULL", 'md_bin.td_stock_id nullable'); + run($pdo, " CREATE TABLE IF NOT EXISTS `td_bin_log` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, @@ -513,7 +522,7 @@ CREATE TABLE IF NOT EXISTS `td_stock` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `ref_id` int(11) unsigned NOT NULL DEFAULT 0, `company_id` int(11) NOT NULL DEFAULT 0, - `uuid` varchar(20) NOT NULL, + `uuid` varchar(64) NOT NULL, `type` varchar(20) NOT NULL, `ref_warehouse` varchar(20) NOT NULL DEFAULT '', `date` datetime DEFAULT NULL, @@ -540,6 +549,22 @@ CREATE TABLE IF NOT EXISTS `td_stock` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; ", 'td_stock (base template)'); +// Column widened for db_auth.php's 32-char $uuid (bin2hex(random_bytes(16))) plus +// manager-appended suffixes (e.g. "_ret_0", "_srn_0", "_3") — varchar(20) was too +// narrow and caused "Data too long for column 'uuid'" on every real stock movement +// (PO receipt, order confirm, return confirm, stock transfer) under strict SQL mode. +run($pdo, "ALTER TABLE `td_stock` MODIFY `uuid` varchar(64) NOT NULL", 'td_stock.uuid width'); + +// Per-warehouse td_stock_ tables are cloned from td_stock at warehouse-creation +// time (WarehouseManager::createStockTableForWarehouse()) and are NOT otherwise +// tracked by this script — widen uuid on every one that already exists so +// previously-created warehouses get the same fix. New warehouses get it for free +// from the template above. +$stockTables = $pdo->query("SHOW TABLES LIKE 'td\\_stock\\_%'")->fetchAll(PDO::FETCH_COLUMN); +foreach ($stockTables as $t) { + run($pdo, "ALTER TABLE `{$t}` MODIFY `uuid` varchar(64) NOT NULL", "{$t}.uuid width"); +} + run($pdo, " CREATE TABLE IF NOT EXISTS `etl_stock_summary` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, @@ -558,6 +583,24 @@ CREATE TABLE IF NOT EXISTS `etl_stock_summary` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; ", 'etl_stock_summary'); +run($pdo, " +CREATE TABLE IF NOT EXISTS `etl_gl_summary` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `company_id` int(11) unsigned NOT NULL DEFAULT 0, + `acc_code` int(11) NOT NULL DEFAULT 0, + `period` varchar(7) NOT NULL, + `debit` decimal(18,4) NOT NULL DEFAULT 0.0000, + `credit` decimal(18,4) NOT NULL DEFAULT 0.0000, + `count` int(11) NOT NULL DEFAULT 0, + `source_updated_at` datetime DEFAULT NULL, + `updated_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `company_id_acc_code_period` (`company_id`,`acc_code`,`period`), + KEY `company_id` (`company_id`), + KEY `period` (`period`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; +", 'etl_gl_summary'); + // ── Transactions ────────────────────────────────────────────────────────────── run($pdo, " CREATE TABLE IF NOT EXISTS `td_gl` ( @@ -795,6 +838,7 @@ CREATE TABLE IF NOT EXISTS `td_return` ( `tax` decimal(18,4) NOT NULL DEFAULT 0.0000, `tax_adjustment` decimal(8,2) NOT NULL DEFAULT 0.00, `notes` text NOT NULL DEFAULT '', + `formula_id` int(11) DEFAULT NULL, `log` mediumtext NOT NULL DEFAULT '{}', PRIMARY KEY (`id`), KEY `company_id` (`company_id`), @@ -806,6 +850,12 @@ CREATE TABLE IF NOT EXISTS `td_return` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; ", 'td_return'); +// Column added for ReturnManager::saveFormula() / custom credit-note GL formula +// selection — was missing from the original table (unlike td_invoice/td_receipt/ +// td_payment, which all have formula_id). Without it, ReturnManager::saveReturn() +// fails on every insert. +run($pdo, "ALTER TABLE `td_return` ADD COLUMN `formula_id` int(11) DEFAULT NULL AFTER `notes`", 'td_return.formula_id'); + run($pdo, " CREATE TABLE IF NOT EXISTS `td_return_item` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, @@ -1135,7 +1185,7 @@ CREATE TABLE IF NOT EXISTS `td_payment_item` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; ", 'td_payment_item'); -run_ddl(" +run($pdo, " CREATE TABLE IF NOT EXISTS `document_types` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `company_id` int(11) NOT NULL DEFAULT 0, @@ -1152,7 +1202,7 @@ CREATE TABLE IF NOT EXISTS `document_types` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3; ", 'document_types'); -run_ddl(" +run($pdo, " CREATE TABLE IF NOT EXISTS `document_number_sequences` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `company_id` int(11) NOT NULL DEFAULT 0,