diff --git a/.htaccess b/.htaccess
new file mode 100644
index 0000000..ee92cda
--- /dev/null
+++ b/.htaccess
@@ -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
+
+
+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]
+
+
+
+# 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'"
+
diff --git a/app/assets/utils/classes/SmtpManager.php b/app/assets/utils/classes/SmtpManager.php
index f01e28a..b41556b 100644
--- a/app/assets/utils/classes/SmtpManager.php
+++ b/app/assets/utils/classes/SmtpManager.php
@@ -1,5 +1,6 @@
method, $this->pinkey, 0, $this->iv);
+ return secret_encrypt($plain, $this->pinkey);
}
}
?>
diff --git a/app/assets/utils/cron_auth.php b/app/assets/utils/cron_auth.php
index 1930b81..252c1bb 100644
--- a/app/assets/utils/cron_auth.php
+++ b/app/assets/utils/cron_auth.php
@@ -11,8 +11,9 @@ header('Content-Type: application/json; charset=utf-8');
require_once __DIR__ . '/../../config.php';
require_once __DIR__ . '/../../dbconn.php';
-$secret = $_SERVER['HTTP_X_CRON_SECRET'] ?? '';
-if (!defined('NODE_EMIT_SECRET') || $secret !== NODE_EMIT_SECRET) {
+// An empty configured secret must never match an empty header.
+$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);
exit(json_encode(['success' => 0, 'message' => 'Forbidden']));
}
diff --git a/app/assets/utils/module/mailer.php b/app/assets/utils/module/mailer.php
index c53b124..fea5969 100644
--- a/app/assets/utils/module/mailer.php
+++ b/app/assets/utils/module/mailer.php
@@ -72,7 +72,8 @@ class mailer{
"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"]);
}
diff --git a/app/assets/utils/secret_box.php b/app/assets/utils/secret_box.php
new file mode 100644
index 0000000..af0a325
--- /dev/null
+++ b/app/assets/utils/secret_box.php
@@ -0,0 +1,48 @@
+": 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;
+}
diff --git a/app/config.example.php b/app/config.example.php
index 691b6f4..f2d1c4e 100644
--- a/app/config.example.php
+++ b/app/config.example.php
@@ -2,13 +2,15 @@
//<><><><><><><><> MAIN CONFIG <><><><><><><><>//
if(true){
- $isTest = "master";
$base_url = "/your-app-folder/"; // e.g. "/wms-app/" — folder name under web root
$server_url = $base_url."app/";
$include_url = $_SERVER['DOCUMENT_ROOT'].$server_url;
$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_type = "mysql";
$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";
$SMTP = [];
diff --git a/app/file.php b/app/file.php
new file mode 100644
index 0000000..9ddaa21
--- /dev/null
+++ b/app/file.php
@@ -0,0 +1,56 @@
+ request here, so the
+ * existing
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);
diff --git a/app/include_ending.php b/app/include_ending.php
index fbbbd77..fce2fe9 100644
--- a/app/include_ending.php
+++ b/app/include_ending.php
@@ -7,17 +7,34 @@
+ 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));
+}
+?>
-
+