0874a30ac9
* feat(docker): add all-in-one image — backend + frontend in one container (#1042) One container, one Node process, SQLite by default: `docker run` with no compose file, no nginx, no supervisor, no bundled Postgres/Redis. - Dockerfile.aio (repo-root context): frontend build stage + backend deps stage + a runtime stage mirroring backend/Dockerfile's production stage, with the built SPA copied to /app/frontend/dist and SERVE_FRONTEND=true. DATABASE_CLIENT=sqlite3 and STORAGE_PATH=/app/storage are pinned explicitly — the storage fallback resolves to container-root /storage, which EACCESes after the su-exec drop. - server.js: the SERVE_FRONTEND block now does what the nginx image did — renders ${BRAND_TITLE}/${BRAND_DESCRIPTION} into index.html once at boot, serves that rendered shell on /index.html and every SPA route, caches hashed /assets/* immutably while the shell revalidates, and gzips the bundle via compression() mounted after all /api routers. express.static now runs with index:false so `/` keeps flowing to handlePublicSiteRequest — its default index option was shadowing the landing page on native installs. - wait-for-db.sh: skip the Postgres readiness wait when DATABASE_CLIENT is sqlite3. The engine resolver still runs, still logs, and still refuses the populated-both conflict (#1038). - .dockerignore: **/node_modules, so the root-context build can't pick up host deps from backend/ or frontend/. - docker-build.yml: build-aio / merge-aio follow the same per-arch build → digest-merge → per-version tag scheme as backend/frontend (GHCR only for now; the Docker Hub mirror is wired once the Hub repo exists), plus a smoke-aio job that boots the image on every PR and asserts /health, the SPA shell, the rendered brand title, immutable asset caching and the SQLite engine resolution. Pointing DB_HOST/DB_USER/DB_PASSWORD + DATABASE_CLIENT=pg at an external Postgres works exactly like the backend image. * fix(ci): correct three smoke-aio assertions that would fail a green image (#1042) Found by running the smoke job locally against a real build — the image passed every behavioral check, but three assertions were wrong: - `/` asserts 200, but handlePublicSiteRequest 302s to /admin/login while the public landing site is disabled, which is the state of the fresh install the smoke container always is. Assert the redirect target instead — that still proves express.static's index option is not shadowing the handler, which is the thing the check exists for. - The placeholder-leak grep matched index.html's explanatory comment, which mentions BRAND_TITLE in prose and survives into the built shell. Match the literal ${BRAND_TITLE}/${BRAND_DESCRIPTION} tokens with -F, and cover the description token too. - Add a gzip assertion, probing with GET: the compression middleware skips bodyless responses, so a HEAD probe reports no Content-Encoding even when compression is active. Verified locally on linux/arm64: image builds clean, boots to healthy in ~8s on the SQLite default, and 25/25 checks pass (SPA shell, rendered brand title, immutable+gzipped assets, no-store shell, SPA fallbacks, npm removed, su-exec drop to nodejs, no errors in the boot log). The DATABASE_CLIENT=pg override was exercised against a real Postgres too — the readiness wait still runs and the engine resolves to postgres. * fix(server): serve the SPA for every client route, not just /admin and /gallery (#1042) nginx did `try_files $uri $uri/ /index.html`, so behind compose every client-side route survived a direct hit or a refresh and the short `['/admin', '/admin/*', '/gallery/*']` list was never exercised. Without nginx that list is the whole contract, and everything outside it 404'd: /setup /customer /impressum /datenschutz /payment-check /quote/:token /contract/:token /invite/:token /transfer/:token /transfer-upload/:token /setup is the first URL a new install visits, so the all-in-one image was unusable from a cold start. The catch-all is registered after `app.use('/api', notFoundHandler)`, so an unknown /api route still answers JSON instead of being handed the HTML shell, and after the /s/:shortSlug resolver, so a typo'd short URL still 404s (#699). It is GET-only — a stray POST keeps 404ing rather than getting a 200 page back. The handler is hoisted out of the SERVE_FRONTEND block via `spaCatchAll` because that block runs before the API 404 handler is registered. Verified on the built image: all ten routes above now 200, /api/nope still returns JSON 404, /s/nonexistent still returns 404, / still 302s to /admin/login, and the smoke suite is 25/25. Both boundaries are now asserted in the smoke-aio job. * docs(readme): document the single-container install (#1042) The README had no mention of the all-in-one image, so the only way to discover it was reading the workflow file. Adds a Quick Start subsection with the one-line `docker run` and the `docker exec … cat SETUP_TOKEN` step, plus a row in the documentation table. Deliberately does not sell it as the default: the note says the compose stack is still the right choice for anything busier, gives the reason (SQLite takes one writer at a time), and points at the `.picpeak` restore as the way out, so nobody picks it and then finds themselves stuck. Full details live at docs.picpeak.app/deployment/single-container (PicPeak/docs#8). * feat(docker): fold #1067's items into the all-in-one image (#1042) Consolidating the two parallel AIO branches into this one. This PR's approach is kept wherever the two differed on design — in particular the in-process brand render, `index: false` (which fixes express.static shadowing handlePublicSiteRequest, a bug #1067 had), the compression middleware, and the smoke-aio job. What follows is what #1067 had that this branch did not. Layout — the issue asks for a single mountable root, and this moves to one: /data/db picpeak.db (+ -wal/-shm) and SETUP_TOKEN /data/storage originals, thumbnails, archives /data/logs application logs /data/backup built-in backup output; /backup symlinks here `-v picpeak:/data` and nothing else to remember. README and the smoke job's database-path assertion follow the new layout. Correctness items: - sqlite CLI. DatabaseBackupService SPAWNS `sqlite3` for `.backup` and PRAGMA integrity_check; the npm module does not ship that binary. backend/Dockerfile omits it because compose always runs Postgres — this image defaults to SQLite, so every database backup failed with ENOENT. - /backup wired in. Migrations 029 + 030 seed /backup/picpeak and /backup/database as the backup destinations; nothing created or mounted them, so backups had nowhere to write and anything written would die with the container. Symlinked into the volume, subdirectories created at startup (a bind mount hides the tree baked into the image), and adopted only when BACKUP_DIR is set so it never gates boot for compose deployments that do not mount it. - logger.js honours LOG_DIR. It hard-coded <backend>/logs, so logs could not leave the container. Unset keeps the old path for every existing install. - wait-for-db.sh derives its writable roots from STORAGE_PATH / DATA_DIR / LOG_DIR instead of hard-coded /app paths, and mkdir -p's them before chown — a bind-mounted /data hides the image's tree, and chown against a missing path reports "the filesystem rejects chown", which is both wrong and a dead end. - .dockerignore excludes backend/-prefixed runtime data. Docker reads only the root file, so the unprefixed data/*.db, logs/* and storage/* rules missed backend/data, backend/logs and backend/storage entirely; a checkout used to run PicPeak would bake its database, photos, logs and SETUP_TOKEN into a published layer. - HEALTHCHECK follows $PORT rather than a hard-coded 3000. - --max-http-header-size=32768 matches nginx's large_client_header_buffers 4 32k; Node's 16 KiB default would reject a guest carrying several per-gallery JWT cookies. docs/single-container.md is added as the in-repo reference the README links to. The smoke job gains four assertions for the above: the one-volume layout and writable backup destinations, the sqlite3 CLI, logs landing on the volume, and the image carrying no runtime data from the build context. Verified on a built image — named volume, bind mount and PORT=8080 all healthy; every existing smoke assertion still passes, including / -> 302 /admin/login, the rendered BRAND_TITLE, immutable assets, gzip and /s/<unknown> -> 404. Co-authored-by: Luca-Timo <102960244+Luca-Timo@users.noreply.github.com> * fix(docker): restore the SPA-fallback exclusions and close the build-context leak (#1042) Both found by external review of the consolidated branch. - The SPA catch-all had no backend-owned exclusions. This was a regression I introduced while merging: #1067 carried a BACKEND_OWNED prefix list, and taking this branch's server.js wholesale (correctly — its index:false and in-process brand render are the better design) dropped it. /photos, /thumbnails, /uploads and /fonts are static mounts whose middleware calls next() on a miss, so the catch-all was answering 200 text/html under image and font URLs instead of 404. nginx gave each of those its own location block, so try_files never applied to them. - backend/data is now excluded wholesale rather than by suffix. The suffix list (*.db, *.db-wal, *.db-shm, SETUP_TOKEN) let real secrets through: a used checkout carries ADMIN_CREDENTIALS.txt next to the database, plus -journal files and any DATABASE_PATH not ending in .db. Since Dockerfile.aio builds from the repository root and COPYs backend/ wholesale, any of those would be baked into a published layer. The directory holds only runtime state and is already gitignored in full. smoke-aio gains an assertion that the backend static routes still 404, so the exclusion cannot be dropped again silently. Verified on a built image: /photos, /thumbnails, /fonts and /uploads misses all 404; /setup, /impressum, /gallery/x, /admin/login still 200; / still 302s to /admin/login; /api/nope still answers JSON; /s/<unknown> still 404s; and the image carries no *.db, ADMIN_CREDENTIALS.txt, logs or storage from the context. * fix(aio): three failures that only surface outside a dev laptop (#1042) Backups aborted on SQLite. getTableChecksums() built its digest with `CAST(t.* AS TEXT)`, which is Postgres row-to-text syntax; SQLite parses `*` there as a syntax error, so every backup threw before reaching the .backup call. Since the all-in-one image ships SQLite by default, that is every AIO install. Enumerate the columns via columnInfo() and sum their lengths instead. The shared /data mount root was never adopted. wait-for-db.sh chowned the children it creates but not the mount point itself, so a host directory arriving as 0700 with a foreign owner stayed untraversable by UID 1001 after the su-exec drop. Docker Desktop's permissive bind mounts hide this completely, which is why local testing passed; a NAS share does not. DATA_ROOT is now adopted first. Maintenance mode locked the admin out of the box. The middleware runs at server.js:493, long before the static block at 891, and exempted the auth endpoints but not the page that calls them. With the backend serving the frontend, /admin/login and /assets/* returned 503 JSON, so an admin who enabled maintenance mode could never load the UI to turn it off. nginx serves those paths in the compose stack, which is why it never surfaced there. Guest and API surfaces stay gated. Verified on a built image: checksums compute across all 95 tables; a bind mount created 0700/4000:4000 boots healthy and ends up 1001:1001; with general_maintenance_mode=true, /admin/login, /admin and /assets/* return 200 while /gallery/* and /api/gallery/* return 503 — and 503 across all three once the exemption is removed again. Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc * fix(aio): stop leaking .env into the image, fix the broken checksum test (#1042) The Jest suite was red: mocking db.raw is no longer enough now that the SQLite checksum branch asks the query builder for its column list, so db(table) came back undefined and getTableChecksums failed on every PR. The production code is right; the fixture needed to know about the call. backend/.env was landing in the published layer. The root ignore file's `.env`, `.env.*` and `data/*.db` rules read as unanchored but Docker matches them from the context root, so they catch ./.env and never backend/.env — and `COPY backend/ .` then puts a real JWT_SECRET at /app/.env. Matched at any depth instead, the way **/node_modules in the same file already is. Confirmed by building from a checkout carrying a planted secret: before, `cat /app/.env` printed it back. Business documents wrote outside the volume. quoteService, invoice sending/reminders and contract signatures build paths from process.cwd()/storage and never read STORAGE_PATH; compose hides it by setting STORAGE_PATH=/app/storage with WORKDIR /app so the two are the same directory. Here they are not, and /app is root-owned, so a quote or invoice PDF failed to write as UID 1001 — and would not survive the container if it had. Symlinked /app/storage into the volume, matching the /backup symlink beside it. Teaching those services STORAGE_PATH is the real fix and wants its own change. Two smaller ones: the mount root is now chowned shallow rather than recursively, since every child below it is already walked recursively and a NAS-sized photo library should not be traversed twice on each restart; and /assets/ joins the backend-owned prefixes, so a stale hashed chunk requested by a tab left open across an upgrade gets a 404 instead of index.html served with 200 under a .js URL. Verified on a built image: planted backend/.env and backend/probe.db are absent; /app/storage resolves to /data/storage and a business-doc write as UID 1001 appears on the host; a 0700 bind mount owned by 4000:4000 boots healthy; a missing /assets chunk 404s while the real bundle still serves 200 as application/javascript. The databaseBackup suite is green again, and the branch adds no failing suite that origin/main does not already fail on the same machine. Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc * test(aio): teach the leak assertion about the storage symlink (#1042) The previous check listed /app/storage/events and treated a hit as a leak. That was true while /app/storage was either absent or a copied directory; now it is a symlink into the volume, so the check followed it and found the empty tree the image itself creates — a false positive on its own design. Check the shape instead: /app/storage must be a symlink pointing at /data/storage, and the volume's photo tree must contain no files on a fresh install. A real directory there now fails loudly, which is the condition the assertion was always trying to catch. Also extended the path list to /app/.env and loose database files, matching the .dockerignore rules added alongside. Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc * fix(aio): show the maintenance screen instead of raw JSON to guests (#1042) The previous commit exempted the admin shell so an admin could still reach the switch they had just flipped. Guests had the same problem for the same reason: with no nginx in front, /gallery/<slug> reaches this middleware long before the static block, so a visitor during maintenance got a 503 JSON body where every other deployment shows the branded maintenance screen the frontend already ships. Replaced the two path-specific exemptions with the rule they were both special cases of: a GET that is not an API call and not a backend-owned content mount is the SPA shell, and the shell is inert HTML — it boots, reads /api/public/settings (already exempt) and renders MaintenanceMode on its own. Everything that carries real data stays gated: /api/*, /photos/, /thumbnails/, /fonts/, and any non-GET. Compose is untouched by construction, since nginx answers those paths and they never arrive here. Verified on a built image with the flag on: /gallery/x, /customer/x, /admin and /admin/login return 200 text/html while /api/gallery/x/verify, /photos/x.jpg and /thumbnails/x.jpg return 503 and a POST to a public API still returns 503; with the flag off the same paths go back to 404. Added a middleware test over that exemption matrix — over-exemption is the real risk in this change, so it asserts the gated half too. It fails on five cases without the fix. Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc * fix(aio): stop the shell exemption from un-gating /og and the public CMS (#1042) The previous commit exempted "any GET that is not an API call". That negative rule reads as safe and is not: /og/gallery/<slug> and its /cover render the event name and the hero thumbnail, /s/<code> renders short-link previews, and `/` is handed to the public CMS. All four are proxy_passed to the backend by nginx, so they were gated before this PR in every deployment — the rule un-gated them, and for compose too, not just the new image. A site switched to maintenance would have kept publishing gallery metadata. Replaced the guess with the split nginx already defines: exempt what the frontend container answers itself, gate what it proxies. That is the same rule the all-in-one image needs by definition, since its whole job is to be both halves of that stack, and it now matches compose in both directions rather than only in the direction the last commit tested. Verified on a built image with the flag on: /admin/login, /gallery/<slug> and /customer/* return 200, while /, /og/gallery/x, /og/gallery/x/cover, /s/abc, /robots.txt, /api/* and /photos/* return 503; with the flag off all of them behave normally again. The middleware test grew the gated cases — it now covers 21, most of them asserting what must NOT be exempt. Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc * fix(aio): give the image a FRONTEND_URL default so share links are absolute (#1042) getFrontendBaseUrl() reads FRONTEND_URL, falls back to the general_site_url setting, and otherwise returns an empty string — which makes share_url come back as a bare "/gallery/<slug>/<token>". Compose defaults the variable to http://localhost:3000, but the documented one-liner for this image passes only JWT_SECRET, so every fresh single-container install handed out relative links in API responses, QR codes and emails. Defaulted to the same value compose uses; -e FRONTEND_URL=https://... overrides it, as does the site URL field in Settings. Found by pointing tests/e2e/local at a running AIO container: auth/06-api-tokens asserts share_url matches /^https?:\/\//, and it was the one spec that failed for a product reason rather than a harness one. It passes now, and the suite is 19/20 against the image — the remaining failure is smoke/02-auth-flow, whose seed helper shells out to a hard-coded `docker exec picpeak-backend`, so it cannot arrange its precondition against any other container. Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc * feat(aio): mark the image so face recognition stays off (#1042, #1074) Face recognition needs a separate ML container this image does not contain, and enabling it here would add a second image-processing pipeline competing with Sharp for the CPU and memory of a container sized for one photographer plus guests browsing. The failure mode would not be a clear error — just a slow install that looks broken. The backend gate for this lands in #1075 and keys on PICPEAK_SINGLE_CONTAINER. Without this line the guard never triggers on an actual all-in-one build, so the two changes have to arrive together: whichever merges second completes the pair. Verified against this file's exact value — isFeatureEnabled() returns false with it set. An explicit marker rather than inferring from SERVE_FRONTEND or the SQLite path, because legitimate multi-container deployments do both of those and should keep the feature. Also adds it to the Limits section of docs/single-container.md, next to the SQLite and Redis constraints, since that is where someone will look before choosing this image. --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local> Co-authored-by: the-luap <paul-nothaft@hotmail.de>
1215 lines
53 KiB
JavaScript
1215 lines
53 KiB
JavaScript
require('dotenv').config();
|
|
|
|
// Validate critical environment variables before proceeding
|
|
const { validateEnvironment } = require('./src/config/validateEnv');
|
|
validateEnvironment();
|
|
|
|
// Resolve which database engine this process should use, BEFORE anything
|
|
// requires knexfile/db (#1038). wait-for-db.sh normally does this and exports
|
|
// DATABASE_CLIENT, but a Kubernetes manifest that sets `command`/`args`, or a
|
|
// plain `docker run … node server.js`, bypasses the entrypoint entirely — and
|
|
// those are exactly the deployments this fix is for. Without this, such an
|
|
// install would resolve to Postgres (NODE_ENV is baked into the image now) and
|
|
// come up against an empty database while its SQLite data sat there unseen.
|
|
//
|
|
// spawnSync because the decision needs an async Postgres probe and this must
|
|
// happen before the first `require` of knexfile. It short-circuits without
|
|
// probing when DATABASE_CLIENT is already set, so the entrypoint path pays
|
|
// nothing.
|
|
// Also run it when a migration pin exists: an explicit DATABASE_CLIENT=pg
|
|
// would otherwise skip the check and start against a half-migrated Postgres
|
|
// while SQLite is still the database of record.
|
|
if (!process.env.DATABASE_CLIENT
|
|
|| require('./src/utils/databaseEngine').hasMigrationInProgress()) {
|
|
const { spawnSync } = require('child_process');
|
|
const probe = spawnSync(
|
|
process.execPath,
|
|
[require('path').join(__dirname, 'scripts', 'resolve-db-engine.js')],
|
|
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }
|
|
);
|
|
// Exit 3: two populated databases and no record of which is authoritative.
|
|
// The resolver has printed the comparison and the two ways to resolve it;
|
|
// starting either engine would hide the other's data.
|
|
if (probe.status === 3) {
|
|
process.exit(1);
|
|
}
|
|
const resolved = (probe.stdout || '').trim();
|
|
if (probe.status === 0 && resolved) {
|
|
process.env.DATABASE_CLIENT = resolved;
|
|
// Pin the CONNECTION too, not just the client. knexfile's development block
|
|
// defaults Postgres to localhost/postgres/photo_sharing and production to
|
|
// db/picpeak/picpeak, so naming only the client can point this process at a
|
|
// different database than the resolver probed — with SQLite already retired.
|
|
if (resolved === 'pg') {
|
|
const conn = require('./src/utils/databaseEngine').pgConnectionFromEnv();
|
|
process.env.DB_HOST = String(conn.host);
|
|
process.env.DB_PORT = String(conn.port);
|
|
process.env.DB_USER = String(conn.user);
|
|
process.env.DB_NAME = String(conn.database);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize logger early to capture startup logs
|
|
const logger = require('./src/utils/logger');
|
|
logger.info('Server starting up', {
|
|
nodeVersion: process.version,
|
|
environment: process.env.NODE_ENV || 'development',
|
|
// Which database this process actually talks to (#1038). Nothing logged this
|
|
// before, so an install silently running on SQLite with Postgres configured
|
|
// had no way to notice.
|
|
database: require('./src/utils/databaseEngine').describeEngine(require('./knexfile')),
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
const fs = require('fs');
|
|
const express = require('express');
|
|
const helmet = require('helmet');
|
|
const compression = require('compression');
|
|
const cors = require('cors');
|
|
const path = require('path');
|
|
const { initializeDatabase, db } = require('./src/database/db');
|
|
const { startFileWatcher } = require('./src/services/fileWatcher');
|
|
const { startExpirationChecker } = require('./src/services/expirationChecker');
|
|
const { startTransferCleanup } = require('./src/services/transferCleanupService');
|
|
const { startDownloadJobCleanup } = require('./src/services/downloadJobCleanupService');
|
|
const { startRevealScheduler } = require('./src/services/revealScheduler');
|
|
const { startInvoiceScheduler } = require('./src/services/invoiceSchedulerService');
|
|
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
|
|
const { startBackupService } = require('./src/services/backupService');
|
|
const { startScheduledBackups } = require('./src/services/databaseBackup');
|
|
const backgroundProcessor = require('./src/services/backgroundProcessor');
|
|
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
|
|
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
|
|
const { errorHandler, notFoundHandler } = require('./src/middleware/errorHandler');
|
|
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
|
|
const { getPublicSitePayload } = require('./src/services/publicSiteService');
|
|
const cookieParser = require('cookie-parser');
|
|
const {
|
|
getAdminTokenFromRequest,
|
|
getGalleryTokenFromRequest,
|
|
} = require('./src/utils/tokenUtils');
|
|
|
|
// Import routes
|
|
const authRoutes = require('./src/routes/auth');
|
|
const galleryRoutes = require('./src/routes/gallery');
|
|
const adminRoutes = require('./src/routes/admin');
|
|
const adminAuthRoutes = require('./src/routes/adminAuth');
|
|
const secureImagesRoutes = require('./src/routes/secureImages');
|
|
const setupRoutes = require('./src/routes/setup');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
// Trust proxy headers (required for Traefik/nginx).
|
|
//
|
|
// `req.ip` is computed by Express by walking X-Forwarded-For from
|
|
// right-to-left and stopping at the first hop NOT in this list, so
|
|
// the value picpeak audits (signing IPs, payment-check actions,
|
|
// rate-limit keys) is the originating client IP behind any number
|
|
// of trusted reverse proxies.
|
|
//
|
|
// Default: 'loopback, linklocal, uniquelocal' — covers localhost,
|
|
// link-local (169.254.0.0/16), and unique-local IPv6 (fc00::/7).
|
|
// Standard for nginx-in-front-of-Node deployments on the same host
|
|
// and for Docker bridge networks. Operators with unusual topologies
|
|
// (load balancer in a public subnet, multi-hop NAT) override via
|
|
// TRUST_PROXY env, accepting any value Express accepts: a number,
|
|
// 'loopback', 'linklocal', 'uniquelocal', a CIDR, a comma list, or
|
|
// 'true' (trust ALL proxies — only safe behind a fully-controlled
|
|
// reverse-proxy chain).
|
|
//
|
|
// NEVER read req.headers['x-forwarded-for'] directly in audit paths
|
|
// — see utils/clientIp.js for the rationale.
|
|
const trustProxySetting = process.env.TRUST_PROXY || 'loopback, linklocal, uniquelocal';
|
|
app.set('trust proxy', trustProxySetting === 'true' ? true : trustProxySetting);
|
|
|
|
// Security middleware with custom CSP
|
|
// In native HTTP installs, do NOT force HTTPS for subresources.
|
|
const enableHsts = process.env.ENABLE_HSTS === 'true';
|
|
const cspDirectives = {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: [
|
|
"'self'",
|
|
'https://www.google.com',
|
|
'https://www.gstatic.com'
|
|
],
|
|
styleSrc: ["'self'", "'unsafe-inline'", "https:"], // Required for styled components
|
|
imgSrc: ["'self'", "data:", "https:", "blob:"], // Allow data URLs and external images
|
|
connectSrc: ["'self'", 'https://www.google.com', 'https://www.gstatic.com'], // API connections
|
|
fontSrc: ["'self'", "https:", "data:"], // Web fonts
|
|
objectSrc: ["'none'"], // Disable plugins
|
|
mediaSrc: ["'self'"], // Audio/video
|
|
frameSrc: ["'self'", 'https://www.google.com'],
|
|
};
|
|
// Only upgrade insecure requests when HSTS explicitly enabled (HTTPS deployment)
|
|
if (enableHsts) {
|
|
// In helmet, an empty array enables the directive
|
|
cspDirectives.upgradeInsecureRequests = [];
|
|
}
|
|
|
|
app.use(cookieParser());
|
|
|
|
app.use((req, res, next) => {
|
|
if (req.headers.authorization) {
|
|
return next();
|
|
}
|
|
|
|
const path = req.path || '';
|
|
const slugMatch = path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/);
|
|
const slug = slugMatch ? slugMatch[1] : req.requestedSlug;
|
|
const adminToken = getAdminTokenFromRequest(req);
|
|
const galleryToken = getGalleryTokenFromRequest(req, slug);
|
|
|
|
const isAdminRequest = path.startsWith('/api/admin') || path.startsWith('/admin');
|
|
const isGalleryRequest = Boolean(slugMatch)
|
|
|| path.startsWith('/api/gallery')
|
|
|| path.startsWith('/gallery')
|
|
|| path.startsWith('/api/secure-images');
|
|
|
|
// Prefer admin credentials on admin routes so gallery sessions cannot override them.
|
|
if (isAdminRequest) {
|
|
if (adminToken) {
|
|
req.headers.authorization = `Bearer ${adminToken}`;
|
|
}
|
|
} else if (isGalleryRequest) {
|
|
if (galleryToken) {
|
|
req.headers.authorization = `Bearer ${galleryToken}`;
|
|
} else if (adminToken) {
|
|
req.headers.authorization = `Bearer ${adminToken}`;
|
|
}
|
|
} else if (adminToken) {
|
|
req.headers.authorization = `Bearer ${adminToken}`;
|
|
} else if (galleryToken) {
|
|
req.headers.authorization = `Bearer ${galleryToken}`;
|
|
}
|
|
|
|
next();
|
|
});
|
|
|
|
app.use(helmet({
|
|
contentSecurityPolicy: {
|
|
// Avoid helmet adding defaults like upgrade-insecure-requests when not desired
|
|
useDefaults: false,
|
|
directives: cspDirectives,
|
|
},
|
|
hsts: enableHsts ? {
|
|
maxAge: 31536000, // 1 year
|
|
includeSubDomains: true,
|
|
preload: true
|
|
} : false,
|
|
permittedCrossDomainPolicies: false,
|
|
referrerPolicy: { policy: "strict-origin-when-cross-origin" }
|
|
}));
|
|
|
|
// Additional security headers
|
|
app.use((req, res, next) => {
|
|
// Permissions Policy (controls browser features)
|
|
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=()');
|
|
next();
|
|
});
|
|
|
|
// CORS configuration (apply only to API routes)
|
|
const corsOptions = {
|
|
origin: function (origin, callback) {
|
|
const allowedOrigins = [
|
|
process.env.FRONTEND_URL || 'http://localhost:3005',
|
|
process.env.ADMIN_URL || 'http://localhost:3005'
|
|
];
|
|
|
|
// In development, also allow localhost origins
|
|
if (process.env.NODE_ENV === 'development') {
|
|
allowedOrigins.push(
|
|
'http://localhost:5173', // Vite dev server
|
|
'http://localhost:3002', // Backend server
|
|
'http://localhost:3001', // For API testing
|
|
'http://localhost:3000' // Direct backend access
|
|
);
|
|
}
|
|
|
|
// Allow requests with no origin (like curl) and allow-listed origins
|
|
if (!origin || allowedOrigins.indexOf(origin) !== -1) {
|
|
callback(null, true);
|
|
} else {
|
|
// Do not error globally; just omit CORS headers on disallowed origins
|
|
callback(null, false);
|
|
}
|
|
},
|
|
credentials: true,
|
|
// Expose Content-Disposition so split (cross-origin) frontend
|
|
// deployments can read the server's chosen download filename. Used
|
|
// by the gallery/admin download flows to honour the #493 "original
|
|
// camera filename" toggle on individual photo downloads (#507).
|
|
exposedHeaders: ['Content-Disposition'],
|
|
};
|
|
|
|
// Only attach CORS to API endpoints, not static assets
|
|
app.use('/api', cors(corsOptions));
|
|
// Handle preflight explicitly for API paths
|
|
app.options('/api/*', cors(corsOptions));
|
|
|
|
// Initialize rate limiters (they will be created dynamically)
|
|
let generalRateLimiter;
|
|
let authRateLimiter;
|
|
|
|
function composeInlineStyles(payload) {
|
|
const { branding } = payload;
|
|
const cssSegments = [];
|
|
|
|
cssSegments.push(`:root {
|
|
--brand-primary: ${branding.colors.primary};
|
|
--brand-accent: ${branding.colors.accent};
|
|
--brand-background: ${branding.colors.background};
|
|
--brand-text: ${branding.colors.text};
|
|
--brand-surface: ${branding.colors.surface || '#ffffff'};
|
|
--brand-elevated: ${branding.colors.elevated || '#f5f5f5'};
|
|
--brand-border: ${branding.colors.border || '#e5e5e5'};
|
|
--brand-muted-text: ${branding.colors.mutedText || '#737373'};
|
|
}`);
|
|
|
|
if (payload.baseCss) {
|
|
cssSegments.push(payload.baseCss);
|
|
}
|
|
|
|
if (payload.css) {
|
|
cssSegments.push(`/* Custom styles */\n${payload.css}`);
|
|
}
|
|
|
|
return cssSegments.join('\n\n');
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function renderBrandHeader(branding) {
|
|
const displayName = escapeHtml(branding.companyName || 'PicPeak');
|
|
const logoSrc = encodeURI(branding.logoUrl || '/picpeak-logo-transparent.png');
|
|
const logo = `<img src="${logoSrc}" alt="${displayName}" class="brand-logo" loading="lazy" decoding="async" />`;
|
|
|
|
const tagline = branding.companyTagline
|
|
? `<p class="brand-tagline">${escapeHtml(branding.companyTagline)}</p>`
|
|
: '';
|
|
|
|
return `<header class="site-header">
|
|
<div class="header-inner">
|
|
<div class="brand">
|
|
${logo}
|
|
<div class="brand-copy">
|
|
<p class="brand-label">${displayName}</p>
|
|
${tagline}
|
|
</div>
|
|
</div>
|
|
<nav class="site-nav">
|
|
<a href="#features">${'Features'}</a>
|
|
<a href="#workflow">${'Workflow'}</a>
|
|
<a href="#collections">${'Collections'}</a>
|
|
<a href="#stories">${'Stories'}</a>
|
|
<a href="#contact">${'Contact'}</a>
|
|
</nav>
|
|
</div>
|
|
</header>`;
|
|
}
|
|
|
|
function renderBrandFooter(branding) {
|
|
const displayName = escapeHtml(branding.companyName || 'PicPeak');
|
|
const footerNote = branding.footerText
|
|
? `<p>${escapeHtml(branding.footerText)}</p>`
|
|
: '<p>Powered by PicPeak to keep every celebration beautifully organised.</p>';
|
|
|
|
const supportEmail = escapeHtml(branding.supportEmail || '');
|
|
const supportLink = supportEmail
|
|
? `<a href="mailto:${supportEmail}">Support</a>`
|
|
: '';
|
|
|
|
const legalLinks = `
|
|
<a href="/datenschutz">Privacy Policy</a>
|
|
<a href="/impressum">Impressum</a>
|
|
${supportLink}
|
|
`;
|
|
|
|
return `<footer class="site-footer" id="contact">
|
|
<div class="footer-inner">
|
|
<div>
|
|
<h2>${displayName}</h2>
|
|
${footerNote}
|
|
</div>
|
|
<div class="footer-links">
|
|
${legalLinks}
|
|
</div>
|
|
</div>
|
|
</footer>`;
|
|
}
|
|
|
|
function buildSeoMetaTags(seoSettings) {
|
|
const tags = [];
|
|
const robotsDirectives = [];
|
|
|
|
if (seoSettings.seo_meta_noindex) robotsDirectives.push('noindex');
|
|
if (seoSettings.seo_meta_nofollow) robotsDirectives.push('nofollow');
|
|
|
|
if (robotsDirectives.length > 0) {
|
|
tags.push(`<meta name="robots" content="${robotsDirectives.join(', ')}" />`);
|
|
}
|
|
|
|
if (seoSettings.seo_meta_noai) {
|
|
tags.push('<meta name="robots" content="noai, noimageai" />');
|
|
}
|
|
|
|
return tags.join('\n ');
|
|
}
|
|
|
|
function buildPublicSiteDocument(payload) {
|
|
const inlineStyles = composeInlineStyles(payload);
|
|
const header = renderBrandHeader(payload.branding);
|
|
const footer = renderBrandFooter(payload.branding);
|
|
const seoMeta = payload.seoSettings ? buildSeoMetaTags(payload.seoSettings) : '';
|
|
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>${escapeHtml(payload.title)}</title>
|
|
<meta name="description" content="Curated photo galleries and stories from unforgettable celebrations." />
|
|
${seoMeta}
|
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
<style>${inlineStyles}</style>
|
|
</head>
|
|
<body>
|
|
<div class="site-shell">
|
|
${header}
|
|
<main class="site-main">
|
|
${payload.html}
|
|
</main>
|
|
${footer}
|
|
</div>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
async function handlePublicSiteRequest(req, res, next) {
|
|
try {
|
|
const payload = await getPublicSitePayload();
|
|
|
|
if (!payload.enabled) {
|
|
res.redirect(302, '/admin/login');
|
|
return;
|
|
}
|
|
|
|
if (payload.etag && req.headers['if-none-match'] === payload.etag) {
|
|
res.status(304).end();
|
|
return;
|
|
}
|
|
|
|
// Inject SEO meta settings into payload
|
|
try {
|
|
const seoRows = await db('app_settings')
|
|
.where('setting_type', 'seo')
|
|
.whereIn('setting_key', ['seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai'])
|
|
.select('setting_key', 'setting_value');
|
|
const seoSettings = {};
|
|
for (const row of seoRows) {
|
|
let val = row.setting_value;
|
|
if (typeof val === 'string') { try { val = JSON.parse(val); } catch {} }
|
|
seoSettings[row.setting_key] = val;
|
|
}
|
|
payload.seoSettings = seoSettings;
|
|
} catch {}
|
|
|
|
const document = buildPublicSiteDocument(payload);
|
|
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
res.setHeader('Cache-Control', 'public, max-age=30, must-revalidate');
|
|
res.setHeader('ETag', payload.etag);
|
|
res.setHeader('Vary', 'Accept-Encoding');
|
|
res.setHeader('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' https: data:; object-src 'none'; script-src 'self'; form-action 'self'");
|
|
|
|
res.status(200).send(document);
|
|
} catch (error) {
|
|
logger.error('Failed to render public site', { error: error.message });
|
|
next();
|
|
}
|
|
}
|
|
|
|
// Function to initialize rate limiters
|
|
async function initializeRateLimiters() {
|
|
generalRateLimiter = await createRateLimiter();
|
|
authRateLimiter = await createAuthRateLimiter();
|
|
|
|
// Apply rate limiting
|
|
app.use('/api/', generalRateLimiter);
|
|
app.use('/api/auth', authRateLimiter);
|
|
app.use('/api/gallery/:slug/verify', authRateLimiter);
|
|
app.use('/api/admin/auth/login', authRateLimiter);
|
|
app.use('/api/setup/admin', authRateLimiter);
|
|
app.use('/api/setup/verify-token', authRateLimiter);
|
|
}
|
|
|
|
// Note: Rate limiters will be initialized after database connection
|
|
app.use(express.json({ limit: '50mb' }));
|
|
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
|
|
|
// CSRF protection: require JSON Content-Type on mutating API requests
|
|
// This blocks cross-origin form submissions which cannot set Content-Type: application/json
|
|
app.use('/api', (req, res, next) => {
|
|
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
|
|
const contentType = req.headers['content-type'] || '';
|
|
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
|
|
// Allow empty-body requests (e.g. logout), multipart for uploads, and JSON for API calls
|
|
if (contentLength > 0 && !contentType.includes('application/json') && !contentType.includes('multipart/form-data')) {
|
|
return res.status(415).json({ error: 'Unsupported Content-Type. Use application/json or multipart/form-data.' });
|
|
}
|
|
}
|
|
next();
|
|
});
|
|
|
|
// Request logging for API routes (with timestamps)
|
|
const apiRequestLogger = (req, res, next) => {
|
|
try {
|
|
const started = Date.now();
|
|
const ts = new Date().toISOString();
|
|
logger.info(`[${ts}] ${req.method} ${req.originalUrl}`);
|
|
res.on('finish', () => {
|
|
const ms = Date.now() - started;
|
|
const tsDone = new Date().toISOString();
|
|
logger.info(`[${tsDone}] ${req.method} ${req.originalUrl} -> ${res.statusCode} (${ms}ms)`);
|
|
});
|
|
} catch (_) {}
|
|
next();
|
|
};
|
|
app.use('/api', apiRequestLogger);
|
|
|
|
// Maintenance mode middleware - add after body parsing but before routes
|
|
app.use(maintenanceMiddleware);
|
|
|
|
// Session timeout middleware for admin routes
|
|
app.use('/api/admin', sessionTimeoutMiddleware);
|
|
|
|
// Middleware to set CORS headers for static files
|
|
const setCorsHeaders = (req, res, next) => {
|
|
const origin = req.headers.origin;
|
|
const staticAllowedOrigins = [
|
|
process.env.FRONTEND_URL || 'http://localhost:3005',
|
|
process.env.ADMIN_URL || 'http://localhost:3005'
|
|
];
|
|
if (process.env.NODE_ENV === 'development') {
|
|
staticAllowedOrigins.push(
|
|
'http://localhost:5173',
|
|
'http://localhost:3002',
|
|
'http://localhost:3001',
|
|
'http://localhost:3000'
|
|
);
|
|
}
|
|
if (origin && staticAllowedOrigins.indexOf(origin) !== -1) {
|
|
res.header('Access-Control-Allow-Origin', origin);
|
|
res.header('Access-Control-Allow-Credentials', 'true');
|
|
}
|
|
res.header('Cross-Origin-Resource-Policy', 'cross-origin');
|
|
next();
|
|
};
|
|
|
|
// Import secure static middleware
|
|
const secureStatic = require('./src/middleware/secureStatic');
|
|
|
|
// Get storage path from environment or use default
|
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../storage');
|
|
process.env.EXTERNAL_MEDIA_ROOT = process.env.EXTERNAL_MEDIA_ROOT || '/external-media';
|
|
|
|
// Static file serving for photos (protected)
|
|
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'events/active')));
|
|
|
|
// Static file serving for thumbnails (protected)
|
|
app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'thumbnails')));
|
|
|
|
// Static file serving for uploads (public - logos, favicons)
|
|
app.use('/uploads', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads')));
|
|
|
|
// Static file serving for self-hosted webfonts (public — gallery visitors
|
|
// load these via @font-face). Replaces the previous Google Fonts CDN
|
|
// dependency, which leaked visitor IPs to a third party (LG München 2022
|
|
// GDPR ruling).
|
|
//
|
|
// Two mounts in priority order:
|
|
// 1. STORAGE_PATH/fonts/ — runtime user additions (drop a folder, restart)
|
|
// 2. backend/assets/fonts/ — bundled defaults baked into the image
|
|
// Express evaluates handlers in order, so user-supplied files win on overlap.
|
|
//
|
|
// We deliberately do NOT set `immutable` on these responses. The filenames
|
|
// are stable (e.g. Inter/400.woff2), so an admin replacing the file on disk
|
|
// must be able to roll out the change to clients. With max-age + Last-Modified
|
|
// (set by express.static from file mtime), browsers send If-Modified-Since
|
|
// after expiry and pick up the new version automatically. See https://docs.picpeak.app/guides/custom-fonts
|
|
// "Replacing an existing font" for the documented rollout strategy.
|
|
const fontStaticOpts = { maxAge: '7d' };
|
|
app.use(
|
|
'/fonts',
|
|
setCorsHeaders,
|
|
secureStatic(path.join(storagePath, 'fonts'), fontStaticOpts)
|
|
);
|
|
app.use(
|
|
'/fonts',
|
|
setCorsHeaders,
|
|
secureStatic(path.resolve(__dirname, 'assets/fonts'), fontStaticOpts)
|
|
);
|
|
|
|
// Debug endpoint to check IP detection (only in development)
|
|
if (process.env.NODE_ENV === 'development') {
|
|
app.get('/api/debug/ip', (req, res) => {
|
|
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
|
req.headers['x-real-ip'] ||
|
|
req.connection.remoteAddress ||
|
|
req.ip;
|
|
|
|
res.json({
|
|
detectedIp: clientIp,
|
|
reqIp: req.ip,
|
|
headers: {
|
|
'x-forwarded-for': req.headers['x-forwarded-for'],
|
|
'x-real-ip': req.headers['x-real-ip'],
|
|
'x-forwarded-proto': req.headers['x-forwarded-proto'],
|
|
'x-forwarded-host': req.headers['x-forwarded-host']
|
|
},
|
|
trustProxy: app.get('trust proxy')
|
|
});
|
|
});
|
|
}
|
|
|
|
// OG/Twitter-card preview endpoint for gallery share URLs. Crawlers (WhatsApp,
|
|
// Slack, Facebook, etc.) don't execute JS, so the SPA's client-side meta tags
|
|
// never reach them. nginx routes UA-detected crawlers from /gallery/:slug to
|
|
// here; humans still get the SPA via try_files.
|
|
const {
|
|
isSocialCrawler,
|
|
handleGalleryOgRequest,
|
|
handleGalleryOgCover,
|
|
} = require('./src/services/galleryOgService');
|
|
app.get('/og/gallery/:slug', handleGalleryOgRequest);
|
|
// Public hero-photo cover served as og:image when the admin has flipped
|
|
// events.og_image_share_enabled (#474). Unauthenticated by design;
|
|
// returns 404 unless the opt-in is on AND a hero_photo_id is set.
|
|
app.get('/og/gallery/:slug/cover', handleGalleryOgCover);
|
|
|
|
// Branded URL shortener (#699). /s/<short_slug> is bot-UA aware:
|
|
// - Social crawler → server-render OG for the target event so the
|
|
// SHORT URL itself is what scrapes cache against. The og:url canonical
|
|
// in the rendered HTML points back at /s/<slug>, not the underlying
|
|
// gallery URL — so a re-share of the same short URL keeps the cache
|
|
// warm even if the underlying gallery slug rotates.
|
|
// - Browser → 302 to the stored target_path. The target_path was
|
|
// captured at create time from the event's slug + share_token + the
|
|
// global "Use short gallery URLs" setting, so it doesn't silently
|
|
// change later.
|
|
// - Soft-deleted → 410 Gone so the admin can tell their delete worked
|
|
// vs. a typo'd unknown slug (which returns 404).
|
|
const galleryShortUrlService = require('./src/services/galleryShortUrlService');
|
|
const { buildOgMetadata, renderOgHtml } = require('./src/services/galleryOgService');
|
|
app.get('/s/:shortSlug', async (req, res) => {
|
|
try {
|
|
const row = await galleryShortUrlService.findByShortSlug(req.params.shortSlug);
|
|
if (!row) {
|
|
return res.status(404).type('text/plain').send('Short URL not found');
|
|
}
|
|
if (row.deleted_at) {
|
|
return res.status(410).type('text/plain').send('Short URL has been removed');
|
|
}
|
|
|
|
// Bot UA → render OG metadata for the target event. We look up the
|
|
// event via the short URL's event_id rather than re-parsing the
|
|
// target_path so a future migration that adds new target shapes
|
|
// (slideshow, client-access) doesn't need to rewrite the URL parser.
|
|
if (isSocialCrawler(req.get('user-agent'))) {
|
|
const event = await require('./src/database/db').db('events')
|
|
.where({ id: row.event_id })
|
|
.first('slug');
|
|
if (event?.slug) {
|
|
const meta = await buildOgMetadata(event.slug, req.originalUrl);
|
|
// Override the canonical to point at the SHORT URL itself —
|
|
// social platforms cache OG by URL, and the short URL is the
|
|
// one operators actually share, so that's the cache key we
|
|
// want them to stick with.
|
|
const base = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '');
|
|
meta.url = `${base}/s/${row.short_slug}`;
|
|
res.set('Cache-Control', 'public, max-age=300');
|
|
res.set('Content-Type', 'text/html; charset=utf-8');
|
|
res.send(renderOgHtml(meta));
|
|
// Hit accounting is fire-and-forget — don't block the bot.
|
|
galleryShortUrlService.recordHit(row.id).catch(() => {});
|
|
return;
|
|
}
|
|
// Event disappeared (FK CASCADE in flight, or admin hard-deleted
|
|
// outside the normal soft-delete path) — fall through to 410 so
|
|
// the scraper sees a clean signal.
|
|
return res.status(410).type('text/plain').send('Short URL points at a deleted event');
|
|
}
|
|
|
|
// Browser path: redirect. Hit accounting is fire-and-forget.
|
|
galleryShortUrlService.recordHit(row.id).catch(() => {});
|
|
return res.redirect(302, row.target_path);
|
|
} catch (err) {
|
|
logger.error('Short URL resolver failed', { slug: req.params.shortSlug, error: err.message });
|
|
return res.status(500).type('text/plain').send('Internal server error');
|
|
}
|
|
});
|
|
|
|
// robots.txt endpoint (dynamic, served from DB settings)
|
|
const { generateRobotsTxt } = require('./src/services/robotsTxtService');
|
|
app.get('/robots.txt', async (req, res) => {
|
|
try {
|
|
const robotsTxt = await generateRobotsTxt();
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
res.setHeader('Cache-Control', 'public, max-age=3600');
|
|
res.status(200).send(robotsTxt);
|
|
} catch (error) {
|
|
logger.error('Failed to generate robots.txt', { error: error.message });
|
|
// Safe default for a private photo platform
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
res.status(200).send('User-agent: *\nDisallow: /\n');
|
|
}
|
|
});
|
|
|
|
// Dynamic favicon endpoints. Browsers — notably Safari — request
|
|
// /favicon.ico and /apple-touch-icon*.png directly at the site root and are
|
|
// unreliable about honouring JS-injected <link rel="icon"> tags. Serving the
|
|
// admin's configured branding favicon here makes it work without client-side
|
|
// JS (and survive aggressive favicon caches). Falls back to the bundled asset
|
|
// shipped with the frontend build when no custom favicon is set.
|
|
app.get(
|
|
['/favicon.ico', '/apple-touch-icon.png', '/apple-touch-icon-precomposed.png'],
|
|
async (req, res) => {
|
|
try {
|
|
const { getAppSetting } = require('./src/utils/appSettings');
|
|
const raw = await getAppSetting('branding_favicon_url', null);
|
|
const url = (raw && String(raw).trim()) || null;
|
|
if (url) {
|
|
// External URL — can't stream the bytes, so redirect (best effort).
|
|
if (/^https?:\/\//i.test(url)) return res.redirect(302, url);
|
|
// Local upload → stream the file bytes DIRECTLY rather than 302'ing.
|
|
// Safari does NOT reliably follow a redirect for favicon requests
|
|
// (it falls back to the HTML <link>, i.e. the bundled default),
|
|
// whereas Firefox/Chrome do — so a 302 worked everywhere except
|
|
// Safari. sendFile sets the right content-type from the extension.
|
|
const rel = String(url).replace(/^\/+/, '').replace(/^uploads\//, '');
|
|
const uploadsRoot = path.resolve(path.join(storagePath, 'uploads'));
|
|
const resolved = path.resolve(path.join(uploadsRoot, rel));
|
|
// Path containment — never serve outside the uploads dir.
|
|
if (resolved.startsWith(uploadsRoot + path.sep) && fs.existsSync(resolved)) {
|
|
// This route streams the file directly, bypassing the secureStatic
|
|
// middleware — so re-apply its SVG hardening here. An admin-uploaded
|
|
// SVG favicon could contain <script>; served at the top-level
|
|
// /favicon.ico origin without CSP that would be stored XSS. Keep in
|
|
// sync with secureStatic.js.
|
|
if (/\.svg$/i.test(resolved)) {
|
|
res.setHeader('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:");
|
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
}
|
|
res.setHeader('Cache-Control', 'public, max-age=86400');
|
|
return res.sendFile(resolved);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
logger.warn('Favicon lookup failed; serving bundled default', { error: error.message });
|
|
}
|
|
return res.redirect(302, '/favicon-32x32.png');
|
|
}
|
|
);
|
|
|
|
// Health check endpoint. `pid` + `uptime` let monitors (and the local E2E
|
|
// watchdog) detect a silent process restart between two checks.
|
|
app.get('/health', async (req, res) => {
|
|
try {
|
|
await db.raw('SELECT 1');
|
|
res.json({
|
|
status: 'ok',
|
|
timestamp: new Date().toISOString(),
|
|
pid: process.pid,
|
|
uptime: process.uptime()
|
|
});
|
|
} catch (error) {
|
|
logger.error('Health check failed:', error);
|
|
res.status(503).json({
|
|
status: 'error',
|
|
timestamp: new Date().toISOString(),
|
|
pid: process.pid,
|
|
uptime: process.uptime()
|
|
});
|
|
}
|
|
});
|
|
|
|
// Routes
|
|
app.use('/api/setup', setupRoutes); // public first-run bootstrap (self-closes after setup)
|
|
app.use('/api/auth', authRoutes);
|
|
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
|
|
// Gallery routes - main routes first, then feedback routes
|
|
app.use('/api/gallery', galleryRoutes);
|
|
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
|
|
app.use('/api/gallery', require('./src/routes/galleryGuests'));
|
|
app.use('/api/admin', adminRoutes);
|
|
app.use('/api/admin/auth', adminAuthRoutes);
|
|
app.use('/api/admin/system', require('./src/routes/adminSystem'));
|
|
// Branded URL shortener admin CRUD (#699) — list/create/delete short URLs
|
|
// per event. Mounted at /api/admin so the routes appear at
|
|
// /api/admin/events/:eventId/short-urls and /api/admin/short-urls/:id.
|
|
app.use('/api/admin', require('./src/routes/adminShortUrls'));
|
|
app.use('/api/admin/feature-flags', require('./src/routes/adminFeatureFlags'));
|
|
app.use('/api/admin/whatsapp', require('./src/routes/adminWhatsapp'));
|
|
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
|
|
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
|
|
app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
|
|
app.use('/api/admin', require('./src/routes/adminGuests'));
|
|
app.use('/api/admin/image-security', require('./src/routes/adminImageSecurity'));
|
|
app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails'));
|
|
app.use('/api/admin/photos', require('./src/routes/adminPhotoDimensions'));
|
|
app.use('/api/admin/photos', require('./src/routes/adminPhotos'));
|
|
app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
|
|
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
|
|
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
|
|
app.use('/api/admin/users', require('./src/routes/adminUsers'));
|
|
app.use('/api/admin/roles', require('./src/routes/adminRoles'));
|
|
// Customer portal (#354). The customerPortal feature flag is a
|
|
// VISIBILITY toggle for the admin surface, not a kill switch for
|
|
// customer access. Enforcement:
|
|
//
|
|
// 1. Frontend: RequireFeature guards + AdminSidebar visibility
|
|
// hide the Clients section when the flag is off. Customer-side
|
|
// /customer/* surfaces stay reachable.
|
|
// 2. Backend: NO route-level gate. The admin surface is gated by
|
|
// adminAuth + permission checks (admin still has rights to
|
|
// manage customer records even if the section is hidden in
|
|
// their UI). The customer surface is gated by customerAuth +
|
|
// is_active checks on customer_accounts.
|
|
//
|
|
// For close-to-realtime access changes use the dedicated tools:
|
|
// - Revoke a customer's access to ONE gallery → "Manage galleries"
|
|
// dialog removes the event_customer_assignments row, which
|
|
// verifyGalleryAccess re-checks on every customer-minted JWT.
|
|
// - Lock out a customer entirely → "Deactivate" sets is_active=false
|
|
// and bumps password_changed_at, killing every outstanding JWT.
|
|
// - Toggle per-customer feature surfaces (calendar/quotes/bills)
|
|
// → toggles on the customer detail page.
|
|
//
|
|
// Putting the global flag in the kill-switch role was a mistake — a
|
|
// stray click in Settings → Features would lock every paying
|
|
// customer out at once. PR-revert moved the gate back to per-record.
|
|
//
|
|
// `noStoreCache` belt-and-braces the cache-control story for both
|
|
// surfaces: any response — 200, 4xx, 5xx — carries `Cache-Control:
|
|
// no-store` so a transient error (the now-reverted #458 410, a
|
|
// permission flip mid-session, a backend restart) can't get pinned
|
|
// in browser or intermediate caches and outlive its cause. See the
|
|
// PR #458 → #470 history in the middleware file for context.
|
|
const { noStoreCache } = require('./src/middleware/noStoreCache');
|
|
app.use('/api/admin/customers', noStoreCache, require('./src/routes/adminCustomers'));
|
|
// Customer-side surface (#354). Strictly separate from /api/admin/* —
|
|
// distinct token type, distinct cookie, distinct middleware. The
|
|
// noStoreCache wrapper (upstream) prevents stale customer-portal
|
|
// data from being served after logout. The CRM-area route-flag
|
|
// gate was reverted upstream and lives in the UI now.
|
|
app.use('/api/customer/auth', noStoreCache, require('./src/routes/customerAuth'));
|
|
app.use('/api/customer', noStoreCache, require('./src/routes/customer'));
|
|
|
|
// --- CRM (#TBD) -------------------------------------------------------
|
|
// Quotes / Invoices / Contracts / Calendar / Tax report / Deals lineage.
|
|
// Business profile (issuer block for PDFs) lives at
|
|
// /api/admin/business-profile, gated by the existing settings.manage
|
|
// permission rather than a CRM-specific one. The public endpoints
|
|
// host the customer-side accept/decline / sign / payment-check pages.
|
|
app.use('/api/admin/business-profile', require('./src/routes/adminBusinessProfile'));
|
|
app.use('/api/admin/quotes', require('./src/routes/adminQuotes'));
|
|
app.use('/api/admin/invoices', require('./src/routes/adminInvoices'));
|
|
app.use('/api/admin/contracts', require('./src/routes/adminContracts'));
|
|
app.use('/api/admin/projects', require('./src/routes/adminProjects'));
|
|
app.use('/api/admin/calendar', require('./src/routes/adminCalendar'));
|
|
app.use('/api/admin/deals', require('./src/routes/adminDeals'));
|
|
app.use('/api/admin/workflows', require('./src/routes/adminWorkflows'));
|
|
app.use('/api/admin/tax-report', require('./src/routes/adminTaxReport'));
|
|
app.use('/api/admin/expenses', require('./src/routes/adminExpenses'));
|
|
app.use('/api/admin/ledger', require('./src/routes/adminLedger'));
|
|
// Read-only VAT-code registry for the invoice/quote editors — un-gated by the
|
|
// accounting flag (management stays under /ledger).
|
|
app.use('/api/admin/vat-codes', require('./src/routes/adminVatCodes'));
|
|
app.use('/api/admin/system-health', require('./src/routes/adminSystemHealth'));
|
|
app.use('/api/admin/dev', require('./src/routes/adminDev'));
|
|
app.use('/api/admin/transfers', require('./src/routes/adminTransfers'));
|
|
app.use('/api/public/quotes', require('./src/routes/publicQuotes'));
|
|
app.use('/api/public/contracts', require('./src/routes/publicContracts'));
|
|
// PicTransfer (#997): recipient download + client upload, token-authenticated.
|
|
app.use('/api/public/transfer', require('./src/routes/publicTransfer'));
|
|
app.use('/api/public/transfer-upload', require('./src/routes/publicTransferUpload'));
|
|
app.use('/api/public/payment-check', require('./src/routes/publicPaymentCheck'));
|
|
app.use('/api/public/workflow-approvals', require('./src/routes/publicWorkflowApprovals'));
|
|
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
|
|
app.use('/api/admin/api-tokens', require('./src/routes/adminApiTokens'));
|
|
app.use('/api/admin/webhooks', require('./src/routes/adminWebhooks'));
|
|
// Public v1 API for n8n / external integrations (#322). Mounted under
|
|
// /api/v1; auth handled per-route via apiTokenAuth (Bearer tokens).
|
|
app.use('/api/v1', require('./src/routes/v1/events'));
|
|
|
|
// Swagger UI for the v1 API. Admin-gated since it lists endpoint shapes
|
|
// that should not be enumerable to anonymous users (a common reduce-info-leak hardening).
|
|
{
|
|
const swaggerUi = require('swagger-ui-express');
|
|
const { adminAuth } = require('./src/middleware/auth');
|
|
const { getOpenApiSpec } = require('./src/openapi/spec');
|
|
app.get('/api/openapi.json', adminAuth, (_req, res) => res.json(getOpenApiSpec()));
|
|
app.use(
|
|
'/api/docs',
|
|
adminAuth,
|
|
swaggerUi.serve,
|
|
swaggerUi.setup(getOpenApiSpec(), { customSiteTitle: 'PicPeak API · v1' })
|
|
);
|
|
}
|
|
|
|
app.use('/api/invite', require('./src/routes/acceptInvite'));
|
|
app.use('/api/public/settings', require('./src/routes/publicSettings'));
|
|
app.use('/api/public/fonts', require('./src/routes/publicFonts'));
|
|
app.use('/api/public', require('./src/routes/publicCMS'));
|
|
app.use('/api/images', require('./src/routes/protectedImages'));
|
|
app.use('/api/secure-images', secureImagesRoutes);
|
|
|
|
// Optional: Serve built frontend (native installs and the all-in-one image, #1042)
|
|
// Set when the SPA is being served, and registered as a catch-all AFTER the
|
|
// /api 404 handler further down — see the registration site for why it cannot
|
|
// live inside this block.
|
|
let spaCatchAll = null;
|
|
try {
|
|
const serveFrontendEnv = process.env.SERVE_FRONTEND; // 'true' | 'false' | undefined
|
|
const frontendDir = process.env.FRONTEND_DIR || path.join(__dirname, '../frontend/dist');
|
|
const indexPath = path.join(frontendDir, 'index.html');
|
|
// Auto-serve when dist exists unless explicitly disabled
|
|
const shouldServe = (serveFrontendEnv === 'true') || ((serveFrontendEnv === undefined || serveFrontendEnv === 'auto') && fs.existsSync(indexPath));
|
|
if (shouldServe) {
|
|
logger.info(`Serving frontend from ${frontendDir}`);
|
|
|
|
// The built index.html carries ${BRAND_TITLE} / ${BRAND_DESCRIPTION}
|
|
// placeholders (#521) that the nginx image renders via envsubst in its
|
|
// entrypoint. Here the render happens once at boot, in memory — same
|
|
// semantics: locked to exactly these two vars (never the JS bundle's own
|
|
// template literals), defaults applied when unset, re-rendered on every
|
|
// process start so changing the env + restarting is enough.
|
|
const spaHtml = fs
|
|
.readFileSync(indexPath, 'utf8')
|
|
.split('${BRAND_TITLE}').join(process.env.BRAND_TITLE || 'PicPeak')
|
|
.split('${BRAND_DESCRIPTION}').join(process.env.BRAND_DESCRIPTION || 'Photo gallery shared with PicPeak.');
|
|
// Mirrors nginx's `location = /index.html` cache rule: the SPA shell must
|
|
// revalidate so a redeploy is picked up, while the hashed assets below
|
|
// cache immutably.
|
|
const sendSpa = (res) => {
|
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
res.type('html').send(spaHtml);
|
|
};
|
|
|
|
// gzip for the SPA bundle (nginx parity — its server block gzips js/css/
|
|
// json). Mounted here, after every /api router, so API responses keep
|
|
// their exact current behavior; only the statics and SPA shell below
|
|
// pass through it.
|
|
app.use(compression());
|
|
|
|
// /index.html must serve the RENDERED shell, and express.static would
|
|
// otherwise answer first with the raw template straight off disk.
|
|
app.get('/index.html', (req, res) => sendSpa(res));
|
|
|
|
// Serve pre-built assets. index:false keeps `/` flowing to the landing-page
|
|
// handler below (nginx parity: `location = /` goes to the backend, it never
|
|
// serves index.html off disk) — express.static's default index option was
|
|
// shadowing handlePublicSiteRequest in native installs. Vite's hashed
|
|
// /assets/* get nginx's 1y-immutable rule; everything else keeps etag
|
|
// revalidation.
|
|
app.use(express.static(frontendDir, {
|
|
index: false,
|
|
setHeaders: (res, filePath) => {
|
|
if (/[/\\]assets[/\\]/.test(filePath)) {
|
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
|
}
|
|
},
|
|
}));
|
|
|
|
// Landing page handler or SPA fallback
|
|
app.get('/', handlePublicSiteRequest, (req, res) => {
|
|
sendSpa(res);
|
|
});
|
|
|
|
// SPA fallback for admin + gallery routes. For gallery URLs we intercept
|
|
// social-crawler User-Agents and serve OG/Twitter-card metadata so link
|
|
// previews show the event name + branding instead of the SPA stub.
|
|
//
|
|
// Two route shapes — 1-2 segments (`/gallery/:slug/:token?`) and the
|
|
// 3-segment slideshow form (`/gallery/:slug/show/:token`). The slideshow
|
|
// shape was previously falling through to the SPA-catchall below and
|
|
// skipping OG injection entirely (#699). Both patterns route to the
|
|
// same handler — buildOgMetadata only looks at `slug`, so the extra
|
|
// /show/ segment is harmless.
|
|
const ogIntercept = (req, res, next) => {
|
|
if (isSocialCrawler(req.get('user-agent'))) {
|
|
return handleGalleryOgRequest(req, res);
|
|
}
|
|
return next();
|
|
};
|
|
app.get('/gallery/:slug/:token?', ogIntercept, (req, res) => sendSpa(res));
|
|
app.get('/gallery/:slug/show/:token', ogIntercept, (req, res) => sendSpa(res));
|
|
|
|
app.get(['/admin', '/admin/*', '/gallery/*'], (req, res) => {
|
|
sendSpa(res);
|
|
});
|
|
|
|
// Everything else the router owns client-side. nginx did `try_files $uri
|
|
// $uri/ /index.html`, so behind compose every client route survived a
|
|
// reload and the short route list above was never exercised. Without
|
|
// nginx it is the whole contract: /setup, /customer, /impressum,
|
|
// /datenschutz, /payment-check, /quote/:token, /contract/:token,
|
|
// /invite/:token, /transfer/:token, /transfer-upload/:token and the
|
|
// branded short URLs all 404'd on a direct hit or a refresh. /setup is
|
|
// the first URL a new install visits.
|
|
spaCatchAll = (req, res) => sendSpa(res);
|
|
} else {
|
|
logger.info('Frontend static serving disabled or dist not found', { serveFrontendEnv, frontendDir });
|
|
app.get('/', handlePublicSiteRequest, (req, res) => {
|
|
res.status(503).send('Frontend bundle not available. Build frontend or enable public site.');
|
|
});
|
|
}
|
|
} catch (e) {
|
|
logger.warn('Failed to enable frontend static serving', { error: e.message });
|
|
}
|
|
|
|
// 404 handler for undefined API routes
|
|
app.use('/api', notFoundHandler);
|
|
|
|
// SPA history fallback, deliberately registered here — AFTER the /api 404
|
|
// handler, so an unknown /api/* route still answers JSON instead of being
|
|
// handed the HTML shell, and after the short-URL resolver so a real short
|
|
// code still redirects. GET-only: a stray POST/PUT keeps 404ing rather than
|
|
// getting a 200 page back.
|
|
if (spaCatchAll) {
|
|
// nginx gives these their own `location` blocks, so `try_files` never applied
|
|
// to them. The fallback has to mirror that: /photos, /thumbnails, /uploads
|
|
// and /fonts are backend-owned static mounts whose middleware calls next()
|
|
// when the file is missing, and swallowing that would answer 200 text/html
|
|
// under an image or font URL instead of a 404.
|
|
// /assets/ belongs on this list for the same reason even though it is the
|
|
// frontend's own bundle: after an upgrade a still-open tab requests the old
|
|
// hashed chunk, which no longer exists. Answering index.html would hand a
|
|
// JavaScript URL a 200 text/html body, so the module load fails with a MIME
|
|
// error instead of the plain 404 nginx returns — and the 200 hides it from
|
|
// any monitoring watching status codes.
|
|
const BACKEND_OWNED = ['/photos/', '/thumbnails/', '/uploads/', '/fonts/', '/assets/', '/health'];
|
|
app.get('*', (req, res, next) => {
|
|
if (BACKEND_OWNED.some((prefix) => req.path.startsWith(prefix))) return next();
|
|
return spaCatchAll(req, res);
|
|
});
|
|
}
|
|
|
|
// Global error handler (must be last)
|
|
app.use(errorHandler);
|
|
|
|
// Initialize services
|
|
async function startServer() {
|
|
try {
|
|
// Initialize database
|
|
await initializeDatabase();
|
|
|
|
// Initialize storage backend (local fs or S3) — fail fast on misconfig
|
|
const { initStorage } = require('./src/services/storage');
|
|
await initStorage();
|
|
|
|
// Initialize rate limiters after database is ready
|
|
await initializeRateLimiters();
|
|
logger.info('Rate limiters initialized with database configuration');
|
|
|
|
// Initialize auth security cleanup job
|
|
const { initializeCleanupJob } = require('./src/utils/authSecurity');
|
|
initializeCleanupJob();
|
|
|
|
// Initialize temp upload cleanup job
|
|
const { cleanupTempUploads } = require('./src/utils/cleanupTempUploads');
|
|
// Run cleanup on startup
|
|
cleanupTempUploads();
|
|
// Schedule periodic cleanup every hour
|
|
setInterval(cleanupTempUploads, 60 * 60 * 1000);
|
|
logger.info('Temp upload cleanup scheduled');
|
|
|
|
// Start file watcher
|
|
startFileWatcher();
|
|
|
|
// Start expiration checker
|
|
startExpirationChecker();
|
|
// PicTransfer retention sweep (#997): expire links, notify admins, and
|
|
// hard-delete client uploads once the grace window elapses.
|
|
startTransferCleanup();
|
|
// Custom-resolution download archives (#858) are disposable renditions —
|
|
// sweep them once their TTL passes so .download-cache doesn't grow forever.
|
|
startDownloadJobCleanup();
|
|
// Reveal-mode scheduler (#838): minutely stamp for scheduled reveals.
|
|
startRevealScheduler();
|
|
// CRM invoice scheduler: hourly tick to flush scheduled-send invoices
|
|
// + run the overdue reminder ladder. No-op when the `bills` feature
|
|
// flag is OFF (the service short-circuits on empty result sets).
|
|
startInvoiceScheduler();
|
|
|
|
// Initialize email transporter and start queue processor
|
|
await initializeTransporter();
|
|
// Seed CRM / contract / event-reminder email templates and recover
|
|
// any queue rows that exhausted retries because their template
|
|
// didn't exist yet. Runs once per boot via module-level caches in
|
|
// each seeder. See _emailTemplateBoot.js for the full rationale.
|
|
try {
|
|
const { seedEmailTemplatesAndRecoverQueue } = require('./src/services/_emailTemplateBoot');
|
|
await seedEmailTemplatesAndRecoverQueue(db, logger);
|
|
} catch (err) {
|
|
logger.warn('Email template self-heal failed at boot:', err.message);
|
|
}
|
|
startEmailQueueProcessor();
|
|
|
|
// Start WhatsApp queue processor — no-ops each cycle unless the
|
|
// `whatsapp` flag is on and a config exists (migration 136, #640D).
|
|
try {
|
|
const { startWhatsAppQueueProcessor } = require('./src/services/whatsappProcessor');
|
|
startWhatsAppQueueProcessor();
|
|
} catch (err) {
|
|
logger.warn('WhatsApp queue processor start failed:', err.message);
|
|
}
|
|
|
|
// Start incoming-mail (IMAP) poller — no-ops each minute unless the
|
|
// `incomingMail` flag is on and a mailbox is configured (migration 128).
|
|
try {
|
|
const { startIncomingMailPoller } = require('./src/services/emailIntakeService');
|
|
startIncomingMailPoller();
|
|
} catch (err) {
|
|
logger.warn('Incoming-mail poller failed to start:', err.message);
|
|
}
|
|
|
|
// Start webhook delivery worker (#327)
|
|
const { startWebhookDeliveryWorker } = require('./src/services/webhookDeliveryWorker');
|
|
startWebhookDeliveryWorker();
|
|
|
|
// Start S3 auto-importer (#328 follow-up). No-op when STORAGE_AUTO_IMPORT
|
|
// is unset OR STORAGE_BACKEND=local — replaces the chokidar watcher
|
|
// for S3-mode deployments that drop files into the bucket directly.
|
|
const { startS3AutoImporter } = require('./src/services/s3AutoImporter');
|
|
startS3AutoImporter();
|
|
|
|
// Self-heal the `backup_paths` table before the backup service
|
|
// starts — the file-backup walker reads from it, so missing
|
|
// canonical rows (a new subdirectory shipped by a future feature)
|
|
// get re-seeded here on every boot. See _backupPathsBoot.js for
|
|
// the full rationale; pattern mirrors _emailTemplateBoot.js.
|
|
try {
|
|
const { seedBackupPathsAtBoot } = require('./src/services/_backupPathsBoot');
|
|
await seedBackupPathsAtBoot(db, logger);
|
|
} catch (err) {
|
|
logger.warn('backup_paths self-heal failed at boot:', err.message);
|
|
}
|
|
|
|
// Self-heal restore-meta settings — currently just
|
|
// `restore_allow_force` defaulting to ON so fresh installs can
|
|
// recover from disaster without a SQL incantation. Only seeds on
|
|
// FRESH installs (existing rows, true or false, are preserved).
|
|
// See _restoreSettingsBoot.js for the full rationale.
|
|
try {
|
|
const { seedRestoreSettingsAtBoot } = require('./src/services/_restoreSettingsBoot');
|
|
await seedRestoreSettingsAtBoot(db, logger);
|
|
} catch (err) {
|
|
logger.warn('restore-settings self-heal failed at boot:', err.message);
|
|
}
|
|
|
|
// Seed built-in workflows (the editable invoice-dunning flow). Disabled by
|
|
// default — live reminder behaviour is unchanged. See _workflowSeedBoot.js.
|
|
try {
|
|
const { seedBuiltinWorkflowsAtBoot } = require('./src/services/_workflowSeedBoot');
|
|
await seedBuiltinWorkflowsAtBoot(db, logger);
|
|
} catch (err) {
|
|
logger.warn('built-in workflow seed failed at boot:', err.message);
|
|
}
|
|
|
|
// Self-heal the RBAC catalog: ensure super_admin holds every permission
|
|
// (the "Admin tracks all" guarantee) and the solo_photographer preset
|
|
// exists. New perms never need a compensation migration. See
|
|
// _permissionsBoot.js + project_permission_gating.
|
|
try {
|
|
const { seedPermissionsAtBoot } = require('./src/services/_permissionsBoot');
|
|
await seedPermissionsAtBoot(db, logger);
|
|
} catch (err) {
|
|
logger.warn('permissions self-heal failed at boot:', err.message);
|
|
}
|
|
|
|
// Install-from-backup trigger. If `RESTORE_ON_INSTALL` (or
|
|
// `.txt`) exists in the /backup mount AND the DB is empty, run
|
|
// the restore HERE before any admin UI surfaces. Lets admins
|
|
// recover a picpeak install with: (a) place backup files in the
|
|
// bind mount, (b) drop the trigger file, (c) `docker compose up`.
|
|
// No onboarding wizard, no throwaway admin, no compose-file
|
|
// changes. See _installFromBackupBoot.js for the full rationale
|
|
// + the safety gates.
|
|
try {
|
|
const { tryInstallFromBackup } = require('./src/services/_installFromBackupBoot');
|
|
const result = await tryInstallFromBackup(db, logger);
|
|
if (result.ran) {
|
|
logger.info(`Install-from-backup: completed from ${result.manifestPath}. Server will start with restored state.`);
|
|
}
|
|
} catch (err) {
|
|
logger.warn('Install-from-backup hook threw:', err.message);
|
|
}
|
|
|
|
// First-run: surface a one-time setup token while no admin account exists.
|
|
// Runs AFTER install-from-backup so a restored instance (which repopulates
|
|
// admin_users) never prints a throwaway token. Best-effort — never blocks boot.
|
|
let setupToken = null;
|
|
let setupTokenFile = null;
|
|
try {
|
|
const setupSvc = require('./src/services/setupService');
|
|
setupToken = await setupSvc.ensureSetupToken();
|
|
// The path the write ACTUALLY produced (null when it failed). existsSync
|
|
// on the candidate answered a different question and reported success
|
|
// for a stale, read-only or directory-shaped SETUP_TOKEN — suppressing
|
|
// the token here while pointing the operator at content that is not it.
|
|
setupTokenFile = setupSvc.writtenSetupTokenFile();
|
|
} catch (err) {
|
|
logger.warn(`[setup] ensureSetupToken skipped: ${err.message}`);
|
|
}
|
|
|
|
// Start backup service
|
|
await startBackupService();
|
|
|
|
// Start database backup service
|
|
await startScheduledBackups();
|
|
|
|
// Start the async photo-processing worker pool. Picks up
|
|
// photos in 'pending' state (from POST /upload) and runs the
|
|
// sharp/ffmpeg/EXIF pipeline off the request thread.
|
|
backgroundProcessor.start();
|
|
|
|
app.listen(PORT, () => {
|
|
logger.info(`Server running on port ${PORT}`);
|
|
logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`);
|
|
logger.info(`Frontend: ${process.env.FRONTEND_URL || 'http://localhost:3001'}`);
|
|
// First-run banner. Print the TOKEN ITSELF only when the 0600 token file
|
|
// could not be written — otherwise this lands a live first-admin
|
|
// credential in `docker logs` / journald, which is the leak GHSA-r794's
|
|
// sweep turned up. When the file exists we point at it instead.
|
|
if (setupToken) {
|
|
const url = `${process.env.ADMIN_URL || 'http://localhost:3000'}/admin`;
|
|
const line = '='.repeat(64);
|
|
const secretLine = setupTokenFile
|
|
? ` Setup token saved to: ${setupTokenFile}\n (read it there — deliberately not printed)`
|
|
: ` One-time setup token: ${setupToken}\n (could not write the token file, so it is shown here)`;
|
|
console.log(`\n${line}\n PicPeak first-run setup — no admin account yet.\n Open: ${url}\n${secretLine}\n${line}\n`);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
logger.error('Failed to start server:', error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
startServer();
|
|
|
|
module.exports = app; // For testing
|