Prepare shop-ready deployment docs and fix transaction modal UI

This commit is contained in:
Krikorios
2026-05-20 19:47:55 +03:00
parent 1896cbdd11
commit 1f23102050
16 changed files with 584 additions and 60 deletions
-6
View File
@@ -1,6 +0,0 @@
# Server config (copy to server/.env or set in your shell)
PORT=4000
DATABASE_URL=postgres://postgres:postgres@localhost:5432/crm_omt
JWT_SECRET=change-me-to-a-long-random-string
JWT_EXPIRES_IN=12h
CORS_ORIGIN=http://localhost:5173,http://localhost:8080
+9 -6
View File
@@ -1,9 +1,12 @@
# Server config (copy to server/.env or set in your shell)
# Production API config (copy to server/.env)
NODE_ENV=production
PORT=4000
DATABASE_URL=postgres://postgres:postgres@localhost:5432/crm_omt
JWT_SECRET=change-me-to-a-long-random-string
BIND_HOST=127.0.0.1
DATABASE_URL=postgres://postgres:replace-with-db-password@localhost:5432/crm_omt
JWT_SECRET=replace-with-a-long-random-string-at-least-32-chars
JWT_EXPIRES_IN=12h
CORS_ORIGIN=http://localhost:5173,http://localhost:8080
# Frontend clients on the same LAN can use the host machine's IP automatically.
# Add fixed origins here if you want to restrict access more tightly.
# Same-origin when SPA is served by this API.
# Keep localhost/127.0.0.1 unless you intentionally expose the service behind a reverse proxy.
CORS_ORIGIN=http://localhost:4000,http://127.0.0.1:4000
ENABLE_LOCAL_TEST_ROUTES=0
+79 -8
View File
@@ -4,17 +4,39 @@ import cors from 'cors';
import pkg from 'pg';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import path from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
const { Pool } = pkg;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const IS_PROD = process.env.NODE_ENV === 'production';
const BIND_HOST = process.env.BIND_HOST || (IS_PROD ? '127.0.0.1' : '0.0.0.0');
const PORT = Number(process.env.PORT || 4000);
const DATABASE_URL = process.env.DATABASE_URL || 'postgres://postgres:postgres@localhost:5432/crm_omt';
if (IS_PROD) {
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) {
console.error('[server] refusing to start: JWT_SECRET missing or too short in production');
process.exit(1);
}
if (/:(postgres|password|change[-_]?me)@/i.test(DATABASE_URL)) {
console.error('[server] refusing to start: DATABASE_URL still uses a default password');
process.exit(1);
}
}
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '12h';
const LOCAL_DEV_TOOLS_ENABLED = process.env.ENABLE_LOCAL_TEST_ROUTES === '1'
|| (process.env.NODE_ENV !== 'production' && /localhost|127\.0\.0\.1/i.test(DATABASE_URL));
const CORS_ORIGINS = (process.env.CORS_ORIGIN || 'http://localhost:5173,http://localhost:8080')
const LOCAL_DEV_TOOLS_ENABLED = !IS_PROD
&& (process.env.ENABLE_LOCAL_TEST_ROUTES === '1'
|| /localhost|127\.0\.0\.1/i.test(DATABASE_URL));
const CORS_ORIGINS = (process.env.CORS_ORIGIN || (IS_PROD ? '' : 'http://localhost:5173,http://localhost:8080'))
.split(',').map(s => s.trim()).filter(Boolean);
// Always allow same-origin requests when we're serving the SPA from this server.
const SAME_ORIGIN_HOSTS = ['localhost', '127.0.0.1'];
const STATIC_DIR = process.env.STATIC_DIR
|| path.resolve(__dirname, '..', '..', 'dist');
const SERVE_STATIC = IS_PROD && fs.existsSync(STATIC_DIR);
function isPrivateIpv4(hostname) {
return /^10\./.test(hostname)
@@ -26,10 +48,15 @@ function isPrivateIpv4(hostname) {
function isAllowedOrigin(origin) {
if (!origin) return true;
if (CORS_ORIGINS.includes(origin)) return true;
// Same-origin: when the SPA is served from this server, the browser sends
// Origin: http://<host>:<PORT>. Allow that pair regardless of NODE_ENV.
try {
const url = new URL(origin);
return ['localhost', '127.0.0.1'].includes(url.hostname) || isPrivateIpv4(url.hostname);
if (SAME_ORIGIN_HOSTS.includes(url.hostname) && Number(url.port || (url.protocol === 'https:' ? 443 : 80)) === PORT) {
return true;
}
if (IS_PROD) return false; // in prod, only explicit list + same-origin
return SAME_ORIGIN_HOSTS.includes(url.hostname) || isPrivateIpv4(url.hostname);
} catch {
return false;
}
@@ -38,12 +65,45 @@ function isAllowedOrigin(origin) {
const pool = new Pool({ connectionString: DATABASE_URL, max: 10 });
const app = express();
// Same-origin requests (where the browser's Origin host:port matches the
// request's own Host header) are always allowed. This makes the API + SPA
// combo work whether the cashier opens http://localhost:4000,
// http://127.0.0.1:4000, or http://thispc.local:4000.
function isSameOrigin(req) {
const origin = req.get('origin');
if (!origin) return true;
try {
const o = new URL(origin);
const host = req.get('host') || '';
return `${o.hostname}:${o.port || (o.protocol === 'https:' ? '443' : '80')}` === host
|| o.host === host;
} catch {
return false;
}
}
app.use(cors({
origin(origin, callback) {
callback(isAllowedOrigin(origin) ? null : new Error('Not allowed by CORS'), isAllowedOrigin(origin));
// Pass-through; per-request same-origin check happens below.
if (!origin) return callback(null, true);
if (isAllowedOrigin(origin)) return callback(null, true);
return callback(null, false);
},
credentials: true,
}));
// Belt + suspenders: if the cors() middleware rejected based on a stale
// allowlist but the request is actually same-origin, let it through.
app.use((req, res, next) => {
if (isSameOrigin(req)) {
const origin = req.get('origin');
if (origin && !res.get('Access-Control-Allow-Origin')) {
res.set('Access-Control-Allow-Origin', origin);
res.set('Vary', 'Origin');
}
}
next();
});
app.use(express.json({ limit: '1mb' }));
// ---- helpers ---------------------------------------------------------
@@ -405,6 +465,17 @@ app.get('/health', async (_req, res) => {
catch (e) { res.status(500).json({ ok: false, error: e.message }); }
});
app.listen(PORT, () => {
console.log(`[server] listening on http://localhost:${PORT}`);
// ---- static SPA ------------------------------------------------------
// In production we serve the React build from this same process so there
// is only one port to manage. CORS is not crossed when the SPA and API
// share an origin, which is the whole point on the shop's local box.
if (SERVE_STATIC) {
console.log(`[server] serving SPA from ${STATIC_DIR}`);
app.use(express.static(STATIC_DIR, { index: false, maxAge: '1h' }));
app.get(/^\/(?!auth|rpc|from|employees|employee_transactions|admin|health).*/,
(_req, res) => res.sendFile(path.join(STATIC_DIR, 'index.html')));
}
app.listen(PORT, BIND_HOST, () => {
console.log(`[server] listening on http://${BIND_HOST}:${PORT} (NODE_ENV=${process.env.NODE_ENV || 'development'})`);
});