Prepare shop-ready deployment docs and fix transaction modal UI
This commit is contained in:
@@ -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
@@ -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
@@ -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'})`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user