Harden web root, secrets and realtime auth
- .htaccess: refuse .git, .env, docker, nodejs, sdlc*, scripts, archives and app internals; security headers, HSTS over TLS, optional HTTPS redirect - uploads served through app/file.php to signed-in users only - Apache/PHP hardening config for the container (ServerTokens, expose_php) - least-privilege DB account and APP_SECRET_KEY via docker/php/provision.php; SMTP passwords re-encrypted with a random IV (secret_box.php) - Socket.IO rooms from a PHP-signed token; /emit and cron refuse empty secrets - escape notification text; CLI guards on build scripts; no fixed demo password
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
# wms-app — web server rules for the repository root.
|
||||||
|
#
|
||||||
|
# The whole repository sits under the web root (/wms-app/), so everything that is
|
||||||
|
# not part of the running app must be refused here: git history, .env files,
|
||||||
|
# deployment and build folders, SDLC documents, the Node server source, CLI-only
|
||||||
|
# PHP scripts and library internals. Needs AllowOverride All (docker/php/apache-wms.conf
|
||||||
|
# enables it for the container) plus mod_rewrite and mod_headers.
|
||||||
|
|
||||||
|
Options -Indexes
|
||||||
|
|
||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# HTTP → HTTPS when the deployment says TLS is available (FORCE_HTTPS=true in the
|
||||||
|
# environment). Honours X-Forwarded-Proto so it also works behind a TLS proxy.
|
||||||
|
RewriteCond %{ENV:FORCE_HTTPS} ^true$
|
||||||
|
RewriteCond %{HTTPS} !=on
|
||||||
|
RewriteCond %{HTTP:X-Forwarded-Proto} !=https
|
||||||
|
RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
||||||
|
|
||||||
|
# Dotfiles and dot-folders anywhere: .git, .env, .claude, .htaccess, .mcp.json …
|
||||||
|
RewriteRule (^|/)\. - [R=404,L]
|
||||||
|
|
||||||
|
# Folders that are never served.
|
||||||
|
RewriteRule ^(nodejs|docker|sdlc|sdlc-delivery|scripts|lib|notes|docs|vendor|node_modules)(/|$) - [R=404,L]
|
||||||
|
|
||||||
|
# Repository files at the root: build/deploy config, CLI scripts, archives, docs.
|
||||||
|
RewriteRule ^(composer\.(json|lock)|docker-compose\.ya?ml|setup\.php|demo_seed[^/]*\.php)$ - [R=404,L]
|
||||||
|
RewriteRule \.(zip|tar|gz|tgz|sql|sh|md|log|bak|old|orig|swp|dist|example|ini|yml|yaml|lock|env|pem|key|crt|map)$ - [R=404,L]
|
||||||
|
|
||||||
|
# App internals included by the entry points, never requested directly: config,
|
||||||
|
# DB connection, shared utilities, manager classes, bundled libraries (PHPMailer
|
||||||
|
# ships get_oauth_token.php), and the page fragments.
|
||||||
|
RewriteRule ^app/(config[^/]*\.php|dbconn\.php|preset\.php)$ - [R=404,L]
|
||||||
|
RewriteRule ^app/assets/utils/ - [R=404,L]
|
||||||
|
RewriteRule ^app/include_[^/]+\.php$ - [R=404,L]
|
||||||
|
|
||||||
|
# Uploaded files are served through a PHP gate that requires a signed-in session.
|
||||||
|
RewriteRule ^app/uploads/(.+)$ app/file.php?path=$1 [L,QSA,B]
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
# Sent on every response (pages, API JSON, static files). Pages add a
|
||||||
|
# Content-Security-Policy of their own from include_header.php.
|
||||||
|
Header always set X-Content-Type-Options "nosniff"
|
||||||
|
Header always set X-Frame-Options "SAMEORIGIN"
|
||||||
|
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
Header always set Permissions-Policy "geolocation=(), microphone=(), payment=(), usb=()"
|
||||||
|
Header always unset X-Powered-By
|
||||||
|
Header unset X-Powered-By
|
||||||
|
# HSTS only means anything over HTTPS; browsers ignore it on plain HTTP.
|
||||||
|
Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" "expr=%{HTTPS} == 'on' || %{HTTP:X-Forwarded-Proto} == 'https'"
|
||||||
|
</IfModule>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once __DIR__ . '/../module/mailer.php';
|
require_once __DIR__ . '/../module/mailer.php';
|
||||||
|
require_once __DIR__ . '/../secret_box.php';
|
||||||
|
|
||||||
class SmtpManager
|
class SmtpManager
|
||||||
{
|
{
|
||||||
@@ -173,7 +174,7 @@ class SmtpManager
|
|||||||
|
|
||||||
private function encryptPassword(string $plain): string
|
private function encryptPassword(string $plain): string
|
||||||
{
|
{
|
||||||
return openssl_encrypt($plain, $this->method, $this->pinkey, 0, $this->iv);
|
return secret_encrypt($plain, $this->pinkey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ header('Content-Type: application/json; charset=utf-8');
|
|||||||
require_once __DIR__ . '/../../config.php';
|
require_once __DIR__ . '/../../config.php';
|
||||||
require_once __DIR__ . '/../../dbconn.php';
|
require_once __DIR__ . '/../../dbconn.php';
|
||||||
|
|
||||||
$secret = $_SERVER['HTTP_X_CRON_SECRET'] ?? '';
|
// An empty configured secret must never match an empty header.
|
||||||
if (!defined('NODE_EMIT_SECRET') || $secret !== NODE_EMIT_SECRET) {
|
$secret = (string)($_SERVER['HTTP_X_CRON_SECRET'] ?? '');
|
||||||
|
if (!defined('NODE_EMIT_SECRET') || NODE_EMIT_SECRET === '' || !hash_equals((string)NODE_EMIT_SECRET, $secret)) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
exit(json_encode(['success' => 0, 'message' => 'Forbidden']));
|
exit(json_encode(['success' => 0, 'message' => 'Forbidden']));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ class mailer{
|
|||||||
"input" => $input
|
"input" => $input
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return openssl_decrypt(trim($input["data"]), "AES-256-CBC", $input["key"], 0, "1234567890123456" );
|
require_once __DIR__ . '/../secret_box.php';
|
||||||
|
return secret_decrypt((string)$input["data"], (string)$input["key"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* secret_box.php — reversible encryption for stored credentials (SMTP passwords).
|
||||||
|
*
|
||||||
|
* Format "v2:<base64(iv . ciphertext)>": AES-256-CBC with a random IV per value and
|
||||||
|
* a key derived from APP_SECRET_KEY (config.php, from the deployment environment).
|
||||||
|
*
|
||||||
|
* Older values were encrypted with the fixed key $pinkey ("wms") and a constant IV,
|
||||||
|
* which anyone reading the source can undo. secret_decrypt() still reads that legacy
|
||||||
|
* format so existing rows keep working; setup.php re-encrypts them to v2 and every
|
||||||
|
* save writes v2. Without APP_SECRET_KEY the legacy format is written (and logged)
|
||||||
|
* so a deployment that has not set the key yet keeps sending mail.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SECRET_BOX_LEGACY_IV = '1234567890123456';
|
||||||
|
|
||||||
|
function secret_box_key(): ?string {
|
||||||
|
if (!defined('APP_SECRET_KEY') || APP_SECRET_KEY === '') return null;
|
||||||
|
return hash('sha256', APP_SECRET_KEY, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function secret_encrypt(string $plain, string $legacy_key = 'wms'): string {
|
||||||
|
$key = secret_box_key();
|
||||||
|
if ($key === null) {
|
||||||
|
error_log('[secret_box] APP_SECRET_KEY is not set; storing a credential in the legacy format.');
|
||||||
|
return openssl_encrypt($plain, 'AES-256-CBC', $legacy_key, 0, SECRET_BOX_LEGACY_IV);
|
||||||
|
}
|
||||||
|
$iv = random_bytes(16);
|
||||||
|
$ct = openssl_encrypt($plain, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
return 'v2:' . base64_encode($iv . $ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the plain text, or false when the value cannot be decrypted. */
|
||||||
|
function secret_decrypt(string $stored, string $legacy_key = 'wms') {
|
||||||
|
$stored = trim($stored);
|
||||||
|
if (strncmp($stored, 'v2:', 3) === 0) {
|
||||||
|
$key = secret_box_key();
|
||||||
|
$raw = base64_decode(substr($stored, 3), true);
|
||||||
|
if ($key === null || $raw === false || strlen($raw) <= 16) return false;
|
||||||
|
return openssl_decrypt(substr($raw, 16), 'AES-256-CBC', $key, OPENSSL_RAW_DATA, substr($raw, 0, 16));
|
||||||
|
}
|
||||||
|
return openssl_decrypt($stored, 'AES-256-CBC', $legacy_key, 0, SECRET_BOX_LEGACY_IV);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a stored value still uses the legacy fixed-key format. */
|
||||||
|
function secret_is_legacy(string $stored): bool {
|
||||||
|
return $stored !== '' && strncmp(trim($stored), 'v2:', 3) !== 0;
|
||||||
|
}
|
||||||
+11
-3
@@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
//<><><><><><><><> MAIN CONFIG <><><><><><><><>//
|
//<><><><><><><><> MAIN CONFIG <><><><><><><><>//
|
||||||
if(true){
|
if(true){
|
||||||
$isTest = "master";
|
|
||||||
$base_url = "/your-app-folder/"; // e.g. "/wms-app/" — folder name under web root
|
$base_url = "/your-app-folder/"; // e.g. "/wms-app/" — folder name under web root
|
||||||
$server_url = $base_url."app/";
|
$server_url = $base_url."app/";
|
||||||
$include_url = $_SERVER['DOCUMENT_ROOT'].$server_url;
|
$include_url = $_SERVER['DOCUMENT_ROOT'].$server_url;
|
||||||
|
|
||||||
$db_server = "localhost";
|
$db_server = "localhost";
|
||||||
$db_user = "root";
|
// Use a dedicated account with only SELECT, INSERT, UPDATE, DELETE, CREATE,
|
||||||
|
// INDEX, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE on wms and wms2 —
|
||||||
|
// never root (docker/php/provision.php shows the grants).
|
||||||
|
$db_user = "wms_app";
|
||||||
$db_pass = "YOUR_DB_PASSWORD";
|
$db_pass = "YOUR_DB_PASSWORD";
|
||||||
$db_type = "mysql";
|
$db_type = "mysql";
|
||||||
$db_database = "wms";
|
$db_database = "wms";
|
||||||
@@ -69,7 +71,13 @@ $packages = [
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// unique key — used for SMTP password encryption, keep consistent across deploys
|
// Key for stored SMTP passwords (assets/utils/secret_box.php): a long random
|
||||||
|
// string, e.g. `openssl rand -hex 32`. Keep it stable across deploys — changing it
|
||||||
|
// makes saved SMTP passwords unreadable. Never commit the real value.
|
||||||
|
if (!defined('APP_SECRET_KEY')) {
|
||||||
|
define('APP_SECRET_KEY', 'YOUR_APP_SECRET_KEY');
|
||||||
|
}
|
||||||
|
// Legacy fixed key: only used to read SMTP passwords saved before APP_SECRET_KEY.
|
||||||
$pinkey = "wms";
|
$pinkey = "wms";
|
||||||
|
|
||||||
$SMTP = [];
|
$SMTP = [];
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* file.php — serves files from app/uploads/ to signed-in users only.
|
||||||
|
*
|
||||||
|
* The root .htaccess rewrites every /app/uploads/<path> request here, so the
|
||||||
|
* existing <img src=".../uploads/profile/x.png"> URLs keep working but an
|
||||||
|
* anonymous visitor gets 401 instead of the file. Only the upload types that
|
||||||
|
* FileUploader accepts are served, and never anything that could execute.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/session.php';
|
||||||
|
|
||||||
|
if (empty($_SESSION['login_company_id'])) {
|
||||||
|
http_response_code(401);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
// The session is only read from here on; release its lock so pages that load
|
||||||
|
// many images do not queue behind each other.
|
||||||
|
session_write_close();
|
||||||
|
|
||||||
|
$types = [
|
||||||
|
'jpg' => 'image/jpeg',
|
||||||
|
'jpeg' => 'image/jpeg',
|
||||||
|
'png' => 'image/png',
|
||||||
|
'gif' => 'image/gif',
|
||||||
|
'webp' => 'image/webp',
|
||||||
|
'pdf' => 'application/pdf',
|
||||||
|
];
|
||||||
|
|
||||||
|
$base = realpath(__DIR__ . '/uploads');
|
||||||
|
$rel = (string)($_GET['path'] ?? '');
|
||||||
|
$file = $base ? realpath($base . '/' . $rel) : false;
|
||||||
|
|
||||||
|
// realpath() resolves ../ and symlinks; anything outside uploads/ is refused.
|
||||||
|
if ($base === false || $file === false || !is_file($file) || strpos($file, $base . DIRECTORY_SEPARATOR) !== 0) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION));
|
||||||
|
if (!isset($types[$ext])) {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (ob_get_level() > 0) {
|
||||||
|
ob_end_clean();
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Content-Type: ' . $types[$ext]);
|
||||||
|
header('Content-Length: ' . filesize($file));
|
||||||
|
header('Content-Disposition: ' . ($ext === 'pdf' ? 'attachment' : 'inline') . '; filename="' . basename($file) . '"');
|
||||||
|
header('Cache-Control: private, max-age=3600');
|
||||||
|
header("Content-Security-Policy: default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox");
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
readfile($file);
|
||||||
+29
-5
@@ -7,17 +7,34 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
// Signed socket token: the Node server joins rooms from these claims only, so a
|
||||||
|
// browser cannot pick another company's room. Must match verifySocketToken() in
|
||||||
|
// nodejs/server.js (key derived from NODE_EMIT_SECRET, HMAC-SHA256, base64url).
|
||||||
|
$_socket_token = '';
|
||||||
|
if (defined('NODE_EMIT_SECRET') && NODE_EMIT_SECRET !== '' && !empty($_SESSION['login_company_id'])) {
|
||||||
|
$_b64url = fn ($s) => rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
|
||||||
|
$_payload = $_b64url(json_encode([
|
||||||
|
'c' => (int)$_SESSION['login_company_id'],
|
||||||
|
'u' => (int)($_SESSION['login_user_id'] ?? 0),
|
||||||
|
'r' => (string)($_SESSION['login_role'] ?? 'viewer'),
|
||||||
|
'exp' => time() + 8 * 3600,
|
||||||
|
]));
|
||||||
|
$_socket_key = hash_hmac('sha256', 'socket-token', NODE_EMIT_SECRET, true);
|
||||||
|
$_socket_token = $_payload . '.' . $_b64url(hash_hmac('sha256', $_payload, $_socket_key, true));
|
||||||
|
}
|
||||||
|
?>
|
||||||
<!-- ── Real-time WebSocket connection ──────────────────────────────────────── -->
|
<!-- ── Real-time WebSocket connection ──────────────────────────────────────── -->
|
||||||
<!-- Socket.io client is served by the Node.js server itself -->
|
<!-- Socket.io client is served by the Node.js server itself -->
|
||||||
<script src="<?php echo NODE_PUBLIC_URL; ?>/socket.io/socket.io.js"></script>
|
<script src="<?php echo htmlspecialchars(NODE_PUBLIC_URL, ENT_QUOTES, 'UTF-8'); ?>/socket.io/socket.io.js"></script>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
// company_id is set in include_topbar.php as a JS global
|
// company_id is set in include_topbar.php as a JS global
|
||||||
if (typeof company_id === 'undefined' || !company_id) return;
|
if (typeof company_id === 'undefined' || !company_id) return;
|
||||||
if (typeof io === 'undefined') return;
|
if (typeof io === 'undefined') return;
|
||||||
|
|
||||||
window._socket = io('<?php echo NODE_PUBLIC_URL; ?>', {
|
window._socket = io(<?php echo json_encode(NODE_PUBLIC_URL, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES); ?>, {
|
||||||
query: { company_id: company_id, user_id: user_id, role: user_role },
|
auth: { token: <?php echo json_encode($_socket_token, JSON_HEX_TAG); ?> },
|
||||||
reconnection: true,
|
reconnection: true,
|
||||||
reconnectionDelay: 2000,
|
reconnectionDelay: 2000,
|
||||||
});
|
});
|
||||||
@@ -57,6 +74,13 @@ function show_toast(title, message, type) {
|
|||||||
};
|
};
|
||||||
var icon = icon_map[type] || icon_map.info;
|
var icon = icon_map[type] || icon_map.info;
|
||||||
|
|
||||||
|
// Notification text is data, never markup.
|
||||||
|
var esc = function (v) {
|
||||||
|
return String(v).replace(/[&<>"']/g, function (c) {
|
||||||
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
var container = document.getElementById('_toast_container');
|
var container = document.getElementById('_toast_container');
|
||||||
if (!container) {
|
if (!container) {
|
||||||
container = document.createElement('div');
|
container = document.createElement('div');
|
||||||
@@ -77,8 +101,8 @@ function show_toast(title, message, type) {
|
|||||||
' <div class="toast-body d-flex align-items-start gap-2">',
|
' <div class="toast-body d-flex align-items-start gap-2">',
|
||||||
' <i class="ti ' + icon + ' fs-5 mt-1 flex-shrink-0"></i>',
|
' <i class="ti ' + icon + ' fs-5 mt-1 flex-shrink-0"></i>',
|
||||||
' <div>',
|
' <div>',
|
||||||
(title ? '<div class="fw-semibold lh-sm">' + title + '</div>' : ''),
|
(title ? '<div class="fw-semibold lh-sm">' + esc(title) + '</div>' : ''),
|
||||||
(message ? '<div class="small text-muted">' + message + '</div>' : ''),
|
(message ? '<div class="small text-muted">' + esc(message) + '</div>' : ''),
|
||||||
' </div>',
|
' </div>',
|
||||||
' </div>',
|
' </div>',
|
||||||
' <button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast"></button>',
|
' <button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast"></button>',
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
$current_page = basename($_SERVER['PHP_SELF']);
|
$current_page = basename($_SERVER['PHP_SELF']);
|
||||||
$setting_role = $_SESSION['login_role'] ?? 'viewer';
|
$setting_role = $_SESSION['login_role'] ?? 'viewer';
|
||||||
|
// Users Access and SMTP are owner/admin only (their API engines enforce it);
|
||||||
|
// don't offer other roles a page that can only answer "Access denied".
|
||||||
|
$setting_can_admin = in_array($setting_role, ['owner', 'admin'], true);
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<!-- SIDEBAR -->
|
<!-- SIDEBAR -->
|
||||||
@@ -36,6 +39,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<?php if ($setting_can_admin): ?>
|
||||||
<li>
|
<li>
|
||||||
<a class="nav-link <?php echo $current_page === 'users.php' ? 'active' : ''; ?>"
|
<a class="nav-link <?php echo $current_page === 'users.php' ? 'active' : ''; ?>"
|
||||||
href="<?php echo $server_url?>setting/users.php">
|
href="<?php echo $server_url?>setting/users.php">
|
||||||
@@ -51,6 +55,7 @@
|
|||||||
<span class="nav-text">SMTP Setting</span>
|
<span class="nav-text">SMTP Setting</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<a class="nav-link <?php echo $current_page === 'system_config.php' ? 'active' : ''; ?>"
|
<a class="nav-link <?php echo $current_page === 'system_config.php' ? 'active' : ''; ?>"
|
||||||
|
|||||||
@@ -359,13 +359,19 @@ function _render_notif_list() {
|
|||||||
el.innerHTML = '<div class="text-center text-muted small py-4">No notifications</div>';
|
el.innerHTML = '<div class="text-center text-muted small py-4">No notifications</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Notification text arrives over the socket: escape it, never render it as markup.
|
||||||
|
var esc = function (v) {
|
||||||
|
return String(v).replace(/[&<>"']/g, function (c) {
|
||||||
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||||
|
});
|
||||||
|
};
|
||||||
el.innerHTML = _notif_items.map(function(n) {
|
el.innerHTML = _notif_items.map(function(n) {
|
||||||
var icon = _notif_icon_map[n.type] || _notif_icon_map.info;
|
var icon = _notif_icon_map[n.type] || _notif_icon_map.info;
|
||||||
return '<div class="d-flex align-items-start gap-2 px-3 py-2 border-bottom">' +
|
return '<div class="d-flex align-items-start gap-2 px-3 py-2 border-bottom">' +
|
||||||
'<i class="ti ' + icon + ' fs-5 flex-shrink-0 mt-1"></i>' +
|
'<i class="ti ' + icon + ' fs-5 flex-shrink-0 mt-1"></i>' +
|
||||||
'<div class="flex-grow-1 overflow-hidden">' +
|
'<div class="flex-grow-1 overflow-hidden">' +
|
||||||
(n.title ? '<div class="fw-semibold small text-truncate">' + n.title + '</div>' : '') +
|
(n.title ? '<div class="fw-semibold small text-truncate">' + esc(n.title) + '</div>' : '') +
|
||||||
(n.message ? '<div class="small text-muted text-truncate">' + n.message + '</div>' : '') +
|
(n.message ? '<div class="small text-muted text-truncate">' + esc(n.message) + '</div>' : '') +
|
||||||
'<div class="small text-muted opacity-75 mt-1">' + n.time + '</div>' +
|
'<div class="small text-muted opacity-75 mt-1">' + n.time + '</div>' +
|
||||||
'</div></div>';
|
'</div></div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|||||||
+3
-1
@@ -91,7 +91,9 @@ echo "--- Owner account & company ---\n";
|
|||||||
|
|
||||||
$demo_username = 'admin';
|
$demo_username = 'admin';
|
||||||
$demo_email = 'thanakorn.inbox@gmail.com';
|
$demo_email = 'thanakorn.inbox@gmail.com';
|
||||||
$demo_password = 'Demo@12345';
|
// Never a fixed password in the repository: DEMO_PASSWORD from the environment,
|
||||||
|
// or a random one that is printed once below.
|
||||||
|
$demo_password = getenv('DEMO_PASSWORD') ?: ('Demo-' . bin2hex(random_bytes(6)));
|
||||||
$demo_channel = 'demo';
|
$demo_channel = 'demo';
|
||||||
|
|
||||||
$sth = $pdo1->prepare("SELECT user_id FROM user WHERE username = :u OR email = :e LIMIT 1");
|
$sth = $pdo1->prepare("SELECT user_id FROM user WHERE username = :u OR email = :e LIMIT 1");
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ services:
|
|||||||
SMTP_USERNAME: ${SMTP_USERNAME}
|
SMTP_USERNAME: ${SMTP_USERNAME}
|
||||||
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
||||||
OTP_REQUIRED: ${OTP_REQUIRED:-false}
|
OTP_REQUIRED: ${OTP_REQUIRED:-false}
|
||||||
|
DB_APP_USER: ${DB_APP_USER:-wms_app}
|
||||||
|
DB_APP_PASSWORD: ${DB_APP_PASSWORD:-}
|
||||||
|
APP_SECRET_KEY: ${APP_SECRET_KEY:-}
|
||||||
|
# true once TLS is in place (directly or via a proxy): HTTP is redirected to HTTPS.
|
||||||
|
FORCE_HTTPS: ${FORCE_HTTPS:-false}
|
||||||
volumes:
|
volumes:
|
||||||
- .:/var/www/html/wms-app
|
- .:/var/www/html/wms-app
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ if [ -z "$emit_secret" ]; then
|
|||||||
echo " generated: $emit_secret"
|
echo " generated: $emit_secret"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== App secrets (generated) =="
|
||||||
|
db_app_pass=$(openssl rand -hex 24)
|
||||||
|
app_secret=$(openssl rand -hex 32)
|
||||||
|
echo " DB_APP_PASSWORD and APP_SECRET_KEY generated (stored in $ENV_FILE only)."
|
||||||
|
read -r -p "Serve over HTTPS (redirect HTTP → HTTPS)? Only answer y once TLS is set up [y/N]: " force_https
|
||||||
|
case "$force_https" in [yY]*) force_https=true ;; *) force_https=false ;; esac
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "== SMTP (outgoing mail) =="
|
echo "== SMTP (outgoing mail) =="
|
||||||
read -r -p "SMTP username (email address): " smtp_user
|
read -r -p "SMTP username (email address): " smtp_user
|
||||||
@@ -57,6 +65,10 @@ SMTP_USERNAME=$smtp_user
|
|||||||
SMTP_PASSWORD=$smtp_pass
|
SMTP_PASSWORD=$smtp_pass
|
||||||
OTP_REQUIRED=false
|
OTP_REQUIRED=false
|
||||||
HTTP_PORT=$http_port
|
HTTP_PORT=$http_port
|
||||||
|
DB_APP_USER=wms_app
|
||||||
|
DB_APP_PASSWORD=$db_app_pass
|
||||||
|
APP_SECRET_KEY=$app_secret
|
||||||
|
FORCE_HTTPS=$force_https
|
||||||
EOF
|
EOF
|
||||||
chmod 600 "$ENV_FILE"
|
chmod 600 "$ENV_FILE"
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libpng-dev libjpeg-dev libfreetype6-dev \
|
libpng-dev libjpeg-dev libfreetype6-dev \
|
||||||
&& docker-php-ext-configure gd --with-jpeg --with-freetype \
|
&& docker-php-ext-configure gd --with-jpeg --with-freetype \
|
||||||
&& docker-php-ext-install pdo_mysql mysqli mbstring gd zip intl sockets exif opcache \
|
&& docker-php-ext-install pdo_mysql mysqli mbstring gd zip intl sockets exif opcache \
|
||||||
&& a2enmod rewrite \
|
&& a2enmod rewrite headers \
|
||||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache-recommended.ini
|
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache-recommended.ini
|
||||||
|
COPY docker/php/security.ini /usr/local/etc/php/conf.d/zz-security.ini
|
||||||
|
COPY docker/php/apache-wms.conf /etc/apache2/conf-available/wms.conf
|
||||||
|
RUN a2enconf wms
|
||||||
COPY docker/php/entrypoint.sh /usr/local/bin/docker-entrypoint-wms.sh
|
COPY docker/php/entrypoint.sh /usr/local/bin/docker-entrypoint-wms.sh
|
||||||
COPY docker/php/config.php.template /usr/local/etc/wms/config.php.template
|
COPY docker/php/config.php.template /usr/local/etc/wms/config.php.template
|
||||||
|
COPY docker/php/provision.php /usr/local/etc/wms/provision.php
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint-wms.sh
|
RUN chmod +x /usr/local/bin/docker-entrypoint-wms.sh
|
||||||
|
|
||||||
WORKDIR /var/www/html/wms-app
|
WORKDIR /var/www/html/wms-app
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Apache hardening for the wms-app container.
|
||||||
|
|
||||||
|
# No version banners in headers or error pages.
|
||||||
|
ServerTokens Prod
|
||||||
|
ServerSignature Off
|
||||||
|
TraceEnable Off
|
||||||
|
|
||||||
|
# The repository is mounted under the web root; its .htaccess refuses .git, .env,
|
||||||
|
# deployment folders and app internals, so it must be honoured.
|
||||||
|
<Directory /var/www/html/wms-app>
|
||||||
|
Options -Indexes +FollowSymLinks
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
# Nothing else under the default web root is part of this app.
|
||||||
|
<Directory /var/www/html>
|
||||||
|
Options -Indexes
|
||||||
|
</Directory>
|
||||||
|
|
||||||
|
# FORCE_HTTPS from docker-compose is read by the .htaccess redirect rule.
|
||||||
|
PassEnv FORCE_HTTPS
|
||||||
@@ -2,14 +2,15 @@
|
|||||||
|
|
||||||
//<><><><><><><><> MAIN CONFIG (docker-generated) <><><><><><><><>//
|
//<><><><><><><><> MAIN CONFIG (docker-generated) <><><><><><><><>//
|
||||||
if(true){
|
if(true){
|
||||||
$isTest = "master";
|
|
||||||
$base_url = "/wms-app/";
|
$base_url = "/wms-app/";
|
||||||
$server_url = $base_url."app/";
|
$server_url = $base_url."app/";
|
||||||
$include_url = $_SERVER['DOCUMENT_ROOT'].$server_url;
|
$include_url = $_SERVER['DOCUMENT_ROOT'].$server_url;
|
||||||
|
|
||||||
$db_server = "db";
|
$db_server = "db";
|
||||||
$db_user = "root";
|
// Least-privilege account created by the entrypoint (docker/php/entrypoint.sh);
|
||||||
$db_pass = "${DB_ROOT_PASSWORD}";
|
// root is only used there and by setup.php.
|
||||||
|
$db_user = "${DB_APP_USER}";
|
||||||
|
$db_pass = "${DB_APP_PASSWORD}";
|
||||||
$db_type = "mysql";
|
$db_type = "mysql";
|
||||||
$db_database = "wms";
|
$db_database = "wms";
|
||||||
$db_database2 = "wms2";
|
$db_database2 = "wms2";
|
||||||
@@ -58,7 +59,12 @@ $packages = [
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
// unique key — used for SMTP password encryption, keep consistent across deploys
|
// Key for stored SMTP passwords (assets/utils/secret_box.php). Keep it stable
|
||||||
|
// across deploys: changing it makes saved SMTP passwords unreadable.
|
||||||
|
if (!defined('APP_SECRET_KEY')) {
|
||||||
|
define('APP_SECRET_KEY', '${APP_SECRET_KEY}');
|
||||||
|
}
|
||||||
|
// Legacy fixed key: only used to read SMTP passwords saved before APP_SECRET_KEY.
|
||||||
$pinkey = "wms";
|
$pinkey = "wms";
|
||||||
|
|
||||||
$SMTP = [];
|
$SMTP = [];
|
||||||
|
|||||||
@@ -10,12 +10,32 @@ CONFIG=$APP_DIR/app/config.php
|
|||||||
[ "$OTP_REQUIRED" = "true" ] || OTP_REQUIRED=false
|
[ "$OTP_REQUIRED" = "true" ] || OTP_REQUIRED=false
|
||||||
export OTP_REQUIRED
|
export OTP_REQUIRED
|
||||||
|
|
||||||
|
# An empty EMIT_SECRET would let anyone call Node's /emit and the PHP cron
|
||||||
|
# endpoints, so refuse to start without one.
|
||||||
|
if [ -z "$EMIT_SECRET" ]; then
|
||||||
|
echo "[entrypoint] ERROR: EMIT_SECRET is empty. Set it in .env (docker/init-env.sh generates one)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "$APP_SECRET_KEY" ]; then
|
||||||
|
echo "[entrypoint] WARNING: APP_SECRET_KEY is not set; SMTP passwords stay in the legacy fixed-key format."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The app connects as a least-privilege account (see provision.php). Without
|
||||||
|
# DB_APP_PASSWORD it keeps connecting as root, as before.
|
||||||
|
: "${DB_APP_USER:=wms_app}"
|
||||||
|
if [ -z "$DB_APP_PASSWORD" ]; then
|
||||||
|
echo "[entrypoint] WARNING: DB_APP_PASSWORD is not set; the app connects as root. Re-run docker/init-env.sh to add it."
|
||||||
|
DB_APP_USER=root
|
||||||
|
DB_APP_PASSWORD=$DB_ROOT_PASSWORD
|
||||||
|
fi
|
||||||
|
export DB_APP_USER DB_APP_PASSWORD APP_SECRET_KEY CONFIG
|
||||||
|
|
||||||
# Generate app/config.php from template on first run only.
|
# Generate app/config.php from template on first run only.
|
||||||
# Restrict envsubst to known placeholders so it never touches the app's own
|
# Restrict envsubst to known placeholders so it never touches the app's own
|
||||||
# $variable syntax (envsubst blanks out any $NAME it doesn't recognize).
|
# $variable syntax (envsubst blanks out any $NAME it doesn't recognize).
|
||||||
if [ ! -f "$CONFIG" ]; then
|
if [ ! -f "$CONFIG" ]; then
|
||||||
echo "[entrypoint] generating app/config.php"
|
echo "[entrypoint] generating app/config.php"
|
||||||
envsubst '${DB_ROOT_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD} ${OTP_REQUIRED}' \
|
envsubst '${DB_APP_USER} ${DB_APP_PASSWORD} ${PUBLIC_HOST} ${EMIT_SECRET} ${SMTP_USERNAME} ${SMTP_PASSWORD} ${OTP_REQUIRED} ${APP_SECRET_KEY}' \
|
||||||
< /usr/local/etc/wms/config.php.template > "$CONFIG"
|
< /usr/local/etc/wms/config.php.template > "$CONFIG"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -45,7 +65,10 @@ until mysqladmin ping -h db -u root -p"$DB_ROOT_PASSWORD" --silent 2>/dev/null;
|
|||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
|
php /usr/local/etc/wms/provision.php
|
||||||
|
|
||||||
|
# setup.php creates databases and tables, so it runs as root, not the app account.
|
||||||
echo "[entrypoint] running setup.php (idempotent schema sync)"
|
echo "[entrypoint] running setup.php (idempotent schema sync)"
|
||||||
php "$APP_DIR/setup.php" || true
|
DB_SETUP_USER=root DB_SETUP_PASSWORD="$DB_ROOT_PASSWORD" php "$APP_DIR/setup.php" || true
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* provision.php — run by the container entrypoint before setup.php (CLI only).
|
||||||
|
*
|
||||||
|
* 1. Creates/updates the least-privilege database account the app connects as
|
||||||
|
* (DML + CREATE/INDEX on wms and wms2 only — the app creates td_stock_<id>
|
||||||
|
* tables with CREATE TABLE … LIKE; no DROP, ALTER, GRANT or other databases).
|
||||||
|
* 2. Brings an existing app/config.php (generated once, never regenerated) onto
|
||||||
|
* that account and adds APP_SECRET_KEY if it is missing.
|
||||||
|
*
|
||||||
|
* All values come from the environment and are passed to MariaDB as bound
|
||||||
|
* parameters or written with var_export(), so no password is placed on a
|
||||||
|
* command line or interpolated into SQL or PHP source.
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$root_pass = (string)getenv('DB_ROOT_PASSWORD');
|
||||||
|
$app_user = (string)getenv('DB_APP_USER');
|
||||||
|
$app_pass = (string)getenv('DB_APP_PASSWORD');
|
||||||
|
$secret = (string)getenv('APP_SECRET_KEY');
|
||||||
|
$config = (string)getenv('CONFIG');
|
||||||
|
|
||||||
|
// ── 1. Database account ──────────────────────────────────────────────────────
|
||||||
|
if ($app_user !== 'root') {
|
||||||
|
if (!preg_match('/^[A-Za-z0-9_]{1,32}$/', $app_user)) {
|
||||||
|
fwrite(STDERR, "[provision] DB_APP_USER must be letters, digits or _\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
$pdo = new PDO('mysql:host=db', 'root', $root_pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||||
|
$pdo->exec('CREATE DATABASE IF NOT EXISTS `wms`');
|
||||||
|
$pdo->exec('CREATE DATABASE IF NOT EXISTS `wms2`');
|
||||||
|
$pdo->prepare("CREATE USER IF NOT EXISTS ?@'%' IDENTIFIED BY ?")->execute([$app_user, $app_pass]);
|
||||||
|
$pdo->prepare("ALTER USER ?@'%' IDENTIFIED BY ?")->execute([$app_user, $app_pass]);
|
||||||
|
foreach (['wms', 'wms2'] as $db) {
|
||||||
|
$pdo->exec("GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, INDEX, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE"
|
||||||
|
. " ON `{$db}`.* TO " . $pdo->quote($app_user) . "@'%'");
|
||||||
|
}
|
||||||
|
echo "[provision] database account {$app_user} is ready\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Existing config.php ───────────────────────────────────────────────────
|
||||||
|
if ($config === '' || !is_file($config)) {
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
$src = file_get_contents($config);
|
||||||
|
$orig = $src;
|
||||||
|
|
||||||
|
$set = function (string $var, string $value) use (&$src) {
|
||||||
|
$src = preg_replace_callback(
|
||||||
|
'/^(\s*\$' . $var . '\s*=\s*)[^;]*;/m',
|
||||||
|
fn ($m) => $m[1] . var_export($value, true) . ';',
|
||||||
|
$src,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
};
|
||||||
|
$set('db_user', $app_user);
|
||||||
|
$set('db_pass', $app_pass);
|
||||||
|
|
||||||
|
if ($secret !== '' && strpos($src, 'APP_SECRET_KEY') === false) {
|
||||||
|
$src = preg_replace('/\?>\s*$/', '', $src);
|
||||||
|
$src .= "\nif (!defined('APP_SECRET_KEY')) {\n\tdefine('APP_SECRET_KEY', " . var_export($secret, true) . ");\n}\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($src !== $orig) {
|
||||||
|
file_put_contents($config, $src);
|
||||||
|
echo "[provision] app/config.php updated (database account / secret key)\n";
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
; PHP hardening for the wms-app container.
|
||||||
|
expose_php = Off
|
||||||
|
display_errors = Off
|
||||||
|
display_startup_errors = Off
|
||||||
|
log_errors = On
|
||||||
|
|
||||||
|
; Session defaults; app/session.php sets the same per request (plus Secure over HTTPS).
|
||||||
|
session.use_strict_mode = 1
|
||||||
|
session.use_only_cookies = 1
|
||||||
|
session.cookie_httponly = 1
|
||||||
|
session.cookie_samesite = Lax
|
||||||
@@ -8,6 +8,13 @@ const PHP_WEBROOT = process.env.PHP_WEBROOT || '/brnwms/app';
|
|||||||
const SECRET = process.env.EMIT_SECRET || '';
|
const SECRET = process.env.EMIT_SECRET || '';
|
||||||
const NODE_PORT = process.env.PORT || 3000;
|
const NODE_PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
if (!SECRET) {
|
||||||
|
// The PHP cron endpoints refuse an empty secret; fail loudly instead of
|
||||||
|
// sending unauthenticated calls every schedule tick.
|
||||||
|
console.error('EMIT_SECRET is not set — scheduler not started.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
function callPhp(path, body) {
|
function callPhp(path, body) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const payload = JSON.stringify(body);
|
const payload = JSON.stringify(body);
|
||||||
|
|||||||
+49
-18
@@ -1,9 +1,42 @@
|
|||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { createServer } = require('http');
|
const { createServer } = require('http');
|
||||||
const { Server } = require('socket.io');
|
const { Server } = require('socket.io');
|
||||||
|
|
||||||
|
const EMIT_SECRET = process.env.EMIT_SECRET || '';
|
||||||
|
if (!EMIT_SECRET) {
|
||||||
|
// Without a secret anyone could call /emit and sign socket tokens.
|
||||||
|
console.error('EMIT_SECRET is not set — refusing to start.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Socket tokens are signed with a key derived from EMIT_SECRET, so a token can
|
||||||
|
// never double as the /emit secret. Must match socket_token() in include_ending.php.
|
||||||
|
const SOCKET_KEY = crypto.createHmac('sha256', EMIT_SECRET).update('socket-token').digest();
|
||||||
|
|
||||||
|
function safeEqual(a, b) {
|
||||||
|
const x = Buffer.from(String(a));
|
||||||
|
const y = Buffer.from(String(b));
|
||||||
|
return x.length === y.length && crypto.timingSafeEqual(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token = base64url(JSON {c, u, r, exp}) + "." + base64url(HMAC-SHA256(payload)).
|
||||||
|
// Returns the claims, or null when the token is missing, forged or expired.
|
||||||
|
function verifySocketToken(token) {
|
||||||
|
if (typeof token !== 'string' || token.indexOf('.') < 1) return null;
|
||||||
|
const [payload, sig] = token.split('.', 2);
|
||||||
|
const expected = crypto.createHmac('sha256', SOCKET_KEY).update(payload).digest('base64url');
|
||||||
|
if (!safeEqual(sig, expected)) return null;
|
||||||
|
let claims;
|
||||||
|
try { claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); }
|
||||||
|
catch (e) { return null; }
|
||||||
|
if (!claims || !Number.isInteger(claims.c) || claims.c <= 0) return null;
|
||||||
|
if (!Number.isInteger(claims.exp) || claims.exp < Math.floor(Date.now() / 1000)) return null;
|
||||||
|
return claims;
|
||||||
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const httpServer = createServer(app);
|
const httpServer = createServer(app);
|
||||||
const io = new Server(httpServer, {
|
const io = new Server(httpServer, {
|
||||||
@@ -17,8 +50,8 @@ app.use(express.json());
|
|||||||
// Body: { event, data, company_id }
|
// Body: { event, data, company_id }
|
||||||
//
|
//
|
||||||
app.post('/emit', (req, res) => {
|
app.post('/emit', (req, res) => {
|
||||||
const secret = req.headers['x-emit-secret'];
|
const secret = req.headers['x-emit-secret'] || '';
|
||||||
if (secret !== process.env.EMIT_SECRET) {
|
if (!safeEqual(secret, EMIT_SECRET)) {
|
||||||
return res.status(403).json({ ok: false, message: 'Forbidden' });
|
return res.status(403).json({ ok: false, message: 'Forbidden' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,28 +79,26 @@ app.post('/emit', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── /health ───────────────────────────────────────────────────────────────────
|
// ── /health ───────────────────────────────────────────────────────────────────
|
||||||
|
// Deliberately public (used by uptime checks); reports status only.
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({
|
res.json({ status: 'ok' });
|
||||||
status: 'ok',
|
|
||||||
uptime: Math.floor(process.uptime()),
|
|
||||||
connections: io.engine.clientsCount,
|
|
||||||
memory_mb: Math.round(process.memoryUsage().rss / 1024 / 1024 * 10) / 10
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── WebSocket connections ─────────────────────────────────────────────────────
|
// ── WebSocket connections ─────────────────────────────────────────────────────
|
||||||
// Each browser tab connects here on page load.
|
// Each browser tab connects here on page load with a token PHP signed for the
|
||||||
// It joins a room named company_<id> so events stay isolated per company.
|
// signed-in user (include_ending.php). Rooms come only from the verified token —
|
||||||
|
// never from values the browser chooses — so a visitor cannot listen to another
|
||||||
|
// company's events.
|
||||||
//
|
//
|
||||||
io.on('connection', (socket) => {
|
io.use((socket, next) => {
|
||||||
const company_id = socket.handshake.query.company_id;
|
const claims = verifySocketToken(socket.handshake.auth && socket.handshake.auth.token);
|
||||||
const user_id = socket.handshake.query.user_id;
|
if (!claims) return next(new Error('unauthorized'));
|
||||||
const role = socket.handshake.query.role || 'viewer';
|
socket.data.claims = claims;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
if (!company_id) {
|
io.on('connection', (socket) => {
|
||||||
socket.disconnect();
|
const { c: company_id, u: user_id, r: role } = socket.data.claims;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.join(`company_${company_id}`);
|
socket.join(`company_${company_id}`);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Build script, CLI only — the repository sits under the web root.
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
if ($argc !== 2) {
|
if ($argc !== 2) {
|
||||||
fwrite(STDERR, "Usage: php adjust_docx_whitespace.php <document.docx>\n");
|
fwrite(STDERR, "Usage: php adjust_docx_whitespace.php <document.docx>\n");
|
||||||
exit(2);
|
exit(2);
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Build script, CLI only — the repository sits under the web root.
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Applies the approved print layout from the first formatted Progress Status
|
* Applies the approved print layout from the first formatted Progress Status
|
||||||
* Record to every generated Progress Status Record HTML file. Markdown source
|
* Record to every generated Progress Status Record HTML file. Markdown source
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Build script, CLI only — the repository sits under the web root.
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$directory = __DIR__ . '/../sdlc/1-PM Process (10 Work Product)/3.Progess Status Record';
|
$directory = __DIR__ . '/../sdlc/1-PM Process (10 Work Product)/3.Progess Status Record';
|
||||||
|
|
||||||
$records = [
|
$records = [
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Build script, CLI only — the repository sits under the web root.
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$base = __DIR__ . '/../sdlc/1-PM Process (10 Work Product)/2.Project Plan/';
|
$base = __DIR__ . '/../sdlc/1-PM Process (10 Work Product)/2.Project Plan/';
|
||||||
$w = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
$w = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Build script, CLI only — the repository sits under the web root.
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate the ISO/IEC 29110 Statement of Work for BRN WMS.
|
* Generate the ISO/IEC 29110 Statement of Work for BRN WMS.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Build script, CLI only — the repository sits under the web root.
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$output = __DIR__ . '/../sdlc/1-PM Process (10 Work Product)/2.Project Plan/1-Work Schedule/'
|
$output = __DIR__ . '/../sdlc/1-PM Process (10 Work Product)/2.Project Plan/1-Work Schedule/'
|
||||||
. '200-WMS-26-001-00 Work Schedule 25690105 V1.0 Final.xlsx';
|
. '200-WMS-26-001-00 Work Schedule 25690105 V1.0 Final.xlsx';
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// Build script, CLI only — the repository sits under the web root.
|
||||||
|
if (PHP_SAPI !== 'cli') {
|
||||||
|
http_response_code(404);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
$root = __DIR__ . '/../sdlc';
|
$root = __DIR__ . '/../sdlc';
|
||||||
$english = [
|
$english = [
|
||||||
'January'=>'01','February'=>'02','March'=>'03','April'=>'04','May'=>'05','June'=>'06',
|
'January'=>'01','February'=>'02','March'=>'03','April'=>'04','May'=>'05','June'=>'06',
|
||||||
|
|||||||
@@ -26,8 +26,10 @@ if (!file_exists($config)) {
|
|||||||
require $config;
|
require $config;
|
||||||
|
|
||||||
$host = $db_server ?? 'localhost';
|
$host = $db_server ?? 'localhost';
|
||||||
$user = $db_user ?? 'root';
|
// The app's own account may be least-privilege; schema changes need an admin
|
||||||
$pass = $db_pass ?? '';
|
// account, which the docker entrypoint passes as DB_SETUP_USER / DB_SETUP_PASSWORD.
|
||||||
|
$user = getenv('DB_SETUP_USER') ?: ($db_user ?? 'root');
|
||||||
|
$pass = getenv('DB_SETUP_USER') ? (string)getenv('DB_SETUP_PASSWORD') : ($db_pass ?? '');
|
||||||
$db1 = $db_database ?? 'wms';
|
$db1 = $db_database ?? 'wms';
|
||||||
$db2 = $db_database2 ?? 'wms2';
|
$db2 = $db_database2 ?? 'wms2';
|
||||||
|
|
||||||
@@ -225,6 +227,20 @@ CREATE TABLE IF NOT EXISTS `whitelist` (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
||||||
", 'whitelist');
|
", 'whitelist');
|
||||||
|
|
||||||
|
// Request throttle for the login / OTP / registration endpoints
|
||||||
|
// (app/assets/utils/rate_limit.php). key_hash is SHA-256 of bucket|key, so raw
|
||||||
|
// IPs and emails are never stored.
|
||||||
|
run($pdo, "
|
||||||
|
CREATE TABLE IF NOT EXISTS `auth_throttle` (
|
||||||
|
`bucket` varchar(40) NOT NULL,
|
||||||
|
`key_hash` char(64) NOT NULL,
|
||||||
|
`window_start` datetime NOT NULL,
|
||||||
|
`hits` int(11) NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (`bucket`,`key_hash`),
|
||||||
|
KEY `idx_window_start` (`window_start`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
||||||
|
", 'auth_throttle');
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
// WMS2 (business data) tables
|
// WMS2 (business data) tables
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -1271,6 +1287,34 @@ foreach (['subtotal', 'discount', 'tax', 'shipping_fee', 'grand_total'] as $col)
|
|||||||
run($pdo, "ALTER TABLE `{$db2}`.`td_purchase_order` MODIFY `{$col}` decimal(18,4) NOT NULL DEFAULT 0.0000", "td_purchase_order.{$col} decimal(18,4)");
|
run($pdo, "ALTER TABLE `{$db2}`.`td_purchase_order` MODIFY `{$col}` decimal(18,4) NOT NULL DEFAULT 0.0000", "td_purchase_order.{$col} decimal(18,4)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── SMTP passwords: legacy fixed key → APP_SECRET_KEY ─────────────────────────
|
||||||
|
// Rows saved before APP_SECRET_KEY existed are encrypted with the public fixed key
|
||||||
|
// "wms". Once the deployment sets APP_SECRET_KEY, re-encrypt them (idempotent: rows
|
||||||
|
// already in the v2 format are skipped).
|
||||||
|
require_once __DIR__ . '/app/assets/utils/secret_box.php';
|
||||||
|
if (secret_box_key() === null) {
|
||||||
|
skip('SMTP passwords: APP_SECRET_KEY not set, left in the legacy format');
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
$rows = $pdo->query("SELECT smtp_id, password FROM `{$db1}`.`company_smtp`")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$upd = $pdo->prepare("UPDATE `{$db1}`.`company_smtp` SET password = :p WHERE smtp_id = :id");
|
||||||
|
$moved = 0;
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (!secret_is_legacy((string)$row['password'])) continue;
|
||||||
|
$plain = secret_decrypt((string)$row['password'], $pinkey ?? 'wms');
|
||||||
|
if ($plain === false) {
|
||||||
|
fail("SMTP password for smtp_id {$row['smtp_id']} could not be decrypted; left unchanged");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$upd->execute([':p' => secret_encrypt($plain), ':id' => $row['smtp_id']]);
|
||||||
|
$moved++;
|
||||||
|
}
|
||||||
|
$moved ? ok("SMTP passwords re-encrypted with APP_SECRET_KEY ({$moved})") : skip('SMTP passwords already use APP_SECRET_KEY');
|
||||||
|
} catch (PDOException $e) {
|
||||||
|
fail('SMTP password re-encryption: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Summary ───────────────────────────────────────────────────────────────────
|
// ── Summary ───────────────────────────────────────────────────────────────────
|
||||||
$total = $ok_count + $skip_count + $err_count;
|
$total = $ok_count + $skip_count + $err_count;
|
||||||
echo "\n\033[1m=== Done ===\033[0m\n";
|
echo "\n\033[1m=== Done ===\033[0m\n";
|
||||||
|
|||||||
Reference in New Issue
Block a user