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
|
||||
require_once __DIR__ . '/../module/mailer.php';
|
||||
require_once __DIR__ . '/../secret_box.php';
|
||||
|
||||
class SmtpManager
|
||||
{
|
||||
@@ -173,7 +174,7 @@ class SmtpManager
|
||||
|
||||
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__ . '/../../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']));
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 <><><><><><><><>//
|
||||
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 = [];
|
||||
|
||||
@@ -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>
|
||||
|
||||
<?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 ──────────────────────────────────────── -->
|
||||
<!-- 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>
|
||||
(function () {
|
||||
// company_id is set in include_topbar.php as a JS global
|
||||
if (typeof company_id === 'undefined' || !company_id) return;
|
||||
if (typeof io === 'undefined') return;
|
||||
|
||||
window._socket = io('<?php echo NODE_PUBLIC_URL; ?>', {
|
||||
query: { company_id: company_id, user_id: user_id, role: user_role },
|
||||
window._socket = io(<?php echo json_encode(NODE_PUBLIC_URL, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES); ?>, {
|
||||
auth: { token: <?php echo json_encode($_socket_token, JSON_HEX_TAG); ?> },
|
||||
reconnection: true,
|
||||
reconnectionDelay: 2000,
|
||||
});
|
||||
@@ -57,6 +74,13 @@ function show_toast(title, message, type) {
|
||||
};
|
||||
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');
|
||||
if (!container) {
|
||||
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">',
|
||||
' <i class="ti ' + icon + ' fs-5 mt-1 flex-shrink-0"></i>',
|
||||
' <div>',
|
||||
(title ? '<div class="fw-semibold lh-sm">' + title + '</div>' : ''),
|
||||
(message ? '<div class="small text-muted">' + message + '</div>' : ''),
|
||||
(title ? '<div class="fw-semibold lh-sm">' + esc(title) + '</div>' : ''),
|
||||
(message ? '<div class="small text-muted">' + esc(message) + '</div>' : ''),
|
||||
' </div>',
|
||||
' </div>',
|
||||
' <button type="button" class="btn-close me-2 m-auto" data-bs-dismiss="toast"></button>',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<?php
|
||||
$current_page = basename($_SERVER['PHP_SELF']);
|
||||
$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 -->
|
||||
@@ -36,6 +39,7 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<?php if ($setting_can_admin): ?>
|
||||
<li>
|
||||
<a class="nav-link <?php echo $current_page === 'users.php' ? 'active' : ''; ?>"
|
||||
href="<?php echo $server_url?>setting/users.php">
|
||||
@@ -51,6 +55,7 @@
|
||||
<span class="nav-text">SMTP Setting</span>
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
|
||||
<li>
|
||||
<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>';
|
||||
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) {
|
||||
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">' +
|
||||
'<i class="ti ' + icon + ' fs-5 flex-shrink-0 mt-1"></i>' +
|
||||
'<div class="flex-grow-1 overflow-hidden">' +
|
||||
(n.title ? '<div class="fw-semibold small text-truncate">' + n.title + '</div>' : '') +
|
||||
(n.message ? '<div class="small text-muted text-truncate">' + n.message + '</div>' : '') +
|
||||
(n.title ? '<div class="fw-semibold small text-truncate">' + esc(n.title) + '</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></div>';
|
||||
}).join('');
|
||||
|
||||
+3
-1
@@ -91,7 +91,9 @@ echo "--- Owner account & company ---\n";
|
||||
|
||||
$demo_username = 'admin';
|
||||
$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';
|
||||
|
||||
$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_PASSWORD: ${SMTP_PASSWORD}
|
||||
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:
|
||||
- .:/var/www/html/wms-app
|
||||
ports:
|
||||
|
||||
@@ -44,6 +44,14 @@ if [ -z "$emit_secret" ]; then
|
||||
echo " generated: $emit_secret"
|
||||
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 "== SMTP (outgoing mail) =="
|
||||
read -r -p "SMTP username (email address): " smtp_user
|
||||
@@ -57,6 +65,10 @@ SMTP_USERNAME=$smtp_user
|
||||
SMTP_PASSWORD=$smtp_pass
|
||||
OTP_REQUIRED=false
|
||||
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
|
||||
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 \
|
||||
&& docker-php-ext-configure gd --with-jpeg --with-freetype \
|
||||
&& 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/*
|
||||
|
||||
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/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
|
||||
|
||||
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) <><><><><><><><>//
|
||||
if(true){
|
||||
$isTest = "master";
|
||||
$base_url = "/wms-app/";
|
||||
$server_url = $base_url."app/";
|
||||
$include_url = $_SERVER['DOCUMENT_ROOT'].$server_url;
|
||||
|
||||
$db_server = "db";
|
||||
$db_user = "root";
|
||||
$db_pass = "${DB_ROOT_PASSWORD}";
|
||||
// Least-privilege account created by the entrypoint (docker/php/entrypoint.sh);
|
||||
// root is only used there and by setup.php.
|
||||
$db_user = "${DB_APP_USER}";
|
||||
$db_pass = "${DB_APP_PASSWORD}";
|
||||
$db_type = "mysql";
|
||||
$db_database = "wms";
|
||||
$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";
|
||||
|
||||
$SMTP = [];
|
||||
|
||||
@@ -10,12 +10,32 @@ CONFIG=$APP_DIR/app/config.php
|
||||
[ "$OTP_REQUIRED" = "true" ] || OTP_REQUIRED=false
|
||||
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.
|
||||
# Restrict envsubst to known placeholders so it never touches the app's own
|
||||
# $variable syntax (envsubst blanks out any $NAME it doesn't recognize).
|
||||
if [ ! -f "$CONFIG" ]; then
|
||||
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"
|
||||
fi
|
||||
|
||||
@@ -45,7 +65,10 @@ until mysqladmin ping -h db -u root -p"$DB_ROOT_PASSWORD" --silent 2>/dev/null;
|
||||
sleep 2
|
||||
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)"
|
||||
php "$APP_DIR/setup.php" || true
|
||||
DB_SETUP_USER=root DB_SETUP_PASSWORD="$DB_ROOT_PASSWORD" php "$APP_DIR/setup.php" || true
|
||||
|
||||
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 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) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = JSON.stringify(body);
|
||||
|
||||
+49
-18
@@ -1,9 +1,42 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
const { createServer } = require('http');
|
||||
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 httpServer = createServer(app);
|
||||
const io = new Server(httpServer, {
|
||||
@@ -17,8 +50,8 @@ app.use(express.json());
|
||||
// Body: { event, data, company_id }
|
||||
//
|
||||
app.post('/emit', (req, res) => {
|
||||
const secret = req.headers['x-emit-secret'];
|
||||
if (secret !== process.env.EMIT_SECRET) {
|
||||
const secret = req.headers['x-emit-secret'] || '';
|
||||
if (!safeEqual(secret, EMIT_SECRET)) {
|
||||
return res.status(403).json({ ok: false, message: 'Forbidden' });
|
||||
}
|
||||
|
||||
@@ -46,28 +79,26 @@ app.post('/emit', (req, res) => {
|
||||
});
|
||||
|
||||
// ── /health ───────────────────────────────────────────────────────────────────
|
||||
// Deliberately public (used by uptime checks); reports status only.
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
uptime: Math.floor(process.uptime()),
|
||||
connections: io.engine.clientsCount,
|
||||
memory_mb: Math.round(process.memoryUsage().rss / 1024 / 1024 * 10) / 10
|
||||
});
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
// ── WebSocket connections ─────────────────────────────────────────────────────
|
||||
// Each browser tab connects here on page load.
|
||||
// It joins a room named company_<id> so events stay isolated per company.
|
||||
// Each browser tab connects here on page load with a token PHP signed for the
|
||||
// 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) => {
|
||||
const company_id = socket.handshake.query.company_id;
|
||||
const user_id = socket.handshake.query.user_id;
|
||||
const role = socket.handshake.query.role || 'viewer';
|
||||
io.use((socket, next) => {
|
||||
const claims = verifySocketToken(socket.handshake.auth && socket.handshake.auth.token);
|
||||
if (!claims) return next(new Error('unauthorized'));
|
||||
socket.data.claims = claims;
|
||||
next();
|
||||
});
|
||||
|
||||
if (!company_id) {
|
||||
socket.disconnect();
|
||||
return;
|
||||
}
|
||||
io.on('connection', (socket) => {
|
||||
const { c: company_id, u: user_id, r: role } = socket.data.claims;
|
||||
|
||||
socket.join(`company_${company_id}`);
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
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) {
|
||||
fwrite(STDERR, "Usage: php adjust_docx_whitespace.php <document.docx>\n");
|
||||
exit(2);
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<?php
|
||||
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
|
||||
* Record to every generated Progress Status Record HTML file. Markdown source
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
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';
|
||||
|
||||
$records = [
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
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/';
|
||||
$w = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
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/'
|
||||
. '200-WMS-26-001-00 Work Schedule 25690105 V1.0 Final.xlsx';
|
||||
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
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';
|
||||
$english = [
|
||||
'January'=>'01','February'=>'02','March'=>'03','April'=>'04','May'=>'05','June'=>'06',
|
||||
|
||||
@@ -26,8 +26,10 @@ if (!file_exists($config)) {
|
||||
require $config;
|
||||
|
||||
$host = $db_server ?? 'localhost';
|
||||
$user = $db_user ?? 'root';
|
||||
$pass = $db_pass ?? '';
|
||||
// The app's own account may be least-privilege; schema changes need an admin
|
||||
// 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';
|
||||
$db2 = $db_database2 ?? 'wms2';
|
||||
|
||||
@@ -225,6 +227,20 @@ CREATE TABLE IF NOT EXISTS `whitelist` (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
|
||||
", '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
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -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)");
|
||||
}
|
||||
|
||||
// ── 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 ───────────────────────────────────────────────────────────────────
|
||||
$total = $ok_count + $skip_count + $err_count;
|
||||
echo "\n\033[1m=== Done ===\033[0m\n";
|
||||
|
||||
Reference in New Issue
Block a user