import 'express-async-errors'; import 'dotenv/config'; import express from 'express'; import cors from 'cors'; import cookieParser from 'cookie-parser'; import rateLimit from 'express-rate-limit'; import helmet from 'helmet'; import multer from 'multer'; import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; import sharp from 'sharp'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import { nanoid } from 'nanoid'; import path from 'node:path'; import fs from 'node:fs'; import { fileURLToPath } from 'node:url'; import { randomInt, createHmac, randomUUID } from 'node:crypto'; import { db, rowToArticle, rowToRiskSignal, rowToPrice, rowToEvent, rowToTeamMember, rowToPlan, rowToBanner, rowToRadarItem, rowToRadarPage, rowToFactoryReport, rowToIntegration, rowToInstituteStat, rowToMarketPrice, rowToMarketChartPoint, rowToVisionItem, rowToAdvisoryMember } from './db.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PORT = Number(process.env.PORT || 3001); const JWT_SECRET = process.env.JWT_SECRET; if (!JWT_SECRET || JWT_SECRET.length < 32) { console.error('FATAL: JWT_SECRET env var is missing or shorter than 32 chars. Set it before starting.'); process.exit(1); } // refuse to run with a leftover placeholder secret — a guessable JWT_SECRET means // anyone can forge an admin session, which defeats every other auth control. if (/change-me|replace-this|replace-with|your-secret|example|placeholder/i.test(JWT_SECRET)) { console.error('FATAL: JWT_SECRET is still a placeholder. Set a real random value, e.g. `openssl rand -hex 32`.'); process.exit(1); } const PUBLIC_ORIGIN = process.env.PUBLIC_ORIGIN || `http://localhost:${PORT}`; // Canonical base for media URLs. The object-storage host blocks browser User-Agents, // so we never hand browsers a direct storage URL — they go through this app's /media proxy. const MEDIA_BASE = (process.env.MEDIA_BASE || PUBLIC_ORIGIN).replace(/\/+$/, ''); // On Liara, set UPLOADS_DIR to a mounted Disk path (e.g. /var/lib/uploads) so // uploaded images survive redeploys. Defaults to a local folder for dev. const uploadsDir = process.env.UPLOADS_DIR || path.join(__dirname, 'uploads'); // Only needed for local-disk fallback. On Liara uploads go to object storage and // the app dir may be read-only — never let a failed mkdir crash startup. try { if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true }); } catch (e) { console.warn(`[uploads] could not create ${uploadsDir}: ${e.message} (ok when using object storage)`); } // buffer the upload in memory so we can transcode it to WebP before writing to disk const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 10 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { if (/^image\/(jpe?g|png|webp|gif|avif|tiff?)$/.test(file.mimetype)) cb(null, true); else cb(new Error('Only image uploads are allowed (jpeg, png, webp, gif)')); }, }); // raw (non-image) uploads — e.g. videos — streamed straight to object storage. const uploadRaw = multer({ storage: multer.memoryStorage(), limits: { fileSize: 200 * 1024 * 1024 }, fileFilter: (_req, file, cb) => { if (/^(video\/(mp4|webm|ogg|quicktime|x-matroska)|audio\/(mpeg|mp3|wav|x-wav|ogg|webm|aac|x-m4a|mp4)|application\/pdf)$/.test(file.mimetype)) cb(null, true); else cb(new Error('Only video, audio or PDF uploads are allowed')); }, }); // ── Object storage (Liara / any S3-compatible) ── // Enabled when all LIARA_* env vars are set; otherwise uploads fall back to the local disk. const S3_ENABLED = !!( process.env.LIARA_ENDPOINT && process.env.LIARA_BUCKET && process.env.LIARA_ACCESS_KEY && process.env.LIARA_SECRET_KEY ); const s3Host = (process.env.LIARA_ENDPOINT || '').replace(/^https?:\/\//, '').replace(/\/+$/, ''); const s3 = S3_ENABLED ? new S3Client({ region: 'default', endpoint: `https://${s3Host}`, credentials: { accessKeyId: process.env.LIARA_ACCESS_KEY, secretAccessKey: process.env.LIARA_SECRET_KEY, }, forcePathStyle: true, }) : null; // upload a buffer to the bucket and return its public URL (bucket must allow public reads) async function putObject(key, body, contentType) { await s3.send(new PutObjectCommand({ Bucket: process.env.LIARA_BUCKET, Key: key, Body: body, ContentType: contentType, })); return `${MEDIA_BASE}/media/${key}`; } // Validate a file by its magic bytes (content), not the client-supplied mimetype. // Returns the detected family ('image'|'video'|'audio'|'pdf') or null if not allowed. function sniffKind(buf) { if (!buf || buf.length < 12) return null; const b = buf; const hex = (n) => b.subarray(0, n).toString('hex'); if (b.subarray(0, 4).toString('ascii') === '%PDF') return 'pdf'; // PDF if (hex(3) === 'ffd8ff') return 'image'; // JPEG if (hex(8) === '89504e470d0a1a0a') return 'image'; // PNG if (b.subarray(0, 4).toString('ascii') === 'GIF8') return 'image'; // GIF if (b.subarray(0, 4).toString('ascii') === 'RIFF' && b.subarray(8, 12).toString('ascii') === 'WEBP') return 'image'; if (hex(4) === '49492a00' || hex(4) === '4d4d002a') return 'image'; // TIFF if (b.subarray(4, 8).toString('ascii') === 'ftyp') return 'video'; // MP4/MOV/m4a if (hex(4) === '1a45dfa3') return 'video'; // WebM/MKV if (hex(2) === 'fffb' || hex(2) === 'fff3' || b.subarray(0, 3).toString('ascii') === 'ID3') return 'audio'; // MP3 if (b.subarray(0, 4).toString('ascii') === 'OggS') return 'audio'; // Ogg if (b.subarray(0, 4).toString('ascii') === 'fLaC') return 'audio'; // FLAC return null; } // only object-storage keys produced by this app (nanoid + extension) are valid — // blocks path traversal (e.g. ..%2F..) into arbitrary bucket keys const SAFE_KEY = /^[A-Za-z0-9_-]+\.[A-Za-z0-9]+$/; // reject non-JSON bodies on state-changing routes. With strict CORS this forces a // preflight (which a cross-site attacker can't pass), so it doubles as CSRF defence. function jsonOnly(req, res, next) { if (!req.is('application/json')) return res.status(415).json({ error: 'json_required' }); next(); } const app = express(); app.set('trust proxy', 1); // honor X-Forwarded-Proto from the reverse proxy // Send the session cookie with Secure whenever the request actually arrived over HTTPS, // so a forgotten NODE_ENV can't downgrade the cookie to plaintext behind a TLS proxy. const cookieSecure = (req) => req.secure || req.headers['x-forwarded-proto'] === 'https'; const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || 'http://localhost:5173') .split(',').map(s => s.trim()); // any localhost/127.0.0.1 origin (any port) is allowed in addition to ALLOWED_ORIGINS — // the panel serves its own SPA, whose module scripts fetch same-origin with an Origin // header; rejecting that 500s app.js. Disallowed origins get cb(null,false) (no CORS // headers) rather than a thrown error, so a bad cross-origin request never 500s static assets. // localhost (any port) is allowed only in dev — in production it would widen the // credentialed-CORS allowlist to any local service on a victim's machine. const isLocalhost = (o) => process.env.NODE_ENV !== 'production' && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(o); app.use(cors({ origin: (origin, cb) => { cb(null, !origin || ALLOWED_ORIGINS.includes(origin) || isLocalhost(origin)); }, credentials: true, })); app.use(cookieParser()); app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], imgSrc: ["'self'", "data:", "blob:", ...(s3Host && process.env.LIARA_BUCKET ? [`https://${process.env.LIARA_BUCKET}.${s3Host}`] : [])], connectSrc: ["'self'"], fontSrc: ["'self'"], objectSrc: ["'none'"], frameAncestors: ["'none'"], }, }, frameguard: { action: 'deny' }, })); app.use(express.json({ limit: '1mb' })); // Public media proxy: streams an object from storage server-side and relays it from // our own domain. Browsers are blocked from the storage host by a User-Agent filter, // so this is the only way an /