modify classed and comments

This commit is contained in:
Thanakorn S
2026-04-29 14:21:09 +07:00
parent 2061624641
commit f6dc9a3278
15 changed files with 4122 additions and 2638 deletions
+199 -132
View File
@@ -3,11 +3,18 @@
/** /**
* ContactManager * ContactManager
* *
* Encapsulates contact and contact type operations: * Handles all CRUD and soft-delete operations for contacts and contact types.
* - Soft-delete with downstream validation
* *
* Note: Methods that modify data do NOT manage their own DB transactions. * Method order:
* Callers are responsible for wrapping operations in dbTransaction() when atomicity is needed. * Master file basis → get/save/delete for contact types and contacts
* Transaction basis → (none — contacts are master data only)
* Report basis → (none — reporting is handled by ReportManager)
*
* Note: Write methods do NOT manage their own DB transactions.
* Callers must wrap multi-step operations inside dbTransaction().
*
* Security: All SQL uses PDO prepared statements with bound parameters.
* No user input is ever interpolated directly into a query string.
*/ */
class ContactManager { class ContactManager {
@@ -24,8 +31,15 @@ class ContactManager {
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/** /**
* Check if a contact_id is referenced in any active td_stock_* row. * Check whether a contact is referenced by any active stock transaction
* Returns true if blocking stock exists. * across all td_stock_* warehouse tables.
*
* Used as a pre-delete guard to prevent orphaning stock records.
* Scans information_schema to discover all td_stock_* tables dynamically.
* Table names from information_schema are backtick-quoted for safety.
*
* @param int $contact_id The md_contact.id to check.
* @return bool true if at least one stock row references this contact, false if clear.
*/ */
private function hasActiveStock(int $contact_id): bool { private function hasActiveStock(int $contact_id): bool {
@@ -38,6 +52,8 @@ class ContactManager {
$tables = $sth->fetchAll(PDO::FETCH_COLUMN); $tables = $sth->fetchAll(PDO::FETCH_COLUMN);
foreach ($tables as $table) { foreach ($tables as $table) {
// table name comes from information_schema (trusted system table),
// and is backtick-quoted — no user input reaches the table identifier.
$sth = $this->pdo->prepare( $sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM `$table` "SELECT COUNT(*) FROM `$table`
WHERE company_id = :company_id WHERE company_id = :company_id
@@ -56,7 +72,17 @@ class ContactManager {
} }
/** /**
* Build a log entry array for audit context. * Build a standard audit log entry array for append-to-JSON log columns.
*
* Captures the acting user_id, current datetime, session login time,
* and a short action label (e.g. 'delete', 'update').
*
* Usage:
* $log[] = $this->buildLogEntry('delete');
* $params[':log'] = json_encode($log);
*
* @param string $action Short label describing the operation (e.g. 'delete').
* @return array Associative array ready to be appended to a log array.
*/ */
private function buildLogEntry(string $action): array { private function buildLogEntry(string $action): array {
return [ return [
@@ -70,126 +96,15 @@ class ContactManager {
} }
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Contact type // MASTER FILE BASIS — Contact Type
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/** /**
* Soft-delete a contact type. * Return all contact types for the company.
* *
* Blocks if any md_contact references this type. * Used to populate dropdowns and the contact type listing page.
* *
* @throws Exception if contact type not found or has dependent contacts * @return array All rows from md_contact_type for this company.
*/
public function deleteContactType(int $type_id): void {
$sth = $this->pdo->prepare(
"SELECT id, contact_type, `log` FROM md_contact_type
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([
':company_id' => $this->company_id,
':id' => $type_id,
]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new Exception("Contact type not found.");
}
// Block if any contact references this type
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM md_contact
WHERE company_id = :company_id
AND contact_type = :type_id"
);
$sth->execute([
':company_id' => $this->company_id,
':type_id' => $type_id,
]);
if ($sth->fetchColumn() > 0) {
throw new Exception(
"Cannot delete — contact type \"{$row['contact_type']}\" " .
"still has contacts assigned to it."
);
}
// Append delete event to log
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = $this->buildLogEntry('delete');
// Soft-delete
$this->pdo->prepare(
"UPDATE md_contact_type
SET company_id = company_id * -1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $type_id,
':company_id' => $this->company_id,
]);
}
// ─────────────────────────────────────────────────────────────
// Contact
// ─────────────────────────────────────────────────────────────
/**
* Soft-delete a contact.
*
* Blocks if the contact is referenced in any active td_stock_* row.
*
* @throws Exception if contact not found or referenced in active stock
*/
public function deleteContact(int $contact_id): void {
$sth = $this->pdo->prepare(
"SELECT id, contact_name, `log` FROM md_contact
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([
':company_id' => $this->company_id,
':id' => $contact_id,
]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new Exception("Contact not found.");
}
// Block if contact is referenced in any active stock transaction
if ($this->hasActiveStock($contact_id)) {
throw new Exception(
"Cannot delete — \"{$row['contact_name']}\" " .
"is referenced in active stock transactions."
);
}
// Append delete event to log
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = $this->buildLogEntry('delete');
// Soft-delete
$this->pdo->prepare(
"UPDATE md_contact
SET company_id = company_id * -1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $contact_id,
':company_id' => $this->company_id,
]);
}
// ─────────────────────────────────────────────────────────────
// Contact type queries
// ─────────────────────────────────────────────────────────────
/**
* Full contact type list.
*/ */
public function getContactTypeList(): array public function getContactTypeList(): array
{ {
@@ -202,7 +117,12 @@ class ContactManager {
} }
/** /**
* Fetch a single contact type row by ID. * Fetch a single contact type row by its primary key.
*
* Used to pre-fill the edit form on the manage contact type page.
*
* @param int $id The md_contact_type.id to fetch.
* @return array|false Associative row, or false if not found.
*/ */
public function getContactTypeById(int $id): array|false public function getContactTypeById(int $id): array|false
{ {
@@ -215,9 +135,16 @@ class ContactManager {
} }
/** /**
* Insert or update a contact type. * Insert a new contact type or update an existing one.
* Pass $data['id'] > 0 for update, 0 for insert. *
* Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update.
* The $logging array is appended to the row's JSON log column
* (caller constructs this from session/request context).
*
* Must be called inside dbTransaction() by the caller. * Must be called inside dbTransaction() by the caller.
*
* @param array $data Keys: id, contact_type, description, status.
* @param array $logging Audit entry to append to the log column.
*/ */
public function saveContactType(array $data, array $logging): void public function saveContactType(array $data, array $logging): void
{ {
@@ -259,12 +186,78 @@ class ContactManager {
} }
} }
/**
* Soft-delete a contact type by negating its company_id.
*
* Blocks deletion if any md_contact row is still assigned to this type,
* preventing orphaned contacts.
*
* Must be called inside dbTransaction() by the caller.
*
* @param int $type_id The md_contact_type.id to delete.
* @throws Exception If the type is not found or has contacts assigned to it.
*/
public function deleteContactType(int $type_id): void {
$sth = $this->pdo->prepare(
"SELECT id, contact_type, `log` FROM md_contact_type
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([
':company_id' => $this->company_id,
':id' => $type_id,
]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new Exception("Contact type not found.");
}
// Block if any contact references this type
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM md_contact
WHERE company_id = :company_id
AND contact_type = :type_id"
);
$sth->execute([
':company_id' => $this->company_id,
':type_id' => $type_id,
]);
if ($sth->fetchColumn() > 0) {
throw new Exception(
"Cannot delete — contact type \"{$row['contact_type']}\" " .
"still has contacts assigned to it."
);
}
// Append delete event to log
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = $this->buildLogEntry('delete');
// Soft-delete: negate company_id so row is hidden but recoverable
$this->pdo->prepare(
"UPDATE md_contact_type
SET company_id = company_id * -1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $type_id,
':company_id' => $this->company_id,
]);
}
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Contact queries // MASTER FILE BASIS — Contact
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/** /**
* Full contact list. * Return all contacts for the company.
*
* Used to populate the contact listing page and bulk dropdowns.
*
* @return array All rows from md_contact for this company.
*/ */
public function getContactList(): array public function getContactList(): array
{ {
@@ -277,7 +270,12 @@ class ContactManager {
} }
/** /**
* Fetch a single contact row by ID. * Fetch a single contact row by its primary key.
*
* Used to pre-fill the edit form on the manage contact page.
*
* @param int $id The md_contact.id to fetch.
* @return array|false Associative row, or false if not found.
*/ */
public function getContactById(int $id): array|false public function getContactById(int $id): array|false
{ {
@@ -290,7 +288,13 @@ class ContactManager {
} }
/** /**
* Search contacts by name keyword — for autocomplete. * Search contacts by name keyword — for live autocomplete on stock forms.
*
* Returns up to 50 matches. The keyword is safely bound as a LIKE parameter;
* no wildcard escaping is needed here since '%' wrapping is the intended behaviour.
*
* @param string $keyword Partial contact name to match.
* @return array Matching md_contact rows.
*/ */
public function searchContact(string $keyword): array public function searchContact(string $keyword): array
{ {
@@ -307,11 +311,21 @@ class ContactManager {
return $sth->fetchAll(PDO::FETCH_ASSOC); return $sth->fetchAll(PDO::FETCH_ASSOC);
} }
/** /**
* Insert or update a contact. * Insert a new contact or update an existing one.
* Pass $data['id'] > 0 for update, 0 for insert. *
* Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update.
* $contact_image is the resolved filename/path from FileUploader — may be
* the existing image when no new file was uploaded.
* The $logging array is appended to the row's JSON log column.
*
* Must be called inside dbTransaction() by the caller. * Must be called inside dbTransaction() by the caller.
*
* @param array $data Keys: id, contact_name, tax_id, organization, branch,
* contact_type, billing_address, shipping_location,
* shipping_address, remark, status.
* @param array $logging Audit entry to append to the log column.
* @param string $contact_image Stored filename for the contact's profile image.
*/ */
public function saveContact(array $data, array $logging, string $contact_image): void public function saveContact(array $data, array $logging, string $contact_image): void
{ {
@@ -370,4 +384,57 @@ class ContactManager {
)->execute($params); )->execute($params);
} }
} }
/**
* Soft-delete a contact by negating its company_id.
*
* Blocks deletion if the contact is referenced in any active stock
* transaction across all td_stock_* warehouse tables, preventing
* broken foreign key references in transaction history.
*
* Must be called inside dbTransaction() by the caller.
*
* @param int $contact_id The md_contact.id to delete.
* @throws Exception If the contact is not found or has active stock references.
*/
public function deleteContact(int $contact_id): void {
$sth = $this->pdo->prepare(
"SELECT id, contact_name, `log` FROM md_contact
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([
':company_id' => $this->company_id,
':id' => $contact_id,
]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new Exception("Contact not found.");
}
// Block if contact is referenced in any active stock transaction
if ($this->hasActiveStock($contact_id)) {
throw new Exception(
"Cannot delete — \"{$row['contact_name']}\" " .
"is referenced in active stock transactions."
);
}
// Append delete event to log
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = $this->buildLogEntry('delete');
// Soft-delete: negate company_id so row is hidden but recoverable
$this->pdo->prepare(
"UPDATE md_contact
SET company_id = company_id * -1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $contact_id,
':company_id' => $this->company_id,
]);
}
} }
+147 -38
View File
@@ -1,38 +1,84 @@
<?php <?php
/**
* FileUploader
*
* Handles secure file upload, validation, cleanup, and rollback for
* the WMS application. Designed to be used with any form that accepts
* image or PDF attachments (product images, contact photos, etc.).
*
* Typical usage flow:
* 1. Instantiate with the absolute server path to the upload directory.
* 2. Call cleanup() to delete files the user removed in the UI.
* 3. Call upload() to validate and move new files from $_FILES.
* 4. If DB write succeeds, call buildFileString() to persist the final CSV.
* 5. If DB write fails, call rollbackUploads() to delete the newly uploaded files.
*
* Example:
* $uploader = new FileUploader('/var/www/app/uploads/products/');
* $uploader->cleanup($existing_files_csv, $kept_files_csv);
* $errors = $uploader->upload('product_image');
* if (empty($errors)) {
* $file_string = $uploader->buildFileString($kept_files_csv);
* // save $file_string to DB...
* } else {
* $uploader->rollbackUploads();
* }
*
* Security:
* - Validates both file extension and MIME type via finfo (double-check).
* - Sanitises original filename before use; replaces non-alphanumeric characters with '_'.
* - Generates a unique filename with uniqid() to prevent overwrite attacks.
* - Sets uploaded files to 0644 permissions (web-readable, not executable).
* - Rejects any file exceeding the 5MB size limit.
*/
class FileUploader { class FileUploader {
private $target_dir; private $target_dir;
private $allowed_mimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf']; private $allowed_mimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
private $allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf']; private $allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf'];
private $max_size = 5 * 1024 * 1024; // 5MB private $max_size = 5 * 1024 * 1024; // 5MB
private $uploaded_files = []; // newly uploaded filenames private $uploaded_files = []; // filenames of files successfully uploaded in this request
private $deleted_files = []; // files we deleted from disk private $deleted_files = []; // filenames of files deleted from disk in this request
/**
* Initialise the uploader for a specific directory.
*
* Creates the directory if it does not exist (recursive, 0755).
* Throws if the directory cannot be created or is not writable.
*
* @param string $target_dir Absolute path to the upload directory (trailing slash optional).
* @throws Exception If the directory cannot be created or is not writable.
*/
public function __construct($target_dir) { public function __construct($target_dir) {
$this->target_dir = rtrim($target_dir, '/') . '/'; $this->target_dir = rtrim($target_dir, '/') . '/';
$this->ensureDirectory(); $this->ensureDirectory();
} }
private function ensureDirectory() { // ─────────────────────────────────────────────────────────────
if (!is_dir($this->target_dir)) { // Public — cleanup
if (!mkdir($this->target_dir, 0755, true)) { // ─────────────────────────────────────────────────────────────
throw new Exception("Failed to create directory: " . $this->target_dir);
}
}
if (!is_writable($this->target_dir)) {
throw new Exception("Target directory is not writable: " . $this->target_dir);
}
}
/** /**
* Remove files that user deleted in the UI * Delete files that the user removed in the UI.
*
* Compare the original comma-separated file list ($existing_csv) against
* the files the user chose to keep ($keep_csv). Any file in $existing but
* not in $keep is deleted from disk.
*
* Call this BEFORE upload() so that freed space is available and the
* $deleted_files list is populated before any rollback is needed.
*
* @param string $existing_csv Comma-separated filenames previously stored in DB.
* @param string $keep_csv Comma-separated filenames the user kept in the form.
*/ */
public function cleanup($existing_csv, $keep_csv) { public function cleanup($existing_csv, $keep_csv) {
if (empty($existing_csv)) return; if (empty($existing_csv)) return;
$existing = array_filter(array_map('trim', explode(',', $existing_csv))); $existing = array_filter(array_map('trim', explode(',', $existing_csv)));
$keep = !empty($keep_csv) ? array_filter(array_map('trim', explode(',', $keep_csv))) : []; $keep = !empty($keep_csv)
? array_filter(array_map('trim', explode(',', $keep_csv)))
: [];
foreach ($existing as $file) { foreach ($existing as $file) {
if (!in_array($file, $keep)) { if (!in_array($file, $keep)) {
@@ -45,16 +91,32 @@ class FileUploader {
} }
} }
// ─────────────────────────────────────────────────────────────
// Public — upload
// ─────────────────────────────────────────────────────────────
/** /**
* Upload new files from $_FILES * Validate and move uploaded files from $_FILES to the target directory.
* Returns array of errors (empty = success) *
* Handles both single-file and multi-file inputs transparently.
* Each file is validated for:
* - PHP upload error code (UPLOAD_ERR_OK)
* - File size (≤ max_size, default 5MB)
* - File extension (whitelist)
* - MIME type via finfo (double-check against extension spoofing)
*
* Successfully uploaded files are tracked in $this->uploaded_files and
* can be rolled back via rollbackUploads() if the DB write later fails.
*
* @param string $field_name The $_FILES key (HTML input name attribute).
* @return array Array of human-readable error strings. Empty array = all files accepted.
*/ */
public function upload($field_name) { public function upload($field_name) {
if (!isset($_FILES[$field_name])) return []; if (!isset($_FILES[$field_name])) return [];
$errors = []; $errors = [];
// Normalize single file to array structure // Normalise single-file $_FILES entry to array structure
$files = $_FILES[$field_name]; $files = $_FILES[$field_name];
if (!is_array($files['name'])) { if (!is_array($files['name'])) {
$files['name'] = [$files['name']]; $files['name'] = [$files['name']];
@@ -75,16 +137,19 @@ class FileUploader {
$file_size = $files['size'][$key]; $file_size = $files['size'][$key];
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
// Size check
if ($file_size > $this->max_size) { if ($file_size > $this->max_size) {
$errors[] = "{$name}: exceeds " . ($this->max_size / 1024 / 1024) . "MB limit"; $errors[] = "{$name}: exceeds " . ($this->max_size / 1024 / 1024) . "MB limit";
continue; continue;
} }
// Extension whitelist check
if (!in_array($extension, $this->allowed_extensions)) { if (!in_array($extension, $this->allowed_extensions)) {
$errors[] = "{$name}: .{$extension} is not allowed"; $errors[] = "{$name}: .{$extension} is not allowed";
continue; continue;
} }
// MIME type check via finfo (cannot be spoofed by renaming)
$finfo = finfo_open(FILEINFO_MIME_TYPE); $finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $tmp_name); $mime = finfo_file($finfo, $tmp_name);
finfo_close($finfo); finfo_close($finfo);
@@ -94,16 +159,15 @@ class FileUploader {
continue; continue;
} }
// FILE NAME SANITIZATION + UNIQUE ID // Sanitise original filename and generate a unique destination name
$original = pathinfo($name, PATHINFO_FILENAME); $original = pathinfo($name, PATHINFO_FILENAME);
$original = preg_replace('/[^a-zA-Z0-9_-]/', '_', $original); // sanitize $original = preg_replace('/[^a-zA-Z0-9_-]/', '_', $original);
$file_id = $original . "_" . uniqid() . "." . $extension; $file_id = $original . '_' . uniqid() . '.' . $extension;
$destination = $this->target_dir . $file_id; $destination = $this->target_dir . $file_id;
if (move_uploaded_file($tmp_name, $destination)) { if (move_uploaded_file($tmp_name, $destination)) {
$this->uploaded_files[] = $file_id; $this->uploaded_files[] = $file_id;
chmod($destination, 0644); chmod($destination, 0644); // web-readable, not executable
} else { } else {
$errors[] = "{$name}: failed to save"; $errors[] = "{$name}: failed to save";
} }
@@ -112,24 +176,46 @@ class FileUploader {
return $errors; return $errors;
} }
// ─────────────────────────────────────────────────────────────
// Public — result helpers
// ─────────────────────────────────────────────────────────────
/** /**
* Build final CSV string for DB * Build the final comma-separated filename string for DB storage.
*
* Merges the files the user kept ($keep_csv) with any newly uploaded
* files from this request, and returns them as a single CSV string.
* Pass this return value to the DB column that stores file references.
*
* @param string $keep_csv Comma-separated filenames the user kept in the form.
* @return string Final comma-separated file list ready for DB storage.
*/ */
public function buildFileString($keep_csv) { public function buildFileString($keep_csv) {
$keep = !empty($keep_csv) ? array_filter(array_map('trim', explode(',', $keep_csv))) : []; $keep = !empty($keep_csv)
? array_filter(array_map('trim', explode(',', $keep_csv)))
: [];
$final = array_merge($keep, $this->uploaded_files); $final = array_merge($keep, $this->uploaded_files);
return implode(",", array_filter($final)); return implode(",", array_filter($final));
} }
/** /**
* Build final CSV string for DB as single file * Return the list of filenames successfully uploaded in this request.
*
* Useful when only a single image field is expected and you need to
* retrieve the stored filename directly without building a CSV string.
*
* @return array Flat array of uploaded filenames (may be empty).
*/ */
public function getUploadedFiles() { public function getUploadedFiles() {
return $this->uploaded_files; return $this->uploaded_files;
} }
/** /**
* Rollback uploaded files if DB fails * Delete all files uploaded in this request — call if the DB write fails.
*
* Ensures no orphaned files remain on disk when a transaction is rolled back.
* Safe to call multiple times (idempotent if files are already gone).
* After rollback, $this->uploaded_files is cleared.
*/ */
public function rollbackUploads() { public function rollbackUploads() {
foreach ($this->uploaded_files as $file) { foreach ($this->uploaded_files as $file) {
@@ -141,6 +227,35 @@ class FileUploader {
$this->uploaded_files = []; $this->uploaded_files = [];
} }
// ─────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────
/**
* Create the target directory if it does not exist, and verify it is writable.
*
* @throws Exception If mkdir fails or the directory is not writable.
*/
private function ensureDirectory() {
if (!is_dir($this->target_dir)) {
if (!mkdir($this->target_dir, 0755, true)) {
throw new Exception("Failed to create directory: " . $this->target_dir);
}
}
if (!is_writable($this->target_dir)) {
throw new Exception("Target directory is not writable: " . $this->target_dir);
}
}
/**
* Convert a PHP upload error code into a human-readable message.
*
* Covers all UPLOAD_ERR_* constants defined by PHP.
* Falls back to a generic message for unknown codes.
*
* @param int $code A UPLOAD_ERR_* constant value.
* @return string Human-readable error description.
*/
private function getUploadError($code) { private function getUploadError($code) {
$messages = [ $messages = [
UPLOAD_ERR_INI_SIZE => "exceeds server max upload size (" . ini_get('upload_max_filesize') . ")", UPLOAD_ERR_INI_SIZE => "exceeds server max upload size (" . ini_get('upload_max_filesize') . ")",
@@ -153,10 +268,4 @@ class FileUploader {
]; ];
return $messages[$code] ?? "unknown error (code {$code})"; return $messages[$code] ?? "unknown error (code {$code})";
} }
} }
?>
+152 -53
View File
@@ -3,54 +3,84 @@
/** /**
* PasswordManager * PasswordManager
* *
* Handles all password operations using zxcvbn-php for strength enforcement. * Handles all password-related operations using zxcvbn-php for strength enforcement.
* Designed to be reused across: * Designed to be reused across multiple flows:
* - Profile: change password (requires current password verification) * - Profile page: authenticated user changes their own password (requires current password).
* - Login: forced password reset (no current password needed) * - Login forced reset: system-initiated change, no current password needed.
* - Future: forgot password / admin reset flows * - Future: admin reset, forgot password (delegate to PasswordResetManager for OTP).
*
* Method order:
* Core public API → checkStrength, change, forceSet
* HTTP handlers → handleCheck, handleChange (thin wrappers for AJAX endpoints)
* Private helpers → loadZxcvbn, fetchUser, enforceStrength, persist
* *
* Usage: * Usage:
* $pm = new PasswordManager($pdo1, $include_url); * $pm = new PasswordManager($pdo, $include_url);
* *
* // Check strength only (for live UI feedback) * // Live strength check (AJAX feedback while typing):
* $result = $pm->checkStrength('mypassword', ['john', 'john@example.com']); * $result = $pm->checkStrength('mypassword', ['john', 'john@example.com']);
* *
* // Change password (profile page — verifies current password) * // Authenticated user changing their own password:
* $pm->change($user_id, $current_password, $new_password, $confirm_password); * $pm->change($user_id, $current_password, $new_password, $confirm_password);
* *
* // Force set password (admin reset / login forced reset — no current password) * // Forced set (admin reset / login forced reset — skips current password check):
* $pm->forceSet($user_id, $new_password, $confirm_password); * $pm->forceSet($user_id, $new_password, $confirm_password);
*
* Security:
* - Passwords are hashed with PASSWORD_BCRYPT (cost factor PHP default = 10).
* - zxcvbn score ≥ 3 ("safely unguessable") is required before any hash is written.
* - User's own name, username, and email are passed to zxcvbn as penalty inputs.
* - All DB queries use PDO prepared statements with bound parameters.
* - AJAX handler methods output JSON via json_encode (XSS-safe for string values).
*/ */
class PasswordManager { class PasswordManager {
private $pdo; private $pdo;
private $zxcvbn_path; private $zxcvbn_path;
/** Minimum zxcvbn score required (0–4). 3 = "safely unguessable" */ /** Minimum zxcvbn score required (0–4). 3 = "safely unguessable". */
const MIN_SCORE = 3; const MIN_SCORE = 3;
/**
* @param PDO $pdo PDO connection to the wms.user table database.
* @param string $include_url Absolute server path to the app root directory,
* used to locate the zxcvbn-php autoloader.
* Example: '/var/www/html/wms'
*/
public function __construct($pdo, string $include_url) { public function __construct($pdo, string $include_url) {
$this->pdo = $pdo; $this->pdo = $pdo;
$this->zxcvbn_path = rtrim($include_url, '/') . '/assets/zxcvbn-php-master/vendor/autoload.php'; $this->zxcvbn_path = rtrim($include_url, '/') . '/assets/zxcvbn-php-master/vendor/autoload.php';
} }
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Public: strength check (used by live AJAX feedback endpoint) // Core public API
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/** /**
* Run zxcvbn strength analysis. * Run a zxcvbn password strength analysis and return structured feedback.
* *
* @param string $password * Used by the live AJAX strength indicator while the user types.
* @param array $user_inputs Personal data to penalise (name, email, username…) * Pass user-specific strings as $user_inputs so zxcvbn penalises
* @return array ['score' => 0-4, 'warning' => string, 'suggestions' => array] * passwords that contain the user's own name, email, or username.
*
* Score meanings:
* 0 — too guessable (online attack in < 100 guesses)
* 1 — very guessable (online attack in < 1000 guesses)
* 2 — somewhat guessable (offline attack in < 1M guesses)
* 3 — safely unguessable (offline attack in < 100M guesses) ← MIN_SCORE
* 4 — very unguessable (offline attack in > 100M guesses)
*
* @param string $password The password string to analyse.
* @param array $user_inputs Strings to penalise if found in the password
* (e.g. name, email, username).
* @return array Keys: score (int 0–4), warning (string), suggestions (array of strings).
*/ */
public function checkStrength(string $password, array $user_inputs = []): array { public function checkStrength(string $password, array $user_inputs = []): array {
$this->loadZxcvbn(); $this->loadZxcvbn();
$zxcvbn = new \ZxcvbnPhp\Zxcvbn(); $zxcvbn = new \ZxcvbnPhp\Zxcvbn();
$result = $zxcvbn->passwordStrength($password, $user_inputs); $result = $zxcvbn->passwordStrength($password, $user_inputs);
return [ return [
'score' => (int) $result['score'], 'score' => (int) $result['score'],
@@ -59,20 +89,28 @@ class PasswordManager {
]; ];
} }
// ─────────────────────────────────────────────────────────────
// Public: change password (profile — verifies current password)
// ─────────────────────────────────────────────────────────────
/** /**
* Change password for an authenticated user. * Change the password for an authenticated user.
* Verifies current password before applying the new one.
* *
* @throws InvalidArgumentException on validation failure (safe to show user) * Verifies the current password before applying the new one.
* @throws RuntimeException on DB failure (log internally, show generic message) * Use this on the profile settings page where the user is already logged in
* and must prove knowledge of their existing password.
*
* Steps:
* 1. Validates all fields are present and new passwords match.
* 2. Fetches the user record and verifies $current_password via password_verify().
* 3. Runs zxcvbn strength enforcement (throws if score < MIN_SCORE).
* 4. Hashes and persists the new password.
*
* @param int $user_id The wms.user.user_id of the user changing their password.
* @param string $current_password The user's current password for verification.
* @param string $new_password The desired new password.
* @param string $confirm_password Must match $new_password exactly.
* @throws \InvalidArgumentException On validation failure (message is safe to show the user).
* @throws \RuntimeException On DB failure (log internally, show generic message).
*/ */
public function change(int $user_id, string $current_password, string $new_password, string $confirm_password): void { public function change(int $user_id, string $current_password, string $new_password, string $confirm_password): void {
// ── Basic field validation ────────────────────────────────
if (empty($current_password) || empty($new_password) || empty($confirm_password)) { if (empty($current_password) || empty($new_password) || empty($confirm_password)) {
throw new \InvalidArgumentException('All password fields are required.'); throw new \InvalidArgumentException('All password fields are required.');
} }
@@ -81,31 +119,35 @@ class PasswordManager {
throw new \InvalidArgumentException('New passwords do not match.'); throw new \InvalidArgumentException('New passwords do not match.');
} }
// ── Load user record ──────────────────────────────────────
$user = $this->fetchUser($user_id); $user = $this->fetchUser($user_id);
// ── Verify current password ───────────────────────────────
if (!password_verify($current_password, $user['password'])) { if (!password_verify($current_password, $user['password'])) {
throw new \InvalidArgumentException('Current password is incorrect.'); throw new \InvalidArgumentException('Current password is incorrect.');
} }
// ── Strength check ────────────────────────────────────────
$this->enforceStrength($new_password, $user); $this->enforceStrength($new_password, $user);
// ── Hash and persist ──────────────────────────────────────
$this->persist($user_id, $new_password); $this->persist($user_id, $new_password);
} }
// ─────────────────────────────────────────────────────────────
// Public: force set password (admin reset / login forced reset)
// ─────────────────────────────────────────────────────────────
/** /**
* Force-set a new password without requiring the current password. * Force-set a new password without verifying the current one.
* Use for: admin-initiated reset, forgot-password flow, first-login forced change.
* *
* @throws InvalidArgumentException on validation failure * Use for flows where the current password is unavailable or irrelevant:
* @throws RuntimeException on DB failure * - Admin-initiated password reset
* - First-login forced password change
* - Forgot-password flow after OTP verification (via PasswordResetManager)
*
* Steps:
* 1. Validates new password fields are present and match.
* 2. Fetches the user record (needed for zxcvbn personalisation).
* 3. Runs zxcvbn strength enforcement.
* 4. Hashes and persists the new password.
*
* @param int $user_id The wms.user.user_id to reset.
* @param string $new_password The desired new password.
* @param string $confirm_password Must match $new_password exactly.
* @throws \InvalidArgumentException On validation failure.
* @throws \RuntimeException On DB failure.
*/ */
public function forceSet(int $user_id, string $new_password, string $confirm_password): void { public function forceSet(int $user_id, string $new_password, string $confirm_password): void {
@@ -124,14 +166,23 @@ class PasswordManager {
} }
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Public: HTTP handlers (call from thin API endpoint files) // HTTP handlers (thin AJAX endpoint wrappers)
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/** /**
* Handle AJAX strength-check request and echo JSON response. * Handle an AJAX strength-check request and echo a JSON response.
* Endpoint: setting/api/engine/check_password.php
* *
* Expected $data keys: password * Called by: setting/api/engine/check_password.php
*
* Expected $data keys:
* password (string) — the password string to analyse
*
* Response JSON keys:
* success (int 1), score (int -1 if empty, else 0–4), feedback (string)
*
* Outputs JSON and calls exit. Safe against XSS — all output via json_encode.
*
* @param array $data Request data array (typically from $_POST or decoded JSON body).
*/ */
public function handleCheck(array $data): void { public function handleCheck(array $data): void {
@@ -154,10 +205,22 @@ class PasswordManager {
} }
/** /**
* Handle AJAX change-password request and echo JSON response. * Handle an AJAX change-password request and echo a JSON response.
* Endpoint: setting/api/engine/change_password.php
* *
* Expected $data keys: current_password, new_password, confirm_password * Called by: setting/api/engine/change_password.php
*
* Expected $data keys:
* current_password, new_password, confirm_password (all strings)
*
* On success: destroys the session (forces re-login with new password),
* returns { success: 1, message: "..." }
* On InvalidArgumentException (validation): HTTP 400, { success: 0, message: "..." }
* On other Exception (system error): HTTP 500, generic message (error logged server-side)
*
* Outputs JSON and calls exit. Safe against XSS — all output via json_encode.
*
* @param int $user_id The wms.user.user_id of the currently authenticated user.
* @param array $data Request data array containing the password fields.
*/ */
public function handleChange(int $user_id, array $data): void { public function handleChange(int $user_id, array $data): void {
@@ -170,6 +233,9 @@ class PasswordManager {
$data['confirm_password'] ?? '' $data['confirm_password'] ?? ''
); );
// Destroy session: user must re-authenticate with new password.
// The db_auth OTP would invalidate naturally on next request
// (hash changed), but explicit destroy is immediate.
session_destroy(); session_destroy();
echo json_encode(['success' => 1, 'message' => 'Password changed successfully.']); echo json_encode(['success' => 1, 'message' => 'Password changed successfully.']);
@@ -190,6 +256,15 @@ class PasswordManager {
// Private helpers // Private helpers
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/**
* Load the zxcvbn-php autoloader.
*
* Called lazily (only when a strength check is actually needed).
* Throws a RuntimeException if the autoloader file is missing,
* so misconfiguration is caught clearly rather than as a silent failure.
*
* @throws \RuntimeException If the autoloader file is not found.
*/
private function loadZxcvbn(): void { private function loadZxcvbn(): void {
if (!file_exists($this->zxcvbn_path)) { if (!file_exists($this->zxcvbn_path)) {
throw new \RuntimeException('zxcvbn autoloader not found at: ' . $this->zxcvbn_path); throw new \RuntimeException('zxcvbn autoloader not found at: ' . $this->zxcvbn_path);
@@ -197,6 +272,16 @@ class PasswordManager {
require_once $this->zxcvbn_path; require_once $this->zxcvbn_path;
} }
/**
* Fetch a user record by user_id from the wms.user table.
*
* Returns the full user row including the hashed password (needed for
* password_verify) and personal fields (needed for zxcvbn penalisation).
*
* @param int $user_id The wms.user.user_id to fetch.
* @return array Associative user row: user_id, username, name, surname, email, password.
* @throws \RuntimeException If no user is found for the given ID.
*/
private function fetchUser(int $user_id): array { private function fetchUser(int $user_id): array {
$sth = $this->pdo->prepare( $sth = $this->pdo->prepare(
'SELECT user_id, username, name, surname, email, password 'SELECT user_id, username, name, surname, email, password
@@ -213,8 +298,16 @@ class PasswordManager {
} }
/** /**
* Run zxcvbn and throw if score is below MIN_SCORE. * Run zxcvbn strength check and throw if the score is below MIN_SCORE.
* Passes personal fields so zxcvbn penalises name/email use. *
* Passes user personal fields (name, surname, username, email) to zxcvbn
* so that passwords containing the user's own details are penalised.
* The first actionable feedback string from zxcvbn is included in the
* exception message shown to the user.
*
* @param string $password The new password to evaluate.
* @param array $user User row from fetchUser() — provides personalisation data.
* @throws \InvalidArgumentException If score < MIN_SCORE, with zxcvbn feedback.
*/ */
private function enforceStrength(string $password, array $user): void { private function enforceStrength(string $password, array $user): void {
@@ -234,10 +327,16 @@ class PasswordManager {
} }
/** /**
* Hash and write the new password to the DB. * Hash the password with bcrypt and write it to the wms.user table.
* The OTP session will invalidate automatically on the next *
* request because db_auth.php re-derives the OTP from the * Uses PASSWORD_BCRYPT with PHP's default cost factor.
* stored password hash — changing it forces re-login. * After this write, the OTP-based auth in db_auth.php will invalidate
* on the next request because it re-derives the OTP from the stored hash —
* changing the hash implicitly forces a fresh login.
*
* @param int $user_id The wms.user.user_id to update.
* @param string $password The plain-text new password (will be hashed here).
* @throws \RuntimeException If the UPDATE affected 0 rows (user not found or no change).
*/ */
private function persist(int $user_id, string $password): void { private function persist(int $user_id, string $password): void {
$hashed = password_hash($password, PASSWORD_BCRYPT); $hashed = password_hash($password, PASSWORD_BCRYPT);
+151 -56
View File
@@ -3,21 +3,34 @@
/** /**
* PasswordResetManager * PasswordResetManager
* *
* Handles the full OTP-based password reset flow. * Handles the full OTP-based password reset flow for the WMS application.
* Reusable across: * Reusable across:
* - Profile page: "Forgot your current password?" (authenticated user) * - Profile page: "Forgot your current password?" (authenticated user)
* - Login page: "Forgot password?" (unauthenticated user) * - Login page: "Forgot password?" (unauthenticated user — user_id resolved by caller)
* *
* Flow: * Reset flow:
* 1. requestOtp($user_id) — generate OTP, email it, store in session * 1. requestOtp($user_id, $company_id) — generate TOTP, email it, store in session.
* 2. confirmReset($user_id, ...) — verify OTP, call PasswordManager::forceSet() * 2. confirmReset($user_id, $otp, ...) — verify OTP, call PasswordManager::forceSet().
* *
* HTTP handler methods for thin endpoint wrappers: * The OTP is a 6-digit TOTP derived from the user's current password hash via HMAC-SHA1,
* handleRequestOtp($user_id) * scoped to a 3-minute time step. It cannot be replayed after the window expires.
* A human-readable reference number (6 uppercase letters) is also generated and emailed
* so the user can confirm they received the correct OTP request.
*
* HTTP handler methods for thin AJAX endpoint wrappers:
* handleRequestOtp($user_id, $company_id)
* handleConfirmReset($user_id, $data) * handleConfirmReset($user_id, $data)
* *
* Session keys used (prefixed to avoid collision with login OTP): * Session keys used (prefixed with 'reset_' to avoid collision with login OTP):
* reset_otp, reset_otp_time, reset_reference, reset_user_id * reset_otp, reset_otp_time, reset_reference, reset_user_id
*
* Security:
* - OTP is HMAC-derived from the current password hash — it changes when the password changes.
* - OTP is valid for OTP_EXPIRY_MINUTES (5) only; older OTPs are rejected with clearSession().
* - reset_user_id in session is verified against $user_id to prevent cross-user OTP reuse.
* - Session is fully destroyed on successful reset, forcing re-authentication.
* - All DB queries use PDO prepared statements with bound parameters.
* - AJAX handler methods output JSON via json_encode (XSS-safe).
*/ */
class PasswordResetManager { class PasswordResetManager {
@@ -27,15 +40,17 @@ class PasswordResetManager {
private $SMTP; private $SMTP;
private $pinkey; private $pinkey;
/** OTP validity window in minutes — matches login OTP */ /** OTP validity window in minutes — matches the login OTP window. */
const OTP_EXPIRY_MINUTES = 5; const OTP_EXPIRY_MINUTES = 5;
/** /**
* @param PDO $pdo1 Main DB (user table) * @param PDO $pdo1 PDO connection to the wms database (user table).
* @param PDO $pdo2 Company DB (smtp_setting table) * @param PDO $pdo2 PDO connection to the company database (smtp_setting table).
* @param string $include_url Absolute server path to app root (for requires) * @param string $include_url Absolute server path to the app root (for require_once paths).
* @param array $SMTP System default SMTP config from config.php * Example: '/var/www/html/wms'
* @param string $pinkey Encryption key from config.php * @param array $SMTP System default SMTP configuration array from config.php.
* Used as fallback when the company has no custom SMTP.
* @param string $pinkey Encryption key from config.php, passed to the mailer.
*/ */
public function __construct($pdo1, $pdo2, string $include_url, array $SMTP, string $pinkey) { public function __construct($pdo1, $pdo2, string $include_url, array $SMTP, string $pinkey) {
$this->pdo1 = $pdo1; $this->pdo1 = $pdo1;
@@ -46,22 +61,29 @@ class PasswordResetManager {
} }
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Public: request OTP // Core public API
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/** /**
* Generate OTP, send it to the user's registered email, store in session. * Generate a TOTP, send it to the user's registered email, and store it in session.
* *
* @param int $user_id From session (profile) or looked-up by email/username (login) * The OTP is derived from the user's current password hash so it is unique per user
* @param int $company_id For SMTP fallback lookup * and automatically invalidated if the password is changed by any other means.
* @return array ['masked_email' => string, 'reference' => string] * A reference number (6 uppercase letters) is included in the email so the user
* can verify the request is legitimate.
* *
* @throws \RuntimeException if user not found or email missing * Call this from the "request OTP" endpoint. The user_id must be resolved by the
* @throws \Exception if mailer fails (mailer calls exit internally) * caller (from $_SESSION for authenticated flows, or from a username/email lookup
* for unauthenticated login-page flows).
*
* @param int $user_id The wms.user.user_id whose password is being reset.
* @param int $company_id Used to look up company SMTP settings (0 = use system default).
* @return array Keys: masked_email (string), reference (string 6-letter code).
* @throws \RuntimeException If user not found, email missing, or mailer fails.
*/ */
public function requestOtp(int $user_id, int $company_id = 0): array { public function requestOtp(int $user_id, int $company_id = 0): array {
// ── Fetch user ──────────────────────────────────────────── // Fetch user — need email (to send to) and password hash (for OTP derivation)
$sth = $this->pdo1->prepare( $sth = $this->pdo1->prepare(
'SELECT user_id, email, password FROM user WHERE user_id = :id LIMIT 1' 'SELECT user_id, email, password FROM user WHERE user_id = :id LIMIT 1'
); );
@@ -72,12 +94,12 @@ class PasswordResetManager {
throw new \RuntimeException('No email address found for this account.'); throw new \RuntimeException('No email address found for this account.');
} }
// ── Generate OTP ────────────────────────────────────────── // Generate 6-digit TOTP and a human-readable 6-letter reference number
$otp_time = time(); $otp_time = time();
$otp = $this->generateOTP($user['password'], $otp_time); $otp = $this->generateOTP($user['password'], $otp_time);
$reference_number = $this->numberToLetters((int) $this->generateOTP($otp, $otp_time)); $reference_number = $this->numberToLetters((int) $this->generateOTP($otp, $otp_time));
// ── Send email ──────────────────────────────────────────── // Send via the mailer module (uses company SMTP or falls back to system default)
require_once $this->include_url . '/assets/utils/module/mailer.php'; require_once $this->include_url . '/assets/utils/module/mailer.php';
$mailer = new mailer(['pdo1' => $this->pdo1, 'pdo2' => $this->pdo2]); $mailer = new mailer(['pdo1' => $this->pdo1, 'pdo2' => $this->pdo2]);
@@ -97,81 +119,94 @@ class PasswordResetManager {
'key' => $this->pinkey, 'key' => $this->pinkey,
]); ]);
// ── Store in session ────────────────────────────────────── // Persist in session so confirmReset() can verify against it
$_SESSION['reset_otp'] = $otp; $_SESSION['reset_otp'] = $otp;
$_SESSION['reset_otp_time'] = $otp_time; $_SESSION['reset_otp_time'] = $otp_time;
$_SESSION['reset_reference'] = $reference_number; $_SESSION['reset_reference'] = $reference_number;
$_SESSION['reset_user_id'] = $user_id; $_SESSION['reset_user_id'] = $user_id;
// ── Return masked email for UI display ────────────────────
return [ return [
'masked_email' => $this->maskEmail($user['email']), 'masked_email' => $this->maskEmail($user['email']),
'reference' => $reference_number, 'reference' => $reference_number,
]; ];
} }
// ─────────────────────────────────────────────────────────────
// Public: confirm reset
// ─────────────────────────────────────────────────────────────
/** /**
* Verify OTP then force-set new password via PasswordManager. * Verify the OTP and force-set a new password via PasswordManager.
* *
* @param int $user_id * Validates:
* @param string $otp_input * - Active session with a stored OTP and timestamp.
* @param string $new_password * - Session reset_user_id matches the $user_id being reset (prevents cross-user reuse).
* @param string $confirm_password * - OTP is within the OTP_EXPIRY_MINUTES window.
* - Submitted OTP matches the stored value exactly.
* *
* @throws \InvalidArgumentException OTP invalid/expired, passwords weak/mismatch * On success:
* @throws \RuntimeException DB or session state error * - Delegates to PasswordManager::forceSet() for strength enforcement and hashing.
* - Clears reset session keys and destroys the full session (forces re-login).
*
* @param int $user_id The wms.user.user_id being reset.
* @param string $otp_input The OTP submitted by the user.
* @param string $new_password The desired new password.
* @param string $confirm_password Must match $new_password exactly.
* @throws \InvalidArgumentException OTP missing, expired, incorrect, or passwords invalid.
* @throws \RuntimeException Session integrity error or DB failure.
*/ */
public function confirmReset(int $user_id, string $otp_input, string $new_password, string $confirm_password): void { public function confirmReset(int $user_id, string $otp_input, string $new_password, string $confirm_password): void {
// ── Validate session state ──────────────────────────────── // Validate reset session exists
if (empty($_SESSION['reset_otp']) || empty($_SESSION['reset_otp_time'])) { if (empty($_SESSION['reset_otp']) || empty($_SESSION['reset_otp_time'])) {
throw new \InvalidArgumentException('No active reset request. Please request a new OTP.'); throw new \InvalidArgumentException('No active reset request. Please request a new OTP.');
} }
// ── Verify ownership ────────────────────────────────────── // Verify this OTP belongs to the user making the request (prevents session swap attacks)
if ((int)$_SESSION['reset_user_id'] !== $user_id) { if ((int)$_SESSION['reset_user_id'] !== $user_id) {
throw new \RuntimeException('Invalid reset request.'); throw new \RuntimeException('Invalid reset request.');
} }
// ── Check expiry ────────────────────────────────────────── // Check OTP has not expired
$elapsed_minutes = (time() - (int)$_SESSION['reset_otp_time']) / 60; $elapsed_minutes = (time() - (int)$_SESSION['reset_otp_time']) / 60;
if ($elapsed_minutes > self::OTP_EXPIRY_MINUTES) { if ($elapsed_minutes > self::OTP_EXPIRY_MINUTES) {
$this->clearSession(); $this->clearSession();
throw new \InvalidArgumentException('OTP has expired. Please request a new one.'); throw new \InvalidArgumentException('OTP has expired. Please request a new one.');
} }
// ── Verify OTP value ────────────────────────────────────── // Verify OTP value
if (trim($otp_input) !== $_SESSION['reset_otp']) { if (trim($otp_input) !== $_SESSION['reset_otp']) {
throw new \InvalidArgumentException('Incorrect OTP. Please try again.'); throw new \InvalidArgumentException('Incorrect OTP. Please try again.');
} }
// ── Force-set password via PasswordManager ──────────────── // Delegate to PasswordManager for strength enforcement and persistence
require_once $this->include_url . '/assets/utils/classes/PasswordManager.php'; require_once $this->include_url . '/assets/utils/classes/PasswordManager.php';
$pm = new PasswordManager($this->pdo1, $this->include_url); $pm = new PasswordManager($this->pdo1, $this->include_url);
$pm->forceSet($user_id, $new_password, $confirm_password); $pm->forceSet($user_id, $new_password, $confirm_password);
// ── Clear full session on success ──────────────────────── // Clear reset keys and destroy session — user must log in with new password.
// Destroys the login session so the user must re-authenticate // The OTP in db_auth would invalidate naturally (hash changed), but
// with their new password. The OTP in db_auth would invalidate // explicit destroy is immediate and leaves no dangling session state.
// naturally on next request anyway (hash changed), but clearing
// here is immediate and explicit.
$this->clearSession(); $this->clearSession();
session_destroy(); session_destroy();
} }
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Public: HTTP handlers (thin endpoint wrappers call these) // HTTP handlers (thin AJAX endpoint wrappers)
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/** /**
* Handle AJAX request-OTP call and echo JSON. * Handle an AJAX request-OTP call and echo a JSON response.
* Endpoint: setting/api/engine/request_reset_otp.php *
* login/api/engine/request_reset_otp.php * Called by:
* setting/api/engine/request_reset_otp.php (profile page)
* login/api/engine/request_reset_otp.php (login page)
*
* On success: { success: 1, masked_email: "...", reference: "ABCDEF" }
* On RuntimeException (user/mail error): HTTP 500, { success: 0, message: "..." }
* On other Exception: HTTP 500, generic message (error logged server-side)
*
* Outputs JSON and calls exit. XSS-safe — all output via json_encode.
*
* @param int $user_id The wms.user.user_id to send the OTP to.
* @param int $company_id Company SMTP scope (0 = use system default).
*/ */
public function handleRequestOtp(int $user_id, int $company_id = 0): void { public function handleRequestOtp(int $user_id, int $company_id = 0): void {
@@ -200,11 +235,23 @@ class PasswordResetManager {
} }
/** /**
* Handle AJAX confirm-reset call and echo JSON. * Handle an AJAX confirm-reset call and echo a JSON response.
* Endpoint: setting/api/engine/reset_password_otp.php
* login/api/engine/reset_password_otp.php
* *
* Expected $data keys: otp, new_password, confirm_password * Called by:
* setting/api/engine/reset_password_otp.php (profile page)
* login/api/engine/reset_password_otp.php (login page)
*
* Expected $data keys:
* otp (string), new_password (string), confirm_password (string)
*
* On success: { success: 1, message: "Password reset successfully." }
* On InvalidArgumentException (validation): HTTP 400, { success: 0, message: "..." }
* On other Exception: HTTP 500, generic message (error logged server-side)
*
* Outputs JSON and calls exit. XSS-safe — all output via json_encode.
*
* @param int $user_id The wms.user.user_id being reset.
* @param array $data Request data array with otp, new_password, confirm_password.
*/ */
public function handleConfirmReset(int $user_id, array $data): void { public function handleConfirmReset(int $user_id, array $data): void {
@@ -236,6 +283,24 @@ class PasswordResetManager {
// Private helpers // Private helpers
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/**
* Generate a TOTP-style numeric OTP from a secret key and timestamp.
*
* Implements a simplified TOTP algorithm (RFC 6238 subset):
* - Counter = floor(time / time_step) — changes every $time_step seconds (default 180)
* - HMAC-SHA1 over an 8-byte counter using $secret_key
* - Dynamic truncation to extract a 31-bit value
* - Modulo 10^$length to produce a $length-digit OTP (default 6)
*
* The OTP is derived from $secret_key, so using the user's password hash
* means the OTP is unique per user and automatically invalidated on password change.
*
* @param string $secret_key HMAC key — use user's password hash for per-user uniqueness.
* @param int $otp_time Unix timestamp at which the OTP was generated (store in session).
* @param int $time_step Window size in seconds (default 180 = 3 minutes per step).
* @param int $length Number of digits in the OTP (default 6).
* @return string Zero-padded OTP string of $length digits.
*/
private function generateOTP(string $secret_key, int $otp_time, int $time_step = 180, int $length = 6): string { private function generateOTP(string $secret_key, int $otp_time, int $time_step = 180, int $length = 6): string {
$counter = floor($otp_time / $time_step); $counter = floor($otp_time / $time_step);
$data = pack('NN', 0, $counter); $data = pack('NN', 0, $counter);
@@ -246,6 +311,16 @@ class PasswordResetManager {
return str_pad(strval($otp), $length, '0', STR_PAD_LEFT); return str_pad(strval($otp), $length, '0', STR_PAD_LEFT);
} }
/**
* Convert a positive integer into a base-26 uppercase letter string.
*
* Used to turn the numeric reference OTP into a human-friendly 6-letter
* reference code (e.g. 123456 → "BCDFHJ") for inclusion in the reset email.
* The result is left-padded with 'A' to always return a 6-character string.
*
* @param int $num Positive integer to convert.
* @return string 6-character uppercase string (e.g. "AAAABC").
*/
private function numberToLetters(int $num): string { private function numberToLetters(int $num): string {
$result = ''; $result = '';
while ($num > 0) { while ($num > 0) {
@@ -256,13 +331,33 @@ class PasswordResetManager {
return str_pad($result, 6, 'A', STR_PAD_LEFT); return str_pad($result, 6, 'A', STR_PAD_LEFT);
} }
/**
* Mask an email address for display in the UI.
*
* Shows the first 2 characters of the local part, replaces the remainder
* with asterisks, and keeps the full domain. The masked email is returned
* to the frontend as confirmation that the OTP was sent to the right address
* without revealing it in full.
*
* Example: "john.doe@example.com" → "jo******@example.com"
*
* @param string $email The full email address to mask.
* @return string Masked email address.
*/
private function maskEmail(string $email): string { private function maskEmail(string $email): string {
$at = strpos($email, '@'); $at = strpos($email, '@');
return substr($email, 0, 2) return substr($email, 0, 2)
. str_repeat('*', max(1, $at - 2)) . str_repeat('*', max(1, $at - 2))
. substr($email, $at); . substr($email, $at);
} }
/**
* Remove reset-related keys from the current session.
*
* Called on OTP expiry (to invalidate the request) and on successful
* reset (before session_destroy). Does not destroy the full session —
* only the 4 reset-specific keys are unset.
*/
private function clearSession(): void { private function clearSession(): void {
unset( unset(
$_SESSION['reset_otp'], $_SESSION['reset_otp'],
+263 -185
View File
@@ -3,11 +3,18 @@
/** /**
* ProductManager * ProductManager
* *
* Encapsulates product and product category operations: * Handles all CRUD and soft-delete operations for products and product categories.
* - Soft-delete with downstream validation
* *
* Note: Methods that modify data do NOT manage their own DB transactions. * Method order:
* Callers are responsible for wrapping operations in dbTransaction() when atomicity is needed. * Master file basis → get/save/delete for product categories and products
* Transaction basis → (none — products are master data only)
* Report basis → getRackOccupancy (cross-warehouse physical inventory view)
*
* Note: Write methods do NOT manage their own DB transactions.
* Callers must wrap multi-step operations inside dbTransaction().
*
* Security: All SQL uses PDO prepared statements with bound parameters.
* No user input is ever interpolated directly into a query string.
*/ */
class ProductManager { class ProductManager {
@@ -24,8 +31,16 @@ class ProductManager {
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/** /**
* Check if a SKU has any active stock across all td_stock_* tables. * Scan all td_stock_* warehouse tables for any active stock row
* Returns the first blocking table name found, or null if clear. * that references the given SKU.
*
* Used as a pre-delete guard on products: a product cannot be removed
* while stock exists for it in any warehouse.
* Table names come from information_schema (trusted system table)
* and are backtick-quoted — no user input reaches the identifier.
*
* @param string $sku The product SKU to search for.
* @return string|null The first blocking table name found, or null if clear.
*/ */
private function findActiveStock(string $sku): ?string { private function findActiveStock(string $sku): ?string {
@@ -56,7 +71,17 @@ class ProductManager {
} }
/** /**
* Build a log entry array for audit context. * Build a standard audit log entry array for append-to-JSON log columns.
*
* Captures the acting user_id, current datetime, session login time,
* and a short action label (e.g. 'delete', 'update').
*
* Usage:
* $log[] = $this->buildLogEntry('delete');
* $params[':log'] = json_encode($log);
*
* @param string $action Short label describing the operation (e.g. 'delete').
* @return array Associative array ready to be appended to a log array.
*/ */
private function buildLogEntry(string $action): array { private function buildLogEntry(string $action): array {
return [ return [
@@ -70,180 +95,17 @@ class ProductManager {
} }
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Product category // MASTER FILE BASIS — Product Category
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/** /**
* Soft-delete a product category. * Return all product categories with their product count.
* *
* Blocks if any md_product references this category. * Used to populate the category listing page and category dropdowns.
* The LEFT JOIN count lets the UI show how many products are in each category.
* *
* @throws Exception if category not found or has dependent products * @return array All md_product_category rows for this company,
*/ * each augmented with a 'product_count' field.
public function deleteCategory(int $category_id): void {
$sth = $this->pdo->prepare(
"SELECT id, category, `log` FROM md_product_category
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([
':company_id' => $this->company_id,
':id' => $category_id,
]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new Exception("Product category not found.");
}
// Block if any product references this category
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM md_product
WHERE company_id = :company_id
AND category = :category_id"
);
$sth->execute([
':company_id' => $this->company_id,
':category_id' => $category_id,
]);
if ($sth->fetchColumn() > 0) {
throw new Exception(
"Cannot delete — category \"{$row['category']}\" " .
"still has products assigned to it."
);
}
// Append delete event to log
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = $this->buildLogEntry('delete');
// Soft-delete
$this->pdo->prepare(
"UPDATE md_product_category
SET company_id = company_id * -1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $category_id,
':company_id' => $this->company_id,
]);
}
// ─────────────────────────────────────────────────────────────
// Product
// ─────────────────────────────────────────────────────────────
/**
* Soft-delete a product.
*
* Blocks if the product SKU has any active stock across any td_stock_* table.
*
* @throws Exception if product not found or has active stock
*/
public function deleteProduct(int $product_id): void {
$sth = $this->pdo->prepare(
"SELECT id, product_name, sku, `log` FROM md_product
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([
':company_id' => $this->company_id,
':id' => $product_id,
]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new Exception("Product not found.");
}
// Block if active stock exists for this SKU in any warehouse
$blocking_table = $this->findActiveStock($row['sku']);
if ($blocking_table !== null) {
throw new Exception(
"Cannot delete — \"{$row['product_name']}\" " .
"still has active stock in the system."
);
}
// Append delete event to log
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = $this->buildLogEntry('delete');
// Soft-delete
$this->pdo->prepare(
"UPDATE md_product
SET company_id = company_id * -1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $product_id,
':company_id' => $this->company_id,
]);
}
// ─────────────────────────────────────────────────────────────
// Product queries
// ─────────────────────────────────────────────────────────────
/**
* Full product list with current warehouse balance.
*/
public function getProductList(): array
{
$sth = $this->pdo->prepare(
"SELECT a.*, IFNULL(SUM(b.total_in - b.total_out), 0) AS product_balance
FROM md_product a
LEFT JOIN warehouse_balance b
ON a.company_id = b.company_id
AND a.sku = b.product_sku
WHERE a.company_id = :company_id
GROUP BY a.id"
);
$sth->execute([':company_id' => $this->company_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Fetch a single product row by ID.
*/
public function getProductById(int $id): array|false
{
$sth = $this->pdo->prepare(
"SELECT * FROM md_product
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
return $sth->fetch(PDO::FETCH_ASSOC);
}
/**
* Search products by SKU keyword — for autocomplete.
*/
public function searchProduct(string $keyword): array
{
$sth = $this->pdo->prepare(
"SELECT * FROM md_product
WHERE company_id = :company_id
AND sku LIKE :keyword
LIMIT 50"
);
$sth->execute([
':company_id' => $this->company_id,
':keyword' => '%' . $keyword . '%',
]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
// ─────────────────────────────────────────────────────────────
// Product category queries
// ─────────────────────────────────────────────────────────────
/**
* Full category list with product count per category.
*/ */
public function getCategoryList(): array public function getCategoryList(): array
{ {
@@ -261,7 +123,12 @@ class ProductManager {
} }
/** /**
* Fetch a single category row by ID. * Fetch a single product category row by its primary key.
*
* Used to pre-fill the edit form on the manage category page.
*
* @param int $id The md_product_category.id to fetch.
* @return array|false Associative row, or false if not found.
*/ */
public function getCategoryById(int $id): array|false public function getCategoryById(int $id): array|false
{ {
@@ -274,11 +141,16 @@ class ProductManager {
} }
/** /**
* Insert or update a product category. * Insert a new product category or update an existing one.
* Pass $data['id'] > 0 for update, 0 for insert. *
* Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update.
* The $logging array is appended to the row's JSON log column.
*
* Must be called inside dbTransaction() by the caller. * Must be called inside dbTransaction() by the caller.
* *
* @throws Exception on validation failure * @param array $data Keys: id, category, slug, description, status.
* @param array $logging Audit entry to append to the log column.
* @throws Exception On validation failure (e.g. duplicate slug).
*/ */
public function saveCategory(array $data, array $logging): void public function saveCategory(array $data, array $logging): void
{ {
@@ -322,15 +194,153 @@ class ProductManager {
} }
} }
/**
* Soft-delete a product category by negating its company_id.
*
* Blocks deletion if any md_product row is still assigned to this category,
* preventing products from losing their category reference.
*
* Must be called inside dbTransaction() by the caller.
*
* @param int $category_id The md_product_category.id to delete.
* @throws Exception If the category is not found or has products assigned to it.
*/
public function deleteCategory(int $category_id): void {
$sth = $this->pdo->prepare(
"SELECT id, category, `log` FROM md_product_category
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([
':company_id' => $this->company_id,
':id' => $category_id,
]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new Exception("Product category not found.");
}
// Block if any product references this category
$sth = $this->pdo->prepare(
"SELECT COUNT(*) FROM md_product
WHERE company_id = :company_id
AND category = :category_id"
);
$sth->execute([
':company_id' => $this->company_id,
':category_id' => $category_id,
]);
if ($sth->fetchColumn() > 0) {
throw new Exception(
"Cannot delete — category \"{$row['category']}\" " .
"still has products assigned to it."
);
}
// Append delete event to log
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = $this->buildLogEntry('delete');
// Soft-delete: negate company_id so row is hidden but recoverable
$this->pdo->prepare(
"UPDATE md_product_category
SET company_id = company_id * -1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $category_id,
':company_id' => $this->company_id,
]);
}
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Product write // MASTER FILE BASIS — Product
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/** /**
* Insert or update a product. * Return all products with their current aggregate warehouse balance.
* Pass $data['id'] > 0 for update, 0 for insert. *
* The balance is the sum of (total_in - total_out) across all warehouses
* from the warehouse_balance table. Products with no balance rows show 0.
*
* Used to populate the product listing page.
*
* @return array All md_product rows for this company, each with a 'product_balance' field.
*/
public function getProductList(): array
{
$sth = $this->pdo->prepare(
"SELECT a.*, IFNULL(SUM(b.total_in - b.total_out), 0) AS product_balance
FROM md_product a
LEFT JOIN warehouse_balance b
ON a.company_id = b.company_id
AND a.sku = b.product_sku
WHERE a.company_id = :company_id
GROUP BY a.id"
);
$sth->execute([':company_id' => $this->company_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Fetch a single product row by its primary key.
*
* Used to pre-fill the edit form on the manage product page.
*
* @param int $id The md_product.id to fetch.
* @return array|false Associative row, or false if not found.
*/
public function getProductById(int $id): array|false
{
$sth = $this->pdo->prepare(
"SELECT * FROM md_product
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([':company_id' => $this->company_id, ':id' => $id]);
return $sth->fetch(PDO::FETCH_ASSOC);
}
/**
* Search products by SKU keyword — for live autocomplete on stock forms.
*
* Returns up to 50 matches. The keyword is safely bound as a LIKE parameter;
* no wildcard escaping is needed here since '%' wrapping is the intended behaviour.
*
* @param string $keyword Partial SKU to match.
* @return array Matching md_product rows.
*/
public function searchProduct(string $keyword): array
{
$sth = $this->pdo->prepare(
"SELECT * FROM md_product
WHERE company_id = :company_id
AND sku LIKE :keyword
LIMIT 50"
);
$sth->execute([
':company_id' => $this->company_id,
':keyword' => '%' . $keyword . '%',
]);
return $sth->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Insert a new product or update an existing one.
*
* Pass $data['id'] = 0 to insert; pass $data['id'] > 0 to update.
* $product_image is the resolved filename/path from FileUploader — may be
* the existing image when no new file was uploaded.
* The $logging array is appended to the row's JSON log column.
*
* Must be called inside dbTransaction() by the caller. * Must be called inside dbTransaction() by the caller.
*
* @param array $data Keys: id, product_name, sku, price, min_stock,
* reorder_point, category, description, status.
* @param array $logging Audit entry to append to the log column.
* @param string $product_image Stored filename for the product image.
*/ */
public function saveProduct(array $data, array $logging, string $product_image): void public function saveProduct(array $data, array $logging, string $product_image): void
{ {
@@ -386,9 +396,77 @@ class ProductManager {
} }
} }
/**
* Soft-delete a product by negating its company_id.
*
* Blocks deletion if the product SKU has any active stock across any
* td_stock_* warehouse table. This prevents the product master record
* from disappearing while physical inventory still exists for it.
*
* Must be called inside dbTransaction() by the caller.
*
* @param int $product_id The md_product.id to delete.
* @throws Exception If the product is not found or has active stock.
*/
public function deleteProduct(int $product_id): void {
$sth = $this->pdo->prepare(
"SELECT id, product_name, sku, `log` FROM md_product
WHERE company_id = :company_id AND id = :id"
);
$sth->execute([
':company_id' => $this->company_id,
':id' => $product_id,
]);
$row = $sth->fetch(PDO::FETCH_ASSOC);
if (!$row) {
throw new Exception("Product not found.");
}
// Block if active stock exists for this SKU in any warehouse
$blocking_table = $this->findActiveStock($row['sku']);
if ($blocking_table !== null) {
throw new Exception(
"Cannot delete — \"{$row['product_name']}\" " .
"still has active stock in the system."
);
}
// Append delete event to log
$log = json_decode($row['log'] ?? '[]', true) ?: [];
$log[] = $this->buildLogEntry('delete');
// Soft-delete: negate company_id so row is hidden but recoverable
$this->pdo->prepare(
"UPDATE md_product
SET company_id = company_id * -1,
`log` = :log
WHERE id = :id AND company_id = :company_id"
)->execute([
':log' => json_encode($log),
':id' => $product_id,
':company_id' => $this->company_id,
]);
}
// ─────────────────────────────────────────────────────────────
// REPORT BASIS
// ─────────────────────────────────────────────────────────────
/** /**
* Full rack list with warehouse name, product info and occupancy status. * Return all rack slots across all warehouses with occupancy status,
* warehouse name, and product name.
*
* Used by the rack occupancy dashboard to visualise which racks are
* empty vs. occupied, and which product is in each slot.
* Results are ordered by warehouse → zone → aisle → rack, with
* numeric-first sorting via CAST so e.g. "2" sorts before "10".
*
* Security fix: was previously using $this->companyId (undefined property),
* corrected to $this->company_id.
*
* @return array All md_rack rows joined to warehouse and product, with 'status' field.
*/ */
public function getRackOccupancy(): array public function getRackOccupancy(): array
{ {
@@ -416,7 +494,7 @@ class ProductManager {
CAST(r.aisle AS UNSIGNED), r.aisle, CAST(r.aisle AS UNSIGNED), r.aisle,
CAST(r.rack AS UNSIGNED), r.rack" CAST(r.rack AS UNSIGNED), r.rack"
); );
$sth->execute([':company_id' => $this->companyId]); $sth->execute([':company_id' => $this->company_id]);
return $sth->fetchAll(PDO::FETCH_ASSOC); return $sth->fetchAll(PDO::FETCH_ASSOC);
} }
} }
+450 -111
View File
@@ -1,5 +1,33 @@
<?php <?php
/**
* ReportManager
*
* Provides all read-only reporting and dashboard data aggregation.
* Contains no write operations — purely for data retrieval and summarisation.
*
* Method order (all report basis):
* Master data summaries → category, product, warehouse, contact counts
* Stock summaries → balance, low stock, critical/warning counts
* Dashboard reports → stats, movement charts, most moved, recent activity
* Warehouse detail → capacity, space used, balance, movement, trend, activity
* Expiry reports → expired / near-expiry stock
* Lot / Rack reports → lot stock log, product lots, rack log, rack occupancy
* Balance summary → getWarehouseBalanceSummary
*
* Security: All SQL uses PDO prepared statements with bound parameters.
* Dynamic table names (td_stock_<warehouse>) are derived from DB-sourced
* warehouse names sanitised with preg_replace('/[^a-zA-Z0-9_]/', '', ...)
* before interpolation. Integer parameters (warehouse_id, company_id, limit)
* are explicitly cast to (int) before use in any SQL string fragment.
*
* Security fixes applied vs. previous version:
* - getRecentActivity: warehouse_name in SQL now uses $safe (was unescaped)
* - getExpiredStock: warehouse_id cast to (int) before SQL fragment injection;
* warehouse_name in UNION now uses $safe (was unescaped)
* - getRackLog: DB name from SELECT DATABASE() bound via PDO (was raw interpolation)
* - getRackOccupancy: (already safe — no dynamic identifiers)
*/
class ReportManager class ReportManager
{ {
private PDO $pdo; private PDO $pdo;
@@ -11,9 +39,18 @@ class ReportManager
$this->companyId = $companyId; $this->companyId = $companyId;
} }
// ─────────────────────────────────────────────────────────────
// Private helpers
// ─────────────────────────────────────────────────────────────
/** /**
* Resolve the td_stock_<wh> table name for a given warehouse_id. * Resolve the td_stock_<wh> table name for a warehouse, requiring active status.
* Returns null if warehouse not found. *
* Returns null if the warehouse is not found or inactive.
* The warehouse_name is sanitised before use as a table suffix.
*
* @param int $warehouse_id The md_warehouse.id to resolve.
* @return string|null Sanitised table name, or null if not active/found.
*/ */
private function resolveWarehouseTable(int $warehouse_id): ?string private function resolveWarehouseTable(int $warehouse_id): ?string
{ {
@@ -28,7 +65,15 @@ class ReportManager
return "td_stock_{$safe}"; return "td_stock_{$safe}";
} }
// These helper methods abstract away the common pattern of preparing, executing, and fetching results from the database. /**
* Execute a simple SELECT COUNT(*) or scalar query and return one value.
*
* Used by the stat-count methods that only need a single integer result.
* The SQL must contain a :company_id placeholder.
*
* @param string $sql Prepared SQL with :company_id placeholder.
* @return mixed The first column of the first result row.
*/
private function fetchScalar(string $sql) private function fetchScalar(string $sql)
{ {
$sth = $this->pdo->prepare($sql); $sth = $this->pdo->prepare($sql);
@@ -36,6 +81,15 @@ class ReportManager
return $sth->fetchColumn(); return $sth->fetchColumn();
} }
/**
* Execute a SELECT query and return all rows as an associative array.
*
* Used by list-style reporting methods that need multiple rows.
* The SQL must contain a :company_id placeholder.
*
* @param string $sql Prepared SQL with :company_id placeholder.
* @return array All result rows as PDO::FETCH_ASSOC arrays.
*/
private function fetchAll(string $sql): array private function fetchAll(string $sql): array
{ {
$sth = $this->pdo->prepare($sql); $sth = $this->pdo->prepare($sql);
@@ -43,8 +97,13 @@ class ReportManager
return $sth->fetchAll(PDO::FETCH_ASSOC); return $sth->fetchAll(PDO::FETCH_ASSOC);
} }
// ─────────────────────────────────────────────────────────────
// REPORT BASIS — Master data summaries
// ─────────────────────────────────────────────────────────────
/** /**
* Master Data Summary * Total number of product categories (all statuses) for this company.
* Used by the reports_stats dashboard tile.
*/ */
public function getTotalCategory(): int public function getTotalCategory(): int
{ {
@@ -54,6 +113,10 @@ class ReportManager
return (int) $this->fetchScalar($sql); return (int) $this->fetchScalar($sql);
} }
/**
* Number of active (status = 1) product categories for this company.
* Used by the reports_stats dashboard tile.
*/
public function getActiveCategory(): int public function getActiveCategory(): int
{ {
$sql = "SELECT COUNT(*) $sql = "SELECT COUNT(*)
@@ -63,6 +126,10 @@ class ReportManager
return (int) $this->fetchScalar($sql); return (int) $this->fetchScalar($sql);
} }
/**
* Number of inactive (status = 0) product categories for this company.
* Used by the reports_stats dashboard tile.
*/
public function getInactiveCategory(): int public function getInactiveCategory(): int
{ {
$sql = "SELECT COUNT(*) $sql = "SELECT COUNT(*)
@@ -72,6 +139,10 @@ class ReportManager
return (int) $this->fetchScalar($sql); return (int) $this->fetchScalar($sql);
} }
/**
* Number of active warehouses (status = 1) for this company.
* Used by the reports_stats dashboard tile.
*/
public function getTotalWarehouse(): int public function getTotalWarehouse(): int
{ {
$sql = "SELECT COUNT(*) $sql = "SELECT COUNT(*)
@@ -81,6 +152,10 @@ class ReportManager
return (int) $this->fetchScalar($sql); return (int) $this->fetchScalar($sql);
} }
/**
* Number of distinct warehouse locations for this company's active warehouses.
* Used by the reports_stats dashboard tile.
*/
public function getTotalLocation(): int public function getTotalLocation(): int
{ {
$sql = "SELECT COUNT(DISTINCT `location`) $sql = "SELECT COUNT(DISTINCT `location`)
@@ -90,6 +165,10 @@ class ReportManager
return (int) $this->fetchScalar($sql); return (int) $this->fetchScalar($sql);
} }
/**
* Total rack capacity (all racks across all warehouses) for this company.
* Used by the reports_stats dashboard tile.
*/
public function getTotalCapacity(): int public function getTotalCapacity(): int
{ {
$sql = "SELECT COUNT(*) $sql = "SELECT COUNT(*)
@@ -98,6 +177,10 @@ class ReportManager
return (int) $this->fetchScalar($sql); return (int) $this->fetchScalar($sql);
} }
/**
* Number of occupied racks (product_sku IS NOT NULL) for this company.
* Used by the reports_stats dashboard tile.
*/
public function getSpaceUsed(): int public function getSpaceUsed(): int
{ {
$sql = "SELECT COUNT(*) $sql = "SELECT COUNT(*)
@@ -107,6 +190,10 @@ class ReportManager
return (int) $this->fetchScalar($sql); return (int) $this->fetchScalar($sql);
} }
/**
* Total number of products (all statuses) for this company.
* Used by the reports_stats dashboard tile.
*/
public function getTotalProduct(): int public function getTotalProduct(): int
{ {
$sql = "SELECT COUNT(*) $sql = "SELECT COUNT(*)
@@ -115,6 +202,11 @@ class ReportManager
return (int) $this->fetchScalar($sql); return (int) $this->fetchScalar($sql);
} }
/**
* Number of distinct SKUs with a positive running balance across all warehouses.
* "In stock" means (total_in - total_out) > 0 in warehouse_balance.
* Used by the reports_stats dashboard tile.
*/
public function getTotalProductInStock(): int public function getTotalProductInStock(): int
{ {
$sql = "SELECT COUNT(DISTINCT product_sku) $sql = "SELECT COUNT(DISTINCT product_sku)
@@ -124,19 +216,68 @@ class ReportManager
return (int) $this->fetchScalar($sql); return (int) $this->fetchScalar($sql);
} }
public function getLowStockCount(): int /**
* Number of contact types (all statuses) for this company.
* Used by the reports_stats dashboard tile.
*/
public function getTotalContactCategory(): int
{ {
$products = $this->getStockBalance(); $sql = "SELECT COUNT(*)
$count = 0; FROM md_contact_type
foreach ($products as $product) { WHERE company_id = :company_id";
$balance = (float) $product["total_in"] - (float) $product["total_out"]; return (int) $this->fetchScalar($sql);
if ($balance < (float) $product["min_stock"]) {
$count++;
}
}
return $count;
} }
/**
* Number of active (status = 1) contact types for this company.
* Used by the reports_stats dashboard tile.
*/
public function getActiveContactCategory(): int
{
$sql = "SELECT COUNT(*)
FROM md_contact_type
WHERE company_id = :company_id
AND `status` = 1";
return (int) $this->fetchScalar($sql);
}
/**
* Number of inactive (status = 0) contact types for this company.
* Used by the reports_stats dashboard tile.
*/
public function getInactiveContactCategory(): int
{
$sql = "SELECT COUNT(*)
FROM md_contact_type
WHERE company_id = :company_id
AND `status` = 0";
return (int) $this->fetchScalar($sql);
}
/**
* Total number of contacts for this company.
* Used by the reports_stats dashboard tile.
*/
public function getTotalContact(): int
{
$sql = "SELECT COUNT(*)
FROM md_contact
WHERE company_id = :company_id";
return (int) $this->fetchScalar($sql);
}
// ─────────────────────────────────────────────────────────────
// REPORT BASIS — Stock summaries
// ─────────────────────────────────────────────────────────────
/**
* Return aggregated total_in, total_out, and min_stock for every SKU
* across all warehouses from warehouse_balance.
*
* Used internally by getLowStockCount and as a general balance query.
*
* @return array Rows with product_sku, total_in, total_out, min_stock.
*/
public function getStockBalance(): array public function getStockBalance(): array
{ {
$sql = "SELECT $sql = "SELECT
@@ -153,6 +294,34 @@ class ReportManager
return $this->fetchAll($sql); return $this->fetchAll($sql);
} }
/**
* Count how many SKUs are currently below their min_stock threshold.
*
* Computed in PHP from getStockBalance() to avoid complex SQL HAVING with JOIN.
*
* @return int Number of SKUs with balance < min_stock.
*/
public function getLowStockCount(): int
{
$products = $this->getStockBalance();
$count = 0;
foreach ($products as $product) {
$balance = (float) $product["total_in"] - (float) $product["total_out"];
if ($balance < (float) $product["min_stock"]) {
$count++;
}
}
return $count;
}
/**
* Return all SKUs at or below their reorder_point with full product and warehouse details.
*
* Includes a 'status' field: 'critical' if balance <= min_stock, 'warning' otherwise.
* Only products with reorder_point > 0 are included (to skip un-configured products).
*
* @return array Low/critical stock items with warehouse_name, product_name, balance, status.
*/
public function getLowStockItems(): array public function getLowStockItems(): array
{ {
$sql = "SELECT $sql = "SELECT
@@ -200,55 +369,43 @@ class ReportManager
return $items; return $items;
} }
/**
* Count SKUs with status = 'critical' (balance <= min_stock).
* Derived from getLowStockItems().
*
* @return int Number of critical stock items.
*/
public function getCriticalStockCount(): int public function getCriticalStockCount(): int
{ {
$items = $this->getLowStockItems(); $items = $this->getLowStockItems();
return count(array_filter($items, fn($i) => $i['status'] === 'critical')); return count(array_filter($items, fn($i) => $i['status'] === 'critical'));
} }
/**
* Count SKUs with status = 'warning' (reorder_point < balance <= min_stock boundary).
* Derived from getLowStockItems().
*
* @return int Number of warning stock items.
*/
public function getWarningStockCount(): int public function getWarningStockCount(): int
{ {
$items = $this->getLowStockItems(); $items = $this->getLowStockItems();
return count(array_filter($items, fn($i) => $i['status'] === 'warning')); return count(array_filter($items, fn($i) => $i['status'] === 'warning'));
} }
// CONTACT SUMMARY // ─────────────────────────────────────────────────────────────
public function getTotalContactCategory(): int // REPORT BASIS — Dashboard reports
{ // ─────────────────────────────────────────────────────────────
$sql = "SELECT COUNT(*)
FROM md_contact_type
WHERE company_id = :company_id";
return (int) $this->fetchScalar($sql);
}
public function getActiveContactCategory(): int
{
$sql = "SELECT COUNT(*)
FROM md_contact_type
WHERE company_id = :company_id
AND `status` = 1";
return (int) $this->fetchScalar($sql);
}
public function getInactiveContactCategory(): int
{
$sql = "SELECT COUNT(*)
FROM md_contact_type
WHERE company_id = :company_id
AND `status` = 0";
return (int) $this->fetchScalar($sql);
}
public function getTotalContact(): int
{
$sql = "SELECT COUNT(*)
FROM md_contact
WHERE company_id = :company_id";
return (int) $this->fetchScalar($sql);
}
// DASHBOARD REPORTS (cross-warehouse, month-filtered via warehouse_balance)
/**
* Return aggregate stock movement stats for a specific month.
*
* Used by the main dashboard stats widget.
* $month format: 'YYYY-MM' (e.g. '2025-04').
*
* @param string $month Month in YYYY-MM format.
* @return array Keys: total_in, total_out, active_products.
*/
public function getDashboardStats(string $month): array public function getDashboardStats(string $month): array
{ {
$sth = $this->pdo->prepare( $sth = $this->pdo->prepare(
@@ -266,6 +423,14 @@ class ReportManager
]; ];
} }
/**
* Count SKUs currently below their min_stock threshold — for the dashboard alert tile.
*
* Similar to getLowStockCount() but computed directly via SQL aggregate for efficiency,
* without the intermediate getStockBalance() call.
*
* @return int Number of SKUs with balance < min_stock.
*/
public function getDashboardLowStockCount(): int public function getDashboardLowStockCount(): int
{ {
$sth = $this->pdo->prepare( $sth = $this->pdo->prepare(
@@ -288,6 +453,15 @@ class ReportManager
return $count; return $count;
} }
/**
* Return monthly stock_in and stock_out totals for a rolling N-month window.
*
* Produces chart-ready arrays with labels (e.g. "Apr 2025"), stock_in, and stock_out.
* Months with no data return 0. Data is sourced from warehouse_balance (all warehouses).
*
* @param int $months Number of months to include (default 12).
* @return array Keys: labels (array), stock_in (array), stock_out (array).
*/
public function getStockMovementChart(int $months = 12): array public function getStockMovementChart(int $months = 12): array
{ {
$now = new DateTime(); $now = new DateTime();
@@ -328,9 +502,19 @@ class ReportManager
return ['labels' => $labels, 'stock_in' => $stock_in, 'stock_out' => $stock_out]; return ['labels' => $labels, 'stock_in' => $stock_in, 'stock_out' => $stock_out];
} }
/**
* Return the top N most-moved products (by total_out) for a given month.
*
* Used by the dashboard "most moved" widget.
* $limit is cast to (int) before interpolation to prevent SQL injection.
*
* @param string $month Month in YYYY-MM format.
* @param int $limit Maximum number of products to return (default 10).
* @return array Rows with product_sku, total_in, total_out, product_name, category_name.
*/
public function getMostMovedProducts(string $month, int $limit = 10): array public function getMostMovedProducts(string $month, int $limit = 10): array
{ {
$limit = (int) $limit; $limit = (int) $limit; // cast before interpolation
$sth = $this->pdo->prepare( $sth = $this->pdo->prepare(
"SELECT "SELECT
wb.product_sku, wb.product_sku,
@@ -358,6 +542,13 @@ class ReportManager
return $sth->fetchAll(PDO::FETCH_ASSOC); return $sth->fetchAll(PDO::FETCH_ASSOC);
} }
/**
* Return all months for which warehouse_balance data exists, newest first.
*
* Used to populate the month selector on the dashboard and reports pages.
*
* @return array Flat array of YYYY-MM strings.
*/
public function getAvailableMonths(): array public function getAvailableMonths(): array
{ {
$sth = $this->pdo->prepare( $sth = $this->pdo->prepare(
@@ -371,7 +562,17 @@ class ReportManager
} }
/** /**
* Recent stock activity across all warehouses — last N transactions. * Return the most recent N stock transactions across all active warehouses.
*
* Builds a UNION ALL across all td_stock_* tables to produce a unified
* activity feed ordered by date DESC. Used by the dashboard "recent activity" widget.
*
* Security fix: warehouse_name in UNION SQL now uses $safe (sanitised with
* preg_replace) instead of the raw warehouse_name string.
*
* @param int $limit Maximum number of transactions to return (default 10).
* @return array Activity rows with product_name, product_sku, warehouse_name,
* direction ('in'|'out'), qty, type, date.
*/ */
public function getRecentActivity(int $limit = 10): array public function getRecentActivity(int $limit = 10): array
{ {
@@ -384,21 +585,27 @@ class ReportManager
if (empty($warehouses)) return []; if (empty($warehouses)) return [];
// Security: use $safe (sanitised name) for both the table identifier
// and the literal warehouse_name string in the SELECT — never raw $wh['warehouse_name'].
$unions = implode("\nUNION ALL\n", array_map( $unions = implode("\nUNION ALL\n", array_map(
fn($wh) => "SELECT s.date, s.product_sku, s.type, function ($wh) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
$cid = (int) $this->companyId;
return "SELECT s.date, s.product_sku, s.type,
ROUND(COALESCE(s.`in`, 0), 2) AS stock_in, ROUND(COALESCE(s.`in`, 0), 2) AS stock_in,
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out, ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
p.product_name, p.product_name,
'{$wh['warehouse_name']}' AS warehouse_name '{$safe}' AS warehouse_name
FROM `td_stock_{$wh['warehouse_name']}` s FROM `td_stock_{$safe}` s
LEFT JOIN md_product p LEFT JOIN md_product p
ON p.company_id = s.company_id ON p.company_id = s.company_id
AND p.sku = s.product_sku AND p.sku = s.product_sku
WHERE s.company_id = {$this->companyId}", WHERE s.company_id = {$cid}";
},
$warehouses $warehouses
)); ));
$limit = (int) $limit; $limit = (int) $limit; // cast before interpolation
$sth = $this->pdo->query( $sth = $this->pdo->query(
"SELECT * FROM ({$unions}) AS all_stock "SELECT * FROM ({$unions}) AS all_stock
ORDER BY date DESC ORDER BY date DESC
@@ -421,7 +628,16 @@ class ReportManager
return $items; return $items;
} }
// WAREHOUSE OVERVIEW // ─────────────────────────────────────────────────────────────
// REPORT BASIS — Warehouse detail reports
// ─────────────────────────────────────────────────────────────
/**
* Total rack capacity for a specific warehouse (all racks regardless of occupancy).
*
* @param int $warehouse_id The warehouse to query.
* @return int Total rack count.
*/
public function getWarehouseCapacity(int $warehouse_id): int public function getWarehouseCapacity(int $warehouse_id): int
{ {
$sth = $this->pdo->prepare( $sth = $this->pdo->prepare(
@@ -432,6 +648,12 @@ class ReportManager
return (int) $sth->fetchColumn(); return (int) $sth->fetchColumn();
} }
/**
* Number of occupied racks (product_sku IS NOT NULL) for a specific warehouse.
*
* @param int $warehouse_id The warehouse to query.
* @return int Occupied rack count.
*/
public function getWarehouseSpaceUsed(int $warehouse_id): int public function getWarehouseSpaceUsed(int $warehouse_id): int
{ {
$sth = $this->pdo->prepare( $sth = $this->pdo->prepare(
@@ -443,6 +665,12 @@ class ReportManager
return (int) $sth->fetchColumn(); return (int) $sth->fetchColumn();
} }
/**
* Total in and out balance for a specific warehouse from warehouse_balance.
*
* @param int $warehouse_id The warehouse to query.
* @return array Keys: total_in, total_out.
*/
public function getWarehouseBalance(int $warehouse_id): array public function getWarehouseBalance(int $warehouse_id): array
{ {
$sth = $this->pdo->prepare( $sth = $this->pdo->prepare(
@@ -456,6 +684,14 @@ class ReportManager
return $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0]; return $sth->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0];
} }
/**
* Count SKUs below min_stock threshold for a specific warehouse.
*
* Scoped to warehouse_balance rows for this warehouse only.
*
* @param int $warehouse_id The warehouse to query.
* @return int Number of SKUs below min_stock.
*/
public function getWarehouseLowStockCount(int $warehouse_id): int public function getWarehouseLowStockCount(int $warehouse_id): int
{ {
$sth = $this->pdo->prepare( $sth = $this->pdo->prepare(
@@ -479,6 +715,15 @@ class ReportManager
return $count; return $count;
} }
/**
* Return monthly stock_in and stock_out totals for a warehouse over the last 12 months.
*
* Queries the per-warehouse td_stock_<wh> table directly (not warehouse_balance)
* for per-warehouse granularity. Produces chart-ready arrays with month labels.
*
* @param int $warehouse_id The warehouse to query.
* @return array Keys: labels, stock_in, stock_out (each a 12-element array).
*/
public function getWarehouseStockMovement(int $warehouse_id): array public function getWarehouseStockMovement(int $warehouse_id): array
{ {
$table = $this->resolveWarehouseTable($warehouse_id); $table = $this->resolveWarehouseTable($warehouse_id);
@@ -522,6 +767,16 @@ class ReportManager
return ['labels' => $labels, 'stock_in' => $stock_in, 'stock_out' => $stock_out]; return ['labels' => $labels, 'stock_in' => $stock_in, 'stock_out' => $stock_out];
} }
/**
* Return a running cumulative balance trend for a warehouse over the last 12 months.
*
* Starts from the balance before the 12-month window and accumulates
* monthly in/out to produce a month-by-month balance curve.
* Used by the warehouse overview balance trend chart.
*
* @param int $warehouse_id The warehouse to query.
* @return array Keys: labels (12-element), balance (12-element cumulative floats).
*/
public function getWarehouseStockTrend(int $warehouse_id): array public function getWarehouseStockTrend(int $warehouse_id): array
{ {
$table = $this->resolveWarehouseTable($warehouse_id); $table = $this->resolveWarehouseTable($warehouse_id);
@@ -531,6 +786,7 @@ class ReportManager
$start = (clone $now)->modify('-12 months')->format('Y-m-d 00:00:00'); $start = (clone $now)->modify('-12 months')->format('Y-m-d 00:00:00');
$end = $now->format('Y-m-d 23:59:59'); $end = $now->format('Y-m-d 23:59:59');
// Opening balance: everything before the 12-month window
$sth = $this->pdo->prepare( $sth = $this->pdo->prepare(
"SELECT ROUND(COALESCE(SUM(`in`) - SUM(`out`), 0), 2) AS balance "SELECT ROUND(COALESCE(SUM(`in`) - SUM(`out`), 0), 2) AS balance
FROM `{$table}` FROM `{$table}`
@@ -574,6 +830,15 @@ class ReportManager
return ['labels' => $labels, 'balance' => $balance]; return ['labels' => $labels, 'balance' => $balance];
} }
/**
* Return the 10 most recent stock movements for a warehouse.
*
* Used by the warehouse overview recent activity list.
* Includes only rows with in > 0 or out > 0 (excludes zero-quantity rows).
*
* @param int $warehouse_id The warehouse to query.
* @return array Activity rows with product_name, sku, direction, qty, type, description, date.
*/
public function getWarehouseActivity(int $warehouse_id): array public function getWarehouseActivity(int $warehouse_id): array
{ {
$table = $this->resolveWarehouseTable($warehouse_id); $table = $this->resolveWarehouseTable($warehouse_id);
@@ -610,24 +875,59 @@ class ReportManager
return $activities; return $activities;
} }
// EXPIRED / NEAR EXPIRY STOCK // ─────────────────────────────────────────────────────────────
// REPORT BASIS — Expiry reports
// ─────────────────────────────────────────────────────────────
/**
* Return all stock rows with lot numbers that are expired or expiring within 30 days.
*
* Can be scoped to a single warehouse by passing warehouse_id > 0,
* or run across all warehouses with warehouse_id = 0.
*
* Status thresholds:
* days_remaining < 0 → 'expired'
* 0–7 → 'critical'
* 8–14 → 'warning'
* 15–30 → 'caution'
* > 30 → excluded
*
* Security fix: $warehouse_id is now cast to (int) and the WHERE condition
* uses a bound parameter (:warehouse_id) instead of raw string interpolation.
* warehouse_name in UNION now uses $safe variable (sanitised) not raw $wh['warehouse_name'].
*
* @param int $warehouse_id Warehouse filter (0 = all warehouses).
* @return array Expiry-status stock items with product, lot, location, and days_remaining.
*/
public function getExpiredStock(int $warehouse_id = 0): array public function getExpiredStock(int $warehouse_id = 0): array
{ {
$where_wh = $warehouse_id > 0 ? "AND id = {$warehouse_id}" : ''; // Security: cast to int, use bound parameter in WHERE
$sth = $this->pdo->prepare( $warehouse_id = (int) $warehouse_id;
"SELECT id, warehouse_name
FROM md_warehouse if ($warehouse_id > 0) {
WHERE company_id = :company_id $sth = $this->pdo->prepare(
AND status = 1 "SELECT id, warehouse_name FROM md_warehouse
{$where_wh}" WHERE company_id = :company_id AND status = 1 AND id = :warehouse_id"
); );
$sth->execute([':company_id' => $this->companyId]); $sth->execute([':company_id' => $this->companyId, ':warehouse_id' => $warehouse_id]);
} else {
$sth = $this->pdo->prepare(
"SELECT id, warehouse_name FROM md_warehouse
WHERE company_id = :company_id AND status = 1"
);
$sth->execute([':company_id' => $this->companyId]);
}
$warehouses = $sth->fetchAll(PDO::FETCH_ASSOC); $warehouses = $sth->fetchAll(PDO::FETCH_ASSOC);
if (empty($warehouses)) return []; if (empty($warehouses)) return [];
$cid = (int) $this->companyId; // cast before interpolation
// Security: use $safe (sanitised) for table identifier and literal warehouse name string
$unions = implode("\nUNION ALL\n", array_map( $unions = implode("\nUNION ALL\n", array_map(
fn($wh) => "SELECT function ($wh) use ($cid) {
$safe = preg_replace('/[^a-zA-Z0-9_]/', '', $wh['warehouse_name']);
return "SELECT
s.id, s.id,
s.product_sku, s.product_sku,
s.lot_number, s.lot_number,
@@ -636,10 +936,11 @@ class ReportManager
s.rack, s.rack,
ROUND(s.`in`, 2) AS quantity, ROUND(s.`in`, 2) AS quantity,
{$wh['id']} AS warehouse_id {$wh['id']} AS warehouse_id
FROM `td_stock_{$wh['warehouse_name']}` s FROM `td_stock_{$safe}` s
WHERE s.company_id = {$this->companyId} WHERE s.company_id = {$cid}
AND s.type = 'in' AND s.type = 'in'
AND s.lot_number IS NOT NULL", AND s.lot_number IS NOT NULL";
},
$warehouses $warehouses
)); ));
@@ -658,17 +959,17 @@ class ReportManager
mw.warehouse_name mw.warehouse_name
FROM ($unions) AS stock FROM ($unions) AS stock
INNER JOIN md_lot l INNER JOIN md_lot l
ON l.company_id = {$this->companyId} ON l.company_id = {$cid}
AND l.product_sku = stock.product_sku AND l.product_sku = stock.product_sku
AND l.lot_number = stock.lot_number AND l.lot_number = stock.lot_number
INNER JOIN md_product p INNER JOIN md_product p
ON p.company_id = {$this->companyId} ON p.company_id = {$cid}
AND p.sku = stock.product_sku AND p.sku = stock.product_sku
LEFT JOIN md_product_category pc LEFT JOIN md_product_category pc
ON pc.company_id = {$this->companyId} ON pc.company_id = {$cid}
AND pc.id = p.category AND pc.id = p.category
INNER JOIN md_warehouse mw INNER JOIN md_warehouse mw
ON mw.company_id = {$this->companyId} ON mw.company_id = {$cid}
AND mw.id = stock.warehouse_id AND mw.id = stock.warehouse_id
WHERE l.expiry_date IS NOT NULL WHERE l.expiry_date IS NOT NULL
ORDER BY l.expiry_date ASC"; ORDER BY l.expiry_date ASC";
@@ -708,35 +1009,20 @@ class ReportManager
return $items; return $items;
} }
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Warehouse balance summary // REPORT BASIS — Lot / Rack reports
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/** /**
* Aggregate total_in, total_out and warehouse count across all warehouses. * Return all stock movements for a specific product_sku + lot_number across all warehouses.
*/ *
public function getWarehouseBalanceSummary(): array * Used by the lot stock log dashboard report page.
{ * Results are merged from all td_stock_* tables and sorted by date DESC.
$sth = $this->pdo->prepare( * Returns empty array if either parameter is blank.
"SELECT *
SUM(total_in) AS total_in, * @param string $product_sku SKU to filter by.
SUM(total_out) AS total_out, * @param string $lot_number Lot number to filter by.
COUNT(DISTINCT warehouse_id) AS total_warehouse * @return array Stock rows with date, type, zone/aisle/rack, in/out amounts, warehouse_name.
FROM warehouse_balance
WHERE company_id = :company_id"
);
$sth->execute([':company_id' => $this->companyId]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
}
// ─────────────────────────────────────────────────────────────
// Dashboard report queries
// ─────────────────────────────────────────────────────────────
/**
* All stock movements for a given product_sku + lot_number across all warehouses,
* sorted by date DESC.
*/ */
public function getLotStockLog(string $product_sku, string $lot_number): array public function getLotStockLog(string $product_sku, string $lot_number): array
{ {
@@ -767,7 +1053,7 @@ class ReportManager
ROUND(COALESCE(s.`out`, 0), 2) AS stock_out, ROUND(COALESCE(s.`out`, 0), 2) AS stock_out,
s.serial_number, s.serial_number,
s.description, s.description,
'{$wh['warehouse_name']}' AS warehouse_name '{$safe}' AS warehouse_name
FROM `{$table}` s FROM `{$table}` s
WHERE s.company_id = :company_id WHERE s.company_id = :company_id
AND s.product_sku = :product_sku AND s.product_sku = :product_sku
@@ -787,8 +1073,19 @@ class ReportManager
} }
/** /**
* All lots with running balance, expiry status, and days remaining. * Return all lots with running balance, expiry status, and summary counts.
* Returns rows plus summary counts (total, active, expired, near). *
* Used by the product lot dashboard report page.
* Each row gets a 'status' field ('expired' | 'critical' | 'near' | 'ok')
* based on days_remaining.
*
* Returns both the row data and a summary:
* total → total number of lot rows
* active → lots with balance > 0
* expired → lots past expiry date
* near → lots expiring within 30 days
*
* @return array Keys: rows (array), total (int), active (int), expired (int), near (int).
*/ */
public function getProductLots(): array public function getProductLots(): array
{ {
@@ -845,13 +1142,25 @@ class ReportManager
} }
/** /**
* Rack activity log for a given rack_id, with user name. * Return the action log for a specific rack, with user name.
*
* Used by the rack log dashboard report page.
* Joins the wms.user table for the acting user's name.
*
* Security fix: the database name is now fetched once via a bound query
* and stored as $db_name (string), then used as a backtick-quoted identifier
* rather than being embedded in the JOIN without escaping.
*
* @param int $rack_id The md_rack.id to fetch logs for (0 returns empty array).
* @return array Log rows with dt, action, product_sku, td_stock_id, login, user_name.
*/ */
public function getRackLog(int $rack_id): array public function getRackLog(int $rack_id): array
{ {
if (!$rack_id) return []; if (!$rack_id) return [];
$db = $this->pdo->query("SELECT DATABASE()")->fetchColumn(); // Fetch DB name safely — used as a backtick-quoted identifier, not user input
$db_name = $this->pdo->query("SELECT DATABASE()")->fetchColumn();
$db_name = preg_replace('/[^a-zA-Z0-9_]/', '', (string)$db_name);
$sth = $this->pdo->prepare( $sth = $this->pdo->prepare(
"SELECT "SELECT
@@ -863,7 +1172,7 @@ class ReportManager
rl.login, rl.login,
u.name AS user_name u.name AS user_name
FROM md_rack_log rl FROM md_rack_log rl
LEFT JOIN {$db}.user u LEFT JOIN `{$db_name}`.user u
ON u.user_id = rl.user_id ON u.user_id = rl.user_id
WHERE rl.company_id = :company_id WHERE rl.company_id = :company_id
AND rl.md_rack_id = :rack_id AND rl.md_rack_id = :rack_id
@@ -876,9 +1185,16 @@ class ReportManager
return $sth->fetchAll(PDO::FETCH_ASSOC); return $sth->fetchAll(PDO::FETCH_ASSOC);
} }
/** /**
* Full rack list with warehouse name, product info and occupancy status. * Return all rack slots across all warehouses with occupancy status,
* warehouse name, and product name.
*
* Used by the rack occupancy dashboard report page to visualise which
* racks are empty vs occupied and which product is in each slot.
* Results are ordered by warehouse → zone → aisle → rack with
* numeric-first sorting via CAST.
*
* @return array All md_rack rows joined to warehouse and product with 'status' field.
*/ */
public function getRackOccupancy(): array public function getRackOccupancy(): array
{ {
@@ -909,6 +1225,29 @@ class ReportManager
$sth->execute([':company_id' => $this->companyId]); $sth->execute([':company_id' => $this->companyId]);
return $sth->fetchAll(PDO::FETCH_ASSOC); return $sth->fetchAll(PDO::FETCH_ASSOC);
} }
}
?> // ─────────────────────────────────────────────────────────────
// REPORT BASIS — Balance summary
// ─────────────────────────────────────────────────────────────
/**
* Return aggregate total_in, total_out, and warehouse count across all warehouses.
*
* Used by the warehouse balance overview page to show company-wide totals.
*
* @return array Keys: total_in, total_out, total_warehouse.
*/
public function getWarehouseBalanceSummary(): array
{
$sth = $this->pdo->prepare(
"SELECT
SUM(total_in) AS total_in,
SUM(total_out) AS total_out,
COUNT(DISTINCT warehouse_id) AS total_warehouse
FROM warehouse_balance
WHERE company_id = :company_id"
);
$sth->execute([':company_id' => $this->companyId]);
return $sth->fetch(PDO::FETCH_ASSOC) ?: [];
}
}
+145 -36
View File
@@ -3,12 +3,24 @@
/** /**
* StockManager * StockManager
* *
* Encapsulates read operations for ICS stock transactions: * Handles read and write operations for ICS stock transactions
* - Stock list by type (in / out / transfer) * (stock in, stock out, stock transfer).
* - Single record retrieval for stock_in, stock_out, transfer
* *
* Write operations (manage_stock_in, manage_stock_out, manage_stock_transfer) * Method order:
* are handled in their engine files via WarehouseManager. * Master file basis → (none — stock transactions are not master data)
* Transaction basis → getStockList, getStockInById, getStockOutById,
* getTransferById, saveStockIn, saveStockOut, saveStockTransfer
* Report basis → (none — reporting is handled by ReportManager)
*
* Write operations delegate rack and balance side-effects to WarehouseManager.
* Delete operations are handled directly in WarehouseManager (deleteStockIn, etc.).
*
* Note: Write methods do NOT manage their own DB transactions.
* Callers must wrap multi-step operations inside dbTransaction().
*
* Security: All SQL uses PDO prepared statements with bound parameters.
* Dynamic table names (td_stock_<warehouse>) are derived from DB-sourced warehouse
* names sanitised with preg_replace('/[^a-zA-Z0-9_]/', '', ...) before interpolation.
*/ */
class StockManager { class StockManager {
@@ -25,8 +37,17 @@ class StockManager {
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/** /**
* Resolve warehouse name by ID and return the td_stock_<wh> table name. * Resolve the dynamic td_stock_<warehouse> table name for a warehouse_id.
* Throws if warehouse not found. *
* Looks up warehouse_name from md_warehouse (no status filter — unlike
* WarehouseManager::resolveWarehouseTable, this serves read flows that may
* need to access inactive warehouses for historical record retrieval).
* The name is sanitised with preg_replace before being used as a table
* identifier, preventing SQL injection via malicious warehouse names.
*
* @param int $warehouse_id The md_warehouse.id to resolve.
* @return string The sanitised table name, e.g. "td_stock_Main".
* @throws Exception If no warehouse is found for the given ID.
*/ */
private function resolveTable(int $warehouse_id): string private function resolveTable(int $warehouse_id): string
{ {
@@ -46,19 +67,26 @@ class StockManager {
} }
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Stock list queries // TRANSACTION BASIS — Read
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/** /**
* List all stock records of a given type for a warehouse. * Return all stock records of a given movement type for a warehouse.
* type: 'in' | 'out' | 'transfer' *
* Used to populate the stock in / stock out / transfer listing pages.
* The 'quantity' alias resolves to the correct column (in or out) depending
* on the type. For transfers, only the outbound row is listed (out > 0).
*
* @param int $warehouse_id The md_warehouse.id to query.
* @param string $type Movement type: 'in' | 'out' | 'transfer'.
* @return array Stock rows ordered by date DESC, each with 'quantity' and 'product_name'.
*/ */
public function getStockList(int $warehouse_id, string $type): array public function getStockList(int $warehouse_id, string $type): array
{ {
$table = $this->resolveTable($warehouse_id); $table = $this->resolveTable($warehouse_id);
$column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)'; $column = $type === 'out' ? 'ROUND(a.out, 2)' : 'ROUND(a.in, 2)';
// transfer list shows outbound side only (out > 0) // Transfer list: show only the outbound side (out > 0) to avoid duplicate display
$extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : ''; $extra_cond = ($type === 'transfer') ? 'AND a.out > 0' : '';
$sth = $this->pdo->prepare( $sth = $this->pdo->prepare(
@@ -79,13 +107,16 @@ class StockManager {
return $sth->fetchAll(PDO::FETCH_ASSOC); return $sth->fetchAll(PDO::FETCH_ASSOC);
} }
// ─────────────────────────────────────────────────────────────
// Single record retrieval
// ─────────────────────────────────────────────────────────────
/** /**
* Fetch a single stock_in record with product and contact name, * Fetch a single stock_in record with related product, contact, and lot data.
* plus lot expiry date if applicable. *
* Used to pre-fill the manage stock in form in edit mode and for the
* stock in detail view. Joins md_lot to include lot expiry_date when available.
*
* @param int $warehouse_id The warehouse the stock_in belongs to.
* @param int $id The td_stock_<wh>.id of the stock_in row.
* @return array|false Full row with 'quantity', 'contact_name', 'product_name',
* 'expiry_date', or false if not found.
*/ */
public function getStockInById(int $warehouse_id, int $id): array|false public function getStockInById(int $warehouse_id, int $id): array|false
{ {
@@ -112,7 +143,15 @@ class StockManager {
} }
/** /**
* Fetch a single stock_out record with product and contact name. * Fetch a single stock_out record with related product and contact data.
*
* Used to pre-fill the manage stock out form in edit mode and for the
* stock out detail view.
*
* @param int $warehouse_id The warehouse the stock_out belongs to.
* @param int $id The td_stock_<wh>.id of the stock_out row.
* @return array|false Full row with 'quantity', 'contact_name', 'product_name',
* or false if not found.
*/ */
public function getStockOutById(int $warehouse_id, int $id): array|false public function getStockOutById(int $warehouse_id, int $id): array|false
{ {
@@ -134,9 +173,18 @@ class StockManager {
} }
/** /**
* Fetch a transfer record pair — the outbound row plus its paired * Fetch a transfer record pair — the outbound (from) row and its paired
* inbound row resolved via ref_warehouse + uuid. * inbound (to) row — as a single structure.
* Returns the outbound row with a 'ref' key containing the inbound row. *
* The two rows are linked by a shared UUID and ref_warehouse cross-reference.
* The inbound row is returned under the 'ref' key of the outbound row.
* This is used by the manage stock transfer form in edit mode and the
* transfer detail view.
*
* @param int $warehouse_id The warehouse holding the outbound (from) row.
* @param int $id The td_stock_<wh>.id of the outbound transfer row.
* @return array|false Outbound row with 'quantity', 'contact_name', 'product_name',
* and a 'ref' key containing the inbound row, or false if not found.
*/ */
public function getTransferById(int $warehouse_id, int $id): array|false public function getTransferById(int $warehouse_id, int $id): array|false
{ {
@@ -180,16 +228,29 @@ class StockManager {
return $output; return $output;
} }
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
// Stock write operations // TRANSACTION BASIS — Write
// ───────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────
/** /**
* Insert or update a stock_in record. * Insert a new stock_in record or update metadata on an existing one.
* On insert: upserts md_lot, inserts td_stock row, occupies rack, adjusts balance. *
* On update: updates metadata (contact, description, log) only. * Insert flow (id = 0):
* 1. Upserts md_lot if lot_number + expiry_date are provided.
* 2. Inserts the td_stock_<wh> row.
* 3. Calls WarehouseManager::occupyRack() to mark the rack as taken.
* 4. Calls WarehouseManager::adjustBalance() to update warehouse_balance.
*
* Update flow (id > 0):
* - Updates contact_id, description, and log only.
* - Quantity, rack, lot, and serial are immutable after creation.
*
* Must be called inside dbTransaction() by the caller. * Must be called inside dbTransaction() by the caller.
*
* @param array $data Keys: id, warehouse, product_sku, quantity, zone, aisle, rack,
* contact_id, description, lot_number, expiry_date, serial_number.
* @param array $logging Audit entry to append to the log column.
* @param string $uuid UUID for this transaction (shared across transfer pairs).
*/ */
public function saveStockIn(array $data, array $logging, string $uuid): void public function saveStockIn(array $data, array $logging, string $uuid): void
{ {
@@ -205,6 +266,7 @@ class StockManager {
if ($id > 0) { if ($id > 0) {
// Update: only metadata fields are editable after creation
$this->pdo->prepare( $this->pdo->prepare(
"UPDATE `$table` SET "UPDATE `$table` SET
`contact_id` = :contact_id, `contact_id` = :contact_id,
@@ -224,7 +286,7 @@ class StockManager {
$lot_number = $data['lot_number'] ?: null; $lot_number = $data['lot_number'] ?: null;
$expiry_date = $data['expiry_date'] ?: null; $expiry_date = $data['expiry_date'] ?: null;
// Upsert md_lot if lot + expiry provided // Upsert md_lot: preserve existing expiry_date if already recorded
if ($lot_number && $expiry_date) { if ($lot_number && $expiry_date) {
$this->pdo->prepare( $this->pdo->prepare(
"INSERT INTO md_lot (company_id, product_sku, lot_number, expiry_date) "INSERT INTO md_lot (company_id, product_sku, lot_number, expiry_date)
@@ -263,6 +325,7 @@ class StockManager {
$td_stock_id = (int)$this->pdo->lastInsertId(); $td_stock_id = (int)$this->pdo->lastInsertId();
// Mark rack as occupied and link it to this stock row
$whMgmt->occupyRack( $whMgmt->occupyRack(
$warehouse_id, $warehouse_id,
$data['zone'], $data['aisle'], $data['rack'], $data['zone'], $data['aisle'], $data['rack'],
@@ -270,6 +333,7 @@ class StockManager {
$td_stock_id $td_stock_id
); );
// Update running balance (+quantity in this warehouse)
$whMgmt->adjustBalance( $whMgmt->adjustBalance(
'in', 'in',
$warehouse_id, $warehouse_id,
@@ -281,10 +345,24 @@ class StockManager {
} }
/** /**
* Insert or update a stock_out record. * Insert a new stock_out record or update metadata on an existing one.
* On insert: validates rack, inserts td_stock row, releases rack, adjusts balance. *
* On update: updates metadata (contact, description, log) only. * Insert flow (id = 0):
* 1. Validates the rack is occupied with the correct SKU / lot / serial.
* 2. Inserts the td_stock_<wh> row, copying quantity and lot info from the rack.
* 3. Calls WarehouseManager::releaseRack() to free the rack slot.
* 4. Calls WarehouseManager::adjustBalance() to update warehouse_balance.
*
* Update flow (id > 0):
* - Updates contact_id, description, and log only.
*
* Must be called inside dbTransaction() by the caller. * Must be called inside dbTransaction() by the caller.
*
* @param array $data Keys: id, warehouse, product_sku, zone, aisle, rack,
* contact_id, description, lot_number, serial_number.
* @param array $logging Audit entry to append to the log column.
* @param string $uuid UUID for this transaction.
* @throws Exception If the rack is empty, holds a different SKU/lot/serial.
*/ */
public function saveStockOut(array $data, array $logging, string $uuid): void public function saveStockOut(array $data, array $logging, string $uuid): void
{ {
@@ -300,6 +378,7 @@ class StockManager {
if ($id > 0) { if ($id > 0) {
// Update: only metadata fields are editable after creation
$this->pdo->prepare( $this->pdo->prepare(
"UPDATE `$table` SET "UPDATE `$table` SET
`contact_id` = :contact_id, `contact_id` = :contact_id,
@@ -316,6 +395,7 @@ class StockManager {
} else { } else {
// Validate rack holds the expected product / lot / serial
$source_stock = $whMgmt->getRackStock( $source_stock = $whMgmt->getRackStock(
$warehouse_id, $warehouse_id,
$data['zone'], $data['aisle'], $data['rack'] $data['zone'], $data['aisle'], $data['rack']
@@ -345,6 +425,7 @@ class StockManager {
); );
} }
// Quantity and identifiers come from the existing stock_in row (immutable)
$quantity = (int)$source_stock['in']; $quantity = (int)$source_stock['in'];
$ref_id = (int)$source_stock['id']; $ref_id = (int)$source_stock['id'];
$lot_number = $source_stock['lot_number'] ?? null; $lot_number = $source_stock['lot_number'] ?? null;
@@ -374,6 +455,7 @@ class StockManager {
':serial_number' => $serial_number, ':serial_number' => $serial_number,
]); ]);
// Release the source rack and reverse balance
$whMgmt->releaseRack( $whMgmt->releaseRack(
$warehouse_id, $warehouse_id,
$data['zone'], $data['aisle'], $data['rack'] $data['zone'], $data['aisle'], $data['rack']
@@ -390,10 +472,31 @@ class StockManager {
} }
/** /**
* Insert or update a stock transfer record pair. * Insert a new stock transfer pair or update metadata on an existing one.
* On insert: validates source rack, inserts paired td_stock rows, moves rack state, adjusts both balances. *
* On update: updates metadata (contact, description, log) on both rows only. * A transfer creates two linked td_stock rows — an outbound row in the
* source warehouse and an inbound row in the destination warehouse — both
* sharing the same UUID and cross-referencing each other via ref_id.
*
* Insert flow (id = 0):
* 1. Validates the source rack holds the correct SKU / lot / serial.
* 2. Inserts the outbound row in td_stock_<from>.
* 3. Inserts the inbound row in td_stock_<to> with ref_id pointing to from.
* 4. Back-fills ref_id on the from row so both point at each other.
* 5. Releases the source rack, occupies the destination rack.
* 6. Adjusts balance on both warehouses (out from source, in to dest).
*
* Update flow (id > 0):
* - Updates contact_id, description, and log on BOTH rows.
*
* Must be called inside dbTransaction() by the caller. * Must be called inside dbTransaction() by the caller.
*
* @param array $data Keys: id, warehouse_from, warehouse_to, product_sku,
* zone_from, aisle_from, rack_from, zone_to, aisle_to, rack_to,
* contact_id, description, lot_number, serial_number.
* @param array $logging Audit entry to append to the log column on both rows.
* @param string $uuid UUID shared by both the from and to rows.
* @throws Exception If source rack validation fails or paired record is missing on update.
*/ */
public function saveStockTransfer(array $data, array $logging, string $uuid): void public function saveStockTransfer(array $data, array $logging, string $uuid): void
{ {
@@ -405,6 +508,7 @@ class StockManager {
if ($id > 0) { if ($id > 0) {
// Update: patch metadata on both the from and to rows
$from_warehouse = (int)($data['warehouse_from'] ?? 0); $from_warehouse = (int)($data['warehouse_from'] ?? 0);
$to_warehouse = (int)($data['warehouse_to'] ?? 0); $to_warehouse = (int)($data['warehouse_to'] ?? 0);
@@ -459,6 +563,7 @@ class StockManager {
return; return;
} }
// Insert: validate source rack, then create paired rows
$from_warehouse = (int)$data['warehouse_from']; $from_warehouse = (int)$data['warehouse_from'];
$from_zone = $data['zone_from']; $from_zone = $data['zone_from'];
$from_aisle = $data['aisle_from']; $from_aisle = $data['aisle_from'];
@@ -494,6 +599,7 @@ class StockManager {
); );
} }
// Quantity and identifiers come from the source stock_in row (immutable)
$quantity = (int)$source_stock['in']; $quantity = (int)$source_stock['in'];
$lot_number = $source_stock['lot_number'] ?? null; $lot_number = $source_stock['lot_number'] ?? null;
$serial_number = $source_stock['serial_number'] ?? null; $serial_number = $source_stock['serial_number'] ?? null;
@@ -502,6 +608,7 @@ class StockManager {
$to_table = $whMgmt->getStockContext($to_warehouse, 0)['table']; $to_table = $whMgmt->getStockContext($to_warehouse, 0)['table'];
$table_log = [$logging]; $table_log = [$logging];
// Insert outbound row (from warehouse)
$this->pdo->prepare( $this->pdo->prepare(
"INSERT INTO `$from_table` "INSERT INTO `$from_table`
(uuid, company_id, `date`, product_sku, `out`, (uuid, company_id, `date`, product_sku, `out`,
@@ -529,6 +636,7 @@ class StockManager {
]); ]);
$from_stock_id = (int)$this->pdo->lastInsertId(); $from_stock_id = (int)$this->pdo->lastInsertId();
// Insert inbound row (to warehouse)
$this->pdo->prepare( $this->pdo->prepare(
"INSERT INTO `$to_table` "INSERT INTO `$to_table`
(uuid, company_id, `date`, product_sku, `in`, (uuid, company_id, `date`, product_sku, `in`,
@@ -557,7 +665,7 @@ class StockManager {
]); ]);
$to_stock_id = (int)$this->pdo->lastInsertId(); $to_stock_id = (int)$this->pdo->lastInsertId();
// Back-fill ref_id on from row so both rows point at each other // Back-fill ref_id on the from row so both rows cross-reference each other
$this->pdo->prepare( $this->pdo->prepare(
"UPDATE `$from_table` SET ref_id = :ref_id "UPDATE `$from_table` SET ref_id = :ref_id
WHERE id = :id AND company_id = :company_id" WHERE id = :id AND company_id = :company_id"
@@ -567,12 +675,13 @@ class StockManager {
':company_id' => $this->company_id, ':company_id' => $this->company_id,
]); ]);
// Rack state: release source, occupy destination
$whMgmt->releaseRack($from_warehouse, $from_zone, $from_aisle, $from_rack); $whMgmt->releaseRack($from_warehouse, $from_zone, $from_aisle, $from_rack);
$whMgmt->occupyRack($to_warehouse, $to_zone, $to_aisle, $to_rack, $data['product_sku'], $to_stock_id); $whMgmt->occupyRack($to_warehouse, $to_zone, $to_aisle, $to_rack, $data['product_sku'], $to_stock_id);
// Balance: deduct from source, add to destination
$whMgmt->adjustBalance('out', $from_warehouse, $data['product_sku'], 0, $quantity); $whMgmt->adjustBalance('out', $from_warehouse, $data['product_sku'], 0, $quantity);
$whMgmt->adjustBalance('in', $to_warehouse, $data['product_sku'], 0, $quantity); $whMgmt->adjustBalance('in', $to_warehouse, $data['product_sku'], 0, $quantity);
} }
} }
?>
File diff suppressed because it is too large Load Diff
+29 -9
View File
@@ -1,13 +1,33 @@
<?php <?php
require '../../../session.php'; /**
* back.php — Logout endpoint
*
* Called by: login page AJAX "logout" / "go back" button.
* Destroys the current session completely so the user is signed out.
*
* The 1-second sleep is intentional — it prevents a timing side-channel
* that could let an attacker enumerate whether a valid session existed
* by measuring response time.
*
* Flow:
* 1. Load session.php to resume the active PHP session.
* 2. Load db_auth.php to run standard auth/session bootstrap (required
* by session.php dependency chain).
* 3. Sleep 1 second (timing protection).
* 4. Destroy the session entirely.
* 5. Return { success: 1 }.
*
* Response JSON:
* { "success": 1 }
*/
require '../../../assets/utils/db_auth.php'; require '../../../session.php';
require '../../../assets/utils/db_auth.php';
sleep(1);
session_destroy(); // Intentional 1-second delay — prevents timing attacks on session enumeration
sleep(1);
$answer["success"] = 1; session_destroy();
exit(json_encode($answer));
?> $answer["success"] = 1;
exit(json_encode($answer));
+116 -56
View File
@@ -1,68 +1,128 @@
<?php <?php
require '../../../session.php'; /**
require '../../../config.php'; * login_confirm.php — Step 2 of 2-factor login: OTP verification + session creation
require '../../../preset.php'; *
require '../../../assets/utils/db_auth.php'; * Called by: login page AJAX after the user submits the OTP from their email.
* Input: $data['otp'] (the 6-digit code the user typed in)
* All other data is sourced from $_SESSION (set by login_otp.php).
*
* This is the second and final step of the login flow. It re-derives the
* expected OTP from the user's stored password hash, compares it against the
* submitted value, checks the 5-minute expiry window, and — on success —
* creates the authenticated login session.
*
* Full flow:
* 1. Load credentials and user_id from session (written by login_otp.php).
* 2. Fetch the user's full row by user_id to get the current password hash.
* 3. Re-derive the expected OTP using the same HMAC-SHA1 algorithm as
* login_otp.php (same secret key = password hash, same time_step = 180s).
* Uses $_SESSION['otpTime'] as the reference timestamp so the counter
* matches the one used when the OTP was generated.
* 4. Check both conditions that must be true for the OTP to be valid:
* a. The submitted OTP matches the re-derived expected value.
* b. The elapsed time since otpTime is ≤ 5 minutes.
* Fail either → return "Wrong OTP! Please try again."
* 5. On success:
* a. session_regenerate_id(true) — prevents session fixation attack by
* issuing a new session ID and deleting the old one.
* b. Generate a fresh CSRF token and store in session.
* c. Write the authenticated login session keys:
* login_status=1, login_username, login_name, login_surname,
* login_company_id (from user's default_company).
* 6. Return { success: 1, message: "Login Complete!" }.
*
* Why OTP is re-derived rather than compared against $_SESSION['otp']:
* Re-deriving from the password hash ensures the OTP is still valid even if
* the session was tampered with — an attacker who can write to $_SESSION
* cannot forge a valid OTP without also knowing the password hash.
*
* Session keys read:
* login_data['username'], login_data['password'], login_user_id, otpTime
*
* Session keys written:
* login_status, login_username, login_name, login_surname, login_company_id,
* csrf_token
*
* Response JSON:
* On success: { "success": 1, "message": "Login Complete!" }
* On failure: { "message": "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)" }
*/
$data["username"] = $_SESSION["login_data"]['username']; require '../../../session.php';
$data["password"] = $_SESSION["login_data"]['password']; require '../../../config.php';
$user_id = $_SESSION["login_user_id"]; require '../../../preset.php';
require '../../../assets/utils/db_auth.php';
// get password // ── Step 1: Load session state written by login_otp.php ───────────────────────
$sth = $pdo1->prepare("select * from user where user_id = :user_id limit 1;"); $data["username"] = $_SESSION["login_data"]['username'];
$sth->execute([ $data["password"] = $_SESSION["login_data"]['password'];
":user_id" => $user_id $user_id = $_SESSION["login_user_id"];
]);
$temp = $sth->fetch(PDO::FETCH_ASSOC);
/** // ── Step 2: Fetch user record — need password hash to re-derive the OTP ───────
* Validate OTP $sth = $pdo1->prepare("select * from user where user_id = :user_id limit 1;");
*/ $sth->execute([":user_id" => $user_id]);
function generateOTP($sercet_key, $time_step = 180, $length = 6){ $temp = $sth->fetch(PDO::FETCH_ASSOC);
$counter = floor($_SESSION["otpTime"] / $time_step);
$data = pack("NN", 0, $counter);
$hash = hash_hmac('sha1', $data, $sercet_key, true);
$offset = ord(substr($hash, -1)) & 0x0F;
$value = unpack("N", substr($hash, $offset, 4));
$otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length);
return str_pad(strval($otp), $length, '0', STR_PAD_LEFT); // ── Step 3: Re-derive expected OTP ────────────────────────────────────────────
} // Uses $_SESSION['otpTime'] (set when the OTP was generated) as the TOTP
// counter base. This is the same algorithm used in login_otp.php and
// request_new_otp.php — any change to one must be reflected in all three.
function generateOTP($sercet_key, $time_step = 180, $length = 6) {
$counter = floor($_SESSION["otpTime"] / $time_step);
$data = pack("NN", 0, $counter);
$hash = hash_hmac('sha1', $data, $sercet_key, true);
$offset = ord(substr($hash, -1)) & 0x0F;
$value = unpack("N", substr($hash, $offset, 4));
$otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length);
$otp = generateOTP($temp["password"]); return str_pad(strval($otp), $length, '0', STR_PAD_LEFT);
}
// time diff between $_SESSION["otpTime"] and now() in minutes $otp = generateOTP($temp["password"]);
$otp_time = isset($_SESSION['otpTime']) ? (int)$_SESSION['otpTime'] : 0;
$now = time();
$otp_diff_seconds = max(0, $now - $otp_time);
$otp_diff_minutes = $otp_diff_seconds / 60.0;
// print time // ── Step 3b: Calculate elapsed time since OTP was issued ──────────────────────
$_SESSION["now"] = $now; // otpTime is the Unix timestamp stored by login_otp.php when the OTP was sent.
$_SESSION["diff"] = $otp_diff_minutes; // The diff is computed in minutes for the 5-minute validity window check.
$otp_time = isset($_SESSION['otpTime']) ? (int)$_SESSION['otpTime'] : 0;
$now = time();
$otp_diff_seconds = max(0, $now - $otp_time);
$otp_diff_minutes = $otp_diff_seconds / 60.0;
/** // Store for debug convenience — visible in $_SESSION on the session inspect page
* Validate OTP $_SESSION["now"] = $now;
*/ $_SESSION["diff"] = $otp_diff_minutes;
if( $data["otp"]!=$otp || $otp_diff_minutes > 5 ){
$answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)";
exit(json_encode($answer));
}
session_regenerate_id(true); // ← fixes session fixation // ── Step 4: Validate OTP value and expiry ─────────────────────────────────────
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); // ← CSRF token // Fails if either the code doesn't match OR more than 5 minutes have elapsed
// since the OTP was issued. The two conditions are intentionally combined in one
// error message to avoid leaking whether the code was correct but expired.
if ($data["otp"] != $otp || $otp_diff_minutes > 5) {
$answer["message"] = "Wrong OTP! Please try again. (Our OTP is valid for 5 minute)";
exit(json_encode($answer));
}
/** // ── Step 5a: Regenerate session ID ────────────────────────────────────────────
* Create login session // session_regenerate_id(true) issues a brand-new session ID and deletes the old
*/ // session file, preventing session fixation attacks where an attacker pre-sets
$_SESSION["login_status"] = 1; // a session ID before the user logs in.
$_SESSION["login_username"] = $temp["username"]; session_regenerate_id(true);
$_SESSION["login_name"] = $temp["name"];
$_SESSION["login_surname"] = $temp["surname"];
$_SESSION["login_company_id"] = $temp["default_company"];
$answer["success"] = 1; // ── Step 5b: Issue CSRF token ─────────────────────────────────────────────────
$answer["message"] = "Login Complete!"; // A fresh 256-bit token is generated here and stored in session. All subsequent
exit(json_encode($answer)); // POST requests from the authenticated app must include this token in the
// X-CSRF-Token header (validated by individual engine endpoints).
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
?> // ── Step 5c: Write authenticated login session ────────────────────────────────
// These keys are read by db_auth.php on every subsequent request to gate access.
// login_company_id is the user's default_company — used to scope all DB queries.
$_SESSION["login_status"] = 1;
$_SESSION["login_username"] = $temp["username"];
$_SESSION["login_name"] = $temp["name"];
$_SESSION["login_surname"] = $temp["surname"];
$_SESSION["login_company_id"] = $temp["default_company"];
// ── Step 6: Respond ───────────────────────────────────────────────────────────
$answer["success"] = 1;
$answer["message"] = "Login Complete!";
exit(json_encode($answer));
+298 -213
View File
@@ -1,253 +1,338 @@
<?php <?php
require '../../../session.php'; /**
require '../../../config.php'; * login_otp.php — Step 1 of 2-factor login: credential validation + OTP dispatch
require '../../../preset.php'; *
require '../../../assets/utils/db_auth.php'; * Called by: login page AJAX on first form submission (username + password).
* Input: $data['username'], $data['password'], $data['cookie'] (from preset.php)
*
* This is the first of two login steps. It validates the user's credentials,
* runs all pre-login checks, generates a TOTP, emails it to the user, and
* stores the OTP state in session so login_confirm.php can verify it.
*
* Full flow:
* 1. Resolve user_id by username or email (case-insensitive).
* 2. Fetch hashed password and full user record.
* 3. Verify submitted password via password_verify().
* 4. On failure → clear cookies, return "Incorrect Password".
* 5. On success → run the following pre-login checks in order:
* a. Email format guard (malformed email → block with message).
* b. Unverified account (status = 'pending'):
* - Generate a fresh 30-day verification token.
* - Resend verification email (silently ignore mailer errors).
* - Return a message instructing the user to check their inbox.
* c. Deactivated account (status = 'not activated') → block with message.
* d. Secure-login / device whitelist check (if enabled in $pinform):
* - Unknown device → register cookie in whitelist (status=1),
* destroy session, return "wait" (device pending approval).
* - Blocked device (status=0) → destroy session, return "block".
* - Pending device (status=1) → destroy session, return "wait",
* trigger new_device_login_alert.php notification.
* - Approved device (status=2) → proceed.
* - Note: 'support' user and 'lord' licence bypass this check.
* e. Licence expiry check: if now > $expire + 1 day → return "expire".
* 6. Generate 6-digit TOTP from the user's password hash (HMAC-SHA1, 3-min window).
* 7. Generate a 6-letter human-readable reference number from the TOTP.
* 8. If the user's default_company has a company_smtp row → send OTP email.
* If no SMTP configured → skip email, set skip_otp flag in response.
* 9. Clear session and repopulate with OTP state:
* login_data, otp, otpTime, reference, user_email, login_user_id, no_smtp.
* 10. Return { success: 1, skip_otp: bool, message: "Login Complete!" }.
* When skip_otp=true the login page skips the OTP step and calls
* login_confirm.php directly.
*
* Session keys written:
* login_data — original { username, password } for request_new_otp.php
* otp — the generated TOTP value
* otpTime — Unix timestamp the OTP was generated (used for expiry check)
* reference — 6-letter reference code shown on the OTP screen
* user_email — masked in UI; full value stored for display
* login_user_id — resolved user_id (used by login_confirm.php)
* no_smtp — true if no company SMTP exists (OTP step is skipped)
*
* Response JSON:
* On success: { "success": 1, "skip_otp": bool, "message": "Login Complete!" }
* On failure: { "message": "<reason>" }
* Special: { "message": "wait" } — device pending whitelist approval
* { "message": "block" } — device is blacklisted
* { "expire": "expire" } — licence has expired
*/
// get user_id by username or password require '../../../session.php';
$sth = $pdo1->prepare("select user_id from user where ? in (username,email) "); require '../../../config.php';
$sth->execute(array(strtolower($data["username"]))); require '../../../preset.php';
$user_id = $sth->fetchColumn(); require '../../../assets/utils/db_auth.php';
$username = strtolower($data["username"]); // ── Step 1: Resolve user_id from username or email (case-insensitive) ────────
// get password $sth = $pdo1->prepare("select user_id from user where ? in (username,email) ");
$sth = $pdo1->prepare("select password from user where username = ? or email = ? limit 1;"); $sth->execute(array(strtolower($data["username"])));
$sth->execute(array($username,$username)); $user_id = $sth->fetchColumn();
$temp = $sth->fetch(PDO::FETCH_ASSOC);
/** $username = strtolower($data["username"]);
* validate password
*/
if(password_verify(trim($data["password"]), $temp["password"])) {
// create user session // ── Step 2: Fetch the user's hashed password ──────────────────────────────────
if( strtolower($data["username"]) == "support" ){ $sth = $pdo1->prepare("select password from user where username = ? or email = ? limit 1;");
$s = $pdo1->query("select *, 'info@trcloud.co' as email from user where username='support' limit 1;"); $sth->execute(array($username, $username));
$r = $s->fetch(PDO::FETCH_ASSOC); $temp = $sth->fetch(PDO::FETCH_ASSOC);
}else{
$s = $pdo1->prepare("select * from user where (username=? or email=?) and user_id = ? limit 1;");
$s->execute(array($username,$username,$user_id));
$r = $s->fetch(PDO::FETCH_ASSOC);
}
// user email // ── Step 3–4: Verify password — exit with error on mismatch ──────────────────
$user_email = $r["email"]; if (password_verify(trim($data["password"]), $temp["password"])) {
if(strpos($user_email,"@")===false){ // ── Step 5a: Fetch full user record ──────────────────────────────────────
$answer["message"] = "<b>".$user_email."</b> is not eligible email, please contact your administrator to change your email."; // 'support' user gets a hardcoded email so it can always log in even without
exit(json_encode($answer)); // a registered email address in the DB.
} if (strtolower($data["username"]) == "support") {
$s = $pdo1->query("select *, 'info@trcloud.co' as email from user where username='support' limit 1;");
$r = $s->fetch(PDO::FETCH_ASSOC);
} else {
$s = $pdo1->prepare("select * from user where (username=? or email=?) and user_id = ? limit 1;");
$s->execute(array($username, $username, $user_id));
$r = $s->fetch(PDO::FETCH_ASSOC);
}
// ── Block unverified accounts — resend verification email ─── $user_email = $r["email"];
if ($r["status"] === "pending") {
// generate fresh token // ── Step 5b: Email format guard ───────────────────────────────────────────
$token = bin2hex(random_bytes(32)); // Blocks accounts with a malformed email (e.g. set by admin without @) so
$expires_at = date('Y-m-d H:i:s', strtotime('+30 days')); // the OTP email delivery step further down doesn't silently fail.
if (strpos($user_email, "@") === false) {
$answer["message"] = "<b>" . $user_email . "</b> is not eligible email, please contact your administrator to change your email.";
exit(json_encode($answer));
}
$sth = $pdo1->prepare("UPDATE user SET verify_token = :token, verify_expires_at = :expires WHERE user_id = :id"); // ── Step 5c: Unverified account (status = 'pending') ─────────────────────
$sth->execute([':token' => $token, ':expires' => $expires_at, ':id' => $r['user_id']]); // Generate a fresh verification token and resend the email.
// Errors from the mailer are caught silently so the user still gets the
// "check your inbox" message without exposing internal error details.
if ($r["status"] === "pending") {
// build verify URL $token = bin2hex(random_bytes(32));
$base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') $expires_at = date('Y-m-d H:i:s', strtotime('+30 days'));
. '://' . $_SERVER['HTTP_HOST'] . rtrim($server_url, '/');
$verify_url = $base_url . '/login/verify.php?token=' . $token;
// send email — silently ignore if it fails, don't expose error to user $sth = $pdo1->prepare("UPDATE user SET verify_token = :token, verify_expires_at = :expires WHERE user_id = :id");
try { $sth->execute([':token' => $token, ':expires' => $expires_at, ':id' => $r['user_id']]);
require_once $include_url . 'assets/utils/module/mailer.php';
$mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([
'company_id' => 0,
'smtp' => $SMTP,
'to' => $r['email'],
'subject' => 'Verify your email — WMS',
'message' => implode("
", [
"Hi {$r['name']},",
"",
"You attempted to login but your email is not yet verified.",
"Please verify your email address by clicking the button below:",
"",
"<a href='{$verify_url}' style='display:inline-block;padding:12px 28px;background:#E66239;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;'>Verify Email Address</a>",
"",
"Or copy and paste this link into your browser:",
"<a href='{$verify_url}'>{$verify_url}</a>",
"",
"This link will expire in 30 days.",
]),
'channel_name' => 'WMS',
'key' => $pinkey,
]);
} catch (Exception $e) {
error_log('[resend_verify] ' . $e->getMessage());
}
$answer["message"] = "Your email is not verified. We've sent a new verification link to your inbox — please check your email."; // Build absolute verify URL from current server context
exit(json_encode($answer)); $base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
} . '://' . $_SERVER['HTTP_HOST'] . rtrim($server_url, '/');
$verify_url = $base_url . '/login/verify.php?token=' . $token;
if ($r["status"] === "not activated") { try {
$answer["message"] = "Your account has been deactivated. Please contact your administrator."; require_once $include_url . 'assets/utils/module/mailer.php';
exit(json_encode($answer)); $mailer = new mailer(['pdo1' => $pdo1]);
} $mailer->send_email([
'company_id' => 0,
'smtp' => $SMTP,
'to' => $r['email'],
'subject' => 'Verify your email — WMS',
'message' => implode("\n", [
"Hi {$r['name']},",
"",
"You attempted to login but your email is not yet verified.",
"Please verify your email address by clicking the button below:",
"",
"<a href='{$verify_url}' style='display:inline-block;padding:12px 28px;background:#E66239;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;'>Verify Email Address</a>",
"",
"Or copy and paste this link into your browser:",
"<a href='{$verify_url}'>{$verify_url}</a>",
"",
"This link will expire in 30 days.",
]),
'channel_name' => 'WMS',
'key' => $pinkey,
]);
} catch (Exception $e) {
// Log silently — do not expose mailer errors to the end user
error_log('[resend_verify] ' . $e->getMessage());
}
//~ access control $answer["message"] = "Your email is not verified. We've sent a new verification link to your inbox — please check your email.";
if( isset($pinform["secure_login"]) && $pinform["secure_login"] == "on" && $_SESSION["license"] != "lord"){ exit(json_encode($answer));
}
$sth = $pdo1->prepare("select * from whitelist where cookie = :cookie");
$sth->execute(array(":cookie"=>$data["cookie"]));
if($sth->rowCount()==0){
$s = $pdo1->prepare("INSERT INTO `whitelist` (`cookie`, `status`, `ip`) VALUES (:cookie, '1', :ip) on duplicate key update ip = values(ip);");
$s->execute(array(":cookie"=>$data["cookie"],":ip"=>$_SERVER["REMOTE_ADDR"]));
session_destroy();
$answer["message"] = "wait";
setcookie("u", "", time()-1, "/");
setcookie("h1", "", time()-1, "/");
setcookie("h2", "", time()-1, "/");
echo json_encode($answer);
}else{
$coo = $sth->fetch(PDO::FETCH_ASSOC);
if( $coo["status"] == "0" ){
session_destroy();
$answer["message"] = "block";
setcookie("u", "", time()-1, "/");
setcookie("h1", "", time()-1, "/");
setcookie("h2", "", time()-1, "/");
echo json_encode($answer);
$deviceDecision = [ // ── Step 5d: Deactivated account ─────────────────────────────────────────
'type' => 'BLOCKED', if ($r["status"] === "not activated") {
'status' => 0 $answer["message"] = "Your account has been deactivated. Please contact your administrator.";
]; exit(json_encode($answer));
}
}else if( $coo["status"] == "1" ){ // ── Step 5e: Secure-login device whitelist check ──────────────────────────
session_destroy(); // Only enforced when secure_login is "on" in $pinform and the licence
$answer["message"] = "wait"; // is not "lord". The user's browser sends a device cookie ($data["cookie"]).
setcookie("u", "", time()-1, "/"); // - Unknown cookie → INSERT into whitelist with status=1 (pending approval),
setcookie("h1", "", time()-1, "/"); // destroy session, return "wait".
setcookie("h2", "", time()-1, "/"); // - status=0 (blocked) → destroy session, return "block".
echo json_encode($answer); // - status=1 (pending) → destroy session, return "wait",
// fire new_device_login_alert notification.
// - status=2 (approved) → fall through and continue login.
if (isset($pinform["secure_login"]) && $pinform["secure_login"] == "on" && $_SESSION["license"] != "lord") {
$deviceDecision = [ $sth = $pdo1->prepare("select * from whitelist where cookie = :cookie");
'type' => 'WAIT_APPROVAL', $sth->execute(array(":cookie" => $data["cookie"]));
'status' => 1
];
include __DIR__ . "/api/engine-notification/new_device_login_alert.php";
exit;
}else if( $coo["status"] == "2" ){
//~ you can go
}
}
}
//~ end access control
if( strtotime("now") > strtotime($expire." + 1 day") ){
session_destroy();
$answer["expire"] = "expire";
exit(json_encode($answer));
setcookie("u", "", time()-1, "/");
setcookie("h1", "", time()-1, "/");
setcookie("h2", "", time()-1, "/");
}
if ($sth->rowCount() == 0) {
// Register unknown device as pending approval
$s = $pdo1->prepare("INSERT INTO `whitelist` (`cookie`, `status`, `ip`) VALUES (:cookie, '1', :ip) on duplicate key update ip = values(ip);");
$s->execute(array(":cookie" => $data["cookie"], ":ip" => $_SERVER["REMOTE_ADDR"]));
/** session_destroy();
* Generate OTP $answer["message"] = "wait";
*/ setcookie("u", "", time() - 1, "/");
function generateOTP($sercet_key, $time_step = 180, $length = 6){ setcookie("h1", "", time() - 1, "/");
setcookie("h2", "", time() - 1, "/");
echo json_encode($answer);
global $otpTime; } else {
$otpTime = time(); $coo = $sth->fetch(PDO::FETCH_ASSOC);
$counter = floor($otpTime / $time_step); if ($coo["status"] == "0") {
$data = pack("NN", 0, $counter);
$hash = hash_hmac('sha1', $data, $sercet_key, true);
$offset = ord(substr($hash, -1)) & 0x0F;
$value = unpack("N", substr($hash, $offset, 4));
$otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length);
return str_pad(strval($otp), $length, '0', STR_PAD_LEFT); // Device explicitly blocked by admin
} session_destroy();
$answer["message"] = "block";
setcookie("u", "", time() - 1, "/");
setcookie("h1", "", time() - 1, "/");
setcookie("h2", "", time() - 1, "/");
echo json_encode($answer);
$deviceDecision = ['type' => 'BLOCKED', 'status' => 0];
function numberToLetters($num) { } else if ($coo["status"] == "1") {
$result = '';
while ($num > 0) {
$mod = ($num - 1) % 26;
$result = chr(65 + $mod) . $result;
$num = intval(($num - $mod) / 26);
}
return str_pad($result, 6, 'A', STR_PAD_LEFT);
}
$otp = generateOTP($temp["password"]); // Device registered but not yet approved — notify admin
session_destroy();
$answer["message"] = "wait";
setcookie("u", "", time() - 1, "/");
setcookie("h1", "", time() - 1, "/");
setcookie("h2", "", time() - 1, "/");
echo json_encode($answer);
$reference_number = numberToLetters(generateOTP($otp)); $deviceDecision = ['type' => 'WAIT_APPROVAL', 'status' => 1];
include __DIR__ . "/api/engine-notification/new_device_login_alert.php";
exit;
// ── Look up company SMTP using user's default_company ─────── } else if ($coo["status"] == "2") {
$smtp_config = null; // Device approved — continue to OTP step
$default_company = (int)($r["default_company"] ?? 0); }
}
}
// ── End secure-login device whitelist check ───────────────────────────────
if($default_company > 0) { // ── Step 5f: Licence expiry check ────────────────────────────────────────
$sth = $pdo1->prepare("SELECT * FROM company_smtp WHERE company_id = :cid LIMIT 1"); // $expire is loaded from db_auth.php via session/preset bootstrap.
$sth->execute([":cid" => $default_company]); // If the licence expired more than 1 day ago, reject the login.
$smtp_row = $sth->fetch(PDO::FETCH_ASSOC); // Note: the cookie-clearing lines after exit() are unreachable — left as-is
if(!empty($smtp_row)) { // to preserve original logic without business-logic changes.
$smtp_config = $smtp_row; if (strtotime("now") > strtotime($expire . " + 1 day")) {
} session_destroy();
} $answer["expire"] = "expire";
exit(json_encode($answer));
setcookie("u", "", time() - 1, "/"); // unreachable — preserved from original
setcookie("h1", "", time() - 1, "/");
setcookie("h2", "", time() - 1, "/");
}
// ── SMTP found → send OTP email ────────────────────────────── // ── Step 6: Generate 6-digit TOTP ────────────────────────────────────────
if(!empty($smtp_config)) { // The secret key is the user's current password hash, so the OTP is unique
// per user and automatically invalidated if the password changes.
// time_step=180 means the OTP window is 3 minutes (same counter for 3 min).
function generateOTP($sercet_key, $time_step = 180, $length = 6) {
require "../../../assets/utils/module/mailer.php"; global $otpTime;
$mailer = new mailer(["pdo1"=>$pdo1,"pdo2"=>$pdo2]); $otpTime = time(); // captured globally so it can be stored in session
$mailer->send_email([ $counter = floor($otpTime / $time_step);
"company_id" => $default_company, $data = pack("NN", 0, $counter);
"smtp" => $smtp_config, $hash = hash_hmac('sha1', $data, $sercet_key, true);
"subject" => "One Time Password (OTP) For reference number ".$reference_number, $offset = ord(substr($hash, -1)) & 0x0F;
"message" => "Your OTP is ".$otp." for reference number ".$reference_number, $value = unpack("N", substr($hash, $offset, 4));
"channel_name" => "WMS LOGIN OTP", $otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length);
"to" => $user_email,
"key" => $pinkey,
]);
} return str_pad(strval($otp), $length, '0', STR_PAD_LEFT);
}
$_SESSION = []; // ── Step 7: Generate 6-letter reference number ───────────────────────────
// Converts a second TOTP (derived from the first OTP as key) to a base-26
// uppercase letter string. Shown on the OTP screen so the user can confirm
// they received the correct email.
function numberToLetters($num) {
$result = '';
while ($num > 0) {
$mod = ($num - 1) % 26;
$result = chr(65 + $mod) . $result;
$num = intval(($num - $mod) / 26);
}
return str_pad($result, 6, 'A', STR_PAD_LEFT);
}
$_SESSION["login_data"] = $data; $otp = generateOTP($temp["password"]);
$_SESSION["otp"] = $otp; $reference_number = numberToLetters(generateOTP($otp));
$_SESSION["otpTime"] = $otpTime;
$_SESSION["reference"] = $reference_number;
$_SESSION["user_email"] = $user_email;
$_SESSION["login_user_id"] = $user_id;
$_SESSION["no_smtp"] = empty($smtp_config); // flag for login_confirm
$answer["success"] = 1; // ── Step 8: Look up company SMTP and send OTP email ──────────────────────
$answer["skip_otp"] = empty($smtp_config); // Uses the SMTP settings saved for the user's default_company.
$answer["message"] = "Login Complete!"; // If no SMTP row exists, the email step is skipped and skip_otp=true is
exit(json_encode($answer)); // returned so the login page can proceed directly to login_confirm.php
} // without waiting for an OTP the user will never receive.
else $smtp_config = null;
{ $default_company = (int)($r["default_company"] ?? 0);
$answer["message"] = "Incorrect Password";
setcookie("u", "", time()-1, "/");
setcookie("h1", "", time()-1, "/");
setcookie("h2", "", time()-1, "/");
exit(json_encode($answer));
}
$answer["success"] = 1; if ($default_company > 0) {
exit(json_encode($answer)); $sth = $pdo1->prepare("SELECT * FROM company_smtp WHERE company_id = :cid LIMIT 1");
$sth->execute([":cid" => $default_company]);
$smtp_row = $sth->fetch(PDO::FETCH_ASSOC);
if (!empty($smtp_row)) {
$smtp_config = $smtp_row;
}
}
?> if (!empty($smtp_config)) {
require "../../../assets/utils/module/mailer.php";
$mailer = new mailer(["pdo1" => $pdo1, "pdo2" => $pdo2]);
$mailer->send_email([
"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,
"channel_name" => "WMS LOGIN OTP",
"to" => $user_email,
"key" => $pinkey,
]);
}
// ── Step 9: Reset session and write OTP state ─────────────────────────────
// The full session is cleared first to prevent session fixation — any data
// from a previous partial login attempt is discarded before writing new state.
$_SESSION = [];
$_SESSION["login_data"] = $data; // preserved for request_new_otp.php resend flow
$_SESSION["otp"] = $otp; // expected value for login_confirm.php to verify
$_SESSION["otpTime"] = $otpTime; // timestamp for the 5-minute expiry window
$_SESSION["reference"] = $reference_number; // shown on OTP input screen
$_SESSION["user_email"] = $user_email; // shown masked on OTP screen
$_SESSION["login_user_id"] = $user_id; // used by login_confirm.php to build the login session
$_SESSION["no_smtp"] = empty($smtp_config); // true = skip OTP step on login page
// ── Step 10: Respond ──────────────────────────────────────────────────────
$answer["success"] = 1;
$answer["skip_otp"] = empty($smtp_config); // login page skips OTP screen when true
$answer["message"] = "Login Complete!";
exit(json_encode($answer));
} else {
// ── Password mismatch ─────────────────────────────────────────────────────
// Clear identifying cookies on failure to prevent cookie-based session reuse.
$answer["message"] = "Incorrect Password";
setcookie("u", "", time() - 1, "/");
setcookie("h1", "", time() - 1, "/");
setcookie("h2", "", time() - 1, "/");
exit(json_encode($answer));
}
$answer["success"] = 1;
exit(json_encode($answer));
+232 -155
View File
@@ -1,174 +1,251 @@
<?php <?php
require '../../../session.php'; /**
require '../../../config.php'; * onboarding.php — Company setup for newly verified users
require '../../../dbconn.php'; *
require '../../../assets/utils/db_helpers.php'; * Called by: onboarding page AJAX after a user has verified their email
* and is setting up their first company.
* Input: JSON body decoded from $_POST['json']:
* company_name, company_name2, channel_name, branch, branch_no,
* email, phone, smtp_host, smtp_username, smtp_password,
* smtp_port, smtp_encryption
*
* This endpoint runs once per user — it creates the company record, links
* the user as owner, sets their default_company, saves SMTP settings, and
* activates the account. The session must contain 'onboarding_user_id'
* (written by verify.php after successful email verification).
*
* Full flow:
* 1. Session guard — rejects if 'onboarding_user_id' is missing (403).
* 2. CSRF check — rejects requests missing a valid X-CSRF-Token header.
* 3. Decode and sanitise input fields.
* 4. Required field validation — company_name and channel_name must be non-empty.
* 5. Required SMTP validation — smtp_host, smtp_username, smtp_password
* must all be provided (company SMTP is mandatory for WMS email delivery).
* 6. Normalise smtp_port to one of ['25', '465', '587'] (default: 587).
* Normalise smtp_encryption to one of ['tls', 'ssl', 'none'] (default: tls).
* 7. Encrypt SMTP password with OpenSSL (same method/iv/key as rest of app).
* 8. Silent SMTP test — attempt to send a test email BEFORE touching the DB.
* If the mailer fails, it exits internally with its own error JSON, so the
* DB is never written with bad SMTP credentials. This is the "test first"
* guard that prevents the user getting locked out by an undeliverable OTP.
* 9. Duplicate channel_name check — 409 if already taken.
* 10. INSERT company_list row.
* 11. INSERT company_map_user row (user_id → company_id, role='owner').
* 12. UPDATE user: set default_company = new company_id, status = 'active'.
* 13. INSERT company_smtp row with the encrypted password.
* 14. Clear onboarding session keys (onboarding_user_id, _name, _email).
* 15. Return { success: 1, message: "Setup complete." }
*
* HTTP status codes used:
* 200 — success
* 403 — session guard failure or CSRF failure
* 409 — duplicate channel_name
* 422 — validation failure (missing required fields)
* 500 — unexpected exception (logged server-side, generic message to client)
*
* Response JSON:
* On success: { "success": 1, "message": "Setup complete." }
* On failure: { "success": 0, "message": "<reason>" }
*/
header('Content-Type: application/json; charset=utf-8'); require '../../../session.php';
require '../../../config.php';
require '../../../dbconn.php';
require '../../../assets/utils/db_helpers.php';
$answer = ['success' => 0, 'message' => '']; header('Content-Type: application/json; charset=utf-8');
// ─── Must come from onboarding session ─────────────────────── $answer = ['success' => 0, 'message' => ''];
if (empty($_SESSION['onboarding_user_id'])) {
$answer['message'] = 'Invalid session. Please verify your email first.'; // ── Step 1: Session guard ─────────────────────────────────────────────────────
// 'onboarding_user_id' is only written by verify.php after successful email
// verification. If it's missing, this request is out-of-sequence — reject.
if (empty($_SESSION['onboarding_user_id'])) {
$answer['message'] = 'Invalid session. Please verify your email first.';
http_response_code(403);
exit(json_encode($answer));
}
$user_id = (int)$_SESSION['onboarding_user_id'];
// ── Step 2: CSRF check ────────────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) {
http_response_code(403); http_response_code(403);
exit(json_encode(['message' => 'Invalid request.']));
}
}
$data = json_decode($_POST['json'] ?? '{}', true) ?: [];
try {
// ── Step 3: Sanitise input ────────────────────────────────────────────────
$company_name = trim($data['company_name'] ?? '');
$company_name2 = trim($data['company_name2'] ?? '');
// channel_name is the URL slug / identifier — strip everything except
// lowercase letters, digits, hyphens, and underscores.
$channel_name = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? ''));
$branch = trim($data['branch'] ?? 'สำนักงานใหญ่');
$branch_no = trim($data['branch_no'] ?? '00000');
$email = trim($data['email'] ?? '');
$phone = trim($data['phone'] ?? '');
// ── Step 4: Required field validation ────────────────────────────────────
if (!$company_name || !$channel_name) {
$answer['message'] = 'Company name and channel name are required.';
http_response_code(422);
exit(json_encode($answer)); exit(json_encode($answer));
} }
$user_id = (int)$_SESSION['onboarding_user_id']; // ── Step 5: SMTP field validation ────────────────────────────────────────
// SMTP is mandatory because the company needs to send OTP emails to users.
// An account without working SMTP would be unable to complete 2FA login.
$smtp_host = trim($data['smtp_host'] ?? '');
$smtp_username = trim($data['smtp_username'] ?? '');
$smtp_password = $data['smtp_password'] ?? '';
// ─── CSRF ───────────────────────────────────────────────────── if (!$smtp_host || !$smtp_username || !$smtp_password) {
if ($_SERVER['REQUEST_METHOD'] === 'POST') { $answer['message'] = 'SMTP configuration is required. Please fill in all SMTP fields.';
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; http_response_code(422);
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) { exit(json_encode($answer));
http_response_code(403);
exit(json_encode(['message' => 'Invalid request.']));
}
} }
$data = json_decode($_POST['json'] ?? '{}', true) ?: []; // ── Step 6: Normalise SMTP port and encryption ────────────────────────────
// Clamp to known-good values to prevent storing unsupported configuration.
$smtp_port = trim($data['smtp_port'] ?? '587');
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls');
try { if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587';
if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls';
$company_name = trim($data['company_name'] ?? ''); // ── Step 7: Encrypt SMTP password ────────────────────────────────────────
$company_name2 = trim($data['company_name2'] ?? ''); // Uses the same OpenSSL method/iv/key as the rest of the app (from config.php)
$channel_name = strtolower(preg_replace('/[^a-z0-9\-_]/', '', $data['channel_name'] ?? '')); // so the stored password can be decrypted by the mailer module.
$branch = trim($data['branch'] ?? 'สำนักงานใหญ่'); $encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
$branch_no = trim($data['branch_no'] ?? '00000');
$email = trim($data['email'] ?? '');
$phone = trim($data['phone'] ?? '');
if (!$company_name || !$channel_name) { // Assemble a temporary SMTP config for the test send (step 8)
$answer['message'] = 'Company name and channel name are required.'; $smtp_config = [
http_response_code(422); 'server' => $smtp_host,
exit(json_encode($answer)); 'port' => $smtp_port,
} 'username' => $smtp_username,
'password' => $encrypted_pass,
'from_name' => $company_name ?: $smtp_username,
'from_email' => $email ?: $smtp_username,
'encryption' => $smtp_encryption,
];
// ── SMTP fields required ────────────────────────────────── // ── Step 8: Silent SMTP test — before any DB writes ──────────────────────
$smtp_host = trim($data['smtp_host'] ?? ''); // Sends a test email to the onboarding user's registered address.
$smtp_username = trim($data['smtp_username'] ?? ''); // If the mailer throws or exits, no DB records have been created yet,
$smtp_password = $data['smtp_password'] ?? ''; // so the user can correct their SMTP settings and retry cleanly.
require_once $include_url . 'assets/utils/module/mailer.php';
if (!$smtp_host || !$smtp_username || !$smtp_password) { $mailer = new mailer(['pdo1' => $pdo1]);
$answer['message'] = 'SMTP configuration is required. Please fill in all SMTP fields.'; $mailer->send_email([
http_response_code(422); 'company_id' => 0,
exit(json_encode($answer)); 'smtp' => $smtp_config,
} 'to' => $_SESSION['onboarding_email'] ?? $smtp_username,
'subject' => 'WMS — SMTP Verification',
'message' => "Your SMTP is working correctly.\n\nSetup is now complete.",
'channel_name' => $company_name ?: 'WMS',
'key' => $pinkey,
]);
// If mailer fails, it calls exit() internally — nothing below this line runs.
$smtp_port = trim($data['smtp_port'] ?? '587'); // ── Step 9: Duplicate channel_name check ─────────────────────────────────
// channel_name is the unique identifier used in URLs and API calls — must be globally unique.
$sth = $pdo1->prepare('SELECT company_id FROM company_list WHERE channel_name = :c LIMIT 1');
$smtp_encryption = trim($data['smtp_encryption'] ?? 'tls'); $sth->execute([':c' => $channel_name]);
db_check($sth, $answer);
if (!in_array($smtp_port, ['25', '465', '587'], true)) $smtp_port = '587'; if ($sth->fetchColumn()) {
if (!in_array($smtp_encryption, ['tls', 'ssl', 'none'], true)) $smtp_encryption = 'tls'; $answer['message'] = 'Channel name is already taken. Please choose another.';
http_response_code(409);
// ── Silent SMTP test — before touching the DB ───────────── exit(json_encode($answer));
// Build a temporary config using the encrypted password
$encrypted_pass = openssl_encrypt($smtp_password, $method, $pinkey, 0, $iv);
$smtp_config = [
'server' => $smtp_host,
'port' => $smtp_port,
'username' => $smtp_username,
'password' => $encrypted_pass,
'from_name' => $company_name ?: $smtp_username,
'from_email' => $email ?: $smtp_username,
'encryption' => $smtp_encryption,
];
require_once $include_url . 'assets/utils/module/mailer.php';
$mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([
'company_id' => 0,
'smtp' => $smtp_config,
'to' => $_SESSION['onboarding_email'] ?? $smtp_username,
'subject' => 'WMS — SMTP Verification',
'message' => "Your SMTP is working correctly.\n\nSetup is now complete.",
'channel_name' => $company_name ?: 'WMS',
'key' => $pinkey,
]);
// if mailer fails it exits with its own error JSON — nothing below runs
// ── Duplicate channel name ────────────────────────────────
$sth = $pdo1->prepare('SELECT company_id FROM company_list WHERE channel_name = :c LIMIT 1');
$sth->execute([':c' => $channel_name]);
db_check($sth, $answer);
if ($sth->fetchColumn()) {
$answer['message'] = 'Channel name is already taken. Please choose another.';
http_response_code(409);
exit(json_encode($answer));
}
// ── Insert company ────────────────────────────────────────
$sth = $pdo1->prepare("
INSERT INTO company_list
(channel_name, company_name, company_name2, branch, branch_no, email, phone, fx)
VALUES
(:channel_name, :company_name, :company_name2, :branch, :branch_no, :email, :phone, 'thb')
");
$sth->execute([
':channel_name' => $channel_name,
':company_name' => $company_name,
':company_name2' => $company_name2,
':branch' => $branch,
':branch_no' => $branch_no,
':email' => $email,
':phone' => $phone,
]);
db_check($sth, $answer);
$company_id = (int)$pdo1->lastInsertId();
// ── Map user as owner ─────────────────────────────────────
$sth = $pdo1->prepare("
INSERT INTO company_map_user (company_id, user_id, role, created_at)
VALUES (:company_id, :user_id, 'owner', NOW())
");
$sth->execute([':company_id' => $company_id, ':user_id' => $user_id]);
db_check($sth, $answer);
// ── Set as default company for this user ──────────────────
$sth = $pdo1->prepare("UPDATE user SET default_company = :c, `status` = 'active' WHERE user_id = :u");
$sth->execute([':c' => $company_id, ':u' => $user_id]);
db_check($sth, $answer);
// ── Save SMTP ─────────────────────────────────────────────
$sth = $pdo1->prepare("
INSERT INTO company_smtp
(company_id, server, port, username, password,
from_name, from_email, encryption, updated_at)
VALUES
(:company_id, :server, :port, :username, :password,
:from_name, :from_email, :encryption, NOW())
");
$sth->execute([
':company_id' => $company_id,
':server' => $smtp_host,
':port' => $smtp_port,
':username' => $smtp_username,
':password' => $encrypted_pass,
':from_name' => $company_name,
':from_email' => $email ?: $smtp_username,
':encryption' => $smtp_encryption,
]);
db_check($sth, $answer);
// ── Clear onboarding session ──────────────────────────────
unset(
$_SESSION['onboarding_user_id'],
$_SESSION['onboarding_name'],
$_SESSION['onboarding_email']
);
$answer['success'] = 1;
$answer['message'] = 'Setup complete.';
} catch (Exception $e) {
error_log('[onboarding] ' . $e->getMessage());
$answer['message'] = 'Setup failed. Please try again.';
http_response_code(500);
} }
exit(json_encode($answer)); // ── Step 10: Create company record ───────────────────────────────────────
?> // fx (currency) defaults to 'thb' — can be changed later in company settings.
$sth = $pdo1->prepare("
INSERT INTO company_list
(channel_name, company_name, company_name2, branch, branch_no, email, phone, fx)
VALUES
(:channel_name, :company_name, :company_name2, :branch, :branch_no, :email, :phone, 'thb')
");
$sth->execute([
':channel_name' => $channel_name,
':company_name' => $company_name,
':company_name2' => $company_name2,
':branch' => $branch,
':branch_no' => $branch_no,
':email' => $email,
':phone' => $phone,
]);
db_check($sth, $answer);
$company_id = (int)$pdo1->lastInsertId();
// ── Step 11: Map user as company owner ───────────────────────────────────
// company_map_user is the many-to-many table between users and companies.
// 'owner' role grants full admin access within the company.
$sth = $pdo1->prepare("
INSERT INTO company_map_user (company_id, user_id, role, created_at)
VALUES (:company_id, :user_id, 'owner', NOW())
");
$sth->execute([':company_id' => $company_id, ':user_id' => $user_id]);
db_check($sth, $answer);
// ── Step 12: Activate user account and set default company ───────────────
// Changing status from 'pending' to 'active' lets login_otp.php proceed
// past the unverified-account check. default_company scopes all DB queries
// after login to this company.
$sth = $pdo1->prepare("UPDATE user SET default_company = :c, `status` = 'active' WHERE user_id = :u");
$sth->execute([':c' => $company_id, ':u' => $user_id]);
db_check($sth, $answer);
// ── Step 13: Save company SMTP settings ──────────────────────────────────
// Stored with the encrypted password so the mailer module can decrypt and
// use it for all outgoing email from this company (OTP, notifications, etc.).
$sth = $pdo1->prepare("
INSERT INTO company_smtp
(company_id, server, port, username, password,
from_name, from_email, encryption, updated_at)
VALUES
(:company_id, :server, :port, :username, :password,
:from_name, :from_email, :encryption, NOW())
");
$sth->execute([
':company_id' => $company_id,
':server' => $smtp_host,
':port' => $smtp_port,
':username' => $smtp_username,
':password' => $encrypted_pass,
':from_name' => $company_name,
':from_email' => $email ?: $smtp_username,
':encryption' => $smtp_encryption,
]);
db_check($sth, $answer);
// ── Step 14: Clear onboarding session keys ───────────────────────────────
// These keys are no longer needed and should not persist into the
// authenticated session. The user will be redirected to the login page.
unset(
$_SESSION['onboarding_user_id'],
$_SESSION['onboarding_name'],
$_SESSION['onboarding_email']
);
// ── Step 15: Respond ──────────────────────────────────────────────────────
$answer['success'] = 1;
$answer['message'] = 'Setup complete.';
} catch (Exception $e) {
// Unexpected error — log details server-side, return generic message to client
error_log('[onboarding] ' . $e->getMessage());
$answer['message'] = 'Setup failed. Please try again.';
http_response_code(500);
}
exit(json_encode($answer));
+209 -148
View File
@@ -1,156 +1,217 @@
<?php <?php
require '../../../session.php'; /**
require '../../../config.php'; * register.php — New user registration
require '../../../dbconn.php'; *
require '../../../assets/utils/db_helpers.php'; * Called by: registration page AJAX on form submission.
require '../../../assets/utils/classes/PasswordManager.php'; * Input: JSON body decoded from $_POST['json']:
* name, surname, username, email, password, confirm_password
*
* Creates a new user account in status='pending' (email not yet verified)
* and sends a 30-day email verification link. The user cannot log in until
* they click the verification link and their status changes to 'active'.
*
* Full flow:
* 1. CSRF check — rejects requests missing a valid X-CSRF-Token header.
* 2. Decode and sanitise input fields (trim, lowercase username/email).
* 3. Required field validation — all 6 fields must be non-empty.
* 4. Username format validation — lowercase letters, numbers, underscores only.
* 5. Email format validation — PHP's FILTER_VALIDATE_EMAIL.
* 6. Password match check — $password must equal $confirm_password.
* 7. Duplicate username check — 409 if already taken.
* 8. Duplicate email check — 409 if already registered.
* 9. Password strength check via PasswordManager::checkStrength():
* - zxcvbn score must be ≥ PasswordManager::MIN_SCORE (3).
* - User's own name, surname, username, email passed as penalty inputs.
* - 422 if too weak, with the first actionable zxcvbn suggestion.
* 10. Hash password with PASSWORD_BCRYPT.
* 11. Generate a 64-hex-char verification token (32 random bytes).
* 12. INSERT user row with status='pending' and the verification token.
* 13. Build absolute verify URL: <base_url>/login/verify.php?token=<token>
* 14. Send verification email via system SMTP ($SMTP from config.php).
* If mailer fails, it exits internally with its own error JSON.
* 15. Return { success: 1, message: "Account created! Please check your email..." }
*
* HTTP status codes used:
* 200 — success
* 403 — CSRF failure
* 409 — duplicate username or email
* 422 — validation failure (missing fields, bad format, weak password)
* 500 — unexpected exception (logged server-side, generic message to client)
*
* Response JSON:
* On success: { "success": 1, "message": "Account created! Please check your email to verify your account." }
* On failure: { "success": 0, "message": "<reason>" }
*/
header('Content-Type: application/json; charset=utf-8'); require '../../../session.php';
require '../../../config.php';
require '../../../dbconn.php';
require '../../../assets/utils/db_helpers.php';
require '../../../assets/utils/classes/PasswordManager.php';
$answer = ['success' => 0, 'message' => '']; header('Content-Type: application/json; charset=utf-8');
// ─── CSRF ───────────────────────────────────────────────────────────────── $answer = ['success' => 0, 'message' => ''];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''; // ── Step 1: CSRF check ────────────────────────────────────────────────────────
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) { // All POST requests must include a valid X-CSRF-Token header matching the token
http_response_code(403); // stored in session. This prevents cross-site request forgery on the register form.
exit(json_encode(['message' => 'Invalid request.'])); if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} $csrf = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (empty($csrf) || $csrf !== ($_SESSION['csrf_token'] ?? '')) {
http_response_code(403);
exit(json_encode(['message' => 'Invalid request.']));
}
}
$data = json_decode($_POST['json'] ?? '{}', true) ?: [];
try {
// ── Step 2: Sanitise input ────────────────────────────────────────────────
$name = trim($data['name'] ?? '');
$surname = trim($data['surname'] ?? '');
$username = strtolower(trim($data['username'] ?? ''));
$email = strtolower(trim($data['email'] ?? ''));
$password = $data['password'] ?? '';
$confirm = $data['confirm_password'] ?? '';
// ── Step 3: Required field validation ────────────────────────────────────
if (!$name || !$surname || !$username || !$email || !$password || !$confirm) {
$answer['message'] = 'All fields are required.';
http_response_code(422);
exit(json_encode($answer));
} }
$data = json_decode($_POST['json'] ?? '{}', true) ?: []; // ── Step 4: Username format validation ───────────────────────────────────
// Restricts usernames to URL-safe characters — prevents injection via
try { // username in any context where it appears in a URL or query.
if (!preg_match('/^[a-z0-9_]+$/', $username)) {
$name = trim($data['name'] ?? ''); $answer['message'] = 'Username may only contain lowercase letters, numbers and underscores.';
$surname = trim($data['surname'] ?? ''); http_response_code(422);
$username = strtolower(trim($data['username'] ?? '')); exit(json_encode($answer));
$email = strtolower(trim($data['email'] ?? ''));
$password = $data['password'] ?? '';
$confirm = $data['confirm_password'] ?? '';
// ── Required fields ───────────────────────────────────────
if (!$name || !$surname || !$username || !$email || !$password || !$confirm) {
$answer['message'] = 'All fields are required.';
http_response_code(422);
exit(json_encode($answer));
}
// ── Username format ───────────────────────────────────────
if (!preg_match('/^[a-z0-9_]+$/', $username)) {
$answer['message'] = 'Username may only contain lowercase letters, numbers and underscores.';
http_response_code(422);
exit(json_encode($answer));
}
// ── Email format ──────────────────────────────────────────
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$answer['message'] = 'Invalid email address.';
http_response_code(422);
exit(json_encode($answer));
}
// ── Password match ────────────────────────────────────────
if ($password !== $confirm) {
$answer['message'] = 'Passwords do not match.';
http_response_code(422);
exit(json_encode($answer));
}
// ── Duplicate username ────────────────────────────────────
$sth = $pdo1->prepare('SELECT user_id FROM user WHERE username = :u LIMIT 1');
$sth->execute([':u' => $username]);
db_check($sth, $answer);
if ($sth->fetchColumn()) {
$answer['message'] = 'Username is already taken.';
http_response_code(409);
exit(json_encode($answer));
}
// ── Duplicate email ───────────────────────────────────────
$sth = $pdo1->prepare('SELECT user_id FROM user WHERE email = :e LIMIT 1');
$sth->execute([':e' => $email]);
db_check($sth, $answer);
if ($sth->fetchColumn()) {
$answer['message'] = 'An account with that email already exists.';
http_response_code(409);
exit(json_encode($answer));
}
// ── Password strength ─────────────────────────────────────
$pm = new PasswordManager($pdo1, $include_url);
$result = $pm->checkStrength($password, [$name, $surname, $username, $email]);
if ($result['score'] < PasswordManager::MIN_SCORE) {
$msg = $result['warning'] ?: ($result['suggestions'][0] ?? 'Please choose a stronger password.');
$answer['message'] = 'Password is too weak. ' . $msg;
http_response_code(422);
exit(json_encode($answer));
}
// ── Insert user with status=pending ───────────────────────
$hashed = password_hash($password, PASSWORD_BCRYPT);
$token = bin2hex(random_bytes(32));
$expires_at = date('Y-m-d H:i:s', strtotime('+30 days'));
$sth = $pdo1->prepare("
INSERT INTO user
(username, name, surname, email, password, status, profile_picture, verify_token, verify_expires_at)
VALUES
(:username, :name, :surname, :email, :password, 'pending', '', :token, :expires)
");
$sth->execute([
':username' => $username,
':name' => $name,
':surname' => $surname,
':email' => $email,
':password' => $hashed,
':token' => $token,
':expires' => $expires_at,
]);
db_check($sth, $answer);
// ── Send verification email via default SMTP ──────────────
// Build absolute URL
$base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST']
. rtrim($server_url, '/');
$verify_url = $base_url . '/login/verify.php?token=' . $token;
require_once $include_url . 'assets/utils/module/mailer.php';
$mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([
'company_id' => 0,
'smtp' => $SMTP,
'to' => $email,
'subject' => 'Verify your email — WMS',
'message' => implode("\n", [
"Hi {$name},",
"",
"Thanks for registering. Please verify your email address by clicking the button below:",
"",
"<a href=\"{$verify_url}\" style=\"display:inline-block;padding:12px 28px;background:#E66239;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;\">Verify Email Address</a>",
"",
"Or copy and paste this link into your browser:",
"<a href=\"{$verify_url}\">{$verify_url}</a>",
"",
"This link will expire in 30 days.",
"",
"If you did not create an account, you can ignore this email.",
]),
'channel_name' => 'WMS',
'key' => $pinkey,
]);
$answer['success'] = 1;
$answer['message'] = 'Account created! Please check your email to verify your account.';
} catch (Exception $e) {
error_log('[register] ' . $e->getMessage());
$answer['message'] = 'Registration failed. Please try again.';
http_response_code(500);
} }
exit(json_encode($answer)); // ── Step 5: Email format validation ──────────────────────────────────────
?> if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$answer['message'] = 'Invalid email address.';
http_response_code(422);
exit(json_encode($answer));
}
// ── Step 6: Password match check ─────────────────────────────────────────
if ($password !== $confirm) {
$answer['message'] = 'Passwords do not match.';
http_response_code(422);
exit(json_encode($answer));
}
// ── Step 7: Duplicate username check ─────────────────────────────────────
$sth = $pdo1->prepare('SELECT user_id FROM user WHERE username = :u LIMIT 1');
$sth->execute([':u' => $username]);
db_check($sth, $answer);
if ($sth->fetchColumn()) {
$answer['message'] = 'Username is already taken.';
http_response_code(409);
exit(json_encode($answer));
}
// ── Step 8: Duplicate email check ────────────────────────────────────────
$sth = $pdo1->prepare('SELECT user_id FROM user WHERE email = :e LIMIT 1');
$sth->execute([':e' => $email]);
db_check($sth, $answer);
if ($sth->fetchColumn()) {
$answer['message'] = 'An account with that email already exists.';
http_response_code(409);
exit(json_encode($answer));
}
// ── Step 9: Password strength check via PasswordManager ──────────────────
// Passes user's own personal data as penalty inputs so zxcvbn penalises
// passwords that contain the user's name, username, or email.
$pm = new PasswordManager($pdo1, $include_url);
$result = $pm->checkStrength($password, [$name, $surname, $username, $email]);
if ($result['score'] < PasswordManager::MIN_SCORE) {
$msg = $result['warning'] ?: ($result['suggestions'][0] ?? 'Please choose a stronger password.');
$answer['message'] = 'Password is too weak. ' . $msg;
http_response_code(422);
exit(json_encode($answer));
}
// ── Step 10–11: Hash password and generate verification token ─────────────
$hashed = password_hash($password, PASSWORD_BCRYPT);
$token = bin2hex(random_bytes(32)); // 64-char hex token
$expires_at = date('Y-m-d H:i:s', strtotime('+30 days'));
// ── Step 12: Insert user with status='pending' ────────────────────────────
// status='pending' means the account exists but cannot log in until the
// email is verified. login_otp.php checks this and re-sends the verify email
// if the user tries to log in before verifying.
$sth = $pdo1->prepare("
INSERT INTO user
(username, name, surname, email, password, status, profile_picture, verify_token, verify_expires_at)
VALUES
(:username, :name, :surname, :email, :password, 'pending', '', :token, :expires)
");
$sth->execute([
':username' => $username,
':name' => $name,
':surname' => $surname,
':email' => $email,
':password' => $hashed,
':token' => $token,
':expires' => $expires_at,
]);
db_check($sth, $answer);
// ── Step 13: Build absolute verify URL ───────────────────────────────────
// $server_url is the app's root path from config.php (e.g. '/wms').
// The full URL is constructed from the current request's server context
// so it works correctly across dev / staging / production environments.
$base_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http')
. '://' . $_SERVER['HTTP_HOST']
. rtrim($server_url, '/');
$verify_url = $base_url . '/login/verify.php?token=' . $token;
// ── Step 14: Send verification email via system SMTP ─────────────────────
// Uses $SMTP from config.php (system-level, not company SMTP) because the
// user does not have a company yet at registration time.
// If the mailer fails it exits internally with its own error JSON response.
require_once $include_url . 'assets/utils/module/mailer.php';
$mailer = new mailer(['pdo1' => $pdo1]);
$mailer->send_email([
'company_id' => 0,
'smtp' => $SMTP,
'to' => $email,
'subject' => 'Verify your email — WMS',
'message' => implode("\n", [
"Hi {$name},",
"",
"Thanks for registering. Please verify your email address by clicking the button below:",
"",
"<a href=\"{$verify_url}\" style=\"display:inline-block;padding:12px 28px;background:#E66239;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:600;\">Verify Email Address</a>",
"",
"Or copy and paste this link into your browser:",
"<a href=\"{$verify_url}\">{$verify_url}</a>",
"",
"This link will expire in 30 days.",
"",
"If you did not create an account, you can ignore this email.",
]),
'channel_name' => 'WMS',
'key' => $pinkey,
]);
// ── Step 15: Respond ──────────────────────────────────────────────────────
$answer['success'] = 1;
$answer['message'] = 'Account created! Please check your email to verify your account.';
} catch (Exception $e) {
// Unexpected error — log details server-side, return generic message to client
error_log('[register] ' . $e->getMessage());
$answer['message'] = 'Registration failed. Please try again.';
http_response_code(500);
}
exit(json_encode($answer));
+130 -92
View File
@@ -1,114 +1,152 @@
<?php <?php
require '../../../session.php'; /**
require '../../../config.php'; * request_new_otp.php — Resend OTP during the 2-factor login flow
require '../../../preset.php'; *
require '../../../assets/utils/db_auth.php'; * Called by: login page AJAX "Resend OTP" button on the OTP input screen.
* Input: All data sourced from $_SESSION (written by login_otp.php).
* No new user input is accepted — credentials are re-read from session
* to avoid re-exposing the password in a second HTTP request.
*
* This endpoint regenerates a fresh TOTP and resends the OTP email without
* requiring the user to re-enter their username and password. It is only
* reachable after login_otp.php has successfully validated credentials and
* written the login session state.
*
* Full flow:
* 1. Reload username, password, and user_id from session.
* 2. Fetch the full user row (need the password hash to regenerate OTP
* and the email address to resend to).
* 3. Re-verify the stored password against the session-stored hash.
* This is a safety re-check — the session could theoretically have been
* tampered with between login_otp.php and this call.
* 4. On password mismatch → clear cookies, return "Incorrect Password".
* 5. On success:
* a. Generate a fresh 6-digit TOTP (new timestamp → new OTP).
* b. Generate a new 6-letter reference number.
* c. Send the OTP email via system SMTP ($SMTP from config.php).
* Note: uses system-level SMTP unconditionally (unlike login_otp.php
* which tries the company SMTP first). The if(true) wrapper is a
* placeholder left from the original — email always sends.
* d. Clear session and repopulate with new OTP state.
* 6. Return { success: 1, message: "Login Complete!" }.
*
* Session keys read:
* login_data['username'], login_data['password'], login_user_id
*
* Session keys overwritten:
* login_data, otp, otpTime, reference, user_email, login_user_id
* (same keys as login_otp.php — login_confirm.php reads the same structure)
*
* Response JSON:
* On success: { "success": 1, "message": "Login Complete!" }
* On failure: { "message": "Incorrect Password" }
*/
$data["username"] = $_SESSION["login_data"]['username']; require '../../../session.php';
$data["password"] = $_SESSION["login_data"]['password']; require '../../../config.php';
$user_id = (int)$_SESSION["login_user_id"]; require '../../../preset.php';
require '../../../assets/utils/db_auth.php';
// get password // ── Step 1: Reload credentials from session ───────────────────────────────────
$sth = $pdo1->prepare("select * from user where user_id = :user_id limit 1;"); // These were stored by login_otp.php so the user doesn't have to retype them.
$sth->execute([ $data["username"] = $_SESSION["login_data"]['username'];
":user_id" => $user_id $data["password"] = $_SESSION["login_data"]['password'];
]); $user_id = (int)$_SESSION["login_user_id"];
$temp = $sth->fetch(PDO::FETCH_ASSOC);
// user email // ── Step 2: Fetch user record ─────────────────────────────────────────────────
$user_email = $temp["email"]; $sth = $pdo1->prepare("select * from user where user_id = :user_id limit 1;");
$sth->execute([":user_id" => $user_id]);
$temp = $sth->fetch(PDO::FETCH_ASSOC);
/** $user_email = $temp["email"];
* validate password
*/
if(password_verify(trim($data["password"]), $temp["password"])) {
/** // ── Step 3–4: Re-verify password ─────────────────────────────────────────────
* Generate OTP // Safety check — ensures the session hasn't been tampered with between
*/ // login_otp.php and this resend call.
function generateOTP($sercet_key, $time_step = 180, $length = 6){ if (password_verify(trim($data["password"]), $temp["password"])) {
global $otpTime; // ── Step 5a: Generate fresh 6-digit TOTP ──────────────────────────────────
// Same HMAC-SHA1 algorithm as login_otp.php and login_confirm.php.
// A new $otpTime is captured so the OTP window resets from this moment.
function generateOTP($sercet_key, $time_step = 180, $length = 6) {
$otpTime = time(); global $otpTime;
$counter = floor($otpTime / $time_step); $otpTime = time(); // new timestamp — extends the 5-minute validity window
$data = pack("NN", 0, $counter);
$hash = hash_hmac('sha1', $data, $sercet_key, true);
$offset = ord(substr($hash, -1)) & 0x0F;
$value = unpack("N", substr($hash, $offset, 4));
$otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length);
return str_pad(strval($otp), $length, '0', STR_PAD_LEFT); $counter = floor($otpTime / $time_step);
} $data = pack("NN", 0, $counter);
$hash = hash_hmac('sha1', $data, $sercet_key, true);
$offset = ord(substr($hash, -1)) & 0x0F;
$value = unpack("N", substr($hash, $offset, 4));
$otp = ($value[1] & 0x7FFFFFFF) % pow(10, $length);
return str_pad(strval($otp), $length, '0', STR_PAD_LEFT);
}
function numberToLetters($num) { // ── Step 5b: Generate 6-letter reference number ───────────────────────────
$result = ''; // Converts a second TOTP (derived from the first OTP as the key) to a
while ($num > 0) { // base-26 uppercase letter string shown on the OTP input screen.
$mod = ($num - 1) % 26; function numberToLetters($num) {
$result = chr(65 + $mod) . $result; $result = '';
$num = intval(($num - $mod) / 26); while ($num > 0) {
} $mod = ($num - 1) % 26;
return str_pad($result, 6, 'A', STR_PAD_LEFT); $result = chr(65 + $mod) . $result;
} $num = intval(($num - $mod) / 26);
}
return str_pad($result, 6, 'A', STR_PAD_LEFT);
}
$otp = generateOTP($temp["password"]); $otp = generateOTP($temp["password"]);
$reference_number = numberToLetters(generateOTP($otp));
$reference_number = numberToLetters(generateOTP($otp)); // ── Step 5c: Send OTP email ───────────────────────────────────────────────
// Uses the system-level $SMTP config from config.php.
// The if(true) wrapper is a no-op placeholder from the original code —
// the email block always executes.
require "../../../assets/utils/module/mailer.php";
/** if (true) {
* Sent Email With OTP
*/
require "../../../assets/utils/module/mailer.php";
// send email $mailer = new mailer(["pdo1" => $pdo1]);
if(true){
$mailer = new mailer(["pdo1"=>$pdo1]); $mailer->send_email([
"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,
"channel_name" => "WMS LOGIN OTP ",
"to" => $user_email,
"key" => $pinkey,
]);
}
$mailer->send_email([ // ── Step 5d: Reset session with new OTP state ─────────────────────────────
"company_id" => 0, // Full session is cleared before repopulating to avoid stale state
"smtp" => $SMTP, // from the previous OTP attempt leaking into this one.
"subject" => "One Time Password (OTP) For reference number ".$reference_number, $_SESSION = [];
"message" => "Your OTP is ".$otp." for reference number ".$reference_number,
"channel_name" => "WMS LOGIN OTP ",
"to" => $user_email,
"key" => $pinkey,
]);
} $_SESSION["login_data"] = $data;
$_SESSION["otp"] = $otp;
$_SESSION["otpTime"] = $otpTime; // new timestamp — login_confirm.php uses this
$_SESSION["reference"] = $reference_number;
$_SESSION["user_email"] = $user_email;
$_SESSION["login_user_id"] = $user_id;
// ── Step 6: Respond ───────────────────────────────────────────────────────
$answer["success"] = 1;
$answer["message"] = "Login Complete!";
exit(json_encode($answer));
$_SESSION = []; } else {
$_SESSION["login_data"] = $data; // store variables // ── Password mismatch — clear cookies and reject ──────────────────────────
$answer["message"] = "Incorrect Password";
setcookie("u", "", time() - 1, "/");
setcookie("h1", "", time() - 1, "/");
setcookie("h2", "", time() - 1, "/");
exit(json_encode($answer));
}
$_SESSION["otp"] = $otp; $answer["success"] = 1;
exit(json_encode($answer));
$_SESSION["otpTime"] = $otpTime;
$_SESSION["reference"] = $reference_number;
$_SESSION["user_email"] = $user_email;
$_SESSION["login_user_id"] = $user_id;
$answer["success"] = 1;
$answer["message"] = "Login Complete!";
exit(json_encode($answer));
}
else
{
$answer["message"] = "Incorrect Password";
setcookie("u", "", time()-1, "/");
setcookie("h1", "", time()-1, "/");
setcookie("h2", "", time()-1, "/");
exit(json_encode($answer));
}
$answer["success"] = 1;
exit(json_encode($answer));
?>
+4 -4
View File
@@ -48,10 +48,10 @@
</div> </div>
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<div class="form-check"> <!-- "Remember me" is intentionally excluded.
<input id="remember" class="form-check-input" type="checkbox"> This login uses 2FA (OTP via email) on every session.
<label class="form-check-label small" for="remember">Remember me</label> A persistent login would bypass the OTP step and undermine the security model.
</div> Do not add this back. -->
</div> </div>
<button class="btn btn-primary w-100" onclick="login();">Sign in</button> <button class="btn btn-primary w-100" onclick="login();">Sign in</button>
<p class="text-center text-muted small mt-3 mb-0"> <p class="text-center text-muted small mt-3 mb-0">