From f0e6d2dfb12460cb1d003802f346e2026fa1c016 Mon Sep 17 00:00:00 2001 From: Paul Nothaft <53005142+the-luap@users.noreply.github.com> Date: Tue, 8 Sep 2026 15:34:09 +0200 Subject: [PATCH] fix: enforce gallery access and consolidate gallery workflows (#1357) Harden gallery authentication and authorization, consolidate gallery workflows, and prevent token-bearing URLs from leaking through nginx request error logs. --- .env.example | 11 + .github/workflows/tests.yml | 38 +- .../integration/webhookDelivery.test.js | 22 +- .../securityHardeningBatch2.test.js | 17 +- .../eventsTimestampContract.test.js | 40 + .../migrations/freshPostgresGallery.test.js | 57 + .../routes/authSession.symmetry.test.js | 534 +-- .../routes/galleryAccessPolicy.test.js | 203 + .../__tests__/routes/galleryTokenJti.test.js | 40 + .../routes/secureImageTokenBinding.test.js | 7 +- .../services/emailWebhookTransport.test.js | 4 +- .../__tests__/services/scheduledTask.test.js | 25 + .../secureImageService.lifecycle.test.js | 15 + .../__tests__/utils/photoContentType.test.js | 2 +- backend/__tests__/utils/pinnedRequest.test.js | 79 + .../__tests__/utils/requestLogPath.test.js | 28 + .../utils/testListenerAddress.test.js | 13 + backend/jest.setup.js | 23 + .../migrations/core/210_events_updated_at.js | 16 + backend/server.js | 107 +- ...verifyGalleryAccess.customerRevoke.test.js | 18 +- backend/src/middleware/apiRequestLogger.js | 11 + backend/src/middleware/auth.js | 109 +- backend/src/middleware/csrf.js | 15 + backend/src/middleware/customerAuth.js | 84 +- backend/src/middleware/errorHandler.js | 5 +- backend/src/middleware/feedbackRateLimit.js | 6 +- backend/src/middleware/gallery.js | 303 +- backend/src/middleware/ownership.js | 8 +- backend/src/middleware/permissions.js | 5 +- .../src/middleware/secureImageMiddleware.js | 22 +- backend/src/middleware/sessionTimeout.js | 3 +- backend/src/routes/adminEvents/crud.js | 592 +-- backend/src/routes/adminEvents/helpers.js | 423 +-- backend/src/routes/auth.js | 131 +- backend/src/routes/customer.js | 12 +- backend/src/routes/gallery.js | 3369 +---------------- backend/src/routes/gallery/downloads.js | 945 +++++ backend/src/routes/gallery/media.js | 639 ++++ backend/src/routes/gallery/metadata.js | 249 ++ backend/src/routes/gallery/photos.js | 192 + backend/src/routes/gallery/slideshow.js | 293 ++ backend/src/routes/gallery/stats.js | 44 + backend/src/routes/gallery/styles.js | 41 + backend/src/routes/gallery/uploads.js | 211 ++ backend/src/routes/protectedImages.js | 32 +- backend/src/routes/secureImages.js | 12 + .../routes/v1/__tests__/events.create.test.js | 349 +- backend/src/routes/v1/events.js | 266 +- backend/src/services/chunkedUploadService.js | 11 +- .../src/services/downloadJobCleanupService.js | 17 +- backend/src/services/downloadZipService.js | 7 + backend/src/services/emailIntakeService.js | 11 +- backend/src/services/emailProcessor.js | 36 +- backend/src/services/emailWebhookTransport.js | 4 + backend/src/services/eventCreationService.js | 615 +++ .../src/services/eventCreationValidation.js | 47 + backend/src/services/eventService.js | 170 +- backend/src/services/eventSettings.js | 403 ++ backend/src/services/expirationChecker.js | 14 +- backend/src/services/fileWatcher.js | 23 +- backend/src/services/galleryAccessService.js | 57 + backend/src/services/galleryModel.js | 30 + backend/src/services/galleryPhotoQuery.js | 37 + backend/src/services/galleryQueryService.js | 567 +++ .../src/services/invoiceSchedulerService.js | 33 +- backend/src/services/rateLimitService.js | 10 +- backend/src/services/revealScheduler.js | 12 +- backend/src/services/s3AutoImporter.js | 27 +- backend/src/services/scheduledTask.js | 29 + backend/src/services/secureImageService.js | 46 +- backend/src/services/serviceShutdown.js | 28 + backend/src/services/sessionAccessService.js | 74 + .../src/services/transferCleanupService.js | 13 +- backend/src/services/webhookDeliveryWorker.js | 25 +- backend/src/services/whatsappProcessor.js | 28 +- backend/src/services/workerManager.js | 23 +- backend/src/utils/authSecurity.js | 11 +- backend/src/utils/cleanupTempUploads.js | 21 +- backend/src/utils/dateNormalize.js | 13 +- backend/src/utils/galleryLifecycle.js | 38 + backend/src/utils/networkValidation.js | 43 +- backend/src/utils/pinnedRequest.js | 28 + backend/src/utils/rateLimitSecurity.js | 9 +- backend/src/utils/requestLogPath.js | 12 + backend/src/utils/requestOrigin.js | 33 +- backend/src/utils/routeHelpers.js | 3 +- backend/src/utils/tokenRevocation.js | 13 +- docs/usage-coverage.v5.json | 107 +- frontend/eslint.config.js | 2 +- frontend/nginx.conf | 12 + frontend/nginx.dev.conf | 14 +- frontend/package-lock.json | 816 ++-- frontend/package.json | 18 +- .../src/components/admin/AdminPhotoViewer.tsx | 30 +- frontend/src/components/admin/CMSEditor.tsx | 52 +- .../components/admin/EmailTemplateEditor.tsx | 52 +- .../src/components/gallery/GalleryView.tsx | 265 +- .../gallery/PhotoGridWithLayouts.tsx | 10 +- .../gallery/UploadProcessingNotice.tsx | 17 + .../__tests__/CarouselLifecycle.test.tsx | 18 + .../__tests__/galleryUploadRefresh.test.ts | 169 +- .../gallery/hooks/useGalleryFiltering.ts | 138 + .../gallery/hooks/useGallerySelection.ts | 16 + .../gallery/hooks/useGalleryUpload.ts | 91 + .../gallery/layouts/CarouselGalleryLayout.tsx | 10 +- .../__tests__/usePhotoSelection.test.tsx | 15 + frontend/src/hooks/useGallery.ts | 2 +- frontend/src/hooks/usePhotoSelection.ts | 26 + frontend/src/pages/admin/EventDetailsPage.tsx | 2 - .../event-details/EventInformationCard.tsx | 24 +- .../src/pages/admin/event-details/types.ts | 2 - .../__tests__/gallery.pagination.test.ts | 66 + frontend/src/services/gallery.service.ts | 44 +- frontend/src/types/index.ts | 1 + nginx/nginx.conf | 16 +- nginx/nginx.conf.example | 14 +- package-lock.json | 1169 +----- package.json | 3 +- tests/nginx/test_request_logging.py | 160 + 120 files changed, 7147 insertions(+), 8525 deletions(-) create mode 100644 backend/__tests__/migrations/eventsTimestampContract.test.js create mode 100644 backend/__tests__/migrations/freshPostgresGallery.test.js create mode 100644 backend/__tests__/routes/galleryAccessPolicy.test.js create mode 100644 backend/__tests__/routes/galleryTokenJti.test.js create mode 100644 backend/__tests__/services/scheduledTask.test.js create mode 100644 backend/__tests__/services/secureImageService.lifecycle.test.js create mode 100644 backend/__tests__/utils/pinnedRequest.test.js create mode 100644 backend/__tests__/utils/requestLogPath.test.js create mode 100644 backend/__tests__/utils/testListenerAddress.test.js create mode 100644 backend/migrations/core/210_events_updated_at.js create mode 100644 backend/src/middleware/apiRequestLogger.js create mode 100644 backend/src/middleware/csrf.js create mode 100644 backend/src/routes/gallery/downloads.js create mode 100644 backend/src/routes/gallery/media.js create mode 100644 backend/src/routes/gallery/metadata.js create mode 100644 backend/src/routes/gallery/photos.js create mode 100644 backend/src/routes/gallery/slideshow.js create mode 100644 backend/src/routes/gallery/stats.js create mode 100644 backend/src/routes/gallery/styles.js create mode 100644 backend/src/routes/gallery/uploads.js create mode 100644 backend/src/services/eventCreationService.js create mode 100644 backend/src/services/eventCreationValidation.js create mode 100644 backend/src/services/eventSettings.js create mode 100644 backend/src/services/galleryAccessService.js create mode 100644 backend/src/services/galleryModel.js create mode 100644 backend/src/services/galleryPhotoQuery.js create mode 100644 backend/src/services/galleryQueryService.js create mode 100644 backend/src/services/scheduledTask.js create mode 100644 backend/src/services/serviceShutdown.js create mode 100644 backend/src/services/sessionAccessService.js create mode 100644 backend/src/utils/galleryLifecycle.js create mode 100644 backend/src/utils/pinnedRequest.js create mode 100644 backend/src/utils/requestLogPath.js create mode 100644 frontend/src/components/gallery/UploadProcessingNotice.tsx create mode 100644 frontend/src/components/gallery/__tests__/CarouselLifecycle.test.tsx create mode 100644 frontend/src/components/gallery/hooks/useGalleryFiltering.ts create mode 100644 frontend/src/components/gallery/hooks/useGallerySelection.ts create mode 100644 frontend/src/components/gallery/hooks/useGalleryUpload.ts create mode 100644 frontend/src/hooks/__tests__/usePhotoSelection.test.tsx create mode 100644 frontend/src/hooks/usePhotoSelection.ts create mode 100644 frontend/src/services/__tests__/gallery.pagination.test.ts create mode 100644 tests/nginx/test_request_logging.py diff --git a/.env.example b/.env.example index 0a55d867..1cb297e1 100644 --- a/.env.example +++ b/.env.example @@ -125,6 +125,11 @@ DB_NAME=picpeak_prod # address is refused by the SSRF check otherwise. Running n8n beside PicPeak is # normal, so set EMAIL_WEBHOOK_ALLOW_PRIVATE_URLS=true for that. # +# Webhook and email-webhook deliveries connect to the DNS answer they just +# validated and ignore HTTP_PROXY / HTTPS_PROXY. Behind a mandatory egress +# proxy set the *_ALLOW_PRIVATE_URLS flag, which sends through the proxy +# without pinning. +# # A mail account with its own SMTP host (Settings -> Mail accounts) keeps # sending through it; this replaces the global transport only. # @@ -353,3 +358,9 @@ LOGS=./logs # Backend signing-key encryption (32+ characters); defaults to JWT_SECRET. # Keep this value stable until participation has been deleted. # USAGE_ENCRYPTION_KEY= + +# Graceful shutdown budget in milliseconds. On SIGTERM the server stops +# accepting requests, drains workers and closes the pool; whatever is still +# running after this long is abandoned so the process exits before Docker's +# 10 s stop grace period (raise stop_grace_period together with this value). +#SHUTDOWN_TIMEOUT_MS=8000 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 15f0d0a4..9bb54921 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,11 +6,6 @@ name: Tests # calendar) plus the photo / settings / OG / auth surface — wiring them # into CI makes regressions visible at PR time instead of post-merge. # -# Six backend suites are excluded via --testPathIgnorePatterns. They -# fail on `upstream/beta` too (pre-existing mock/infra issues, NOT CRM -# regressions). Excluding them here keeps CI green from day 1; revisit -# each individually as its own fix. -# # Triggers on any change that could affect either suite. The backend # job intentionally omits frontend paths and vice versa so unrelated # PRs don't pay both build costs. @@ -89,18 +84,7 @@ jobs: # Un-gates the real-Postgres cases in the .picpeak restore suites # (see the `services:` note above). Absent it they silently skip. PICPEAK_PG_TEST_URL: 'postgres://picpeak:testpass@127.0.0.1:5432/picpeak_test' - run: | - # Excluded suites — fail on upstream/beta too, tracked - # separately as test-infra debt: - # adminSettings.logo — supertest fixture - # integration/adminPhotos.reference — supertest fixture - # integration/webhookDelivery — supertest fixture - # services/backupService.enhanced — knex mock chain - # routes/__tests__/adminAuth — supertest fixture - # (adminNotifications was excluded; #597 fix re-enables it.) - npx jest \ - --testPathIgnorePatterns='/node_modules/|adminSettings\.logo\.test|integration/adminPhotos\.reference|integration/webhookDelivery|backupService\.enhanced|routes/__tests__/adminAuth' \ - --ci + run: npx jest --ci frontend: runs-on: ubuntu-latest @@ -121,10 +105,30 @@ jobs: working-directory: ./frontend run: npm ci + - name: Lint frontend (including Rules of Hooks) + working-directory: ./frontend + run: npm run lint + - name: Run Vitest suite working-directory: ./frontend run: npm test -- --run + nginx: + runs-on: ubuntu-latest + timeout-minutes: 5 + strategy: + matrix: + # Match the two shipped frontend Dockerfiles. + image: ['nginx:1.28-alpine', 'nginx:1.30-alpine'] + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Verify token-safe nginx logging + env: + NGINX_TEST_IMAGE: ${{ matrix.image }} + run: python3 tests/nginx/test_request_logging.py + # Optional face-detection sidecar (#1074). Runs on every PR regardless of # whether the feature is enabled anywhere — these tests need no model # weights (they stub the pipeline out) and cover the auth boundary, the diff --git a/backend/__tests__/integration/webhookDelivery.test.js b/backend/__tests__/integration/webhookDelivery.test.js index 35de5a18..5f3c6deb 100644 --- a/backend/__tests__/integration/webhookDelivery.test.js +++ b/backend/__tests__/integration/webhookDelivery.test.js @@ -5,9 +5,10 @@ process.env.WEBHOOK_ALLOW_PRIVATE_URLS = 'true'; process.env.WEBHOOK_DELIVERY_INTERVAL_MS = '50'; const http = require('http'); -const { db } = require('../../src/database/db'); -const webhookService = require('../../src/services/webhookService'); -const { __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker'); +const { bootCrmDb, seedMinimal } = require('./helpers/crmDb'); +let db, cleanup, adminId; +let webhookService; +let __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker; // Local-only test stub: matches what dev/webhook-receiver/server.js does // in the docker-compose flow but spun up inside the Jest process so the @@ -43,7 +44,7 @@ async function insertWebhook(url, events = ['event.published'], extras = {}) { secret_preview: preview, events: JSON.stringify(events), active: extras.active !== false, - created_by: 1, + created_by: adminId, }).returning('id'); const id = insert[0]?.id || insert[0]; return { id, secret: plaintext }; @@ -56,16 +57,15 @@ async function clearWebhooks() { describe('webhook delivery worker (#327)', () => { beforeAll(async () => { - // Schema is expected to already be applied by `npm run migrate`. We - // just verify the webhooks tables exist; if not, the test harness has - // missed running migration 082. - const ok = await db.schema.hasTable('webhooks'); - if (!ok) throw new Error('webhooks table missing — run `npm run migrate` first'); + ({ db, cleanup } = await bootCrmDb()); + ({ adminId } = await seedMinimal(db)); + webhookService = require('../../src/services/webhookService'); + ({ __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker')); }, 30000); afterAll(async () => { - stopWebhookDeliveryWorker(); - await db.destroy(); + await stopWebhookDeliveryWorker(); + await cleanup(); }); beforeEach(async () => { diff --git a/backend/__tests__/middleware/securityHardeningBatch2.test.js b/backend/__tests__/middleware/securityHardeningBatch2.test.js index e255c4a0..2cbb700d 100644 --- a/backend/__tests__/middleware/securityHardeningBatch2.test.js +++ b/backend/__tests__/middleware/securityHardeningBatch2.test.js @@ -20,6 +20,7 @@ const fake = { maintenance: 'true', revoked: false, beforeCutoff: false, admin: jest.mock('../../src/database/db', () => { const db = jest.fn((table) => { const q = { + leftJoin: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), select: jest.fn().mockReturnThis(), first: jest.fn(async () => { @@ -27,6 +28,7 @@ jest.mock('../../src/database/db', () => { return { setting_key: 'general_maintenance_mode', setting_value: fake.maintenance }; } if (table === 'admin_users') return fake.admin; + if (table === 'events') return { id: 1, slug: 'preview', created_by: 1, is_active: 1 }; return null; }), }; @@ -34,6 +36,7 @@ jest.mock('../../src/database/db', () => { }); return { db, withRetry: (fn) => fn() }; }); +jest.mock('../../src/middleware/permissions', () => ({ userHasAllPermissions: jest.fn().mockResolvedValue(true) })); jest.mock('../../src/utils/logger', () => ({ error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() })); jest.mock('../../src/utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn(async () => fake.revoked) })); jest.mock('../../src/utils/sessionCutoff', () => ({ isTokenBeforeCutoff: jest.fn(async () => fake.beforeCutoff) })); @@ -75,7 +78,7 @@ describe('general rate limiter skip', () => { }); describe('admin preview requires a live admin session', () => { - const req = (token) => ({ query: { admin_preview: '1' }, cookies: { admin_token: token }, headers: {} }); + const req = (token) => ({ params: { slug: 'preview' }, query: { admin_preview: '1' }, cookies: { admin_token: token }, headers: {} }); beforeEach(() => { fake.revoked = false; fake.beforeCutoff = false; fake.admin = { id: 1, password_changed_at: null }; }); it('passes for a live session and sets req.isAdminPreview', async () => { @@ -108,13 +111,23 @@ describe('multipart origin gate', () => { const req = (headers) => ({ headers: { host: 'photos.example.com', ...headers } }); it('accepts same-origin, same-site and non-browser requests', () => { expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-origin' }))).toBe(true); - expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-site' }))).toBe(true); + expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'same-site' }))).toBe(false); expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'none' }))).toBe(true); expect(multipartOriginAllowed(req({}))).toBe(true); expect(multipartOriginAllowed(req({ origin: 'https://photos.example.com' }))).toBe(true); // Same-origin install without FRONTEND_URL: Origin matches the Host. expect(multipartOriginAllowed({ headers: { host: 'gallery.local', origin: 'http://gallery.local' } })).toBe(true); }); + it('trusts Fetch Metadata same-origin before the Origin/scheme comparison', () => { + // TLS terminated upstream without X-Forwarded-Proto: req.protocol is http + // while the browser's Origin is https. Login must still work. + // gallery.local is not in the configured allowlist, so only the Host/scheme + // comparison or Fetch Metadata can admit it. + const proxied = { protocol: 'http', headers: { host: 'gallery.local', origin: 'https://gallery.local', 'sec-fetch-site': 'same-origin' } }; + expect(multipartOriginAllowed(proxied)).toBe(true); + const legacyBrowser = { protocol: 'http', headers: { host: 'gallery.local', origin: 'https://gallery.local' } }; + expect(multipartOriginAllowed(legacyBrowser)).toBe(false); + }); it('rejects cross-site form posts', () => { expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'cross-site' }))).toBe(false); expect(multipartOriginAllowed(req({ origin: 'https://evil.example' }))).toBe(false); diff --git a/backend/__tests__/migrations/eventsTimestampContract.test.js b/backend/__tests__/migrations/eventsTimestampContract.test.js new file mode 100644 index 00000000..c10bf5ce --- /dev/null +++ b/backend/__tests__/migrations/eventsTimestampContract.test.js @@ -0,0 +1,40 @@ +const knex = require('knex'); +const migration = require('../../migrations/core/210_events_updated_at'); +const { toTimestamp } = require('../../src/utils/dateNormalize'); +const { randomUUID } = require('crypto'); + +const engines = [['sqlite', null], ...(process.env.PICPEAK_PG_TEST_URL ? [['pg', process.env.PICPEAK_PG_TEST_URL]] : [])]; +describe.each(engines)('event timestamp migration contract (%s)', (engine, connection) => { + let db, owner, schema; + beforeEach(async () => { + if (engine === 'pg') { + schema = `event_contract_${randomUUID().replace(/-/g, '')}`; + owner = knex({ client: 'pg', connection }); + await owner.schema.createSchema(schema); + db = knex({ client: 'pg', connection, searchPath: [schema] }); + } else db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + }); + afterEach(async () => { + await db.destroy(); + if (owner) { await owner.schema.dropSchema(schema, true); await owner.destroy(); } + }); + it('upgrades legacy data, is repeatable and preserves subsequent edits', async () => { + await db.schema.createTable('events', table => { + table.increments('id'); table.timestamp('created_at').defaultTo(db.fn.now()); table.boolean('is_active').defaultTo(true); + }); + const created = '2026-01-02T03:04:05.000Z'; + await db('events').insert({ created_at: created }); + await migration.up(db); await migration.up(db); + let row = await db('events').first(); + expect(toTimestamp(row.updated_at)).toBe(Date.parse(created)); + await db('events').where({ id: row.id }).update({ updated_at: db.fn.now(), is_active: engine === 'pg' ? false : 0 }); + const changed = (await db('events').first()).updated_at; + await migration.up(db); row = await db('events').first(); + expect(toTimestamp(row.updated_at)).toBe(toTimestamp(changed)); expect([false, 0]).toContain(row.is_active); + }); + it('handles a fresh table and an already present updated_at column', async () => { + await db.schema.createTable('events', table => { table.increments('id'); table.timestamp('created_at'); table.timestamp('updated_at'); }); + await migration.up(db); + expect(await db.schema.hasColumn('events', 'updated_at')).toBe(true); + }); +}); diff --git a/backend/__tests__/migrations/freshPostgresGallery.test.js b/backend/__tests__/migrations/freshPostgresGallery.test.js new file mode 100644 index 00000000..4db57545 --- /dev/null +++ b/backend/__tests__/migrations/freshPostgresGallery.test.js @@ -0,0 +1,57 @@ +const knex = require('knex'); +const { randomUUID } = require('crypto'); +const fs = require('fs/promises'); +const path = require('path'); +const os = require('os'); +const request = require('supertest'); +const pgUrl = process.env.PICPEAK_PG_TEST_URL; +(pgUrl ? describe : describe.skip)('fresh PostgreSQL gallery contract', () => { + let owner, db, schema, tmpDir, cleanup, previousClient; + beforeAll(async () => { + schema = `fresh_gallery_${randomUUID().replace(/-/g, '')}`; + owner = knex({ client: 'pg', connection: pgUrl }); + await owner.schema.createSchema(schema); + previousClient = process.env.DATABASE_CLIENT; + process.env.DATABASE_CLIENT = 'pg'; + process.env.JWT_SECRET = 'fresh-pg-gallery-test-secret-at-least-32-characters'; + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-fresh-pg-')); + process.env.STORAGE_PATH = path.join(tmpDir, 'storage'); + jest.doMock('../../knexfile', () => ({ client: 'pg', connection: pgUrl, searchPath: [schema] })); + ({ db } = require('../../src/database/db')); + // bootCrmDb runs the complete core chain against the shared db singleton. + ({ cleanup } = await require('../integration/helpers/crmDb').bootCrmDb()); + }, 120000); + afterAll(async () => { + await require('../../src/services/serviceShutdown').stopServices(); + if (cleanup) await cleanup(); else if (db) await db.destroy(); + if (owner) { await owner.schema.dropSchema(schema, true); await owner.destroy(); } + if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true }); + if (previousClient === undefined) delete process.env.DATABASE_CLIENT; else process.env.DATABASE_CLIENT = previousClient; + jest.dontMock('../../knexfile'); + }); + it('creates through the real admin route, then toggles a typed boolean and timestamp', async () => { + const { seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp } = require('../integration/helpers/crmDb'); + const { adminId } = await seedMinimal(db); + await assignAdminRole(db, adminId); + const app = buildRouteApp('/api/admin/events', require('../../src/routes/adminEvents')); + const bearer = `Bearer ${mintAdminToken(adminId)}`; + const created = await request(app).post('/api/admin/events').set('Authorization', bearer).send({ + event_type: 'wedding', event_name: 'Fresh PostgreSQL', event_date: '2026-10-01', + customer_name: 'Customer', customer_email: 'customer@example.test', admin_email: 'admin@example.test', + password: 'Strong-Test-Photo-Pass-924!', expiration_days: 30, feedback_enabled: true, + }); + expect(created.status).toBe(200); + const event = await db('events').where({ event_name: 'Fresh PostgreSQL' }).first(); + expect(event.created_by).toBe(adminId); + expect(event.is_active).toBe(true); + expect(event.updated_at).toBeInstanceOf(Date); + expect(await db('event_feedback_settings').where({ event_id: event.id }).first()).toBeTruthy(); + const toggled = await request(app).post(`/api/admin/events/${event.id}/toggle-status`).set('Authorization', bearer).send({}); + expect(toggled.status).toBe(200); + const row = await db('events').where({ id: event.id }).first(); + expect(row.is_active).toBe(false); + expect(row.updated_at).toBeInstanceOf(Date); + await require('../../migrations/core/210_events_updated_at').up(db); + expect((await db('events').where({ id: event.id }).first()).updated_at).toEqual(row.updated_at); + }); +}); diff --git a/backend/__tests__/routes/authSession.symmetry.test.js b/backend/__tests__/routes/authSession.symmetry.test.js index 5c48adf8..122a33e0 100644 --- a/backend/__tests__/routes/authSession.symmetry.test.js +++ b/backend/__tests__/routes/authSession.symmetry.test.js @@ -1,460 +1,82 @@ -/** - * Regression test for the /admin/login → /admin/dashboard → /admin/login - * redirect loop reported on v3.32.4-beta.0. - * - * Cause: GET /auth/session was less strict than the adminAuth middleware. - * The session endpoint accepted tokens that the protected endpoints - * subsequently rejected with 401, which the frontend's interceptor - * translated into a hard redirect to /admin/login. /auth/session then - * said "valid: true" again on the next page load and the cycle closed. - * - * /auth/session must reject the same admin tokens adminAuth would - * reject, specifically: deactivated admin user, deleted admin user, - * password changed since iat. Same for gallery: archived event. - */ - -const express = require('express'); +/** Session restoration uses the same live policy as protected routes. */ const request = require('supertest'); const jwt = require('jsonwebtoken'); - -process.env.JWT_SECRET = 'session-symmetry-test-secret'; - -const fakeDb = { - adminUsers: [], - events: [], - revokedTokens: [], -}; - -jest.mock('../../src/database/db', () => { - const formatBoolean = (v) => (v ? 1 : 0); - void formatBoolean; - function dbFn(table) { - if (table === 'admin_users') { - let rowFilter = () => true; - return { - // The session route joins roles for the adminUser payload (#798); - // fake rows carry no role fields, so the join is a pass-through. - leftJoin() { - return this; - }, - where(criteria) { - rowFilter = (row) => { - return Object.entries(criteria).every(([rawKey, v]) => { - // Joined queries prefix columns ('admin_users.id') — the fake - // rows use bare names. - const k = rawKey.replace(/^admin_users\./, ''); - if (k === 'is_active') return Boolean(row.is_active) === Boolean(v); - return row[k] === v; - }); - }; - return this; - }, - select(...cols) { - this._cols = cols; - return this; - }, - async first() { - const row = fakeDb.adminUsers.find(rowFilter); - if (!row) return undefined; - if (!this._cols) return row; - const out = {}; - for (const c of this._cols) { - // Support 'table.col' and 'table.col as alias' shapes. - const [source, alias] = c.split(/\s+as\s+/i); - const bare = source.includes('.') ? source.split('.').pop() : source; - out[alias || bare] = row[bare]; - } - return out; - }, - }; - } - if (table === 'events') { - let rowFilter = () => true; - return { - where(criteria) { - rowFilter = (row) => - Object.entries(criteria).every(([k, v]) => { - if (k === 'is_active') return Boolean(row.is_active) === Boolean(v); - if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v); - return row[k] === v; - }); - return this; - }, - async first() { - return fakeDb.events.find(rowFilter); - }, - }; - } - throw new Error(`Unexpected table: ${table}`); - } - return { db: dbFn, formatBoolean: () => 1 }; +const crypto = require('crypto'); +const { bootCrmDb, seedMinimal, assignAdminRole, buildRouteApp } = require('../integration/helpers/crmDb'); +process.env.JWT_SECRET = 'session-symmetry-test-secret-with-at-least-32-characters'; +let db, cleanup, app, adminId, customerId, eventId, cutoff; +const slug = 'session-symmetry'; +const sign = (claims = {}) => jwt.sign({ type: 'admin', id: adminId, username: 'tester', + iat: Math.floor(Date.now() / 1000) - 60, jti: crypto.randomUUID(), ...claims }, +process.env.JWT_SECRET, { issuer: 'picpeak-auth', expiresIn: '4h' }); +const gallery = (claims = {}) => sign({ type: 'gallery', eventId, eventSlug: slug, ...claims }); +const session = bearer => request(app).get(`/api/auth/session?slug=${slug}`).set('Authorization', `Bearer ${bearer}`); +beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + ({ adminId, customerId } = await seedMinimal(db)); + await assignAdminRole(db, adminId); + const row = await require('../../src/services/eventCreationService').createEvent({ + event_type: 'wedding', event_name: 'Session symmetry', event_date: '2026-10-01', + slug, password: 'Session-Strong-Password-924!', expiration_days: 30, + customer_email: 'customer@example.test', admin_email: 'admin@example.test', + }, { actor: { id: adminId }, source: 'v1' }); + eventId = row.id; + await db('events').where({ id: eventId }).update({ slug }); + await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId }); + cutoff = require('../../src/utils/sessionCutoff'); + app = buildRouteApp('/api/auth', require('../../src/routes/auth')); +}, 120000); +beforeEach(async () => { + await db('admin_users').where({ id: adminId }).update({ is_active: 1, password_changed_at: null }); + await db('customer_accounts').where({ id: customerId }).update({ is_active: 1, password_changed_at: null }); + await db('events').where({ id: eventId }).update({ is_active: 1, is_archived: 0, is_draft: 0, + expires_at: new Date(Date.now() + 86400000).toISOString() }); + await cutoff.setSessionsValidAfter(0); }); - -jest.mock('../../src/utils/dbCompat', () => ({ - formatBoolean: (v) => (v ? 1 : 0), -})); - -jest.mock('../../src/utils/tokenRevocation', () => ({ - isTokenRevoked: jest.fn(async (decoded) => fakeDb.revokedTokens.includes(decoded.id)), - revokeToken: jest.fn(), -})); - -jest.mock('../../src/utils/tokenUtils', () => ({ - getAdminTokenFromRequest: (req) => { - const auth = req.headers.authorization; - if (auth && auth.startsWith('Bearer ')) return auth.slice(7); - return null; - }, - getGalleryTokenFromRequest: () => null, - setAdminAuthCookie: jest.fn(), - setGalleryAuthCookies: jest.fn(), - clearAdminAuthCookie: jest.fn(), - clearGalleryAuthCookies: jest.fn(), - buildCookieOptionsWithExpiry: () => ({}), -})); - -jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: () => Promise.resolve(true) })); -// Mock sessionTimeout's isSessionExpired so each test controls the return. -// Default: not expired (so existing tests keep passing without setup). -jest.mock('../../src/middleware/sessionTimeout', () => ({ - endSession: jest.fn(), - isSessionExpired: jest.fn(() => Promise.resolve(false)), -})); -jest.mock('../../src/utils/logger', () => ({ - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), -})); - -const authRouter = require('../../src/routes/auth'); - -function makeApp() { - const app = express(); - app.use(express.json()); - app.use('/auth', authRouter); - return app; -} - -function signAdminToken({ id = 1, username = 'admin', iat, exp }) { - const issuedAt = iat ?? Math.floor(Date.now() / 1000); - // Note: do NOT pass noTimestamp:true here — that strips iat from the - // payload entirely, defeating the password-change comparison. Provide - // iat (and exp) via the payload directly instead. - return jwt.sign( - { id, username, type: 'admin', iat: issuedAt, exp: exp ?? issuedAt + 3600 }, - process.env.JWT_SECRET, - { issuer: 'picpeak-auth' } - ); -} - -function signGalleryToken({ eventId = 100, eventSlug = 'wedding', ...extra } = {}) { - return jwt.sign( - { eventId, eventSlug, type: 'gallery', ...extra }, - process.env.JWT_SECRET, - { expiresIn: '1h', issuer: 'picpeak-auth' } - ); -} - -describe('GET /auth/session — symmetry with protected middleware', () => { - beforeEach(() => { - fakeDb.adminUsers = []; - fakeDb.events = []; - fakeDb.revokedTokens = []; - }); - - it('returns valid:true for an active admin token', async () => { - fakeDb.adminUsers.push({ - id: 1, - username: 'admin', - email: 'a@b.com', - is_active: true, - password_changed_at: null, - }); - const token = signAdminToken({ id: 1 }); - - const res = await request(makeApp()) - .get('/auth/session') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(true); - expect(res.body.type).toBe('admin'); - }); - - it('returns valid:false when the admin user has been deactivated', async () => { - fakeDb.adminUsers.push({ - id: 1, - username: 'admin', - email: 'a@b.com', - is_active: false, - password_changed_at: null, - }); - const token = signAdminToken({ id: 1 }); - - const res = await request(makeApp()) - .get('/auth/session') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(false); - }); - - it('returns valid:false when the admin user no longer exists', async () => { - // adminUsers is empty - const token = signAdminToken({ id: 999 }); - - const res = await request(makeApp()) - .get('/auth/session') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(false); - }); - - it('returns valid:false when password was changed after the token was issued', async () => { - // iat must be in the past, exp must be in the future so jwt.verify - // doesn't reject the token before /auth/session even gets to look - // at password_changed_at. - const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60; // 1 min ago - const tokenExp = tokenIssuedAt + 86400; - fakeDb.adminUsers.push({ - id: 1, - username: 'admin', - email: 'a@b.com', - is_active: true, - password_changed_at: new Date((tokenIssuedAt + 30) * 1000), // 30s after iat - }); - const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp }); - - const res = await request(makeApp()) - .get('/auth/session') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(false); - }); - - it('returns valid:true when password was changed BEFORE the token was issued', async () => { - const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60; - const tokenExp = tokenIssuedAt + 86400; - fakeDb.adminUsers.push({ - id: 1, - username: 'admin', - email: 'a@b.com', - is_active: true, - password_changed_at: new Date((tokenIssuedAt - 3600) * 1000), // 1h before iat - }); - const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp }); - - const res = await request(makeApp()) - .get('/auth/session') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(true); - }); - - it('returns valid:false for a gallery token whose event is archived', async () => { - fakeDb.events.push({ - id: 100, - slug: 'wedding', - is_active: true, - is_archived: true, - expires_at: null, - }); - const token = signGalleryToken(); - - const res = await request(makeApp()) - .get('/auth/session?slug=wedding') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(false); - }); - - it('returns valid:false for a gallery token whose event is expired', async () => { - fakeDb.events.push({ - id: 100, - slug: 'wedding', - is_active: true, - is_archived: false, - expires_at: new Date(Date.now() - 86400_000), - }); - const token = signGalleryToken(); - - const res = await request(makeApp()) - .get('/auth/session?slug=wedding') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(false); - }); - - it('returns valid:true for an active gallery token', async () => { - fakeDb.events.push({ - id: 100, - slug: 'wedding', - is_active: true, - is_archived: false, - expires_at: new Date(Date.now() + 86400_000), - }); - const token = signGalleryToken(); - - const res = await request(makeApp()) - .get('/auth/session?slug=wedding') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(true); - }); - - /** - * What KIND of gallery session this is (#1149). - * - * The frontend used to keep this in sessionStorage, which is per-TAB while - * the cookie is per-browser: a gallery reopened in a second tab lost - * 'client' even though the backend still served it as one, and the UI hid - * the only control that clears the privileged cookie. Reported from the - * token so a restored session knows what it actually is. - */ - describe('gallery session kind', () => { - beforeEach(() => { - fakeDb.events.push({ - id: 100, - slug: 'wedding', - is_active: true, - is_archived: false, - expires_at: new Date(Date.now() + 86400_000), - }); - }); - - it('reports a PIN-client session as client', async () => { - const res = await request(makeApp()) - .get('/auth/session?slug=wedding') - .set('Authorization', `Bearer ${signGalleryToken({ accessLevel: 'client' })}`); - expect(res.body.valid).toBe(true); - expect(res.body.accessLevel).toBe('client'); - expect(res.body.viaCustomer).toBe(false); - }); - - it('reports a customer-portal session, which looks like a guest', async () => { - // via:'customer' runs at accessLevel 'guest' but bypasses reveal mode, - // so it is a credential that does not look like one. - const res = await request(makeApp()) - .get('/auth/session?slug=wedding') - .set('Authorization', `Bearer ${signGalleryToken({ via: 'customer', customerId: 7 })}`); - expect(res.body.valid).toBe(true); - expect(res.body.accessLevel).toBe('guest'); - expect(res.body.viaCustomer).toBe(true); - }); - - it('reports a plain guest as neither', async () => { - // The flags have to discriminate, or they would just hand every visitor - // a Logout button back. - const res = await request(makeApp()) - .get('/auth/session?slug=wedding') - .set('Authorization', `Bearer ${signGalleryToken()}`); - expect(res.body.valid).toBe(true); - expect(res.body.accessLevel).toBe('guest'); - expect(res.body.viaCustomer).toBe(false); - }); - }); - - it('returns valid:false when the token is revoked', async () => { - fakeDb.adminUsers.push({ - id: 1, - username: 'admin', - is_active: true, - password_changed_at: null, - }); - fakeDb.revokedTokens.push(1); - const token = signAdminToken({ id: 1 }); - - const res = await request(makeApp()) - .get('/auth/session') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(401); - expect(res.body.valid).toBe(false); - }); - - // Session-timeout symmetry — issue #350 recurrence on v3.39.1-beta.0. - // sessionTimeoutMiddleware (mounted on /api/admin) rejects idle/old-iat - // tokens with 401 SESSION_TIMEOUT, but /auth/session previously didn't. - // The new isSessionExpired helper closes that asymmetry. - describe('session-timeout symmetry', () => { - const { isSessionExpired } = require('../../src/middleware/sessionTimeout'); - - beforeEach(() => { - isSessionExpired.mockReset(); - // Default to "active session" so the other admin checks above also - // pass when this branch runs. - isSessionExpired.mockResolvedValue(false); - }); - - it('returns valid:false when isSessionExpired reports the token has timed out', async () => { - fakeDb.adminUsers.push({ - id: 1, - username: 'admin', - is_active: true, - password_changed_at: null, - }); - isSessionExpired.mockResolvedValue(true); - const token = signAdminToken({ id: 1 }); - - const res = await request(makeApp()) - .get('/auth/session') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(false); - expect(res.body.error).toBe('Session expired'); - }); - - it('returns valid:true for an active admin token (helper says not expired)', async () => { - fakeDb.adminUsers.push({ - id: 1, - username: 'admin', - is_active: true, - password_changed_at: null, - }); - isSessionExpired.mockResolvedValue(false); - const token = signAdminToken({ id: 1 }); - - const res = await request(makeApp()) - .get('/auth/session') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(true); - expect(isSessionExpired).toHaveBeenCalledTimes(1); - }); - - it('does not call isSessionExpired for gallery tokens', async () => { - fakeDb.events.push({ - id: 100, - slug: 'wedding', - is_active: true, - is_archived: false, - expires_at: new Date(Date.now() + 86400_000), - }); - const token = signGalleryToken(); - - const res = await request(makeApp()) - .get('/auth/session?slug=wedding') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(true); - expect(isSessionExpired).not.toHaveBeenCalled(); - }); - - it('falls through (treats as valid) if the helper itself throws', async () => { - // Defensive: the require() in auth.js is wrapped in try/catch so a - // missing/broken helper doesn't fail-closed during early bootstrap. - fakeDb.adminUsers.push({ - id: 1, - username: 'admin', - is_active: true, - password_changed_at: null, - }); - isSessionExpired.mockRejectedValue(new Error('boom')); - const token = signAdminToken({ id: 1 }); - - const res = await request(makeApp()) - .get('/auth/session') - .set('Authorization', `Bearer ${token}`); - expect(res.status).toBe(200); - expect(res.body.valid).toBe(true); - }); - }); +afterAll(async () => { + await require('../../src/services/serviceShutdown').stopServices(); + if (cleanup) await cleanup(); +}); +it('hydrates an active admin and its role', async () => { + const res = await session(sign()); + expect(res.body).toMatchObject({ valid: true, type: 'admin', adminUser: { id: adminId, role: { name: 'super_admin' } } }); +}); +it.each(['disabled', 'password', 'deleted', 'idle'])('rejects an admin after %s', async reason => { + let bearer = sign(); + if (reason === 'disabled') await db('admin_users').where({ id: adminId }).update({ is_active: 0 }); + if (reason === 'password') await db('admin_users').where({ id: adminId }).update({ password_changed_at: new Date().toISOString() }); + if (reason === 'deleted') bearer = sign({ id: 999999 }); + if (reason === 'idle') bearer = sign({ iat: Math.floor(Date.now() / 1000) - 7200 }); + expect((await session(bearer)).body.valid).toBe(false); +}); +it('accepts a session issued after a previous password change', async () => { + await db('admin_users').where({ id: adminId }).update({ password_changed_at: new Date(Date.now() - 120000).toISOString() }); + expect((await session(sign())).body.valid).toBe(true); +}); +it.each(['archived', 'expired', 'draft', 'inactive'])('rejects a gallery that is %s', async reason => { + await db('events').where({ id: eventId }).update({ + ...(reason === 'archived' && { is_archived: 1 }), ...(reason === 'draft' && { is_draft: 1 }), + ...(reason === 'inactive' && { is_active: 0 }), ...(reason === 'expired' && { expires_at: new Date(Date.now() - 1000).toISOString() }), + }); + expect((await session(gallery())).body.valid).toBe(false); +}); +it.each(['guest', 'client', 'customer'])('restores the %s gallery session kind', async kind => { + const res = await session(gallery(kind === 'customer' ? { via: 'customer', customerId } : { accessLevel: kind })); + expect(res.body).toMatchObject({ valid: true, accessLevel: kind === 'client' ? 'client' : 'guest', viaCustomer: kind === 'customer' }); +}); +it.each(['revoked', 'restore'])('invalidates both admin and gallery sessions after %s', async reason => { + const tokens = [sign(), gallery()]; + if (reason === 'restore') await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000)); + else for (const bearer of tokens) await require('../../src/utils/tokenRevocation').revokeToken(bearer, 'test'); + for (const bearer of tokens) expect((await session(bearer)).body.valid).toBe(false); +}); +it('refuses a deactivated customer gallery session', async () => { + const bearer = gallery({ via: 'customer', customerId }); + expect((await session(bearer)).body.valid).toBe(true); + await db('customer_accounts').where({ id: customerId }).update({ is_active: 0 }); + expect((await session(bearer)).body.valid).toBe(false); +}); +it('refuses an unrelated JWT type', async () => { + const res = await session(sign({ type: 'password-reset' })); + expect(res.status).toBe(403); expect(res.body.valid).toBe(false); }); diff --git a/backend/__tests__/routes/galleryAccessPolicy.test.js b/backend/__tests__/routes/galleryAccessPolicy.test.js new file mode 100644 index 00000000..80dd960b --- /dev/null +++ b/backend/__tests__/routes/galleryAccessPolicy.test.js @@ -0,0 +1,203 @@ +/** Real routes + migrated SQLite: the same session policy protects lists and media. */ +const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb'); +const request = require('supertest'); +const express = require('express'); +const cookieParser = require('cookie-parser'); +const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); +const fs = require('fs/promises'); +const path = require('path'); +process.env.JWT_SECRET = 'gallery-policy-regression-secret-at-least-32-characters'; + +jest.mock('../../src/middleware/secureImageMiddleware', () => ({ + secureImageAccess: (req, _res, next) => { + req.clientInfo = { fingerprint: 'policy-test', ip: '127.0.0.1', userAgent: 'jest' }; + next(); + }, + getSecurityStatus: (_req, res) => res.json({}), +})); + +let db, cleanup, app, adminId, customerId, foreignId, event, secure, cutoff, revokeToken; +const eventId = 70001, photoId = 70002, slug = 'policy-test'; +const token = (claims = {}) => jwt.sign({ type: 'gallery', eventId, eventSlug: slug, + iat: Math.floor(Date.now() / 1000) - 60, jti: crypto.randomUUID(), ...claims }, +process.env.JWT_SECRET, { issuer: 'picpeak-auth', expiresIn: '1h' }); +const get = (url, bearer) => { + const req = request(app).get(url); + return bearer ? req.set('Authorization', `Bearer ${bearer}`) : req; +}; +const endpoints = [`/api/gallery/${slug}/photos`, `/api/gallery/${slug}/photo/${photoId}`, + `/api/gallery/${slug}/thumbnail/${photoId}`, `/api/gallery/${slug}/download/${photoId}`]; +const expectDirect = async (bearer, status, suffix = '') => { + for (const url of endpoints) expect((await get(url + suffix, bearer)).status).toBe(status); +}; + +beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); + ({ adminId, customerId } = await seedMinimal(db)); + await assignAdminRole(db, adminId); + const [row] = await db('admin_users').insert({ username: 'foreign', email: 'foreign@example.test', password_hash: 'unused', is_active: 1 }).returning('id'); + foreignId = row.id ?? row; + await assignAdminRole(db, foreignId, 'viewer'); + await db('events').insert({ id: eventId, slug, event_type: 'wedding', event_name: 'Policy test', + event_date: '2026-01-01', host_email: 'h@example.test', admin_email: 'a@example.test', password_hash: 'unused', + share_link: '/gallery/policy-test', created_by: adminId }); + const file = path.join(process.env.STORAGE_PATH, `events/active/${slug}/individual/fixture.jpg`); + await fs.mkdir(path.dirname(file), { recursive: true }); + await require('sharp')({ create: { width: 8, height: 8, channels: 3, background: '#228844' } }).jpeg().toFile(file); + await db('photos').insert({ id: photoId, event_id: eventId, filename: 'fixture.jpg', path: `${slug}/individual/fixture.jpg`, + type: 'individual', mime_type: 'image/jpeg', processing_status: 'complete', size_bytes: (await fs.stat(file)).size }); + await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId }); + secure = require('../../src/services/secureImageService'); + jest.spyOn(secure, 'createClientFingerprint').mockReturnValue('policy-test'); + cutoff = require('../../src/utils/sessionCutoff'); + ({ revokeToken } = require('../../src/utils/tokenRevocation')); + app = express(); app.use(express.json()); app.use(cookieParser()); + app.use('/api', require('../../src/middleware/csrf')); + app.use('/api/admin/events', require('../../src/routes/adminEvents')); + app.use('/api/gallery', require('../../src/routes/gallery')); + app.use('/api/images', require('../../src/routes/protectedImages')); + app.use('/api/secure-images', require('../../src/routes/secureImages')); +}, 120000); + +beforeEach(async () => { + await db('events').where({ id: eventId }).update({ is_active: 1, is_archived: 0, is_draft: 0, require_password: 1, + expires_at: new Date(Date.now() + 86400000).toISOString(), reveal_mode: 0 }); + await db('customer_accounts').where({ id: customerId }).update({ is_active: 1, password_changed_at: null }); + if (!await db('event_customer_assignments').where({ event_id: eventId, customer_account_id: customerId }).first()) { + await db('event_customer_assignments').insert({ event_id: eventId, customer_account_id: customerId }); + } + await cutoff.setSessionsValidAfter(0); + event = await db('events').where({ id: eventId }).first(); +}); +afterAll(async () => { secure?.dispose(); if (cleanup) await cleanup(); }); + +it('serves a valid session as a real list and JPEG', async () => { + const bearer = token(); + const list = await get(endpoints[0], bearer); + expect(list.status).toBe(200); + expect(list.body.photos).toEqual(expect.arrayContaining([expect.objectContaining({ id: photoId })])); + const image = await get(endpoints[1], bearer); + expect(image.status).toBe(200); expect(image.headers['content-type']).toMatch(/image\/jpeg/); + expect(image.body.length).toBeGreaterThan(100); +}); +it('scopes draft previews to the owner and current read permissions', async () => { + await db('events').where({ id: eventId }).update({ is_draft: 1 }); + await expectDirect(mintAdminToken(foreignId), 403, '?admin_preview=1'); + await expectDirect(mintAdminToken(adminId), 200, '?admin_preview=1'); + // Ownership alone does not grant a user without a role read access. + const role = (await db('admin_users').where({ id: adminId }).first()).role_id; + await db('admin_users').where({ id: adminId }).update({ role_id: null }); + try { await expectDirect(mintAdminToken(adminId), 403, '?admin_preview=1'); } + finally { await db('admin_users').where({ id: adminId }).update({ role_id: role }); } +}); +it.each(['revocation', 'restore'])('rejects gallery sessions after %s', async (reason) => { + const bearer = token(); + if (reason === 'revocation') expect(await revokeToken(bearer, 'test')).toBe(true); + else await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000)); + await expectDirect(bearer, 401); +}); +it.each(['deactivated', 'password changed'])('rejects an assigned customer when %s', async (reason) => { + const bearer = token({ via: 'customer', customerId }); + await expectDirect(bearer, 200); + await db('customer_accounts').where({ id: customerId }).update(reason === 'deactivated' + ? { is_active: 0 } : { password_changed_at: new Date().toISOString() }); + await expectDirect(bearer, 401); +}); +it.each(['ISO', 'epoch'])('enforces expiry immediately for public and JWT access (%s)', async (format) => { + const expiry = Date.now() - 1000; + await db('events').where({ id: eventId }).update({ require_password: 0, expires_at: format === 'ISO' ? new Date(expiry).toISOString() : expiry }); + await expectDirect(undefined, 404); await expectDirect(token(), 404); + await expectDirect(mintAdminToken(adminId), 200, '?admin_preview=1'); +}); +it.each(['revocation', 'restore', 'expiry', 'customer'])('rechecks signed and secure image grants after %s', async (reason) => { + const bearer = token(reason === 'customer' ? { via: 'customer', customerId } : {}); + const signed = await request(app).post(`/api/images/${slug}/photo/${photoId}/generate-url`).set('Authorization', `Bearer ${bearer}`).send({}); + expect(signed.status).toBe(200); + const minted = await request(app).post(`/api/secure-images/${slug}/generate-token`).set('Authorization', `Bearer ${bearer}`).send({ photoId }); + expect(minted.status).toBe(200); + const secureUrl = `/api/secure-images/${slug}/secure/${photoId}/${minted.body.token}`; + expect((await get(signed.body.url)).status).toBe(200); + expect((await get(secureUrl)).status).toBe(200); + if (reason === 'revocation') await revokeToken(bearer, 'test'); + if (reason === 'restore') await cutoff.setSessionsValidAfter(Math.floor(Date.now() / 1000)); + if (reason === 'expiry') await db('events').where({ id: eventId }).update({ expires_at: new Date(Date.now() - 1000).toISOString() }); + if (reason === 'customer') await db('customer_accounts').where({ id: customerId }).update({ is_active: 0 }); + const status = reason === 'expiry' ? 404 : 401; + expect((await get(signed.body.url)).status).toBe(status); + expect((await get(secureUrl)).status).toBe(status); +}); + +it('blocks an empty cross-site cookie POST before the reveal state changes', async () => { + await db('events').where({ id: eventId }).update({ reveal_mode: 1, revealed_at: null }); + const cookie = `admin_token=${mintAdminToken(adminId)}`; + const url = `/api/admin/events/${eventId}/reveal`; + const blocked = await request(app).post(url).set('Cookie', cookie).set('Origin', 'https://attacker.example') + .set('Sec-Fetch-Site', 'cross-site').set('Content-Type', 'application/x-www-form-urlencoded').send(''); + expect(blocked.status).toBe(403); + expect((await db('events').where({ id: eventId }).first()).revealed_at).toBeNull(); + process.env.ADMIN_URL = 'https://admin.example.test'; + try { + const allowed = await request(app).post(url).set('Cookie', cookie).set('Origin', process.env.ADMIN_URL) + .set('Sec-Fetch-Site', 'cross-site').send({}); + expect(allowed.status).toBe(200); + expect((await db('events').where({ id: eventId }).first()).revealed_at).not.toBeNull(); + } finally { delete process.env.ADMIN_URL; } +}); + +it('toggles status on a fully migrated fresh database and records updated_at', async () => { + const response = await request(app).post(`/api/admin/events/${eventId}/toggle-status`) + .set('Authorization', `Bearer ${mintAdminToken(adminId)}`).send({}); + expect(response.status).toBe(200); + const row = await db('events').where({ id: eventId }).first(); + expect([false, 0]).toContain(row.is_active); + expect(Number.isFinite(require('../../src/utils/dateNormalize').toTimestamp(row.updated_at))).toBe(true); +}); +it('paginates after feedback filtering, with a total independent of page size', async () => { + const ids = [70003, 70004, 70005]; + await db('photos').insert(ids.map(id => ({ id, event_id: eventId, filename: `${id}.jpg`, path: 'unused', + type: 'individual', like_count: 1, processing_status: 'complete' }))); + await db('event_feedback_settings').insert({ event_id: eventId, feedback_enabled: 1, show_feedback_to_guests: 1 }); + try { + const bearer = token(); + const first = await get(`${endpoints[0]}?filter=liked&limit=2&page=1&sort=filename&order=asc`, bearer); + const second = await get(`${endpoints[0]}?filter=liked&limit=2&page=2&sort=filename&order=asc`, bearer); + expect(first.status).toBe(200); expect(second.status).toBe(200); + expect(first.body.pagination).toMatchObject({ total: 3, has_more: true }); + expect(second.body.pagination).toMatchObject({ total: 3, has_more: false }); + expect([...first.body.photos, ...second.body.photos].map(photo => photo.id)).toEqual(ids); + } finally { + await db('photos').whereIn('id', ids).del(); + await db('event_feedback_settings').where({ event_id: eventId }).del(); + } +}); + +it.each(['assignment removed', 'anonymized'])('rejects an existing customer grant after %s', async reason => { + const bearer = token({ via: 'customer', customerId }); + await expectDirect(bearer, 200); + if (reason === 'assignment removed') await db('event_customer_assignments').where({ event_id: eventId, customer_account_id: customerId }).del(); + else await require('../../src/services/customerAccountsService').eraseCustomer(customerId, adminId); + await expectDirect(bearer, reason === 'assignment removed' ? 403 : 401); +}); +it('denies foreign editors and allows the editor who owns the gallery', async () => { + await assignAdminRole(db, foreignId, 'editor'); + try { + await expectDirect(mintAdminToken(foreignId), 403, '?admin_preview=1'); + await db('events').where({ id: eventId }).update({ created_by: foreignId }); + await expectDirect(mintAdminToken(foreignId), 200, '?admin_preview=1'); + } finally { + await db('events').where({ id: eventId }).update({ created_by: adminId }); + await assignAdminRole(db, foreignId, 'viewer'); + } +}); +it('bounds a large gallery response while retaining the complete count', async () => { + const rows = Array.from({ length: 5000 }, (_, index) => ({ id: 80000 + index, event_id: eventId, + filename: `large-${index}.jpg`, path: 'unused', type: 'individual', processing_status: 'complete' })); + try { + await db.batchInsert('photos', rows, 100); + const response = await get(`${endpoints[0]}?limit=999999&page=1`, token()); + expect(response.status).toBe(200); + expect(response.body.photos).toHaveLength(250); + expect(response.body.pagination).toMatchObject({ total: 5001, limit: 250, has_more: true }); + } finally { await db('photos').where('id', '>=', 80000).where({ event_id: eventId }).del(); } +}); diff --git a/backend/__tests__/routes/galleryTokenJti.test.js b/backend/__tests__/routes/galleryTokenJti.test.js new file mode 100644 index 00000000..4334c1d9 --- /dev/null +++ b/backend/__tests__/routes/galleryTokenJti.test.js @@ -0,0 +1,40 @@ +/** + * Gallery tokens must carry a per-token `jti`. tokenRevocation falls back to + * `${eventId}-${iat}-gallery` without one, so a guest logging out would revoke + * every other guest whose token was minted for the same event in the same + * second (QR-code share links at an event make that routine). + */ +const fs = require('fs'); +const path = require('path'); +const jwt = require('jsonwebtoken'); + +const mintSites = [ + 'src/routes/auth.js', + 'src/routes/customer.js', + 'src/routes/gallery/slideshow.js', +]; + +describe('gallery token mint sites', () => { + it.each(mintSites)('%s sets a unique jti on every gallery token', (file) => { + const source = fs.readFileSync(path.join(__dirname, '../../', file), 'utf8'); + const payloads = source.split('jwt.sign(').slice(1) + .map((chunk) => chunk.split('process.env.JWT_SECRET')[0]) + .filter((payload) => payload.includes("type: 'gallery'")); + expect(payloads.length).toBeGreaterThan(0); + for (const payload of payloads) expect(payload).toContain('jti: crypto.randomUUID()'); + }); +}); + +describe('revocation key', () => { + beforeAll(() => { process.env.JWT_SECRET = process.env.JWT_SECRET || 'jti-regression-secret-at-least-32-characters-long'; }); + it('is distinct for two same-second gallery logins of the same event', () => { + const { buildTokenId } = require('../../src/utils/tokenRevocation'); + const crypto = require('crypto'); + const iat = Math.floor(Date.now() / 1000); + const mint = () => jwt.decode(jwt.sign({ eventId: 7, type: 'gallery', iat, jti: crypto.randomUUID() }, process.env.JWT_SECRET)); + expect(buildTokenId(mint())).not.toBe(buildTokenId(mint())); + // Without a jti the key collapses to eventId + login second. + const bare = jwt.decode(jwt.sign({ eventId: 7, type: 'gallery', iat }, process.env.JWT_SECRET)); + expect(buildTokenId(bare)).toBe(buildTokenId({ ...bare })); + }); +}); diff --git a/backend/__tests__/routes/secureImageTokenBinding.test.js b/backend/__tests__/routes/secureImageTokenBinding.test.js index d11b818d..c14f93e7 100644 --- a/backend/__tests__/routes/secureImageTokenBinding.test.js +++ b/backend/__tests__/routes/secureImageTokenBinding.test.js @@ -89,7 +89,8 @@ describe('secure-image view route token binding (GHSA-g94x)', () => { const mint = (photoId, eventId) => secureImageService.generateSecureToken( photoId, `gallery_public_${eventId}_${Date.now()}`, - { clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600 }, + { clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600, + galleryAccess: require('../../src/services/galleryAccessService').grant({ id: eventId }, 'public') }, ); const view = (slug, photoId, token) => request(app) @@ -114,7 +115,7 @@ describe('secure-image view route token binding (GHSA-g94x)', () => { const token = mint(photoA, galleryA); const res = await view('secimg-private-b', photoB, token); expect(res.status).toBe(403); - expect(res.body.error).toMatch(/not valid for this photo/i); + expect(res.body.code).toBe('INVALID_GALLERY_GRANT'); }); it('rejects a gallery-A token replayed on gallery B with A\'s photoId', async () => { @@ -123,7 +124,7 @@ describe('secure-image view route token binding (GHSA-g94x)', () => { // check (sessionId gallery A != URL gallery B) must catch it. const res = await view('secimg-private-b', photoA, token); expect(res.status).toBe(403); - expect(res.body.error).toMatch(/not valid for this gallery/i); + expect(res.body.code).toBe('INVALID_GALLERY_GRANT'); }); it('lets a token read its own gallery + photo (binding passes)', async () => { diff --git a/backend/__tests__/services/emailWebhookTransport.test.js b/backend/__tests__/services/emailWebhookTransport.test.js index f13505a5..429c276e 100644 --- a/backend/__tests__/services/emailWebhookTransport.test.js +++ b/backend/__tests__/services/emailWebhookTransport.test.js @@ -15,7 +15,7 @@ jest.mock('axios', () => ({ post: jest.fn() })); jest.mock('../../src/utils/networkValidation', () => ({ - validateExternalUrlAsync: jest.fn(async () => ({ valid: true, reason: 'ok' })), + validateExternalUrlAsync: jest.fn(async () => ({ valid: true, reason: 'ok', hostname: 'relay.example', addresses: [{ address: '93.184.216.34', family: 4 }] })), })); jest.mock('../../src/utils/logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), @@ -48,7 +48,7 @@ beforeEach(() => { process.env.EMAIL_WEBHOOK_SECRET = SECRET; transport.__testing.setAllowPrivateUrls(false); transport.__testing.resetSecretWarning(); - validateExternalUrlAsync.mockResolvedValue({ valid: true, reason: 'ok' }); + validateExternalUrlAsync.mockResolvedValue({ valid: true, reason: 'ok', hostname: 'relay.example', addresses: [{ address: '93.184.216.34', family: 4 }] }); axios.post.mockResolvedValue({ status: 200, data: streamOf('') }); }); diff --git a/backend/__tests__/services/scheduledTask.test.js b/backend/__tests__/services/scheduledTask.test.js new file mode 100644 index 00000000..d9c600f6 --- /dev/null +++ b/backend/__tests__/services/scheduledTask.test.js @@ -0,0 +1,25 @@ +jest.mock('../../src/utils/logger', () => ({ error: jest.fn() })); +const { scheduledTask } = require('../../src/services/scheduledTask'); +beforeEach(() => jest.useFakeTimers()); +afterEach(() => jest.useRealTimers()); +it('starts once, skips overlap and drains the accepted run on stop', async () => { + let finish; + const work = jest.fn(() => new Promise(resolve => { finish = resolve; })); + const task = scheduledTask(work, { interval: 100 }); + task.start(); task.start(); + expect(jest.getTimerCount()).toBe(1); + await jest.advanceTimersByTimeAsync(300); + expect(work).toHaveBeenCalledTimes(1); + let stopped = false; + const stop = task.stop().then(() => { stopped = true; }); + await Promise.resolve(); expect(stopped).toBe(false); + finish(); await stop; expect(stopped).toBe(true); + expect(jest.getTimerCount()).toBe(0); + await jest.advanceTimersByTimeAsync(1000); expect(work).toHaveBeenCalledTimes(1); +}); +it('cancels a delayed first run and can restart cleanly', async () => { + const work = jest.fn(); const task = scheduledTask(work, { interval: 100, initialDelay: 10 }); + task.start(); await task.stop(); await jest.advanceTimersByTimeAsync(200); expect(work).not.toHaveBeenCalled(); + task.start(); await jest.advanceTimersByTimeAsync(10); expect(work).toHaveBeenCalledTimes(1); + await task.stop(); +}); diff --git a/backend/__tests__/services/secureImageService.lifecycle.test.js b/backend/__tests__/services/secureImageService.lifecycle.test.js new file mode 100644 index 00000000..108a488e --- /dev/null +++ b/backend/__tests__/services/secureImageService.lifecycle.test.js @@ -0,0 +1,15 @@ +jest.mock('../../src/database/db', () => ({ db: jest.fn() })); +const secure = require('../../src/services/secureImageService'); +beforeEach(() => { jest.useFakeTimers(); secure.dispose(); }); +afterEach(() => { secure.dispose(); jest.useRealTimers(); }); +it('owns one timer for many tokens and sweeps expired capabilities', () => { + for (let i = 0; i < 100; i++) secure.generateSecureToken(i, 'gallery_public_1_1', { expiresIn: 1 }); + expect(jest.getTimerCount()).toBe(1); expect(secure.tokenCache.size).toBe(100); + jest.advanceTimersByTime(60000); expect(secure.tokenCache.size).toBe(0); + secure.dispose(); expect(jest.getTimerCount()).toBe(0); +}); +it('disposes all session/rate caches and restarts on demand', () => { + secure.generateSecureToken(1, 'session'); secure.sessionTokens.set('a', 'b'); secure.rateLimitCache.set('a', 'b'); + secure.dispose(); expect(secure.sessionTokens.size + secure.rateLimitCache.size + secure.tokenCache.size).toBe(0); + secure.generateSecureToken(1, 'session'); expect(jest.getTimerCount()).toBe(1); +}); diff --git a/backend/__tests__/utils/photoContentType.test.js b/backend/__tests__/utils/photoContentType.test.js index 406cd196..4bf82cf6 100644 --- a/backend/__tests__/utils/photoContentType.test.js +++ b/backend/__tests__/utils/photoContentType.test.js @@ -31,7 +31,7 @@ describe('resolvePhotoContentType', () => { }); describe('serving routes use the resolver', () => { - const routes = ['gallery.js', 'secureImages.js', 'protectedImages.js', 'adminPhotos.js']; + const routes = ['gallery/media.js', 'gallery/downloads.js', 'secureImages.js', 'protectedImages.js', 'adminPhotos.js']; it.each(routes)('%s sets no Content-Type from photo.mime_type directly', (name) => { const src = fs.readFileSync(path.join(__dirname, '../../src/routes', name), 'utf8'); expect(src).not.toMatch(/'Content-Type':\s*photo\.mime_type/); diff --git a/backend/__tests__/utils/pinnedRequest.test.js b/backend/__tests__/utils/pinnedRequest.test.js new file mode 100644 index 00000000..70256db4 --- /dev/null +++ b/backend/__tests__/utils/pinnedRequest.test.js @@ -0,0 +1,79 @@ +const dns = require('dns'); +const http = require('http'); +const axios = require('axios'); +const { validateExternalUrlAsync } = require('../../src/utils/networkValidation'); +const { pinnedRequestOptions } = require('../../src/utils/pinnedRequest'); +afterEach(() => jest.restoreAllMocks()); +it('never performs a second DNS lookup that could reach a private listener', async () => { + const received = jest.fn(); + const server = http.createServer((req, res) => { received(); res.end('private'); }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const url = `http://rebind.example:${server.address().port}/hook`; + const preflight = jest.spyOn(dns.promises, 'lookup').mockResolvedValue([{ address: '192.0.2.1', family: 4 }]); + const unsafeLookup = jest.spyOn(dns, 'lookup').mockImplementation((_host, opts, cb) => { + if (typeof opts === 'function') { cb = opts; opts = {}; } + cb(null, ...(opts.all ? [[{ address: '127.0.0.1', family: 4 }]] : ['127.0.0.1', 4])); + }); + let options; + try { + const check = await validateExternalUrlAsync(url); + options = pinnedRequestOptions(check); + await expect(axios.post(url, 'private-data', { ...options, timeout: 200 })).rejects.toThrow(); + expect(preflight).toHaveBeenCalledTimes(1); + expect(unsafeLookup).not.toHaveBeenCalled(); expect(received).not.toHaveBeenCalled(); + } finally { + options?.httpAgent.destroy(); options?.httpsAgent.destroy(); + await new Promise(resolve => server.close(resolve)); + } +}); +it('preserves the original Host and refuses redirects while using the pinned address', async () => { + const hosts = []; + const server = http.createServer((req, res) => { + hosts.push(req.headers.host); res.writeHead(302, { Location: 'http://localhost/private' }); res.end(); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const host = `pinned.example:${server.address().port}`; + // Test the transport in isolation: production checks reject this private IP. + const options = pinnedRequestOptions({ valid: true, hostname: 'pinned.example', addresses: [{ address: '127.0.0.1', family: 4 }] }); + try { + const res = await axios.post(`http://${host}/hook`, 'body', { ...options, validateStatus: () => true }); + expect(res.status).toBe(302); expect(hosts).toEqual([host]); expect(options.proxy).toBe(false); + } finally { options.httpAgent.destroy(); options.httpsAgent.destroy(); await new Promise(resolve => server.close(resolve)); } +}); +it('fails closed for missing DNS results', () => { + expect(() => pinnedRequestOptions({ valid: true })).toThrow('validated destination'); +}); + +it('preserves TLS SNI and certificate hostname verification for a pinned connection', async () => { + const fs = require('fs/promises'); + const path = require('path'); + const dir = await fs.mkdtemp(path.join(require('os').tmpdir(), 'picpeak-tls-pin-')); + const key = path.join(dir, 'key.pem'), cert = path.join(dir, 'cert.pem'); + require('child_process').execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', + '-keyout', key, '-out', cert, '-days', '1', '-subj', '/CN=pinned.example', + '-addext', 'subjectAltName=DNS:pinned.example'], { stdio: 'ignore' }); + const certificate = await fs.readFile(cert); + const seen = []; + const server = require('https').createServer({ key: await fs.readFile(key), cert: certificate }, (req, res) => { + seen.push({ host: req.headers.host, servername: req.socket.servername }); res.end('ok'); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const makeOptions = hostname => { + const options = pinnedRequestOptions({ valid: true, hostname, addresses: [{ address: '127.0.0.1', family: 4 }] }); + options.httpsAgent.options.ca = certificate; + return options; + }; + const allowed = makeOptions('pinned.example'), wrong = makeOptions('wrong.example'); + try { + const host = `pinned.example:${server.address().port}`; + expect((await axios.post(`https://${host}/hook`, 'data', { ...allowed, timeout: 2000 })).status).toBe(200); + expect(seen).toEqual([{ host, servername: 'pinned.example' }]); + await expect(axios.post(`https://wrong.example:${server.address().port}/hook`, 'data', { ...wrong, timeout: 2000 })) + .rejects.toMatchObject({ code: 'ERR_TLS_CERT_ALTNAME_INVALID' }); + expect(seen).toHaveLength(1); + } finally { + for (const options of [allowed, wrong]) { options.httpAgent.destroy(); options.httpsAgent.destroy(); } + await new Promise(resolve => server.close(resolve)); + await fs.rm(dir, { recursive: true, force: true }); + } +}); diff --git a/backend/__tests__/utils/requestLogPath.test.js b/backend/__tests__/utils/requestLogPath.test.js new file mode 100644 index 00000000..a54ce2f1 --- /dev/null +++ b/backend/__tests__/utils/requestLogPath.test.js @@ -0,0 +1,28 @@ +const EventEmitter = require('events'); +jest.mock('../../src/utils/logger', () => ({ info: jest.fn() })); +const logger = require('../../src/utils/logger'); +const middleware = require('../../src/middleware/apiRequestLogger'); +const { requestLogPath } = require('../../src/utils/requestLogPath'); +const marker = 'SECRET_TEST_CAPABILITY'; +it.each([ + `/api/gallery/g/photos?token=${marker}&password=${marker}`, + `/api/gallery/g/verify-token/${marker}`, + `/api/gallery/g/show/${marker}/state`, + `/api/images/g/photo/1/signed/${marker}`, + `/api/secure-images/g/secure/1/${marker}`, + `/api/secure-images/g/secure-download/1/${marker}`, + `/api/public/contracts/${marker}/sign`, + `/api/customer/auth/password-reset/${marker}`, + `/api/public/newsletter/unsubscribe/${marker}`, +])('does not log capabilities on request or response: %s', (originalUrl) => { + logger.info.mockClear(); + const res = new EventEmitter(); res.statusCode = 200; + middleware({ originalUrl, method: 'GET' }, res, jest.fn()); + res.emit('finish'); + expect(logger.info).toHaveBeenCalledTimes(2); + expect(JSON.stringify(logger.info.mock.calls)).not.toContain(marker); +}); +it('retains useful non-secret routes and removes control characters', () => { + expect(requestLogPath('/api/admin/events/12?search=private')).toBe('/api/admin/events/12'); + expect(requestLogPath('/api/admin/events\nforged')).not.toContain('\n'); +}); diff --git a/backend/__tests__/utils/testListenerAddress.test.js b/backend/__tests__/utils/testListenerAddress.test.js new file mode 100644 index 00000000..93ee2605 --- /dev/null +++ b/backend/__tests__/utils/testListenerAddress.test.js @@ -0,0 +1,13 @@ +const http = require('http'); +it('directs Supertest to the actual IPv6 listener instead of an unrelated IPv4 port', async () => { + jest.resetModules(); + const request = require('supertest'); + const server = http.createServer((_req, res) => { res.end('actual test listener'); }); + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '::1', resolve); }); + try { + const response = await request(server).get('/'); + expect(response.status).toBe(200); + expect(response.text).toBe('actual test listener'); + expect(response.request.url).toContain('://[::1]:'); + } finally { await new Promise(resolve => server.close(resolve)); } +}); diff --git a/backend/jest.setup.js b/backend/jest.setup.js index e20fdec2..a9f09ac0 100644 --- a/backend/jest.setup.js +++ b/backend/jest.setup.js @@ -1,3 +1,18 @@ +// Supertest 6 binds an IPv6 wildcard listener but hardcodes an IPv4 URL. +// macOS can allocate that IPv6 port while a different IPv4 service owns it. +// Address the listener's actual family so a test cannot reach that service. +jest.mock('supertest/lib/test', () => { + const Test = jest.requireActual('supertest/lib/test'); + const serverAddress = Test.prototype.serverAddress; + Test.prototype.serverAddress = function(app, path) { + const url = serverAddress.call(this, app, path); + return app.address()?.family === 'IPv6' + ? url.replace('://127.0.0.1:', '://[::1]:') + : url; + }; + return Test; +}); + beforeAll(() => { process.env.NODE_ENV = 'test'; process.env.JWT_SECRET = 'test-secret'; @@ -8,3 +23,11 @@ beforeAll(() => { process.env.STORAGE_PATH = '/storage'; } }); + +// Dispose resources loaded by this suite using the application's draining +// shutdown. Individual fixtures still own temporary files and other DB pools. +afterAll(async () => { + await require('./src/services/serviceShutdown').stopServices(); + const loadedDb = require.cache[require.resolve('./src/database/db')]; + if (typeof loadedDb?.exports.db?.destroy === 'function') await loadedDb.exports.db.destroy(); +}); diff --git a/backend/migrations/core/210_events_updated_at.js b/backend/migrations/core/210_events_updated_at.js new file mode 100644 index 00000000..3a9099b0 --- /dev/null +++ b/backend/migrations/core/210_events_updated_at.js @@ -0,0 +1,16 @@ +/** Fresh installs and upgraded databases expose the same event timestamp. */ +exports.up = async function (knex) { + if (!await knex.schema.hasTable('events')) return; + if (!await knex.schema.hasColumn('events', 'updated_at')) { + await knex.schema.alterTable('events', table => { + table.timestamp('updated_at'); + }); + } + // Do not replace existing modification times on a repeated migration. + await knex('events').whereNull('updated_at').update({ updated_at: knex.ref('created_at') }); +}; +exports.down = async function (knex) { + if (await knex.schema.hasTable('events') && await knex.schema.hasColumn('events', 'updated_at')) { + await knex.schema.alterTable('events', table => table.dropColumn('updated_at')); + } +}; diff --git a/backend/server.js b/backend/server.js index d2e9d321..dbdcbaed 100644 --- a/backend/server.js +++ b/backend/server.js @@ -218,7 +218,7 @@ app.use((req, res, next) => { }); // CORS configuration (apply only to API routes) -const { isAllowedOrigin, multipartOriginAllowed } = require('./src/utils/requestOrigin'); +const { isAllowedOrigin } = require('./src/utils/requestOrigin'); const corsOptions = { origin: function (origin, callback) { @@ -528,43 +528,10 @@ app.use(['/api/admin', '/api/v1'], express.json({ limit: '50mb' })); app.use(express.json({ limit: '2mb' })); app.use(express.urlencoded({ extended: true, limit: '2mb' })); -// 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.' }); - } - // multipart is exactly what a cross-site
can send without a - // preflight, and in a split-origin deployment (SameSite=None) the admin - // cookie rides along to the upload routes. Browsers label such a - // submission Sec-Fetch-Site: cross-site (and always send Origin on a - // cross-origin POST); non-browser clients send neither header and pass. - if (contentType.includes('multipart/form-data') && !multipartOriginAllowed(req)) { - return res.status(403).json({ error: 'Cross-site multipart request rejected' }); - } - } - next(); -}); +// Validate the origin independently of body length/content type. +app.use('/api', require('./src/middleware/csrf')); -// 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); +app.use('/api', require('./src/middleware/apiRequestLogger')); // Maintenance mode middleware - add after body parsing but before routes app.use(maintenanceMiddleware); @@ -1098,6 +1065,30 @@ if (spaCatchAll) { // Global error handler (must be last) app.use(errorHandler); +// App construction is side-effect free with respect to listening and workers. +let httpServer; +let shutdownPromise; +// Docker stops a container 10 s after SIGTERM by default (compose sets no +// stop_grace_period), so the drain must finish inside that window. +const SHUTDOWN_TIMEOUT_MS = Number(process.env.SHUTDOWN_TIMEOUT_MS) || 8000; +async function stopServer() { + if (shutdownPromise) return shutdownPromise; + shutdownPromise = (async () => { + const close = httpServer ? new Promise((resolve, reject) => httpServer.close(err => err ? reject(err) : resolve())) : Promise.resolve(); + const timeout = setTimeout(() => httpServer?.closeAllConnections(), Math.floor(SHUTDOWN_TIMEOUT_MS / 2)); + timeout.unref(); + try { + await Promise.all([close, require('./src/services/serviceShutdown').stopServices()]); + } finally { + clearTimeout(timeout); + // Always release the pool: a rejected service stop must not leave + // ref'd sockets keeping the process alive until SIGKILL. + await db.destroy(); + } + })(); + return shutdownPromise; +} + // Initialize services async function startServer() { try { @@ -1122,14 +1113,8 @@ async function startServer() { 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'); - + require('./src/utils/cleanupTempUploads').startTempUploadCleanup(); + // Start file watcher startFileWatcher(); // External-media folder watcher (issue 1187): imports new files into @@ -1149,6 +1134,10 @@ async function startServer() { startTransferCleanup(); // Custom-resolution download archives (#858) are disposable renditions — // sweep them once their TTL passes so .download-cache doesn't grow forever. + // Best-effort, as before the scheduler refactor: a transient DB error on + // this one UPDATE must not abort the whole server start. + await require('./src/services/downloadJobService').recoverOrphanedJobs() + .catch((err) => logger.error('Download job recovery failed', { error: err.message })); startDownloadJobCleanup(); // Reveal-mode scheduler (#838): minutely stamp for scheduled reveals. startRevealScheduler(); @@ -1306,7 +1295,7 @@ async function startServer() { // lazy means they don't pay for a module graph they never use. require('./src/services/faceQueue').start(); - app.listen(PORT, () => { + httpServer = 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'}`); @@ -1325,10 +1314,32 @@ async function startServer() { }); } catch (error) { logger.error('Failed to start server:', error); - process.exit(1); + await stopServer(); + process.exitCode = 1; } } -startServer(); +if (require.main === module) { + let stopping = false; + for (const signal of ['SIGTERM', 'SIGINT']) { + process.on(signal, () => { + if (stopping) { + logger.warn(`Received ${signal} again during shutdown, exiting immediately`); + process.exit(1); + } + stopping = true; + // The drain itself has no deadline; a hung worker must not keep the + // process alive past the container's stop grace period. + setTimeout(() => { + logger.error(`Shutdown exceeded ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit`); + process.exit(1); + }, SHUTDOWN_TIMEOUT_MS).unref(); + stopServer().catch(error => { logger.error('Shutdown failed', { error: error.message }); process.exitCode = 1; }); + }); + } + startServer(); +} +app.startServer = startServer; +app.stopServer = stopServer; module.exports = app; // For testing diff --git a/backend/src/__tests__/verifyGalleryAccess.customerRevoke.test.js b/backend/src/__tests__/verifyGalleryAccess.customerRevoke.test.js index 55302bbb..90f63fec 100644 --- a/backend/src/__tests__/verifyGalleryAccess.customerRevoke.test.js +++ b/backend/src/__tests__/verifyGalleryAccess.customerRevoke.test.js @@ -26,6 +26,8 @@ jest.mock('../database/db', () => { return { db: mockDb, withRetry }; }); +jest.mock('../utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn().mockResolvedValue(false) })); +jest.mock('../utils/sessionCutoff', () => ({ isTokenBeforeCutoff: jest.fn().mockResolvedValue(false) })); jest.mock('../utils/logger', () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), })); @@ -83,8 +85,12 @@ function mockEventAndAssignment({ event, assignment }) { assignChain.where = jest.fn().mockReturnValue(assignChain); assignChain.first = jest.fn().mockResolvedValue(assignment); - db.mockImplementationOnce(() => eventsChain) - .mockImplementationOnce(() => assignChain); + db.mockImplementation((table) => { + if (table === 'events') return eventsChain; + if (table === 'customer_accounts') return { ...eventsChain, first: jest.fn().mockResolvedValue({ id: 7 }) }; + if (table === 'event_customer_assignments') return assignChain; + throw new Error('Unexpected table: ' + table); + }); return { eventsChain, assignChain }; } @@ -101,7 +107,7 @@ describe('verifyGalleryAccess — customer-minted JWT with active assignment', ( it('allows access when the event_customer_assignments row exists', async () => { getGalleryTokenFromRequest.mockReturnValue('tkn'); jwt.verify.mockReturnValue({ - type: 'gallery', + type: 'gallery', iat: Math.floor(Date.now() / 1000), eventId: 42, via: 'customer', customerId: 7, @@ -132,7 +138,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => { it('returns 403 CUSTOMER_ASSIGNMENT_REVOKED when the junction row is gone', async () => { getGalleryTokenFromRequest.mockReturnValue('tkn'); jwt.verify.mockReturnValue({ - type: 'gallery', + type: 'gallery', iat: Math.floor(Date.now() / 1000), eventId: 42, via: 'customer', customerId: 7, @@ -162,7 +168,7 @@ describe('verifyGalleryAccess — customer-minted JWT after revocation', () => { // and start 403'ing per-event-password sessions. getGalleryTokenFromRequest.mockReturnValue('tkn'); jwt.verify.mockReturnValue({ - type: 'gallery', + type: 'gallery', iat: Math.floor(Date.now() / 1000), eventId: 42, customerId: 7, // intentionally no `via` claim @@ -194,7 +200,7 @@ describe('verifyGalleryAccess — per-event-password JWT', () => { it('does NOT touch event_customer_assignments and passes through', async () => { getGalleryTokenFromRequest.mockReturnValue('tkn'); jwt.verify.mockReturnValue({ - type: 'gallery', + type: 'gallery', iat: Math.floor(Date.now() / 1000), eventId: 42, // No via, no customerId — this is the legacy per-event-password // flow where every guest mints their own JWT after entering the diff --git a/backend/src/middleware/apiRequestLogger.js b/backend/src/middleware/apiRequestLogger.js new file mode 100644 index 00000000..8c0291fe --- /dev/null +++ b/backend/src/middleware/apiRequestLogger.js @@ -0,0 +1,11 @@ +const logger = require('../utils/logger'); +const { requestLogPath } = require('../utils/requestLogPath'); +module.exports = function apiRequestLogger(req, res, next) { + const started = Date.now(); + const path = requestLogPath(req.originalUrl); + logger.info(`${req.method} ${path}`); + res.once('finish', () => { + logger.info(`${req.method} ${path} -> ${res.statusCode} (${Date.now() - started}ms)`); + }); + next(); +}; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 40af97b7..30cd5017 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -1,9 +1,5 @@ const jwt = require('jsonwebtoken'); -const { db } = require('../database/db'); -const { formatBoolean } = require('../utils/dbCompat'); -const { isMissingRolesSchema } = require('../utils/dbErrors'); -const { isTokenRevoked } = require('../utils/tokenRevocation'); -const { isTokenBeforeCutoff } = require('../utils/sessionCutoff'); +const sessionAccess = require('../services/sessionAccessService'); const logger = require('../utils/logger'); const { getAdminTokenFromRequest } = require('../utils/tokenUtils'); @@ -32,100 +28,10 @@ async function adminAuth(req, res, next) { return res.status(401).json({ error: 'Invalid token' }); } - // Check if token is revoked - if (await isTokenRevoked(decoded)) { - logger.warn('Revoked token used', { - userId: decoded.id, - tokenType: decoded.type - }); - return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' }); - } - - // Reject any session issued before the global cutoff (set by a .picpeak - // restore, which can reassign admin ids). Forces every pre-restore admin - // session to re-authenticate against the restored data. - if (await isTokenBeforeCutoff(decoded)) { - return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' }); - } - - // Verify token type - if (decoded.type !== 'admin') { - logger.warn('Non-admin token used for admin endpoint', { - userId: decoded.id, - tokenType: decoded.type - }); - return res.status(403).json({ error: 'Insufficient permissions' }); - } - - // IP validation (optional - can be strict or just log) - const currentIp = req.ip || req.connection.remoteAddress; - if (decoded.ip && decoded.ip !== currentIp) { - logger.warn('Token used from different IP', { - userId: decoded.id, - tokenIp: decoded.ip, - currentIp: currentIp - }); - } - - // Check if admin still exists and is active, including role info - // Use try/catch to handle case where roles table doesn't exist yet (upgrade scenario) - let admin; - try { - admin = await db('admin_users') - .leftJoin('roles', 'roles.id', 'admin_users.role_id') - .where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) }) - .select( - 'admin_users.id', - 'admin_users.username', - 'admin_users.email', - 'admin_users.password_changed_at', - 'roles.id as role_id', - 'roles.name as role_name' - ) - .first(); - } catch (joinError) { - // Fail CLOSED on anything that isn't a genuinely missing roles schema: - // the fallback below fabricates super_admin, so a transient query failure - // (connection reset, deadlock, statement timeout, pool exhaustion) must - // not become a free privilege upgrade for every scoped admin. Rethrow → - // outer catch → 401, which is already how every other transient DB fault - // in this try block behaves (isTokenRevoked / isTokenBeforeCutoff both - // hit the DB here). apiTokenAuth takes the same posture on the v1 - // surface, differing only in its 500. - if (!isMissingRolesSchema(joinError)) throw joinError; - // Fallback: roles table may not exist yet during upgrade - // Query without role join - user will have no role info but can still authenticate - logger.debug('Roles table not available, falling back to basic auth', { error: joinError.message }); - admin = await db('admin_users') - .where({ id: decoded.id, is_active: formatBoolean(true) }) - .select('id', 'username', 'email', 'password_changed_at') - .first(); - if (admin) { - admin.role_id = null; - admin.role_name = 'super_admin'; // Assume super_admin for existing users during upgrade - } - } - - if (!admin) { - return res.status(401).json({ error: 'Invalid token' }); - } - - // Check if password was changed after token was issued. JWT `iat` has - // 1-second resolution; `password_changed_at` is sub-second. Floor the - // comparison so a token issued in the *same* second as the password - // change isn't incorrectly rejected — that race used to bite anyone - // logging in immediately after a password reset/change. - if (admin.password_changed_at) { - const passwordChangedSeconds = Math.floor( - new Date(admin.password_changed_at).getTime() / 1000 - ); - if (decoded.iat < passwordChangedSeconds) { - logger.warn('Token used after password change', { userId: decoded.id }); - return res.status(401).json({ - error: 'Token invalid due to password change', - code: 'PASSWORD_CHANGED' - }); - } + const admin = await sessionAccess.admin(decoded); + const requestIp = req.ip || req.connection?.remoteAddress; + if (decoded.ip && requestIp && decoded.ip !== requestIp) { + logger.info('admin session IP changed', { accountId: admin.id, tokenIp: decoded.ip, requestIp }); } // Add user info to request (enhanced with role) @@ -146,7 +52,10 @@ async function adminAuth(req, res, next) { next(); } catch (error) { logger.error('Auth middleware error:', error); - res.status(401).json({ error: 'Authentication failed' }); + res.status(error.statusCode || 401).json({ + error: error.isOperational ? error.message : 'Authentication failed', + ...(error.isOperational && { code: error.code }), + }); } } diff --git a/backend/src/middleware/csrf.js b/backend/src/middleware/csrf.js new file mode 100644 index 00000000..152a6bf7 --- /dev/null +++ b/backend/src/middleware/csrf.js @@ -0,0 +1,15 @@ +const { mutationOriginAllowed } = require('../utils/requestOrigin'); + +module.exports = function csrfProtection(req, res, next) { + if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) return next(); + if (!mutationOriginAllowed(req)) { + return res.status(403).json({ error: 'Cross-site request rejected' }); + } + const contentType = (req.headers['content-type'] || '').split(';')[0].trim().toLowerCase(); + const hasBody = Number(req.headers['content-length']) > 0 || !!req.headers['transfer-encoding']; + const jsonLike = contentType === 'application/json' || contentType.endsWith('+json'); + if (hasBody && !jsonLike && contentType !== 'multipart/form-data') { + return res.status(415).json({ error: 'Unsupported Content-Type. Use application/json or multipart/form-data.' }); + } + next(); +}; diff --git a/backend/src/middleware/customerAuth.js b/backend/src/middleware/customerAuth.js index 1ce58c2e..ba7470b5 100644 --- a/backend/src/middleware/customerAuth.js +++ b/backend/src/middleware/customerAuth.js @@ -1,3 +1,4 @@ +const { requestLogPath } = require('../utils/requestLogPath'); /** * Customer Authentication Middleware * @@ -10,10 +11,7 @@ */ const jwt = require('jsonwebtoken'); -const { db } = require('../database/db'); -const { formatBoolean } = require('../utils/dbCompat'); -const { isTokenRevoked } = require('../utils/tokenRevocation'); -const { isTokenBeforeCutoff } = require('../utils/sessionCutoff'); +const sessionAccess = require('../services/sessionAccessService'); const logger = require('../utils/logger'); const { getCustomerTokenFromRequest } = require('../utils/tokenUtils'); @@ -25,7 +23,7 @@ async function customerAuth(req, res, next) { // normal (page polling, pre-login session probes). Bump to debug // for noisy investigations only. logger.debug('[customerAuth] no token on request', { - url: req.originalUrl, + url: requestLogPath(req.originalUrl), hasCookieHeader: !!req.headers?.cookie, cookieKeys: Object.keys(req.cookies || {}), }); @@ -42,7 +40,7 @@ async function customerAuth(req, res, next) { decoded = verified.payload; } catch (err) { logger.warn('[customerAuth] jwt verification failed', { - url: req.originalUrl, + url: requestLogPath(req.originalUrl), errorName: err.name, errorMessage: err.message, }); @@ -52,71 +50,10 @@ async function customerAuth(req, res, next) { return res.status(401).json({ error: 'Invalid token', code: 'JWT_INVALID' }); } - if (await isTokenRevoked(decoded)) { - logger.warn('[customerAuth] token revoked', { - url: req.originalUrl, - customerId: decoded.customerId, - tokenType: decoded.type, - iat: decoded.iat, - }); - return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' }); - } - - // Reject sessions issued before the global restore cutoff. - if (await isTokenBeforeCutoff(decoded)) { - return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' }); - } - - if (decoded.type !== 'customer') { - logger.warn('[customerAuth] wrong token type', { - url: req.originalUrl, - tokenType: decoded.type, - }); - return res.status(403).json({ error: 'Insufficient permissions', code: 'WRONG_TOKEN_TYPE' }); - } - - // IP drift gets logged but doesn't reject — same lenient policy as - // adminAuth. Customers may roam between mobile networks frequently. - const currentIp = req.ip || req.connection.remoteAddress; - if (decoded.ip && decoded.ip !== currentIp) { - logger.info('Customer token used from different IP', { - customerId: decoded.customerId, - tokenIp: decoded.ip, - currentIp, - }); - } - - const customer = await db('customer_accounts') - .where({ id: decoded.customerId, is_active: formatBoolean(true) }) - .select('id', 'email', 'display_name', 'first_name', 'last_name', 'password_changed_at', 'preferred_language') - .first(); - - if (!customer) { - // Either deleted, deactivated, or the id was forged. 401 across the - // board so the frontend session-expiry handler kicks in. - logger.warn('[customerAuth] customer row not found / inactive', { - url: req.originalUrl, - customerId: decoded.customerId, - }); - return res.status(401).json({ error: 'Invalid token', code: 'CUSTOMER_NOT_FOUND' }); - } - - if (customer.password_changed_at) { - const passwordChangedSeconds = Math.floor( - new Date(customer.password_changed_at).getTime() / 1000 - ); - if (decoded.iat < passwordChangedSeconds) { - logger.warn('[customerAuth] token rejected: password_changed_at', { - url: req.originalUrl, - customerId: decoded.customerId, - iat: decoded.iat, - passwordChangedSeconds, - }); - return res.status(401).json({ - error: 'Token invalid due to password change', - code: 'PASSWORD_CHANGED', - }); - } + const customer = await sessionAccess.customer(decoded); + const requestIp = req.ip || req.connection?.remoteAddress; + if (decoded.ip && requestIp && decoded.ip !== requestIp) { + logger.info('customer session IP changed', { accountId: customer.id, tokenIp: decoded.ip, requestIp }); } req.customer = { @@ -131,7 +68,10 @@ async function customerAuth(req, res, next) { next(); } catch (error) { logger.error('Customer auth middleware error:', error); - res.status(401).json({ error: 'Authentication failed' }); + res.status(error.statusCode || 401).json({ + error: error.isOperational ? error.message : 'Authentication failed', + ...(error.isOperational && { code: error.code }), + }); } } diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js index 782a2370..43dc6782 100644 --- a/backend/src/middleware/errorHandler.js +++ b/backend/src/middleware/errorHandler.js @@ -1,3 +1,4 @@ +const { requestLogPath } = require('../utils/requestLogPath'); /** * Global error handler middleware. * Catches all errors and returns standardized responses. @@ -119,7 +120,7 @@ const errorHandler = (err, req, res, next) => { // Log the error const logContext = { - url: req.originalUrl, + url: requestLogPath(req.originalUrl), method: req.method, ip: req.ip, statusCode, @@ -161,7 +162,7 @@ const errorHandler = (err, req, res, next) => { */ const notFoundHandler = (req, res, next) => { const { NotFoundError } = require('../utils/errors'); - next(new NotFoundError('Route', req.originalUrl)); + next(new NotFoundError('Route', requestLogPath(req.originalUrl))); }; /** diff --git a/backend/src/middleware/feedbackRateLimit.js b/backend/src/middleware/feedbackRateLimit.js index 192e472b..260b77bc 100644 --- a/backend/src/middleware/feedbackRateLimit.js +++ b/backend/src/middleware/feedbackRateLimit.js @@ -1,3 +1,4 @@ +const cleanupTimers = new Set(); const crypto = require('crypto'); const { db } = require('../database/db'); const logger = require('../utils/logger'); @@ -211,7 +212,7 @@ function strictRateLimit(options = {}) { const store = new Map(); // Clean up old entries periodically - setInterval(() => { + const cleanupTimer = setInterval(() => { const now = Date.now(); for (const [key, data] of store.entries()) { if (data.resetTime < now) { @@ -219,6 +220,8 @@ function strictRateLimit(options = {}) { } } }, windowMs); + cleanupTimer.unref(); + cleanupTimers.add(cleanupTimer); return (req, res, next) => { const ip = req.ip || req.connection.remoteAddress; @@ -255,6 +258,7 @@ function strictRateLimit(options = {}) { } module.exports = { + dispose() { cleanupTimers.forEach(clearInterval); cleanupTimers.clear(); }, feedbackRateLimit, strictRateLimit, generateGuestIdentifier, diff --git a/backend/src/middleware/gallery.js b/backend/src/middleware/gallery.js index 32e40f56..6f1dbb7d 100644 --- a/backend/src/middleware/gallery.js +++ b/backend/src/middleware/gallery.js @@ -1,267 +1,104 @@ const jwt = require('jsonwebtoken'); const { db, withRetry } = require('../database/db'); -const { formatBoolean } = require('../utils/dbCompat'); const { getGalleryTokenFromRequest } = require('../utils/tokenUtils'); const logger = require('../utils/logger'); -const { isTokenRevoked } = require('../utils/tokenRevocation'); -const { isTokenBeforeCutoff } = require('../utils/sessionCutoff'); +const access = require('../services/galleryAccessService'); -/** - * True when a logged-in admin is explicitly previewing this gallery (#868). - * - * Two conditions, both required: - * 1. The explicit intent flag `?admin_preview=1` is present. The plain share - * link stays byte-identical to a guest's, so the password gate is still - * testable as a guest while logged in as admin — and the bypass is visible - * in the URL without being reusable (it carries no secret). - * 2. A VERIFIED admin session — the httpOnly `admin_token` cookie (rides along - * on same-origin API calls) or an Authorization: Bearer header, never the - * URL. Must decode as `type: 'admin'`, issuer `picpeak-auth`. - * - * The cookie is tried FIRST and the Bearer is accepted only when it is itself an - * admin token (#981 review): the frontend attaches a gallery Bearer to gallery - * endpoints, and a header-first, type-blind read would let a coexisting gallery - * session shadow the admin cookie and wrongly disable the preview. - * - * Fails closed on any verification error. Replaces the old `?preview=` - * scheme, which leaked a 24h admin token into the address bar. - */ +// Cookie first: a coexisting gallery Bearer must not shadow an admin preview. function decodeAdminPreview(req) { if (req.query?.admin_preview !== '1') return null; - // Cookie first, then a Bearer — but only an admin-typed token satisfies it. - const candidates = []; - if (req.cookies?.admin_token) candidates.push(req.cookies.admin_token); + const candidates = [req.cookies?.admin_token]; const header = req.headers?.authorization; - if (header && header.startsWith('Bearer ')) candidates.push(header.slice(7)); - for (const token of candidates) { + if (header?.startsWith('Bearer ')) candidates.push(header.slice(7)); + for (const token of candidates.filter(Boolean)) { try { - const decoded = jwt.verify(token, process.env.JWT_SECRET, { issuer: 'picpeak-auth' }); + const decoded = jwt.verify(token, process.env.JWT_SECRET, { + issuer: 'picpeak-auth', algorithms: ['HS256'], + }); if (decoded.type === 'admin') return decoded; } catch { /* try the next candidate */ } } return null; } +// Signature-only predicate retained for UI-intent callers. It never authorizes. function isAdminPreview(req) { return decodeAdminPreview(req) !== null; } -/** - * The full session check behind the preview bypass. A verified signature is - * not a live session: adminAuth also rejects revoked tokens, tokens issued - * before the restore cutoff, deactivated admins and tokens minted before the - * admin's last password change. Without those a logged-out or deactivated - * admin token kept unlocking every draft and password gallery until `exp` - * (30 days with remember-me). Sets req.isAdminPreview on success so the - * downstream reveal-mode and logging checks read one verified flag. - */ -async function verifyAdminPreview(req) { - if (req.isAdminPreview === true) return true; +function attachAccess(req, event, grant) { + req.event = event; + req.galleryAccess = grant; + req.isAdminPreview = grant.kind === 'admin'; + req.accessLevel = grant.session?.accessLevel || 'guest'; + req.viaCustomer = grant.session?.via === 'customer'; + req.sessionID = req.isAdminPreview ? `gallery_admin_preview_${event.id}` + : `gallery_${grant.kind === 'public' ? 'public_' : ''}${event.id}_${Date.now()}`; + const ip = req.ip || req.connection?.remoteAddress || 'unknown'; + const userAgent = req.get?.('User-Agent') || 'unknown'; + req.clientInfo = { + ip, userAgent, fingerprint: `${ip}-${userAgent}`.substring(0, 32), timestamp: Date.now(), + }; +} + +async function verifyAdminPreview(req, event) { + if (req.isAdminPreview && req.galleryAccess && (!event || event.id === req.event?.id)) return true; const decoded = decodeAdminPreview(req); if (!decoded) return false; try { - if (await isTokenRevoked(decoded) || await isTokenBeforeCutoff(decoded)) return false; - const admin = await withRetry(async () => db('admin_users') - .where({ id: decoded.id, is_active: formatBoolean(true) }) - .select('id', 'password_changed_at') - .first()); - if (!admin) return false; - if (admin.password_changed_at) { - const changedSeconds = Math.floor(new Date(admin.password_changed_at).getTime() / 1000); - if (decoded.iat < changedSeconds) return false; - } - } catch (err) { - logger.warn('Admin preview session check failed', { error: err.message }); + const slug = req.params?.slug || req.requestedSlug; + if (!event && !slug) return false; + event = event || await db('events').where({ slug }).select('*').first(); + if (!event) return false; + const grant = access.grant(event, 'admin', decoded); + await access.authorize(event, grant); + attachAccess(req, event, grant); + return true; + } catch (error) { + logger.debug('Admin gallery preview denied', { code: error.code }); + req.adminPreviewDenied = error; return false; } - req.isAdminPreview = true; - return true; } -// Middleware to verify gallery access +function decodeGalleryToken(token) { + try { + return jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'], issuer: 'picpeak-auth' }); + } catch (error) { + // Legacy gallery tokens lacked an issuer, but still need the same type, + // lifecycle and session checks as current tokens. + if (error.name === 'JsonWebTokenError' && error.message.includes('jwt issuer invalid')) { + return jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); + } + throw error; + } +} + async function verifyGalleryAccess(req, res, next) { try { - const requestedSlug = req.params.slug || req.requestedSlug; - - // Admin preview (#868) is resolved BEFORE any gallery credential (#981 - // review): a coexisting gallery token/Bearer must not shadow it, and the - // admin session must never fall into the `type !== 'gallery'` reject path - // below. Per-request bypass — draft + password relaxed, NO gallery JWT - // minted (a lingering guest cookie would muddy the coexisting-cookies case). - // req.isAdminPreview flags downstream logging to keep it out of guest stats. - if (await verifyAdminPreview(req)) { - if (!requestedSlug) { - return res.status(401).json({ error: 'No token provided' }); - } - const previewEvent = await withRetry(async () => db('events') - .where({ slug: requestedSlug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) - .select('*').first()); - if (!previewEvent) { - return res.status(404).json({ error: 'Gallery not found or expired' }); - } - req.event = previewEvent; - req.isAdminPreview = true; - req.sessionID = `gallery_admin_preview_${previewEvent.id}`; - req.clientInfo = { - ip: req.ip || req.connection.remoteAddress || 'unknown', - userAgent: req.get('User-Agent') || 'unknown', - fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32), - timestamp: Date.now() - }; - return next(); - } - - const token = getGalleryTokenFromRequest(req, requestedSlug); - let event; - - if (!token) { - if (!requestedSlug) { - return res.status(401).json({ error: 'No token provided' }); - } - - event = await withRetry(async () => db('events') - .where({ - slug: requestedSlug, - is_active: formatBoolean(true), - is_archived: formatBoolean(false), - is_draft: formatBoolean(false) - }) - .select('*').first()); - - if (!event) { - return res.status(404).json({ error: 'Gallery not found or expired' }); - } - - const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); - if (!requiresPassword) { - req.event = event; - req.sessionID = `gallery_public_${event.id}_${Date.now()}`; - req.clientInfo = { - ip: req.ip || req.connection.remoteAddress || 'unknown', - userAgent: req.get('User-Agent') || 'unknown', - fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32), - timestamp: Date.now() - }; - return next(); - } - - return res.status(401).json({ error: 'No token provided' }); - } - - // Try to verify with issuer first, fallback to no issuer for backward compatibility - let decoded; - try { - decoded = jwt.verify(token, process.env.JWT_SECRET, { - algorithms: ['HS256'], - issuer: 'picpeak-auth' - }); - } catch (error) { - // If verification fails with issuer, try without issuer (backward compatibility) - if (error.name === 'JsonWebTokenError' && error.message.includes('jwt issuer invalid')) { - decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); - } else { - throw error; - } - } - logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug }); - - // Only gallery-scoped tokens grant gallery access. Every legitimate - // path (password login, share link, client access, customer-minted, - // slideshow) mints type:'gallery'. Reject anything else — e.g. a guest - // identity token (type:'guest', for feedback attribution) that carries a - // matching eventId — instead of relying on other token types incidentally - // lacking an eventId to fail the id match below. - if (decoded.type !== 'gallery') { + if (await verifyAdminPreview(req)) return next(); + // The caller asked for a preview explicitly: report why it was refused + // instead of falling through to a misleading guest-token error. + if (req.adminPreviewDenied?.isOperational) throw req.adminPreviewDenied; + const slug = req.params.slug || req.requestedSlug; + const token = getGalleryTokenFromRequest(req, slug); + const decoded = token ? decodeGalleryToken(token) : null; + if (decoded && decoded.type !== 'gallery') { return res.status(403).json({ error: 'Invalid token type for gallery access' }); } - - // If we have a slug in the URL params or from pre-middleware, verify it matches. - // (Admin preview never reaches here — it returns above — so drafts stay - // filtered for every real gallery-token request.) - if (requestedSlug) { - // Verify by slug and ensure it matches the token's event - event = await withRetry(async () => db('events') - .where({ - slug: requestedSlug, - is_active: formatBoolean(true), - is_archived: formatBoolean(false), - is_draft: formatBoolean(false) - }) - .select('*').first()); - - // Verify the token's eventId matches - if (event && event.id !== decoded.eventId) { - return res.status(403).json({ error: 'Token does not match requested gallery' }); - } - } else { - // Fallback to using eventId from token - event = await withRetry(async () => db('events') - .where({ - id: decoded.eventId, - is_active: formatBoolean(true), - is_archived: formatBoolean(false), - is_draft: formatBoolean(false) - }) - .select('*').first()); - } - - if (!event) { - logger.warn('[verifyGalleryAccess] Event not found for slug', { slug: requestedSlug || 'no-slug', tokenEventId: decoded.eventId }); - return res.status(404).json({ error: 'Gallery not found or expired' }); - } - - // Customer-minted gallery JWTs (#354): when the customer obtained - // this token via /api/customer/events/:slug/access-token, the - // payload carries `via:'customer'` and `customerId`. The admin - // can revoke the customer's access at any time by removing the - // event_customer_assignments row from the "Manage galleries" - // dialog on the customer detail page. Re-check that row here so - // the revocation takes effect on the customer's very next - // request — no token-blacklisting machinery required. - if (decoded.via === 'customer' && decoded.customerId) { - const assignment = await withRetry(async () => { - return await db('event_customer_assignments') - .where({ - event_id: event.id, - customer_account_id: decoded.customerId, - }) - .first(); - }); - if (!assignment) { - logger.info('[verifyGalleryAccess] Customer assignment revoked, rejecting token', { - customerId: decoded.customerId, - eventId: event.id, - }); - return res.status(403).json({ - error: 'Access to this gallery has been revoked', - code: 'CUSTOMER_ASSIGNMENT_REVOKED', - }); - } - } - - logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug }); - req.event = event; - req.accessLevel = decoded.accessLevel || 'guest'; - // Customer-portal provenance (#746/#849): portal-minted tokens carry - // via:'customer' but NO accessLevel (they default to guest), while - // PIN-client logins carry accessLevel:'client' without `via`. Activity - // attribution/dedup needs the distinction, so surface it explicitly. - req.viaCustomer = decoded.via === 'customer'; - req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`; - - // Create client info for logging (similar to secureImageMiddleware but simpler) - req.clientInfo = { - ip: req.ip || req.connection.remoteAddress || 'unknown', - userAgent: req.get('User-Agent') || 'unknown', - fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32), // Limit to 32 chars for DB column - timestamp: Date.now() - }; - - logger.debug('[verifyGalleryAccess] Access granted', { eventId: event.id, slug: event.slug }); - next(); + if (!slug && !decoded?.eventId) return res.status(401).json({ error: 'No token provided' }); + const event = await withRetry(() => db('events').where(slug ? { slug } : { id: decoded.eventId }).select('*').first()); + if (!event) return res.status(404).json({ error: 'Gallery not found or expired' }); + const grant = access.grant(event, decoded ? 'gallery' : 'public', decoded); + await access.authorize(event, grant); + attachAccess(req, event, grant); + return next(); } catch (error) { - logger.error('Error verifying gallery access', { error: error.message, stack: error.stack }); - res.status(401).json({ error: 'Invalid token' }); + if (!error.isOperational) logger.error('Error verifying gallery access', { error: error.message }); + return res.status(error.statusCode || 401).json({ + error: error.isOperational ? error.message : 'Invalid token', + ...(error.code && error.isOperational && { code: error.code }), + }); } } diff --git a/backend/src/middleware/ownership.js b/backend/src/middleware/ownership.js index 9b6b2018..38f69772 100644 --- a/backend/src/middleware/ownership.js +++ b/backend/src/middleware/ownership.js @@ -1,6 +1,11 @@ const { db } = require('../database/db'); const logger = require('../utils/logger'); +function canAccessEvent(admin, event) { + return Boolean(admin && event && (admin.roleName === 'super_admin' + || event.created_by == null || Number(event.created_by) === Number(admin.id))); +} + /** * Middleware to enforce event ownership for non-super_admin users. * Super admins bypass the check. Other admins can only access events they created. @@ -23,7 +28,7 @@ function requireEventOwnership(req, res, next) { return res.status(404).json({ error: 'Event not found' }); } // Allow access if: event has no owner (legacy/system), or admin owns it - if (event.created_by && event.created_by !== req.admin.id) { + if (!canAccessEvent(req.admin, event)) { return res.status(403).json({ error: 'Access denied' }); } next(); @@ -165,6 +170,7 @@ function requireProjectOwnership(req, res, next) { } module.exports = { + canAccessEvent, requireEventOwnership, filterOwnedEventIds, scopeEventsQuery, diff --git a/backend/src/middleware/permissions.js b/backend/src/middleware/permissions.js index b9a227d3..aa7bb64d 100644 --- a/backend/src/middleware/permissions.js +++ b/backend/src/middleware/permissions.js @@ -1,3 +1,4 @@ +const { requestLogPath } = require('../utils/requestLogPath'); /** * Permission Checking Middleware for RBAC * Provides role-based access control with caching for performance @@ -143,7 +144,7 @@ function requirePermission(permissions, options = { requireAll: false }) { userId: req.admin.id, username: req.admin.username, requiredPermissions: permArray, - path: req.path, + path: requestLogPath(req.originalUrl || req.path), method: req.method }); throw new ForbiddenError('Insufficient permissions'); @@ -180,7 +181,7 @@ function requireSuperAdmin() { logger.warn('Super admin access denied', { userId: req.admin.id, username: req.admin.username, - path: req.path, + path: requestLogPath(req.originalUrl || req.path), method: req.method }); throw new ForbiddenError('Super Admin access required'); diff --git a/backend/src/middleware/secureImageMiddleware.js b/backend/src/middleware/secureImageMiddleware.js index 5d963a53..0548d5d2 100644 --- a/backend/src/middleware/secureImageMiddleware.js +++ b/backend/src/middleware/secureImageMiddleware.js @@ -1,3 +1,4 @@ +const { requestLogPath } = require('../utils/requestLogPath'); const { db } = require('../database/db'); const secureImageService = require('../services/secureImageService'); const logger = require('../utils/logger'); @@ -10,12 +11,24 @@ class SecureImageMiddleware { this.suspiciousIPs = new Set(); this.blockedFingerprints = new Set(); this.rateLimitViolations = new Map(); + this.cleanupTimer = null; + } + + start() { + if (this.cleanupTimer) return; + this.cleanupTimer = setInterval(() => this.cleanup(), 300000); + this.cleanupTimer.unref(); + } + dispose() { + clearInterval(this.cleanupTimer); this.cleanupTimer = null; + this.suspiciousIPs.clear(); this.blockedFingerprints.clear(); this.rateLimitViolations.clear(); } /** * Main security middleware for image access */ secureImageAccess = async (req, res, next) => { + this.start(); try { const startTime = Date.now(); const clientIP = this.getClientIP(req); @@ -56,7 +69,7 @@ class SecureImageMiddleware { error: error.message, stack: error.stack, ip: req.ip, - path: req.path + path: requestLogPath(req.originalUrl || req.path) }); res.status(500).json({ @@ -328,7 +341,7 @@ class SecureImageMiddleware { client_ip: req.clientInfo?.ip || req.ip, client_fingerprint: req.clientInfo?.fingerprint, user_agent: req.get('User-Agent')?.substring(0, 255), - request_path: req.path, + request_path: requestLogPath(req.originalUrl || req.path), request_method: req.method, details: JSON.stringify(details), timestamp: new Date().toISOString() @@ -409,9 +422,4 @@ class SecureImageMiddleware { // Create singleton instance const secureImageMiddleware = new SecureImageMiddleware(); -// Setup cleanup interval -setInterval(() => { - secureImageMiddleware.cleanup(); -}, 300000); // Every 5 minutes - module.exports = secureImageMiddleware; \ No newline at end of file diff --git a/backend/src/middleware/sessionTimeout.js b/backend/src/middleware/sessionTimeout.js index aecd6750..09e81f87 100644 --- a/backend/src/middleware/sessionTimeout.js +++ b/backend/src/middleware/sessionTimeout.js @@ -26,7 +26,7 @@ const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes - reduced DB queries // behaviour is unchanged: the timer fires every 5 min as long as // the server has anything else keeping the loop alive (HTTP server, // other intervals), which is always. -setInterval(() => { +const cleanupTimer = setInterval(() => { const now = Date.now(); for (const [token, lastActivity] of sessions.entries()) { if (now - lastActivity > DEFAULT_SESSION_TIMEOUT) { @@ -208,6 +208,7 @@ function getActiveSessions() { } module.exports = { + dispose: () => { clearInterval(cleanupTimer); sessions.clear(); cachedTimeout = null; cacheExpiry = 0; }, sessionTimeoutMiddleware, isSessionExpired, endSession, diff --git a/backend/src/routes/adminEvents/crud.js b/backend/src/routes/adminEvents/crud.js index 0c494422..5148c457 100644 --- a/backend/src/routes/adminEvents/crud.js +++ b/backend/src/routes/adminEvents/crud.js @@ -5,7 +5,7 @@ const { body, validationResult } = require('express-validator'); const { db, logActivity } = require('../../database/db'); const { formatBoolean } = require('../../utils/dbCompat'); -const { slugify } = require('../../utils/slug'); + const { adminAuth } = require('../../middleware/auth'); const { requirePermission, userHasAllPermissions } = require('../../middleware/permissions'); const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../../utils/emailNormalization'); @@ -25,14 +25,13 @@ const eventTypeService = require('../../services/eventTypeService'); const { normaliseEventTimeTriple } = require('../../services/eventService'); const { hasColumnCached } = require('../../utils/schemaCache'); const { requireEventOwnership } = require('../../middleware/ownership'); -const { getAppSetting } = require('../../utils/appSettings'); + const { galleryPasswordColumns, dropCopiesIfStorageOff } = require('../../utils/galleryPasswordVault'); -const { clampIntOrUndefined } = require('../../utils/numericHelpers'); + const { getFrontendBaseUrl, getAbsoluteFrontendUrl } = require('../../utils/frontendUrl'); const downloadZipService = require('../../services/downloadZipService'); -const { resolveEventFeedbackDefaults, applyFeedbackDefaults, KEYBIND_MODES } = require('../../services/feedbackDefaults'); -const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, - getImageSecurityDefaults, resolveImageSecurityColumns, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers'); +const { KEYBIND_MODES } = require('../../services/feedbackDefaults'); +const { validateHeroImageAnchor, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade } = require('./helpers'); /** * `events.slug` is UNIQUE, and both routes that mint one do a read-then-insert @@ -174,7 +173,6 @@ async function queueGalleryCreatedEmail(event, { password, requirePassword } = { module.exports = (router) => { - // Create new event router.post('/', adminAuth, requirePermission('events.create'), [ body('event_type').notEmpty().trim().custom(async (value) => { @@ -240,7 +238,6 @@ module.exports = (router) => { body('watermark_text').optional().trim(), // #328 follow-up: per-event opt-in for presigned-URL "Download All". // Bypasses watermarks; admin must enable knowingly. - body('allow_presigned_download').optional().isBoolean(), // Feedback sub-toggles (#1044). Optional: omitting them inherits the // global Settings > Events defaults. body('allow_ratings').optional().isBoolean(), @@ -298,571 +295,13 @@ module.exports = (router) => { return res.status(400).json({ errors: safeValidationErrors(errors) }); } - // Get field requirements from settings - const fieldRequirements = await getEventFieldRequirements(); - - const { - event_type, - event_name, - event_date, - // Migration 137 — calendar time fields. is_full_day defaults to - // true at the service layer when undefined (legacy form payloads). - event_time_start, - event_time_end, - is_full_day, - admin_email, - password, - welcome_message = '', - color_theme = null, - expiration_days = 30, - allow_user_uploads = false, - upload_category_id = null, - allow_downloads = true, - disable_right_click = false, - enable_devtools_protection: enableDevtoolsProtectionInput, - watermark_downloads = false, - watermark_text = null, - allow_presigned_download = false, - require_password: requirePasswordInput, - // Feedback settings. The allow_* sub-toggles deliberately have NO - // destructuring defaults: `undefined` means "the caller didn't say", - // which inherits the global Settings > Events default (#1044). The - // admin create form posts explicit values (it seeds its own panel - // from the same globals), so inheritance here is what covers the v1 - // API and any other caller that omits them. - feedback_enabled: feedbackEnabledInput, - allow_ratings: allowRatingsInput, - allow_likes: allowLikesInput, - allow_comments: allowCommentsInput, - allow_favorites: allowFavoritesInput, - allow_reactions: allowReactionsInput, - allow_color_labels: allowColorLabelsInput, - keybind_mode: keybindModeInput, - require_name_email = false, - moderate_comments = true, - show_feedback_to_guests = true, - // The create form has always shown the identity-mode chooser and this - // route has never read it, so a gallery created as 'guest' quietly came - // out 'simple' and the photographer had to set it again on the event. - // Surfaced by adding a third mode (#1197); the fix is the same for all - // three. Unknown values fall back rather than reaching the column, - // which on Postgres is guarded by a CHECK constraint. - identity_mode: identityModeInput, - // CSS Template - css_template_id = null, - // Hero logo settings - hero_logo_visible = true, - // Header style settings - header_style = 'standard', - hero_divider_style = 'wave', - // Hero image anchor position (#162) - hero_image_anchor = 'center', - // Photo cap - photo_cap = null, - // Client access settings (#172) - client_access_enabled = false, - client_password = null, - // Draft mode - is_draft = true, - // Default photo sort - default_photo_sort = 'upload_date_desc', - // Banner overrides (#440 / #932) — see the insert below. - promo_mode = 'inherit', - promo_markdown = null, - info_mode = 'inherit', - info_markdown = null - } = req.body; - - const customerName = getCustomerNameFromPayload(req.body); - const customerEmail = getCustomerEmailFromPayload(req.body); - // Phone field is opt-in via the global setting (#322). If disabled, - // ignore whatever the client posted — defence in depth against form - // bypass. - const phoneEnabled = await isPhoneFieldEnabled(); - const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(req.body) : null; - - const customerColumnsAvailable = await hasCustomerContactColumns(); - - // Conditional validation based on settings - const validationErrors = []; - if (fieldRequirements.require_customer_name && !customerName) { - validationErrors.push({ path: 'customer_name', msg: 'Customer name is required' }); - } - if (fieldRequirements.require_customer_email && !customerEmail) { - validationErrors.push({ path: 'customer_email', msg: 'Customer email is required' }); - } - if (fieldRequirements.require_admin_email && !admin_email) { - validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' }); - } - if (fieldRequirements.require_event_date && !event_date) { - validationErrors.push({ path: 'event_date', msg: 'Event date is required' }); - } - - if (validationErrors.length > 0) { - return res.status(400).json({ errors: validationErrors }); - } - - // Default require_password from global "event_default_require_password" - // setting when the body omits it (#317 — admins want to flip the default). - let requirePasswordFallback = true; - if (requirePasswordInput === undefined) { - const setting = await readBooleanSetting('event_default_require_password'); - if (setting !== undefined) requirePasswordFallback = setting; - } - const requirePassword = parseBooleanInput(requirePasswordInput, requirePasswordFallback); - - // Default feedback_enabled from global "event_default_feedback_enabled" - // setting when the body omits it (#520 — same pattern as require_password - // above, lets admins make Guest Feedback ON the out-of-box default for - // new events instead of toggling it on every time). - let feedbackEnabledFallback = false; - if (feedbackEnabledInput === undefined) { - const setting = await readBooleanSetting('event_default_feedback_enabled'); - if (setting !== undefined) feedbackEnabledFallback = setting; - } - const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback); - - // Sub-toggle defaults from the global Settings > Events values (#1044). - // One batched read; an explicitly-sent body value still wins. - const feedbackDefaults = applyFeedbackDefaults({ - allow_ratings: allowRatingsInput, - allow_likes: allowLikesInput, - allow_comments: allowCommentsInput, - allow_favorites: allowFavoritesInput, - allow_reactions: allowReactionsInput, - allow_color_labels: allowColorLabelsInput, - keybind_mode: keybindModeInput, - }, await resolveEventFeedbackDefaults()); - - // Debug logging - logger.debug('Download control values', { - allow_downloads, - disable_right_click, - watermark_downloads, - watermark_text, - require_password: requirePassword, - types: { - allow_downloads: typeof allow_downloads, - disable_right_click: typeof disable_right_click, - watermark_downloads: typeof watermark_downloads - } - }); - - let passwordValidation = null; - - if (requirePassword) { - passwordValidation = await validatePasswordInContext(password, 'gallery', { - eventName: event_name - }); - - if (!passwordValidation.valid) { - return res.status(400).json({ - error: 'Password does not meet security requirements', - details: passwordValidation.errors, - score: passwordValidation.score, - feedback: passwordValidation.feedback - }); - } - } - - // Generate unique slug. Uses the shared util so accented names - // (Família, Decoração, etc.) get transliterated instead of dropped - // — see backend/src/utils/slug.js for the why (#525). - const processedEventName = slugify(event_name); - - // Use event_date in slug if provided, otherwise use random suffix - const slugSuffix = event_date || crypto.randomBytes(3).toString('hex'); - const baseSlug = `${event_type}-${processedEventName}-${slugSuffix}`; - let slug = baseSlug; - let counter = 1; - - while (await db('events').where({ slug }).first()) { - slug = `${baseSlug}-${counter}`; - counter++; - } - - // Generate share link respecting configured format - const shareToken = crypto.randomBytes(16).toString('hex'); - const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken }); - - // Hash password with configurable rounds (random placeholder when not required) - const password_hash = requirePassword - ? await bcrypt.hash(password, getBcryptRounds()) - : await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds()); - - // Calculate expiration date (days after event date) - // If expiration is not required, expires_at will be null (never expires) - // If event_date is not provided, use current date as base for expiration - let expires_at = null; - if (fieldRequirements.require_expiration) { - const baseDate = event_date || new Date().toISOString().split('T')[0]; - // Parse YYYY-MM-DD format as local date to avoid timezone issues - if (baseDate.match(/^\d{4}-\d{2}-\d{2}$/)) { - const [year, month, day] = baseDate.split('-').map(num => parseInt(num, 10)); - expires_at = new Date(year, month - 1, day); - } else { - expires_at = new Date(baseDate); - } - expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10)); - } - - // Create folder structure - const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage'); - const eventPath = path.join(storagePath, 'events/active', slug); - await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true }); - await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true }); - - // Sync header_style / hero_divider_style from color_theme JSON when not - // explicitly provided in the request body (#158). - let effectiveHeaderStyle = header_style; - let effectiveDividerStyle = hero_divider_style; - if (color_theme && (!req.body.header_style || !req.body.hero_divider_style)) { - try { - if (typeof color_theme === 'string' && color_theme.startsWith('{')) { - const parsed = JSON.parse(color_theme); - if (!req.body.header_style && parsed.headerStyle) { - effectiveHeaderStyle = parsed.headerStyle; - } - if (!req.body.hero_divider_style && parsed.heroDividerStyle) { - effectiveDividerStyle = parsed.heroDividerStyle; - } - } - } catch (_) { - // color_theme is not JSON – nothing to extract - } - } - - // Get branding defaults for hero logo settings (Feature 7: Branding Inheritance) - const brandingDefaults = await getBrandingDefaults(); - // hero_logo_visible: store NULL ("inherit") unless the admin explicitly - // set it, so the global branding_logo_display_hero toggle keeps - // controlling this gallery afterwards (#756). Only an explicit per-event - // choice overrides the global. `!= null` treats an explicit null the same - // as omitted (both → inherit); otherwise formatBoolean(null) would coerce - // to 0/false on SQLite instead of NULL (the PUT handler already does this). - const effectiveHeroLogoVisible = req.body.hero_logo_visible != null - ? formatBoolean(hero_logo_visible) - : null; - // NULL = inherit the global branding_logo_size (#756), resolved at read - // time. Only an explicit per-event size overrides it. - const effectiveHeroLogoSize = req.body.hero_logo_size || null; - const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position; - - // Inherit "Detect dev tools" from the global Image Security setting unless - // the request explicitly overrides it (#317 — admin disabled it globally - // but new events still got it ON because the column default is true). - const protectionDefaults = await getDownloadProtectionDefaults(); - // #1296 — the other four Image-security settings, which were written, - // rendered as controls, and read by nothing. Same inheritance rule as - // the devtools setting below. Creation-time only; see - // getImageSecurityDefaults for why existing events are left alone. - const imageSecurityColumns = resolveImageSecurityColumns( - req.body, - await getImageSecurityDefaults(), - ); - const effectiveEnableDevtoolsProtection = - enableDevtoolsProtectionInput !== undefined - ? enableDevtoolsProtectionInput - : protectionDefaults.enable_devtools_protection !== undefined - ? protectionDefaults.enable_devtools_protection - : true; - - // Migration 137 — normalise calendar time triple. Throws AppError - // 400 when is_full_day=false but times are malformed/inverted. - const calendarTriple = normaliseEventTimeTriple({ - event_time_start, event_time_end, is_full_day, - }); - const calendarColumnsExist = await hasColumnCached('events', 'is_full_day'); - - // Insert into database - // Seed the new event's Live Slideshow display style from the PICPEAK-WIDE - // preset (app_settings, Settings → Slideshow). New events inherit it and the - // admin can still override per event. Watermark is left NULL = inherit the - // global watermark; the share token is minted on demand, not seeded. Guarded - // so un-migrated installs (mid-branch) don't reference missing columns. - let slideshowSeed = {}; - if (await hasColumnCached('events', 'show_interval_ms')) { - try { - // parseInt-first: the previous `Number.isFinite(+v)` pre-check let - // NaN through for null/''/true (+null is 0, parseInt(null) is NaN), - // producing show_interval_ms=NaN in the INSERT — PG rejects that - // with "invalid input syntax for type integer" while SQLite - // silently stores NULL, so event creation 500'd on PG whenever the - // slideshow app_settings rows were absent. - const intP = (v, min, max) => clampIntOrUndefined(v, min, max); - const oneOf = (v, allowed) => (allowed.includes(v) ? v : undefined); - const i = intP(await getAppSetting('slideshow_interval_ms', undefined), 1000, 120000); - const tr = oneOf(await getAppSetting('slideshow_transition', undefined), SLIDESHOW_TRANSITIONS); - const tms = intP(await getAppSetting('slideshow_transition_ms', undefined), 100, 5000); - const cf = oneOf(await getAppSetting('slideshow_colorfilter', undefined), SLIDESHOW_COLORFILTERS); - if (i !== undefined) slideshowSeed.show_interval_ms = i; - if (tr) slideshowSeed.show_transition = tr; - if (tms !== undefined) slideshowSeed.show_transition_ms = tms; - if (cf) slideshowSeed.show_colorfilter = cf; - } catch (e) { - logger.warn('Failed to seed slideshow settings from global preset', { error: e.message }); - } - } - - const insertResult = await db('events').insert({ - slug, - event_type, - event_name, - ...slideshowSeed, - event_date: event_date || null, - ...(calendarColumnsExist ? { - event_time_start: calendarTriple.event_time_start, - event_time_end: calendarTriple.event_time_end, - is_full_day: formatBoolean(calendarTriple.is_full_day), - } : {}), - ...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}), - ...(customerPhone ? { customer_phone: customerPhone } : {}), - host_name: customerName || null, - host_email: customerEmail || null, - admin_email: admin_email || null, - password_hash, - // Opt-in recoverable copy (#1271), written with the hash so the two - // can never disagree. Empty unless the security setting is on. - ...(await galleryPasswordColumns({ - ...(requirePassword && password ? { password } : {}), - ...(client_access_enabled && client_password ? { clientPassword: client_password } : {}), - })), - welcome_message, - color_theme, - share_link: shareLinkToStore, - share_token: shareToken, - expires_at: expires_at ? expires_at.toISOString() : null, - created_at: new Date().toISOString(), - created_by: req.admin.id, - allow_user_uploads, - upload_category_id, - allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true), - disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false), - enable_devtools_protection: formatBoolean(effectiveEnableDevtoolsProtection), - // Request value, else the global default, else the column default — - // a key absent here is one the database fills in (#1296). - ...imageSecurityColumns, - watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false), - watermark_text, - allow_presigned_download: formatBoolean(allow_presigned_download === true || allow_presigned_download === 'true'), - require_password: formatBoolean(requirePassword), - css_template_id: css_template_id || null, - // Already formatBoolean-coerced above, or null = inherit global (#756). - hero_logo_visible: effectiveHeroLogoVisible, - hero_logo_size: effectiveHeroLogoSize, - hero_logo_position: effectiveHeroLogoPosition, - // Banner overrides. Both were accepted by the validators above and - // then dropped here, so an API client could POST info_mode:'off' or a - // custom banner, get 201, and find the row still on 'inherit'. - // Markdown is only stored for 'custom' — same rule the PUT applies. - promo_mode: ['inherit', 'custom', 'off'].includes(promo_mode) ? promo_mode : 'inherit', - promo_markdown: promo_mode === 'custom' && typeof promo_markdown === 'string' && promo_markdown.trim() - ? promo_markdown.trim() : null, - info_mode: ['inherit', 'custom', 'off'].includes(info_mode) ? info_mode : 'inherit', - info_markdown: info_mode === 'custom' && typeof info_markdown === 'string' && info_markdown.trim() - ? info_markdown.trim() : null, - header_style: effectiveHeaderStyle || 'standard', - hero_divider_style: effectiveDividerStyle || 'wave', - hero_image_anchor: hero_image_anchor || 'center', - photo_cap: photo_cap || null, - is_draft: formatBoolean(parseBooleanInput(is_draft, true)), - default_photo_sort: default_photo_sort || 'upload_date_desc', - // Client access (#172) - client_access_enabled: formatBoolean(client_access_enabled), - ...(client_access_enabled && client_password ? { - client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()), - client_share_token: crypto.randomBytes(32).toString('hex') - } : {}), - // Per-event opt-in for hero-photo OG share image (#474). Defaults - // false on create — admin opts in from the event detail page once - // they've picked a hero they're comfortable surfacing publicly. - og_image_share_enabled: formatBoolean(req.body.og_image_share_enabled === true), - }).returning('id'); - - // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) - const eventId = insertResult[0]?.id || insertResult[0]; - // #1271 — the setting was read before the hashes; re-check after the write - await dropCopiesIfStorageOff(eventId); - - // Apply customer-account assignments (#354). Skip when the customer - // portal flag is off — the frontend hides the picker in that case, - // but a stale tab could still POST customer_account_ids; we ignore - // them rather than 403 the entire create. - if (Array.isArray(req.body.customer_account_ids)) { - try { - const customerAccountsService = require('../../services/customerAccountsService'); - if (await customerAccountsService.isCustomerPortalEnabled()) { - await customerAccountsService.setAssignmentsForEvent( - eventId, - req.body.customer_account_ids, - req.admin.id - ); - } - } catch (e) { - logger.error('Failed to set customer assignments on event create', { - eventId, error: e.message, - }); - } - } - - // Insert feedback settings if feedback is enabled - if (feedback_enabled) { - await db('event_feedback_settings').insert({ - event_id: eventId, - feedback_enabled: formatBoolean(feedback_enabled), - allow_ratings: formatBoolean(feedbackDefaults.allow_ratings), - allow_likes: formatBoolean(feedbackDefaults.allow_likes), - allow_comments: formatBoolean(feedbackDefaults.allow_comments), - allow_favorites: formatBoolean(feedbackDefaults.allow_favorites), - allow_reactions: formatBoolean(feedbackDefaults.allow_reactions), - allow_color_labels: formatBoolean(feedbackDefaults.allow_color_labels), - keybind_mode: feedbackDefaults.keybind_mode, - require_name_email: formatBoolean(require_name_email), - moderate_comments: formatBoolean(moderate_comments), - show_feedback_to_guests: formatBoolean(show_feedback_to_guests), - identity_mode: ['simple', 'guest', 'shared'].includes(identityModeInput) - ? identityModeInput - : 'simple', - created_at: new Date().toISOString(), - updated_at: new Date().toISOString() - }); - } - - // Log activity - await logActivity('event_created', - { event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score }, - eventId, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); - - // Fire event.created webhook (#327). If the event is being published - // immediately (not a draft), event.published also fires below. - // Payload uses canonical event subject (#341) so receivers always see - // the same shape (id/slug/event_name + customer contact + share_*). - try { - const webhookService = require('../../services/webhookService'); - await webhookService.fire('event.created', { - event: { - ...webhookService.buildEventSubject({ - id: eventId, - slug, - event_name, - event_type, - event_date, - share_url: shareUrl, - share_token: shareToken, - customer_name: customerName, - customer_email: customerEmail, - customer_phone: customerPhone, - }), - is_draft: parseBooleanInput(is_draft, true), - }, - }); - } catch (e) { /* webhookService.fire never throws but be defensive */ } - - // Queue creation email (only if there is a recipient and event is not a draft) - // Language detection is handled by email processor - const isDraft = parseBooleanInput(is_draft, true); - - if (customerEmail && !isDraft) { - // Build email data with optional client access info - const emailData = { - customer_name: customerName, - customer_email: customerEmail, - host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null), - event_name, - event_date: event_date, // Pass raw date - will be formatted by email processor - gallery_link: shareUrl, - gallery_password: requirePassword ? password : 'No password required', - expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor - welcome_message: welcome_message || '' - }; - - // Include client access info in email when enabled (#172) - if (client_access_enabled && client_password) { - const createdEvent = await db('events').where('id', eventId).first(); - // Same FRONTEND_URL-before-APP_URL order as before: APP_URL is - // passed as the override so it still outranks the general_site_url - // setting and the request origin. Chaining it after the resolver - // would make it dead code, because the resolver only returns falsy - // when NOTHING is configured (#1104). - const frontendUrl = await getAbsoluteFrontendUrl(req, { override: process.env.APP_URL }); - emailData.client_link = `${frontendUrl}/gallery/${slug}/client-access?token=${createdEvent.client_share_token}`; - emailData.client_password = client_password; - } - - await db('email_queue').insert({ - event_id: eventId, - recipient_email: customerEmail, - email_type: 'gallery_created', - email_data: JSON.stringify(emailData), - status: 'pending', - created_at: new Date() - // scheduled_at will use default value - }); - } - - // WhatsApp gallery_ready notification (#640D). Fires when the event is - // created NOT as a draft, the `whatsapp` flag is on, a config exists, and - // the customer supplied a phone number. Non-fatal: a queue failure should - // never block gallery creation. - if (!isDraft && customerPhone) { - try { - const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor'); - const waConfig = await getWhatsAppConfig(); - if (waConfig && waConfig.enabled) { - await queueWhatsapp(eventId, customerPhone, 'gallery_created', { - customer_name: customerName || '', - event_name, - gallery_link: shareUrl, - gallery_password: requirePassword ? password : '', - expiry_date: expires_at ? expires_at.toISOString() : null, - language: null, // resolved by processor via general_default_language - }); - } - } catch (waError) { - logger.warn('Failed to queue WhatsApp notification on create', { error: waError.message }); - } - } - - // Fire event.published when the event is created NOT as a draft. The - // separate /publish endpoint fires it for the draft → live transition; - // this covers the "create-and-publish in one shot" path. - if (!isDraft) { - try { - const webhookService = require('../../services/webhookService'); - await webhookService.fire('event.published', { - event: webhookService.buildEventSubject({ - id: eventId, - slug, - event_name, - event_type, - event_date, - share_url: shareUrl, - share_token: shareToken, - customer_name: customerName, - customer_email: customerEmail, - customer_phone: customerPhone, - }), - }); - } catch (e) { /* non-fatal */ } - } - - res.json({ - id: eventId, - slug, - event_name, - event_type, - customer_name: customerName, - customer_email: customerEmail, - require_password: requirePassword, - photo_cap: photo_cap || null, - is_draft: isDraft, - share_link: shareUrl, - expires_at: expires_at ? expires_at.toISOString() : null, - created_at: new Date().toISOString() + const created = await require('../../services/eventCreationService').createEvent(req.body, { + actor: req.admin, + frontendUrl: await getAbsoluteFrontendUrl(req, { override: process.env.APP_URL }), }); + res.json(created); } catch (error) { + if (error.isOperational) return res.status(error.statusCode).json(error.responseBody || { error: error.message, code: error.code }); if (isDuplicateSlugError(error)) { logger.warn('Event creation lost the slug race', { error: error.message }); return res.status(409).json(DUPLICATE_SLUG_RESPONSE); @@ -1425,6 +864,7 @@ module.exports = (router) => { share_token: shareToken, expires_at: newExpiresAt ? newExpiresAt.toISOString() : null, created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), created_by: req.admin.id, allow_user_uploads: source.allow_user_uploads, upload_category_id: source.upload_category_id, @@ -1441,7 +881,6 @@ module.exports = (router) => { use_canvas_rendering: source.use_canvas_rendering, watermark_downloads: source.watermark_downloads, watermark_text: source.watermark_text, - allow_presigned_download: source.allow_presigned_download, require_password: source.require_password, css_template_id: source.css_template_id || null, hero_logo_visible: source.hero_logo_visible, @@ -1598,7 +1037,6 @@ module.exports = (router) => { body('disable_right_click').optional().isBoolean(), body('watermark_downloads').optional().isBoolean(), body('watermark_text').optional().trim(), - body('allow_presigned_download').optional().isBoolean(), body('source_mode').optional().isIn(['managed', 'reference']), body('external_path').optional({ nullable: true }).isString().trim(), body('external_watch').optional().isBoolean(), @@ -2029,8 +1467,6 @@ module.exports = (router) => { } } - - // Sync header_style / hero_divider_style from color_theme JSON when not // explicitly provided in the request body (#158). This ensures the // database columns stay in sync even if the frontend only sends the @@ -2232,12 +1668,12 @@ module.exports = (router) => { return res.status(404).json({ error: 'Event not found' }); } - const newStatus = !event.is_active; + const newStatus = !parseBooleanInput(event.is_active, false); await db('events') .where('id', id) .update({ - is_active: newStatus, - updated_at: new Date() + is_active: formatBoolean(newStatus), + updated_at: new Date().toISOString() }); // Log activity diff --git a/backend/src/routes/adminEvents/helpers.js b/backend/src/routes/adminEvents/helpers.js index ecfe1b17..f5653970 100644 --- a/backend/src/routes/adminEvents/helpers.js +++ b/backend/src/routes/adminEvents/helpers.js @@ -1,392 +1,9 @@ -// Extracted verbatim from the original routes/adminEvents.js (see ./index.js). -// Shared helpers + module-level caches used across the adminEvents sub-routers. - const { db, logActivity } = require('../../database/db'); const fs = require('fs').promises; const path = require('path'); const logger = require('../../utils/logger'); -const { parseStringInput } = require('../../utils/parsers'); +const settings = require('../../services/eventSettings'); -// Shared validator for hero_image_anchor – accepts legacy keywords or "X% Y%" focal point -const validateHeroImageAnchor = (value) => { - if (['top', 'center', 'bottom'].includes(value)) return true; - if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) { - const [x, y] = value.split(/\s+/).map(v => parseInt(v)); - if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true; - } - throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)'); -}; - -// Get storage path from environment or default -const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../../storage'); - -// Helper to get event field requirements from settings -const getEventFieldRequirements = async () => { - try { - const settings = await db('app_settings') - .whereIn('setting_key', [ - 'event_require_customer_name', - 'event_require_customer_email', - 'event_require_admin_email', - 'event_require_event_date', - 'event_require_expiration' - ]) - .select('setting_key', 'setting_value'); - - const requirements = { - require_customer_name: true, - require_customer_email: true, - require_admin_email: true, - require_event_date: true, - require_expiration: true - }; - - settings.forEach(s => { - let value = s.setting_value; - if (typeof value === 'string') { - try { - value = JSON.parse(value); - } catch (e) { - value = value === 'true'; - } - } - if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value; - if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value; - if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value; - if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value; - if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value; - }); - - return requirements; - } catch (error) { - logger.error('Failed to get event field requirements', { error: error.message }); - return { - require_customer_name: true, - require_customer_email: true, - require_admin_email: true, - require_event_date: true, - require_expiration: true - }; - } -}; - -// Helper to read app_settings booleans by key, used to inherit per-setting -// defaults onto new events. Returns `undefined` for missing/non-boolean rows -// so callers can fall back to a legacy default. -/** - * Decode an app_settings value into the JS value it represents. - * - * setting_value is JSON text on SQLite and may already be decoded by the - * driver on a PG json column, so one parse does not normalise both. On top - * of that, the Image Security tab used to PUT back values it had read - * undecoded, wrapping another layer of quoting around each one on every - * save — the GET handler decodes now, but installs carry however many - * layers they accumulated before that. - * - * Every reader of app_settings has to agree about this, or the admin UI - * shows one thing while event creation does another. - * - * Terminates: each parse of a string is strictly shorter than its input. - */ -const decodeSettingValue = (raw) => { - let value = raw; - while (typeof value === 'string') { - let parsed; - try { parsed = JSON.parse(value); } catch { break; } - if (parsed === value) break; - value = parsed; - } - return value; -}; - -const readBooleanSetting = async (key) => { - try { - const setting = await db('app_settings').where('setting_key', key).first(); - if (!setting) return undefined; - const value = decodeSettingValue(setting.setting_value); - return typeof value === 'boolean' ? value : undefined; - } catch (error) { - logger.error('Failed to read app setting', { key, error: error.message }); - return undefined; - } -}; - -// Helper to read the global "enable_devtools_protection" admin setting so -// new events inherit it instead of always falling back to the DB column default -// (#317 — admin disabled it globally but new events still got it ON). -const getDownloadProtectionDefaults = async () => { - return { enable_devtools_protection: await readBooleanSetting('enable_devtools_protection') }; -}; - -/** - * The rest of Settings → Image security, as creation defaults (#1296). - * - * Four settings in that panel were written, reloaded and rendered as - * controls, and read by nothing: - * - * default_protection_level → events.protection_level - * default_image_quality → events.image_quality - * enable_canvas_rendering → events.use_canvas_rendering - * - * Each maps onto a column migration 038 already created, and each is - * labelled "… by default", so applying them at creation is what the panel - * has always claimed to do. `enable_devtools_protection` above is the only - * one of the five that was ever wired. - * - * Creation-time only, deliberately. Applying them to EXISTING events would - * silently change live galleries on upgrade — an install with - * enable_canvas_rendering already on would switch every grid to canvas - * rendering, which is memory-expensive at scale and is the profile under - * investigation in #1287. New events only; existing rows untouched. - * - * Any value that is missing or malformed comes back undefined so the caller - * falls through to the column default, exactly as before this existed. - */ -const PROTECTION_LEVELS = ['basic', 'standard', 'enhanced', 'maximum']; - -// parseInt would rescue malformed settings instead of rejecting them: -// parseInt('72oops') is 72, parseInt(72.5) is 72, parseInt([72]) is 72. -// That matters because the settings PUT stores whatever JSON it is handed -// without validating the value (adminImageSecurity.js writes -// JSON.stringify(value) for any allow-listed key), so those shapes really -// can be sitting in app_settings. Accept only a genuine integer, or a -// string that is exactly one. -const toInteger = (value) => { - if (typeof value === 'number') return Number.isInteger(value) ? value : undefined; - if (typeof value === 'string' && /^[+-]?\d+$/.test(value.trim())) return Number(value.trim()); - return undefined; -}; - -const getImageSecurityDefaults = async (trx = null) => { - const defaults = {}; - try { - // Accepts a transaction the way getAppSetting does. It matters on - // sqlite3, whose pool holds a single connection: a caller already inside - // db.transaction() that read through the global `db` would block on the - // connection its own transaction holds until the acquire timeout, and - // the catch below would then quietly swallow it and drop the defaults. - const query = trx || db; - const rows = await query('app_settings') - .whereIn('setting_key', [ - 'default_protection_level', - 'default_image_quality', - 'enable_canvas_rendering', - ]) - .select('setting_key', 'setting_value'); - - // app_settings holds JSON text on SQLite, while a PG json column comes - // back already decoded — so one parse is not enough to normalise both. - // Worse, GET /api/admin/image-security/settings returns setting_value - // without decoding it and the settings tab PUTs the whole fetched object - // straight back through JSON.stringify, so opening the tab and saving - // re-encodes every value it read as text. After one such round trip - // `true` is stored as "\"true\"" and a single parse yields the string - // 'true', which the type checks below reject — the settings would go - // quietly dead again, which is the bug this whole change exists to fix. - // The GET handler now decodes, so this stops accumulating — but installs - // that already stacked N layers have to keep working, and N is however - // many times someone opened that tab. So unwrap until it stops being a - // JSON string rather than to a fixed depth; this terminates because each - // parse of a string is strictly shorter than its input. - const read = (key) => { - const row = rows.find((r) => r.setting_key === key); - if (!row) return undefined; - return decodeSettingValue(row.setting_value); - }; - - const level = read('default_protection_level'); - if (typeof level === 'string' && PROTECTION_LEVELS.includes(level)) { - defaults.protection_level = level; - } - - // The column is an integer percentage; anything outside 1..100 is a - // misconfiguration and falls through rather than being clamped into - // something the operator did not choose. - const quality = toInteger(read('default_image_quality')); - if (quality !== undefined && quality >= 1 && quality <= 100) { - defaults.image_quality = quality; - } - - const canvas = read('enable_canvas_rendering'); - if (typeof canvas === 'boolean') { - defaults.use_canvas_rendering = canvas; - } - - } catch (error) { - // A settings read must never block event creation; the column defaults - // are a correct fallback. - logger.error('Failed to read image-security defaults', { error: error.message }); - } - return defaults; -}; - -/** - * Build the image-security columns for a NEW event: an explicit request - * value wins, then the global default, then the column default (the key is - * omitted entirely so the database supplies it). - * - * Shared by the admin create route and POST /api/v1/events so the configured - * security level cannot depend on which entry point created the gallery — - * the same split that made #592 (devtools) a separate bug from #317. - * - * `body` values are already validated by the route's express-validator - * chain; `defaults` come from getImageSecurityDefaults(), which validates - * them itself. - */ -const resolveImageSecurityColumns = (body = {}, defaults = {}) => { - const { formatBoolean } = require('../../utils/dbCompat'); - const columns = {}; - // express-validator runs isInt/isIn/isBoolean element-wise on arrays, so a - // single-element array like `image_quality: [72]` passes the route's chain - // and arrives here still an array. The routes reject those with - // .not().isArray(); this guard means any future caller cannot write one - // into a scalar column (a PG insert error, or `[false]` coerced to true). - const scalar = (v) => (v !== null && typeof v === 'object' ? undefined : v); - const pick = (key) => { - const fromBody = scalar(body[key]); - return fromBody !== undefined ? fromBody : defaults[key]; - }; - - const level = pick('protection_level'); - if (level !== undefined) columns.protection_level = level; - - const quality = pick('image_quality'); - if (quality !== undefined) columns.image_quality = quality; - - const canvas = pick('use_canvas_rendering'); - if (canvas !== undefined) columns.use_canvas_rendering = formatBoolean(canvas); - - - return columns; -}; - -// Helper to get branding defaults for new events (Feature 7: Branding Inheritance). -// -// Note: `branding_logo_position` (header bar — left/center/right) is a -// different concept from `hero_logo_position` (hero block — top/center/ -// bottom) and must NOT be mapped here. A previous version copied the -// branding value over, which wrote 'left'/'right' into per-event -// hero_logo_position columns and broke any subsequent PUT validation -// (#357). Migration 084 heals existing rows. -const getBrandingDefaults = async () => { - try { - const settings = await db('app_settings') - .whereIn('setting_key', [ - 'branding_logo_display_hero', - 'branding_logo_size' - ]) - .select('setting_key', 'setting_value'); - - const defaults = { - hero_logo_visible: true, - hero_logo_size: 'medium', - hero_logo_position: 'top' - }; - - settings.forEach(s => { - let value = s.setting_value; - if (typeof value === 'string') { - try { value = JSON.parse(value); } catch (e) { /* use as-is */ } - } - if (s.setting_key === 'branding_logo_display_hero') { - defaults.hero_logo_visible = value !== false; - } - if (s.setting_key === 'branding_logo_size' && value) { - defaults.hero_logo_size = value; - } - }); - - return defaults; - } catch (error) { - logger.error('Failed to get branding defaults', { error: error.message }); - return { - hero_logo_visible: true, - hero_logo_size: 'medium', - hero_logo_position: 'top' - }; - } -}; - -// Use parseStringInput from shared parsers for customer data extraction -const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name); -const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email); -const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone); - -// Whether the global "phone field" toggle (#322) is enabled. Cached for -// the request via a module-level read; drift is acceptable since this -// only governs whether to persist the field, not security boundaries. -const isPhoneFieldEnabled = async () => { - try { - const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first(); - if (!row) return false; - let value = row.setting_value; - if (typeof value === 'string') { - try { value = JSON.parse(value); } catch { /* keep raw */ } - } - return value === true; - } catch (error) { - logger.debug('Failed to read event_phone_field_enabled', { error: error.message }); - return false; - } -}; - -const RECOVERABLE_PASSWORD_COLUMNS = ['password_recoverable', 'client_password_recoverable']; - -const mapEventForApi = (event) => { - if (!event || typeof event !== 'object') { - return event; - } - - const { - host_name, - host_email, - customer_name, - customer_email, - customer_phone, - // Bound only to exclude the secrets from `...rest` — never read. - password_hash: _ph, client_password_hash: _cph, - ...rest - } = event; - // #1271 — the encrypted copies never leave the server except via - // /:id/password. Removed by name (not destructured) so a secret scanner - // does not read the binding as a hard-coded password. - for (const column of RECOVERABLE_PASSWORD_COLUMNS) delete rest[column]; - - return { - ...rest, - customer_name: customer_name ?? host_name ?? null, - customer_email: customer_email ?? host_email ?? null, - customer_phone: customer_phone ?? null - }; -}; - -let customerColumnCache = null; -const hasCustomerContactColumns = async () => { - if (customerColumnCache === true) { - return true; - } - - try { - const hasColumn = await db.schema.hasColumn('events', 'customer_email'); - if (hasColumn) { - customerColumnCache = true; - } - return hasColumn; - } catch (error) { - logger.debug('Failed to detect customer_email column', { error: error.message }); - return false; - } -}; - -// Cascade-delete a single event: photos, audit/access logs, queued emails, -// the event row itself (in one transaction), then the on-disk folder / -// archive zip / hero logo (best-effort — file failures don't unwind the DB -// changes since the source of truth is the database). Used by both the -// per-event DELETE /:id route and the bulk-delete route to avoid drift. -// -// Throws { code: 'EVENT_NOT_FOUND' } if the event id doesn't exist so the -// bulk-delete loop can report it as a per-id failure without aborting the -// whole batch. Any other error propagates and is the caller's problem. async function deleteEventCascade(eventId, adminContext) { const event = await db('events').where('id', eventId).first(); if (!event) { @@ -678,40 +295,4 @@ async function deleteEventCascade(eventId, adminContext) { return { id: event.id, name: event.event_name }; } -// --------------------------------------------------------------------------- -// Live Slideshow ("Diashow") — a token-only fullscreen kiosk link for live -// events that auto-picks-up new uploads (migration 138). Mirrors the -// client-access second-token pattern: the link is minted on demand, rotatable -// and disable-able, independent of the gallery password / share link. -// --------------------------------------------------------------------------- - -// Allowed slide transition styles (kept in sync with the SlideshowPage). -// dipwhite/dipblack = fade through highlights / lowlights between images. -const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack']; -// Allowed per-slide color filters. -const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette']; -// Allowed slideshow play orders (#202). 'chronological' = upload order, -// 'random' = client-side shuffle. -const SLIDESHOW_ORDERS = ['chronological', 'random']; -module.exports = { - RECOVERABLE_PASSWORD_COLUMNS, - validateHeroImageAnchor, - getStoragePath, - getEventFieldRequirements, - readBooleanSetting, - decodeSettingValue, - getDownloadProtectionDefaults, - getImageSecurityDefaults, - resolveImageSecurityColumns, - getBrandingDefaults, - getCustomerNameFromPayload, - getCustomerEmailFromPayload, - getCustomerPhoneFromPayload, - isPhoneFieldEnabled, - mapEventForApi, - hasCustomerContactColumns, - deleteEventCascade, - SLIDESHOW_ORDERS, - SLIDESHOW_TRANSITIONS, - SLIDESHOW_COLORFILTERS, -}; +module.exports = { ...settings, deleteEventCascade }; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 7a5565c1..bc58cd1c 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -1,6 +1,8 @@ +const { isGalleryAvailable } = require('../utils/galleryLifecycle'); const express = require('express'); const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); const { body, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); @@ -420,7 +422,7 @@ router.post('/gallery/verify', [ .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) .first(); - if (!event) { + if (!isGalleryAvailable(event)) { // Perform a dummy bcrypt compare to prevent timing-based slug enumeration await bcrypt.compare(password || '', DUMMY_BCRYPT_HASH); await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent); @@ -496,6 +498,9 @@ router.post('/gallery/verify', [ eventId: event.id, eventSlug: event.slug, type: 'gallery', + // Unique per token: the revocation key falls back to eventId+iat otherwise, + // so one guest's logout would revoke every same-second login (#1357). + jti: crypto.randomUUID(), ip: ipAddress, loginTime: Date.now() }, process.env.JWT_SECRET, { @@ -545,7 +550,7 @@ router.post('/gallery/:slug/client-login', [ .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) .first(); - if (!event || !event.client_access_enabled || !event.client_password_hash) { + if (!isGalleryAvailable(event) || !event.client_access_enabled || !event.client_password_hash) { await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent); return res.status(401).json({ error: 'Invalid credentials' }); } @@ -570,6 +575,9 @@ router.post('/gallery/:slug/client-login', [ eventId: event.id, eventSlug: event.slug, type: 'gallery', + // Unique per token: the revocation key falls back to eventId+iat otherwise, + // so one guest's logout would revoke every same-second login (#1357). + jti: crypto.randomUUID(), accessLevel: 'client', ip: ipAddress, loginTime: Date.now() @@ -631,14 +639,14 @@ router.post('/gallery/share-login', [ .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) .first(); - if (!event) { + if (!isGalleryAvailable(event)) { const resolved = await resolveShareIdentifier(slug); if (resolved?.event) { event = resolved.event; } } - if (!event) { + if (!isGalleryAvailable(event)) { await trackFailedAttempt(shareIdentifier, ipAddress, userAgent); return res.status(404).json({ error: 'Gallery not found' }); } @@ -666,6 +674,9 @@ router.post('/gallery/share-login', [ eventId: event.id, eventSlug: event.slug, type: 'gallery', + // Unique per token: the revocation key falls back to eventId+iat otherwise, + // so one guest's logout would revoke every same-second login (#1357). + jti: crypto.randomUUID(), ip: ipAddress, loginTime: Date.now() }, process.env.JWT_SECRET, { @@ -743,106 +754,26 @@ router.get('/session', async (req, res) => { issuer: 'picpeak-auth' }); - // Check if token has been revoked (e.g. after logout) - const { isTokenRevoked } = require('../utils/tokenRevocation'); - if (await isTokenRevoked(decoded)) { - return res.status(401).json({ valid: false, error: 'Session has been invalidated' }); - } - - // The redirect loop reported on the v3.32.4-beta.0 release came - // from /auth/session reporting valid: true while the protected - // adminAuth / galleryAuth middleware rejected the same token for - // reasons /auth/session never checked: the admin user was - // deactivated, the admin's password had been changed since iat, - // or the gallery event was archived/deleted. Mirror those checks - // here so the session endpoint is always at least as strict as - // what the protected endpoints will enforce next. - // Full user payload for admin sessions — the SSO callback establishes - // the session via redirect (no JSON response the SPA could store), so - // session restoration must be able to hydrate the user object (#798). + const sessions = require('../services/sessionAccessService'); let adminUser = null; - if (decoded.type === 'admin') { - let admin = null; - try { - admin = await db('admin_users') - .leftJoin('roles', 'roles.id', 'admin_users.role_id') - .where({ 'admin_users.id': decoded.id, 'admin_users.is_active': formatBoolean(true) }) - .select( - 'admin_users.id', 'admin_users.username', 'admin_users.email', - 'admin_users.password_changed_at', 'admin_users.must_change_password', - 'roles.name as role_name', 'roles.display_name as role_display_name' - ) - .first(); - } catch (lookupErr) { - // admin_users table not present (test fixture, fresh DB) — fall - // through and trust the token. Real deployments always have it. - admin = null; - // intentional swallow; if the table is missing we do not want - // to fail-closed during e.g. early bootstrap. - } - - if (admin === null) { - // Lookup didn't run because the table is missing; skip the - // existence/password checks and treat the token as valid. - } else if (!admin) { - return res.json({ valid: false, error: 'Admin account no longer active' }); - } else if (admin.password_changed_at) { - const passwordChangedSeconds = Math.floor( - new Date(admin.password_changed_at).getTime() / 1000 - ); - if (decoded.iat < passwordChangedSeconds) { - return res.json({ valid: false, error: 'Token invalid due to password change' }); - } - } - - // Mirror the session-timeout check that sessionTimeoutMiddleware - // enforces on every /api/admin endpoint. Without this, /auth/session - // returns valid:true for an idle/old-iat token that protected - // endpoints reject with 401 SESSION_TIMEOUT — the same redirect-loop - // shape as the issuer-claim and password-change asymmetries (issue - // #350 recurrence on v3.39.1-beta.0). - try { - const { isSessionExpired } = require('../middleware/sessionTimeout'); - if (await isSessionExpired(token, decoded)) { - return res.json({ valid: false, error: 'Session expired' }); - } - } catch (timeoutErr) { - // Helper lookup failed (test stub may not export it) — fall through - // and trust the token. Real deployments always have the middleware. - } - - if (admin) { - adminUser = { - id: admin.id, - username: admin.username, - email: admin.email, - mustChangePassword: admin.must_change_password || false, - role: admin.role_name ? { - name: admin.role_name, - displayName: admin.role_display_name - } : null - }; + const admin = await sessions.admin(decoded, { includeProfile: true }); + const { isSessionExpired } = require('../middleware/sessionTimeout'); + if (await isSessionExpired(token, decoded)) { + return res.json({ valid: false, error: 'Session expired' }); } + adminUser = { + id: admin.id, username: admin.username, email: admin.email, + mustChangePassword: !!admin.must_change_password, + role: admin.role_name ? { name: admin.role_name, displayName: admin.role_display_name } : null, + }; } else if (decoded.type === 'gallery') { - try { - const event = await db('events') - .where({ - id: decoded.eventId, - is_active: formatBoolean(true), - is_archived: formatBoolean(false), - }) - .first(); - if (!event) { - return res.json({ valid: false, error: 'Gallery no longer available' }); - } - if (event.expires_at && new Date(event.expires_at) < new Date()) { - return res.json({ valid: false, error: 'Gallery has expired' }); - } - } catch (galleryLookupErr) { - // events table missing in this context — same fallback as - // admin path; trust the token rather than fail-closed. - } + const access = require('../services/galleryAccessService'); + const event = await db('events').where({ id: decoded.eventId }).first(); + if (!event) return res.json({ valid: false, error: 'Gallery no longer available' }); + await access.authorize(event, access.grant(event, 'gallery', decoded)); + } else { + return res.status(403).json({ valid: false, error: 'Invalid token type' }); } // Calculate remaining time diff --git a/backend/src/routes/customer.js b/backend/src/routes/customer.js index 166eb943..53f40c94 100644 --- a/backend/src/routes/customer.js +++ b/backend/src/routes/customer.js @@ -1,3 +1,4 @@ +const { isGalleryAvailable, isGalleryExpired } = require('../utils/galleryLifecycle'); /** * Customer dashboard routes * @@ -14,6 +15,7 @@ const express = require('express'); const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); const { body, param, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); const { getBcryptRounds, MAX_PASSWORD_LENGTH } = require('../utils/passwordValidation'); @@ -165,10 +167,12 @@ router.get('/events/:slug/access-token', [ if (event.is_archived) { return res.status(410).json({ error: 'This gallery has been archived' }); } - if (event.expires_at && new Date(event.expires_at) < new Date()) { + if (isGalleryExpired(event)) { return res.status(410).json({ error: 'This gallery has expired' }); } + if (!isGalleryAvailable(event)) return res.status(404).json({ error: 'Event not found' }); + const hasAccess = await customerAccountsService.customerHasAccessToEvent( req.customer.id, event.id @@ -189,10 +193,12 @@ router.get('/events/:slug/access-token', [ eventId: event.id, eventSlug: event.slug, type: 'gallery', + // Unique per token: the revocation key falls back to eventId+iat otherwise, + // so one guest's logout would revoke every same-second login (#1357). + jti: crypto.randomUUID(), ip: ipAddress, loginTime: Date.now(), - // Optional bookkeeping claim — surfaces the originating customer in - // logs when the token is later used. Doesn't affect authorization. + // Rechecked on each gallery/media request, including account status. via: 'customer', customerId: req.customer.id, }, process.env.JWT_SECRET, { diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index 4a5a162e..829e18ad 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -1,3360 +1,11 @@ -const express = require('express'); -const jwt = require('jsonwebtoken'); -const { db, logActivity } = require('../database/db'); -const { formatBoolean } = require('../utils/dbCompat'); -// SQLite stores booleans as 0/1, Postgres as true/false (#1028). Strict -// comparisons against `true`/`false` therefore read every flag backwards on -// SQLite — parseBooleanInput normalises both engines and takes the per-column -// default for legacy NULL rows. -const { parseBooleanInput } = require('../utils/parsers'); -const { getAppSetting } = require('../utils/appSettings'); -const archiver = require('archiver'); -const path = require('path'); -const { resolvePhotoContentType } = require('../utils/photoContentType'); -const { timingSafeEqualStr } = require('../utils/timingSafe'); -const router = express.Router(); - -// #756: a NULL per-event hero_logo_visible means "inherit the global -// branding_logo_display_hero toggle". Only an explicit true/false is a -// per-gallery override. `globalDefault` is branding_logo_display_hero -// (defaults true when unset). -function resolveHeroLogoVisible(perEvent, globalDefault) { - if (perEvent === null || perEvent === undefined) { - return globalDefault !== false; - } - return perEvent !== false && perEvent !== 0 && perEvent !== '0'; -} -const watermarkService = require('../services/watermarkService'); -const watermarkGeneratorService = require('../services/watermarkGeneratorService'); -const { verifyGalleryAccess, denySlideshowToken, verifyAdminPreview } = require('../middleware/gallery'); -// Preserve the admin-preview flag across internal photo redirects (#981 review). -// The redirected request carries no gallery JWT, so without the flag it would -// fall back to the draft/password gate and 404 the derivative. -const withPreview = (req, url) => (req.isAdminPreview ? `${url}${url.includes('?') ? '&' : '?'}admin_preview=1` : url); -const { resolveGuest } = require('../middleware/guestAuth'); -// Private, per-guest JSON on this router carries no explicit Cache-Control, so -// browsers fall back to heuristic freshness and may serve a stale body from -// disk for a session-scoped surface. `noStoreCache` is mounted per route rather -// than on the whole router on purpose: the image/thumbnail/hero/preview and -// css-template routes below set their own long-lived caching headers and MUST -// keep them — re-fetching every derivative on every scroll is the reason those -// headers exist. -const { noStoreCache } = require('../middleware/noStoreCache'); -const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit'); -const { COLOR_LABELS, SHARED_COLOR_LABEL_IDENTITY } = require('../constants/colorLabels'); -const secureImageService = require('../services/secureImageService'); -const logger = require('../utils/logger'); -const { pipeStreamToResponse } = require('../utils/streamResponse'); -const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../services/photoResolver'); -const { getEventCategoriesOrdered } = require('../utils/categoryOrder'); -const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService'); -const { handleAsync, errorResponse } = require('../utils/routeHelpers'); -const { isGalleryHidden, guestBlockedByReveal, blockHiddenGallery } = require('../utils/revealMode'); -const { toIso } = require('../utils/dateNormalize'); -const { NotFoundError } = require('../utils/errors'); -const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor'); -const downloadZipService = require('../services/downloadZipService'); -const { renderPhotoForDownload, resolveWatermarkSettings } = require('../services/downloadRendition'); -const downloadJobService = require('../services/downloadJobService'); -// Download resolutions (#858) — the standard size a gallery hands out, plus -// validation of any guest-picked override. -const { - resolveEventDownloadPolicy, - pickRequestedResolution, - parseResolution, -} = require('../utils/downloadResolutions'); -const { applyPhotoVisibilityFilter, canSeeHiddenPhotos } = require('../utils/photoVisibility'); -const { - getUseOriginalFilenames, - pickRawDownloadName, - getZipEntryNames, -} = require('../services/downloadFilenameService'); -const { buildContentDisposition } = require('../utils/filenameSanitizer'); -const { getStorage } = require('../services/storage'); - -// Formats whose ORIGINAL bytes a browser can't render in an (HEIC/HEIF, -// camera RAW/DNG). For these the lightbox must be served the generated JPEG -// preview instead of `url` (the original) — otherwise it shows a broken image. -// So we force `preview_url` for them regardless of the lightbox_preview_enabled -// toggle. Detection is by MIME first, extension as a fallback (browsers report -// these MIMEs inconsistently). EXPERIMENTAL: whether a preview actually renders -// still depends on the backend being able to decode the source (HEVC-in-HEIC on -// the prod image; exiftool for DNG) — see #821. -const NON_DISPLAYABLE_ORIGINAL_EXT = new Set(['heic', 'heif', 'dng']); -const NON_DISPLAYABLE_ORIGINAL_MIME = new Set(['image/heic', 'image/heif', 'image/x-adobe-dng']); -function originalNeedsPreview(photo) { - const mime = (photo.mime_type || '').toLowerCase(); - if (NON_DISPLAYABLE_ORIGINAL_MIME.has(mime)) return true; - const name = photo.original_filename || photo.filename || ''; - const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : ''; - return NON_DISPLAYABLE_ORIGINAL_EXT.has(ext); -} -const { setGalleryAuthCookies } = require('../utils/tokenUtils'); -// Read globals from app_settings (the real table) — settingsService.getSetting -// queries a non-existent `settings` table and throws. -const { getSlideshowGlobals } = require('../utils/slideshowGlobals'); -const { isFeatureEnabled } = require('../middleware/requireFeatureFlag'); -const fs = require('fs'); - -// Get storage path from environment or default -const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); - -/** - * Parse a single-range `Range: bytes=` header against a known size. - * - * Returns null for absent, malformed, multi-range or unsatisfiable headers — - * every one of which the caller answers with a normal 200 full body, which is - * what a client that sent an unparseable range would get today anyway. - * Validating matters because an unchecked parse yields NaN bounds and a 206 - * with a nonsense Content-Range, which corrupts a resumed download rather - * than merely failing it. - */ -function parseByteRange(header, size) { - if (!header || typeof header !== 'string' || !size) return null; - const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim()); - if (!match) return null; - - const [, rawStart, rawEnd] = match; - if (rawStart === '' && rawEnd === '') return null; - - let start; - let end; - if (rawStart === '') { - // Suffix form: the last N bytes. - const suffix = parseInt(rawEnd, 10); - if (!suffix) return null; - start = Math.max(0, size - suffix); - end = size - 1; - } else { - start = parseInt(rawStart, 10); - end = rawEnd === '' ? size - 1 : parseInt(rawEnd, 10); - } - - if (!Number.isFinite(start) || !Number.isFinite(end)) return null; - if (start > end || start >= size) return null; - return { start, end: Math.min(end, size - 1) }; -} - -// "Gallery opened" for the admin notification bell (#746). The photo-list -// endpoint fires on every gallery page load, so notifying per hit would spam -// the bell — debounce to at most one notification per event per window. The -// map is in-memory on purpose: losing it on restart merely allows one extra -// notification, and it costs the hot view path zero DB reads. -const GALLERY_OPENED_DEBOUNCE_MS = 6 * 60 * 60 * 1000; // 6h -const galleryOpenedNotifiedAt = new Map(); -// #746 covers CLIENT activity too — attribute the actor from the session -// instead of hard-coding 'guest', so a customer opening from the portal -// isn't mislabeled (codex review of #849 round 3). -function galleryActor(req) { - // Portal tokens run as accessLevel 'guest' but carry via:'customer' - // (req.viaCustomer); PIN-client logins carry accessLevel 'client'. - // Both are customers, not guests (codex review of #849, final round). - const isCustomer = !!(req && (req.viaCustomer || req.accessLevel === 'client')); - return { type: isCustomer ? 'customer' : 'guest' }; -} -function notifyGalleryOpened(event, req) { - // Customer-PORTAL opens already log `customer_event_access` on the - // access-token mint — a second `gallery_opened` per portal click would - // double-notify. Keyed on the portal provenance (req.viaCustomer), NOT - // on accessLevel: PIN-client logins are 'client' without any other - // open signal and must keep notifying (codex review of #849, final - // round — the previous check had this inverted). - if (req && req.viaCustomer) return; - const now = Date.now(); - const last = galleryOpenedNotifiedAt.get(event.id) || 0; - if (now - last < GALLERY_OPENED_DEBOUNCE_MS) return; - galleryOpenedNotifiedAt.set(event.id, now); - // Fire-and-forget — logActivity swallows its own errors. - logActivity('gallery_opened', {}, event.id, galleryActor(req)); -} - -// Single-photo saves are frequent (a guest saving 30 photos = 30 route -// hits) — debounce like gallery_opened so the bell gets one "guest is -// downloading photos" signal per event per window instead of a flood -// (codex review of #849). ZIP downloads stay un-debounced: rare, high -// signal. Exact per-photo counts remain in access_logs/analytics. -const SINGLE_DOWNLOAD_DEBOUNCE_MS = 60 * 60 * 1000; // 1h -const singleDownloadNotifiedAt = new Map(); -function notifySinglePhotoDownload(event, req) { - const now = Date.now(); - const last = singleDownloadNotifiedAt.get(event.id) || 0; - if (now - last < SINGLE_DOWNLOAD_DEBOUNCE_MS) return; - singleDownloadNotifiedAt.set(event.id, now); - logActivity('gallery_downloaded', { scope: 'single' }, event.id, galleryActor(req)); -} - -// Check for slug redirect (for renamed events) -async function checkSlugRedirect(slug) { - try { - const hasTable = await db.schema.hasTable('slug_redirects'); - if (!hasTable) return null; - - const redirect = await db('slug_redirects') - .where({ old_slug: slug }) - .first(); - - return redirect ? redirect.new_slug : null; - } catch (error) { - logger.warn('Error checking slug redirect:', { slug, error: error.message }); - return null; - } -} - -// Resolve gallery identifier (slug or token) to canonical data -router.get('/resolve/:identifier', handleAsync(async (req, res) => { - const { identifier } = req.params; - let result = await resolveShareIdentifier(identifier); - - // If not found, check for redirect - if (!result) { - const newSlug = await checkSlugRedirect(identifier); - if (newSlug) { - return res.status(301).json({ - redirect: true, - newSlug, - message: 'Gallery has been renamed' - }); - } - throw new NotFoundError('Gallery'); - } - - const { event, matchType, shareToken } = result; - const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); - - // The share_token is a bearer secret. Only return it (and the share - // links/URLs that embed it) when the caller already proved they hold it — - // i.e. they resolved via the token or the full share link. A bare *slug* - // lookup (slugs appear in gallery URLs and are guessable) must NOT hand - // back the secret, or an anonymous caller could turn a known slug into - // share-link access to a no-password gallery (GHSA-rh8r). - const callerHasToken = matchType !== 'slug'; - if (!callerHasToken) { - return res.json({ slug: event.slug, matchType, requires_password: requiresPassword }); - } - - const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken }); - res.json({ - slug: event.slug, - token: shareToken, - matchType, - share_link: event.share_link, - share_path: linkVariants.sharePath, - share_url: linkVariants.shareUrl, - short_enabled: linkVariants.shortEnabled, - requires_password: requiresPassword - }); -})); - -// Verify share token. no-store: this is an authorization decision — a cached -// `{ valid: true }` would keep answering for a token the admin has rotated. -router.get('/:slug/verify-token/:token', noStoreCache, handleAsync(async (req, res) => { - const { slug, token } = req.params; - - const event = await db('events') - .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false), is_draft: formatBoolean(false) }) - .select('id', 'share_link', 'share_token') - .first(); - - if (!event) { - throw new NotFoundError('Gallery'); - } - - const expectedToken = getEventShareToken(event); - if (!expectedToken || !timingSafeEqualStr(String(token), expectedToken)) { - throw new NotFoundError('Gallery', 'Invalid gallery link'); - } - - res.json({ valid: true }); -})); - -// Get gallery info (with optional token verification) -router.get('/:slug/info', async (req, res) => { - try { - const { slug } = req.params; - const { token } = req.query; - - let event = await db('events') - .where({ slug }) - .select( - 'event_name', - 'event_type', - 'event_date', - 'expires_at', - 'is_active', - 'is_archived', - 'share_link', - 'share_token', - 'allow_downloads', - 'allow_user_uploads', - 'reveal_mode', - 'reveal_at', - 'revealed_at', - 'disable_right_click', - 'watermark_downloads', - 'watermark_text', - 'require_password', - 'color_theme', - 'enable_devtools_protection', - 'use_canvas_rendering', - 'hero_logo_visible', - 'hero_logo_size', - 'hero_logo_position', - 'hero_logo_url', - 'login_logo_visible', - 'header_style', - 'hero_divider_style', - 'hero_image_anchor', - 'is_draft', - 'default_photo_sort', - // Per-event promotional override (#440). Resolution into a - // ready-to-render markdown string happens below so the - // frontend doesn't have to know about modes. - 'promo_mode', - 'promo_markdown', - 'info_mode', - 'info_markdown' - ) - .first(); - - if (!event) { - // Check for redirect - const newSlug = await checkSlugRedirect(slug); - if (newSlug) { - return res.status(301).json({ - redirect: true, - newSlug, - message: 'Gallery has been renamed' - }); - } - return res.status(404).json({ error: 'Gallery not found' }); - } - - // Check if event is archived - if (event.is_archived) { - return res.status(404).json({ error: 'Gallery has been archived and is no longer available' }); - } - - // Admin preview (#868) bypasses both the draft gate and — below — the - // password gate. Computed once and reused. - const adminPreview = await verifyAdminPreview(req); - // Check if event is a draft (allow admin preview) - if (event.is_draft && !adminPreview) { - return res.status(404).json({ error: 'Gallery is not yet published' }); - } - - // If token provided, verify it matches the share link - if (token) { - const expectedToken = getEventShareToken(event); - if (!expectedToken || !timingSafeEqualStr(String(token), expectedToken)) { - return res.status(404).json({ error: 'Invalid gallery link' }); - } - } - - // Admin preview skips the guest password on published, protected galleries - // (#868) — the admin already sees every photo through the admin routes. - const requiresPassword = adminPreview - ? false - : !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); - const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true); - const globalLogoSize = await getAppSetting('branding_logo_size', 'medium'); - - res.json({ - event_name: event.event_name, - event_type: event.event_type, - event_date: event.event_date, - expires_at: event.expires_at, - is_active: event.is_active, - is_expired: !event.is_active || (event.expires_at && new Date(event.expires_at) < new Date()), - requires_password: requiresPassword, - color_theme: event.color_theme, - allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'), - allow_user_uploads: event.allow_user_uploads === true || event.allow_user_uploads === 1 || event.allow_user_uploads === '1', - // Reveal mode (#838): effective hidden state (computed, time-exact) so - // the landing page can hint at the reveal before login too. - hidden_until_reveal: isGalleryHidden(event), - reveal_at: isGalleryHidden(event) ? (event.reveal_at || null) : null, - disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1', - watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1', - watermark_text: event.watermark_text, - enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1', - use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1', - hero_logo_visible: resolveHeroLogoVisible(event.hero_logo_visible, globalHeroLogoVisible), - // #894: only an explicit false hides the logo on the password page; - // NULL keeps the default (show). - login_logo_visible: !(event.login_logo_visible === false || event.login_logo_visible === 0 || event.login_logo_visible === '0'), - // #756: NULL per-event size inherits the global branding_logo_size. - hero_logo_size: event.hero_logo_size || globalLogoSize || 'medium', - hero_logo_position: event.hero_logo_position || 'top', - hero_logo_url: event.hero_logo_url || null, - header_style: event.header_style || 'standard', - hero_divider_style: event.hero_divider_style || 'wave', - hero_image_anchor: event.hero_image_anchor || 'center', - default_photo_sort: event.default_photo_sort || 'upload_date_desc', - // Per-event promotional override (#440). Frontend resolves - // 'inherit' against branding_promo_markdown from public settings. - promo_mode: event.promo_mode || 'inherit', - promo_markdown: event.promo_markdown || null, - // Info banner (#932). Same inherit/custom/off semantics as promo, - // resolved against branding_info_markdown from public settings. - info_mode: event.info_mode || 'inherit', - info_markdown: event.info_markdown || null - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to fetch gallery info'); - } -}); - -// --------------------------------------------------------------------------- -// Live Slideshow ("Diashow") — token-only fullscreen kiosk surface -// (migration 138). The token in the URL IS the secret (no gallery password), -// so these routes are unauthenticated except for the token match itself. The -// slideshow shows ALL public/visible, finished photos — exactly the guest -// set — so once /session mints a short-lived `accessLevel:'slideshow'` JWT, -// the page reuses the normal /photos + image endpoints unchanged. -// --------------------------------------------------------------------------- - -// Photos a slideshow may display: published, finished, non-hidden. Mirrors the -// guest filter in GET /:slug/photos so the live count matches the rendered set. -function slideshowPhotosQuery(eventId, categoryId = null) { - const q = db('photos') - .where('photos.event_id', eventId) - .where(function() { - this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status'); - }) - .where(function() { - this.where('photos.visibility', 'visible').orWhereNull('photos.visibility'); - }); - // Category filter (#202) — keep the /session + /state count in sync with the - // photos the kiosk actually renders. - if (categoryId) q.where('photos.category_id', categoryId); - return q; -} - -// Resolve an active slideshow by slug + token. Returns the event row, or null -// when the link is missing/rotated/disabled or the gallery isn't live (archived -// / draft / inactive / expired) — every one of those collapses to a 404 so a -// dead link reveals nothing and stops any projector on its next poll. -async function resolveSlideshow(slug, token) { - if (!token) return null; - // The `slideshow` feature flag is a master kill-switch: when an admin turns - // Live Slideshow off, every existing /show/ link dies on its next request - // (the running projector stops within one /state poll), not just the admin UI. - if (!(await isFeatureEnabled('slideshow'))) return null; - const event = await db('events') - .where({ - slug, - show_share_token: token, - is_active: formatBoolean(true), - is_archived: formatBoolean(false), - is_draft: formatBoolean(false) - }) - .first(); - if (!event) return null; - if (event.expires_at && new Date(event.expires_at) < new Date()) return null; - return event; -} - -// Resolve the slideshow's live styling, including the ZDF/ARD-ident-style -// watermark (a white, semi-transparent corner logo). The logo URL is resolved -// from the chosen source so the kiosk renders it without knowing about -// branding/event internals; null url = nothing to overlay. -async function slideshowSettings(event, req) { - // The global look/fit (Settings → Slideshow) + branding logo URLs come from a - // short-TTL cached bundle so a 3s projector poll doesn't re-fire ~10 settings - // reads each time (PR #646 review, concern 2). - const g = await getSlideshowGlobals(); - - // Watermark: the LOOK (logo/position/opacity/style/size) is configured ONCE - // globally; it is NOT duplicated per event. The only per-event control is - // whether the watermark shows: `show_watermark` NULL inherits the global - // enabled flag, true/false force it on/off. - const wm = event.show_watermark; - const inherit = (wm === null || wm === undefined); - const enabled = inherit ? g.watermark_enabled : (wm === true || wm === 1 || wm === '1'); - let watermark = null; - if (enabled) { - // Resolve the chosen logo to a URL. Branding assets come from settings; - // the event source uses the event's own hero logo. - let url; - if (g.watermark_source === 'event') { - url = event.hero_logo_url || null; - } else if (g.watermark_source === 'logo_dark') { - url = g.branding_logo_url_dark; - } else if (g.watermark_source === 'favicon') { - url = g.branding_favicon_url; - } else { - url = g.branding_logo_url; - } - if (url) { - watermark = { - url, - position: g.watermark_position, - opacity: g.watermark_opacity, - style: g.watermark_style, - size: g.watermark_size, - }; - } - } - // QR overlay (#837): like the watermark, the LOOK is global-only and the - // per-event `show_qr` tri-state (NULL = inherit) decides visibility. The QR - // encodes the gallery share URL and ships as a data URI so the public - // slideshow client needs no QR library and no extra authenticated endpoint. - const qrOverride = event.show_qr; - const qrInherit = (qrOverride === null || qrOverride === undefined); - const qrEnabled = qrInherit ? g.qr_enabled : (qrOverride === true || qrOverride === 1 || qrOverride === '1'); - let qr = null; - if (qrEnabled) { - const dataUrl = await slideshowQrDataUrl(event, req); - if (dataUrl) { - qr = { - data_url: dataUrl, - position: g.qr_position, - opacity: g.qr_opacity, - size: g.qr_size, - }; - } - } - - return { - interval_ms: event.show_interval_ms || 5000, - transition: event.show_transition || 'crossfade', - transition_ms: event.show_transition_ms || 800, - colorfilter: event.show_colorfilter || 'none', - // Play order (#202): 'chronological' | 'random'. The client shuffles when - // 'random' so live-appended uploads keep working. - order: event.show_order || 'chronological', - fit: g.fit, - watermark, - qr, - }; -} - -// The state endpoint is polled every ~3s per projector — cache the generated -// QR data URI per share URL instead of re-encoding on every poll. Bounded: -// entries live for past events / rotated tokens too, so without eviction the -// map would grow with every share URL ever displayed (codex review of #848). -// Insertion-order eviction is enough — concurrently-shown events stay hot. -const SLIDESHOW_QR_CACHE_MAX = 50; -// Keyed by event id (NOT by URL): the origin is caller-influenced when the -// configured base is loopback, so URL-keyed caching would let a slideshow -// -link holder force a fresh QRCode.toDataURL per request with unique -// origins — a cheap CPU-exhaustion path (codex review of #848, -// confirmation round). Per-event entries + a regeneration throttle bound -// the encode rate regardless of what the caller sends. -const SLIDESHOW_QR_REGEN_MS = 60_000; -const slideshowQrCache = new Map(); // eventId -> { url, dataUrl, at } -// Localhost/relative guard (codex review of #848): with the compose-default -// FRONTEND_URL=http://localhost:3000 (or none configured) the QR would send -// scanning phones to THEIR localhost. The state poll comes from the kiosk -// browser itself, so its Host header + protocol are exactly the public -// origin guests can reach — prefer that whenever the configured base is -// missing or loopback. trust proxy is configured, so req.protocol respects -// X-Forwarded-Proto behind the standard reverse-proxy setups. -// Centralised in utils/frontendUrl (#705) so the QR path and the public-origin -// resolver agree on what counts as a non-shareable base. -const QR_LOCAL_BASE_RE = { test: (v) => require('../utils/frontendUrl').isLoopbackBase(v) }; -const QR_ORIGIN_RE = /^https?:\/\/[^\s/]+$/i; -async function slideshowQrDataUrl(event, req) { - try { - const shareToken = getEventShareToken(event); - if (!shareToken) return null; - let { shareUrl, sharePath } = await buildShareLinkVariants({ slug: event.slug, shareToken }); - if (!/^https?:\/\//i.test(shareUrl) || QR_LOCAL_BASE_RE.test(shareUrl)) { - // Prefer the kiosk's own window.location.origin (?origin=, validated): - // req.get('host') is NOT the browser origin behind the standard - // proxies — frontend/nginx.conf forwards $host (port stripped), so a - // compose LAN deployment on :3000 would encode port 80. A LOOPBACK - // kiosk origin is rejected too: it is no more guest-reachable than - // the loopback base it would replace (codex review of #848). - const rawOrigin = req?.query?.origin; - const queryOrigin = typeof rawOrigin === 'string' && QR_ORIGIN_RE.test(rawOrigin) && !QR_LOCAL_BASE_RE.test(rawOrigin) - ? rawOrigin.replace(/\/$/, '') - : null; - const host = req && req.get ? req.get('host') : null; - const hostOrigin = host ? `${req.protocol}://${host}` : null; - if (queryOrigin) shareUrl = `${queryOrigin}${sharePath}`; - else if (hostOrigin && !QR_LOCAL_BASE_RE.test(hostOrigin)) shareUrl = `${hostOrigin}${sharePath}`; - // Still loopback/relative → no reachable URL exists; suppress the - // overlay rather than encode a QR that sends phones to localhost. - else return null; - } - - const cached = slideshowQrCache.get(event.id); - if (cached && cached.url === shareUrl) return cached.dataUrl; - // URL differs from the cached one: NEVER serve the mismatched artifact — - // a slideshow-token holder could otherwise poison the projector's QR - // with an attacker origin for a whole throttle window (codex review of - // #848, final round). Inside the window the overlay is briefly - // suppressed instead; regeneration stays bounded per event. - if (cached && Date.now() - cached.at < SLIDESHOW_QR_REGEN_MS) { - return cached.pending ? cached.dataUrl : null; - } - // Single-flight: concurrent polls on a cold cache must not each - // schedule their own 512px encode — reserve the entry with a shared - // promise before awaiting. - if (cached && cached.pending && cached.url === shareUrl) return cached.pending; - const QRCode = require('qrcode'); - const entry = { url: shareUrl, dataUrl: null, at: Date.now(), pending: null }; - entry.pending = QRCode.toDataURL(shareUrl, { width: 512, margin: 4 }).then((dataUrl) => { - entry.dataUrl = dataUrl; - entry.pending = null; - return dataUrl; - }).catch((e) => { - slideshowQrCache.delete(event.id); - throw e; - }); - if (!slideshowQrCache.has(event.id) && slideshowQrCache.size >= SLIDESHOW_QR_CACHE_MAX) { - slideshowQrCache.delete(slideshowQrCache.keys().next().value); - } - slideshowQrCache.set(event.id, entry); - return await entry.pending; - } catch (e) { - logger.error('Slideshow QR generation failed:', e); - return null; - } -} - -// Open a slideshow session: validate the token and mint a short-lived gallery -// JWT scoped to `accessLevel:'slideshow'` (treated as a guest by the photo / -// image endpoints → visible photos only, no client-only/hidden). The page -// stores this token and the existing axios interceptor injects it. -// no-store: this response *is* a credential (it mints a gallery JWT and sets -// the per-slug auth cookie), so it must never be retained anywhere. -router.get('/:slug/show/:token/session', noStoreCache, handleAsync(async (req, res) => { - const { slug, token } = req.params; - const event = await resolveSlideshow(slug, token); - if (!event) { - throw new NotFoundError('Slideshow'); - } - - const sessionToken = jwt.sign({ - eventId: event.id, - eventSlug: event.slug, - type: 'gallery', - accessLevel: 'slideshow', - loginTime: Date.now() - }, process.env.JWT_SECRET, { - expiresIn: '12h', - issuer: 'picpeak-auth' - }); - - // tags can't carry an Authorization header, so the photo/thumbnail/ - // preview endpoints authenticate via the per-slug gallery cookie. Set it - // here so the kiosk's image requests are authorized with zero extra wiring. - setGalleryAuthCookies(res, sessionToken, event.slug); - - const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count'); - - res.json({ - token: sessionToken, - event: { - event_name: event.event_name, - event_type: event.event_type, - color_theme: event.color_theme - }, - settings: await slideshowSettings(event, req), - photo_count: parseInt(count, 10) || 0, - expires_at: event.expires_at || null - }); -})); - -// Cheap live-poll endpoint (tiny payload, hit every ~3s by the running show): -// current settings + the visible photo count. The page diffs photo_count to -// decide when to refetch the full list, and re-reads settings so admin changes -// take effect live. A dead/disabled link 404s here → the projector stops. -router.get('/:slug/show/:token/state', noStoreCache, handleAsync(async (req, res) => { - const { slug, token } = req.params; - const event = await resolveSlideshow(slug, token); - if (!event) { - throw new NotFoundError('Slideshow'); - } - - const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count'); - - res.json({ - ...(await slideshowSettings(event, req)), - photo_count: parseInt(count, 10) || 0, - expires_at: event.expires_at || null - }); -})); - -// Get all photos. -// -// no-store (B6): the payload is private and per-guest — it carries the -// viewer's own likes/favorites/ratings and, for a client token, photos hidden -// from plain guests. With no Cache-Control at all a browser applies heuristic -// freshness and may reuse a body it stored on disk, on a shared device, for a -// gallery whose password has since been rotated. Express still computes its -// weak ETag, so a caller that does revalidate (React Query's own in-memory -// cache is unaffected either way) still gets a correct 304. -router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, noStoreCache, async (req, res) => { - try { - // Get filter and sort parameters from query - // `guest_id` is deliberately NOT read from the query string: the viewer's - // own feedback is resolved from the request identity instead (see the - // filter block). The frontend still sends it; it is ignored. - const { filter, sort = 'upload_date', order = 'desc' } = req.query; - - // Get watermark settings to generate cache-busting version for URLs - const watermarkSettings = await watermarkService.getWatermarkSettings(); - const wmVersion = watermarkSettings?.enabled - ? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}` - : ''; - - // Build the query with sorting - const sortOrder = order === 'asc' ? 'asc' : 'desc'; - const isClient = req.accessLevel === 'client'; - let photosQuery = db('photos') - .where('photos.event_id', req.event.id) - // Guests/clients never see photos still being processed by the - // background worker — the original is on disk but the thumbnail - // / dimensions / EXIF haven't landed yet. Photos with a NULL - // processing_status are pre-async-migration rows and are treated - // as complete (the migration's column default is 'complete' so - // this is just defensive against partial migration states). - .where(function() { - this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status'); - }) - .select('photos.*'); - - // Guests only see visible photos; clients see all - if (!isClient) { - photosQuery = photosQuery.where(function() { - this.where('photos.visibility', 'visible').orWhereNull('photos.visibility'); - }); - } - - // Live Slideshow category filter (#202). Enforced server-side so the kiosk - // viewer can't widen the set: when the event pins show_category_id, the - // slideshow only sees that category. NULL = all photos (unchanged). - if (req.accessLevel === 'slideshow' && req.event.show_category_id) { - photosQuery = photosQuery.where('photos.category_id', req.event.show_category_id); - } - - // Apply sort option. - // - // Every branch carries photos.id as a tiebreaker (#1172). Without one the - // order within a tie is whatever the engine happens to return, and ties are - // the normal case rather than the exception: a bulk import writes hundreds - // of rows inside the same second, so uploaded_at collapses — and with - // captured_at NULL the COALESCE below collapses onto it too. The visible - // symptom is a grid that reshuffles between page loads. id is insertion - // order, so it also makes the fallback ordering meaningful rather than - // arbitrary. - if (sort === 'capture_date') { - // Sort by capture date, falling back to uploaded_at if capture date is null. - // - // On SQLite that fallback cannot be a plain COALESCE, because the two - // columns do not hold one type. photos.captured_at ends up carrying three - // different storage classes: - // - // integer managed uploads — photoProcessor.js:488 writes a Date, which - // the sqlite3 binding stores as epoch milliseconds - // text external imports and the backfill, which write ISO-8601 - // ('2026-06-03T01:15:00.000Z') per the CLAUDE.md rule that - // Dates must not be handed to the binding in tests - // null no capture date, so the sort falls through to uploaded_at — - // usually text in knex's 'YYYY-MM-DD HH:MM:SS' default shape, - // but epoch milliseconds on rows written by a legacy archive - // restore (see __tests__/integration/sqliteEpochTimestamps.js), - // so that column needs the same two branches - // - // SQLite orders INTEGER before TEXT unconditionally, so every managed - // photo carrying EXIF sorted ahead of every photo that did not, whatever - // the actual dates — a 2027 capture landing before a 2020 one. Among the - // text values the 'T' separator (0x54) also outranks the space (0x20), so - // a same-day ISO 01:15 sorted after a fallback 23:00. - // - // Normalising in the ORDER BY rather than rewriting the column: the data - // fix would have to touch every existing row and every writer, which is a - // much heavier change than the sort it is meant to correct. The cost here - // is that this sort stops using idx_photos_captured_at on SQLite — an - // acceptable trade on the fallback engine, where the alternative is an - // index-assisted wrong answer. - // - // Postgres is untouched: captured_at is a real timestamp there, so - // COALESCE already compares correctly. - if (db.client.config.client === 'pg') { - photosQuery = photosQuery - .orderByRaw('COALESCE(photos.captured_at, photos.uploaded_at) ' + sortOrder); - } else { - photosQuery = photosQuery.orderByRaw(`CASE - WHEN typeof(photos.captured_at) IN ('integer', 'real') THEN datetime(photos.captured_at / 1000, 'unixepoch') - WHEN photos.captured_at IS NOT NULL THEN replace(replace(substr(photos.captured_at, 1, 19), 'T', ' '), 'Z', '') - WHEN typeof(photos.uploaded_at) IN ('integer', 'real') THEN datetime(photos.uploaded_at / 1000, 'unixepoch') - ELSE substr(photos.uploaded_at, 1, 19) - END ${sortOrder}`); - } - photosQuery = photosQuery.orderBy('photos.id', sortOrder); - } else if (sort === 'filename') { - photosQuery = photosQuery.orderBy('photos.filename', sortOrder).orderBy('photos.id', sortOrder); - } else { - // Default: sort by upload date - photosQuery = photosQuery.orderBy('photos.uploaded_at', sortOrder).orderBy('photos.id', sortOrder); - } - - // Reveal mode (#838): while the gallery is hidden, plain guests get - // the event shell with an empty photo/category set plus the - // hidden_until_reveal flag — the frontend renders the upload-only view - // from it. Slideshow, client access and the admin preview bypass - // (guestBlockedByReveal). Enforced here, not just in the UI. - const hiddenForGuest = guestBlockedByReveal(req); - - // Execute the query - let photos = hiddenForGuest ? [] : await photosQuery; - - // Check if feedback should be visible to guests. Read BEFORE the filter - // block, not after: the filters below consult it, because a filter that - // selects on other people's feedback is a way of reading that feedback. - const feedbackService = require('../services/feedbackService'); - const feedbackSettings = await feedbackService.getEventFeedbackSettings(req.event.id); - const showFeedbackToGuests = isClient || parseBooleanInput(feedbackSettings.show_feedback_to_guests, true); - // One identity-less colour tag per photo, any guest may overwrite it - // (#1197). Read in three places below: the colour filters, the per-viewer - // badge, and the "other viewers" dots that must not double-render it. - const sharedColorMode = feedbackSettings?.identity_mode === 'shared'; - - // Apply filtering if requested (supports global stats + per-guest interactions) - if (filter) { - const filterTokens = new Set( - String(filter) - .toLowerCase() - .split(',') - .map(token => token.trim()) - .filter(Boolean) - ); - - if (filterTokens.size > 0) { - // Treat "saved" / "favorite" synonyms as favorites - if (filterTokens.has('saved')) { - filterTokens.add('favorited'); - } - if (filterTokens.has('favorite')) { - filterTokens.add('favorited'); - } - - const include = new Set(); - - const includeBy = (predicate) => { - photos.forEach(photo => { - if (predicate(photo)) { - include.add(photo.id); - } - }); - }; - - // Whose feedback counts as "mine" for these filters. - // - // Resolved from the REQUEST, the same either/or the per-viewer - // is_liked and my_color_label queries below use — never from the - // `guest_id` query parameter. Two reasons, and both matter now that - // this is the only half left when feedback is hidden: - // - // - It never matched. The frontend's `gallery_guest_id` is a - // localStorage string it invents (`guest__`) and never - // sends when submitting feedback; submissions store - // generateGuestIdentifier(req). So this lookup found nothing, and - // the filters only ever worked through the aggregate half — which - // is exactly the half now gated. - // - It is caller-controlled. Accepting an identifier from the query - // string would let anyone holding someone else's read their hidden - // memberships one token at a time, straight back through the gate. - let guestFeedbackByType = null; - let guestColorLabels = null; - { - // Hidden rows are excluded, matching what the viewer can actually - // SEE: getPhotoFeedback drops is_hidden for the guest's own feedback - // too, so without this a photo could come back under - // `?filter=commented` with no comment visible on it. Unapproved rows - // are NOT excluded — a comment still in the moderation queue is - // still the viewer's own, and the same guest-own read keeps it. - const viewerFeedback = db('photo_feedback') - .where({ event_id: req.event.id, is_hidden: false }); - if (req.guest?.id) { - viewerFeedback.where('guest_id', req.guest.id); - } else { - viewerFeedback.where('guest_identifier', generateGuestIdentifier(req)); - } - const guestFeedbackRows = await viewerFeedback - .select('photo_id', 'feedback_type', 'color_label'); - - guestFeedbackByType = guestFeedbackRows.reduce((acc, row) => { - if (!acc[row.feedback_type]) { - acc[row.feedback_type] = new Set(); - } - acc[row.feedback_type].add(row.photo_id); - return acc; - }, {}); - - // Colour filters are per-COLOUR, not just per-type (#1044) — "show - // me my greens" needs the value, which the type map above discards. - guestColorLabels = guestFeedbackRows.reduce((acc, row) => { - if (row.feedback_type !== 'color_label' || !row.color_label) return acc; - if (!acc[row.color_label]) acc[row.color_label] = new Set(); - acc[row.color_label].add(row.photo_id); - return acc; - }, {}); - - // In shared mode the query above finds nothing for colours — the tag - // is not filed under this viewer — so "my greens" is answered from - // the shared rows instead (#1197). It belongs in the viewer's half - // rather than the aggregate half below, which is gated on - // show_feedback_to_guests: the shared tag is this viewer's tag, and - // a gallery with sharing off would otherwise show colours on the - // tiles while `color:green` returned nothing. - if (sharedColorMode) { - const sharedRows = await db('photo_feedback') - .where({ - event_id: req.event.id, - feedback_type: 'color_label', - guest_identifier: SHARED_COLOR_LABEL_IDENTITY, - is_hidden: false, - }) - .whereNotNull('color_label') - .select('photo_id', 'color_label'); - guestColorLabels = sharedRows.reduce((acc, row) => { - if (!acc[row.color_label]) acc[row.color_label] = new Set(); - acc[row.color_label].add(row.photo_id); - return acc; - }, {}); - } - } - - const includeGuestMatches = (type) => { - const ids = guestFeedbackByType?.[type]; - if (ids && ids.size > 0) { - ids.forEach(id => include.add(id)); - } - }; - - // Every token below is an OR of two halves: what THIS viewer marked, - // and what ANYONE marked. The second half is other people's feedback, - // so it is gated on show_feedback_to_guests exactly like the counts - // this endpoint returns. - // - // Without the gate the setting only hides the numbers. A guest could - // still send `?filter=liked` and get back precisely the set of photos - // other people liked — the membership, one token at a time, which is - // most of what the counts would have told them. The viewer's own half - // is always theirs to filter by. - const includeAggregate = (predicate) => { - if (showFeedbackToGuests) includeBy(predicate); - }; - - if (filterTokens.has('liked')) { - includeGuestMatches('like'); - includeAggregate(photo => (photo.like_count || 0) > 0); - } - - if (filterTokens.has('favorited')) { - includeGuestMatches('favorite'); - includeAggregate(photo => (photo.favorite_count || 0) > 0); - } - - if (filterTokens.has('rated')) { - includeGuestMatches('rating'); - includeAggregate(photo => (photo.average_rating || 0) > 0); - } - - // Colour-label filters (#1044), one token per colour: `color:green`. - // Same OR-of-guest-and-aggregate shape as the sibling tokens above: - // the guest's own labels of that colour, plus anyone's. (The public - // gallery narrows to "my greens" client-side from `my_color_label`, - // which is per-viewer by construction.) - const requestedColors = COLOR_LABELS.filter(color => filterTokens.has(`color:${color}`)); - if (requestedColors.length > 0) { - for (const color of requestedColors) { - guestColorLabels?.[color]?.forEach(id => include.add(id)); - } - if (showFeedbackToGuests) { - const aggregateColors = db('photo_feedback') - .where({ event_id: req.event.id, feedback_type: 'color_label', is_hidden: false }) - .whereIn('color_label', requestedColors); - // Only the set the mode is actually using (#1197). A dormant - // per-guest label left behind by a switch would otherwise pull its - // photo into a colour filter while the tile shows no such colour. - if (sharedColorMode) { - aggregateColors.where('guest_identifier', SHARED_COLOR_LABEL_IDENTITY); - } else { - aggregateColors.where(function () { - this.whereNot('guest_identifier', SHARED_COLOR_LABEL_IDENTITY).orWhereNull('guest_identifier'); - }); - } - const colorRows = await aggregateColors.select('photo_id'); - colorRows.forEach(row => include.add(row.photo_id)); - } - } - - if (filterTokens.has('commented')) { - includeGuestMatches('comment'); - if (showFeedbackToGuests) { - const commentedRows = await db('photo_feedback') - .where({ event_id: req.event.id, feedback_type: 'comment', is_approved: true, is_hidden: false }) - .groupBy('photo_id') - .select('photo_id'); - commentedRows.forEach(row => include.add(row.photo_id)); - } - } - - photos = photos.filter(photo => include.has(photo.id)); - } - } - - // Then get comment counts separately - const commentCounts = await db('photo_feedback') - .whereIn('photo_id', photos.map(p => p.id)) - .where('feedback_type', 'comment') - .where('is_approved', true) - .where('is_hidden', false) - .groupBy('photo_id') - .select('photo_id', db.raw('COUNT(*) as comment_count')); - - // Create a map for quick lookup - const commentMap = {}; - commentCounts.forEach(c => { - commentMap[c.photo_id] = parseInt(c.comment_count); - }); - - // Per-viewer "is_liked" set (#590 follow-up). Hard refresh on the - // gallery grid used to reset every heart to empty because the lifted - // likedPhotoIds state started as a fresh Set on mount — even photos - // the viewer had actually liked. Surface a per-viewer flag so the - // frontend can seed correctly. Prefers req.guest.id when a verified - // guest token is present (per-person identity), falls back to the - // IP+UA hash that the original like was recorded under — same model - // the /my-feedback endpoint uses. - // - // NOT gated on showFeedbackToGuests (#1286). This query is filtered to - // the VIEWER — by guest_id or by their own identifier — so what it - // returns is their own selection, not shared aggregate data. Gating it - // emptied every heart the guest had set themselves on a gallery with - // sharing off, which reads as the gallery silently discarding their - // choices. Same reasoning the colour-label block below already applies; - // this was the one per-viewer field that disagreed with it. - const likedPhotoIds = new Set(); - if (photos.length > 0) { - const likeQuery = db('photo_feedback') - // Hidden rows are not there, for the viewer's OWN feedback as much as - // anyone's (#1150). getPhotoFeedback drops them, the filter drops them - // and updatePhotoFeedbackStats does not count them — leaving the heart - // filled was the one place that disagreed, so a like the photographer - // had hidden still showed as liked on a photo whose like_count was 0. - .where({ event_id: req.event.id, feedback_type: 'like', is_hidden: false }) - .whereIn('photo_id', photos.map(p => p.id)); - if (req.guest?.id) { - likeQuery.where('guest_id', req.guest.id); - } else { - likeQuery.where('guest_identifier', generateGuestIdentifier(req)); - } - const likedRows = await likeQuery.select('photo_id'); - likedRows.forEach(row => likedPhotoIds.add(row.photo_id)); - } - - // Per-viewer colour label (#1044), same identity resolution as the likes - // above. NOT gated on showFeedbackToGuests: a guest's own label is their - // own selection, not shared aggregate data, and hiding it would blank the - // grid badges on every refresh in a gallery with sharing switched off. - // - // In shared identity mode (#1197) there is no per-viewer label to read: - // the photo carries one tag and it belongs to everyone, so it arrives on - // this same field. The badge, the lightbox swatch and the keyboard - // shortcuts then work unchanged — they were already reading "the colour on - // this photo, from my point of view", which is precisely what the shared - // tag is. - const myColorLabelByPhoto = {}; - if (photos.length > 0 && sharedColorMode) { - Object.assign( - myColorLabelByPhoto, - await feedbackService.getSharedColorLabels(req.event.id, photos.map(p => p.id)), - ); - } else if (photos.length > 0) { - const colorQuery = db('photo_feedback') - // Same rule as the heart above (#1150). - .where({ event_id: req.event.id, feedback_type: 'color_label', is_hidden: false }) - .whereIn('photo_id', photos.map(p => p.id)); - if (req.guest?.id) { - colorQuery.where('guest_id', req.guest.id); - } else { - colorQuery.where('guest_identifier', generateGuestIdentifier(req)); - } - const colorRows = await colorQuery.select('photo_id', 'color_label'); - colorRows.forEach(row => { - if (row.color_label) myColorLabelByPhoto[row.photo_id] = row.color_label; - }); - } - - // OTHER viewers' colour labels, per photo (#1178). - // - // The lightbox has always shown these — /photos/:id/feedback returns - // per-colour tallies across everyone — but the grid had no field carrying - // them, so a label set by one guest was visible in fullscreen and invisible - // on the tile. With sharing on, that is just a hole. - // - // DISTINCT colours, not counts: a tile has room for a couple of dots, and - // "who else marked this, and how" is a lightbox question. The viewer's own - // colour is excluded here so the badge and the dots never say the same - // thing twice — the frontend renders `my_color_label` as the badge and - // these beside it. - // - // Gated on showFeedbackToGuests, like every other aggregate: this is other - // people's feedback, unlike my_color_label above. - // - // Skipped entirely in shared mode (#1197). There are no other viewers' - // labels there — there is one tag, already delivered as my_color_label - // above. Without this the shared row would come back here too (its - // reserved identity is not the viewer's), and every tile would render the - // same colour twice: once as the badge, once as a dot beside it. - const otherColorLabelsByPhoto = {}; - if (photos.length > 0 && showFeedbackToGuests && !sharedColorMode) { - const othersQuery = db('photo_feedback') - .where({ event_id: req.event.id, feedback_type: 'color_label', is_hidden: false }) - .whereIn('photo_id', photos.map(p => p.id)) - .whereNotNull('color_label') - // The other direction of the same rule (#1197): an event switched back - // out of shared mode keeps its shared tag, and it is nobody's — so - // without this it would show up as an anonymous other viewer's dot on - // every tile that still carries one. - .where(function () { - this.whereNot('guest_identifier', SHARED_COLOR_LABEL_IDENTITY).orWhereNull('guest_identifier'); - }); - if (req.guest?.id) { - othersQuery.where(function () { - this.whereNot('guest_id', req.guest.id).orWhereNull('guest_id'); - }); - } else { - const mine = generateGuestIdentifier(req); - othersQuery.where(function () { - this.whereNot('guest_identifier', mine).orWhereNull('guest_identifier'); - }); - } - const otherRows = await othersQuery.distinct('photo_id', 'color_label'); - otherRows.forEach(row => { - if (!otherColorLabelsByPhoto[row.photo_id]) otherColorLabelsByPhoto[row.photo_id] = []; - if (!otherColorLabelsByPhoto[row.photo_id].includes(row.color_label)) { - otherColorLabelsByPhoto[row.photo_id].push(row.color_label); - } - }); - } - - // People in each photo (#1074). Two independent gates: the feature must - // be on for this event AND, for a plain guest, the photographer must have - // left the strip visible. A client (PIN access) is the photographer's own - // view, so faces_visible_to_guests doesn't restrict them. - // - // `photos` is already visibility-filtered above, and this only ever asks - // about ids in that set, so it cannot widen what the caller sees. - let peopleEnabled = false; - let personIdsByPhoto = new Map(); - try { - const { isEnabledForEvent, areFacesVisibleToGuests } = require('../services/faceSettings'); - if (photos.length > 0 && await isEnabledForEvent(req.event)) { - peopleEnabled = isClient || areFacesVisibleToGuests(req.event); - if (peopleEnabled) { - const { getPersonIdsByPhoto } = require('../services/facePeopleService'); - personIdsByPhoto = await getPersonIdsByPhoto( - req.event.id, - photos.map(p => p.id), - { forAdmin: isClient } - ); - } - } - } catch (err) { - // A face-feature failure must never take down the gallery payload. - logger.warn(`gallery: person_ids lookup failed for event ${req.event.id}`, { error: err.message }); - peopleEnabled = false; - personIdsByPhoto = new Map(); - } - - // Get actual categories used by photos in this event - // This includes both global categories and event-specific ones - const usedCategoryIds = hiddenForGuest ? [] : await db('photos') - .where('event_id', req.event.id) - .whereNotNull('category_id') - .distinct('category_id') - .pluck('category_id'); - - // Fetch category details from photo_categories table - let categories = []; - if (usedCategoryIds.length > 0) { - // Resolved category order (#782): per-event override, else global - // default, else name — restricted to categories that have photos. - const categoryDetails = await getEventCategoriesOrdered(req.event.id, { - onlyIds: usedCategoryIds, - select: ['c.id', 'c.name', 'c.slug', 'c.is_global', 'c.hero_photo_id', 'c.allow_downloads', 'c.is_folder'], - }); - - categories = categoryDetails.map(cat => ({ - id: cat.id, - name: cat.name, - slug: cat.slug, - is_global: cat.is_global, - hero_photo_id: cat.hero_photo_id || null, - // Per-category download flag (#640). false explicitly disables; the - // gallery hides the download button. Defaults true so categories - // created before migration 135 keep working. - allow_downloads: parseBooleanInput(cat.allow_downloads, true), - // Folder vs filter (#1160). true = the category CONTAINS its photos: - // they leave the root grid and only render inside the folder. Defaults - // false so categories predating migration 185 keep filtering. - is_folder: parseBooleanInput(cat.is_folder, false) - })); - } - - // Build a map for quick category lookup - const categoryMap = {}; - categories.forEach(cat => { - categoryMap[cat.id] = cat; - }); - - // Log view — but NOT for the Live Slideshow kiosk. A running projector - // refetches this list on every new-upload poll, which would massively - // inflate total_views / unique_visitors. The slideshow is explicitly - // excluded from real visitor analytics (migration 138 design). - // Admin preview (#868) is excluded from guest analytics + the "gallery - // opened" bell — it's the photographer looking at their own gallery. - if (req.accessLevel !== 'slideshow' && !req.isAdminPreview) { - await db('access_logs').insert({ - event_id: req.event.id, - ip_address: req.ip, - user_agent: req.headers['user-agent'], - action: 'view' - }); - notifyGalleryOpened(req.event, req); - } - - // Include protection settings in response - const protectionSettings = { - protection_level: req.event.protection_level || 'standard', - image_quality: req.event.image_quality || 85, - use_canvas_rendering: parseBooleanInput(req.event.use_canvas_rendering, false), - overlay_protection: parseBooleanInput(req.event.overlay_protection, true) - }; - - // Lightbox preview tier (#492). When the admin opts in, the - // photos response carries a preview_url alongside url/thumbnail_url - // — the lightbox uses preview_url when present and falls back to - // url when not, so existing galleries continue working before - // any preview has actually been generated. - let lightboxPreviewEnabled = false; - try { - const setting = await db('app_settings') - .where('setting_key', 'lightbox_preview_enabled') - .first(); - if (setting) { - const raw = setting.setting_value; - // setting_value is JSON-stringified per migration 104; tolerate - // raw boolean/string for forward-compat. - const parsed = typeof raw === 'string' ? (() => { - try { return JSON.parse(raw); } catch { return raw; } - })() : raw; - lightboxPreviewEnabled = parsed === true || parsed === 'true' || parsed === 1; - } - } catch (e) { - // Setting missing / DB blip → fall back to off so the lightbox - // keeps working with the original. logger.debug to avoid noise. - logger.debug('lightbox_preview_enabled lookup failed, treating as off', { error: e?.message }); - } - - // #508: when the admin has flipped the "use original camera filenames" - // toggle (#493), the lightbox surfaces each photo's original_filename - // alongside the position counter so the photographer can map a guest's - // selection back to source files. Tied to the same toggle as downloads — - // one switch controls both surfaces. - const useOriginalFilenames = await getUseOriginalFilenames(); - const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true); - const globalLogoSize = await getAppSetting('branding_logo_size', 'medium'); - const downloadPolicy = await resolveEventDownloadPolicy(req.event); - - res.json({ - event: { - id: req.event.id, - event_name: req.event.event_name, - event_type: req.event.event_type, - event_date: req.event.event_date, - welcome_message: req.event.welcome_message, - color_theme: req.event.color_theme, - expires_at: req.event.expires_at, - hero_photo_id: req.event.hero_photo_id, - // Defaults match /info: downloads on unless explicitly disabled, - // uploads off unless explicitly enabled (#1028). - allow_downloads: parseBooleanInput(req.event.allow_downloads, true), - allow_user_uploads: parseBooleanInput(req.event.allow_user_uploads, false), - // Download resolutions (#858). `choices` drives the picker modal and is - // empty when the picker is off, so the UI can never offer a size the - // server would reject. - download_resolution: { - standard: downloadPolicy.standard, - picker_enabled: downloadPolicy.pickerEnabled, - choices: downloadPolicy.pickerEnabled ? downloadPolicy.choices : [], - }, - // Reveal mode (#838): armed flag lets an open VISIBLE gallery keep - // polling so a re-hide propagates without a manual reload. - reveal_armed: parseBooleanInput(req.event.reveal_mode, false), - disable_right_click: parseBooleanInput(req.event.disable_right_click, false), - watermark_downloads: parseBooleanInput(req.event.watermark_downloads, false), - watermark_text: req.event.watermark_text, - enable_devtools_protection: parseBooleanInput(req.event.enable_devtools_protection, false), - use_canvas_rendering: parseBooleanInput(req.event.use_canvas_rendering, false), - hero_logo_visible: resolveHeroLogoVisible(req.event.hero_logo_visible, globalHeroLogoVisible), - hero_logo_size: req.event.hero_logo_size || globalLogoSize || 'medium', - hero_logo_position: req.event.hero_logo_position || 'top', - hero_logo_url: req.event.hero_logo_url || null, - header_style: req.event.header_style || 'standard', - hero_divider_style: req.event.hero_divider_style || 'wave', - hero_image_anchor: req.event.hero_image_anchor || 'center', - default_photo_sort: req.event.default_photo_sort || 'upload_date_desc', - // Promo banner override (#440). GalleryView has always read - // promo_mode from THIS payload, but it was never sent — so every - // per-event promo override silently resolved to 'inherit' and a - // gallery set to 'off' still showed the global banner. - promo_mode: req.event.promo_mode || 'inherit', - promo_markdown: req.event.promo_markdown || null, - // Info banner override (#932). GalleryAuthContext refreshes its cached - // event from THIS payload, so the fields have to travel here — /info - // alone isn't enough, the context stops reading it once the guest is - // authenticated. - info_mode: req.event.info_mode || 'inherit', - info_markdown: req.event.info_markdown || null, - download_zip_ready: !!(req.event.download_zip_path && req.event.download_zip_generated_at), - // Mirror of the admin-side toggle so the lightbox can decide - // whether to surface original camera filenames (#508). - use_original_filenames: useOriginalFilenames, - // "People in this gallery" (#1074). False whenever the global flag - // is off, detection is off for this event, or the photographer chose - // to keep the strip to themselves — the frontend renders no face UI - // at all in that case. - people_enabled: peopleEnabled, - ...protectionSettings - }, - // Reveal mode (#838): the guest UI switches to the upload-only view - // on this flag; reveal_at lets it show the scheduled time. - hidden_until_reveal: hiddenForGuest, - reveal_at: hiddenForGuest ? (req.event.reveal_at || null) : undefined, - categories: categories, - photos: photos.map(photo => { - const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard'); - // Watermark version (cache-busting) + admin-preview flag (#868). In - // preview mode no gallery cookie is minted, so each request must - // re-assert the admin session — thread the flag onto every /api/gallery - // image URL so the browser sends it (the admin_token cookie rides along - // same-origin). - const imgQuery = [wmVersion, req.isAdminPreview ? 'admin_preview=1' : ''].filter(Boolean).join('&'); - const wmQuery = imgQuery ? `?${imgQuery}` : ''; - const photoUrl = useJwtUrl ? - `/api/gallery/${req.params.slug}/photo/${photo.id}${wmQuery}` : - `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`; - - return { - id: photo.id, - filename: photo.filename, - // Raw camera filename (or null for pre-migration-062 uploads). - // The lightbox renders it when `use_original_filenames` is on. - original_filename: photo.original_filename || null, - url: photoUrl, - thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}${wmQuery}` : null, - // Hero-optimized image URL (1920x1080) for full-width hero sections - hero_url: `/api/gallery/${req.params.slug}/hero/${photo.id}${wmQuery}`, - // Lightbox preview URL (#492). Only emitted when the admin - // has flipped lightbox_preview_enabled — the frontend - // lightbox reads preview_url with a fallback to url so - // installs that haven't opted in keep loading the original - // (current behaviour). Skipped for videos since they don't - // get a preview tier; lightbox will use the original .url. - preview_url: (lightboxPreviewEnabled || originalNeedsPreview(photo)) - && photo.media_type !== 'video' - && (!photo.mime_type || !photo.mime_type.startsWith('video/')) - ? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}` - : null, - // Slideshow source (#1015). Same preview tier, but emitted - // unconditionally: the slideshow has no `url` fallback worth - // taking (originals are projector-sized) and must never land on - // `hero_url`, which is cover-cropped to 16:9 — that made the - // "no crop" fit letterbox an already-cropped frame. The preview - // route generates lazily and redirects to the original on any - // failure, so this is safe even where no preview exists yet. - slideshow_url: photo.media_type !== 'video' - && (!photo.mime_type || !photo.mime_type.startsWith('video/')) - ? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}` - : null, - secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`, - download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`, - type: photo.type, - category_id: photo.category_id || null, - category_name: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].name : null, - // Per-category download permission (#640). Defaults true for photos - // without a category or for categories that pre-date migration 135. - category_allow_downloads: photo.category_id && categoryMap[photo.category_id] - ? parseBooleanInput(categoryMap[photo.category_id].allow_downloads, true) - : true, - category_slug: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].slug : null, - size: photo.size_bytes, - // toIso: on SQLite installs rows written with a raw Date (e.g. - // the pre-fix archive-restore path) hold epoch numbers — the - // Timeline layout's parseISO() crashes on those (#485 class). - uploaded_at: toIso(photo.uploaded_at), - // Image dimensions for layout calculations - width: photo.width || null, - height: photo.height || null, - // Fixed: Use the calculated useJwtUrl variable instead of recalculating - requires_token: !useJwtUrl, - // EXIF capture date - captured_at: toIso(photo.captured_at) || null, - // Media type - media_type: photo.media_type || null, - mime_type: photo.mime_type || null, - duration: photo.duration || null, - // Feedback data (hidden when show_feedback_to_guests is disabled) - has_feedback: showFeedbackToGuests ? (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0) : false, - average_rating: showFeedbackToGuests ? (photo.average_rating || 0) : 0, - comment_count: showFeedbackToGuests ? (commentMap[photo.id] || 0) : 0, - like_count: showFeedbackToGuests ? (photo.like_count || 0) : 0, - // Per-viewer flag (#590 follow-up) — true when this viewer has - // an active like row for this photo, false otherwise. Lets the - // grid seed its lifted likedPhotoIds correctly on hard refresh. - // Survives show_feedback_to_guests being off (#1286): the viewer's - // own heart is theirs, and the like_count beside it stays hidden. - is_liked: likedPhotoIds.has(photo.id), - favorite_count: showFeedbackToGuests ? (photo.favorite_count || 0) : 0, - // Colour labels (#1044). The COUNT is aggregate data and follows - // show_feedback_to_guests like its siblings; the viewer's OWN label - // is not aggregate and must survive with sharing off, otherwise the - // grid badge disappears on refresh for the very guest who set it. - color_label_count: showFeedbackToGuests ? (photo.color_label_count || 0) : 0, - my_color_label: myColorLabelByPhoto[photo.id] || null, - // Distinct colours other viewers put on this photo (#1178), so the - // grid can show them beside the viewer's own badge. Empty with - // sharing off — it is other people's feedback. - other_color_labels: otherColorLabelsByPhoto[photo.id] || [], - // People in this photo (#1074). Empty array when the feature is - // off for this event or hidden from guests, so the frontend has - // one shape to handle. Riding along on this payload is what keeps - // face filtering client-side and instant, like the category and - // liked/rated filters. - person_ids: personIdsByPhoto.get(photo.id) || [], - // Visibility (only included for clients) - ...(isClient ? { visibility: photo.visibility || 'visible' } : {}) - }; - }) - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to fetch photos'); - } -}); - -/** - * People in this gallery (#1074). - * - * Returns [] rather than 403 whenever the feature is unavailable — a guest - * must not be able to tell "this gallery has no people" from "this gallery - * has the feature switched off". Same reasoning as reveal mode returning an - * empty photo set rather than an error. - * - * Counts and cover faces are computed against the caller's own visibility - * scope inside facePeopleService; nothing here reads face_count_total. - */ -// no-store for the same reason as /photos: the people list and its scan -// progress are scoped to what THIS viewer may see. -router.get('/:slug/people', verifyGalleryAccess, resolveGuest, noStoreCache, async (req, res) => { - try { - const isClient = req.accessLevel === 'client'; - const { isEnabledForEvent, areFacesVisibleToGuests, getThresholds } = - require('../services/faceSettings'); - - if (!(await isEnabledForEvent(req.event))) { - return res.json({ people: [] }); - } - if (!isClient && !areFacesVisibleToGuests(req.event)) { - return res.json({ people: [] }); - } - // While a gallery is hidden behind reveal mode (#838), a plain guest sees - // no photos — so they see no people either. - if (guestBlockedByReveal(req)) { - return res.json({ people: [] }); - } - - const { listPeople, getScanStatus } = require('../services/facePeopleService'); - const thresholds = await getThresholds(); - - const people = await listPeople(req.event.id, { - isClient, - forAdmin: false, - minClusterSize: thresholds.face_min_cluster_size, - }); - - // Drives the "Finding people… 240/1200" progress line during a backfill. - // Scoped to what this viewer may see — an unscoped total would leak the - // number of hidden photos through the progress bar. - const status = await getScanStatus(req.event.id, { isClient }); - - res.json({ - people, - scan: { - in_progress: status.in_progress, - scanned: status.scanned, - total: status.total, - }, - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to fetch people'); - } -}); - -// Toggle photo visibility (client-only) -router.patch('/:slug/photos/:photoId/visibility', verifyGalleryAccess, async (req, res) => { - try { - if (req.accessLevel !== 'client') { - return res.status(403).json({ error: 'Client access required' }); - } - - const { photoId } = req.params; - const { visibility } = req.body; - - if (!['visible', 'hidden'].includes(visibility)) { - return res.status(400).json({ error: 'Invalid visibility value' }); - } - - const photo = await db('photos') - .where({ id: photoId, event_id: req.event.id }) - .first(); - - if (!photo) { - return res.status(404).json({ error: 'Photo not found' }); - } - - await db('photos') - .where({ id: photoId, event_id: req.event.id }) - .update({ visibility }); - - // A client hiding/showing a photo changes the guest download bundle — - // drop the cached ZIP so it rebuilds fresh (codex review). - downloadZipService.invalidate(req.event.id); - - res.json({ message: 'Photo visibility updated', visibility }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to update photo visibility'); - } -}); - -// Bulk toggle photo visibility (client-only) -router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, res) => { - try { - if (req.accessLevel !== 'client') { - return res.status(403).json({ error: 'Client access required' }); - } - - const { photoIds, visibility } = req.body; - - if (!Array.isArray(photoIds) || photoIds.length === 0) { - return res.status(400).json({ error: 'Invalid photo IDs' }); - } - - if (!['visible', 'hidden'].includes(visibility)) { - return res.status(400).json({ error: 'Invalid visibility value' }); - } - - const count = await db('photos') - .whereIn('id', photoIds) - .where('event_id', req.event.id) - .update({ visibility }); - - // Client bulk hide/show alters the guest download bundle — invalidate - // the cached ZIP (codex review). - downloadZipService.invalidate(req.event.id); - - res.json({ message: `${count} photos updated`, visibility }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to update photo visibility'); - } -}); - - -// Download single photo -router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => { - try { - const { photoId } = req.params; - - // Check if downloads are allowed for this event - if (!parseBooleanInput(req.event.allow_downloads, true)) { - return res.status(403).json({ error: 'Downloads are disabled for this gallery' }); - } - - const photo = await db('photos') - .where({ id: photoId, event_id: req.event.id }) - .first(); - - if (!photo) { - return res.status(404).json({ error: 'Photo not found' }); - } - - // Block guest access to hidden photos - if (photo.visibility === 'hidden' && req.accessLevel !== 'client') { - return res.status(403).json({ error: 'Photo not available' }); - } - - // Per-category download permission (#640). Photos without a category are - // always downloadable when the event allows downloads — only categorised - // photos can opt out per-category. - if (photo.category_id) { - const cat = await db('photo_categories') - .where('id', photo.category_id) - .first('allow_downloads'); - if (cat && !parseBooleanInput(cat.allow_downloads, true)) { - return res.status(403).json({ error: 'Downloads are disabled for this category' }); - } - } - - // Download resolution (#858). Resolved BEFORE the counters below: a - // rejected resolution must not inflate download stats, which a guest - // could otherwise do by replaying ?resolution=bogus. - const isVideo = photo.media_type === 'video' - || (photo.mime_type && photo.mime_type.startsWith('video/')); - const policy = await resolveEventDownloadPolicy(req.event); - const requested = pickRequestedResolution(policy, req.query.resolution); - if (requested === null) { - return res.status(400).json({ error: 'Resolution not available for this gallery' }); - } - const box = isVideo ? null : parseResolution(requested); - - // A HEAD is a metadata probe, not a download. Answering it below the - // counters recorded every probe as a real download, and answering it below - // renderPhotoForDownload fetched and watermarked an image whose body Node - // then discards. Both happen before this point in a GET, so HEAD leaves - // here — with no side effects and no bytes read. - if (req.method === 'HEAD') { - const headUseOriginal = await getUseOriginalFilenames(); - const headHeaders = { - 'Content-Type': resolvePhotoContentType(photo), - 'Content-Disposition': buildContentDisposition(pickRawDownloadName(photo, headUseOriginal)), - 'Accept-Ranges': 'bytes', - }; - - // Content-Length only when the bytes ship untransformed AND the size can - // be read without fetching them. A watermark or resize changes the - // length, and the only way to learn the new one is to do the work this - // branch exists to avoid — HEAD is allowed to omit it. - const headWatermark = await resolveWatermarkSettings(req.event); - if (!box && !headWatermark) { - try { - const headKey = resolvePhotoStorageKey(req.event, photo); - const headStorage = getStorage(); - if (headKey && headStorage.kind() !== 'local') { - const headStat = await headStorage.stat(headKey); - if (!headStat) return res.status(404).json({ error: 'Photo file not found' }); - headHeaders['Content-Length'] = headStat.size; - if (headStat.mtime) headHeaders['Last-Modified'] = new Date(headStat.mtime).toUTCString(); - } - } catch (headErr) { - // No length is a valid HEAD; not worth failing the probe over. - logger.debug('HEAD probe could not stat the object', { photoId, error: headErr.message }); - } - } - - res.set(headHeaders); - return res.end(); - } - - // Admin preview (#868) downloads are excluded from the download count + - // guest analytics — kept out of client-facing stats. - if (!req.isAdminPreview) { - // Update download count - await db('photos').where('id', photoId).increment('download_count', 1); - - // Log download - await db('access_logs').insert({ - event_id: req.event.id, - ip_address: req.ip, - user_agent: req.headers['user-agent'], - action: 'download', - photo_id: photoId - }); - } - // Surface in the admin notification bell (#746) — debounced, and only - // once the response actually finished: notifying up-front would log a - // download that then 404s/fails and the debounce would suppress the - // next real one for an hour (codex review of #849). - res.on('finish', () => { - if (res.statusCode < 400 && !req.isAdminPreview) notifySinglePhotoDownload(req.event, req); - }); - - // #493: if the admin enabled "use original filenames", surface the - // pre-rename camera filename in Content-Disposition. Storage path is - // unchanged — only the user-visible download name is swapped. - const useOriginal = await getUseOriginalFilenames(); - const downloadName = pickRawDownloadName(photo, useOriginal); - const contentDisposition = buildContentDisposition(downloadName); - - // The gallery's standard applies to EVERY ordinary download, single photos - // included — otherwise a lowered standard is trivially bypassed by - // downloading photos one at a time. `box` was resolved above, before the - // counters. Videos have no resize path and always ship as-is. - // - // renderPhotoForDownload (#858) owns the resize-then-watermark ordering - // and the storage fetch, and is what the zip builders below already use. - // It returns null when the photo needs no transformation at all, which is - // the default gallery's common case and lets us ship the stored bytes - // without buffering a full-size original into memory. - const effectiveSettings = await resolveWatermarkSettings(req.event); - - let rendered; - try { - rendered = await renderPhotoForDownload(req.event, photo, box, effectiveSettings); - } catch (renderError) { - // Classify, the same way the pass-through branch below does. This can - // reject because the source object is gone, but equally because - // getToFile timed out, the tmp filesystem filled up, or sharp failed — - // and reporting an operational failure as 404 tells the guest their - // photo does not exist and tells us nothing. - const gone = renderError.code === 'ENOENT' - || renderError.name === 'NoSuchKey' - || renderError.name === 'NotFound' - || renderError.$metadata?.httpStatusCode === 404; - logger.error('Failed to render photo for download', { - slug: req.params.slug, - photoId, - eventId: req.event.id, - error: renderError.message, - }); - return gone - ? res.status(404).json({ error: 'Photo file not found' }) - : res.status(500).json({ error: 'Failed to download photo' }); - } - - if (rendered) { - res.set({ - 'Content-Type': resolvePhotoContentType(photo), - 'Content-Disposition': contentDisposition, - 'Content-Length': rendered.length - }); - - return res.send(rendered); - } - - // Untransformed: ship the stored bytes. - // - // Managed photos live behind the storage abstraction and on an S3/R2 - // deployment are not on local disk at all — resolving a filesystem path - // unconditionally here is what made every single-photo download 404 with - // ENOENT in S3 mode (#1048), while download-all and secure-images worked - // because they already went through getStorage(). - // - // resolvePhotoStorageKey returns null for external/reference photos: those - // live on a local mount and keep the sendFile path. - let storageKey = null; - try { - storageKey = resolvePhotoStorageKey(req.event, photo); - } catch (resolveError) { - logger.error('Failed to resolve photo storage key for download', { - slug: req.params.slug, - photoId, - eventId: req.event.id, - error: resolveError.message, - }); - return res.status(404).json({ error: 'Photo file not found' }); - } - - const storage = getStorage(); - if (storageKey && storage.kind() !== 'local') { - // Deliberately NOT the local path: res.sendFile emits Content-Length, - // Accept-Ranges, ETag and Last-Modified and answers Range requests with - // a 206, and a bare stream.pipe(res) has none of that. On local disk - // sendFile stays the better implementation, so it stays the branch. - // - // On S3 we reproduce the parts that matter for a download: the length - // (browsers need it for the progress indicator, which matters most on - // exactly the large files this route serves) and Range, so an - // interrupted download resumes instead of appending a second full body - // onto the partial file. Conditional requests are not reproduced — - // there is no ETag here, so a client revalidating gets the whole body, - // same as it does today. - const stat = await storage.stat(storageKey); - if (!stat) { - logger.error('Photo not found in storage backend for download', { - slug: req.params.slug, - photoId, - eventId: req.event.id, - storageKey, - }); - return res.status(404).json({ error: 'Photo file not found' }); - } - - const lastModified = stat.mtime ? new Date(stat.mtime).toUTCString() : null; - const headers = { - 'Content-Type': resolvePhotoContentType(photo), - 'Content-Disposition': contentDisposition, - 'Accept-Ranges': 'bytes', - }; - if (lastModified) headers['Last-Modified'] = lastModified; - - // If-Range: a client resuming an interrupted download sends back the - // validator it was given last time. If the object has been replaced - // since — the watcher re-importing a swapped file, an admin re-upload — - // answering 206 from the NEW bytes lets the client splice two different - // versions into one corrupt file. A validator that doesn't match means - // a full 200, which is the whole point of the header. - const ifRange = req.headers['if-range']; - const staleValidator = !!ifRange && (!lastModified || ifRange.trim() !== lastModified); - const range = staleValidator ? null : parseByteRange(req.headers.range, stat.size); - - // Open the stream BEFORE any header is staged or sent. stat() succeeding - // does not mean get() will: a concurrent delete or replace, or a - // transient backend error, lands here. Once writeHead(206) has gone out - // the outer catch can do nothing but throw ERR_HTTP_HEADERS_SENT, and in - // the non-range case it would send its 500 JSON underneath the staged - // image/jpeg attachment headers — a .jpg file full of JSON. - let stream; - try { - stream = range - ? await storage.getRange(storageKey, range.start, range.end) - : await storage.get(storageKey); - } catch (fetchError) { - const gone = fetchError.code === 'ENOENT' - || fetchError.name === 'NoSuchKey' - || fetchError.name === 'NotFound' - || fetchError.$metadata?.httpStatusCode === 404; - logger.error('Failed to open photo stream for download', { - slug: req.params.slug, - photoId, - eventId: req.event.id, - storageKey, - error: fetchError.message, - }); - return gone - ? res.status(404).json({ error: 'Photo file not found' }) - : res.status(500).json({ error: 'Failed to download photo' }); - } - - if (range) { - // status()+set() rather than writeHead(): writeHead commits the - // response immediately, so a stream that resolves and THEN errors - // before its first chunk would leave pipeStreamToResponse able only to - // destroy the connection. Staged headers are flushed by the first body - // write, which means an error at byte zero can still clear them and - // return a clean, retryable status instead of a transport reset. - res.status(206).set({ - ...headers, - 'Content-Range': `bytes ${range.start}-${range.end}/${stat.size}`, - 'Content-Length': (range.end - range.start) + 1, - }); - } else { - res.set({ ...headers, 'Content-Length': stat.size }); - } - pipeStreamToResponse(stream, res, { - context: range ? `download range for photo ${photo.id}` : `download for photo ${photo.id}`, - }); - return; - } - - let filePath; - try { - filePath = resolvePhotoFilePath(req.event, photo); - } catch (resolveError) { - logger.error('Failed to resolve photo path for download', { - slug: req.params.slug, - photoId, - eventId: req.event.id, - error: resolveError.message, - }); - return res.status(404).json({ error: 'Photo file not found' }); - } - - // res.download() builds Content-Disposition itself but doesn't emit the - // RFC 5987 filename* parameter, so unicode camera filenames would lose - // their bytes on download. Set the header explicitly and stream the - // file with res.sendFile-equivalent semantics. - res.set({ - 'Content-Type': resolvePhotoContentType(photo), - 'Content-Disposition': contentDisposition, - }); - res.sendFile(filePath, (downloadError) => { - if (downloadError) { - logger.error('Error streaming gallery download', { - slug: req.params.slug, - photoId, - eventId: req.event.id, - error: downloadError.message, - }); - } - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to download photo'); - } -}); - -// Download all photos as ZIP -// Zip downloads count toward each contained photo's download_count (#895) -// — previously only single-photo downloads did, so galleries whose guests -// grab the zip showed 0 per-photo downloads forever. Used by the -// pre-generated-zip branches only: it mirrors downloadZipService._build, -// which zips EVERY event photo with no per-category allow_downloads -// filter — the counter has to reflect what actually shipped. (That the -// prebuilt zip ignores per-category download opt-outs is a separate, -// pre-existing issue.) Known approximation: _build skips entries whose -// WATERMARK step fails and still publishes the zip; counting those -// would need a persisted archive manifest, which isn't worth it for -// that tail case. Fire-and-forget at the call sites: counters must -// never fail a download. -async function bumpEventDownloadCounts(eventId) { - await db('photos').where('event_id', eventId).increment('download_count', 1); -} - -router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => { - try { - // Check if downloads are allowed for this event - if (!parseBooleanInput(req.event.allow_downloads, true)) { - return res.status(403).json({ error: 'Downloads are disabled for this gallery' }); - } - - // Try to serve pre-generated zip (instant download with Content-Length). - // Guests may use the prebuilt cache ONLY when the event has no hidden - // photos: a cache built before a photo was hidden — or before this - // visibility-aware builder shipped — could otherwise still leak it, and - // getZipInfo only checks the DB pointer + file stat, not freshness. When - // hidden photos exist, guests fall through to the visibility-filtered - // stream below. PIN-clients always stream a full archive. - const isClient = canSeeHiddenPhotos(req.accessLevel); - const eventHasHidden = await db('photos') - .where({ event_id: req.event.id, visibility: 'hidden' }) - .first() - .then(Boolean); - const zipInfo = (isClient || eventHasHidden) - ? null - : await downloadZipService.getZipInfo(req.event.id); - if (zipInfo) { - const storage = getStorage(); - - // Per-event presigned-URL fast path (#328 follow-up). Conditions: - // 1. STORAGE_BACKEND=s3 (presigned URLs are S3-only) - // 2. event.allow_presigned_download is true (admin opted in) - // 3. Watermarking is OFF for this event — presigned URLs bypass the - // backend, which means no watermark on bytes leaving S3. - // Falls through to streaming on any condition mismatch. - const wantsPresigned = req.event.allow_presigned_download === true || req.event.allow_presigned_download === 1; - const watermarkOnEvent = req.event.watermark_downloads === true || req.event.watermark_downloads === 1; - if (wantsPresigned && storage.kind() === 's3' && !watermarkOnEvent) { - try { - const url = await storage.signedUrl(zipInfo.key, 300); // 5 min - // Admin preview (#868): stream the ZIP but keep it out of stats. - if (!req.isAdminPreview) { - db('access_logs').insert({ - event_id: req.event.id, - ip_address: req.ip, - user_agent: req.headers['user-agent'], - action: 'download_all_presigned' - }).catch(() => {}); - bumpEventDownloadCounts(req.event.id).catch(() => {}); - // Surface in the admin notification bell (#746). - logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req)); - } - res.redirect(302, url); - return; - } catch (err) { - logger.warn('presigned download-all failed, falling back to stream', { - eventId: req.event.id, - error: err.message, - }); - } - } - - res.setHeader('Content-Type', 'application/zip'); - res.setHeader('Content-Length', zipInfo.size); - res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`); - const stream = await storage.get(zipInfo.key); - pipeStreamToResponse(stream, res, { context: `prepared zip for event ${req.event.id}`, missingStatus: 410 }); - - // Log bulk download (admin preview #868 excluded — stats stay client-only). - if (!req.isAdminPreview) { - db('access_logs').insert({ - event_id: req.event.id, - ip_address: req.ip, - user_agent: req.headers['user-agent'], - action: 'download_all' - }).catch(() => {}); - bumpEventDownloadCounts(req.event.id).catch(() => {}); - // Surface in the admin notification bell (#746) — only once the - // stream actually finished; logging at pipe-time would report - // downloads that then broke mid-transfer (codex review of #849). - res.on('finish', () => { - if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req)); - }); - } - return; - } - - // Fallback: on-the-fly streaming (existing behavior). Only pre-build the - // guest cache when it will actually be served next time — a guest - // download of an event with no hidden photos. Client bypasses and - // hidden-photo events always stream, so rebuilding the guest archive on - // those requests is wasted I/O (codex review). - if (!isClient && !eventHasHidden) { - downloadZipService.generateZip(req.event.id).catch(err => - logger.warn('Background zip generation failed', { eventId: req.event.id, error: err.message }) - ); - } - - // Fetch photos — exclude photos in categories that disabled downloads (#640). - // Uncategorised photos are always included; categories without the column - // (pre-migration-135) fall through the LEFT JOIN's null and are included. - const photos = await applyPhotoVisibilityFilter( - db('photos') - .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id') - .where('photos.event_id', req.event.id) - .where(function () { - this.whereNull('photos.category_id') - .orWhere('photo_categories.allow_downloads', true) - .orWhereNull('photo_categories.allow_downloads'); - }), - req.accessLevel - ) - .select('photos.*') - .orderBy('photos.type', 'asc') - .orderBy('photos.uploaded_at', 'desc'); - - if (photos.length === 0) { - return res.status(404).json({ error: 'No photos found' }); - } - - // Count unique types - const uniqueTypes = new Set(photos.map(p => p.type)).size; - const hasMultipleTypes = uniqueTypes > 1; - - res.setHeader('Content-Type', 'application/zip'); - res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`); - - const archive = archiver('zip', { zlib: { level: 5 } }); - archive.on('error', (err) => { - throw err; - }); - - archive.pipe(res); - - // Get watermark settings - apply if global setting OR event-level setting is enabled - const watermarkSettings = await watermarkService.getWatermarkSettings(); - const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1; - const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled; - const effectiveSettings = shouldApplyWatermark ? { - ...watermarkSettings, - enabled: true, - text: req.event.watermark_text || watermarkSettings?.text || 'Protected' - } : null; - - // The gallery's standard resolution applies to the streamed archive too, - // not only the cached one (#858). - const { standardBox: bulkBox } = await resolveEventDownloadPolicy(req.event); - - // Add photos to archive — managed photos via storage backend, external via local path. - const { resolvePhotoStorageKey } = require('../services/photoResolver'); - const storage = getStorage(); - // #493: resolve a unique display filename per photo up-front so collisions - // get a deterministic `_1` suffix before the entries hit the archive. - const useOriginalBulk = await getUseOriginalFilenames(); - const bulkEntryNames = getZipEntryNames(photos, useOriginalBulk); - // Only photos whose append succeeded count as downloaded (#895) — the - // catch below deliberately skips missing/corrupt sources, and those - // never make it into the archive. - const appendedIds = []; - for (let i = 0; i < photos.length; i += 1) { - const photo = photos[i]; - const storageKey = resolvePhotoStorageKey(req.event, photo); - const entryName = bulkEntryNames[i]; - let archiveName; - if (hasMultipleTypes) { - const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages'; - archiveName = path.join(folderName, entryName); - } else { - archiveName = entryName; - } - - try { - // Verify the source exists BEFORE appending — but only for local - // sources: fs.createReadStream is lazy, so its error fires outside - // this try/catch and the archive 'error' handler then kills the - // whole response instead of skipping one photo (#895 review). S3's - // get() awaits GetObject and rejects right here on a missing key, - // so a preflight HEAD per entry would just be a redundant serial - // round trip (500-photo zip = 500 extra HEADs). - if (storageKey && storage.kind() === 'local') { - const srcStat = await storage.stat(storageKey); - if (!srcStat) { - throw new Error(`Photo missing in storage: ${storageKey}`); - } - } else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) { - throw new Error('Photo file missing on disk'); - } - - // Resize to the gallery's standard resolution (#858) and/or watermark. - // This branch runs whenever the cached zip isn't usable — the first - // download after an invalidation, PIN clients, and galleries with - // hidden photos all land here, so skipping the cap would leak - // full-resolution files for exactly those cases. - const rendered = await renderPhotoForDownload(req.event, photo, bulkBox, effectiveSettings); - if (rendered) { - archive.append(rendered, { name: archiveName }); - } else if (storageKey) { - const stream = await storage.get(storageKey); - archive.append(stream, { name: archiveName }); - } else { - archive.file(resolvePhotoFilePath(req.event, photo), { name: archiveName }); - } - appendedIds.push(photo.id); - } catch (err) { - logger.warn('Skipping photo in bulk download due to error', { - slug: req.params.slug, - photoId: photo.id, - eventId: req.event.id, - error: err.message, - }); - } - } - - // Notification only after the response actually finished — finalize() - // ends Archiver's input, not the HTTP transfer (codex review of #849, - // confirmation round). Registered before finalize so it can't be missed. - // Admin preview (#868) streams the archive but is excluded from stats. - if (!req.isAdminPreview) { - res.on('finish', () => { - if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req)); - }); - } - await archive.finalize(); - - if (!req.isAdminPreview) { - // Log bulk download - await db('access_logs').insert({ - event_id: req.event.id, - ip_address: req.ip, - user_agent: req.headers['user-agent'], - action: 'download_all' - }); - // Exactly the photos that made it into this archive (#895) — skipped - // (missing/corrupt) sources don't count. - if (appendedIds.length > 0) { - db('photos').whereIn('id', appendedIds) - .increment('download_count', 1).catch(() => {}); - } - } - } catch (error) { - errorResponse(res, error, 500, 'Failed to create download archive'); - } -}); - -// Download selected photos as ZIP -router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => { - try { - // Check if downloads are allowed for this event - if (!parseBooleanInput(req.event.allow_downloads, true)) { - return res.status(403).json({ error: 'Downloads are disabled for this gallery' }); - } - - const ids = Array.isArray(req.body?.photo_ids) ? req.body.photo_ids : []; - if (!ids.length) { - return res.status(400).json({ error: 'photo_ids is required (non-empty array)' }); - } - - // Clean IDs - const photoIds = ids - .map((v) => parseInt(v, 10)) - .filter((v) => Number.isInteger(v)) - .slice(0, 500); - - if (photoIds.length === 0) { - return res.status(400).json({ error: 'No valid photo IDs provided' }); - } - - // Fetch photos — exclude photos in categories that disabled downloads (#640). - // Same LEFT JOIN pattern as the download-all endpoint. - const photos = await applyPhotoVisibilityFilter( - db('photos') - .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id') - .where('photos.event_id', req.event.id) - .whereIn('photos.id', photoIds) - .where(function () { - this.whereNull('photos.category_id') - .orWhere('photo_categories.allow_downloads', true) - .orWhereNull('photo_categories.allow_downloads'); - }), - req.accessLevel - ) - .select('photos.*') - .orderBy('photos.uploaded_at', 'desc'); - - if (photos.length === 0) { - return res.status(404).json({ error: 'No photos found for selected IDs' }); - } - - // Download resolution (#858). Resolve BEFORE any header goes out — once - // the archive starts streaming we can no longer return a JSON error. - const selectedPolicy = await resolveEventDownloadPolicy(req.event); - const selectedResolution = pickRequestedResolution(selectedPolicy, req.body?.resolution); - if (selectedResolution === null) { - return res.status(400).json({ error: 'Resolution not available for this gallery' }); - } - const selectedBox = parseResolution(selectedResolution); - - const archiveName = `${req.event.slug}-selected.zip`; - res.setHeader('Content-Type', 'application/zip'); - res.setHeader('Content-Disposition', `attachment; filename="${archiveName}"`); - - const archive = archiver('zip', { zlib: { level: 5 } }); - archive.on('error', (err) => { - logger.error('Zip error generating selected download', { - slug: req.params.slug, - eventId: req.event?.id, - error: err.message, - }); - try { - res.status(500).end(); - } catch (_) { - // ignore double-send errors - } - }); - archive.pipe(res); - - // Check watermark settings - apply if global setting OR event-level setting is enabled - const watermarkSettings = await watermarkService.getWatermarkSettings(); - const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1; - const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled; - const effectiveSettings = shouldApplyWatermark ? { - ...watermarkSettings, - enabled: true, - text: req.event.watermark_text || watermarkSettings?.text || 'Protected' - } : null; - - const { resolvePhotoStorageKey: resolveSelectedKey } = require('../services/photoResolver'); - const selectedStorage = getStorage(); - // #493: same display-name resolution as bulk download, with dedup. - const useOriginalSelected = await getUseOriginalFilenames(); - const selectedEntryNames = getZipEntryNames(photos, useOriginalSelected); - // Only photos whose append succeeded count as downloaded (#895). - const appendedIds = []; - for (let i = 0; i < photos.length; i += 1) { - const photo = photos[i]; - const name = selectedEntryNames[i] || `photo-${photo.id}.jpg`; - const storageKey = resolveSelectedKey(req.event, photo); - try { - // Same pre-append source check as download-all (#895 review), - // local backend only: a lazy fs stream's async error would kill - // the response instead of skipping the photo; S3's get() rejects - // at the await below, so no redundant per-entry HEAD there. - if (storageKey && selectedStorage.kind() === 'local') { - const srcStat = await selectedStorage.stat(storageKey); - if (!srcStat) { - throw new Error(`Photo missing in storage: ${storageKey}`); - } - } else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) { - throw new Error('Photo file missing on disk'); - } - - // Resize (#858) and/or watermark. renderPhotoForDownload returns null - // when neither applies, so the untransformed case still streams from - // storage rather than buffering the whole photo. - const rendered = await renderPhotoForDownload(req.event, photo, selectedBox, effectiveSettings); - if (rendered) { - archive.append(rendered, { name }); - } else if (storageKey) { - const stream = await selectedStorage.get(storageKey); - archive.append(stream, { name }); - } else { - archive.file(resolvePhotoFilePath(req.event, photo), { name }); - } - appendedIds.push(photo.id); - } catch (err) { - logger.warn('Skipping selected photo due to error', { - slug: req.params.slug, - photoId: photo.id, - eventId: req.event.id, - error: err.message, - }); - } - } - - // See download-all: notify only on response 'finish'. - // Admin preview (#868) streams the archive but is excluded from stats. - if (!req.isAdminPreview) { - res.on('finish', () => { - if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'selected', photo_count: photoIds.length }, req.event.id, galleryActor(req)); - }); - } - await archive.finalize(); - - if (!req.isAdminPreview) { - await db('access_logs').insert({ - event_id: req.event.id, - ip_address: req.ip, - user_agent: req.headers['user-agent'], - action: 'download_selected' - }); - // Exactly the photos that made it into this archive (#895) — skipped - // (missing/corrupt) sources don't count. - if (appendedIds.length > 0) { - db('photos').whereIn('id', appendedIds) - .increment('download_count', 1).catch(() => {}); - } - } - } catch (error) { - errorResponse(res, error, 500, 'Failed to download selected photos'); - } -}); - - -// ────────────────────────────────────────────────────────────────────────── -// Custom-resolution download jobs (#858). -// -// The plain download-all is served from the pre-built cache at the gallery's -// STANDARD resolution. Picking a different size has nothing to cache against, -// and resizing a whole gallery inside one request would sit far past any -// reverse-proxy timeout — so those archives are built as a job the client -// polls. Same access rules as the download routes above. -// ────────────────────────────────────────────────────────────────────────── - -// Kick off (or join) a build. Returns the polling token. -router.post('/:slug/download-jobs', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => { - try { - if (!parseBooleanInput(req.event.allow_downloads, true)) { - return res.status(403).json({ error: 'Downloads are disabled for this gallery' }); - } - - const policy = await resolveEventDownloadPolicy(req.event); - if (!policy.pickerEnabled) { - return res.status(403).json({ error: 'Resolution choice is not enabled for this gallery' }); - } - const resolution = pickRequestedResolution(policy, req.body?.resolution); - if (resolution === null) { - return res.status(400).json({ error: 'Resolution not available for this gallery' }); - } - - // Optional subset. Absent = the whole visible gallery. - let photoIds = null; - if (Array.isArray(req.body?.photo_ids) && req.body.photo_ids.length) { - photoIds = req.body.photo_ids - .map((v) => parseInt(v, 10)) - .filter((v) => Number.isInteger(v)) - .slice(0, 500); - if (photoIds.length === 0) { - return res.status(400).json({ error: 'No valid photo IDs provided' }); - } - } - - let job; - try { - job = await downloadJobService.createJob({ - event: req.event, - resolution, - photoIds, - accessLevel: req.accessLevel, - }); - } catch (err) { - if (err.code === 'NO_PHOTOS') { - return res.status(404).json({ error: 'No photos available for this selection' }); - } - if (err.code === 'BUSY') { - return res.status(429).json({ error: 'Too many downloads are being prepared right now — please try again shortly' }); - } - throw err; - } - - res.status(202).json({ - token: job.token, - status: job.status, - resolution: job.resolution, - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to start download preparation'); - } -}); - -// Poll. The token is unguessable, but it is never sufficient on its own — -// verifyGalleryAccess still runs and the job must belong to THIS event. -// no-store: a cached 'preparing' would strand the caller in a poll that can -// never observe the job finishing. -router.get('/:slug/download-jobs/:token', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, noStoreCache, async (req, res) => { - try { - const job = await downloadJobService.getStatus(req.params.token); - if (!job || job.event_id !== req.event.id) { - return res.status(404).json({ error: 'Download job not found' }); - } - res.json({ - status: job.status, - resolution: job.resolution, - photo_count: job.photo_count || 0, - size_bytes: job.size_bytes || null, - error: job.status === 'failed' ? (job.error || 'Preparation failed') : undefined, - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to read download job'); - } -}); - -// Deliver the finished archive. -router.get('/:slug/download-jobs/:token/file', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => { - try { - // Downloads can be switched off after a job was created — every other - // download route re-checks this per request, so this one must too. - if (!parseBooleanInput(req.event.allow_downloads, true)) { - return res.status(403).json({ error: 'Downloads are disabled for this gallery' }); - } - - const job = await downloadJobService.getStatus(req.params.token); - if (!job || job.event_id !== req.event.id) { - return res.status(404).json({ error: 'Download job not found' }); - } - // The token alone never grants access: the archive was built under one - // visibility scope, and only a requester still in that scope may take it. - // Without this, a leaked client token would hand hidden photos to a guest. - if (job.visibility_scope !== downloadJobService.visibilityScopeFor(req.accessLevel)) { - return res.status(404).json({ error: 'Download job not found' }); - } - if (job.status !== 'ready' || !job.zip_path) { - return res.status(409).json({ error: 'Download is not ready yet', status: job.status }); - } - if (new Date(job.expires_at).getTime() <= Date.now()) { - return res.status(410).json({ error: 'This download has expired — please request it again' }); - } - // A photo hidden AFTER this archive was built is still inside it, and the - // scope check above can't see that — both sides remain 'public'. Re-run - // the visibility query over the packaged set before handing it over. - if (!(await downloadJobService.isStillDeliverable(job, req.event, req.accessLevel))) { - return res.status(409).json({ - error: 'This gallery changed since the download was prepared — please request it again', - status: 'stale', - }); - } - - const storage = getStorage(); - const stat = await storage.stat(job.zip_path); - if (!stat) { - return res.status(410).json({ error: 'This download is no longer available' }); - } - - // Stats parity with the other bulk paths (#895): only count once the - // response actually completed, and keep admin previews out of guest stats. - res.on('finish', () => { - if (res.statusCode >= 400 || req.isAdminPreview) return; - // The DELIVERED set, not the requested one: a photo whose source was - // missing at build time isn't in the zip and must not be counted. - let ids = []; - try { - ids = JSON.parse(job.delivered_photo_ids || job.photo_ids || '[]'); - } catch (_) { /* malformed row — skip counting rather than fail */ } - if (ids.length > 0) { - db('photos').whereIn('id', ids).increment('download_count', 1).catch(() => {}); - } - db('access_logs').insert({ - event_id: req.event.id, - ip_address: req.ip, - user_agent: req.headers['user-agent'], - action: 'download', - photo_id: null, - }).catch(() => {}); - logActivity('gallery_downloaded', { scope: 'all', resolution: job.resolution }, - req.event.id, galleryActor(req)); - }); - - const suffix = job.resolution === 'original' ? 'original' : job.resolution; - res.setHeader('Content-Type', 'application/zip'); - res.setHeader('Content-Length', stat.size); - res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}-${suffix}.zip"`); - const stream = await storage.get(job.zip_path); - pipeStreamToResponse(stream, res, { context: `download job ${job.id}`, missingStatus: 410 }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to serve prepared download'); - } -}); - -// Explicit per-photo view beacon (#895). Counting views on the image- -// serving routes is wrong in both directions: the lightbox preloads the -// prev/next neighbours (three fetches per open), while a preloaded -// neighbour that becomes the current slide is never re-fetched (#505 -// keeps the DOM node alive across the swipe) — so request-level counters -// overcount preloads AND undercount swipe-throughs. Instead the lightbox -// pings this endpoint exactly when a photo becomes the visible slide. -// This also covers enhanced/maximum-protection galleries, whose bytes -// are served by /api/secure-images and never pass the routes below. -// The slideshow kiosk is excluded (denySlideshowToken; migration 138). -router.post('/:slug/photo/:photoId/view', - verifyGalleryAccess, - denySlideshowToken, - blockHiddenGallery, - async (req, res) => { - try { - const photo = await db('photos') - .where({ id: req.params.photoId, event_id: req.event.id }) - .first('id', 'visibility'); - if (!photo) { - return res.status(404).json({ error: 'Photo not found' }); - } - if (photo.visibility === 'hidden' && req.accessLevel !== 'client') { - return res.status(403).json({ error: 'Photo not available' }); - } - // Admin preview (#981 review) is excluded from per-photo view analytics. - if (!req.isAdminPreview) { - await db('photos').where('id', photo.id).increment('view_count', 1); - } - res.status(204).end(); - } catch (error) { - errorResponse(res, error, 500, 'Failed to record view'); - } - }); - -// View single photo (with watermark if enabled) -router.get('/:slug/photo/:photoId', - verifyGalleryAccess, - blockHiddenGallery, - async (req, res) => { - try { - const { photoId } = req.params; - - const photo = await db('photos') - .where({ id: photoId, event_id: req.event.id }) - .first(); - - if (!photo) { - return res.status(404).json({ error: 'Photo not found' }); - } - - // Block guest access to hidden photos - if (photo.visibility === 'hidden' && req.accessLevel !== 'client') { - return res.status(403).json({ error: 'Photo not available' }); - } - - // Check if this is a video - const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/')); - - // Check protection level - basic and standard protection allow direct JWT access - const protectionLevel = req.event.protection_level || 'standard'; - - if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') { - // For enhanced/maximum protection, redirect to secure endpoint - return res.status(302).json({ - error: 'Secure access required', - secureEndpoint: `/api/secure-images/${req.params.slug}/generate-token`, - photoId: photoId - }); - } - - // Resolve where to read the photo bytes from. For external/reference - // photos the source is always a local mount path. For managed photos - // we go through the storage abstraction so S3 deployments work too - // (#432 — previously this route did fs.* directly and 500'd in S3 - // mode because the file wasn't on the container's local fs). - const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../services/photoResolver'); - const storage = getStorage(); - const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference'; - const useStorageBackend = !isExternal; - - let filePath = null; // Local fs path (external photos OR LocalFs storage) - let storageKey = null; // Relative storage key (managed photos via storage abstraction) - let stat; - let fileSize; - - if (useStorageBackend) { - try { - storageKey = resolvePhotoStorageKey(req.event, photo); - } catch (resolveError) { - logger.error('Failed to resolve photo storage key', { - slug: req.params.slug, - photoId, - eventId: req.event.id, - error: resolveError.message, - photoPath: photo.path, - photoFilename: photo.filename - }); - return res.status(404).json({ error: 'Photo file not found' }); - } - stat = await storage.stat(storageKey); - if (!stat) { - logger.error('Photo not found in storage backend', { - slug: req.params.slug, - photoId, - eventId: req.event.id, - storageKey - }); - return res.status(404).json({ error: 'Photo file not found' }); - } - fileSize = stat.size; - } else { - try { - filePath = resolvePhotoFilePath(req.event, photo); - } catch (resolveError) { - logger.error('Failed to resolve photo path', { - slug: req.params.slug, - photoId, - eventId: req.event.id, - error: resolveError.message, - photoPath: photo.path, - photoFilename: photo.filename - }); - return res.status(404).json({ error: 'Photo file not found' }); - } - if (!fs.existsSync(filePath)) { - logger.error('Photo file does not exist at resolved path', { - slug: req.params.slug, - photoId, - eventId: req.event.id, - resolvedPath: filePath, - photoPath: photo.path - }); - return res.status(404).json({ error: 'Photo file not found' }); - } - stat = fs.statSync(filePath); - fileSize = stat.size; - } - - // Handle video streaming with range requests - if (isVideo) { - const range = req.headers.range; - - if (range) { - const parts = range.replace(/bytes=/, '').split('-'); - const start = parseInt(parts[0], 10); - const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; - // Validate before writing the 206: a NaN, inverted or out-of-file - // range used to be committed to the headers and then throw while - // streaming (or read past the end). - if (!Number.isInteger(start) || !Number.isInteger(end) - || start < 0 || end < start || start >= fileSize) { - res.set('Content-Range', `bytes */${fileSize}`); - return res.status(416).end(); - } - const boundedEnd = Math.min(end, fileSize - 1); - const chunksize = (boundedEnd - start) + 1; - - res.writeHead(206, { - 'Content-Range': `bytes ${start}-${boundedEnd}/${fileSize}`, - 'Accept-Ranges': 'bytes', - 'Content-Length': chunksize, - 'Content-Type': resolvePhotoContentType(photo), - 'Cache-Control': 'private, max-age=1800', - 'X-Protection-Level': 'basic' - }); - - const file = useStorageBackend - ? await storage.getRange(storageKey, start, boundedEnd) - : fs.createReadStream(filePath, { start, end: boundedEnd }); - pipeStreamToResponse(file, res, { context: `video range for photo ${photo.id}` }); - } else { - res.writeHead(200, { - 'Content-Length': fileSize, - 'Content-Type': resolvePhotoContentType(photo), - 'Accept-Ranges': 'bytes', - 'Cache-Control': 'private, max-age=1800', - 'X-Protection-Level': 'basic' - }); - const file = useStorageBackend - ? await storage.get(storageKey) - : fs.createReadStream(filePath); - pipeStreamToResponse(file, res, { context: `video for photo ${photo.id}` }); - } - return; - } - - // Image path - const watermarkSettings = await watermarkService.getWatermarkSettings(); - - const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0; - const watermarkHash = watermarkSettings?.enabled - ? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}` - : '-nowm'; - // orientation_checked_at participates because the backfill (#1198) can - // change these bytes without touching either of the other two inputs: - // it rewrites the derived renditions while the ORIGINAL's mtime and the - // watermark settings both stay exactly as they were. Without it a guest - // holding a pre-fix ETag keeps getting 304 and keeps their cached - // sideways image, however many times the backfill succeeds. - const orientationVersion = photo.orientation_checked_at - ? `-o${new Date(photo.orientation_checked_at).getTime()}` - : ''; - const etag = `"${photoId}-${mtimeMs}${watermarkHash}${orientationVersion}"`; - - if (req.headers['if-none-match'] === etag) { - return res.status(304).end(); - } - - if (watermarkSettings && watermarkSettings.enabled) { - // Pre-generated watermarked file: served via the storage backend - // (managed) or directly from local fs (external). - if (photo.watermark_path) { - try { - if (useStorageBackend) { - const wmStat = await storage.stat(photo.watermark_path); - if (wmStat) { - res.set({ - 'Content-Type': resolvePhotoContentType(photo), - 'Content-Length': wmStat.size, - 'Cache-Control': 'private, max-age=1800', - 'ETag': etag, - 'X-Protection-Level': 'basic' - }); - const wmStream = await storage.get(photo.watermark_path); - return pipeStreamToResponse(wmStream, res, { context: `watermarked photo ${photo.id}` }); - } - } else { - const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path); - if (fs.existsSync(watermarkFilePath)) { - res.set({ - 'Content-Type': resolvePhotoContentType(photo), - 'Cache-Control': 'private, max-age=1800', - 'ETag': etag, - 'X-Protection-Level': 'basic' - }); - return res.sendFile(watermarkFilePath); - } - } - } catch (err) { - logger.warn(`Pre-generated watermark not found for photo ${photoId}, falling back to on-the-fly`); - } - } - - // Fallback: apply watermark on-the-fly. applyWatermark needs a - // local file path (sharp + fs.readFile) — for managed photos in - // S3 mode, withLocalCopy materializes to a tmp file and cleans up. - const watermarkedBuffer = useStorageBackend - ? await withLocalCopy(storageKey, (localPath) => - watermarkService.applyWatermark(localPath, watermarkSettings)) - : await watermarkService.applyWatermark(filePath, watermarkSettings); - - // Queue watermark generation in background for next request - watermarkGeneratorService.generateForPhoto(photo.id) - .catch(err => logger.warn(`Background watermark generation failed for photo ${photo.id}:`, err.message)); - - res.set({ - 'Content-Type': resolvePhotoContentType(photo), - 'Cache-Control': 'private, max-age=1800', - 'ETag': etag, - 'X-Protection-Level': 'basic' - }); - - res.send(watermarkedBuffer); - } else { - res.set({ - 'Cache-Control': 'private, max-age=1800', - 'ETag': etag, - 'X-Protection-Level': 'basic' - }); - if (useStorageBackend) { - res.set('Content-Length', stat.size); - res.set('Content-Type', resolvePhotoContentType(photo)); - const stream = await storage.get(storageKey); - pipeStreamToResponse(stream, res, { context: `photo ${photo.id}` }); - } else { - const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath); - res.sendFile(absolutePath); - } - } - } catch (error) { - errorResponse(res, error, 500, 'Failed to serve photo'); - } - } -); - -// Serve thumbnail -router.get('/:slug/thumbnail/:photoId', - verifyGalleryAccess, - blockHiddenGallery, - async (req, res) => { - try { - const { photoId } = req.params; - - const photo = await db('photos') - .where({ id: photoId, event_id: req.event.id }) - .first(); - - if (!photo) { - return res.status(404).json({ error: 'Photo not found' }); - } - - // Block guest access to hidden photos - if (photo.visibility === 'hidden' && req.accessLevel !== 'client') { - return res.status(403).json({ error: 'Photo not available' }); - } - - // Ensure thumbnail exists and is valid, regenerate if needed - // Responsive tier (#1095), whitelisted the same way the preview route's - // is. Unrecognised or absent falls through to the canonical 300px - // thumbnail, so existing clients are untouched. - const { THUMBNAIL_WIDTHS, normalizeTierWidth, ensureThumbnailAtWidth } = - require('../services/imageProcessor'); - const thumbTier = normalizeTierWidth(req.query.w, THUMBNAIL_WIDTHS); - - const thumbnailPath = thumbTier - ? (await ensureThumbnailAtWidth(photo, thumbTier)) || (await ensureThumbnail(photo)) - : await ensureThumbnail(photo); - - // What was actually resolved, not what was asked for. A tier request can - // land on the canonical thumbnail — generation failed, or the row is a - // video — and stamping the requested tier into the ETag below would then - // have the client cache a 300px image under its 900px key for the full - // max-age, with no way to notice. - const servedTier = thumbTier && thumbnailPath - && path.basename(thumbnailPath).startsWith(`thumb_w${thumbTier}_`) - ? thumbTier - : null; - - if (!thumbnailPath) { - logger.error(`Failed to generate thumbnail for photo ${photoId}`); - return res.status(404).json({ error: 'Thumbnail generation failed' }); - } - - // Read thumbnail metadata via the storage abstraction so we work in - // both LocalFs and S3 modes (#432). The previous fs.statSync on the - // resolved local path 500'd in S3 deployments because the thumbnail - // only exists in the bucket, not on the container's local fs. - const storage = getStorage(); - const stat = await storage.stat(thumbnailPath); - if (!stat) { - logger.error(`Thumbnail not found in storage backend for photo ${photoId}`, { thumbnailPath }); - return res.status(404).json({ error: 'Thumbnail not found' }); - } - - // Log thumbnail access - await secureImageService.logImageAccess( - photoId, - req.event.id, - req.clientInfo, - 'thumbnail' - ); - - // Check if watermarks are enabled and apply to thumbnail - const watermarkSettings = await watermarkService.getWatermarkSettings(); - - // ETag uses storage stat mtime + photo id + watermark hash. - const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0; - const watermarkHash = watermarkSettings?.enabled - ? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}` - : '-nowm'; - // Tier in the ETag, same reason as the preview route: without it a - // client holding the 300px thumbnail gets a 304 for its 600px request - // and renders the small one, which is this feature inverted. - const etag = `"thumb-${photoId}-${servedTier || 'def'}-${mtimeMs}${watermarkHash}"`; - - // Check if client has valid cached version - if (req.headers['if-none-match'] === etag) { - return res.status(304).end(); - } - - // Set appropriate headers with enhanced security - res.set({ - 'Content-Type': 'image/jpeg', - 'Cache-Control': 'private, max-age=1800', // Reduced cache time - 'Cross-Origin-Resource-Policy': 'cross-origin', - 'X-Content-Type-Options': 'nosniff', - 'X-Protected-Thumbnail': 'true', - 'ETag': etag - }); - - if (watermarkSettings && watermarkSettings.enabled) { - // Watermarking needs a local file path (sharp + fs.readFile). - // Materialize via withLocalCopy — no-op in local mode, downloads - // to a tmp file then cleans up in S3 mode. - const watermarkedBuffer = await withLocalCopy(thumbnailPath, (localPath) => - watermarkService.applyWatermark(localPath, watermarkSettings) - ); - res.send(watermarkedBuffer); - } else { - res.setHeader('Content-Length', stat.size); - const stream = await storage.get(thumbnailPath); - pipeStreamToResponse(stream, res, { context: `thumbnail for photo ${photoId}` }); - } - } catch (error) { - errorResponse(res, error, 500, 'Failed to serve thumbnail'); - } - } -); - -// Serve hero-optimized image (1920x1080 for full-width hero sections) -router.get('/:slug/hero/:photoId', - verifyGalleryAccess, - // Reveal-gated too: this route serves a 1920px derivative of ANY photo id, - // not just the chosen hero — an open bypass while hidden (review round 1). - blockHiddenGallery, - async (req, res) => { - try { - const { photoId } = req.params; - - const photo = await db('photos') - .where({ id: photoId, event_id: req.event.id }) - .first(); - - if (!photo) { - return res.status(404).json({ error: 'Photo not found' }); - } - - // Block guest access to hidden photos - if (photo.visibility === 'hidden' && req.accessLevel !== 'client') { - return res.status(403).json({ error: 'Photo not available' }); - } - - // Check if this is a video - videos don't get hero images - const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/')); - if (isVideo) { - // For videos, redirect to the regular photo endpoint - return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`)); - } - - // Ensure hero image exists and is valid, regenerate if needed - const heroPath = await ensureHeroImage(photo); - - if (!heroPath) { - // If hero generation fails, fall back to original photo - logger.warn(`Failed to generate hero image for photo ${photoId}, falling back to original`); - return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`)); - } - - // Hero images are always written via the storage abstraction (see - // imageProcessor.generateHeroImage), so they're a managed-storage - // key in both LocalFs and S3 modes (#432). Read via storage. - const storage = getStorage(); - const stat = await storage.stat(heroPath); - if (!stat) { - logger.error('Hero image file does not exist in storage backend', { - slug: req.params.slug, - photoId, - eventId: req.event.id, - heroPath - }); - return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`)); - } - - const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0; - const etag = `"hero-${photoId}-${mtimeMs}"`; - if (req.headers['if-none-match'] === etag) { - return res.status(304).end(); - } - - const watermarkSettings = await watermarkService.getWatermarkSettings(); - - res.set({ - 'Content-Type': 'image/jpeg', - 'Cache-Control': 'private, max-age=3600', // Cache for 1 hour - 'Cross-Origin-Resource-Policy': 'cross-origin', - 'X-Content-Type-Options': 'nosniff', - 'X-Hero-Image': 'true', - 'ETag': etag - }); - - if (watermarkSettings && watermarkSettings.enabled) { - // applyWatermark needs a local file path; materialize via - // withLocalCopy so this works in S3 mode too. - const watermarkedBuffer = await withLocalCopy(heroPath, (localPath) => - watermarkService.applyWatermark(localPath, watermarkSettings) - ); - res.send(watermarkedBuffer); - } else { - res.setHeader('Content-Length', stat.size); - const stream = await storage.get(heroPath); - pipeStreamToResponse(stream, res, { context: `hero for photo ${photoId}` }); - } - } catch (error) { - logger.error('Error serving hero image:', { - error: error.message, - photoId: req.params.photoId, - eventId: req.event?.id - }); - // Fall back to original photo on any error - res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${req.params.photoId}`)); - } - } -); - -// Lightbox preview tier (#492). Aspect-preserved JPEG capped at 1920px -// long edge — admin-controlled opt-in via app_settings.lightbox_preview_enabled. -// Mirrors the hero route shape: same auth, ETag from preview mtime, -// fall back to original on any failure so the lightbox never shows a -// broken image. The watermark application path is preserved so a -// preview surfaced in the lightbox carries the same protection a -// guest would see on the full original. -router.get('/:slug/preview/:photoId', - verifyGalleryAccess, - blockHiddenGallery, - async (req, res) => { - try { - const { photoId } = req.params; - - const photo = await db('photos') - .where({ id: photoId, event_id: req.event.id }) - .first(); - - if (!photo) { - return res.status(404).json({ error: 'Photo not found' }); - } - - if (photo.visibility === 'hidden' && req.accessLevel !== 'client') { - return res.status(403).json({ error: 'Photo not available' }); - } - - // Videos don't get a preview tier — fall through to the regular - // photo endpoint (which serves the source). The frontend should - // already be checking media_type before requesting /preview but - // belt-and-braces in case a stale tab does. - const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/')); - if (isVideo) { - return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`)); - } - - // Responsive tier (#1095). Whitelisted only — an open ?w= would let - // anyone fill the disk with renditions nobody asked for. An unrecognised - // or absent value falls through to the canonical 1920 preview, so old - // clients and hand-typed URLs behave exactly as before. - const { PREVIEW_WIDTHS, normalizeTierWidth, ensurePreviewImageAtWidth } = - require('../services/imageProcessor'); - const tierWidth = normalizeTierWidth(req.query.w, PREVIEW_WIDTHS); - - // Lazy generation: ensurePreviewImage returns null on any - // failure (corrupt source, sharp OOM, storage unavailable, …). - // Fall back to the original so the lightbox always renders. - const previewPath = tierWidth - ? (await ensurePreviewImageAtWidth(photo, tierWidth)) || (await ensurePreviewImage(photo)) - : await ensurePreviewImage(photo); - if (!previewPath) { - logger.warn(`Failed to generate preview for photo ${photoId}, falling back to original`); - return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`)); - } - - const storage = getStorage(); - const stat = await storage.stat(previewPath); - if (!stat) { - logger.error('Preview file does not exist in storage backend', { - slug: req.params.slug, photoId, eventId: req.event.id, previewPath, - }); - return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`)); - } - - const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0; - const watermarkSettings = await watermarkService.getWatermarkSettings(); - const watermarkHash = watermarkSettings?.enabled - ? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}` - : '-nowm'; - // Tier is part of the etag: without it a client that already holds the - // 1920 rendition would get a 304 for its 640 request and render the - // wrong size, which is the whole point of the feature inverted. - const etag = `"preview-${photoId}-${tierWidth || 'def'}-${mtimeMs}${watermarkHash}"`; - if (req.headers['if-none-match'] === etag) { - return res.status(304).end(); - } - - res.set({ - // From the key, not hard-coded: a preview of a transparent or animated - // source is WebP, because JPEG carries neither. `nosniff` below means - // getting this wrong shows a broken image rather than being silently - // corrected by the browser. Pre-existing keys have no .webp suffix and - // are JPEG, so they keep their old header. - 'Content-Type': previewPath.endsWith('.webp') ? 'image/webp' : 'image/jpeg', - // Cache aggressively — preview only changes on photo - // re-upload (which generates a new preview key) or settings - // regenerate (which writes a new mtime + ETag). - 'Cache-Control': 'private, max-age=3600', - 'Cross-Origin-Resource-Policy': 'cross-origin', - 'X-Content-Type-Options': 'nosniff', - 'X-Preview-Image': 'true', - 'ETag': etag, - }); - - if (watermarkSettings && watermarkSettings.enabled) { - // No Content-Type override here. applyWatermark PRESERVES the source - // format (watermarkService.js: png -> png, webp -> webp, else jpeg), - // and its input is this preview — so the output format matches the key - // the header was already derived from. Forcing image/jpeg would - // mislabel a watermarked WebP preview, and `nosniff` means the browser - // will not correct it. - // - // What is still lost is the animation: the compositor flattens a - // multi-frame source to one frame while keeping the WebP container. - // That is a separate problem and a much larger one. - const watermarkedBuffer = await withLocalCopy(previewPath, (localPath) => - watermarkService.applyWatermark(localPath, watermarkSettings) - ); - res.send(watermarkedBuffer); - } else { - res.setHeader('Content-Length', stat.size); - const stream = await storage.get(previewPath); - pipeStreamToResponse(stream, res, { context: `preview for photo ${photoId}` }); - } - } catch (error) { - logger.error('Error serving preview image:', { - error: error.message, - photoId: req.params.photoId, - eventId: req.event?.id, - }); - res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${req.params.photoId}`)); - } - } -); - -// GET /:slug/feedback-settings lives in galleryFeedback.js. A duplicate of it -// used to sit here, and since server.js mounts galleryRoutes before -// galleryFeedback it shadowed the real handler — dropping the per-guest caps -// (#655) from the guest payload, so the gallery could never render the -// favorite/like limits or their counters (#1030). - -// Get photo stats. no-store: view/download/visitor counters are private -// gallery analytics and change on every request. -router.get('/:slug/stats', verifyGalleryAccess, blockHiddenGallery, noStoreCache, async (req, res) => { - try { - const totalPhotos = await db('photos') - .where('event_id', req.event.id) - .count('id as count') - .first(); - - const totalViews = await db('access_logs') - .where('event_id', req.event.id) - .where('action', 'view') - .count('id as count') - .first(); - - const totalDownloads = await db('photos') - .where('event_id', req.event.id) - .sum('download_count as total') - .first(); - - const uniqueVisitors = await db('access_logs') - .where('event_id', req.event.id) - .countDistinct('ip_address as count') - .first(); - - res.json({ - total_photos: totalPhotos.count, - total_views: totalViews.count, - total_downloads: totalDownloads.total || 0, - unique_visitors: uniqueVisitors.count - }); - } catch (error) { - res.status(500).json({ error: 'Failed to fetch stats' }); - } -}); - -// User photo upload endpoint -router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (req, res) => { - try { - const eventId = parseInt(req.params.eventId); - - // Verify the event matches the token - if (req.event.id !== eventId) { - return res.status(403).json({ error: 'Access denied' }); - } - - // Check if user uploads are allowed - if (!req.event.allow_user_uploads) { - return res.status(403).json({ error: 'User uploads are not allowed for this event' }); - } - - // Ensure temp upload directory exists - const fs = require('fs'); - const tempUploadDir = '/tmp/uploads/'; - if (!fs.existsSync(tempUploadDir)) { - try { - fs.mkdirSync(tempUploadDir, { recursive: true, mode: 0o755 }); - logger.info('Created temp upload directory:', tempUploadDir); - } catch (mkdirErr) { - return errorResponse(res, mkdirErr, 500, 'Server configuration error: unable to create upload directory'); - } - } - - // Import multer and photo processing - const multer = require('multer'); - const { getAllowedMimeTypes, getMaxFilesPerUpload, getMaxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_MB } = require('../services/uploadSettings'); - const { validateFileType } = require('../utils/fileSecurityUtils'); - - // Resolve allowed MIME types from settings - let allowedMimeTypes; - try { - allowedMimeTypes = await getAllowedMimeTypes(); - } catch { - allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp']; - } - - // #613 — per-batch file count was hardcoded to 10 here, so the admin's - // Settings → General → "Max Files per Upload" value silently didn't - // apply to guest uploads (only admin uploads honoured it via - // adminPhotos.js:131). Zszywany reported uploading 16 files succeeded - // even with the limit set to 10. Mirror the admin path: resolve from - // settings (cached for 60s in the service) and feed multer both - // `limits.files` and the `.array(...)` cap. Fall back to the service's - // default if the read fails. - let maxFilesPerUpload; - try { - maxFilesPerUpload = await getMaxFilesPerUpload(); - } catch { - maxFilesPerUpload = 500; - } - - // Per-file size cap was hardcoded to 50MB here, so the admin's Settings → - // General → "Max File Size (MB)" value (general_max_file_size_mb) never - // applied to guest uploads — a guest could not upload a large video even - // when the admin allowed it (reported on #613 by mat1990dj). Resolve it from - // settings like the count above; fall back to the 50MB default on read error. - let maxFileSizeBytes; - try { - maxFileSizeBytes = await getMaxFileSizeBytes(); - } catch { - maxFileSizeBytes = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024; - } - - const upload = multer({ - dest: tempUploadDir, - limits: { - fileSize: maxFileSizeBytes, - files: maxFilesPerUpload - }, - fileFilter: (req, file, cb) => { - if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) { - cb(null, true); - } else { - cb(new Error('Invalid file type')); - } - } - }).array('photos', maxFilesPerUpload); - - // Handle upload - upload(req, res, async (err) => { - if (err) { - logger.error('Upload error:', err); - // Turn multer's generic "File too large" into an actionable message - // that names the configured limit. - if (err.code === 'LIMIT_FILE_SIZE') { - const limitMb = Math.floor(maxFileSizeBytes / (1024 * 1024)); - return res.status(400).json({ error: `File too large. Maximum size is ${limitMb} MB per file.` }); - } - return res.status(400).json({ error: err.message }); - } - - if (!req.files || req.files.length === 0) { - return res.status(400).json({ error: 'No files uploaded' }); - } - - const { queueFilesForProcessing } = require('../services/photoProcessor'); - const rawCategory = req.body.category_id || req.event.upload_category_id || null; - const numericCategoryId = (() => { - if (rawCategory === null || rawCategory === undefined) return null; - const n = parseInt(rawCategory, 10); - return Number.isFinite(n) ? n : null; - })(); - - try { - // Queue files as 'pending' — the background worker will process - // thumbnails / EXIF / dimensions off the request thread (#357). - const result = await queueFilesForProcessing(req.files, { - eventId, - photoType: 'individual', - categoryId: numericCategoryId, - }); - - res.status(202).json({ - message: 'Photos queued for processing', - upload_id: result.uploadId, - count: result.photos.length, - photo_ids: result.photos.map((p) => p.id), - photos: result.photos, - errors: result.errors.length > 0 ? result.errors : undefined, - }); - } catch (processError) { - errorResponse(res, processError, 500, 'Failed to process photos'); - } - }); - } catch (error) { - errorResponse(res, error, 500, 'Failed to upload photos'); - } -}); - -// A guest upload_id is `crypto.randomBytes(16).toString('hex')` -// (photoProcessor.js). The pattern is deliberately a little wider than that so -// an id-format change does not silently 400, but narrow enough that the value -// can only ever be an opaque token. -const UPLOAD_ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/; -// The guest UI uploads one file per request, so a batch of N files yields N -// upload ids. Batching them into a single poll keeps the request rate flat -// regardless of batch size; the cap bounds the IN-list. -const MAX_UPLOAD_STATUS_IDS = 50; - -/** - * GET /:slug/uploads/status?ids=[,…] - * - * Guest-facing processing status for the guest's own uploads (B7). - * - * The upload route answers 202 and queues the files, and /photos only returns - * rows that reached `processing_status: 'complete'`. Without this the gallery - * had to poll /photos blind, could not say "processing…", and could not tell a - * slow worker from a photo that failed outright — the guest just watched their - * upload not appear. - * - * Authorization: `verifyGalleryAccess` already resolved `req.event` from the - * caller's gallery token, and the query is filtered on `event_id = req.event.id` - * as well as the ids. An id belonging to another gallery therefore matches no - * row rather than being reported as forbidden — no cross-event read, and no - * existence oracle either. Slideshow tokens are denied because a kiosk never - * uploads. - * - * The response is counts only. The guest already knows which files they sent; - * anything more (filenames, `processing_error` strings, which can carry - * internal paths) would be leaking beyond "how far along is my upload". - */ -router.get('/:slug/uploads/status', verifyGalleryAccess, denySlideshowToken, noStoreCache, async (req, res) => { - try { - const ids = String(req.query.ids || '') - .split(',') - .map((id) => id.trim()) - .filter(Boolean); - - if (ids.length === 0 || ids.length > MAX_UPLOAD_STATUS_IDS || !ids.every((id) => UPLOAD_ID_PATTERN.test(id))) { - return res.status(400).json({ error: 'Invalid upload ids' }); - } - - const rows = await db('photos') - .where('event_id', req.event.id) - .whereIn('upload_id', ids) - .select('processing_status'); - - const summary = { total: rows.length, pending: 0, processing: 0, complete: 0, failed: 0 }; - for (const row of rows) { - // NULL is a pre-async-migration row, treated as complete exactly as the - // /photos filter treats it. - const status = row.processing_status || 'complete'; - if (Object.prototype.hasOwnProperty.call(summary, status) && status !== 'total') { - summary[status] += 1; - } - } - - res.json(summary); - } catch (error) { - errorResponse(res, error, 500, 'Failed to read upload status'); - } -}); - -/** - * GET /:slug/css-template - * Get custom CSS template for gallery (public endpoint) - */ -router.get('/:slug/css-template', async (req, res) => { - try { - const { slug } = req.params; - - // Find the event by slug - const event = await db('events') - .where({ slug }) - .select('css_template_id') - .first(); - - if (!event || !event.css_template_id) { - // No custom CSS - return 204 No Content - return res.status(204).send(); - } - - // Get the template if it's enabled - const template = await db('css_templates') - .where({ id: event.css_template_id, is_enabled: true }) - .select('css_content') - .first(); - - if (!template || !template.css_content) { - return res.status(204).send(); - } - - // Return CSS with caching headers - res.setHeader('Content-Type', 'text/css'); - res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour cache - res.send(template.css_content); - } catch (error) { - logger.error('Get CSS template error:', error); - res.status(500).send('/* Error loading template */'); - } -}); - +const router = require('express').Router(); +// Keep route order stable; each router owns one gallery responsibility. +router.use(require('./gallery/metadata')); +router.use(require('./gallery/slideshow')); +router.use(require('./gallery/photos')); +router.use(require('./gallery/downloads')); +router.use(require('./gallery/media')); +router.use(require('./gallery/stats')); +router.use(require('./gallery/uploads')); +router.use(require('./gallery/styles')); module.exports = router; diff --git a/backend/src/routes/gallery/downloads.js b/backend/src/routes/gallery/downloads.js new file mode 100644 index 00000000..1dee1a08 --- /dev/null +++ b/backend/src/routes/gallery/downloads.js @@ -0,0 +1,945 @@ +const express = require('express'); +const { db, logActivity } = require('../../database/db'); +const { parseBooleanInput } = require('../../utils/parsers'); +const archiver = require('archiver'); +const path = require('path'); +const { resolvePhotoContentType } = require('../../utils/photoContentType'); +const router = express.Router(); +const watermarkService = require('../../services/watermarkService'); +const { verifyGalleryAccess, denySlideshowToken } = require('../../middleware/gallery'); +const { noStoreCache } = require('../../middleware/noStoreCache'); +const logger = require('../../utils/logger'); +const { pipeStreamToResponse } = require('../../utils/streamResponse'); +const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../../services/photoResolver'); +const { errorResponse } = require('../../utils/routeHelpers'); +const { blockHiddenGallery } = require('../../utils/revealMode'); +const downloadZipService = require('../../services/downloadZipService'); +const { renderPhotoForDownload, resolveWatermarkSettings } = require('../../services/downloadRendition'); +const downloadJobService = require('../../services/downloadJobService'); +const { + resolveEventDownloadPolicy, + pickRequestedResolution, + parseResolution, +} = require('../../utils/downloadResolutions'); +const { applyPhotoVisibilityFilter, canSeeHiddenPhotos } = require('../../utils/photoVisibility'); +const { + getUseOriginalFilenames, + pickRawDownloadName, + getZipEntryNames, +} = require('../../services/downloadFilenameService'); +const { buildContentDisposition } = require('../../utils/filenameSanitizer'); +const { getStorage } = require('../../services/storage'); +const fs = require('fs'); +function parseByteRange(header, size) { + if (!header || typeof header !== 'string' || !size) return null; + const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim()); + if (!match) return null; + + const [, rawStart, rawEnd] = match; + if (rawStart === '' && rawEnd === '') return null; + + let start; + let end; + if (rawStart === '') { + // Suffix form: the last N bytes. + const suffix = parseInt(rawEnd, 10); + if (!suffix) return null; + start = Math.max(0, size - suffix); + end = size - 1; + } else { + start = parseInt(rawStart, 10); + end = rawEnd === '' ? size - 1 : parseInt(rawEnd, 10); + } + + if (!Number.isFinite(start) || !Number.isFinite(end)) return null; + if (start > end || start >= size) return null; + return { start, end: Math.min(end, size - 1) }; +} +function galleryActor(req) { + // Portal tokens run as accessLevel 'guest' but carry via:'customer' + // (req.viaCustomer); PIN-client logins carry accessLevel 'client'. + // Both are customers, not guests (codex review of #849, final round). + const isCustomer = !!(req && (req.viaCustomer || req.accessLevel === 'client')); + return { type: isCustomer ? 'customer' : 'guest' }; +} +const SINGLE_DOWNLOAD_DEBOUNCE_MS = 60 * 60 * 1000; +const singleDownloadNotifiedAt = new Map(); +function notifySinglePhotoDownload(event, req) { + const now = Date.now(); + const last = singleDownloadNotifiedAt.get(event.id) || 0; + if (now - last < SINGLE_DOWNLOAD_DEBOUNCE_MS) return; + singleDownloadNotifiedAt.set(event.id, now); + logActivity('gallery_downloaded', { scope: 'single' }, event.id, galleryActor(req)); +} + +router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => { + try { + const { photoId } = req.params; + + // Check if downloads are allowed for this event + if (!parseBooleanInput(req.event.allow_downloads, true)) { + return res.status(403).json({ error: 'Downloads are disabled for this gallery' }); + } + + const photo = await db('photos') + .where({ id: photoId, event_id: req.event.id }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + // Block guest access to hidden photos + if (photo.visibility === 'hidden' && req.accessLevel !== 'client') { + return res.status(403).json({ error: 'Photo not available' }); + } + + // Per-category download permission (#640). Photos without a category are + // always downloadable when the event allows downloads — only categorised + // photos can opt out per-category. + if (photo.category_id) { + const cat = await db('photo_categories') + .where('id', photo.category_id) + .first('allow_downloads'); + if (cat && !parseBooleanInput(cat.allow_downloads, true)) { + return res.status(403).json({ error: 'Downloads are disabled for this category' }); + } + } + + // Download resolution (#858). Resolved BEFORE the counters below: a + // rejected resolution must not inflate download stats, which a guest + // could otherwise do by replaying ?resolution=bogus. + const isVideo = photo.media_type === 'video' + || (photo.mime_type && photo.mime_type.startsWith('video/')); + const policy = await resolveEventDownloadPolicy(req.event); + const requested = pickRequestedResolution(policy, req.query.resolution); + if (requested === null) { + return res.status(400).json({ error: 'Resolution not available for this gallery' }); + } + const box = isVideo ? null : parseResolution(requested); + + // A HEAD is a metadata probe, not a download. Answering it below the + // counters recorded every probe as a real download, and answering it below + // renderPhotoForDownload fetched and watermarked an image whose body Node + // then discards. Both happen before this point in a GET, so HEAD leaves + // here — with no side effects and no bytes read. + if (req.method === 'HEAD') { + const headUseOriginal = await getUseOriginalFilenames(); + const headHeaders = { + 'Content-Type': resolvePhotoContentType(photo), + 'Content-Disposition': buildContentDisposition(pickRawDownloadName(photo, headUseOriginal)), + 'Accept-Ranges': 'bytes', + }; + + // Content-Length only when the bytes ship untransformed AND the size can + // be read without fetching them. A watermark or resize changes the + // length, and the only way to learn the new one is to do the work this + // branch exists to avoid — HEAD is allowed to omit it. + const headWatermark = await resolveWatermarkSettings(req.event); + if (!box && !headWatermark) { + try { + const headKey = resolvePhotoStorageKey(req.event, photo); + const headStorage = getStorage(); + if (headKey && headStorage.kind() !== 'local') { + const headStat = await headStorage.stat(headKey); + if (!headStat) return res.status(404).json({ error: 'Photo file not found' }); + headHeaders['Content-Length'] = headStat.size; + if (headStat.mtime) headHeaders['Last-Modified'] = new Date(headStat.mtime).toUTCString(); + } + } catch (headErr) { + // No length is a valid HEAD; not worth failing the probe over. + logger.debug('HEAD probe could not stat the object', { photoId, error: headErr.message }); + } + } + + res.set(headHeaders); + return res.end(); + } + + // Admin preview (#868) downloads are excluded from the download count + + // guest analytics — kept out of client-facing stats. + if (!req.isAdminPreview) { + // Update download count + await db('photos').where('id', photoId).increment('download_count', 1); + + // Log download + await db('access_logs').insert({ + event_id: req.event.id, + ip_address: req.ip, + user_agent: req.headers['user-agent'], + action: 'download', + photo_id: photoId + }); + } + // Surface in the admin notification bell (#746) — debounced, and only + // once the response actually finished: notifying up-front would log a + // download that then 404s/fails and the debounce would suppress the + // next real one for an hour (codex review of #849). + res.on('finish', () => { + if (res.statusCode < 400 && !req.isAdminPreview) notifySinglePhotoDownload(req.event, req); + }); + + // #493: if the admin enabled "use original filenames", surface the + // pre-rename camera filename in Content-Disposition. Storage path is + // unchanged — only the user-visible download name is swapped. + const useOriginal = await getUseOriginalFilenames(); + const downloadName = pickRawDownloadName(photo, useOriginal); + const contentDisposition = buildContentDisposition(downloadName); + + // The gallery's standard applies to EVERY ordinary download, single photos + // included — otherwise a lowered standard is trivially bypassed by + // downloading photos one at a time. `box` was resolved above, before the + // counters. Videos have no resize path and always ship as-is. + // + // renderPhotoForDownload (#858) owns the resize-then-watermark ordering + // and the storage fetch, and is what the zip builders below already use. + // It returns null when the photo needs no transformation at all, which is + // the default gallery's common case and lets us ship the stored bytes + // without buffering a full-size original into memory. + const effectiveSettings = await resolveWatermarkSettings(req.event); + + let rendered; + try { + rendered = await renderPhotoForDownload(req.event, photo, box, effectiveSettings); + } catch (renderError) { + // Classify, the same way the pass-through branch below does. This can + // reject because the source object is gone, but equally because + // getToFile timed out, the tmp filesystem filled up, or sharp failed — + // and reporting an operational failure as 404 tells the guest their + // photo does not exist and tells us nothing. + const gone = renderError.code === 'ENOENT' + || renderError.name === 'NoSuchKey' + || renderError.name === 'NotFound' + || renderError.$metadata?.httpStatusCode === 404; + logger.error('Failed to render photo for download', { + slug: req.params.slug, + photoId, + eventId: req.event.id, + error: renderError.message, + }); + return gone + ? res.status(404).json({ error: 'Photo file not found' }) + : res.status(500).json({ error: 'Failed to download photo' }); + } + + if (rendered) { + res.set({ + 'Content-Type': resolvePhotoContentType(photo), + 'Content-Disposition': contentDisposition, + 'Content-Length': rendered.length + }); + + return res.send(rendered); + } + + // Untransformed: ship the stored bytes. + // + // Managed photos live behind the storage abstraction and on an S3/R2 + // deployment are not on local disk at all — resolving a filesystem path + // unconditionally here is what made every single-photo download 404 with + // ENOENT in S3 mode (#1048), while download-all and secure-images worked + // because they already went through getStorage(). + // + // resolvePhotoStorageKey returns null for external/reference photos: those + // live on a local mount and keep the sendFile path. + let storageKey = null; + try { + storageKey = resolvePhotoStorageKey(req.event, photo); + } catch (resolveError) { + logger.error('Failed to resolve photo storage key for download', { + slug: req.params.slug, + photoId, + eventId: req.event.id, + error: resolveError.message, + }); + return res.status(404).json({ error: 'Photo file not found' }); + } + + const storage = getStorage(); + if (storageKey && storage.kind() !== 'local') { + // Deliberately NOT the local path: res.sendFile emits Content-Length, + // Accept-Ranges, ETag and Last-Modified and answers Range requests with + // a 206, and a bare stream.pipe(res) has none of that. On local disk + // sendFile stays the better implementation, so it stays the branch. + // + // On S3 we reproduce the parts that matter for a download: the length + // (browsers need it for the progress indicator, which matters most on + // exactly the large files this route serves) and Range, so an + // interrupted download resumes instead of appending a second full body + // onto the partial file. Conditional requests are not reproduced — + // there is no ETag here, so a client revalidating gets the whole body, + // same as it does today. + const stat = await storage.stat(storageKey); + if (!stat) { + logger.error('Photo not found in storage backend for download', { + slug: req.params.slug, + photoId, + eventId: req.event.id, + storageKey, + }); + return res.status(404).json({ error: 'Photo file not found' }); + } + + const lastModified = stat.mtime ? new Date(stat.mtime).toUTCString() : null; + const headers = { + 'Content-Type': resolvePhotoContentType(photo), + 'Content-Disposition': contentDisposition, + 'Accept-Ranges': 'bytes', + }; + if (lastModified) headers['Last-Modified'] = lastModified; + + // If-Range: a client resuming an interrupted download sends back the + // validator it was given last time. If the object has been replaced + // since — the watcher re-importing a swapped file, an admin re-upload — + // answering 206 from the NEW bytes lets the client splice two different + // versions into one corrupt file. A validator that doesn't match means + // a full 200, which is the whole point of the header. + const ifRange = req.headers['if-range']; + const staleValidator = !!ifRange && (!lastModified || ifRange.trim() !== lastModified); + const range = staleValidator ? null : parseByteRange(req.headers.range, stat.size); + + // Open the stream BEFORE any header is staged or sent. stat() succeeding + // does not mean get() will: a concurrent delete or replace, or a + // transient backend error, lands here. Once writeHead(206) has gone out + // the outer catch can do nothing but throw ERR_HTTP_HEADERS_SENT, and in + // the non-range case it would send its 500 JSON underneath the staged + // image/jpeg attachment headers — a .jpg file full of JSON. + let stream; + try { + stream = range + ? await storage.getRange(storageKey, range.start, range.end) + : await storage.get(storageKey); + } catch (fetchError) { + const gone = fetchError.code === 'ENOENT' + || fetchError.name === 'NoSuchKey' + || fetchError.name === 'NotFound' + || fetchError.$metadata?.httpStatusCode === 404; + logger.error('Failed to open photo stream for download', { + slug: req.params.slug, + photoId, + eventId: req.event.id, + storageKey, + error: fetchError.message, + }); + return gone + ? res.status(404).json({ error: 'Photo file not found' }) + : res.status(500).json({ error: 'Failed to download photo' }); + } + + if (range) { + // status()+set() rather than writeHead(): writeHead commits the + // response immediately, so a stream that resolves and THEN errors + // before its first chunk would leave pipeStreamToResponse able only to + // destroy the connection. Staged headers are flushed by the first body + // write, which means an error at byte zero can still clear them and + // return a clean, retryable status instead of a transport reset. + res.status(206).set({ + ...headers, + 'Content-Range': `bytes ${range.start}-${range.end}/${stat.size}`, + 'Content-Length': (range.end - range.start) + 1, + }); + } else { + res.set({ ...headers, 'Content-Length': stat.size }); + } + pipeStreamToResponse(stream, res, { + context: range ? `download range for photo ${photo.id}` : `download for photo ${photo.id}`, + }); + return; + } + + let filePath; + try { + filePath = resolvePhotoFilePath(req.event, photo); + } catch (resolveError) { + logger.error('Failed to resolve photo path for download', { + slug: req.params.slug, + photoId, + eventId: req.event.id, + error: resolveError.message, + }); + return res.status(404).json({ error: 'Photo file not found' }); + } + + // res.download() builds Content-Disposition itself but doesn't emit the + // RFC 5987 filename* parameter, so unicode camera filenames would lose + // their bytes on download. Set the header explicitly and stream the + // file with res.sendFile-equivalent semantics. + res.set({ + 'Content-Type': resolvePhotoContentType(photo), + 'Content-Disposition': contentDisposition, + }); + res.sendFile(filePath, (downloadError) => { + if (downloadError) { + logger.error('Error streaming gallery download', { + slug: req.params.slug, + photoId, + eventId: req.event.id, + error: downloadError.message, + }); + } + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to download photo'); + } +}); + +// Download all photos as ZIP +// Zip downloads count toward each contained photo's download_count (#895) +// — previously only single-photo downloads did, so galleries whose guests +// grab the zip showed 0 per-photo downloads forever. Used by the +// pre-generated-zip branches only: it mirrors downloadZipService._build, +// which zips EVERY event photo with no per-category allow_downloads +// filter — the counter has to reflect what actually shipped. (That the +// prebuilt zip ignores per-category download opt-outs is a separate, +// pre-existing issue.) Known approximation: _build skips entries whose +// WATERMARK step fails and still publishes the zip; counting those +// would need a persisted archive manifest, which isn't worth it for +// that tail case. Fire-and-forget at the call sites: counters must +// never fail a download. +async function bumpEventDownloadCounts(eventId) { + await db('photos').where('event_id', eventId).increment('download_count', 1); +} + +router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => { + try { + // Check if downloads are allowed for this event + if (!parseBooleanInput(req.event.allow_downloads, true)) { + return res.status(403).json({ error: 'Downloads are disabled for this gallery' }); + } + + // Try to serve pre-generated zip (instant download with Content-Length). + // Guests may use the prebuilt cache ONLY when the event has no hidden + // photos: a cache built before a photo was hidden — or before this + // visibility-aware builder shipped — could otherwise still leak it, and + // getZipInfo only checks the DB pointer + file stat, not freshness. When + // hidden photos exist, guests fall through to the visibility-filtered + // stream below. PIN-clients always stream a full archive. + const isClient = canSeeHiddenPhotos(req.accessLevel); + const eventHasHidden = await db('photos') + .where({ event_id: req.event.id, visibility: 'hidden' }) + .first() + .then(Boolean); + const zipInfo = (isClient || eventHasHidden) + ? null + : await downloadZipService.getZipInfo(req.event.id); + if (zipInfo) { + const storage = getStorage(); + + // Stream via the authenticated route so logout, restore and account + // changes are checked on every download, including S3-backed archives. + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Length', zipInfo.size); + res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`); + const stream = await storage.get(zipInfo.key); + pipeStreamToResponse(stream, res, { context: `prepared zip for event ${req.event.id}`, missingStatus: 410 }); + + // Log bulk download (admin preview #868 excluded — stats stay client-only). + if (!req.isAdminPreview) { + db('access_logs').insert({ + event_id: req.event.id, + ip_address: req.ip, + user_agent: req.headers['user-agent'], + action: 'download_all' + }).catch(() => {}); + bumpEventDownloadCounts(req.event.id).catch(() => {}); + // Surface in the admin notification bell (#746) — only once the + // stream actually finished; logging at pipe-time would report + // downloads that then broke mid-transfer (codex review of #849). + res.on('finish', () => { + if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req)); + }); + } + return; + } + + // Fallback: on-the-fly streaming (existing behavior). Only pre-build the + // guest cache when it will actually be served next time — a guest + // download of an event with no hidden photos. Client bypasses and + // hidden-photo events always stream, so rebuilding the guest archive on + // those requests is wasted I/O (codex review). + if (!isClient && !eventHasHidden) { + downloadZipService.generateZip(req.event.id).catch(err => + logger.warn('Background zip generation failed', { eventId: req.event.id, error: err.message }) + ); + } + + // Fetch photos — exclude photos in categories that disabled downloads (#640). + // Uncategorised photos are always included; categories without the column + // (pre-migration-135) fall through the LEFT JOIN's null and are included. + const photos = await applyPhotoVisibilityFilter( + db('photos') + .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id') + .where('photos.event_id', req.event.id) + .where(function () { + this.whereNull('photos.category_id') + .orWhere('photo_categories.allow_downloads', true) + .orWhereNull('photo_categories.allow_downloads'); + }), + req.accessLevel + ) + .select('photos.*') + .orderBy('photos.type', 'asc') + .orderBy('photos.uploaded_at', 'desc'); + + if (photos.length === 0) { + return res.status(404).json({ error: 'No photos found' }); + } + + // Count unique types + const uniqueTypes = new Set(photos.map(p => p.type)).size; + const hasMultipleTypes = uniqueTypes > 1; + + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`); + + const archive = archiver('zip', { zlib: { level: 5 } }); + archive.on('error', (err) => { + throw err; + }); + + archive.pipe(res); + + // Get watermark settings - apply if global setting OR event-level setting is enabled + const watermarkSettings = await watermarkService.getWatermarkSettings(); + const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1; + const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled; + const effectiveSettings = shouldApplyWatermark ? { + ...watermarkSettings, + enabled: true, + text: req.event.watermark_text || watermarkSettings?.text || 'Protected' + } : null; + + // The gallery's standard resolution applies to the streamed archive too, + // not only the cached one (#858). + const { standardBox: bulkBox } = await resolveEventDownloadPolicy(req.event); + + // Add photos to archive — managed photos via storage backend, external via local path. + const { resolvePhotoStorageKey } = require('../../services/photoResolver'); + const storage = getStorage(); + // #493: resolve a unique display filename per photo up-front so collisions + // get a deterministic `_1` suffix before the entries hit the archive. + const useOriginalBulk = await getUseOriginalFilenames(); + const bulkEntryNames = getZipEntryNames(photos, useOriginalBulk); + // Only photos whose append succeeded count as downloaded (#895) — the + // catch below deliberately skips missing/corrupt sources, and those + // never make it into the archive. + const appendedIds = []; + for (let i = 0; i < photos.length; i += 1) { + const photo = photos[i]; + const storageKey = resolvePhotoStorageKey(req.event, photo); + const entryName = bulkEntryNames[i]; + let archiveName; + if (hasMultipleTypes) { + const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages'; + archiveName = path.join(folderName, entryName); + } else { + archiveName = entryName; + } + + try { + // Verify the source exists BEFORE appending — but only for local + // sources: fs.createReadStream is lazy, so its error fires outside + // this try/catch and the archive 'error' handler then kills the + // whole response instead of skipping one photo (#895 review). S3's + // get() awaits GetObject and rejects right here on a missing key, + // so a preflight HEAD per entry would just be a redundant serial + // round trip (500-photo zip = 500 extra HEADs). + if (storageKey && storage.kind() === 'local') { + const srcStat = await storage.stat(storageKey); + if (!srcStat) { + throw new Error(`Photo missing in storage: ${storageKey}`); + } + } else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) { + throw new Error('Photo file missing on disk'); + } + + // Resize to the gallery's standard resolution (#858) and/or watermark. + // This branch runs whenever the cached zip isn't usable — the first + // download after an invalidation, PIN clients, and galleries with + // hidden photos all land here, so skipping the cap would leak + // full-resolution files for exactly those cases. + const rendered = await renderPhotoForDownload(req.event, photo, bulkBox, effectiveSettings); + if (rendered) { + archive.append(rendered, { name: archiveName }); + } else if (storageKey) { + const stream = await storage.get(storageKey); + archive.append(stream, { name: archiveName }); + } else { + archive.file(resolvePhotoFilePath(req.event, photo), { name: archiveName }); + } + appendedIds.push(photo.id); + } catch (err) { + logger.warn('Skipping photo in bulk download due to error', { + slug: req.params.slug, + photoId: photo.id, + eventId: req.event.id, + error: err.message, + }); + } + } + + // Notification only after the response actually finished — finalize() + // ends Archiver's input, not the HTTP transfer (codex review of #849, + // confirmation round). Registered before finalize so it can't be missed. + // Admin preview (#868) streams the archive but is excluded from stats. + if (!req.isAdminPreview) { + res.on('finish', () => { + if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'all' }, req.event.id, galleryActor(req)); + }); + } + await archive.finalize(); + + if (!req.isAdminPreview) { + // Log bulk download + await db('access_logs').insert({ + event_id: req.event.id, + ip_address: req.ip, + user_agent: req.headers['user-agent'], + action: 'download_all' + }); + // Exactly the photos that made it into this archive (#895) — skipped + // (missing/corrupt) sources don't count. + if (appendedIds.length > 0) { + db('photos').whereIn('id', appendedIds) + .increment('download_count', 1).catch(() => {}); + } + } + } catch (error) { + errorResponse(res, error, 500, 'Failed to create download archive'); + } +}); + +// Download selected photos as ZIP +router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => { + try { + // Check if downloads are allowed for this event + if (!parseBooleanInput(req.event.allow_downloads, true)) { + return res.status(403).json({ error: 'Downloads are disabled for this gallery' }); + } + + const ids = Array.isArray(req.body?.photo_ids) ? req.body.photo_ids : []; + if (!ids.length) { + return res.status(400).json({ error: 'photo_ids is required (non-empty array)' }); + } + + // Clean IDs + const photoIds = ids + .map((v) => parseInt(v, 10)) + .filter((v) => Number.isInteger(v)) + .slice(0, 500); + + if (photoIds.length === 0) { + return res.status(400).json({ error: 'No valid photo IDs provided' }); + } + + // Fetch photos — exclude photos in categories that disabled downloads (#640). + // Same LEFT JOIN pattern as the download-all endpoint. + const photos = await applyPhotoVisibilityFilter( + db('photos') + .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id') + .where('photos.event_id', req.event.id) + .whereIn('photos.id', photoIds) + .where(function () { + this.whereNull('photos.category_id') + .orWhere('photo_categories.allow_downloads', true) + .orWhereNull('photo_categories.allow_downloads'); + }), + req.accessLevel + ) + .select('photos.*') + .orderBy('photos.uploaded_at', 'desc'); + + if (photos.length === 0) { + return res.status(404).json({ error: 'No photos found for selected IDs' }); + } + + // Download resolution (#858). Resolve BEFORE any header goes out — once + // the archive starts streaming we can no longer return a JSON error. + const selectedPolicy = await resolveEventDownloadPolicy(req.event); + const selectedResolution = pickRequestedResolution(selectedPolicy, req.body?.resolution); + if (selectedResolution === null) { + return res.status(400).json({ error: 'Resolution not available for this gallery' }); + } + const selectedBox = parseResolution(selectedResolution); + + const archiveName = `${req.event.slug}-selected.zip`; + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Disposition', `attachment; filename="${archiveName}"`); + + const archive = archiver('zip', { zlib: { level: 5 } }); + archive.on('error', (err) => { + logger.error('Zip error generating selected download', { + slug: req.params.slug, + eventId: req.event?.id, + error: err.message, + }); + try { + res.status(500).end(); + } catch (_) { + // ignore double-send errors + } + }); + archive.pipe(res); + + // Check watermark settings - apply if global setting OR event-level setting is enabled + const watermarkSettings = await watermarkService.getWatermarkSettings(); + const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1; + const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled; + const effectiveSettings = shouldApplyWatermark ? { + ...watermarkSettings, + enabled: true, + text: req.event.watermark_text || watermarkSettings?.text || 'Protected' + } : null; + + const { resolvePhotoStorageKey: resolveSelectedKey } = require('../../services/photoResolver'); + const selectedStorage = getStorage(); + // #493: same display-name resolution as bulk download, with dedup. + const useOriginalSelected = await getUseOriginalFilenames(); + const selectedEntryNames = getZipEntryNames(photos, useOriginalSelected); + // Only photos whose append succeeded count as downloaded (#895). + const appendedIds = []; + for (let i = 0; i < photos.length; i += 1) { + const photo = photos[i]; + const name = selectedEntryNames[i] || `photo-${photo.id}.jpg`; + const storageKey = resolveSelectedKey(req.event, photo); + try { + // Same pre-append source check as download-all (#895 review), + // local backend only: a lazy fs stream's async error would kill + // the response instead of skipping the photo; S3's get() rejects + // at the await below, so no redundant per-entry HEAD there. + if (storageKey && selectedStorage.kind() === 'local') { + const srcStat = await selectedStorage.stat(storageKey); + if (!srcStat) { + throw new Error(`Photo missing in storage: ${storageKey}`); + } + } else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) { + throw new Error('Photo file missing on disk'); + } + + // Resize (#858) and/or watermark. renderPhotoForDownload returns null + // when neither applies, so the untransformed case still streams from + // storage rather than buffering the whole photo. + const rendered = await renderPhotoForDownload(req.event, photo, selectedBox, effectiveSettings); + if (rendered) { + archive.append(rendered, { name }); + } else if (storageKey) { + const stream = await selectedStorage.get(storageKey); + archive.append(stream, { name }); + } else { + archive.file(resolvePhotoFilePath(req.event, photo), { name }); + } + appendedIds.push(photo.id); + } catch (err) { + logger.warn('Skipping selected photo due to error', { + slug: req.params.slug, + photoId: photo.id, + eventId: req.event.id, + error: err.message, + }); + } + } + + // See download-all: notify only on response 'finish'. + // Admin preview (#868) streams the archive but is excluded from stats. + if (!req.isAdminPreview) { + res.on('finish', () => { + if (res.statusCode < 400) logActivity('gallery_downloaded', { scope: 'selected', photo_count: photoIds.length }, req.event.id, galleryActor(req)); + }); + } + await archive.finalize(); + + if (!req.isAdminPreview) { + await db('access_logs').insert({ + event_id: req.event.id, + ip_address: req.ip, + user_agent: req.headers['user-agent'], + action: 'download_selected' + }); + // Exactly the photos that made it into this archive (#895) — skipped + // (missing/corrupt) sources don't count. + if (appendedIds.length > 0) { + db('photos').whereIn('id', appendedIds) + .increment('download_count', 1).catch(() => {}); + } + } + } catch (error) { + errorResponse(res, error, 500, 'Failed to download selected photos'); + } +}); + +// ────────────────────────────────────────────────────────────────────────── +// Custom-resolution download jobs (#858). +// +// The plain download-all is served from the pre-built cache at the gallery's +// STANDARD resolution. Picking a different size has nothing to cache against, +// and resizing a whole gallery inside one request would sit far past any +// reverse-proxy timeout — so those archives are built as a job the client +// polls. Same access rules as the download routes above. +// ────────────────────────────────────────────────────────────────────────── + +// Kick off (or join) a build. Returns the polling token. +router.post('/:slug/download-jobs', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => { + try { + if (!parseBooleanInput(req.event.allow_downloads, true)) { + return res.status(403).json({ error: 'Downloads are disabled for this gallery' }); + } + + const policy = await resolveEventDownloadPolicy(req.event); + if (!policy.pickerEnabled) { + return res.status(403).json({ error: 'Resolution choice is not enabled for this gallery' }); + } + const resolution = pickRequestedResolution(policy, req.body?.resolution); + if (resolution === null) { + return res.status(400).json({ error: 'Resolution not available for this gallery' }); + } + + // Optional subset. Absent = the whole visible gallery. + let photoIds = null; + if (Array.isArray(req.body?.photo_ids) && req.body.photo_ids.length) { + photoIds = req.body.photo_ids + .map((v) => parseInt(v, 10)) + .filter((v) => Number.isInteger(v)) + .slice(0, 500); + if (photoIds.length === 0) { + return res.status(400).json({ error: 'No valid photo IDs provided' }); + } + } + + let job; + try { + job = await downloadJobService.createJob({ + event: req.event, + resolution, + photoIds, + accessLevel: req.accessLevel, + }); + } catch (err) { + if (err.code === 'NO_PHOTOS') { + return res.status(404).json({ error: 'No photos available for this selection' }); + } + if (err.code === 'BUSY') { + return res.status(429).json({ error: 'Too many downloads are being prepared right now — please try again shortly' }); + } + throw err; + } + + res.status(202).json({ + token: job.token, + status: job.status, + resolution: job.resolution, + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to start download preparation'); + } +}); + +// Poll. The token is unguessable, but it is never sufficient on its own — +// verifyGalleryAccess still runs and the job must belong to THIS event. +// no-store: a cached 'preparing' would strand the caller in a poll that can +// never observe the job finishing. +router.get('/:slug/download-jobs/:token', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, noStoreCache, async (req, res) => { + try { + const job = await downloadJobService.getStatus(req.params.token); + if (!job || job.event_id !== req.event.id) { + return res.status(404).json({ error: 'Download job not found' }); + } + res.json({ + status: job.status, + resolution: job.resolution, + photo_count: job.photo_count || 0, + size_bytes: job.size_bytes || null, + error: job.status === 'failed' ? (job.error || 'Preparation failed') : undefined, + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to read download job'); + } +}); + +// Deliver the finished archive. +router.get('/:slug/download-jobs/:token/file', verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => { + try { + // Downloads can be switched off after a job was created — every other + // download route re-checks this per request, so this one must too. + if (!parseBooleanInput(req.event.allow_downloads, true)) { + return res.status(403).json({ error: 'Downloads are disabled for this gallery' }); + } + + const job = await downloadJobService.getStatus(req.params.token); + if (!job || job.event_id !== req.event.id) { + return res.status(404).json({ error: 'Download job not found' }); + } + // The token alone never grants access: the archive was built under one + // visibility scope, and only a requester still in that scope may take it. + // Without this, a leaked client token would hand hidden photos to a guest. + if (job.visibility_scope !== downloadJobService.visibilityScopeFor(req.accessLevel)) { + return res.status(404).json({ error: 'Download job not found' }); + } + if (job.status !== 'ready' || !job.zip_path) { + return res.status(409).json({ error: 'Download is not ready yet', status: job.status }); + } + if (new Date(job.expires_at).getTime() <= Date.now()) { + return res.status(410).json({ error: 'This download has expired — please request it again' }); + } + // A photo hidden AFTER this archive was built is still inside it, and the + // scope check above can't see that — both sides remain 'public'. Re-run + // the visibility query over the packaged set before handing it over. + if (!(await downloadJobService.isStillDeliverable(job, req.event, req.accessLevel))) { + return res.status(409).json({ + error: 'This gallery changed since the download was prepared — please request it again', + status: 'stale', + }); + } + + const storage = getStorage(); + const stat = await storage.stat(job.zip_path); + if (!stat) { + return res.status(410).json({ error: 'This download is no longer available' }); + } + + // Stats parity with the other bulk paths (#895): only count once the + // response actually completed, and keep admin previews out of guest stats. + res.on('finish', () => { + if (res.statusCode >= 400 || req.isAdminPreview) return; + // The DELIVERED set, not the requested one: a photo whose source was + // missing at build time isn't in the zip and must not be counted. + let ids = []; + try { + ids = JSON.parse(job.delivered_photo_ids || job.photo_ids || '[]'); + } catch (_) { /* malformed row — skip counting rather than fail */ } + if (ids.length > 0) { + db('photos').whereIn('id', ids).increment('download_count', 1).catch(() => {}); + } + db('access_logs').insert({ + event_id: req.event.id, + ip_address: req.ip, + user_agent: req.headers['user-agent'], + action: 'download', + photo_id: null, + }).catch(() => {}); + logActivity('gallery_downloaded', { scope: 'all', resolution: job.resolution }, + req.event.id, galleryActor(req)); + }); + + const suffix = job.resolution === 'original' ? 'original' : job.resolution; + res.setHeader('Content-Type', 'application/zip'); + res.setHeader('Content-Length', stat.size); + res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}-${suffix}.zip"`); + const stream = await storage.get(job.zip_path); + pipeStreamToResponse(stream, res, { context: `download job ${job.id}`, missingStatus: 410 }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to serve prepared download'); + } +}); + +// Explicit per-photo view beacon (#895). Counting views on the image- +// serving routes is wrong in both directions: the lightbox preloads the +// prev/next neighbours (three fetches per open), while a preloaded +// neighbour that becomes the current slide is never re-fetched (#505 +// keeps the DOM node alive across the swipe) — so request-level counters +// overcount preloads AND undercount swipe-throughs. Instead the lightbox +// pings this endpoint exactly when a photo becomes the visible slide. +// This also covers enhanced/maximum-protection galleries, whose bytes +// are served by /api/secure-images and never pass the routes below. +// The slideshow kiosk is excluded (denySlideshowToken; migration 138). + +module.exports = router; diff --git a/backend/src/routes/gallery/media.js b/backend/src/routes/gallery/media.js new file mode 100644 index 00000000..cec6eb39 --- /dev/null +++ b/backend/src/routes/gallery/media.js @@ -0,0 +1,639 @@ +const express = require('express'); +const { db } = require('../../database/db'); +const path = require('path'); +const { resolvePhotoContentType } = require('../../utils/photoContentType'); +const router = express.Router(); +const watermarkService = require('../../services/watermarkService'); +const watermarkGeneratorService = require('../../services/watermarkGeneratorService'); +const { verifyGalleryAccess, denySlideshowToken } = require('../../middleware/gallery'); +const withPreview = (req, url) => (req.isAdminPreview ? `${url}${url.includes('?') ? '&' : '?'}admin_preview=1` : url); +const secureImageService = require('../../services/secureImageService'); +const logger = require('../../utils/logger'); +const { pipeStreamToResponse } = require('../../utils/streamResponse'); + +const { errorResponse } = require('../../utils/routeHelpers'); +const { blockHiddenGallery } = require('../../utils/revealMode'); +const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../../services/imageProcessor'); +const { getStorage } = require('../../services/storage'); +const fs = require('fs'); +const { getStoragePath } = require('../../config/storage'); + +router.post('/:slug/photo/:photoId/view', + verifyGalleryAccess, + denySlideshowToken, + blockHiddenGallery, + async (req, res) => { + try { + const photo = await db('photos') + .where({ id: req.params.photoId, event_id: req.event.id }) + .first('id', 'visibility'); + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + if (photo.visibility === 'hidden' && req.accessLevel !== 'client') { + return res.status(403).json({ error: 'Photo not available' }); + } + // Admin preview (#981 review) is excluded from per-photo view analytics. + if (!req.isAdminPreview) { + await db('photos').where('id', photo.id).increment('view_count', 1); + } + res.status(204).end(); + } catch (error) { + errorResponse(res, error, 500, 'Failed to record view'); + } + }); + +// View single photo (with watermark if enabled) +router.get('/:slug/photo/:photoId', + verifyGalleryAccess, + blockHiddenGallery, + async (req, res) => { + try { + const { photoId } = req.params; + + const photo = await db('photos') + .where({ id: photoId, event_id: req.event.id }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + // Block guest access to hidden photos + if (photo.visibility === 'hidden' && req.accessLevel !== 'client') { + return res.status(403).json({ error: 'Photo not available' }); + } + + // Check if this is a video + const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/')); + + // Check protection level - basic and standard protection allow direct JWT access + const protectionLevel = req.event.protection_level || 'standard'; + + if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') { + // For enhanced/maximum protection, redirect to secure endpoint + return res.status(302).json({ + error: 'Secure access required', + secureEndpoint: `/api/secure-images/${req.params.slug}/generate-token`, + photoId: photoId + }); + } + + // Resolve where to read the photo bytes from. For external/reference + // photos the source is always a local mount path. For managed photos + // we go through the storage abstraction so S3 deployments work too + // (#432 — previously this route did fs.* directly and 500'd in S3 + // mode because the file wasn't on the container's local fs). + const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../../services/photoResolver'); + const storage = getStorage(); + const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference'; + const useStorageBackend = !isExternal; + + let filePath = null; // Local fs path (external photos OR LocalFs storage) + let storageKey = null; // Relative storage key (managed photos via storage abstraction) + let stat; + let fileSize; + + if (useStorageBackend) { + try { + storageKey = resolvePhotoStorageKey(req.event, photo); + } catch (resolveError) { + logger.error('Failed to resolve photo storage key', { + slug: req.params.slug, + photoId, + eventId: req.event.id, + error: resolveError.message, + photoPath: photo.path, + photoFilename: photo.filename + }); + return res.status(404).json({ error: 'Photo file not found' }); + } + stat = await storage.stat(storageKey); + if (!stat) { + logger.error('Photo not found in storage backend', { + slug: req.params.slug, + photoId, + eventId: req.event.id, + storageKey + }); + return res.status(404).json({ error: 'Photo file not found' }); + } + fileSize = stat.size; + } else { + try { + filePath = resolvePhotoFilePath(req.event, photo); + } catch (resolveError) { + logger.error('Failed to resolve photo path', { + slug: req.params.slug, + photoId, + eventId: req.event.id, + error: resolveError.message, + photoPath: photo.path, + photoFilename: photo.filename + }); + return res.status(404).json({ error: 'Photo file not found' }); + } + if (!fs.existsSync(filePath)) { + logger.error('Photo file does not exist at resolved path', { + slug: req.params.slug, + photoId, + eventId: req.event.id, + resolvedPath: filePath, + photoPath: photo.path + }); + return res.status(404).json({ error: 'Photo file not found' }); + } + stat = fs.statSync(filePath); + fileSize = stat.size; + } + + // Handle video streaming with range requests + if (isVideo) { + const range = req.headers.range; + + if (range) { + const parts = range.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; + // Validate before writing the 206: a NaN, inverted or out-of-file + // range used to be committed to the headers and then throw while + // streaming (or read past the end). + if (!Number.isInteger(start) || !Number.isInteger(end) + || start < 0 || end < start || start >= fileSize) { + res.set('Content-Range', `bytes */${fileSize}`); + return res.status(416).end(); + } + const boundedEnd = Math.min(end, fileSize - 1); + const chunksize = (boundedEnd - start) + 1; + + res.writeHead(206, { + 'Content-Range': `bytes ${start}-${boundedEnd}/${fileSize}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunksize, + 'Content-Type': resolvePhotoContentType(photo), + 'Cache-Control': 'private, max-age=1800', + 'X-Protection-Level': 'basic' + }); + + const file = useStorageBackend + ? await storage.getRange(storageKey, start, boundedEnd) + : fs.createReadStream(filePath, { start, end: boundedEnd }); + pipeStreamToResponse(file, res, { context: `video range for photo ${photo.id}` }); + } else { + res.writeHead(200, { + 'Content-Length': fileSize, + 'Content-Type': resolvePhotoContentType(photo), + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'private, max-age=1800', + 'X-Protection-Level': 'basic' + }); + const file = useStorageBackend + ? await storage.get(storageKey) + : fs.createReadStream(filePath); + pipeStreamToResponse(file, res, { context: `video for photo ${photo.id}` }); + } + return; + } + + // Image path + const watermarkSettings = await watermarkService.getWatermarkSettings(); + + const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0; + const watermarkHash = watermarkSettings?.enabled + ? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}` + : '-nowm'; + // orientation_checked_at participates because the backfill (#1198) can + // change these bytes without touching either of the other two inputs: + // it rewrites the derived renditions while the ORIGINAL's mtime and the + // watermark settings both stay exactly as they were. Without it a guest + // holding a pre-fix ETag keeps getting 304 and keeps their cached + // sideways image, however many times the backfill succeeds. + const orientationVersion = photo.orientation_checked_at + ? `-o${new Date(photo.orientation_checked_at).getTime()}` + : ''; + const etag = `"${photoId}-${mtimeMs}${watermarkHash}${orientationVersion}"`; + + if (req.headers['if-none-match'] === etag) { + return res.status(304).end(); + } + + if (watermarkSettings && watermarkSettings.enabled) { + // Pre-generated watermarked file: served via the storage backend + // (managed) or directly from local fs (external). + if (photo.watermark_path) { + try { + if (useStorageBackend) { + const wmStat = await storage.stat(photo.watermark_path); + if (wmStat) { + res.set({ + 'Content-Type': resolvePhotoContentType(photo), + 'Content-Length': wmStat.size, + 'Cache-Control': 'private, max-age=1800', + 'ETag': etag, + 'X-Protection-Level': 'basic' + }); + const wmStream = await storage.get(photo.watermark_path); + return pipeStreamToResponse(wmStream, res, { context: `watermarked photo ${photo.id}` }); + } + } else { + const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path); + if (fs.existsSync(watermarkFilePath)) { + res.set({ + 'Content-Type': resolvePhotoContentType(photo), + 'Cache-Control': 'private, max-age=1800', + 'ETag': etag, + 'X-Protection-Level': 'basic' + }); + return res.sendFile(watermarkFilePath); + } + } + } catch (err) { + logger.warn(`Pre-generated watermark not found for photo ${photoId}, falling back to on-the-fly`); + } + } + + // Fallback: apply watermark on-the-fly. applyWatermark needs a + // local file path (sharp + fs.readFile) — for managed photos in + // S3 mode, withLocalCopy materializes to a tmp file and cleans up. + const watermarkedBuffer = useStorageBackend + ? await withLocalCopy(storageKey, (localPath) => + watermarkService.applyWatermark(localPath, watermarkSettings)) + : await watermarkService.applyWatermark(filePath, watermarkSettings); + + // Queue watermark generation in background for next request + watermarkGeneratorService.generateForPhoto(photo.id) + .catch(err => logger.warn(`Background watermark generation failed for photo ${photo.id}:`, err.message)); + + res.set({ + 'Content-Type': resolvePhotoContentType(photo), + 'Cache-Control': 'private, max-age=1800', + 'ETag': etag, + 'X-Protection-Level': 'basic' + }); + + res.send(watermarkedBuffer); + } else { + res.set({ + 'Cache-Control': 'private, max-age=1800', + 'ETag': etag, + 'X-Protection-Level': 'basic' + }); + if (useStorageBackend) { + res.set('Content-Length', stat.size); + res.set('Content-Type', resolvePhotoContentType(photo)); + const stream = await storage.get(storageKey); + pipeStreamToResponse(stream, res, { context: `photo ${photo.id}` }); + } else { + const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath); + res.sendFile(absolutePath); + } + } + } catch (error) { + errorResponse(res, error, 500, 'Failed to serve photo'); + } + } +); + +// Serve thumbnail +router.get('/:slug/thumbnail/:photoId', + verifyGalleryAccess, + blockHiddenGallery, + async (req, res) => { + try { + const { photoId } = req.params; + + const photo = await db('photos') + .where({ id: photoId, event_id: req.event.id }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + // Block guest access to hidden photos + if (photo.visibility === 'hidden' && req.accessLevel !== 'client') { + return res.status(403).json({ error: 'Photo not available' }); + } + + // Ensure thumbnail exists and is valid, regenerate if needed + // Responsive tier (#1095), whitelisted the same way the preview route's + // is. Unrecognised or absent falls through to the canonical 300px + // thumbnail, so existing clients are untouched. + const { THUMBNAIL_WIDTHS, normalizeTierWidth, ensureThumbnailAtWidth } = + require('../../services/imageProcessor'); + const thumbTier = normalizeTierWidth(req.query.w, THUMBNAIL_WIDTHS); + + const thumbnailPath = thumbTier + ? (await ensureThumbnailAtWidth(photo, thumbTier)) || (await ensureThumbnail(photo)) + : await ensureThumbnail(photo); + + // What was actually resolved, not what was asked for. A tier request can + // land on the canonical thumbnail — generation failed, or the row is a + // video — and stamping the requested tier into the ETag below would then + // have the client cache a 300px image under its 900px key for the full + // max-age, with no way to notice. + const servedTier = thumbTier && thumbnailPath + && path.basename(thumbnailPath).startsWith(`thumb_w${thumbTier}_`) + ? thumbTier + : null; + + if (!thumbnailPath) { + logger.error(`Failed to generate thumbnail for photo ${photoId}`); + return res.status(404).json({ error: 'Thumbnail generation failed' }); + } + + // Read thumbnail metadata via the storage abstraction so we work in + // both LocalFs and S3 modes (#432). The previous fs.statSync on the + // resolved local path 500'd in S3 deployments because the thumbnail + // only exists in the bucket, not on the container's local fs. + const storage = getStorage(); + const stat = await storage.stat(thumbnailPath); + if (!stat) { + logger.error(`Thumbnail not found in storage backend for photo ${photoId}`, { thumbnailPath }); + return res.status(404).json({ error: 'Thumbnail not found' }); + } + + // Log thumbnail access + await secureImageService.logImageAccess( + photoId, + req.event.id, + req.clientInfo, + 'thumbnail' + ); + + // Check if watermarks are enabled and apply to thumbnail + const watermarkSettings = await watermarkService.getWatermarkSettings(); + + // ETag uses storage stat mtime + photo id + watermark hash. + const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0; + const watermarkHash = watermarkSettings?.enabled + ? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}` + : '-nowm'; + // Tier in the ETag, same reason as the preview route: without it a + // client holding the 300px thumbnail gets a 304 for its 600px request + // and renders the small one, which is this feature inverted. + const etag = `"thumb-${photoId}-${servedTier || 'def'}-${mtimeMs}${watermarkHash}"`; + + // Check if client has valid cached version + if (req.headers['if-none-match'] === etag) { + return res.status(304).end(); + } + + // Set appropriate headers with enhanced security + res.set({ + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'private, max-age=1800', // Reduced cache time + 'Cross-Origin-Resource-Policy': 'cross-origin', + 'X-Content-Type-Options': 'nosniff', + 'X-Protected-Thumbnail': 'true', + 'ETag': etag + }); + + if (watermarkSettings && watermarkSettings.enabled) { + // Watermarking needs a local file path (sharp + fs.readFile). + // Materialize via withLocalCopy — no-op in local mode, downloads + // to a tmp file then cleans up in S3 mode. + const watermarkedBuffer = await withLocalCopy(thumbnailPath, (localPath) => + watermarkService.applyWatermark(localPath, watermarkSettings) + ); + res.send(watermarkedBuffer); + } else { + res.setHeader('Content-Length', stat.size); + const stream = await storage.get(thumbnailPath); + pipeStreamToResponse(stream, res, { context: `thumbnail for photo ${photoId}` }); + } + } catch (error) { + errorResponse(res, error, 500, 'Failed to serve thumbnail'); + } + } +); + +// Serve hero-optimized image (1920x1080 for full-width hero sections) +router.get('/:slug/hero/:photoId', + verifyGalleryAccess, + // Reveal-gated too: this route serves a 1920px derivative of ANY photo id, + // not just the chosen hero — an open bypass while hidden (review round 1). + blockHiddenGallery, + async (req, res) => { + try { + const { photoId } = req.params; + + const photo = await db('photos') + .where({ id: photoId, event_id: req.event.id }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + // Block guest access to hidden photos + if (photo.visibility === 'hidden' && req.accessLevel !== 'client') { + return res.status(403).json({ error: 'Photo not available' }); + } + + // Check if this is a video - videos don't get hero images + const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/')); + if (isVideo) { + // For videos, redirect to the regular photo endpoint + return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`)); + } + + // Ensure hero image exists and is valid, regenerate if needed + const heroPath = await ensureHeroImage(photo); + + if (!heroPath) { + // If hero generation fails, fall back to original photo + logger.warn(`Failed to generate hero image for photo ${photoId}, falling back to original`); + return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`)); + } + + // Hero images are always written via the storage abstraction (see + // imageProcessor.generateHeroImage), so they're a managed-storage + // key in both LocalFs and S3 modes (#432). Read via storage. + const storage = getStorage(); + const stat = await storage.stat(heroPath); + if (!stat) { + logger.error('Hero image file does not exist in storage backend', { + slug: req.params.slug, + photoId, + eventId: req.event.id, + heroPath + }); + return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`)); + } + + const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0; + const etag = `"hero-${photoId}-${mtimeMs}"`; + if (req.headers['if-none-match'] === etag) { + return res.status(304).end(); + } + + const watermarkSettings = await watermarkService.getWatermarkSettings(); + + res.set({ + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'private, max-age=3600', // Cache for 1 hour + 'Cross-Origin-Resource-Policy': 'cross-origin', + 'X-Content-Type-Options': 'nosniff', + 'X-Hero-Image': 'true', + 'ETag': etag + }); + + if (watermarkSettings && watermarkSettings.enabled) { + // applyWatermark needs a local file path; materialize via + // withLocalCopy so this works in S3 mode too. + const watermarkedBuffer = await withLocalCopy(heroPath, (localPath) => + watermarkService.applyWatermark(localPath, watermarkSettings) + ); + res.send(watermarkedBuffer); + } else { + res.setHeader('Content-Length', stat.size); + const stream = await storage.get(heroPath); + pipeStreamToResponse(stream, res, { context: `hero for photo ${photoId}` }); + } + } catch (error) { + logger.error('Error serving hero image:', { + error: error.message, + photoId: req.params.photoId, + eventId: req.event?.id + }); + // Fall back to original photo on any error + res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${req.params.photoId}`)); + } + } +); + +// Lightbox preview tier (#492). Aspect-preserved JPEG capped at 1920px +// long edge — admin-controlled opt-in via app_settings.lightbox_preview_enabled. +// Mirrors the hero route shape: same auth, ETag from preview mtime, +// fall back to original on any failure so the lightbox never shows a +// broken image. The watermark application path is preserved so a +// preview surfaced in the lightbox carries the same protection a +// guest would see on the full original. +router.get('/:slug/preview/:photoId', + verifyGalleryAccess, + blockHiddenGallery, + async (req, res) => { + try { + const { photoId } = req.params; + + const photo = await db('photos') + .where({ id: photoId, event_id: req.event.id }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + if (photo.visibility === 'hidden' && req.accessLevel !== 'client') { + return res.status(403).json({ error: 'Photo not available' }); + } + + // Videos don't get a preview tier — fall through to the regular + // photo endpoint (which serves the source). The frontend should + // already be checking media_type before requesting /preview but + // belt-and-braces in case a stale tab does. + const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/')); + if (isVideo) { + return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`)); + } + + // Responsive tier (#1095). Whitelisted only — an open ?w= would let + // anyone fill the disk with renditions nobody asked for. An unrecognised + // or absent value falls through to the canonical 1920 preview, so old + // clients and hand-typed URLs behave exactly as before. + const { PREVIEW_WIDTHS, normalizeTierWidth, ensurePreviewImageAtWidth } = + require('../../services/imageProcessor'); + const tierWidth = normalizeTierWidth(req.query.w, PREVIEW_WIDTHS); + + // Lazy generation: ensurePreviewImage returns null on any + // failure (corrupt source, sharp OOM, storage unavailable, …). + // Fall back to the original so the lightbox always renders. + const previewPath = tierWidth + ? (await ensurePreviewImageAtWidth(photo, tierWidth)) || (await ensurePreviewImage(photo)) + : await ensurePreviewImage(photo); + if (!previewPath) { + logger.warn(`Failed to generate preview for photo ${photoId}, falling back to original`); + return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`)); + } + + const storage = getStorage(); + const stat = await storage.stat(previewPath); + if (!stat) { + logger.error('Preview file does not exist in storage backend', { + slug: req.params.slug, photoId, eventId: req.event.id, previewPath, + }); + return res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${photoId}`)); + } + + const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0; + const watermarkSettings = await watermarkService.getWatermarkSettings(); + const watermarkHash = watermarkSettings?.enabled + ? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}` + : '-nowm'; + // Tier is part of the etag: without it a client that already holds the + // 1920 rendition would get a 304 for its 640 request and render the + // wrong size, which is the whole point of the feature inverted. + const etag = `"preview-${photoId}-${tierWidth || 'def'}-${mtimeMs}${watermarkHash}"`; + if (req.headers['if-none-match'] === etag) { + return res.status(304).end(); + } + + res.set({ + // From the key, not hard-coded: a preview of a transparent or animated + // source is WebP, because JPEG carries neither. `nosniff` below means + // getting this wrong shows a broken image rather than being silently + // corrected by the browser. Pre-existing keys have no .webp suffix and + // are JPEG, so they keep their old header. + 'Content-Type': previewPath.endsWith('.webp') ? 'image/webp' : 'image/jpeg', + // Cache aggressively — preview only changes on photo + // re-upload (which generates a new preview key) or settings + // regenerate (which writes a new mtime + ETag). + 'Cache-Control': 'private, max-age=3600', + 'Cross-Origin-Resource-Policy': 'cross-origin', + 'X-Content-Type-Options': 'nosniff', + 'X-Preview-Image': 'true', + 'ETag': etag, + }); + + if (watermarkSettings && watermarkSettings.enabled) { + // No Content-Type override here. applyWatermark PRESERVES the source + // format (watermarkService.js: png -> png, webp -> webp, else jpeg), + // and its input is this preview — so the output format matches the key + // the header was already derived from. Forcing image/jpeg would + // mislabel a watermarked WebP preview, and `nosniff` means the browser + // will not correct it. + // + // What is still lost is the animation: the compositor flattens a + // multi-frame source to one frame while keeping the WebP container. + // That is a separate problem and a much larger one. + const watermarkedBuffer = await withLocalCopy(previewPath, (localPath) => + watermarkService.applyWatermark(localPath, watermarkSettings) + ); + res.send(watermarkedBuffer); + } else { + res.setHeader('Content-Length', stat.size); + const stream = await storage.get(previewPath); + pipeStreamToResponse(stream, res, { context: `preview for photo ${photoId}` }); + } + } catch (error) { + logger.error('Error serving preview image:', { + error: error.message, + photoId: req.params.photoId, + eventId: req.event?.id, + }); + res.redirect(withPreview(req, `/api/gallery/${req.params.slug}/photo/${req.params.photoId}`)); + } + } +); + +// GET /:slug/feedback-settings lives in galleryFeedback.js. A duplicate of it +// used to sit here, and since server.js mounts galleryRoutes before +// galleryFeedback it shadowed the real handler — dropping the per-guest caps +// (#655) from the guest payload, so the gallery could never render the +// favorite/like limits or their counters (#1030). + +// Get photo stats. no-store: view/download/visitor counters are private +// gallery analytics and change on every request. + +module.exports = router; diff --git a/backend/src/routes/gallery/metadata.js b/backend/src/routes/gallery/metadata.js new file mode 100644 index 00000000..4a8c79b3 --- /dev/null +++ b/backend/src/routes/gallery/metadata.js @@ -0,0 +1,249 @@ +const { isGalleryExpired } = require('../../utils/galleryLifecycle'); +const express = require('express'); +const { db } = require('../../database/db'); +const { formatBoolean } = require('../../utils/dbCompat'); +const { getAppSetting } = require('../../utils/appSettings'); +const { timingSafeEqualStr } = require('../../utils/timingSafe'); +const router = express.Router(); +const { resolveHeroLogoVisible } = require('../../services/galleryModel'); +const { verifyAdminPreview } = require('../../middleware/gallery'); +const { noStoreCache } = require('../../middleware/noStoreCache'); +const logger = require('../../utils/logger'); +const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../../services/shareLinkService'); +const { handleAsync, errorResponse } = require('../../utils/routeHelpers'); +const { isGalleryHidden } = require('../../utils/revealMode'); +const { NotFoundError } = require('../../utils/errors'); +async function checkSlugRedirect(slug) { + try { + const hasTable = await db.schema.hasTable('slug_redirects'); + if (!hasTable) return null; + + const redirect = await db('slug_redirects') + .where({ old_slug: slug }) + .first(); + + return redirect ? redirect.new_slug : null; + } catch (error) { + logger.warn('Error checking slug redirect:', { slug, error: error.message }); + return null; + } +} + +router.get('/resolve/:identifier', handleAsync(async (req, res) => { + const { identifier } = req.params; + let result = await resolveShareIdentifier(identifier); + + // If not found, check for redirect + if (!result) { + const newSlug = await checkSlugRedirect(identifier); + if (newSlug) { + return res.status(301).json({ + redirect: true, + newSlug, + message: 'Gallery has been renamed' + }); + } + throw new NotFoundError('Gallery'); + } + + const { event, matchType, shareToken } = result; + const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); + + // The share_token is a bearer secret. Only return it (and the share + // links/URLs that embed it) when the caller already proved they hold it — + // i.e. they resolved via the token or the full share link. A bare *slug* + // lookup (slugs appear in gallery URLs and are guessable) must NOT hand + // back the secret, or an anonymous caller could turn a known slug into + // share-link access to a no-password gallery (GHSA-rh8r). + const callerHasToken = matchType !== 'slug'; + if (!callerHasToken) { + return res.json({ slug: event.slug, matchType, requires_password: requiresPassword }); + } + + const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken }); + res.json({ + slug: event.slug, + token: shareToken, + matchType, + share_link: event.share_link, + share_path: linkVariants.sharePath, + share_url: linkVariants.shareUrl, + short_enabled: linkVariants.shortEnabled, + requires_password: requiresPassword + }); +})); + +// Verify share token. no-store: this is an authorization decision — a cached +// `{ valid: true }` would keep answering for a token the admin has rotated. +router.get('/:slug/verify-token/:token', noStoreCache, handleAsync(async (req, res) => { + const { slug, token } = req.params; + + const event = await db('events') + .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false), is_draft: formatBoolean(false) }) + .select('id', 'share_link', 'share_token') + .first(); + + if (!event) { + throw new NotFoundError('Gallery'); + } + + const expectedToken = getEventShareToken(event); + if (!expectedToken || !timingSafeEqualStr(String(token), expectedToken)) { + throw new NotFoundError('Gallery', 'Invalid gallery link'); + } + + res.json({ valid: true }); +})); + +// Get gallery info (with optional token verification) +router.get('/:slug/info', async (req, res) => { + try { + const { slug } = req.params; + const { token } = req.query; + + let event = await db('events') + .where({ slug }) + .select( + 'id', + 'created_by', + 'event_name', + 'event_type', + 'event_date', + 'expires_at', + 'is_active', + 'is_archived', + 'share_link', + 'share_token', + 'allow_downloads', + 'allow_user_uploads', + 'reveal_mode', + 'reveal_at', + 'revealed_at', + 'disable_right_click', + 'watermark_downloads', + 'watermark_text', + 'require_password', + 'color_theme', + 'enable_devtools_protection', + 'use_canvas_rendering', + 'hero_logo_visible', + 'hero_logo_size', + 'hero_logo_position', + 'hero_logo_url', + 'login_logo_visible', + 'header_style', + 'hero_divider_style', + 'hero_image_anchor', + 'is_draft', + 'default_photo_sort', + // Per-event promotional override (#440). Resolution into a + // ready-to-render markdown string happens below so the + // frontend doesn't have to know about modes. + 'promo_mode', + 'promo_markdown', + 'info_mode', + 'info_markdown' + ) + .first(); + + if (!event) { + // Check for redirect + const newSlug = await checkSlugRedirect(slug); + if (newSlug) { + return res.status(301).json({ + redirect: true, + newSlug, + message: 'Gallery has been renamed' + }); + } + return res.status(404).json({ error: 'Gallery not found' }); + } + + // Check if event is archived + if (event.is_archived) { + return res.status(404).json({ error: 'Gallery has been archived and is no longer available' }); + } + + // Admin preview (#868) bypasses both the draft gate and — below — the + // password gate. Computed once and reused. + const adminPreview = await verifyAdminPreview(req, event); + // Check if event is a draft (allow admin preview) + if (event.is_draft && !adminPreview) { + return res.status(404).json({ error: 'Gallery is not yet published' }); + } + + // If token provided, verify it matches the share link + if (token) { + const expectedToken = getEventShareToken(event); + if (!expectedToken || !timingSafeEqualStr(String(token), expectedToken)) { + return res.status(404).json({ error: 'Invalid gallery link' }); + } + } + + // Admin preview skips the guest password on published, protected galleries + // (#868) — the admin already sees every photo through the admin routes. + const requiresPassword = adminPreview + ? false + : !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); + const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true); + const globalLogoSize = await getAppSetting('branding_logo_size', 'medium'); + + res.json({ + event_name: event.event_name, + event_type: event.event_type, + event_date: event.event_date, + expires_at: event.expires_at, + is_active: event.is_active, + is_expired: !event.is_active || isGalleryExpired(event), + requires_password: requiresPassword, + color_theme: event.color_theme, + allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'), + allow_user_uploads: event.allow_user_uploads === true || event.allow_user_uploads === 1 || event.allow_user_uploads === '1', + // Reveal mode (#838): effective hidden state (computed, time-exact) so + // the landing page can hint at the reveal before login too. + hidden_until_reveal: isGalleryHidden(event), + reveal_at: isGalleryHidden(event) ? (event.reveal_at || null) : null, + disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1', + watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1', + watermark_text: event.watermark_text, + enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1', + use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1', + hero_logo_visible: resolveHeroLogoVisible(event.hero_logo_visible, globalHeroLogoVisible), + // #894: only an explicit false hides the logo on the password page; + // NULL keeps the default (show). + login_logo_visible: !(event.login_logo_visible === false || event.login_logo_visible === 0 || event.login_logo_visible === '0'), + // #756: NULL per-event size inherits the global branding_logo_size. + hero_logo_size: event.hero_logo_size || globalLogoSize || 'medium', + hero_logo_position: event.hero_logo_position || 'top', + hero_logo_url: event.hero_logo_url || null, + header_style: event.header_style || 'standard', + hero_divider_style: event.hero_divider_style || 'wave', + hero_image_anchor: event.hero_image_anchor || 'center', + default_photo_sort: event.default_photo_sort || 'upload_date_desc', + // Per-event promotional override (#440). Frontend resolves + // 'inherit' against branding_promo_markdown from public settings. + promo_mode: event.promo_mode || 'inherit', + promo_markdown: event.promo_markdown || null, + // Info banner (#932). Same inherit/custom/off semantics as promo, + // resolved against branding_info_markdown from public settings. + info_mode: event.info_mode || 'inherit', + info_markdown: event.info_markdown || null + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to fetch gallery info'); + } +}); + +// --------------------------------------------------------------------------- +// Live Slideshow ("Diashow") — token-only fullscreen kiosk surface +// (migration 138). The token in the URL IS the secret (no gallery password), +// so these routes are unauthenticated except for the token match itself. The +// slideshow shows ALL public/visible, finished photos — exactly the guest +// set — so once /session mints a short-lived `accessLevel:'slideshow'` JWT, +// the page reuses the normal /photos + image endpoints unchanged. +// --------------------------------------------------------------------------- + +// Photos a slideshow may display: published, finished, non-hidden. Mirrors the +// guest filter in GET /:slug/photos so the live count matches the rendered set. + +module.exports = router; diff --git a/backend/src/routes/gallery/photos.js b/backend/src/routes/gallery/photos.js new file mode 100644 index 00000000..d9f19e2b --- /dev/null +++ b/backend/src/routes/gallery/photos.js @@ -0,0 +1,192 @@ +const express = require('express'); +const { db, logActivity } = require('../../database/db'); +const router = express.Router(); +const { verifyGalleryAccess } = require('../../middleware/gallery'); +const { resolveGuest } = require('../../middleware/guestAuth'); +const { noStoreCache } = require('../../middleware/noStoreCache'); +const { generateGuestIdentifier } = require('../../middleware/feedbackRateLimit'); +const { errorResponse } = require('../../utils/routeHelpers'); +const { guestBlockedByReveal } = require('../../utils/revealMode'); +const downloadZipService = require('../../services/downloadZipService'); +const GALLERY_OPENED_DEBOUNCE_MS = 6 * 60 * 60 * 1000; +const galleryOpenedNotifiedAt = new Map(); +function galleryActor(req) { + // Portal tokens run as accessLevel 'guest' but carry via:'customer' + // (req.viaCustomer); PIN-client logins carry accessLevel 'client'. + // Both are customers, not guests (codex review of #849, final round). + const isCustomer = !!(req && (req.viaCustomer || req.accessLevel === 'client')); + return { type: isCustomer ? 'customer' : 'guest' }; +} +function notifyGalleryOpened(event, req) { + // Customer-PORTAL opens already log `customer_event_access` on the + // access-token mint — a second `gallery_opened` per portal click would + // double-notify. Keyed on the portal provenance (req.viaCustomer), NOT + // on accessLevel: PIN-client logins are 'client' without any other + // open signal and must keep notifying (codex review of #849, final + // round — the previous check had this inverted). + if (req && req.viaCustomer) return; + const now = Date.now(); + const last = galleryOpenedNotifiedAt.get(event.id) || 0; + if (now - last < GALLERY_OPENED_DEBOUNCE_MS) return; + galleryOpenedNotifiedAt.set(event.id, now); + // Fire-and-forget — logActivity swallows its own errors. + logActivity('gallery_opened', {}, event.id, galleryActor(req)); +} + +router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, noStoreCache, async (req, res) => { + try { + const payload = await require('../../services/galleryQueryService').getGalleryPhotos({ + event: req.event, slug: req.params.slug, query: req.query, + identity: { guestId: req.guest?.id, guestIdentifier: generateGuestIdentifier(req) }, + accessLevel: req.accessLevel, adminPreview: req.isAdminPreview, + hiddenForGuest: guestBlockedByReveal(req), + }); + // Log view — but NOT for the Live Slideshow kiosk. A running projector + // refetches this list on every new-upload poll, which would massively + // inflate total_views / unique_visitors. The slideshow is explicitly + // excluded from real visitor analytics (migration 138 design). + // Admin preview (#868) is excluded from guest analytics + the "gallery + // opened" bell — it's the photographer looking at their own gallery. + if (req.accessLevel !== 'slideshow' && !req.isAdminPreview && !(Number(req.query.page) > 1)) { + await db('access_logs').insert({ + event_id: req.event.id, + ip_address: req.ip, + user_agent: req.headers['user-agent'], + action: 'view' + }); + notifyGalleryOpened(req.event, req); + } + + res.json(payload); + } catch (error) { errorResponse(res, error, 500, 'Failed to fetch photos'); } +}); + +/** + * People in this gallery (#1074). + * + * Returns [] rather than 403 whenever the feature is unavailable — a guest + * must not be able to tell "this gallery has no people" from "this gallery + * has the feature switched off". Same reasoning as reveal mode returning an + * empty photo set rather than an error. + * + * Counts and cover faces are computed against the caller's own visibility + * scope inside facePeopleService; nothing here reads face_count_total. + */ +// no-store for the same reason as /photos: the people list and its scan +// progress are scoped to what THIS viewer may see. +router.get('/:slug/people', verifyGalleryAccess, resolveGuest, noStoreCache, async (req, res) => { + try { + const isClient = req.accessLevel === 'client'; + const { isEnabledForEvent, areFacesVisibleToGuests, getThresholds } = + require('../../services/faceSettings'); + + if (!(await isEnabledForEvent(req.event))) { + return res.json({ people: [] }); + } + if (!isClient && !areFacesVisibleToGuests(req.event)) { + return res.json({ people: [] }); + } + // While a gallery is hidden behind reveal mode (#838), a plain guest sees + // no photos — so they see no people either. + if (guestBlockedByReveal(req)) { + return res.json({ people: [] }); + } + + const { listPeople, getScanStatus } = require('../../services/facePeopleService'); + const thresholds = await getThresholds(); + + const people = await listPeople(req.event.id, { + isClient, + forAdmin: false, + minClusterSize: thresholds.face_min_cluster_size, + }); + + // Drives the "Finding people… 240/1200" progress line during a backfill. + // Scoped to what this viewer may see — an unscoped total would leak the + // number of hidden photos through the progress bar. + const status = await getScanStatus(req.event.id, { isClient }); + + res.json({ + people, + scan: { + in_progress: status.in_progress, + scanned: status.scanned, + total: status.total, + }, + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to fetch people'); + } +}); + +// Toggle photo visibility (client-only) +router.patch('/:slug/photos/:photoId/visibility', verifyGalleryAccess, async (req, res) => { + try { + if (req.accessLevel !== 'client') { + return res.status(403).json({ error: 'Client access required' }); + } + + const { photoId } = req.params; + const { visibility } = req.body; + + if (!['visible', 'hidden'].includes(visibility)) { + return res.status(400).json({ error: 'Invalid visibility value' }); + } + + const photo = await db('photos') + .where({ id: photoId, event_id: req.event.id }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + await db('photos') + .where({ id: photoId, event_id: req.event.id }) + .update({ visibility }); + + // A client hiding/showing a photo changes the guest download bundle — + // drop the cached ZIP so it rebuilds fresh (codex review). + downloadZipService.invalidate(req.event.id); + + res.json({ message: 'Photo visibility updated', visibility }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to update photo visibility'); + } +}); + +// Bulk toggle photo visibility (client-only) +router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, res) => { + try { + if (req.accessLevel !== 'client') { + return res.status(403).json({ error: 'Client access required' }); + } + + const { photoIds, visibility } = req.body; + + if (!Array.isArray(photoIds) || photoIds.length === 0) { + return res.status(400).json({ error: 'Invalid photo IDs' }); + } + + if (!['visible', 'hidden'].includes(visibility)) { + return res.status(400).json({ error: 'Invalid visibility value' }); + } + + const count = await db('photos') + .whereIn('id', photoIds) + .where('event_id', req.event.id) + .update({ visibility }); + + // Client bulk hide/show alters the guest download bundle — invalidate + // the cached ZIP (codex review). + downloadZipService.invalidate(req.event.id); + + res.json({ message: `${count} photos updated`, visibility }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to update photo visibility'); + } +}); + +// Download single photo + +module.exports = router; diff --git a/backend/src/routes/gallery/slideshow.js b/backend/src/routes/gallery/slideshow.js new file mode 100644 index 00000000..045c4759 --- /dev/null +++ b/backend/src/routes/gallery/slideshow.js @@ -0,0 +1,293 @@ +const { isGalleryAvailable } = require('../../utils/galleryLifecycle'); +const express = require('express'); +const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); +const { db } = require('../../database/db'); +const { formatBoolean } = require('../../utils/dbCompat'); + +const router = express.Router(); +const { noStoreCache } = require('../../middleware/noStoreCache'); +const logger = require('../../utils/logger'); +const { getEventShareToken, buildShareLinkVariants } = require('../../services/shareLinkService'); +const { handleAsync } = require('../../utils/routeHelpers'); +const { NotFoundError } = require('../../utils/errors'); +const { setGalleryAuthCookies } = require('../../utils/tokenUtils'); +const { getSlideshowGlobals } = require('../../utils/slideshowGlobals'); +const { isFeatureEnabled } = require('../../middleware/requireFeatureFlag'); + +function slideshowPhotosQuery(eventId, categoryId = null) { + const q = db('photos') + .where('photos.event_id', eventId) + .where(function() { + this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status'); + }) + .where(function() { + this.where('photos.visibility', 'visible').orWhereNull('photos.visibility'); + }); + // Category filter (#202) — keep the /session + /state count in sync with the + // photos the kiosk actually renders. + if (categoryId) q.where('photos.category_id', categoryId); + return q; +} + +// Resolve an active slideshow by slug + token. Returns the event row, or null +// when the link is missing/rotated/disabled or the gallery isn't live (archived +// / draft / inactive / expired) — every one of those collapses to a 404 so a +// dead link reveals nothing and stops any projector on its next poll. +async function resolveSlideshow(slug, token) { + if (!token) return null; + // The `slideshow` feature flag is a master kill-switch: when an admin turns + // Live Slideshow off, every existing /show/ link dies on its next request + // (the running projector stops within one /state poll), not just the admin UI. + if (!(await isFeatureEnabled('slideshow'))) return null; + const event = await db('events') + .where({ + slug, + show_share_token: token, + is_active: formatBoolean(true), + is_archived: formatBoolean(false), + is_draft: formatBoolean(false) + }) + .first(); + if (!isGalleryAvailable(event)) return null; + return event; +} + +// Resolve the slideshow's live styling, including the ZDF/ARD-ident-style +// watermark (a white, semi-transparent corner logo). The logo URL is resolved +// from the chosen source so the kiosk renders it without knowing about +// branding/event internals; null url = nothing to overlay. +async function slideshowSettings(event, req) { + // The global look/fit (Settings → Slideshow) + branding logo URLs come from a + // short-TTL cached bundle so a 3s projector poll doesn't re-fire ~10 settings + // reads each time (PR #646 review, concern 2). + const g = await getSlideshowGlobals(); + + // Watermark: the LOOK (logo/position/opacity/style/size) is configured ONCE + // globally; it is NOT duplicated per event. The only per-event control is + // whether the watermark shows: `show_watermark` NULL inherits the global + // enabled flag, true/false force it on/off. + const wm = event.show_watermark; + const inherit = (wm === null || wm === undefined); + const enabled = inherit ? g.watermark_enabled : (wm === true || wm === 1 || wm === '1'); + let watermark = null; + if (enabled) { + // Resolve the chosen logo to a URL. Branding assets come from settings; + // the event source uses the event's own hero logo. + let url; + if (g.watermark_source === 'event') { + url = event.hero_logo_url || null; + } else if (g.watermark_source === 'logo_dark') { + url = g.branding_logo_url_dark; + } else if (g.watermark_source === 'favicon') { + url = g.branding_favicon_url; + } else { + url = g.branding_logo_url; + } + if (url) { + watermark = { + url, + position: g.watermark_position, + opacity: g.watermark_opacity, + style: g.watermark_style, + size: g.watermark_size, + }; + } + } + // QR overlay (#837): like the watermark, the LOOK is global-only and the + // per-event `show_qr` tri-state (NULL = inherit) decides visibility. The QR + // encodes the gallery share URL and ships as a data URI so the public + // slideshow client needs no QR library and no extra authenticated endpoint. + const qrOverride = event.show_qr; + const qrInherit = (qrOverride === null || qrOverride === undefined); + const qrEnabled = qrInherit ? g.qr_enabled : (qrOverride === true || qrOverride === 1 || qrOverride === '1'); + let qr = null; + if (qrEnabled) { + const dataUrl = await slideshowQrDataUrl(event, req); + if (dataUrl) { + qr = { + data_url: dataUrl, + position: g.qr_position, + opacity: g.qr_opacity, + size: g.qr_size, + }; + } + } + + return { + interval_ms: event.show_interval_ms || 5000, + transition: event.show_transition || 'crossfade', + transition_ms: event.show_transition_ms || 800, + colorfilter: event.show_colorfilter || 'none', + // Play order (#202): 'chronological' | 'random'. The client shuffles when + // 'random' so live-appended uploads keep working. + order: event.show_order || 'chronological', + fit: g.fit, + watermark, + qr, + }; +} + +// The state endpoint is polled every ~3s per projector — cache the generated +// QR data URI per share URL instead of re-encoding on every poll. Bounded: +// entries live for past events / rotated tokens too, so without eviction the +// map would grow with every share URL ever displayed (codex review of #848). +// Insertion-order eviction is enough — concurrently-shown events stay hot. +const SLIDESHOW_QR_CACHE_MAX = 50; +// Keyed by event id (NOT by URL): the origin is caller-influenced when the +// configured base is loopback, so URL-keyed caching would let a slideshow +// -link holder force a fresh QRCode.toDataURL per request with unique +// origins — a cheap CPU-exhaustion path (codex review of #848, +// confirmation round). Per-event entries + a regeneration throttle bound +// the encode rate regardless of what the caller sends. +const SLIDESHOW_QR_REGEN_MS = 60_000; +const slideshowQrCache = new Map(); // eventId -> { url, dataUrl, at } +// Localhost/relative guard (codex review of #848): with the compose-default +// FRONTEND_URL=http://localhost:3000 (or none configured) the QR would send +// scanning phones to THEIR localhost. The state poll comes from the kiosk +// browser itself, so its Host header + protocol are exactly the public +// origin guests can reach — prefer that whenever the configured base is +// missing or loopback. trust proxy is configured, so req.protocol respects +// X-Forwarded-Proto behind the standard reverse-proxy setups. +// Centralised in utils/frontendUrl (#705) so the QR path and the public-origin +// resolver agree on what counts as a non-shareable base. +const QR_LOCAL_BASE_RE = { test: (v) => require('../../utils/frontendUrl').isLoopbackBase(v) }; +const QR_ORIGIN_RE = /^https?:\/\/[^\s/]+$/i; +async function slideshowQrDataUrl(event, req) { + try { + const shareToken = getEventShareToken(event); + if (!shareToken) return null; + let { shareUrl, sharePath } = await buildShareLinkVariants({ slug: event.slug, shareToken }); + if (!/^https?:\/\//i.test(shareUrl) || QR_LOCAL_BASE_RE.test(shareUrl)) { + // Prefer the kiosk's own window.location.origin (?origin=, validated): + // req.get('host') is NOT the browser origin behind the standard + // proxies — frontend/nginx.conf forwards $host (port stripped), so a + // compose LAN deployment on :3000 would encode port 80. A LOOPBACK + // kiosk origin is rejected too: it is no more guest-reachable than + // the loopback base it would replace (codex review of #848). + const rawOrigin = req?.query?.origin; + const queryOrigin = typeof rawOrigin === 'string' && QR_ORIGIN_RE.test(rawOrigin) && !QR_LOCAL_BASE_RE.test(rawOrigin) + ? rawOrigin.replace(/\/$/, '') + : null; + const host = req && req.get ? req.get('host') : null; + const hostOrigin = host ? `${req.protocol}://${host}` : null; + if (queryOrigin) shareUrl = `${queryOrigin}${sharePath}`; + else if (hostOrigin && !QR_LOCAL_BASE_RE.test(hostOrigin)) shareUrl = `${hostOrigin}${sharePath}`; + // Still loopback/relative → no reachable URL exists; suppress the + // overlay rather than encode a QR that sends phones to localhost. + else return null; + } + + const cached = slideshowQrCache.get(event.id); + if (cached && cached.url === shareUrl) return cached.dataUrl; + // URL differs from the cached one: NEVER serve the mismatched artifact — + // a slideshow-token holder could otherwise poison the projector's QR + // with an attacker origin for a whole throttle window (codex review of + // #848, final round). Inside the window the overlay is briefly + // suppressed instead; regeneration stays bounded per event. + if (cached && Date.now() - cached.at < SLIDESHOW_QR_REGEN_MS) { + return cached.pending ? cached.dataUrl : null; + } + // Single-flight: concurrent polls on a cold cache must not each + // schedule their own 512px encode — reserve the entry with a shared + // promise before awaiting. + if (cached && cached.pending && cached.url === shareUrl) return cached.pending; + const QRCode = require('qrcode'); + const entry = { url: shareUrl, dataUrl: null, at: Date.now(), pending: null }; + entry.pending = QRCode.toDataURL(shareUrl, { width: 512, margin: 4 }).then((dataUrl) => { + entry.dataUrl = dataUrl; + entry.pending = null; + return dataUrl; + }).catch((e) => { + slideshowQrCache.delete(event.id); + throw e; + }); + if (!slideshowQrCache.has(event.id) && slideshowQrCache.size >= SLIDESHOW_QR_CACHE_MAX) { + slideshowQrCache.delete(slideshowQrCache.keys().next().value); + } + slideshowQrCache.set(event.id, entry); + return await entry.pending; + } catch (e) { + logger.error('Slideshow QR generation failed:', e); + return null; + } +} + +// Open a slideshow session: validate the token and mint a short-lived gallery +// JWT scoped to `accessLevel:'slideshow'` (treated as a guest by the photo / +// image endpoints → visible photos only, no client-only/hidden). The page +// stores this token and the existing axios interceptor injects it. +// no-store: this response *is* a credential (it mints a gallery JWT and sets +// the per-slug auth cookie), so it must never be retained anywhere. +router.get('/:slug/show/:token/session', noStoreCache, handleAsync(async (req, res) => { + const { slug, token } = req.params; + const event = await resolveSlideshow(slug, token); + if (!event) { + throw new NotFoundError('Slideshow'); + } + + const sessionToken = jwt.sign({ + eventId: event.id, + eventSlug: event.slug, + type: 'gallery', + // Unique per token: the revocation key falls back to eventId+iat otherwise, + // so one guest's logout would revoke every same-second login (#1357). + jti: crypto.randomUUID(), + accessLevel: 'slideshow', + loginTime: Date.now() + }, process.env.JWT_SECRET, { + expiresIn: '12h', + issuer: 'picpeak-auth' + }); + + // tags can't carry an Authorization header, so the photo/thumbnail/ + // preview endpoints authenticate via the per-slug gallery cookie. Set it + // here so the kiosk's image requests are authorized with zero extra wiring. + setGalleryAuthCookies(res, sessionToken, event.slug); + + const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count'); + + res.json({ + token: sessionToken, + event: { + event_name: event.event_name, + event_type: event.event_type, + color_theme: event.color_theme + }, + settings: await slideshowSettings(event, req), + photo_count: parseInt(count, 10) || 0, + expires_at: event.expires_at || null + }); +})); + +// Cheap live-poll endpoint (tiny payload, hit every ~3s by the running show): +// current settings + the visible photo count. The page diffs photo_count to +// decide when to refetch the full list, and re-reads settings so admin changes +// take effect live. A dead/disabled link 404s here → the projector stops. +router.get('/:slug/show/:token/state', noStoreCache, handleAsync(async (req, res) => { + const { slug, token } = req.params; + const event = await resolveSlideshow(slug, token); + if (!event) { + throw new NotFoundError('Slideshow'); + } + + const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count'); + + res.json({ + ...(await slideshowSettings(event, req)), + photo_count: parseInt(count, 10) || 0, + expires_at: event.expires_at || null + }); +})); + +// Get all photos. +// +// no-store (B6): the payload is private and per-guest — it carries the +// viewer's own likes/favorites/ratings and, for a client token, photos hidden +// from plain guests. With no Cache-Control at all a browser applies heuristic +// freshness and may reuse a body it stored on disk, on a shared device, for a +// gallery whose password has since been rotated. Express still computes its +// weak ETag, so a caller that does revalidate (React Query's own in-memory +// cache is unaffected either way) still gets a correct 304. + +module.exports = router; diff --git a/backend/src/routes/gallery/stats.js b/backend/src/routes/gallery/stats.js new file mode 100644 index 00000000..fefe8e08 --- /dev/null +++ b/backend/src/routes/gallery/stats.js @@ -0,0 +1,44 @@ +const express = require('express'); +const { db } = require('../../database/db'); +const router = express.Router(); +const { verifyGalleryAccess } = require('../../middleware/gallery'); +const { noStoreCache } = require('../../middleware/noStoreCache'); +const { blockHiddenGallery } = require('../../utils/revealMode'); + +router.get('/:slug/stats', verifyGalleryAccess, blockHiddenGallery, noStoreCache, async (req, res) => { + try { + const totalPhotos = await db('photos') + .where('event_id', req.event.id) + .count('id as count') + .first(); + + const totalViews = await db('access_logs') + .where('event_id', req.event.id) + .where('action', 'view') + .count('id as count') + .first(); + + const totalDownloads = await db('photos') + .where('event_id', req.event.id) + .sum('download_count as total') + .first(); + + const uniqueVisitors = await db('access_logs') + .where('event_id', req.event.id) + .countDistinct('ip_address as count') + .first(); + + res.json({ + total_photos: totalPhotos.count, + total_views: totalViews.count, + total_downloads: totalDownloads.total || 0, + unique_visitors: uniqueVisitors.count + }); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch stats' }); + } +}); + +// User photo upload endpoint + +module.exports = router; diff --git a/backend/src/routes/gallery/styles.js b/backend/src/routes/gallery/styles.js new file mode 100644 index 00000000..73f7a5b9 --- /dev/null +++ b/backend/src/routes/gallery/styles.js @@ -0,0 +1,41 @@ +const express = require('express'); +const { db } = require('../../database/db'); +const router = express.Router(); +const logger = require('../../utils/logger'); + +router.get('/:slug/css-template', async (req, res) => { + try { + const { slug } = req.params; + + // Find the event by slug + const event = await db('events') + .where({ slug }) + .select('css_template_id') + .first(); + + if (!event || !event.css_template_id) { + // No custom CSS - return 204 No Content + return res.status(204).send(); + } + + // Get the template if it's enabled + const template = await db('css_templates') + .where({ id: event.css_template_id, is_enabled: true }) + .select('css_content') + .first(); + + if (!template || !template.css_content) { + return res.status(204).send(); + } + + // Return CSS with caching headers + res.setHeader('Content-Type', 'text/css'); + res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour cache + res.send(template.css_content); + } catch (error) { + logger.error('Get CSS template error:', error); + res.status(500).send('/* Error loading template */'); + } +}); + +module.exports = router; diff --git a/backend/src/routes/gallery/uploads.js b/backend/src/routes/gallery/uploads.js new file mode 100644 index 00000000..5f9191f9 --- /dev/null +++ b/backend/src/routes/gallery/uploads.js @@ -0,0 +1,211 @@ +const express = require('express'); +const { db } = require('../../database/db'); + +const router = express.Router(); +const { verifyGalleryAccess, denySlideshowToken } = require('../../middleware/gallery'); +const { noStoreCache } = require('../../middleware/noStoreCache'); +const logger = require('../../utils/logger'); +const { errorResponse } = require('../../utils/routeHelpers'); + +router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (req, res) => { + try { + const eventId = parseInt(req.params.eventId); + + // Verify the event matches the token + if (req.event.id !== eventId) { + return res.status(403).json({ error: 'Access denied' }); + } + + // Check if user uploads are allowed + if (!req.event.allow_user_uploads) { + return res.status(403).json({ error: 'User uploads are not allowed for this event' }); + } + + // Ensure temp upload directory exists + const fs = require('fs'); + const tempUploadDir = '/tmp/uploads/'; + if (!fs.existsSync(tempUploadDir)) { + try { + fs.mkdirSync(tempUploadDir, { recursive: true, mode: 0o755 }); + logger.info('Created temp upload directory:', tempUploadDir); + } catch (mkdirErr) { + return errorResponse(res, mkdirErr, 500, 'Server configuration error: unable to create upload directory'); + } + } + + // Import multer and photo processing + const multer = require('multer'); + const { getAllowedMimeTypes, getMaxFilesPerUpload, getMaxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_MB } = require('../../services/uploadSettings'); + const { validateFileType } = require('../../utils/fileSecurityUtils'); + + // Resolve allowed MIME types from settings + let allowedMimeTypes; + try { + allowedMimeTypes = await getAllowedMimeTypes(); + } catch { + allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp']; + } + + // #613 — per-batch file count was hardcoded to 10 here, so the admin's + // Settings → General → "Max Files per Upload" value silently didn't + // apply to guest uploads (only admin uploads honoured it via + // adminPhotos.js:131). Zszywany reported uploading 16 files succeeded + // even with the limit set to 10. Mirror the admin path: resolve from + // settings (cached for 60s in the service) and feed multer both + // `limits.files` and the `.array(...)` cap. Fall back to the service's + // default if the read fails. + let maxFilesPerUpload; + try { + maxFilesPerUpload = await getMaxFilesPerUpload(); + } catch { + maxFilesPerUpload = 500; + } + + // Per-file size cap was hardcoded to 50MB here, so the admin's Settings → + // General → "Max File Size (MB)" value (general_max_file_size_mb) never + // applied to guest uploads — a guest could not upload a large video even + // when the admin allowed it (reported on #613 by mat1990dj). Resolve it from + // settings like the count above; fall back to the 50MB default on read error. + let maxFileSizeBytes; + try { + maxFileSizeBytes = await getMaxFileSizeBytes(); + } catch { + maxFileSizeBytes = DEFAULT_MAX_FILE_SIZE_MB * 1024 * 1024; + } + + const upload = multer({ + dest: tempUploadDir, + limits: { + fileSize: maxFileSizeBytes, + files: maxFilesPerUpload + }, + fileFilter: (req, file, cb) => { + if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) { + cb(null, true); + } else { + cb(new Error('Invalid file type')); + } + } + }).array('photos', maxFilesPerUpload); + + // Handle upload + upload(req, res, async (err) => { + if (err) { + logger.error('Upload error:', err); + // Turn multer's generic "File too large" into an actionable message + // that names the configured limit. + if (err.code === 'LIMIT_FILE_SIZE') { + const limitMb = Math.floor(maxFileSizeBytes / (1024 * 1024)); + return res.status(400).json({ error: `File too large. Maximum size is ${limitMb} MB per file.` }); + } + return res.status(400).json({ error: err.message }); + } + + if (!req.files || req.files.length === 0) { + return res.status(400).json({ error: 'No files uploaded' }); + } + + const { queueFilesForProcessing } = require('../../services/photoProcessor'); + const rawCategory = req.body.category_id || req.event.upload_category_id || null; + const numericCategoryId = (() => { + if (rawCategory === null || rawCategory === undefined) return null; + const n = parseInt(rawCategory, 10); + return Number.isFinite(n) ? n : null; + })(); + + try { + // Queue files as 'pending' — the background worker will process + // thumbnails / EXIF / dimensions off the request thread (#357). + const result = await queueFilesForProcessing(req.files, { + eventId, + photoType: 'individual', + categoryId: numericCategoryId, + }); + + res.status(202).json({ + message: 'Photos queued for processing', + upload_id: result.uploadId, + count: result.photos.length, + photo_ids: result.photos.map((p) => p.id), + photos: result.photos, + errors: result.errors.length > 0 ? result.errors : undefined, + }); + } catch (processError) { + errorResponse(res, processError, 500, 'Failed to process photos'); + } + }); + } catch (error) { + errorResponse(res, error, 500, 'Failed to upload photos'); + } +}); + +// A guest upload_id is `crypto.randomBytes(16).toString('hex')` +// (photoProcessor.js). The pattern is deliberately a little wider than that so +// an id-format change does not silently 400, but narrow enough that the value +// can only ever be an opaque token. +const UPLOAD_ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/; +// The guest UI uploads one file per request, so a batch of N files yields N +// upload ids. Batching them into a single poll keeps the request rate flat +// regardless of batch size; the cap bounds the IN-list. +const MAX_UPLOAD_STATUS_IDS = 50; + +/** + * GET /:slug/uploads/status?ids=[,…] + * + * Guest-facing processing status for the guest's own uploads (B7). + * + * The upload route answers 202 and queues the files, and /photos only returns + * rows that reached `processing_status: 'complete'`. Without this the gallery + * had to poll /photos blind, could not say "processing…", and could not tell a + * slow worker from a photo that failed outright — the guest just watched their + * upload not appear. + * + * Authorization: `verifyGalleryAccess` already resolved `req.event` from the + * caller's gallery token, and the query is filtered on `event_id = req.event.id` + * as well as the ids. An id belonging to another gallery therefore matches no + * row rather than being reported as forbidden — no cross-event read, and no + * existence oracle either. Slideshow tokens are denied because a kiosk never + * uploads. + * + * The response is counts only. The guest already knows which files they sent; + * anything more (filenames, `processing_error` strings, which can carry + * internal paths) would be leaking beyond "how far along is my upload". + */ +router.get('/:slug/uploads/status', verifyGalleryAccess, denySlideshowToken, noStoreCache, async (req, res) => { + try { + const ids = String(req.query.ids || '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean); + + if (ids.length === 0 || ids.length > MAX_UPLOAD_STATUS_IDS || !ids.every((id) => UPLOAD_ID_PATTERN.test(id))) { + return res.status(400).json({ error: 'Invalid upload ids' }); + } + + const rows = await db('photos') + .where('event_id', req.event.id) + .whereIn('upload_id', ids) + .select('processing_status'); + + const summary = { total: rows.length, pending: 0, processing: 0, complete: 0, failed: 0 }; + for (const row of rows) { + // NULL is a pre-async-migration row, treated as complete exactly as the + // /photos filter treats it. + const status = row.processing_status || 'complete'; + if (Object.prototype.hasOwnProperty.call(summary, status) && status !== 'total') { + summary[status] += 1; + } + } + + res.json(summary); + } catch (error) { + errorResponse(res, error, 500, 'Failed to read upload status'); + } +}); + +/** + * GET /:slug/css-template + * Get custom CSS template for gallery (public endpoint) + */ + +module.exports = router; diff --git a/backend/src/routes/protectedImages.js b/backend/src/routes/protectedImages.js index a018522c..b0c4379f 100644 --- a/backend/src/routes/protectedImages.js +++ b/backend/src/routes/protectedImages.js @@ -6,6 +6,7 @@ const { verifyGalleryAccess } = require('../middleware/gallery'); const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode'); const watermarkService = require('../services/watermarkService'); const secureImageService = require('../services/secureImageService'); +const galleryAccessService = require('../services/galleryAccessService'); const { getStorage } = require('../services/storage'); const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../services/photoResolver'); const { withLocalCopy } = require('../services/imageProcessor'); @@ -19,16 +20,13 @@ const router = express.Router(); /** * Generate a signed URL token for image access */ -function generateImageToken(photoId, expiresIn = 3600, revealBypass = false, clientBypass = false) { +function generateImageToken(photoId, expiresIn = 3600, revealBypass = false, clientBypass = false, galleryAccess) { const secret = process.env.JWT_SECRET; const expires = Date.now() + (expiresIn * 1000); - // Third segment (#838): whether the minting context bypasses reveal mode - // (slideshow/client/admin). Fourth segment: whether the minter was a - // PIN-client, allowing the serve route to still deliver a photo that was - // hidden AFTER minting (TOCTOU) — a guest's token carries 0, so it stops - // working the moment the photo is hidden. Old shorter tokens verify - // unchanged and read both flags as no-bypass. - const data = `${photoId}:${expires}:${revealBypass ? 1 : 0}:${clientBypass ? 1 : 0}`; + // Bind the URL to its issuing access grant. Old tokens without a grant + // must be refreshed: they cannot prove session revocation or ownership. + const grant = Buffer.from(JSON.stringify(galleryAccess)).toString('base64url'); + const data = `${photoId}:${expires}:${revealBypass ? 1 : 0}:${clientBypass ? 1 : 0}:${grant}`; const signature = crypto.createHmac('sha256', secret).update(data).digest('hex'); return `${Buffer.from(data).toString('base64')}.${signature}`; } @@ -41,7 +39,7 @@ function verifyImageToken(token) { const secret = process.env.JWT_SECRET; const [data, signature] = token.split('.'); const decoded = Buffer.from(data, 'base64').toString(); - const [photoId, expires, bypassFlag, clientFlag] = decoded.split(':'); + const [photoId, expires, bypassFlag, clientFlag, grant] = decoded.split(':'); // Verify signature (constant-time — avoids leaking the HMAC byte-by-byte) const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex'); @@ -50,7 +48,7 @@ function verifyImageToken(token) { } // Check expiration - if (Date.now() > parseInt(expires)) { + if (!Number.isFinite(Number(expires)) || Date.now() >= Number(expires) || !grant) { return null; } @@ -59,6 +57,7 @@ function verifyImageToken(token) { expires: parseInt(expires), revealBypass: bypassFlag === '1', clientBypass: clientFlag === '1', + galleryAccess: JSON.parse(Buffer.from(grant, 'base64url').toString()), }; } catch (error) { return null; @@ -170,6 +169,7 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, blockHiddenGallery res.send(finalImage); } catch (error) { + if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code }); logger.error('Error serving protected image:', error); res.status(500).json({ error: 'Failed to serve image' }); } @@ -207,6 +207,7 @@ router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess, // Generate secure token. clientBypass lets a client's token keep serving // a photo hidden after minting; a guest's stops at the serve route. const token = secureImageService.generateSecureToken(photoId, req.sessionID || 'anonymous', { + galleryAccess: req.galleryAccess, expiresIn, maxUses: protectionLevel === 'maximum' ? 1 : 3, clientFingerprint, @@ -222,6 +223,7 @@ router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess, }); } catch (error) { + if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code }); logger.error('Error generating secure token:', error); res.status(500).json({ error: 'Failed to generate token' }); } @@ -262,7 +264,7 @@ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (re // Generate signed token. The client-bypass flag lets a PIN-client's // token keep serving a photo hidden after minting; a guest's token // (clientBypass=0) stops the moment the photo is hidden. - const token = generateImageToken(photoId, 3600, bypassesReveal(req), canSeeHiddenPhotos(req.accessLevel)); + const token = generateImageToken(photoId, 3600, bypassesReveal(req), canSeeHiddenPhotos(req.accessLevel), req.galleryAccess); const signedUrl = `/api/images/${req.params.slug}/photo/${photoId}/signed/${token}`; res.json({ @@ -271,6 +273,7 @@ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (re }); } catch (error) { + if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code }); logger.error('Error generating signed URL:', error); res.status(500).json({ error: 'Failed to generate URL' }); } @@ -299,6 +302,8 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => { return res.status(404).json({ error: 'Event not found' }); } + await galleryAccessService.authorize(event, tokenData.galleryAccess); + // Reveal mode (#838): a signed URL minted before a re-hide must not keep // serving hidden photos; tokens minted by bypass contexts carry the flag. if (isGalleryHidden(event) && !tokenData.revealBypass) { @@ -338,7 +343,7 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => { res.set({ 'Content-Type': resolvePhotoContentType(photo), 'Content-Length': imageBuffer.length, - 'Cache-Control': 'private, max-age=3600', + 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff' }); @@ -346,9 +351,10 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => { res.send(imageBuffer); } catch (error) { + if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code }); logger.error('Error serving signed image:', error); res.status(500).json({ error: 'Failed to serve image' }); } }); -module.exports = router; \ No newline at end of file +module.exports = router; diff --git a/backend/src/routes/secureImages.js b/backend/src/routes/secureImages.js index bf0d9684..ba6bad69 100644 --- a/backend/src/routes/secureImages.js +++ b/backend/src/routes/secureImages.js @@ -4,6 +4,7 @@ const { db } = require('../database/db'); const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery'); const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode'); const secureImageService = require('../services/secureImageService'); +const galleryAccessService = require('../services/galleryAccessService'); const secureImageMiddleware = require('../middleware/secureImageMiddleware'); const logger = require('../utils/logger'); const { formatBoolean } = require('../utils/dbCompat'); @@ -58,6 +59,7 @@ router.post('/:slug/generate-token', async (req, res, next) => { // Generate secure token with appropriate settings const tokenOptions = { + galleryAccess: req.galleryAccess, expiresIn: protectionLevel === 'maximum' ? 180 : 300, // 3-5 minutes maxUses: accessType === 'download' ? 1 : 3, clientFingerprint, @@ -96,6 +98,7 @@ router.post('/:slug/generate-token', async (req, res, next) => { }); } catch (error) { + if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code }); logger.error('Error generating secure token', { error: error.message, photoId: req.body.photoId, @@ -151,6 +154,10 @@ router.get('/:slug/secure/:photoId/:token', return res.status(404).json({ error: 'Gallery not found' }); } + // Revalidate the issuing session, ownership and gallery lifecycle at + // every use, including capabilities minted before logout or restore. + await galleryAccessService.authorize(event, tokenValidation.data?.galleryAccess); + // Bind the token to the gallery + photo it was minted for // (GHSA-g94x-8vv8-3c9f). This route serves via with the // token in the URL, so it can't require verifyGalleryAccess like the @@ -248,6 +255,7 @@ router.get('/:slug/secure/:photoId/:token', res.send(processedImage); } catch (error) { + if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code }); logger.error('Error serving secure image', { error: error.message, photoId, @@ -292,6 +300,8 @@ router.get('/:slug/secure-download/:photoId/:token', return res.status(403).json({ error: 'Invalid or expired token' }); } + await galleryAccessService.authorize(req.event, tokenValidation.data?.galleryAccess); + // Bind the token to the photo it was minted for (GHSA-crxv) — the // /secure serve route does this, but secure-download did not, so a // token minted for photo A could download photo B (incl. a hidden one). @@ -386,6 +396,7 @@ router.get('/:slug/secure-download/:photoId/:token', res.send(fileBuffer); } catch (error) { + if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code }); logger.error('Error serving secure download', { error: error.message, photoId: req.params.photoId @@ -414,6 +425,7 @@ router.get('/security/stats', adminAuth, requirePermission('settings.view'), asy res.json(stats); } catch (error) { + if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code }); logger.error('Error getting security stats', { error: error.message }); res.status(500).json({ error: 'Failed to get security stats' }); } diff --git a/backend/src/routes/v1/__tests__/events.create.test.js b/backend/src/routes/v1/__tests__/events.create.test.js index 92b1ad8e..3b7a4d13 100644 --- a/backend/src/routes/v1/__tests__/events.create.test.js +++ b/backend/src/routes/v1/__tests__/events.create.test.js @@ -1,283 +1,74 @@ -/** - * Regression tests for issue #550. - * - * Two related bugs in POST /v1/events: - * 1. color_theme was not accepted on the request body and never written - * to the events row. Editing such an event later in the admin UI - * snapped the theme picker to GALLERY_THEME_PRESETS.default and - * saving overwrote whatever theme was inherited visually. - * 2. event_feedback_settings row was never created, so the gallery UI - * read it as "feedback off" regardless of the global - * event_default_feedback_enabled toggle (#520). - * - * Test pattern mirrors events.category.test.js — queue up db() chains - * with db.__setImplementations() in the exact order the handler invokes - * them, then assert against the captured payloads. - */ - +/** Persisted contracts, not a mock tied to the number/order of Knex calls. */ +const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../../../../__tests__/integration/helpers/crmDb'); const request = require('supertest'); const express = require('express'); - -const buildChain = ({ firstResult, insertResult, returningResult, selectResult } = {}) => { - const chain = { - where: jest.fn().mockReturnThis(), - whereIn: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - orWhere: jest.fn().mockReturnThis(), - // `select` resolves to an array so `await db(...).whereIn(...).select(...)` - // gives an iterable result (used by the branding-defaults probe added in - // #592 follow-up). Tests that don't need it leave selectResult undefined - // and get `[]`, which is a safe no-op for any caller that iterates. - select: jest.fn().mockResolvedValue(selectResult ?? []), - first: jest.fn().mockResolvedValue(firstResult), - insert: jest.fn().mockReturnThis(), - returning: jest.fn().mockResolvedValue(returningResult ?? insertResult ?? [{ id: 1 }]), - }; - return chain; -}; - -jest.mock('../../../database/db', () => { - const dbMock = jest.fn(); - dbMock.raw = jest.fn(); - dbMock.__setImplementations = (...chains) => { - dbMock.mockReset(); - chains.forEach((chain) => { - dbMock.mockImplementationOnce(() => chain); - }); - }; - return { - db: dbMock, - logActivity: jest.fn().mockResolvedValue(undefined), - }; +let db, cleanup, app, adminId, adminToken, apiToken; +const base = { event_type: 'wedding', event_name: 'Creation parity', event_date: '2030-06-15', + customer_name: 'Ada', customer_email: 'ada@example.test', admin_email: 'admin@example.test', + require_password: false, is_draft: false, expires_at: '2030-07-15T00:00:00.000Z' }; +beforeAll(async () => { + ({ db, cleanup } = await bootCrmDb()); ({ adminId } = await seedMinimal(db)); await assignAdminRole(db, adminId); + adminToken = mintAdminToken(adminId); + const generated = require('../../../middleware/apiTokenAuth').generateApiToken(); apiToken = generated.plaintext; + await db('api_tokens').insert({ name: 'parity', hashed_token: generated.hashed, scopes: 'admin', created_by: adminId }); + app = express(); app.use(express.json()); + app.use('/admin', require('../../adminEvents')); + app.use('/v1', require('../events')); +}, 120000); +afterAll(async () => { await require('../../../services/serviceShutdown').stopServices(); await cleanup(); }); +async function create(source, extra) { + const input = { ...base, ...extra }; + if (source === 'legacy') return require('../../../services/eventService').createEvent(input, { actor: { id: adminId } }); + const response = await request(app).post(source === 'admin' ? '/admin' : '/v1/events') + .set('Authorization', `Bearer ${source === 'admin' ? adminToken : apiToken}`).send(input); + expect(response.status).toBe(source === 'admin' ? 200 : 201); + return response.body; +} +it.each(['admin', 'v1', 'legacy'])('%s stores theme, owner, dates and feedback defaults through one use case', async source => { + const theme = JSON.stringify({ primaryColor: '#ff0066' }); + const created = await create(source, { color_theme: theme, feedback_enabled: true }); + const row = await db('events').where({ id: created.id }).first(); + expect(row).toMatchObject({ color_theme: theme, created_by: adminId, event_name: base.event_name, customer_email: base.customer_email }); + expect(require('../../../utils/dateNormalize').toIso(row.expires_at)).toBe(base.expires_at); + expect([false, 0]).toContain(row.require_password); + expect(row.updated_at).toBeTruthy(); expect(row.share_token).toBeTruthy(); expect(row.password_hash).toBeTruthy(); + const feedback = await db('event_feedback_settings').where({ event_id: row.id }).first(); + for (const key of ['feedback_enabled','allow_ratings','allow_likes','allow_comments','allow_favorites','allow_reactions','moderate_comments','show_feedback_to_guests']) expect([true, 1]).toContain(feedback[key]); + expect([false, 0]).toContain(feedback.allow_color_labels); expect(feedback.keybind_mode).toBe('colors'); }); - -// RBAC is enforced on these routes since GHSA-9697 (requirePermission), but -// this suite mocks the database, so a real permission lookup would 500. These -// tests cover route logic, not authorization — the intersection of token -// scopes and role permissions is pinned in __tests__/routes/v1EventOwnership. -jest.mock('../../../middleware/permissions', () => ({ - requirePermission: () => (_req, _res, next) => next(), - userHasAnyPermission: async () => true, - userHasAllPermissions: async () => true, -})); - -jest.mock('../../../middleware/apiTokenAuth', () => ({ - apiTokenAuth: (req, _res, next) => { - req.apiToken = { id: 1, admin_id: 1, scopes: ['admin'] }; - req.admin = { id: 1, username: 'token-admin' }; - next(); - }, - requireApiScope: () => (_req, _res, next) => next(), -})); - -// bcrypt.hash is awaited twice per request (real path + dummy path). -// Stub it to a constant so tests don't burn CPU on bcrypt rounds. -jest.mock('bcrypt', () => ({ - hash: jest.fn().mockResolvedValue('$2b$10$mocked-hash'), -})); - -jest.mock('../../../services/shareLinkService', () => ({ - buildShareLinkVariants: jest.fn().mockResolvedValue({ - shareUrl: 'https://example.test/gallery/some-slug?t=abc', - shareLinkToStore: '/gallery/some-slug?t=abc', - }), -})); - -// Webhook fire is in a try/catch; stub to silence the predictable -// failure log so test output stays clean. -jest.mock('../../../services/webhookService', () => ({ - fire: jest.fn().mockResolvedValue(undefined), - buildEventSubject: jest.fn().mockReturnValue({}), -})); - -// event_type is validated against the live event_types catalog (#800) — -// that lookup would consume the first queued db() chain and shift the -// call sequence these tests pin. Stub it valid; the invalid path has its -// own test below. -jest.mock('../../../services/eventTypeService', () => ({ - isValidEventType: jest.fn().mockResolvedValue(true), -})); - -const { db } = require('../../../database/db'); -const { isValidEventType } = require('../../../services/eventTypeService'); -const eventsRouter = require('../events'); - -const buildApp = () => { - const app = express(); - app.use(express.json()); - app.use('/', eventsRouter); - return app; -}; - -const BASE_BODY = { - event_name: 'Issue 550 Wedding', - event_type: 'wedding', - event_date: '2026-06-15', - require_password: false, -}; - -// db() call sequence for BASE_BODY (no feedback / devtools provided, -// require_password supplied so its probe is skipped, no customer_phone, -// no slug collision): -// 1. app_settings.where('event_default_feedback_enabled').first() (#550) -// 2. app_settings.where('enable_devtools_protection').first() (#592) -// 3. app_settings.whereIn([branding_logo_display_hero,...]).select(...) (#592 follow-up) -// Then slug probe, events insert, optional feedback insert. -const baseSettingsChains = () => [ - buildChain({ firstResult: null }), // feedback default - buildChain({ firstResult: null }), // devtools default - buildChain({ selectResult: [] }), // image-security whereIn → empty rows (#1296) - buildChain({ selectResult: [] }), // branding whereIn → empty rows -]; - -describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('persists color_theme to the events row when provided', async () => { - const slugChain = buildChain({ firstResult: null }); - const insertChain = buildChain({ returningResult: [{ id: 42 }] }); - db.__setImplementations(...baseSettingsChains(), slugChain, insertChain); - - await request(buildApp()) - .post('/events') - .send({ ...BASE_BODY, color_theme: 'default' }) - .expect(201); - - const insertedRow = insertChain.insert.mock.calls[0][0]; - expect(insertedRow).toMatchObject({ - event_name: 'Issue 550 Wedding', - color_theme: 'default', - }); - }); - - it('accepts a JSON-encoded theme string and persists it verbatim', async () => { - const slugChain = buildChain({ firstResult: null }); - const insertChain = buildChain({ returningResult: [{ id: 43 }] }); - db.__setImplementations(...baseSettingsChains(), slugChain, insertChain); - - const customTheme = JSON.stringify({ primaryColor: '#ff0066' }); - await request(buildApp()) - .post('/events') - .send({ ...BASE_BODY, color_theme: customTheme }) - .expect(201); - - const insertedRow = insertChain.insert.mock.calls[0][0]; - expect(insertedRow.color_theme).toBe(customTheme); - }); - - it('creates event_feedback_settings row when feedback_enabled=true is sent', async () => { - // feedback_enabled provided → feedback probe SKIPPED. Sequence: - // 1. devtools probe - // 2. image-security probe (whereIn → select, #1296) - // 3. branding probe (whereIn → select) - // 4. slug probe - // 5. events insert - // 6. feedback sub-toggle defaults probe (whereIn → select, #1044) - // 7. event_feedback_settings insert - const devtoolsChain = buildChain({ firstResult: null }); - const imageSecurityChain = buildChain({ selectResult: [] }); - const brandingChain = buildChain({ selectResult: [] }); - const slugChain = buildChain({ firstResult: null }); - const insertChain = buildChain({ returningResult: [{ id: 50 }] }); - const feedbackDefaultsChain = buildChain({ selectResult: [] }); - const feedbackInsertChain = buildChain(); - db.__setImplementations( - devtoolsChain, imageSecurityChain, brandingChain, slugChain, insertChain, - feedbackDefaultsChain, feedbackInsertChain, - ); - - await request(buildApp()) - .post('/events') - .send({ ...BASE_BODY, feedback_enabled: true }) - .expect(201); - - expect(db).toHaveBeenNthCalledWith(7, 'event_feedback_settings'); - - const feedbackRow = feedbackInsertChain.insert.mock.calls[0][0]; - expect(feedbackRow).toMatchObject({ event_id: 50 }); - // formatBoolean() returns 1/0 on SQLite and true/false on PG. Either - // way the value must be truthy/falsy in the right places — assert by - // coercion so the test stays driver-agnostic. - expect(Boolean(feedbackRow.feedback_enabled)).toBe(true); - expect(Boolean(feedbackRow.allow_ratings)).toBe(true); - expect(Boolean(feedbackRow.allow_likes)).toBe(true); - expect(Boolean(feedbackRow.allow_comments)).toBe(true); - expect(Boolean(feedbackRow.allow_favorites)).toBe(true); - // #1044: this insert used to omit allow_reactions entirely, so v1-created - // events only got reactions by accident of the column default. - expect(Boolean(feedbackRow.allow_reactions)).toBe(true); - // Colour labels are opt-in, so they stay off until the global is flipped. - expect(Boolean(feedbackRow.allow_color_labels)).toBe(false); - expect(feedbackRow.keybind_mode).toBe('colors'); - expect(Boolean(feedbackRow.require_name_email)).toBe(false); - expect(Boolean(feedbackRow.moderate_comments)).toBe(true); - expect(Boolean(feedbackRow.show_feedback_to_guests)).toBe(true); - }); - - it('honours the event_default_feedback_enabled global when body omits feedback_enabled', async () => { - // Feedback probe returns serialized "true" → fallback kicks in and - // the feedback insert runs. Sequence: feedback probe, devtools probe, - // image-security probe (#1296), branding probe, slug, insert, sub-toggle - // defaults probe (#1044), feedback insert (8 calls total). - const feedbackProbe = buildChain({ - firstResult: { setting_key: 'event_default_feedback_enabled', setting_value: 'true' }, - }); - const devtoolsChain = buildChain({ firstResult: null }); - const imageSecurityChain = buildChain({ selectResult: [] }); - const brandingChain = buildChain({ selectResult: [] }); - const slugChain = buildChain({ firstResult: null }); - const insertChain = buildChain({ returningResult: [{ id: 51 }] }); - const feedbackDefaultsChain = buildChain({ selectResult: [] }); - const feedbackInsertChain = buildChain(); - db.__setImplementations( - feedbackProbe, devtoolsChain, imageSecurityChain, brandingChain, slugChain, - insertChain, feedbackDefaultsChain, feedbackInsertChain, - ); - - await request(buildApp()) - .post('/events') - .send(BASE_BODY) - .expect(201); - - expect(db).toHaveBeenNthCalledWith(8, 'event_feedback_settings'); - expect(feedbackInsertChain.insert).toHaveBeenCalledTimes(1); - }); - - it('does NOT create a feedback row when global setting is unset and body omits feedback_enabled', async () => { - const slugChain = buildChain({ firstResult: null }); - const insertChain = buildChain({ returningResult: [{ id: 52 }] }); - db.__setImplementations(...baseSettingsChains(), slugChain, insertChain); - - await request(buildApp()) - .post('/events') - .send(BASE_BODY) - .expect(201); - - // 6 db() calls: feedback + devtools + image-security + branding probes, - // slug, insert. event_feedback_settings is never touched. - expect(db).toHaveBeenCalledTimes(6); - expect(db).not.toHaveBeenCalledWith('event_feedback_settings'); - }); - - it('rejects non-boolean feedback_enabled with 400', async () => { - // Validators run before any db() call, so no chain queueing needed. - await request(buildApp()) - .post('/events') - .send({ ...BASE_BODY, feedback_enabled: 'maybe' }) - .expect(400); - }); - - it('rejects an event_type unknown to the catalog with 400 (#800)', async () => { - isValidEventType.mockResolvedValueOnce(false); - const res = await request(buildApp()) - .post('/events') - .send({ ...BASE_BODY, event_type: 'nope' }) - .expect(400); - - expect(isValidEventType).toHaveBeenCalledWith('nope'); - expect(JSON.stringify(res.body.errors)).toContain('event_type'); - expect(db).not.toHaveBeenCalled(); - }); +it('inherits global feedback and preserves explicit overrides for every entry point', async () => { + await db('app_settings').insert({ setting_key: 'event_default_feedback_enabled', setting_value: 'true', setting_type: 'boolean' }) + .onConflict('setting_key').merge({ setting_value: 'true' }); + for (const source of ['admin', 'v1', 'legacy']) { + const inherited = await create(source, {}); + expect(await db('event_feedback_settings').where({ event_id: inherited.id }).first()).toBeTruthy(); + const override = await create(source, { feedback_enabled: false }); + expect(await db('event_feedback_settings').where({ event_id: override.id }).first()).toBeUndefined(); + } +}); +it('queues a publication email only for published galleries', async () => { + const draft = await create('admin', { is_draft: true }); + expect(await db('email_queue').where({ event_id: draft.id })).toHaveLength(0); + const published = await create('v1', {}); + expect(await db('email_queue').where({ event_id: published.id, email_type: 'gallery_created' })).toHaveLength(1); +}); +it('rejects a required password that is missing with 400 on both routes', async () => { + for (const source of ['admin', 'v1']) { + const response = await request(app).post(source === 'admin' ? '/admin' : '/v1/events') + .set('Authorization', `Bearer ${source === 'admin' ? adminToken : apiToken}`).send({ ...base, require_password: true }); + expect(response.status).toBe(400); + } +}); +it('keeps accepting "0"/"1" string booleans on the v1 surface', async () => { + const created = await create('v1', { require_password: '0', feedback_enabled: '1' }); + const row = await db('events').where({ id: created.id }).first(); + expect([false, 0]).toContain(row.require_password); + expect(await db('event_feedback_settings').where({ event_id: row.id }).first()).toBeTruthy(); +}); +it.each([{ feedback_enabled: 'maybe' }, { event_type: 'unknown' }])('rejects invalid creation data before persistence: %j', async extra => { + for (const source of ['admin', 'v1']) { + const response = await request(app).post(source === 'admin' ? '/admin' : '/v1/events') + .set('Authorization', `Bearer ${source === 'admin' ? adminToken : apiToken}`).send({ ...base, ...extra }); + expect(response.status).toBe(400); + } }); diff --git a/backend/src/routes/v1/events.js b/backend/src/routes/v1/events.js index 96b0958b..a910cd26 100644 --- a/backend/src/routes/v1/events.js +++ b/backend/src/routes/v1/events.js @@ -30,15 +30,13 @@ const { requireEventOwnership, scopeEventsQuery } = require('../../middleware/ow // requirePermission gates supply the missing half; they key on req.admin.id, // which apiTokenAuth populates. const { requirePermission } = require('../../middleware/permissions'); -const { resolveEventFeedbackDefaults } = require('../../services/feedbackDefaults'); -const { galleryPasswordColumns, dropCopiesIfStorageOff } = require('../../utils/galleryPasswordVault'); + const { buildShareLinkVariants } = require('../../services/shareLinkService'); const { generateThumbnail } = require('../../services/imageProcessor'); const logger = require('../../utils/logger'); -const { slugify } = require('../../utils/slug'); + const { formatBoolean } = require('../../utils/dbCompat'); -const { parseBooleanInput } = require('../../utils/parsers'); -const { getImageSecurityDefaults, resolveImageSecurityColumns, decodeSettingValue } = require('../adminEvents/helpers'); + const { isValidEventType } = require('../../services/eventTypeService'); const { replacePhoto } = require('../../services/photoReplacementService'); const { getMaxFileSizeBytes, DEFAULT_MAX_FILE_SIZE_MB } = require('../../services/uploadSettings'); @@ -195,262 +193,12 @@ router.post( try { const errors = validationResult(req); if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) }); - const { - event_name, event_type, event_date, - customer_name = null, customer_email = null, customer_phone = null, - admin_email = null, - require_password: requirePasswordInput, - password, - expires_at = null, - color_theme = null, - feedback_enabled: feedbackEnabledInput, - enable_devtools_protection: devtoolsInput, - hero_logo_visible: heroLogoVisibleInput, - hero_logo_size: heroLogoSizeInput, - hero_logo_position: heroLogoPositionInput - } = req.body; - - // Issue #550 — mirror the admin POST path so API-created events - // pick up the global "Enable Guest Feedback by default" toggle - // (event_default_feedback_enabled). Without this, the UI reads - // a missing event_feedback_settings row as "feedback off" - // regardless of the admin's chosen default. - let feedbackEnabledFallback = false; - if (feedbackEnabledInput === undefined) { - const setting = await db('app_settings').where('setting_key', 'event_default_feedback_enabled').first(); - if (setting) { - try { - const parsed = JSON.parse(setting.setting_value); - if (typeof parsed === 'boolean') feedbackEnabledFallback = parsed; - } catch { /* keep false */ } - } - } - const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback); - - // Issue #592 — same shape as the feedback fallback above. The - // events table column default is `true`, so without this an admin - // who disabled devtools detection globally still gets it ON for - // every API-created gallery. Mirrors adminEvents.js behaviour. - let devtoolsFallback = true; - if (devtoolsInput === undefined) { - const setting = await db('app_settings').where('setting_key', 'enable_devtools_protection').first(); - if (setting) { - // Shared decoder: a legacy row can carry several layers of JSON - // quoting, and a single parse would leave the string 'false' here, - // reject it, and quietly enable protection the operator disabled. - const parsed = decodeSettingValue(setting.setting_value); - if (typeof parsed === 'boolean') devtoolsFallback = parsed; - } - } - const enable_devtools_protection = parseBooleanInput(devtoolsInput, devtoolsFallback); - - // #1296 — same shape again, for the four Image Security settings that - // were stored and applied nowhere. Shared with the admin create route - // so a gallery's security level does not depend on which endpoint made - // it; #592 above is the bug this would otherwise repeat. - const imageSecurityColumns = resolveImageSecurityColumns( - req.body, - await getImageSecurityDefaults(), - ); - - // Same shape as the feedback / devtools fallbacks: honour the global - // event_default_require_password toggle (#317). Without this an admin - // who disabled "require password by default" globally still got - // password-required galleries through the API. - let requirePasswordFallback = true; - if (requirePasswordInput === undefined) { - const setting = await db('app_settings').where('setting_key', 'event_default_require_password').first(); - if (setting) { - try { - const parsed = JSON.parse(setting.setting_value); - if (typeof parsed === 'boolean') requirePasswordFallback = parsed; - } catch { /* keep true */ } - } - } - const require_password = parseBooleanInput(requirePasswordInput, requirePasswordFallback); - - // Branding inheritance (Feature 7) — mirror adminEvents.js - // getBrandingDefaults so API-created events inherit the global - // hero logo visibility + size. hero_logo_position is intentionally - // NOT settings-backed (see migration 084 / #357 — branding_logo_position - // is the *header bar*, a different concept than the hero block). - let heroLogoVisibleFallback = true; - let heroLogoSizeFallback = 'medium'; - const brandingRows = await db('app_settings') - .whereIn('setting_key', ['branding_logo_display_hero', 'branding_logo_size']) - .select('setting_key', 'setting_value'); - for (const row of brandingRows) { - let value = row.setting_value; - if (typeof value === 'string') { - try { value = JSON.parse(value); } catch { /* keep raw */ } - } - if (row.setting_key === 'branding_logo_display_hero') heroLogoVisibleFallback = value !== false; - if (row.setting_key === 'branding_logo_size' && value) heroLogoSizeFallback = value; - } - const hero_logo_visible = heroLogoVisibleInput !== undefined ? heroLogoVisibleInput : heroLogoVisibleFallback; - const hero_logo_size = heroLogoSizeInput || heroLogoSizeFallback; - const hero_logo_position = heroLogoPositionInput || 'top'; - - if (require_password && (!password || password.length < 6)) { - return res.status(400).json({ error: 'Password is required when require_password is true (min 6 chars)' }); - } - - // Honour global phone-field toggle (#322). - let persistPhone = null; - if (customer_phone) { - const setting = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first(); - const enabled = setting ? JSON.parse(setting.setting_value) === true : false; - persistPhone = enabled ? customer_phone : null; - } - - // Generate unique slug. - const baseSlug = `${event_type}-${slugify(event_name)}-${event_date || crypto.randomBytes(3).toString('hex')}`; - let slug = baseSlug; - let counter = 1; - while (await db('events').where({ slug }).first()) slug = `${baseSlug}-${counter++}`; - - const shareToken = crypto.randomBytes(16).toString('hex'); - const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken }); - - // password_hash is NOT NULL; use a random placeholder when no - // password is required so the column constraint is satisfied. - const bcrypt = require('bcrypt'); - const passwordHash = require_password - ? await bcrypt.hash(password, 10) - : await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 10); - - const insertResult = await db('events').insert({ - slug, - event_type, - event_name, - event_date: event_date || null, - host_name: customer_name, - host_email: customer_email, - admin_email, - password_hash: passwordHash, - // #1271 — recoverable copy rides with the hash; only when there is one - ...(require_password && password ? await galleryPasswordColumns({ password }) : {}), - require_password, - share_link: shareLinkToStore, - share_token: shareToken, - expires_at: expires_at || null, - created_at: new Date().toISOString(), - created_by: req.admin.id, - is_draft: false, - // Issue #550 — without this, editing an API-created event in the - // admin UI snaps the theme picker to GALLERY_THEME_PRESETS.default - // and saving overwrites whatever theme was inherited visually. - color_theme, - // Issue #592 — write the resolved devtools setting (input value - // or global fallback) so the column default doesn't shadow it. - enable_devtools_protection: formatBoolean(enable_devtools_protection), - // Request value, else the global default, else the column default. - ...imageSecurityColumns, - // Branding inheritance — resolved value from body or app_settings. - hero_logo_visible: formatBoolean(hero_logo_visible), - hero_logo_size, - hero_logo_position, - ...(customer_name ? { customer_name } : {}), - ...(customer_email ? { customer_email } : {}), - ...(persistPhone ? { customer_phone: persistPhone } : {}) - }).returning('id'); - const id = insertResult[0]?.id || insertResult[0]; - if (require_password && password) await dropCopiesIfStorageOff(id); - - // Issue #550 — mirror adminEvents.js: create event_feedback_settings - // row when feedback is enabled, so the gallery actually shows feedback - // UI. The sub-flags come from the shared global defaults (#1044) rather - // than a hard-coded list, which is how this path silently shipped - // without allow_reactions for two releases. - if (feedback_enabled) { - const feedbackDefaults = await resolveEventFeedbackDefaults(); - await db('event_feedback_settings').insert({ - event_id: id, - feedback_enabled: formatBoolean(true), - allow_ratings: formatBoolean(feedbackDefaults.allow_ratings), - allow_likes: formatBoolean(feedbackDefaults.allow_likes), - allow_comments: formatBoolean(feedbackDefaults.allow_comments), - allow_favorites: formatBoolean(feedbackDefaults.allow_favorites), - allow_reactions: formatBoolean(feedbackDefaults.allow_reactions), - allow_color_labels: formatBoolean(feedbackDefaults.allow_color_labels), - keybind_mode: feedbackDefaults.keybind_mode, - require_name_email: formatBoolean(false), - moderate_comments: formatBoolean(true), - show_feedback_to_guests: formatBoolean(true), - created_at: new Date().toISOString(), - updated_at: new Date().toISOString() - }); - } - - await logActivity('event_created', { via: 'api_v1', event_type }, id, { - type: 'admin', id: req.admin.id, name: req.admin.username + const created = await require('../../services/eventCreationService').createEvent(req.body, { + actor: req.admin, source: 'v1', }); - - // Customer notifications (#647 follow-up). v1 events go live in the - // same call (not draft-aware), so the gallery_created email + WhatsApp - // fire here — mirroring the adminEvents.js create-and-publish path. - // Both are best-effort: a queue failure must not block the API response. - const expiryIso = expires_at ? new Date(expires_at).toISOString() : null; - if (customer_email) { - try { - const { queueEmail } = require('../../services/emailProcessor'); - await queueEmail(id, customer_email, 'gallery_created', { - customer_name: customer_name || '', - customer_email, - host_name: customer_name || '', - event_name, - event_date: event_date || null, - gallery_link: shareUrl, - gallery_password: require_password ? password : 'No password required', - expiry_date: expiryIso, - welcome_message: '' - }); - } catch (emailError) { - logger.warn('v1 POST /events: failed to queue gallery_created email', { error: emailError.message }); - } - } - if (persistPhone) { - try { - const { queueWhatsapp, getWhatsAppConfig } = require('../../services/whatsappProcessor'); - const waConfig = await getWhatsAppConfig(); - if (waConfig && waConfig.enabled) { - await queueWhatsapp(id, persistPhone, 'gallery_created', { - customer_name: customer_name || '', - event_name, - gallery_link: shareUrl, - gallery_password: require_password ? password : '', - expiry_date: expiryIso, - language: null, - }); - } - } catch (waError) { - logger.warn('v1 POST /events: failed to queue WhatsApp notification', { error: waError.message }); - } - } - - // Webhook lifecycle (#327). v1 events are not draft-aware, so they're - // both created AND published in the same call. Canonical event - // subject (#341) — customer contact + share_token always included. - try { - const webhookService = require('../../services/webhookService'); - const eventSubject = webhookService.buildEventSubject({ - id, - slug, - event_name, - event_type, - event_date, - share_url: shareUrl, - share_token: shareToken, - customer_name, - customer_email, - customer_phone, - }); - await webhookService.fire('event.created', { event: eventSubject }); - await webhookService.fire('event.published', { event: eventSubject }); - } catch (e) { /* non-fatal */ } - - res.status(201).json({ id, slug, share_url: shareUrl, share_token: shareToken }); + res.status(201).json({ id: created.id, slug: created.slug, share_url: created.share_link, share_token: created.share_token }); } catch (error) { + if (error.isOperational) return res.status(error.statusCode).json(error.responseBody || { error: error.message, code: error.code }); logger.error('v1 POST /events failed', { error: error.message, stack: error.stack }); res.status(500).json({ error: 'Failed to create event', detail: error.message }); } diff --git a/backend/src/services/chunkedUploadService.js b/backend/src/services/chunkedUploadService.js index d0fc4711..43ff475b 100644 --- a/backend/src/services/chunkedUploadService.js +++ b/backend/src/services/chunkedUploadService.js @@ -338,14 +338,13 @@ async function cleanupExpiredUploads() { return expiredIds.length; } -// Run cleanup every hour. unref so this module-level housekeeping timer -// never holds the process open on its own — in production the HTTP -// listener keeps the loop alive, and in Jest this exact handle kept the -// runner from exiting for every suite that requires adminPhotos (#908; -// it is why adminPhotos.reference sits on the CI ignore list). -setInterval(cleanupExpiredUploads, 60 * 60 * 1000).unref(); +const cleanupTask = require('./scheduledTask').scheduledTask(cleanupExpiredUploads, { + interval: 60 * 60 * 1000 +}); +cleanupTask.start(); module.exports = { + stop: () => cleanupTask.stop(), initializeUpload, uploadChunk, completeUpload, diff --git a/backend/src/services/downloadJobCleanupService.js b/backend/src/services/downloadJobCleanupService.js index 65a1fbcb..f614add2 100644 --- a/backend/src/services/downloadJobCleanupService.js +++ b/backend/src/services/downloadJobCleanupService.js @@ -12,21 +12,13 @@ * schedulers don't all wake at once. */ -const cron = require('node-cron'); +const { scheduledTask } = require('./scheduledTask'); const logger = require('../utils/logger'); const downloadJobService = require('./downloadJobService'); -function startDownloadJobCleanup() { - // A restart leaves any in-flight build with no worker. Fail those rows once - // at startup so their owners get a clear error instead of polling forever. - downloadJobService.recoverOrphanedJobs().catch((err) => - logger.error('Download job recovery failed', { error: err.message })); - - cron.schedule('7,27,47 * * * *', async () => { - await runDownloadJobCleanup(); - }); - logger.info('Download job cleanup scheduler started'); -} +const task = scheduledTask(runDownloadJobCleanup, { schedule: '7,27,47 * * * *' }); +function startDownloadJobCleanup() { task.start(); } +const stopDownloadJobCleanup = () => task.stop(); async function runDownloadJobCleanup() { try { @@ -37,6 +29,7 @@ async function runDownloadJobCleanup() { } module.exports = { + stopDownloadJobCleanup, startDownloadJobCleanup, // exported for tests / manual invocation runDownloadJobCleanup, diff --git a/backend/src/services/downloadZipService.js b/backend/src/services/downloadZipService.js index 428c028b..ffc5f42d 100644 --- a/backend/src/services/downloadZipService.js +++ b/backend/src/services/downloadZipService.js @@ -37,6 +37,13 @@ class DownloadZipService { this.versions = new Map(); // eventId -> generation counter } + async stop() { + for (const timer of this.debounceTimers.values()) clearTimeout(timer); + this.debounceTimers.clear(); + await Promise.allSettled([...this.activeBuilds.values()].map(build => build.promise)); + this.versions.clear(); + } + /** * Relative storage key for the cached zip. */ diff --git a/backend/src/services/emailIntakeService.js b/backend/src/services/emailIntakeService.js index a4834d08..6db14ca6 100644 --- a/backend/src/services/emailIntakeService.js +++ b/backend/src/services/emailIntakeService.js @@ -532,11 +532,8 @@ async function pollOnce() { } /** Start the 1-minute poll loop (mirrors the outgoing queue cadence). */ -function startIncomingMailPoller() { - const run = () => pollOnce().catch((e) => logger.error?.(`emailIntake: ${e.message}`)); - setTimeout(run, 15000); // first run shortly after boot - setInterval(run, 60 * 1000); - logger.info?.('Incoming-mail poller started (every 60s when enabled)'); -} +const mailPoller = require('./scheduledTask').scheduledTask(pollOnce, { interval: 60000, initialDelay: 15000 }); +function startIncomingMailPoller() { mailPoller.start(); } +const stopIncomingMailPoller = () => mailPoller.stop(); -module.exports = { pollOnce, startIncomingMailPoller, listFolders, testConnection, roundTripTest, _internal: { getImapConfig, isEnabled, saveAttachment } }; +module.exports = { stopIncomingMailPoller, pollOnce, startIncomingMailPoller, listFolders, testConnection, roundTripTest, _internal: { getImapConfig, isEnabled, saveAttachment } }; diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index f6a6c093..f41a0c59 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -1514,39 +1514,13 @@ async function testEmailConnection() { } } -// Start email queue processor -let emailQueueInterval = null; - +const emailTask = require('./scheduledTask').scheduledTask(processEmailQueue, { interval: 60000, initialDelay: 0 }); function startEmailQueueProcessor() { - logger.info('Email queue processor: Attempting to start...'); - - if (!emailQueueInterval) { - // Process immediately on start - processEmailQueue().catch(err => { - logger.error('Email queue processor: Initial processing failed:', err); - }); - - // Then process every minute - emailQueueInterval = setInterval(() => { - processEmailQueue().catch(err => { - logger.error('Email queue processor: Periodic processing failed:', err); - }); - }, 60000); - - processorStatus.started = true; - logger.info('Email queue processor started successfully'); - } else { - logger.info('Email queue processor: Already running'); - } + emailTask.start(); processorStatus.started = true; } - -function stopEmailQueueProcessor() { - if (emailQueueInterval) { - clearInterval(emailQueueInterval); - emailQueueInterval = null; - processorStatus.started = false; - logger.info('Email queue processor stopped'); - } +async function stopEmailQueueProcessor() { + await emailTask.stop(); processorStatus.started = false; + transporter?.close?.(); transporter = null; } // Initialize on module load - DISABLED for production startup diff --git a/backend/src/services/emailWebhookTransport.js b/backend/src/services/emailWebhookTransport.js index 28703c15..51589c7b 100644 --- a/backend/src/services/emailWebhookTransport.js +++ b/backend/src/services/emailWebhookTransport.js @@ -25,6 +25,7 @@ const fs = require('fs').promises; const axios = require('axios'); const logger = require('../utils/logger'); const { signPayload } = require('./webhookService'); +const { pinnedRequestOptions } = require('../utils/pinnedRequest'); const { validateExternalUrlAsync } = require('../utils/networkValidation'); const SIGNATURE_HEADER = 'X-PicPeak-Signature'; @@ -167,6 +168,7 @@ async function send(mail) { // Vetted before every send, not once at startup: DNS answers change, and the // check is what stops an operator-supplied URL becoming a request to link // local metadata or a service on the host network. + let connectionOptions = {}; if (!allowPrivateUrls) { // https for anything leaving the machine. The HMAC proves who sent the // body, not who can read it — and these bodies carry password-reset links @@ -190,6 +192,7 @@ async function send(mail) { + 'private network (a container or LAN address).' ); } + connectionOptions = pinnedRequestOptions(check); } const payload = { @@ -218,6 +221,7 @@ async function send(mail) { let response; try { response = await axios.post(url, rawBody, { + ...connectionOptions, headers: { 'Content-Type': 'application/json', [SIGNATURE_HEADER]: signature, diff --git a/backend/src/services/eventCreationService.js b/backend/src/services/eventCreationService.js new file mode 100644 index 00000000..8941d27a --- /dev/null +++ b/backend/src/services/eventCreationService.js @@ -0,0 +1,615 @@ +const { db, logActivity } = require('../database/db'); +const bcrypt = require('bcrypt'); +const crypto = require('crypto'); +const fs = require('fs').promises; +const path = require('path'); +const logger = require('../utils/logger'); +const { AppError } = require('../utils/errors'); +const { formatBoolean } = require('../utils/dbCompat'); +const { slugify } = require('../utils/slug'); +const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); +const { buildShareLinkVariants } = require('./shareLinkService'); +const { parseBooleanInput } = require('../utils/parsers'); +const { normaliseEventTimeTriple } = require('./eventService'); +const { hasColumnCached } = require('../utils/schemaCache'); +const { getAppSetting } = require('../utils/appSettings'); +const { galleryPasswordColumns, dropCopiesIfStorageOff } = require('../utils/galleryPasswordVault'); +const { clampIntOrUndefined } = require('../utils/numericHelpers'); +const { getFrontendBaseUrl } = require('../utils/frontendUrl'); +const { resolveEventFeedbackDefaults, applyFeedbackDefaults } = require('./feedbackDefaults'); +const { getStoragePath, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, + getImageSecurityDefaults, resolveImageSecurityColumns, getBrandingDefaults, getCustomerNameFromPayload, + getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, hasCustomerContactColumns, + SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./eventSettings'); +const { validateCreationInput } = require('./eventCreationValidation'); +function creationError(body) { + const error = new AppError(body.error || 'Invalid event', 400, 'EVENT_INVALID'); + error.responseBody = body; + return error; +} + +/** Shared creation operation. v1 explicitly publishes immediately and accepts + * an optional absolute expiry; admin/legacy use configured field requirements. + */ +async function createEvent(data, { actor, source = 'admin', frontendUrl } = {}) { + const input = await validateCreationInput(data); + if (source === 'v1') input.is_draft = false; + if (!actor || !Number.isInteger(actor.id)) throw new AppError('Event owner required', 400, 'EVENT_OWNER_REQUIRED'); + // Get field requirements from settings + const fieldRequirements = source === 'v1' + ? { require_expiration: false } : await getEventFieldRequirements(); + + const { + event_type, + event_name, + event_date, + // Migration 137 — calendar time fields. is_full_day defaults to + // true at the service layer when undefined (legacy form payloads). + event_time_start, + event_time_end, + is_full_day, + admin_email, + password, + welcome_message = '', + color_theme = null, + expiration_days = 30, + allow_user_uploads = false, + upload_category_id = null, + allow_downloads = true, + disable_right_click = false, + enable_devtools_protection: enableDevtoolsProtectionInput, + watermark_downloads = false, + watermark_text = null, + require_password: requirePasswordInput, + // Feedback settings. The allow_* sub-toggles deliberately have NO + // destructuring defaults: `undefined` means "the caller didn't say", + // which inherits the global Settings > Events default (#1044). The + // admin create form posts explicit values (it seeds its own panel + // from the same globals), so inheritance here is what covers the v1 + // API and any other caller that omits them. + feedback_enabled: feedbackEnabledInput, + allow_ratings: allowRatingsInput, + allow_likes: allowLikesInput, + allow_comments: allowCommentsInput, + allow_favorites: allowFavoritesInput, + allow_reactions: allowReactionsInput, + allow_color_labels: allowColorLabelsInput, + keybind_mode: keybindModeInput, + require_name_email = false, + moderate_comments = true, + show_feedback_to_guests = true, + // The create form has always shown the identity-mode chooser and this + // route has never read it, so a gallery created as 'guest' quietly came + // out 'simple' and the photographer had to set it again on the event. + // Surfaced by adding a third mode (#1197); the fix is the same for all + // three. Unknown values fall back rather than reaching the column, + // which on Postgres is guarded by a CHECK constraint. + identity_mode: identityModeInput, + // CSS Template + css_template_id = null, + // Hero logo settings + hero_logo_visible = true, + // Header style settings + header_style = 'standard', + hero_divider_style = 'wave', + // Hero image anchor position (#162) + hero_image_anchor = 'center', + // Photo cap + photo_cap = null, + // Client access settings (#172) + client_access_enabled = false, + client_password = null, + // Draft mode + is_draft = source !== 'v1', + // Default photo sort + default_photo_sort = 'upload_date_desc', + // Banner overrides (#440 / #932) — see the insert below. + promo_mode = 'inherit', + promo_markdown = null, + info_mode = 'inherit', + info_markdown = null + } = input; + + const customerName = getCustomerNameFromPayload(input); + const customerEmail = getCustomerEmailFromPayload(input); + // Phone field is opt-in via the global setting (#322). If disabled, + // ignore whatever the client posted — defence in depth against form + // bypass. + const phoneEnabled = await isPhoneFieldEnabled(); + const customerPhone = phoneEnabled ? getCustomerPhoneFromPayload(input) : null; + + const customerColumnsAvailable = await hasCustomerContactColumns(); + + // Conditional validation based on settings + const validationErrors = []; + if (fieldRequirements.require_customer_name && !customerName) { + validationErrors.push({ path: 'customer_name', msg: 'Customer name is required' }); + } + if (fieldRequirements.require_customer_email && !customerEmail) { + validationErrors.push({ path: 'customer_email', msg: 'Customer email is required' }); + } + if (fieldRequirements.require_admin_email && !admin_email) { + validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' }); + } + if (fieldRequirements.require_event_date && !event_date) { + validationErrors.push({ path: 'event_date', msg: 'Event date is required' }); + } + + if (validationErrors.length > 0) { + throw creationError({ errors: validationErrors }); + } + + // Default require_password from global "event_default_require_password" + // setting when the body omits it (#317 — admins want to flip the default). + let requirePasswordFallback = true; + if (requirePasswordInput === undefined) { + const setting = await readBooleanSetting('event_default_require_password'); + if (setting !== undefined) requirePasswordFallback = setting; + } + const requirePassword = parseBooleanInput(requirePasswordInput, requirePasswordFallback); + + // Default feedback_enabled from global "event_default_feedback_enabled" + // setting when the body omits it (#520 — same pattern as require_password + // above, lets admins make Guest Feedback ON the out-of-box default for + // new events instead of toggling it on every time). + let feedbackEnabledFallback = false; + if (feedbackEnabledInput === undefined) { + const setting = await readBooleanSetting('event_default_feedback_enabled'); + if (setting !== undefined) feedbackEnabledFallback = setting; + } + const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback); + + // Sub-toggle defaults from the global Settings > Events values (#1044). + // One batched read; an explicitly-sent body value still wins. + const feedbackDefaults = applyFeedbackDefaults({ + allow_ratings: allowRatingsInput, + allow_likes: allowLikesInput, + allow_comments: allowCommentsInput, + allow_favorites: allowFavoritesInput, + allow_reactions: allowReactionsInput, + allow_color_labels: allowColorLabelsInput, + keybind_mode: keybindModeInput, + }, await resolveEventFeedbackDefaults()); + + let passwordValidation = null; + + if (requirePassword) { + // The v1 route validator marks password optional; the admin route's + // custom() guard is not shared, so enforce presence here for every path. + if (typeof password !== 'string' || password.length === 0) { + throw creationError({ error: 'Password is required when require_password is true' }); + } + passwordValidation = await validatePasswordInContext(password, 'gallery', { + eventName: event_name + }); + + if (!passwordValidation.valid) { + throw creationError({ + error: 'Password does not meet security requirements', + details: passwordValidation.errors, + score: passwordValidation.score, + feedback: passwordValidation.feedback + }); + } + } + + // Generate unique slug. Uses the shared util so accented names + // (Família, Decoração, etc.) get transliterated instead of dropped + // — see backend/src/utils/slug.js for the why (#525). + const processedEventName = slugify(event_name); + + // Use event_date in slug if provided, otherwise use random suffix + const slugSuffix = event_date || crypto.randomBytes(3).toString('hex'); + const baseSlug = `${event_type}-${processedEventName}-${slugSuffix}`; + let slug = baseSlug; + let counter = 1; + + while (await db('events').where({ slug }).first()) { + slug = `${baseSlug}-${counter}`; + counter++; + } + + // Generate share link respecting configured format + const shareToken = crypto.randomBytes(16).toString('hex'); + const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken }); + + // Hash password with configurable rounds (random placeholder when not required) + const password_hash = requirePassword + ? await bcrypt.hash(password, getBcryptRounds()) + : await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds()); + + // Calculate expiration date (days after event date) + // If expiration is not required, expires_at will be null (never expires) + // If event_date is not provided, use current date as base for expiration + let expires_at = input.expires_at ? new Date(input.expires_at) : null; + if (!expires_at && fieldRequirements.require_expiration) { + const baseDate = event_date || new Date().toISOString().split('T')[0]; + // Parse YYYY-MM-DD format as local date to avoid timezone issues + if (baseDate.match(/^\d{4}-\d{2}-\d{2}$/)) { + const [year, month, day] = baseDate.split('-').map(num => parseInt(num, 10)); + expires_at = new Date(year, month - 1, day); + } else { + expires_at = new Date(baseDate); + } + expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10)); + } + + // Create folder structure + const storagePath = getStoragePath(); + const eventPath = path.join(storagePath, 'events/active', slug); + await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true }); + await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true }); + + // Sync header_style / hero_divider_style from color_theme JSON when not + // explicitly provided in the request body (#158). + let effectiveHeaderStyle = header_style; + let effectiveDividerStyle = hero_divider_style; + if (color_theme && (!input.header_style || !input.hero_divider_style)) { + try { + if (typeof color_theme === 'string' && color_theme.startsWith('{')) { + const parsed = JSON.parse(color_theme); + if (!input.header_style && parsed.headerStyle) { + effectiveHeaderStyle = parsed.headerStyle; + } + if (!input.hero_divider_style && parsed.heroDividerStyle) { + effectiveDividerStyle = parsed.heroDividerStyle; + } + } + } catch (_) { + // color_theme is not JSON – nothing to extract + } + } + + // Get branding defaults for hero logo settings (Feature 7: Branding Inheritance) + const brandingDefaults = await getBrandingDefaults(); + // hero_logo_visible: store NULL ("inherit") unless the admin explicitly + // set it, so the global branding_logo_display_hero toggle keeps + // controlling this gallery afterwards (#756). Only an explicit per-event + // choice overrides the global. `!= null` treats an explicit null the same + // as omitted (both → inherit); otherwise formatBoolean(null) would coerce + // to 0/false on SQLite instead of NULL (the PUT handler already does this). + const effectiveHeroLogoVisible = input.hero_logo_visible != null + ? formatBoolean(hero_logo_visible) + : null; + // NULL = inherit the global branding_logo_size (#756), resolved at read + // time. Only an explicit per-event size overrides it. + const effectiveHeroLogoSize = input.hero_logo_size || null; + const effectiveHeroLogoPosition = input.hero_logo_position || brandingDefaults.hero_logo_position; + + // Inherit "Detect dev tools" from the global Image Security setting unless + // the request explicitly overrides it (#317 — admin disabled it globally + // but new events still got it ON because the column default is true). + const protectionDefaults = await getDownloadProtectionDefaults(); + // #1296 — the other four Image-security settings, which were written, + // rendered as controls, and read by nothing. Same inheritance rule as + // the devtools setting below. Creation-time only; see + // getImageSecurityDefaults for why existing events are left alone. + const imageSecurityColumns = resolveImageSecurityColumns( + input, + await getImageSecurityDefaults(), + ); + const effectiveEnableDevtoolsProtection = + enableDevtoolsProtectionInput !== undefined + ? enableDevtoolsProtectionInput + : protectionDefaults.enable_devtools_protection !== undefined + ? protectionDefaults.enable_devtools_protection + : true; + + // Migration 137 — normalise calendar time triple. Throws AppError + // 400 when is_full_day=false but times are malformed/inverted. + const calendarTriple = normaliseEventTimeTriple({ + event_time_start, event_time_end, is_full_day, + }); + const calendarColumnsExist = await hasColumnCached('events', 'is_full_day'); + + // Insert into database + // Seed the new event's Live Slideshow display style from the PICPEAK-WIDE + // preset (app_settings, Settings → Slideshow). New events inherit it and the + // admin can still override per event. Watermark is left NULL = inherit the + // global watermark; the share token is minted on demand, not seeded. Guarded + // so un-migrated installs (mid-branch) don't reference missing columns. + let slideshowSeed = {}; + if (await hasColumnCached('events', 'show_interval_ms')) { + try { + // parseInt-first: the previous `Number.isFinite(+v)` pre-check let + // NaN through for null/''/true (+null is 0, parseInt(null) is NaN), + // producing show_interval_ms=NaN in the INSERT — PG rejects that + // with "invalid input syntax for type integer" while SQLite + // silently stores NULL, so event creation 500'd on PG whenever the + // slideshow app_settings rows were absent. + const intP = (v, min, max) => clampIntOrUndefined(v, min, max); + const oneOf = (v, allowed) => (allowed.includes(v) ? v : undefined); + const i = intP(await getAppSetting('slideshow_interval_ms', undefined), 1000, 120000); + const tr = oneOf(await getAppSetting('slideshow_transition', undefined), SLIDESHOW_TRANSITIONS); + const tms = intP(await getAppSetting('slideshow_transition_ms', undefined), 100, 5000); + const cf = oneOf(await getAppSetting('slideshow_colorfilter', undefined), SLIDESHOW_COLORFILTERS); + if (i !== undefined) slideshowSeed.show_interval_ms = i; + if (tr) slideshowSeed.show_transition = tr; + if (tms !== undefined) slideshowSeed.show_transition_ms = tms; + if (cf) slideshowSeed.show_colorfilter = cf; + } catch (e) { + logger.warn('Failed to seed slideshow settings from global preset', { error: e.message }); + } + } + + const insertData = { + slug, + event_type, + event_name, + ...slideshowSeed, + event_date: event_date || null, + ...(calendarColumnsExist ? { + event_time_start: calendarTriple.event_time_start, + event_time_end: calendarTriple.event_time_end, + is_full_day: formatBoolean(calendarTriple.is_full_day), + } : {}), + ...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}), + ...(customerPhone ? { customer_phone: customerPhone } : {}), + host_name: customerName || null, + host_email: customerEmail || null, + admin_email: admin_email || null, + password_hash, + // Opt-in recoverable copy (#1271), written with the hash so the two + // can never disagree. Empty unless the security setting is on. + ...(await galleryPasswordColumns({ + ...(requirePassword && password ? { password } : {}), + ...(client_access_enabled && client_password ? { clientPassword: client_password } : {}), + })), + welcome_message, + color_theme, + share_link: shareLinkToStore, + share_token: shareToken, + expires_at: expires_at ? expires_at.toISOString() : null, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + created_by: actor.id, + allow_user_uploads: formatBoolean(allow_user_uploads), + upload_category_id, + allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true), + disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false), + enable_devtools_protection: formatBoolean(effectiveEnableDevtoolsProtection), + // Request value, else the global default, else the column default — + // a key absent here is one the database fills in (#1296). + ...imageSecurityColumns, + watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false), + watermark_text, + require_password: formatBoolean(requirePassword), + css_template_id: css_template_id || null, + // Already formatBoolean-coerced above, or null = inherit global (#756). + hero_logo_visible: effectiveHeroLogoVisible, + hero_logo_size: effectiveHeroLogoSize, + hero_logo_position: effectiveHeroLogoPosition, + // Banner overrides. Both were accepted by the validators above and + // then dropped here, so an API client could POST info_mode:'off' or a + // custom banner, get 201, and find the row still on 'inherit'. + // Markdown is only stored for 'custom' — same rule the PUT applies. + promo_mode: ['inherit', 'custom', 'off'].includes(promo_mode) ? promo_mode : 'inherit', + promo_markdown: promo_mode === 'custom' && typeof promo_markdown === 'string' && promo_markdown.trim() + ? promo_markdown.trim() : null, + info_mode: ['inherit', 'custom', 'off'].includes(info_mode) ? info_mode : 'inherit', + info_markdown: info_mode === 'custom' && typeof info_markdown === 'string' && info_markdown.trim() + ? info_markdown.trim() : null, + header_style: effectiveHeaderStyle || 'standard', + hero_divider_style: effectiveDividerStyle || 'wave', + hero_image_anchor: hero_image_anchor || 'center', + photo_cap: photo_cap || null, + is_draft: formatBoolean(parseBooleanInput(is_draft, true)), + default_photo_sort: default_photo_sort || 'upload_date_desc', + // Client access (#172) + client_access_enabled: formatBoolean(client_access_enabled), + ...(client_access_enabled && client_password ? { + client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()), + client_share_token: crypto.randomBytes(32).toString('hex') + } : {}), + // Per-event opt-in for hero-photo OG share image (#474). Defaults + // false on create — admin opts in from the event detail page once + // they've picked a hero they're comfortable surfacing publicly. + og_image_share_enabled: formatBoolean(input.og_image_share_enabled === true), + }; + + // The gallery row and its feedback configuration commit together. + const eventId = await db.transaction(async trx => { + const result = await trx('events').insert(insertData).returning('id'); + const eventId = result[0]?.id ?? result[0]; + // Insert feedback settings if feedback is enabled + if (feedback_enabled) { + await trx('event_feedback_settings').insert({ + event_id: eventId, + feedback_enabled: formatBoolean(feedback_enabled), + allow_ratings: formatBoolean(feedbackDefaults.allow_ratings), + allow_likes: formatBoolean(feedbackDefaults.allow_likes), + allow_comments: formatBoolean(feedbackDefaults.allow_comments), + allow_favorites: formatBoolean(feedbackDefaults.allow_favorites), + allow_reactions: formatBoolean(feedbackDefaults.allow_reactions), + allow_color_labels: formatBoolean(feedbackDefaults.allow_color_labels), + keybind_mode: feedbackDefaults.keybind_mode, + require_name_email: formatBoolean(require_name_email), + moderate_comments: formatBoolean(moderate_comments), + show_feedback_to_guests: formatBoolean(show_feedback_to_guests), + identity_mode: ['simple', 'guest', 'shared'].includes(identityModeInput) + ? identityModeInput + : 'simple', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }); + } + + return eventId; + }); + // #1271 — the setting was read before the hashes; re-check after the write + await dropCopiesIfStorageOff(eventId); + + // Apply customer-account assignments (#354). Skip when the customer + // portal flag is off — the frontend hides the picker in that case, + // but a stale tab could still POST customer_account_ids; we ignore + // them rather than 403 the entire create. + if (Array.isArray(input.customer_account_ids)) { + try { + const customerAccountsService = require('./customerAccountsService'); + if (await customerAccountsService.isCustomerPortalEnabled()) { + await customerAccountsService.setAssignmentsForEvent( + eventId, + input.customer_account_ids, + actor.id + ); + } + } catch (e) { + logger.error('Failed to set customer assignments on event create', { + eventId, error: e.message, + }); + } + } + + // Log activity + await logActivity('event_created', + { event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score }, + eventId, + { type: 'admin', id: actor.id, name: actor.username } + ); + + // Fire event.created webhook (#327). If the event is being published + // immediately (not a draft), event.published also fires below. + // Payload uses canonical event subject (#341) so receivers always see + // the same shape (id/slug/event_name + customer contact + share_*). + try { + const webhookService = require('./webhookService'); + await webhookService.fire('event.created', { + event: { + ...webhookService.buildEventSubject({ + id: eventId, + slug, + event_name, + event_type, + event_date, + share_url: shareUrl, + share_token: shareToken, + customer_name: customerName, + customer_email: customerEmail, + customer_phone: customerPhone, + }), + is_draft: parseBooleanInput(is_draft, true), + }, + }); + } catch (e) { /* webhookService.fire never throws but be defensive */ } + + // Queue creation email (only if there is a recipient and event is not a draft) + // Language detection is handled by email processor + const isDraft = parseBooleanInput(is_draft, true); + + if (customerEmail && !isDraft) { + // Build email data with optional client access info + const emailData = { + customer_name: customerName, + customer_email: customerEmail, + host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null), + event_name, + event_date: event_date, // Pass raw date - will be formatted by email processor + gallery_link: shareUrl, + gallery_password: requirePassword ? password : 'No password required', + expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor + welcome_message: welcome_message || '' + }; + + // Include client access info in email when enabled (#172) + if (client_access_enabled && client_password) { + const createdEvent = await db('events').where('id', eventId).first(); + // Same FRONTEND_URL-before-APP_URL order as before: APP_URL is + // passed as the override so it still outranks the general_site_url + // setting and the request origin. Chaining it after the resolver + // would make it dead code, because the resolver only returns falsy + // when NOTHING is configured (#1104). + const resolvedFrontendUrl = frontendUrl || await getFrontendBaseUrl(); + emailData.client_link = `${resolvedFrontendUrl}/gallery/${slug}/client-access?token=${createdEvent.client_share_token}`; + emailData.client_password = client_password; + } + + // Best-effort, as the v1 route always was: the event, folder, activity + // log and webhook are committed by now, so a queue failure must not 500. + try { + await db('email_queue').insert({ + event_id: eventId, + recipient_email: customerEmail, + email_type: 'gallery_created', + email_data: JSON.stringify(emailData), + status: 'pending', + created_at: new Date() + // scheduled_at will use default value + }); + } catch (queueError) { + logger.warn('Failed to queue gallery_created email on create', { eventId, error: queueError.message }); + } + } + + // WhatsApp gallery_ready notification (#640D). Fires when the event is + // created NOT as a draft, the `whatsapp` flag is on, a config exists, and + // the customer supplied a phone number. Non-fatal: a queue failure should + // never block gallery creation. + if (!isDraft && customerPhone) { + try { + const { queueWhatsapp, getWhatsAppConfig } = require('./whatsappProcessor'); + const waConfig = await getWhatsAppConfig(); + if (waConfig && waConfig.enabled) { + await queueWhatsapp(eventId, customerPhone, 'gallery_created', { + customer_name: customerName || '', + event_name, + gallery_link: shareUrl, + gallery_password: requirePassword ? password : '', + expiry_date: expires_at ? expires_at.toISOString() : null, + language: null, // resolved by processor via general_default_language + }); + } + } catch (waError) { + logger.warn('Failed to queue WhatsApp notification on create', { error: waError.message }); + } + } + + // Fire event.published when the event is created NOT as a draft. The + // separate /publish endpoint fires it for the draft → live transition; + // this covers the "create-and-publish in one shot" path. + if (!isDraft) { + try { + const webhookService = require('./webhookService'); + await webhookService.fire('event.published', { + event: webhookService.buildEventSubject({ + id: eventId, + slug, + event_name, + event_type, + event_date, + share_url: shareUrl, + share_token: shareToken, + customer_name: customerName, + customer_email: customerEmail, + customer_phone: customerPhone, + }), + }); + } catch (e) { /* non-fatal */ } + } + + if (!isDraft) { + await require('./workflows').emitWorkflowEvent('gallery.published', { + entityType: 'event', entityId: eventId, + payload: { eventId, slug, eventName: event_name, eventDate: event_date, + customerEmail, adminEmail: admin_email, galleryLink: shareUrl, + expiresAt: expires_at ? expires_at.toISOString() : null }, + }).catch(error => logger.warn('Failed to emit gallery.published', { eventId, error: error.message })); + } + + return { + id: eventId, + slug, + event_name, + event_type, + customer_name: customerName, + customer_email: customerEmail, + require_password: requirePassword, + photo_cap: photo_cap || null, + is_draft: isDraft, + share_link: shareUrl, + share_token: shareToken, + expires_at: expires_at ? expires_at.toISOString() : null, + created_at: new Date().toISOString() + }; +} + +module.exports = { createEvent }; diff --git a/backend/src/services/eventCreationValidation.js b/backend/src/services/eventCreationValidation.js new file mode 100644 index 00000000..deabc8de --- /dev/null +++ b/backend/src/services/eventCreationValidation.js @@ -0,0 +1,47 @@ +const Joi = require('joi'); +const eventTypes = require('./eventTypeService'); +const { AppError } = require('../utils/errors'); +const { normaliseEventTimeTriple } = require('./eventService'); +const optionalText = Joi.string().allow('', null); +const schema = Joi.object({ + event_name: Joi.string().trim().min(1).max(255).required(), + event_type: Joi.string().trim().min(1).max(255).required(), + event_date: Joi.string().isoDate().raw().allow('', null), + expires_at: Joi.string().isoDate().raw().allow('', null), + expiration_days: Joi.number().integer().min(1).max(365), + customer_email: Joi.string().email({ tlds: { allow: false } }).allow('', null), + admin_email: Joi.string().email({ tlds: { allow: false } }).allow('', null), + customer_name: optionalText, + customer_phone: optionalText.max(32), + password: Joi.string().max(1024).allow('', null), + client_password: Joi.string().max(1024).allow('', null), + color_theme: optionalText, + welcome_message: optionalText, + photo_cap: Joi.number().integer().min(1).allow(null), + image_quality: Joi.number().integer().min(1).max(100), + protection_level: Joi.string().valid('basic', 'standard', 'enhanced', 'maximum'), + hero_logo_size: Joi.string().valid('small', 'medium', 'large', 'xlarge').allow(null), + hero_logo_position: Joi.string().valid('top', 'center', 'bottom'), + customer_account_ids: Joi.array().items(Joi.number().integer().min(1)), + ...Object.fromEntries(['is_draft', 'require_password', 'allow_downloads', 'allow_user_uploads', + 'disable_right_click', 'watermark_downloads', 'enable_devtools_protection', 'use_canvas_rendering', + 'feedback_enabled', 'allow_ratings', 'allow_likes', 'allow_comments', 'allow_favorites', + 'allow_reactions', 'allow_color_labels', 'require_name_email', 'moderate_comments', + 'show_feedback_to_guests', 'client_access_enabled', 'og_image_share_enabled'] + .map(key => [key, Joi.boolean().truthy(1, '1').falsy(0, '0')])), + hero_logo_visible: Joi.boolean().truthy(1, '1').falsy(0, '0').allow(null), +}).unknown(true); + +async function validateCreationInput(data) { + const { value, error } = schema.validate(data, { abortEarly: false }); + if (error) { + const err = new AppError('Invalid event', 400, 'EVENT_INVALID'); + // Never return Joi's submitted value/context: it can contain passwords. + err.responseBody = { errors: error.details.map(item => ({ path: item.path.join('.'), msg: item.message })) }; + throw err; + } + if (!await eventTypes.isValidEventType(value.event_type)) throw new AppError('Invalid event type', 400, 'EVENT_TYPE_INVALID'); + normaliseEventTimeTriple(value); // Reject before password hashing or filesystem writes. + return value; +} +module.exports = { validateCreationInput }; diff --git a/backend/src/services/eventService.js b/backend/src/services/eventService.js index bb732247..079e783b 100644 --- a/backend/src/services/eventService.js +++ b/backend/src/services/eventService.js @@ -10,11 +10,11 @@ const crypto = require('crypto'); const path = require('path'); const fs = require('fs').promises; const { db } = require('../database/db'); -const logger = require('../utils/logger'); + const { formatBoolean } = require('../utils/dbCompat'); const { hasColumnCached } = require('../utils/schemaCache'); -const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); -const { buildShareLinkVariants } = require('./shareLinkService'); +const { getBcryptRounds } = require('../utils/passwordValidation'); + const { parseBooleanInput, parseStringInput } = require('../utils/parsers'); const eventTypeService = require('./eventTypeService'); const { AppError } = require('../utils/errors'); @@ -154,167 +154,11 @@ const createEventFolders = async (slug) => { * @param {Object} eventData - Event data * @returns {Promise} - Created event */ -const createEvent = async (eventData) => { - const { - event_type, - event_name, - event_date, - customer_name, - customer_email, - admin_email, - password, - require_password = true, - welcome_message, - color_theme, - expiration_days = 30, - // Feedback settings - feedback_enabled, - allow_ratings, - allow_likes, - allow_comments, - allow_favorites, - require_name_email, - moderate_comments, - show_feedback_to_guests, - // Upload settings - allow_user_uploads, - upload_category_id, - // Photo cap - photo_cap, - // Migration 137 — calendar time fields. Defaults to full-day when - // the caller (legacy create-event form) doesn't know about them. - event_time_start, - event_time_end, - is_full_day - } = eventData; - - const requirePassword = parseBooleanInput(require_password, true); - const customerColumnsAvailable = await hasCustomerContactColumns(); - // Validate + normalise the calendar time triple up front so we throw - // before bcrypt + folder creation if the payload is bad. - const timeTriple = normaliseEventTimeTriple({ - event_time_start, event_time_end, is_full_day, +const createEvent = async (eventData, options = {}) => { + return require('./eventCreationService').createEvent(eventData, { + ...options, + actor: options.actor || (eventData.created_by ? { id: eventData.created_by } : undefined), }); - - // Validate password if required - if (requirePassword) { - const passwordValidation = await validatePasswordInContext(password, 'gallery', { - eventName: event_name - }); - - if (!passwordValidation.valid) { - const error = new Error('Password does not meet security requirements'); - error.code = 'PASSWORD_INVALID'; - error.details = passwordValidation.errors; - error.score = passwordValidation.score; - error.feedback = passwordValidation.feedback; - throw error; - } - } - - // Generate unique slug - const slug = await generateUniqueSlug(event_type, event_name, event_date); - - // Generate share link - const shareToken = crypto.randomBytes(16).toString('hex'); - const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken }); - - // Hash password - const password_hash = requirePassword - ? await bcrypt.hash(password, getBcryptRounds()) - : await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds()); - - // Calculate expiration date - const expires_at = new Date(event_date); - expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10)); - - // Create folder structure - await createEventFolders(slug); - - // Build insert data - const insertData = { - slug, - event_type, - event_name, - event_date, - ...(customerColumnsAvailable ? { customer_name, customer_email } : {}), - host_name: customer_name, - host_email: customer_email, - admin_email, - password_hash, - welcome_message, - color_theme, - share_link: shareLinkToStore, - share_token: shareToken, - expires_at, - require_password: formatBoolean(requirePassword), - // Feedback settings - feedback_enabled: feedback_enabled !== undefined ? formatBoolean(feedback_enabled) : undefined, - allow_ratings: allow_ratings !== undefined ? formatBoolean(allow_ratings) : undefined, - allow_likes: allow_likes !== undefined ? formatBoolean(allow_likes) : undefined, - allow_comments: allow_comments !== undefined ? formatBoolean(allow_comments) : undefined, - allow_favorites: allow_favorites !== undefined ? formatBoolean(allow_favorites) : undefined, - require_name_email: require_name_email !== undefined ? formatBoolean(require_name_email) : undefined, - moderate_comments: moderate_comments !== undefined ? formatBoolean(moderate_comments) : undefined, - show_feedback_to_guests: show_feedback_to_guests !== undefined ? formatBoolean(show_feedback_to_guests) : undefined, - // Upload settings - allow_user_uploads: allow_user_uploads !== undefined ? formatBoolean(allow_user_uploads) : undefined, - upload_category_id: upload_category_id || null, - // Photo cap - photo_cap: photo_cap || null - }; - - // Migration 137 — calendar time fields. Guarded by hasColumnCached so - // installs that haven't applied 137 yet skip the columns silently - // (per feedback_schema_drift_guards.md / feedback_cache_hasColumn_lookups.md). - if (await hasColumnCached('events', 'is_full_day')) { - insertData.event_time_start = timeTriple.event_time_start; - insertData.event_time_end = timeTriple.event_time_end; - insertData.is_full_day = formatBoolean(timeTriple.is_full_day); - } - - // Remove undefined values - Object.keys(insertData).forEach(key => { - if (insertData[key] === undefined) { - delete insertData[key]; - } - }); - - // Insert into database - const insertResult = await db('events').insert(insertData).returning('id'); - const eventId = insertResult[0]?.id || insertResult[0]; - - // Fire gallery.published — a gallery goes live the moment it's created (active - // + share link). Best-effort; emit is fail-closed when the workflows flag is - // off and never throws into the create path. - try { - await require('./workflows').emitWorkflowEvent('gallery.published', { - entityType: 'event', - entityId: eventId, - payload: { - eventId, - slug, - eventName: event_name, - eventDate: event_date, - customerEmail: customer_email || null, - adminEmail: admin_email || null, - galleryLink: shareUrl, - expiresAt: expires_at, - }, - }); - } catch (err) { - logger.warn('Failed to emit gallery.published workflow event', { eventId, error: err.message }); - } - - return { - id: eventId, - slug, - share_link: shareUrl, - expires_at, - require_password: requirePassword, - customer_name, - customer_email - }; }; /** diff --git a/backend/src/services/eventSettings.js b/backend/src/services/eventSettings.js new file mode 100644 index 00000000..5cd92424 --- /dev/null +++ b/backend/src/services/eventSettings.js @@ -0,0 +1,403 @@ +const { db } = require('../database/db'); +const logger = require('../utils/logger'); +const { parseStringInput } = require('../utils/parsers'); +// Shared validator for hero_image_anchor – accepts legacy keywords or "X% Y%" focal point +const validateHeroImageAnchor = (value) => { + if (['top', 'center', 'bottom'].includes(value)) return true; + if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) { + const [x, y] = value.split(/\s+/).map(v => parseInt(v)); + if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true; + } + throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)'); +}; + +// Get storage path from environment or default +const { getStoragePath } = require('../config/storage'); + +// Helper to get event field requirements from settings +const getEventFieldRequirements = async () => { + try { + const settings = await db('app_settings') + .whereIn('setting_key', [ + 'event_require_customer_name', + 'event_require_customer_email', + 'event_require_admin_email', + 'event_require_event_date', + 'event_require_expiration' + ]) + .select('setting_key', 'setting_value'); + + const requirements = { + require_customer_name: true, + require_customer_email: true, + require_admin_email: true, + require_event_date: true, + require_expiration: true + }; + + settings.forEach(s => { + let value = s.setting_value; + if (typeof value === 'string') { + try { + value = JSON.parse(value); + } catch (e) { + value = value === 'true'; + } + } + if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value; + if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value; + if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value; + if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value; + if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value; + }); + + return requirements; + } catch (error) { + logger.error('Failed to get event field requirements', { error: error.message }); + return { + require_customer_name: true, + require_customer_email: true, + require_admin_email: true, + require_event_date: true, + require_expiration: true + }; + } +}; + +// Helper to read app_settings booleans by key, used to inherit per-setting +// defaults onto new events. Returns `undefined` for missing/non-boolean rows +// so callers can fall back to a legacy default. +/** + * Decode an app_settings value into the JS value it represents. + * + * setting_value is JSON text on SQLite and may already be decoded by the + * driver on a PG json column, so one parse does not normalise both. On top + * of that, the Image Security tab used to PUT back values it had read + * undecoded, wrapping another layer of quoting around each one on every + * save — the GET handler decodes now, but installs carry however many + * layers they accumulated before that. + * + * Every reader of app_settings has to agree about this, or the admin UI + * shows one thing while event creation does another. + * + * Terminates: each parse of a string is strictly shorter than its input. + */ +const decodeSettingValue = (raw) => { + let value = raw; + while (typeof value === 'string') { + let parsed; + try { parsed = JSON.parse(value); } catch { break; } + if (parsed === value) break; + value = parsed; + } + return value; +}; + +const readBooleanSetting = async (key) => { + try { + const setting = await db('app_settings').where('setting_key', key).first(); + if (!setting) return undefined; + const value = decodeSettingValue(setting.setting_value); + return typeof value === 'boolean' ? value : undefined; + } catch (error) { + logger.error('Failed to read app setting', { key, error: error.message }); + return undefined; + } +}; + +// Helper to read the global "enable_devtools_protection" admin setting so +// new events inherit it instead of always falling back to the DB column default +// (#317 — admin disabled it globally but new events still got it ON). +const getDownloadProtectionDefaults = async () => { + return { enable_devtools_protection: await readBooleanSetting('enable_devtools_protection') }; +}; + +/** + * The rest of Settings → Image security, as creation defaults (#1296). + * + * Four settings in that panel were written, reloaded and rendered as + * controls, and read by nothing: + * + * default_protection_level → events.protection_level + * default_image_quality → events.image_quality + * enable_canvas_rendering → events.use_canvas_rendering + * + * Each maps onto a column migration 038 already created, and each is + * labelled "… by default", so applying them at creation is what the panel + * has always claimed to do. `enable_devtools_protection` above is the only + * one of the five that was ever wired. + * + * Creation-time only, deliberately. Applying them to EXISTING events would + * silently change live galleries on upgrade — an install with + * enable_canvas_rendering already on would switch every grid to canvas + * rendering, which is memory-expensive at scale and is the profile under + * investigation in #1287. New events only; existing rows untouched. + * + * Any value that is missing or malformed comes back undefined so the caller + * falls through to the column default, exactly as before this existed. + */ +const PROTECTION_LEVELS = ['basic', 'standard', 'enhanced', 'maximum']; + +// parseInt would rescue malformed settings instead of rejecting them: +// parseInt('72oops') is 72, parseInt(72.5) is 72, parseInt([72]) is 72. +// That matters because the settings PUT stores whatever JSON it is handed +// without validating the value (adminImageSecurity.js writes +// JSON.stringify(value) for any allow-listed key), so those shapes really +// can be sitting in app_settings. Accept only a genuine integer, or a +// string that is exactly one. +const toInteger = (value) => { + if (typeof value === 'number') return Number.isInteger(value) ? value : undefined; + if (typeof value === 'string' && /^[+-]?\d+$/.test(value.trim())) return Number(value.trim()); + return undefined; +}; + +const getImageSecurityDefaults = async (trx = null) => { + const defaults = {}; + try { + // Accepts a transaction the way getAppSetting does. It matters on + // sqlite3, whose pool holds a single connection: a caller already inside + // db.transaction() that read through the global `db` would block on the + // connection its own transaction holds until the acquire timeout, and + // the catch below would then quietly swallow it and drop the defaults. + const query = trx || db; + const rows = await query('app_settings') + .whereIn('setting_key', [ + 'default_protection_level', + 'default_image_quality', + 'enable_canvas_rendering', + ]) + .select('setting_key', 'setting_value'); + + // app_settings holds JSON text on SQLite, while a PG json column comes + // back already decoded — so one parse is not enough to normalise both. + // Worse, GET /api/admin/image-security/settings returns setting_value + // without decoding it and the settings tab PUTs the whole fetched object + // straight back through JSON.stringify, so opening the tab and saving + // re-encodes every value it read as text. After one such round trip + // `true` is stored as "\"true\"" and a single parse yields the string + // 'true', which the type checks below reject — the settings would go + // quietly dead again, which is the bug this whole change exists to fix. + // The GET handler now decodes, so this stops accumulating — but installs + // that already stacked N layers have to keep working, and N is however + // many times someone opened that tab. So unwrap until it stops being a + // JSON string rather than to a fixed depth; this terminates because each + // parse of a string is strictly shorter than its input. + const read = (key) => { + const row = rows.find((r) => r.setting_key === key); + if (!row) return undefined; + return decodeSettingValue(row.setting_value); + }; + + const level = read('default_protection_level'); + if (typeof level === 'string' && PROTECTION_LEVELS.includes(level)) { + defaults.protection_level = level; + } + + // The column is an integer percentage; anything outside 1..100 is a + // misconfiguration and falls through rather than being clamped into + // something the operator did not choose. + const quality = toInteger(read('default_image_quality')); + if (quality !== undefined && quality >= 1 && quality <= 100) { + defaults.image_quality = quality; + } + + const canvas = read('enable_canvas_rendering'); + if (typeof canvas === 'boolean') { + defaults.use_canvas_rendering = canvas; + } + + } catch (error) { + // A settings read must never block event creation; the column defaults + // are a correct fallback. + logger.error('Failed to read image-security defaults', { error: error.message }); + } + return defaults; +}; + +/** + * Build the image-security columns for a NEW event: an explicit request + * value wins, then the global default, then the column default (the key is + * omitted entirely so the database supplies it). + * + * Shared by the admin create route and POST /api/v1/events so the configured + * security level cannot depend on which entry point created the gallery — + * the same split that made #592 (devtools) a separate bug from #317. + * + * `body` values are already validated by the route's express-validator + * chain; `defaults` come from getImageSecurityDefaults(), which validates + * them itself. + */ +const resolveImageSecurityColumns = (body = {}, defaults = {}) => { + const { formatBoolean } = require('../utils/dbCompat'); + const columns = {}; + // express-validator runs isInt/isIn/isBoolean element-wise on arrays, so a + // single-element array like `image_quality: [72]` passes the route's chain + // and arrives here still an array. The routes reject those with + // .not().isArray(); this guard means any future caller cannot write one + // into a scalar column (a PG insert error, or `[false]` coerced to true). + const scalar = (v) => (v !== null && typeof v === 'object' ? undefined : v); + const pick = (key) => { + const fromBody = scalar(body[key]); + return fromBody !== undefined ? fromBody : defaults[key]; + }; + + const level = pick('protection_level'); + if (level !== undefined) columns.protection_level = level; + + const quality = pick('image_quality'); + if (quality !== undefined) columns.image_quality = quality; + + const canvas = pick('use_canvas_rendering'); + if (canvas !== undefined) columns.use_canvas_rendering = formatBoolean(canvas); + + + return columns; +}; + +// Helper to get branding defaults for new events (Feature 7: Branding Inheritance). +// +// Note: `branding_logo_position` (header bar — left/center/right) is a +// different concept from `hero_logo_position` (hero block — top/center/ +// bottom) and must NOT be mapped here. A previous version copied the +// branding value over, which wrote 'left'/'right' into per-event +// hero_logo_position columns and broke any subsequent PUT validation +// (#357). Migration 084 heals existing rows. +const getBrandingDefaults = async () => { + try { + const settings = await db('app_settings') + .whereIn('setting_key', [ + 'branding_logo_display_hero', + 'branding_logo_size' + ]) + .select('setting_key', 'setting_value'); + + const defaults = { + hero_logo_visible: true, + hero_logo_size: 'medium', + hero_logo_position: 'top' + }; + + settings.forEach(s => { + let value = s.setting_value; + if (typeof value === 'string') { + try { value = JSON.parse(value); } catch (e) { /* use as-is */ } + } + if (s.setting_key === 'branding_logo_display_hero') { + defaults.hero_logo_visible = value !== false; + } + if (s.setting_key === 'branding_logo_size' && value) { + defaults.hero_logo_size = value; + } + }); + + return defaults; + } catch (error) { + logger.error('Failed to get branding defaults', { error: error.message }); + return { + hero_logo_visible: true, + hero_logo_size: 'medium', + hero_logo_position: 'top' + }; + } +}; + +// Use parseStringInput from shared parsers for customer data extraction +const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name); +const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email); +const getCustomerPhoneFromPayload = (payload = {}) => parseStringInput(payload.customer_phone); + +// Whether the global "phone field" toggle (#322) is enabled. Cached for +// the request via a module-level read; drift is acceptable since this +// only governs whether to persist the field, not security boundaries. +const isPhoneFieldEnabled = async () => { + try { + const row = await db('app_settings').where('setting_key', 'event_phone_field_enabled').first(); + if (!row) return false; + let value = row.setting_value; + if (typeof value === 'string') { + try { value = JSON.parse(value); } catch { /* keep raw */ } + } + return value === true; + } catch (error) { + logger.debug('Failed to read event_phone_field_enabled', { error: error.message }); + return false; + } +}; + +const RECOVERABLE_PASSWORD_COLUMNS = ['password_recoverable', 'client_password_recoverable']; + +const mapEventForApi = (event) => { + if (!event || typeof event !== 'object') { + return event; + } + + const { + host_name, + host_email, + customer_name, + customer_email, + customer_phone, + // Bound only to exclude the secrets from `...rest` — never read. + password_hash: _ph, client_password_hash: _cph, + ...rest + } = event; + // #1271 — the encrypted copies never leave the server except via + // /:id/password. Removed by name (not destructured) so a secret scanner + // does not read the binding as a hard-coded password. + for (const column of RECOVERABLE_PASSWORD_COLUMNS) delete rest[column]; + + return { + ...rest, + customer_name: customer_name ?? host_name ?? null, + customer_email: customer_email ?? host_email ?? null, + customer_phone: customer_phone ?? null + }; +}; + +let customerColumnCache = null; +const hasCustomerContactColumns = async () => { + if (customerColumnCache === true) { + return true; + } + + try { + const hasColumn = await db.schema.hasColumn('events', 'customer_email'); + if (hasColumn) { + customerColumnCache = true; + } + return hasColumn; + } catch (error) { + logger.debug('Failed to detect customer_email column', { error: error.message }); + return false; + } +}; + +// Allowed slide transition styles (kept in sync with the SlideshowPage). +// dipwhite/dipblack = fade through highlights / lowlights between images. +const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack']; +// Allowed per-slide color filters. +const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette']; +// Allowed slideshow play orders (#202). 'chronological' = upload order, +// 'random' = client-side shuffle. +const SLIDESHOW_ORDERS = ['chronological', 'random']; +module.exports = { + RECOVERABLE_PASSWORD_COLUMNS, + validateHeroImageAnchor, + getStoragePath, + getEventFieldRequirements, + readBooleanSetting, + decodeSettingValue, + getDownloadProtectionDefaults, + getImageSecurityDefaults, + resolveImageSecurityColumns, + getBrandingDefaults, + getCustomerNameFromPayload, + getCustomerEmailFromPayload, + getCustomerPhoneFromPayload, + isPhoneFieldEnabled, + mapEventForApi, + hasCustomerContactColumns, + SLIDESHOW_ORDERS, + SLIDESHOW_TRANSITIONS, + SLIDESHOW_COLORFILTERS, +}; diff --git a/backend/src/services/expirationChecker.js b/backend/src/services/expirationChecker.js index d9876383..3d2164ff 100644 --- a/backend/src/services/expirationChecker.js +++ b/backend/src/services/expirationChecker.js @@ -1,4 +1,4 @@ -const cron = require('node-cron'); +const { scheduledTask } = require('./scheduledTask'); const { db } = require('../database/db'); const { archiveEvent } = require('./archiveService'); const { queueEmail, getSupportEmail } = require('./emailProcessor'); @@ -6,14 +6,9 @@ const { buildShareLinkVariants } = require('./shareLinkService'); const logger = require('../utils/logger'); const { formatBoolean } = require('../utils/dbCompat'); -function startExpirationChecker() { - // Check every hour for expired events and warnings - cron.schedule('0 * * * *', async () => { - await checkExpirations(); - }); - - logger.info('Expiration checker started'); -} +const task = scheduledTask(checkExpirations, { schedule: '0 * * * *' }); +function startExpirationChecker() { task.start(); } +const stopExpirationChecker = () => task.stop(); async function checkExpirations() { try { @@ -248,6 +243,7 @@ async function handleExpiredEvent(event, { sendLegacyEmails = true } = {}) { } module.exports = { + stopExpirationChecker, startExpirationChecker, // Reused by the workflow notify_gallery_* actions so the engine path sends the // exact same emails as the legacy hourly checker. diff --git a/backend/src/services/fileWatcher.js b/backend/src/services/fileWatcher.js index a8c0633d..f92e19e4 100644 --- a/backend/src/services/fileWatcher.js +++ b/backend/src/services/fileWatcher.js @@ -28,7 +28,21 @@ const watcherConcurrency = Number.isFinite(configuredConcurrency) : 2; const processLimit = pLimit(watcherConcurrency); +let watcher = null; +const pending = new Set(); +const enqueue = (run) => { + const task = processLimit(run); pending.add(task); + task.finally(() => pending.delete(task)).catch(() => {}); + return task; +}; +async function stopFileWatcher() { + const closing = watcher; watcher = null; + if (closing) await closing.close(); + await Promise.allSettled([...pending]); +} + function startFileWatcher() { + if (watcher) return watcher; // Auto-import via filesystem watching only works with the local storage // backend. In S3 mode there is no local directory to watch — every photo // must enter through the admin upload API. Skip cleanly with a clear log @@ -39,7 +53,7 @@ function startFileWatcher() { return null; } - const watcher = chokidar.watch(WATCH_PATH(), { + watcher = chokidar.watch(WATCH_PATH(), { ignored: /(^|[/\\])\../, // ignore dotfiles persistent: true, awaitWriteFinish: { @@ -50,17 +64,18 @@ function startFileWatcher() { watcher .on('add', (filePath) => { - processLimit(() => processNewPhoto(filePath)).catch((error) => { + enqueue(() => processNewPhoto(filePath)).catch((error) => { logger.error('Error processing new photo:', error); }); }) .on('unlink', (filePath) => { - processLimit(() => removePhoto(filePath)).catch((error) => { + enqueue(() => removePhoto(filePath)).catch((error) => { logger.error('Error removing photo:', error); }); }); logger.info('File watcher started'); + return watcher; } /** @@ -222,4 +237,4 @@ async function removePhoto(filePath) { logger.info(`Removed photo: ${relativePath}`); } -module.exports = { startFileWatcher, findExistingPhoto }; +module.exports = { stopFileWatcher, startFileWatcher, findExistingPhoto }; diff --git a/backend/src/services/galleryAccessService.js b/backend/src/services/galleryAccessService.js new file mode 100644 index 00000000..5400a01e --- /dev/null +++ b/backend/src/services/galleryAccessService.js @@ -0,0 +1,57 @@ +const { db } = require('../database/db'); +const { userHasAllPermissions } = require('../middleware/permissions'); +const { canAccessEvent } = require('../middleware/ownership'); +const { assertGalleryAvailable, requiresGalleryPassword } = require('../utils/galleryLifecycle'); +const { isTokenBeforeCutoff } = require('../utils/sessionCutoff'); +const { AppError } = require('../utils/errors'); +const sessions = require('./sessionAccessService'); + +// These claims identify a session for revocation; no raw JWT, IP or password +// enters a media URL. Only use grants from this service or a verified signature. +const CLAIMS = ['type', 'id', 'customerId', 'eventId', 'eventSlug', 'iat', 'exp', 'jti', 'via', 'accessLevel']; + +class GalleryAccessService { + grant(event, kind, decoded) { + const session = decoded && Object.fromEntries(CLAIMS + .filter((key) => decoded[key] !== undefined).map((key) => [key, decoded[key]])); + return { kind, eventId: event.id, issuedAt: Math.floor(Date.now() / 1000), ...(session && { session }) }; + } + + async authorize(event, grant) { + if (!grant || !['public', 'gallery', 'admin'].includes(grant.kind) + || !event || Number(grant.eventId) !== Number(event.id)) { + throw new AppError('Invalid gallery grant', 403, 'INVALID_GALLERY_GRANT'); + } + assertGalleryAvailable(event, { adminPreview: grant.kind === 'admin' }); + if (!Number.isFinite(grant.issuedAt) || await isTokenBeforeCutoff({ iat: grant.issuedAt })) { + throw new AppError('Session invalidated', 401, 'SESSION_INVALIDATED'); + } + const session = grant.session; + if (grant.kind === 'admin') { + const account = await sessions.admin(session); + const principal = { id: account.id, roleName: account.role_name }; + if (!canAccessEvent(principal, event) + || !await userHasAllPermissions(account.id, ['events.view', 'photos.view'])) { + throw new AppError('Access denied', 403, 'FORBIDDEN'); + } + } else if (grant.kind === 'gallery') { + await sessions.assertActive(session, 'gallery'); + if (Number(session.eventId) !== Number(event.id)) { + throw new AppError('Token does not match requested gallery', 403, 'INVALID_GALLERY_GRANT'); + } + if (session.via === 'customer') { + await sessions.customer(session, { derived: true }); + const assignment = await db('event_customer_assignments') + .where({ event_id: event.id, customer_account_id: session.customerId }).first(); + if (!assignment) { + throw new AppError('Access to this gallery has been revoked', 403, 'CUSTOMER_ASSIGNMENT_REVOKED'); + } + } + } else if (requiresGalleryPassword(event)) { + throw new AppError('No token provided', 401, 'NO_TOKEN'); + } + return grant; + } +} + +module.exports = new GalleryAccessService(); diff --git a/backend/src/services/galleryModel.js b/backend/src/services/galleryModel.js new file mode 100644 index 00000000..2bda9cb0 --- /dev/null +++ b/backend/src/services/galleryModel.js @@ -0,0 +1,30 @@ +// #756: a NULL per-event hero_logo_visible means "inherit the global +// branding_logo_display_hero toggle". Only an explicit true/false is a +// per-gallery override. `globalDefault` is branding_logo_display_hero +// (defaults true when unset). +function resolveHeroLogoVisible(perEvent, globalDefault) { + if (perEvent === null || perEvent === undefined) { + return globalDefault !== false; + } + return perEvent !== false && perEvent !== 0 && perEvent !== '0'; +} + +// Formats whose ORIGINAL bytes a browser can't render in an (HEIC/HEIF, +// camera RAW/DNG). For these the lightbox must be served the generated JPEG +// preview instead of `url` (the original) — otherwise it shows a broken image. +// So we force `preview_url` for them regardless of the lightbox_preview_enabled +// toggle. Detection is by MIME first, extension as a fallback (browsers report +// these MIMEs inconsistently). EXPERIMENTAL: whether a preview actually renders +// still depends on the backend being able to decode the source (HEVC-in-HEIC on +// the prod image; exiftool for DNG) — see #821. +const NON_DISPLAYABLE_ORIGINAL_EXT = new Set(['heic', 'heif', 'dng']); +const NON_DISPLAYABLE_ORIGINAL_MIME = new Set(['image/heic', 'image/heif', 'image/x-adobe-dng']); +function originalNeedsPreview(photo) { + const mime = (photo.mime_type || '').toLowerCase(); + if (NON_DISPLAYABLE_ORIGINAL_MIME.has(mime)) return true; + const name = photo.original_filename || photo.filename || ''; + const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : ''; + return NON_DISPLAYABLE_ORIGINAL_EXT.has(ext); +} + +module.exports = { resolveHeroLogoVisible, originalNeedsPreview }; diff --git a/backend/src/services/galleryPhotoQuery.js b/backend/src/services/galleryPhotoQuery.js new file mode 100644 index 00000000..1d6b7b8f --- /dev/null +++ b/backend/src/services/galleryPhotoQuery.js @@ -0,0 +1,37 @@ +const { db } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); +const { COLOR_LABELS, SHARED_COLOR_LABEL_IDENTITY } = require('../constants/colorLabels'); +/** Apply the same own/shared feedback visibility before pagination and counts. */ +function applyFeedbackFilter(query, { filter, event, identity, sharedColorMode, showFeedbackToGuests }) { + if (!filter) return query; + const tokens = new Set(String(filter).toLowerCase().split(',').map(x => x.trim()).filter(Boolean)); + if (tokens.size === 0 || tokens.has('all')) return query; + if (tokens.has('saved') || tokens.has('favorite')) tokens.add('favorited'); + const feedback = type => db('photo_feedback').where({ event_id: event.id, feedback_type: type, + is_hidden: formatBoolean(false) }).select('photo_id'); + const own = q => identity.guestId ? q.where('guest_id', identity.guestId) : q.where('guest_identifier', identity.guestIdentifier); + return query.where(function () { + this.whereRaw('1 = 0'); + for (const [token, type, column] of [['liked', 'like', 'like_count'], ['favorited', 'favorite', 'favorite_count'], ['rated', 'rating', 'average_rating'], ['commented', 'comment', null]]) { + if (!tokens.has(token)) continue; + this.orWhereIn('photos.id', own(feedback(type))); + if (showFeedbackToGuests) { + if (column) this.orWhere(`photos.${column}`, '>', 0); + else this.orWhereIn('photos.id', feedback(type).where('is_approved', formatBoolean(true))); + } + } + const colors = COLOR_LABELS.filter(color => tokens.has(`color:${color}`)); + if (colors.length) { + const colorQuery = feedback('color_label').whereIn('color_label', colors); + if (sharedColorMode) { + this.orWhereIn('photos.id', colorQuery.where('guest_identifier', SHARED_COLOR_LABEL_IDENTITY)); + } else { + this.orWhereIn('photos.id', own(colorQuery.clone())); + if (showFeedbackToGuests) this.orWhereIn('photos.id', colorQuery.where(function () { + this.whereNot('guest_identifier', SHARED_COLOR_LABEL_IDENTITY).orWhereNull('guest_identifier'); + })); + } + } + }); +} +module.exports = { applyFeedbackFilter }; diff --git a/backend/src/services/galleryQueryService.js b/backend/src/services/galleryQueryService.js new file mode 100644 index 00000000..91eb7a00 --- /dev/null +++ b/backend/src/services/galleryQueryService.js @@ -0,0 +1,567 @@ +const { toIso } = require('../utils/dateNormalize'); +const { db } = require('../database/db'); +const { parseBooleanInput } = require('../utils/parsers'); +const { getAppSetting } = require('../utils/appSettings'); +const { formatBoolean } = require('../utils/dbCompat'); +const { SHARED_COLOR_LABEL_IDENTITY } = require('../constants/colorLabels'); +const watermarkService = require('./watermarkService'); +const logger = require('../utils/logger'); +const { getEventCategoriesOrdered } = require('../utils/categoryOrder'); +const { getUseOriginalFilenames } = require('./downloadFilenameService'); +const { resolveEventDownloadPolicy } = require('../utils/downloadResolutions'); +const { resolveHeroLogoVisible, originalNeedsPreview } = require('./galleryModel'); +const { applyFeedbackFilter } = require('./galleryPhotoQuery'); +async function getGalleryPhotos({ event, query = {}, identity, accessLevel, adminPreview, hiddenForGuest, slug }) { + // Get filter and sort parameters from query + // `guest_id` is deliberately NOT read from the query string: the viewer's + // own feedback is resolved from the request identity instead (see the + // filter block). The frontend still sends it; it is ignored. + const { filter, sort = 'upload_date', order = 'desc' } = query; + + // Get watermark settings to generate cache-busting version for URLs + const watermarkSettings = await watermarkService.getWatermarkSettings(); + const wmVersion = watermarkSettings?.enabled + ? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}` + : ''; + + // Build the query with sorting + const sortOrder = order === 'asc' ? 'asc' : 'desc'; + const isClient = accessLevel === 'client'; + let photosQuery = db('photos') + .where('photos.event_id', event.id) + // Guests/clients never see photos still being processed by the + // background worker — the original is on disk but the thumbnail + // / dimensions / EXIF haven't landed yet. Photos with a NULL + // processing_status are pre-async-migration rows and are treated + // as complete (the migration's column default is 'complete' so + // this is just defensive against partial migration states). + .where(function() { + this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status'); + }) + .select('photos.*'); + + // Guests only see visible photos; clients see all + if (!isClient) { + photosQuery = photosQuery.where(function() { + this.where('photos.visibility', 'visible').orWhereNull('photos.visibility'); + }); + } + + // Live Slideshow category filter (#202). Enforced server-side so the kiosk + // viewer can't widen the set: when the event pins show_category_id, the + // slideshow only sees that category. NULL = all photos (unchanged). + if (accessLevel === 'slideshow' && event.show_category_id) { + photosQuery = photosQuery.where('photos.category_id', event.show_category_id); + } + + // Apply sort option. + // + // Every branch carries photos.id as a tiebreaker (#1172). Without one the + // order within a tie is whatever the engine happens to return, and ties are + // the normal case rather than the exception: a bulk import writes hundreds + // of rows inside the same second, so uploaded_at collapses — and with + // captured_at NULL the COALESCE below collapses onto it too. The visible + // symptom is a grid that reshuffles between page loads. id is insertion + // order, so it also makes the fallback ordering meaningful rather than + // arbitrary. + if (sort === 'capture_date') { + // Sort by capture date, falling back to uploaded_at if capture date is null. + // + // On SQLite that fallback cannot be a plain COALESCE, because the two + // columns do not hold one type. photos.captured_at ends up carrying three + // different storage classes: + // + // integer managed uploads — photoProcessor.js:488 writes a Date, which + // the sqlite3 binding stores as epoch milliseconds + // text external imports and the backfill, which write ISO-8601 + // ('2026-06-03T01:15:00.000Z') per the CLAUDE.md rule that + // Dates must not be handed to the binding in tests + // null no capture date, so the sort falls through to uploaded_at — + // usually text in knex's 'YYYY-MM-DD HH:MM:SS' default shape, + // but epoch milliseconds on rows written by a legacy archive + // restore (see __tests__/integration/sqliteEpochTimestamps.js), + // so that column needs the same two branches + // + // SQLite orders INTEGER before TEXT unconditionally, so every managed + // photo carrying EXIF sorted ahead of every photo that did not, whatever + // the actual dates — a 2027 capture landing before a 2020 one. Among the + // text values the 'T' separator (0x54) also outranks the space (0x20), so + // a same-day ISO 01:15 sorted after a fallback 23:00. + // + // Normalising in the ORDER BY rather than rewriting the column: the data + // fix would have to touch every existing row and every writer, which is a + // much heavier change than the sort it is meant to correct. The cost here + // is that this sort stops using idx_photos_captured_at on SQLite — an + // acceptable trade on the fallback engine, where the alternative is an + // index-assisted wrong answer. + // + // Postgres is untouched: captured_at is a real timestamp there, so + // COALESCE already compares correctly. + if (db.client.config.client === 'pg') { + photosQuery = photosQuery + .orderByRaw('COALESCE(photos.captured_at, photos.uploaded_at) ' + sortOrder); + } else { + photosQuery = photosQuery.orderByRaw(`CASE + WHEN typeof(photos.captured_at) IN ('integer', 'real') THEN datetime(photos.captured_at / 1000, 'unixepoch') + WHEN photos.captured_at IS NOT NULL THEN replace(replace(substr(photos.captured_at, 1, 19), 'T', ' '), 'Z', '') + WHEN typeof(photos.uploaded_at) IN ('integer', 'real') THEN datetime(photos.uploaded_at / 1000, 'unixepoch') + ELSE substr(photos.uploaded_at, 1, 19) + END ${sortOrder}`); + } + photosQuery = photosQuery.orderBy('photos.id', sortOrder); + } else if (sort === 'filename') { + photosQuery = photosQuery.orderBy('photos.filename', sortOrder).orderBy('photos.id', sortOrder); + } else { + // Default: sort by upload date + photosQuery = photosQuery.orderBy('photos.uploaded_at', sortOrder).orderBy('photos.id', sortOrder); + } + + // Reveal mode (#838): while the gallery is hidden, plain guests get + // the event shell with an empty photo/category set plus the + // hidden_until_reveal flag — the frontend renders the upload-only view + // from it. Slideshow, client access and the admin preview bypass + // (guestBlockedByReveal). Enforced here, not just in the UI. + + + // Check if feedback should be visible to guests. Read BEFORE the filter + // block, not after: the filters below consult it, because a filter that + // selects on other people's feedback is a way of reading that feedback. + const feedbackService = require('./feedbackService'); + const feedbackSettings = await feedbackService.getEventFeedbackSettings(event.id); + const showFeedbackToGuests = isClient || parseBooleanInput(feedbackSettings.show_feedback_to_guests, true); + // One identity-less colour tag per photo, any guest may overwrite it + // (#1197). Read in three places below: the colour filters, the per-viewer + // badge, and the "other viewers" dots that must not double-render it. + const sharedColorMode = feedbackSettings?.identity_mode === 'shared'; + + applyFeedbackFilter(photosQuery, { filter, event, identity, sharedColorMode, showFeedbackToGuests }); + const limit = query.limit === undefined ? null : Math.min(250, Math.max(1, parseInt(query.limit, 10) || 100)); + const page = Math.max(1, parseInt(query.page, 10) || 1); + const countRow = hiddenForGuest ? { total: 0 } : await photosQuery.clone().clearSelect().clearOrder().count('photos.id as total').first(); + const total = Number(countRow.total); + if (limit) photosQuery.limit(limit).offset((page - 1) * limit); + const photos = hiddenForGuest ? [] : await photosQuery; + + // Then get comment counts separately + const commentCounts = await db('photo_feedback') + .whereIn('photo_id', photos.map(p => p.id)) + .where('feedback_type', 'comment') + .where('is_approved', formatBoolean(true)) + .where('is_hidden', formatBoolean(false)) + .groupBy('photo_id') + .select('photo_id', db.raw('COUNT(*) as comment_count')); + + // Create a map for quick lookup + const commentMap = {}; + commentCounts.forEach(c => { + commentMap[c.photo_id] = parseInt(c.comment_count); + }); + + // Per-viewer "is_liked" set (#590 follow-up). Hard refresh on the + // gallery grid used to reset every heart to empty because the lifted + // likedPhotoIds state started as a fresh Set on mount — even photos + // the viewer had actually liked. Surface a per-viewer flag so the + // frontend can seed correctly. Prefers identity.guestId when a verified + // guest token is present (per-person identity), falls back to the + // IP+UA hash that the original like was recorded under — same model + // the /my-feedback endpoint uses. + // + // NOT gated on showFeedbackToGuests (#1286). This query is filtered to + // the VIEWER — by guest_id or by their own identifier — so what it + // returns is their own selection, not shared aggregate data. Gating it + // emptied every heart the guest had set themselves on a gallery with + // sharing off, which reads as the gallery silently discarding their + // choices. Same reasoning the colour-label block below already applies; + // this was the one per-viewer field that disagreed with it. + const likedPhotoIds = new Set(); + if (photos.length > 0) { + const likeQuery = db('photo_feedback') + // Hidden rows are not there, for the viewer's OWN feedback as much as + // anyone's (#1150). getPhotoFeedback drops them, the filter drops them + // and updatePhotoFeedbackStats does not count them — leaving the heart + // filled was the one place that disagreed, so a like the photographer + // had hidden still showed as liked on a photo whose like_count was 0. + .where({ event_id: event.id, feedback_type: 'like', is_hidden: formatBoolean(false) }) + .whereIn('photo_id', photos.map(p => p.id)); + if (identity.guestId) { + likeQuery.where('guest_id', identity.guestId); + } else { + likeQuery.where('guest_identifier', identity.guestIdentifier); + } + const likedRows = await likeQuery.select('photo_id'); + likedRows.forEach(row => likedPhotoIds.add(row.photo_id)); + } + + // Per-viewer colour label (#1044), same identity resolution as the likes + // above. NOT gated on showFeedbackToGuests: a guest's own label is their + // own selection, not shared aggregate data, and hiding it would blank the + // grid badges on every refresh in a gallery with sharing switched off. + // + // In shared identity mode (#1197) there is no per-viewer label to read: + // the photo carries one tag and it belongs to everyone, so it arrives on + // this same field. The badge, the lightbox swatch and the keyboard + // shortcuts then work unchanged — they were already reading "the colour on + // this photo, from my point of view", which is precisely what the shared + // tag is. + const myColorLabelByPhoto = {}; + if (photos.length > 0 && sharedColorMode) { + Object.assign( + myColorLabelByPhoto, + await feedbackService.getSharedColorLabels(event.id, photos.map(p => p.id)), + ); + } else if (photos.length > 0) { + const colorQuery = db('photo_feedback') + // Same rule as the heart above (#1150). + .where({ event_id: event.id, feedback_type: 'color_label', is_hidden: formatBoolean(false) }) + .whereIn('photo_id', photos.map(p => p.id)); + if (identity.guestId) { + colorQuery.where('guest_id', identity.guestId); + } else { + colorQuery.where('guest_identifier', identity.guestIdentifier); + } + const colorRows = await colorQuery.select('photo_id', 'color_label'); + colorRows.forEach(row => { + if (row.color_label) myColorLabelByPhoto[row.photo_id] = row.color_label; + }); + } + + // OTHER viewers' colour labels, per photo (#1178). + // + // The lightbox has always shown these — /photos/:id/feedback returns + // per-colour tallies across everyone — but the grid had no field carrying + // them, so a label set by one guest was visible in fullscreen and invisible + // on the tile. With sharing on, that is just a hole. + // + // DISTINCT colours, not counts: a tile has room for a couple of dots, and + // "who else marked this, and how" is a lightbox question. The viewer's own + // colour is excluded here so the badge and the dots never say the same + // thing twice — the frontend renders `my_color_label` as the badge and + // these beside it. + // + // Gated on showFeedbackToGuests, like every other aggregate: this is other + // people's feedback, unlike my_color_label above. + // + // Skipped entirely in shared mode (#1197). There are no other viewers' + // labels there — there is one tag, already delivered as my_color_label + // above. Without this the shared row would come back here too (its + // reserved identity is not the viewer's), and every tile would render the + // same colour twice: once as the badge, once as a dot beside it. + const otherColorLabelsByPhoto = {}; + if (photos.length > 0 && showFeedbackToGuests && !sharedColorMode) { + const othersQuery = db('photo_feedback') + .where({ event_id: event.id, feedback_type: 'color_label', is_hidden: formatBoolean(false) }) + .whereIn('photo_id', photos.map(p => p.id)) + .whereNotNull('color_label') + // The other direction of the same rule (#1197): an event switched back + // out of shared mode keeps its shared tag, and it is nobody's — so + // without this it would show up as an anonymous other viewer's dot on + // every tile that still carries one. + .where(function () { + this.whereNot('guest_identifier', SHARED_COLOR_LABEL_IDENTITY).orWhereNull('guest_identifier'); + }); + if (identity.guestId) { + othersQuery.where(function () { + this.whereNot('guest_id', identity.guestId).orWhereNull('guest_id'); + }); + } else { + const mine = identity.guestIdentifier; + othersQuery.where(function () { + this.whereNot('guest_identifier', mine).orWhereNull('guest_identifier'); + }); + } + const otherRows = await othersQuery.distinct('photo_id', 'color_label'); + otherRows.forEach(row => { + if (!otherColorLabelsByPhoto[row.photo_id]) otherColorLabelsByPhoto[row.photo_id] = []; + if (!otherColorLabelsByPhoto[row.photo_id].includes(row.color_label)) { + otherColorLabelsByPhoto[row.photo_id].push(row.color_label); + } + }); + } + + // People in each photo (#1074). Two independent gates: the feature must + // be on for this event AND, for a plain guest, the photographer must have + // left the strip visible. A client (PIN access) is the photographer's own + // view, so faces_visible_to_guests doesn't restrict them. + // + // `photos` is already visibility-filtered above, and this only ever asks + // about ids in that set, so it cannot widen what the caller sees. + let peopleEnabled = false; + let personIdsByPhoto = new Map(); + try { + const { isEnabledForEvent, areFacesVisibleToGuests } = require('./faceSettings'); + if (photos.length > 0 && await isEnabledForEvent(event)) { + peopleEnabled = isClient || areFacesVisibleToGuests(event); + if (peopleEnabled) { + const { getPersonIdsByPhoto } = require('./facePeopleService'); + personIdsByPhoto = await getPersonIdsByPhoto( + event.id, + photos.map(p => p.id), + { forAdmin: isClient } + ); + } + } + } catch (err) { + // A face-feature failure must never take down the gallery payload. + logger.warn(`gallery: person_ids lookup failed for event ${event.id}`, { error: err.message }); + peopleEnabled = false; + personIdsByPhoto = new Map(); + } + + // Get actual categories used by photos in this event + // This includes both global categories and event-specific ones + const usedCategoryIds = hiddenForGuest ? [] : await db('photos') + .where('event_id', event.id) + .whereNotNull('category_id') + .distinct('category_id') + .pluck('category_id'); + + // Fetch category details from photo_categories table + let categories = []; + if (usedCategoryIds.length > 0) { + // Resolved category order (#782): per-event override, else global + // default, else name — restricted to categories that have photos. + const categoryDetails = await getEventCategoriesOrdered(event.id, { + onlyIds: usedCategoryIds, + select: ['c.id', 'c.name', 'c.slug', 'c.is_global', 'c.hero_photo_id', 'c.allow_downloads', 'c.is_folder'], + }); + + categories = categoryDetails.map(cat => ({ + id: cat.id, + name: cat.name, + slug: cat.slug, + is_global: cat.is_global, + hero_photo_id: cat.hero_photo_id || null, + // Per-category download flag (#640). false explicitly disables; the + // gallery hides the download button. Defaults true so categories + // created before migration 135 keep working. + allow_downloads: parseBooleanInput(cat.allow_downloads, true), + // Folder vs filter (#1160). true = the category CONTAINS its photos: + // they leave the root grid and only render inside the folder. Defaults + // false so categories predating migration 185 keep filtering. + is_folder: parseBooleanInput(cat.is_folder, false) + })); + } + + // Build a map for quick category lookup + const categoryMap = {}; + categories.forEach(cat => { + categoryMap[cat.id] = cat; + }); + + // Include protection settings in response + const protectionSettings = { + protection_level: event.protection_level || 'standard', + image_quality: event.image_quality || 85, + use_canvas_rendering: parseBooleanInput(event.use_canvas_rendering, false), + overlay_protection: parseBooleanInput(event.overlay_protection, true) + }; + + // Lightbox preview tier (#492). When the admin opts in, the + // photos response carries a preview_url alongside url/thumbnail_url + // — the lightbox uses preview_url when present and falls back to + // url when not, so existing galleries continue working before + // any preview has actually been generated. + let lightboxPreviewEnabled = false; + try { + const setting = await db('app_settings') + .where('setting_key', 'lightbox_preview_enabled') + .first(); + if (setting) { + const raw = setting.setting_value; + // setting_value is JSON-stringified per migration 104; tolerate + // raw boolean/string for forward-compat. + const parsed = typeof raw === 'string' ? (() => { + try { return JSON.parse(raw); } catch { return raw; } + })() : raw; + lightboxPreviewEnabled = parsed === true || parsed === 'true' || parsed === 1; + } + } catch (e) { + // Setting missing / DB blip → fall back to off so the lightbox + // keeps working with the original. logger.debug to avoid noise. + logger.debug('lightbox_preview_enabled lookup failed, treating as off', { error: e?.message }); + } + + // #508: when the admin has flipped the "use original camera filenames" + // toggle (#493), the lightbox surfaces each photo's original_filename + // alongside the position counter so the photographer can map a guest's + // selection back to source files. Tied to the same toggle as downloads — + // one switch controls both surfaces. + const useOriginalFilenames = await getUseOriginalFilenames(); + const globalHeroLogoVisible = await getAppSetting('branding_logo_display_hero', true); + const globalLogoSize = await getAppSetting('branding_logo_size', 'medium'); + const downloadPolicy = await resolveEventDownloadPolicy(event); + + return { + pagination: { page, limit: limit || total, total, has_more: !!limit && page * limit < total }, + event: { + id: event.id, + event_name: event.event_name, + event_type: event.event_type, + event_date: event.event_date, + welcome_message: event.welcome_message, + color_theme: event.color_theme, + expires_at: event.expires_at, + hero_photo_id: event.hero_photo_id, + // Defaults match /info: downloads on unless explicitly disabled, + // uploads off unless explicitly enabled (#1028). + allow_downloads: parseBooleanInput(event.allow_downloads, true), + allow_user_uploads: parseBooleanInput(event.allow_user_uploads, false), + // Download resolutions (#858). `choices` drives the picker modal and is + // empty when the picker is off, so the UI can never offer a size the + // server would reject. + download_resolution: { + standard: downloadPolicy.standard, + picker_enabled: downloadPolicy.pickerEnabled, + choices: downloadPolicy.pickerEnabled ? downloadPolicy.choices : [], + }, + // Reveal mode (#838): armed flag lets an open VISIBLE gallery keep + // polling so a re-hide propagates without a manual reload. + reveal_armed: parseBooleanInput(event.reveal_mode, false), + disable_right_click: parseBooleanInput(event.disable_right_click, false), + watermark_downloads: parseBooleanInput(event.watermark_downloads, false), + watermark_text: event.watermark_text, + enable_devtools_protection: parseBooleanInput(event.enable_devtools_protection, false), + use_canvas_rendering: parseBooleanInput(event.use_canvas_rendering, false), + hero_logo_visible: resolveHeroLogoVisible(event.hero_logo_visible, globalHeroLogoVisible), + hero_logo_size: event.hero_logo_size || globalLogoSize || 'medium', + hero_logo_position: event.hero_logo_position || 'top', + hero_logo_url: event.hero_logo_url || null, + header_style: event.header_style || 'standard', + hero_divider_style: event.hero_divider_style || 'wave', + hero_image_anchor: event.hero_image_anchor || 'center', + default_photo_sort: event.default_photo_sort || 'upload_date_desc', + // Promo banner override (#440). GalleryView has always read + // promo_mode from THIS payload, but it was never sent — so every + // per-event promo override silently resolved to 'inherit' and a + // gallery set to 'off' still showed the global banner. + promo_mode: event.promo_mode || 'inherit', + promo_markdown: event.promo_markdown || null, + // Info banner override (#932). GalleryAuthContext refreshes its cached + // event from THIS payload, so the fields have to travel here — /info + // alone isn't enough, the context stops reading it once the guest is + // authenticated. + info_mode: event.info_mode || 'inherit', + info_markdown: event.info_markdown || null, + download_zip_ready: !!(event.download_zip_path && event.download_zip_generated_at), + // Mirror of the admin-side toggle so the lightbox can decide + // whether to surface original camera filenames (#508). + use_original_filenames: useOriginalFilenames, + // "People in this gallery" (#1074). False whenever the global flag + // is off, detection is off for this event, or the photographer chose + // to keep the strip to themselves — the frontend renders no face UI + // at all in that case. + people_enabled: peopleEnabled, + ...protectionSettings + }, + // Reveal mode (#838): the guest UI switches to the upload-only view + // on this flag; reveal_at lets it show the scheduled time. + hidden_until_reveal: hiddenForGuest, + reveal_at: hiddenForGuest ? (event.reveal_at || null) : undefined, + categories: categories, + photos: photos.map(photo => { + const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard'); + // Watermark version (cache-busting) + admin-preview flag (#868). In + // preview mode no gallery cookie is minted, so each request must + // re-assert the admin session — thread the flag onto every /api/gallery + // image URL so the browser sends it (the admin_token cookie rides along + // same-origin). + const imgQuery = [wmVersion, adminPreview ? 'admin_preview=1' : ''].filter(Boolean).join('&'); + const wmQuery = imgQuery ? `?${imgQuery}` : ''; + const photoUrl = useJwtUrl ? + `/api/gallery/${slug}/photo/${photo.id}${wmQuery}` : + `/api/secure-images/${slug}/secure/${photo.id}/{{token}}`; + + return { + id: photo.id, + filename: photo.filename, + // Raw camera filename (or null for pre-migration-062 uploads). + // The lightbox renders it when `use_original_filenames` is on. + original_filename: photo.original_filename || null, + url: photoUrl, + thumbnail_url: photo.thumbnail_path ? `/api/gallery/${slug}/thumbnail/${photo.id}${wmQuery}` : null, + // Hero-optimized image URL (1920x1080) for full-width hero sections + hero_url: `/api/gallery/${slug}/hero/${photo.id}${wmQuery}`, + // Lightbox preview URL (#492). Only emitted when the admin + // has flipped lightbox_preview_enabled — the frontend + // lightbox reads preview_url with a fallback to url so + // installs that haven't opted in keep loading the original + // (current behaviour). Skipped for videos since they don't + // get a preview tier; lightbox will use the original .url. + preview_url: (lightboxPreviewEnabled || originalNeedsPreview(photo)) + && photo.media_type !== 'video' + && (!photo.mime_type || !photo.mime_type.startsWith('video/')) + ? `/api/gallery/${slug}/preview/${photo.id}${wmQuery}` + : null, + // Slideshow source (#1015). Same preview tier, but emitted + // unconditionally: the slideshow has no `url` fallback worth + // taking (originals are projector-sized) and must never land on + // `hero_url`, which is cover-cropped to 16:9 — that made the + // "no crop" fit letterbox an already-cropped frame. The preview + // route generates lazily and redirects to the original on any + // failure, so this is safe even where no preview exists yet. + slideshow_url: photo.media_type !== 'video' + && (!photo.mime_type || !photo.mime_type.startsWith('video/')) + ? `/api/gallery/${slug}/preview/${photo.id}${wmQuery}` + : null, + secure_url_template: `/api/secure-images/${slug}/secure/${photo.id}/{{token}}`, + download_url_template: `/api/secure-images/${slug}/secure-download/${photo.id}/{{token}}`, + type: photo.type, + category_id: photo.category_id || null, + category_name: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].name : null, + // Per-category download permission (#640). Defaults true for photos + // without a category or for categories that pre-date migration 135. + category_allow_downloads: photo.category_id && categoryMap[photo.category_id] + ? parseBooleanInput(categoryMap[photo.category_id].allow_downloads, true) + : true, + category_slug: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].slug : null, + size: photo.size_bytes, + // toIso: on SQLite installs rows written with a raw Date (e.g. + // the pre-fix archive-restore path) hold epoch numbers — the + // Timeline layout's parseISO() crashes on those (#485 class). + uploaded_at: toIso(photo.uploaded_at), + // Image dimensions for layout calculations + width: photo.width || null, + height: photo.height || null, + // Fixed: Use the calculated useJwtUrl variable instead of recalculating + requires_token: !useJwtUrl, + // EXIF capture date + captured_at: toIso(photo.captured_at) || null, + // Media type + media_type: photo.media_type || null, + mime_type: photo.mime_type || null, + duration: photo.duration || null, + // Feedback data (hidden when show_feedback_to_guests is disabled) + has_feedback: showFeedbackToGuests ? (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0) : false, + average_rating: showFeedbackToGuests ? (photo.average_rating || 0) : 0, + comment_count: showFeedbackToGuests ? (commentMap[photo.id] || 0) : 0, + like_count: showFeedbackToGuests ? (photo.like_count || 0) : 0, + // Per-viewer flag (#590 follow-up) — true when this viewer has + // an active like row for this photo, false otherwise. Lets the + // grid seed its lifted likedPhotoIds correctly on hard refresh. + // Survives show_feedback_to_guests being off (#1286): the viewer's + // own heart is theirs, and the like_count beside it stays hidden. + is_liked: likedPhotoIds.has(photo.id), + favorite_count: showFeedbackToGuests ? (photo.favorite_count || 0) : 0, + // Colour labels (#1044). The COUNT is aggregate data and follows + // show_feedback_to_guests like its siblings; the viewer's OWN label + // is not aggregate and must survive with sharing off, otherwise the + // grid badge disappears on refresh for the very guest who set it. + color_label_count: showFeedbackToGuests ? (photo.color_label_count || 0) : 0, + my_color_label: myColorLabelByPhoto[photo.id] || null, + // Distinct colours other viewers put on this photo (#1178), so the + // grid can show them beside the viewer's own badge. Empty with + // sharing off — it is other people's feedback. + other_color_labels: otherColorLabelsByPhoto[photo.id] || [], + // People in this photo (#1074). Empty array when the feature is + // off for this event or hidden from guests, so the frontend has + // one shape to handle. Riding along on this payload is what keeps + // face filtering client-side and instant, like the category and + // liked/rated filters. + person_ids: personIdsByPhoto.get(photo.id) || [], + // Visibility (only included for clients) + ...(isClient ? { visibility: photo.visibility || 'visible' } : {}) + }; + }) + }; +} +module.exports = { getGalleryPhotos }; diff --git a/backend/src/services/invoiceSchedulerService.js b/backend/src/services/invoiceSchedulerService.js index d10f4ace..2c2fc918 100644 --- a/backend/src/services/invoiceSchedulerService.js +++ b/backend/src/services/invoiceSchedulerService.js @@ -24,13 +24,13 @@ * `crmSchedulerService` is a future cleanup. */ -const cron = require('node-cron'); +const { scheduledTask } = require('./scheduledTask'); const invoiceService = require('./invoiceService'); const eventReminderService = require('./eventReminderService'); const quoteService = require('./quoteService'); const logger = require('../utils/logger'); -let task = null; + async function runTick() { try { @@ -71,31 +71,8 @@ async function runTick() { } } -function startInvoiceScheduler() { - if (task) { - logger.info('Invoice scheduler already running'); - return task; - } - // Hourly at minute 11 to spread load away from other hourly jobs. - task = cron.schedule('11 * * * *', async () => { - logger.info('Invoice scheduler: tick'); - await runTick(); - }); - logger.info('Invoice scheduler started (hourly @ :11) — invoice + event-reminder jobs'); - // Run once on boot so a missed window (server restart) gets caught - // up immediately. - runTick().catch((err) => { - logger.warn('Invoice scheduler initial tick failed', { err: err.message }); - }); - return task; -} - -function stopInvoiceScheduler() { - if (task) { - task.stop(); - task = null; - logger.info('Invoice scheduler stopped'); - } -} +const task = scheduledTask(runTick, { schedule: '11 * * * *', initialDelay: 0 }); +function startInvoiceScheduler() { task.start(); return task; } +const stopInvoiceScheduler = () => task.stop(); module.exports = { startInvoiceScheduler, stopInvoiceScheduler }; diff --git a/backend/src/services/rateLimitService.js b/backend/src/services/rateLimitService.js index c66b4a80..dc50818f 100644 --- a/backend/src/services/rateLimitService.js +++ b/backend/src/services/rateLimitService.js @@ -1,3 +1,4 @@ +const { requestLogPath } = require('../utils/requestLogPath'); const rateLimit = require('express-rate-limit'); const { MemoryStore } = require('express-rate-limit'); const jwt = require('jsonwebtoken'); @@ -250,19 +251,18 @@ async function createRateLimiter(store = new MemoryStore()) { // Enhanced logging for production analysis logger.warn('Rate limit exceeded', { ip: clientIp, - path: req.path, + path: requestLogPath(req.originalUrl || req.path), method: req.method, authenticated: isAuthenticated(req), tokenType: req.tokenType, userAgent: req.headers['user-agent'], - referer: req.headers['referer'], origin: req.headers['origin'], timestamp: new Date().toISOString(), headers: { 'x-forwarded-for': req.headers['x-forwarded-for'], 'x-real-ip': req.headers['x-real-ip'] }, - requestUrl: req.originalUrl, + requestUrl: requestLogPath(req.originalUrl || req.path), rateLimitInfo: { limit: req.rateLimit?.limit, current: req.rateLimit?.current, @@ -318,7 +318,7 @@ async function createAuthRateLimiter(store = new MemoryStore()) { // Enhanced logging for auth failures logger.warn('Auth rate limit exceeded', { ip: clientIp, - path: req.path, + path: requestLogPath(req.originalUrl || req.path), method: req.method, userAgent: req.headers['user-agent'], timestamp: new Date().toISOString(), @@ -326,7 +326,7 @@ async function createAuthRateLimiter(store = new MemoryStore()) { 'x-forwarded-for': req.headers['x-forwarded-for'], 'x-real-ip': req.headers['x-real-ip'] }, - requestUrl: req.originalUrl, + requestUrl: requestLogPath(req.originalUrl || req.path), authType: req.path.includes('admin') ? 'admin' : 'gallery', rateLimitInfo: { limit: req.rateLimit?.limit, diff --git a/backend/src/services/revealScheduler.js b/backend/src/services/revealScheduler.js index a1458c2e..d5e6a6fc 100644 --- a/backend/src/services/revealScheduler.js +++ b/backend/src/services/revealScheduler.js @@ -10,7 +10,7 @@ * workflow trigger so hosts can hook a notification email onto it. */ -const cron = require('node-cron'); +const { scheduledTask } = require('./scheduledTask'); const { db, logActivity } = require('../database/db'); const { formatBoolean } = require('../utils/dbCompat'); const logger = require('../utils/logger'); @@ -68,9 +68,9 @@ async function checkScheduledReveals() { } } -function startRevealScheduler() { - cron.schedule('* * * * *', checkScheduledReveals); - logger.info('Reveal scheduler started'); -} +const task = scheduledTask(checkScheduledReveals, { schedule: '* * * * *' }); +function startRevealScheduler() { task.start(); } +const stopRevealScheduler = () => task.stop(); -module.exports = { startRevealScheduler, checkScheduledReveals }; +module.exports = { + stopRevealScheduler, startRevealScheduler, checkScheduledReveals }; diff --git a/backend/src/services/s3AutoImporter.js b/backend/src/services/s3AutoImporter.js index eceb23f5..b63c1416 100644 --- a/backend/src/services/s3AutoImporter.js +++ b/backend/src/services/s3AutoImporter.js @@ -31,10 +31,10 @@ const ENABLED = process.env.STORAGE_AUTO_IMPORT === 'true'; // On the next poll, any key in BOTH the previous and current snapshots is // eligible for import. This is the eventual-consistency gate. const previousSnapshot = new Map(); -let intervalHandle = null; + let stopped = false; -async function tick() { +async function runTick() { if (stopped) return; const storage = getStorage(); if (storage.kind() !== 's3') return; // no-op for local fs @@ -157,24 +157,19 @@ async function processEvent(event, storage) { previousSnapshot.set(event.id, currentKeys); } +const pollingTask = require('./scheduledTask').scheduledTask(runTick, { + // Run once on start so admins see import activity without waiting a full poll. + interval: POLL_INTERVAL_MS, initialDelay: 0, +}); +const tick = () => runTick(); // Explicit test/manual tick does not start a timer. function startS3AutoImporter() { - if (!ENABLED) return null; - if (intervalHandle) return intervalHandle; + if (!ENABLED) return; stopped = false; - // Run once on startup so admins see import activity in logs without - // waiting for the first poll interval. - tick().catch((err) => logger.error(`[s3AutoImporter] initial tick error: ${err.message}`)); - intervalHandle = setInterval(tick, POLL_INTERVAL_MS); - logger.info(`[s3AutoImporter] started — interval=${POLL_INTERVAL_MS}ms`); - return intervalHandle; + pollingTask.start(); } - -function stopS3AutoImporter() { +async function stopS3AutoImporter() { stopped = true; - if (intervalHandle) { - clearInterval(intervalHandle); - intervalHandle = null; - } + await pollingTask.stop(); previousSnapshot.clear(); } diff --git a/backend/src/services/scheduledTask.js b/backend/src/services/scheduledTask.js new file mode 100644 index 00000000..c62e3841 --- /dev/null +++ b/backend/src/services/scheduledTask.js @@ -0,0 +1,29 @@ +const cron = require('node-cron'); +const logger = require('../utils/logger'); +/** One owner for a periodic job, with no overlapping runs and a draining stop. */ +function scheduledTask(run, { schedule, interval, initialDelay } = {}) { + let started = false, timer = null, first = null, running = null; + const tick = () => { + if (!started || running) return running; + running = Promise.resolve().then(run).catch(error => { + logger.error('Scheduled task failed', { error: error.message }); + }).finally(() => { running = null; }); + return running; + }; + return { + start() { + if (started) return; + started = true; + timer = schedule ? cron.schedule(schedule, tick) : setInterval(tick, interval); + if (!schedule) timer.unref?.(); + if (initialDelay !== undefined) { first = setTimeout(tick, initialDelay); first.unref?.(); } + }, + async stop() { + started = false; + if (schedule) timer?.stop(); else clearInterval(timer); + clearTimeout(first); timer = null; first = null; + await running; + }, + }; +} +module.exports = { scheduledTask }; diff --git a/backend/src/services/secureImageService.js b/backend/src/services/secureImageService.js index 49a7c28b..896abbb6 100644 --- a/backend/src/services/secureImageService.js +++ b/backend/src/services/secureImageService.js @@ -9,6 +9,25 @@ class SecureImageService { this.tokenCache = new Map(); this.sessionTokens = new Map(); this.rateLimitCache = new Map(); + this.cleanupTimer = null; + } + + start() { + if (this.cleanupTimer) return; + this.cleanupTimer = setInterval(() => this.cleanup(), 60_000); + this.cleanupTimer.unref(); + } + + stop() { + clearInterval(this.cleanupTimer); + this.cleanupTimer = null; + } + + dispose() { + this.stop(); + this.tokenCache.clear(); + this.sessionTokens.clear(); + this.rateLimitCache.clear(); } /** @@ -27,9 +46,14 @@ class SecureImageService { // Whether the minter was a PIN-client — lets the serve route keep // delivering a photo hidden AFTER minting (TOCTOU). A guest's token // carries false, so it stops the moment the photo is hidden. - clientBypass = false + clientBypass = false, + galleryAccess = null } = options; + if (!Number.isFinite(Number(expiresIn)) || Number(expiresIn) <= 0 || Number(expiresIn) > 3600) { + throw new (require('../utils/errors').ValidationError)('Invalid image token lifetime'); + } + const tokenData = { photoId: parseInt(photoId), sessionId, @@ -40,6 +64,7 @@ class SecureImageService { protectionLevel, revealBypass, clientBypass, + galleryAccess, createdAt: Date.now() }; @@ -56,10 +81,8 @@ class SecureImageService { // Cache token with metadata this.tokenCache.set(token, tokenData); - // Set cleanup timer - setTimeout(() => { - this.tokenCache.delete(token); - }, expiresIn * 1000 + 60000); // Add 1 minute buffer + // One owned timer per service, not one live handle per issued token. + this.start(); return token; } @@ -217,9 +240,7 @@ class SecureImageService { } image = image.withMetadata({ - exif: { - [sharp.EXIF.IFD0.ImageDescription]: `Protected:${fingerprint}` - } + exif: { IFD0: { ImageDescription: `Protected:${fingerprint}` } } }); return await image.toBuffer(); @@ -261,9 +282,7 @@ class SecureImageService { // Embed fingerprint in metadata image = image.withMetadata({ - exif: { - [sharp.EXIF.IFD0.ImageDescription]: `Protected:${fingerprint}` - } + exif: { IFD0: { ImageDescription: `Protected:${fingerprint}` } } }); } @@ -430,6 +449,9 @@ class SecureImageService { cleanup() { // Clear expired rate limit entries const now = Date.now(); + for (const [token, data] of this.tokenCache) { + if (data.expiresAt <= now) this.tokenCache.delete(token); + } for (const [clientId, requests] of this.rateLimitCache.entries()) { const recent = requests.filter(timestamp => timestamp > now - 60000); if (recent.length === 0) { @@ -441,4 +463,4 @@ class SecureImageService { } } -module.exports = new SecureImageService(); \ No newline at end of file +module.exports = new SecureImageService(); diff --git a/backend/src/services/serviceShutdown.js b/backend/src/services/serviceShutdown.js new file mode 100644 index 00000000..1462b46f --- /dev/null +++ b/backend/src/services/serviceShutdown.js @@ -0,0 +1,28 @@ +const logger = require('../utils/logger'); +// Resolve only services already loaded by startup. Shutdown must not construct +// unrelated singletons or start new work just to stop it. +const resources = [ + ['../middleware/sessionTimeout', 'dispose'], ['./chunkedUploadService', 'stop'], + ['../utils/cleanupTempUploads', 'stopTempUploadCleanup'], + ['../middleware/secureImageMiddleware', 'dispose'], ['../middleware/feedbackRateLimit', 'dispose'], + ['./downloadZipService', 'stop'], + ['./fileWatcher', 'stopFileWatcher'], ['./externalMediaWatcher', 'stopExternalMediaWatcher'], + ['./expirationChecker', 'stopExpirationChecker'], ['./transferCleanupService', 'stopTransferCleanup'], + ['./downloadJobCleanupService', 'stopDownloadJobCleanup'], ['./revealScheduler', 'stopRevealScheduler'], + ['./invoiceSchedulerService', 'stopInvoiceScheduler'], ['./emailProcessor', 'stopEmailQueueProcessor'], + ['./whatsappProcessor', 'stopWhatsAppQueueProcessor'], ['./emailIntakeService', 'stopIncomingMailPoller'], + ['./webhookDeliveryWorker', 'stopWebhookDeliveryWorker'], ['./s3AutoImporter', 'stopS3AutoImporter'], + ['./backupService', 'stopBackupService'], ['./databaseBackup', 'stopScheduledBackups'], + ['./backgroundProcessor', 'stop'], ['./faceQueue', 'stop'], ['./secureImageService', 'dispose'], + ['../utils/authSecurity', 'stopCleanupJob'], ['../utils/tokenRevocation', 'stopRevocationCleanup'], +]; +async function stopServices() { + const results = await Promise.allSettled(resources.map(async ([path, method]) => { + const loaded = require.cache[require.resolve(path)]; + if (typeof loaded?.exports[method] === 'function') await loaded.exports[method](); + })); + const failures = results.filter(result => result.status === 'rejected'); + failures.forEach(result => logger.error('Service shutdown failed', { error: result.reason.message })); + if (failures.length) throw new AggregateError(failures.map(result => result.reason), 'Service shutdown failed'); +} +module.exports = { stopServices }; diff --git a/backend/src/services/sessionAccessService.js b/backend/src/services/sessionAccessService.js new file mode 100644 index 00000000..0bf539e7 --- /dev/null +++ b/backend/src/services/sessionAccessService.js @@ -0,0 +1,74 @@ +const { db } = require('../database/db'); +const { formatBoolean } = require('../utils/dbCompat'); +const { isMissingRolesSchema } = require('../utils/dbErrors'); +const { isTokenRevoked } = require('../utils/tokenRevocation'); +const { isTokenBeforeCutoff } = require('../utils/sessionCutoff'); +const { toTimestamp } = require('../utils/dateNormalize'); +const { AppError } = require('../utils/errors'); + +/** Call only with a verified JWT payload or a signed, server-created grant. */ +class SessionAccessService { + async assertActive(session, type) { + if (!session || session.type !== type) { + throw new AppError('Invalid token type', 403, 'WRONG_TOKEN_TYPE'); + } + if (!Number.isFinite(session.iat) + || (session.exp !== undefined && (!Number.isFinite(session.exp) || session.exp <= Date.now() / 1000))) { + throw new AppError('Token expired or invalid', 401, 'TOKEN_EXPIRED'); + } + if (await isTokenRevoked(session)) { + throw new AppError('Token has been revoked', 401, 'TOKEN_REVOKED'); + } + if (await isTokenBeforeCutoff(session)) { + throw new AppError('Session invalidated', 401, 'SESSION_INVALIDATED'); + } + } + + assertPasswordCurrent(account, session) { + if (account.password_changed_at == null) return; + const changed = toTimestamp(account.password_changed_at); + // Preserve the same-second login convention used by admin/customer auth. + if (!Number.isFinite(changed) || session.iat < Math.floor(changed / 1000)) { + throw new AppError('Token invalid due to password change', 401, 'PASSWORD_CHANGED'); + } + } + + async admin(session, { includeProfile = false } = {}) { + await this.assertActive(session, 'admin'); + let account; + try { + account = await db('admin_users') + .leftJoin('roles', 'roles.id', 'admin_users.role_id') + .where({ 'admin_users.id': session.id, 'admin_users.is_active': formatBoolean(true) }) + .select('admin_users.id', 'admin_users.username', 'admin_users.email', + 'admin_users.password_changed_at', 'roles.id as role_id', 'roles.name as role_name', + ...(includeProfile ? ['admin_users.must_change_password', 'roles.display_name as role_display_name'] : [])) + .first(); + } catch (error) { + if (!isMissingRolesSchema(error)) throw error; + account = await db('admin_users') + .where({ id: session.id, is_active: formatBoolean(true) }) + .select('id', 'username', 'email', 'password_changed_at').first(); + if (account) Object.assign(account, { role_id: null, role_name: 'super_admin' }); + } + if (!account) throw new AppError('Invalid token', 401, 'ADMIN_NOT_FOUND'); + this.assertPasswordCurrent(account, session); + return account; + } + + async customer(session, { derived = false } = {}) { + if (!derived) await this.assertActive(session, 'customer'); + if (!Number.isInteger(session.customerId)) { + throw new AppError('Invalid customer session', 401, 'CUSTOMER_NOT_FOUND'); + } + const account = await db('customer_accounts') + .where({ id: session.customerId, is_active: formatBoolean(true) }) + .select('id', 'email', 'display_name', 'first_name', 'last_name', 'password_changed_at', 'preferred_language') + .first(); + if (!account) throw new AppError('Invalid token', 401, 'CUSTOMER_NOT_FOUND'); + this.assertPasswordCurrent(account, session); + return account; + } +} + +module.exports = new SessionAccessService(); diff --git a/backend/src/services/transferCleanupService.js b/backend/src/services/transferCleanupService.js index b419449a..ce96cba6 100644 --- a/backend/src/services/transferCleanupService.js +++ b/backend/src/services/transferCleanupService.js @@ -17,7 +17,7 @@ * are deleted, which is what the retention cap is about.) */ -const cron = require('node-cron'); +const { scheduledTask } = require('./scheduledTask'); const { db } = require('../database/db'); const logger = require('../utils/logger'); const { formatBoolean } = require('../utils/dbCompat'); @@ -26,13 +26,9 @@ const transferService = require('./transferService'); const DAY_MS = 24 * 60 * 60 * 1000; -function startTransferCleanup() { - // Hourly at :15 — staggered from the gallery expiration checker (:00). - cron.schedule('15 * * * *', async () => { - await runTransferCleanup(); - }); - logger.info('Transfer cleanup scheduler started'); -} +const task = scheduledTask(runTransferCleanup, { schedule: '15 * * * *' }); +function startTransferCleanup() { task.start(); } +const stopTransferCleanup = () => task.stop(); async function runTransferCleanup() { try { @@ -138,6 +134,7 @@ async function deleteRetiredTransfers() { } module.exports = { + stopTransferCleanup, startTransferCleanup, // exported for tests / manual invocation runTransferCleanup, diff --git a/backend/src/services/webhookDeliveryWorker.js b/backend/src/services/webhookDeliveryWorker.js index 2bb91cb4..4b33f2ec 100644 --- a/backend/src/services/webhookDeliveryWorker.js +++ b/backend/src/services/webhookDeliveryWorker.js @@ -2,6 +2,7 @@ const axios = require('axios'); const { db } = require('../database/db'); const logger = require('../utils/logger'); const { signPayload, renderTemplate } = require('./webhookService'); +const { pinnedRequestOptions } = require('../utils/pinnedRequest'); const { validateExternalUrlAsync } = require('../utils/networkValidation'); const POLL_INTERVAL_MS = parseInt(process.env.WEBHOOK_DELIVERY_INTERVAL_MS || '5000', 10); @@ -32,7 +33,7 @@ const BACKOFF_MS = [ 12 * 60 * 60_000, // 12 h (only used when MAX_ATTEMPTS extended past 5) ]; -let intervalHandle = null; + let stopped = false; // Tracks deliveries currently being processed in this tick — guards // against the same row being claimed twice if a tick takes longer than @@ -95,6 +96,7 @@ async function deliverOne(row) { // host and vets every A/AAAA record (a public-looking name that now // resolves to an internal IP is rejected). Admin can opt out via // WEBHOOK_ALLOW_PRIVATE_URLS=true for local-receiver dev runs. + let connectionOptions = {}; if (!allowPrivateUrls) { const urlCheck = await validateExternalUrlAsync(webhook.url); if (!urlCheck.valid) { @@ -111,6 +113,7 @@ async function deliverOne(row) { } return; } + connectionOptions = pinnedRequestOptions(urlCheck); } const envelopeBody = typeof row.payload === 'string' ? row.payload : JSON.stringify(row.payload); @@ -139,6 +142,7 @@ async function deliverOne(row) { let networkError; try { response = await axios.post(webhook.url, rawBody, { + ...connectionOptions, headers: { 'Content-Type': contentType, [SIGNATURE_HEADER]: signature, @@ -266,7 +270,7 @@ function stringifyBody(data) { try { return JSON.stringify(data); } catch { return String(data); } } -async function tick() { +async function runTick() { if (stopped) return; try { const slots = Math.max(0, CONCURRENCY - inFlight.size); @@ -286,22 +290,15 @@ async function tick() { } } +const pollingTask = require('./scheduledTask').scheduledTask(runTick, { interval: POLL_INTERVAL_MS }); +const tick = () => runTick(); // Explicit test/manual tick does not start a timer. function startWebhookDeliveryWorker() { - if (intervalHandle) return; // idempotent stopped = false; - intervalHandle = setInterval(tick, POLL_INTERVAL_MS); - logger.info( - `[webhookWorker] started — interval=${POLL_INTERVAL_MS}ms, concurrency=${CONCURRENCY}, ` + - `max_attempts=${MAX_ATTEMPTS}, allow_private=${allowPrivateUrls}` - ); + pollingTask.start(); } - -function stopWebhookDeliveryWorker() { +async function stopWebhookDeliveryWorker() { stopped = true; - if (intervalHandle) { - clearInterval(intervalHandle); - intervalHandle = null; - } + await pollingTask.stop(); } module.exports = { diff --git a/backend/src/services/whatsappProcessor.js b/backend/src/services/whatsappProcessor.js index 6a34c5be..191e48b6 100644 --- a/backend/src/services/whatsappProcessor.js +++ b/backend/src/services/whatsappProcessor.js @@ -67,7 +67,7 @@ const POLL_INTERVAL_MS = parseInt(process.env.WHATSAPP_QUEUE_POLL_MS || '30000', const CYCLE_BATCH_SIZE = parseInt(process.env.WHATSAPP_QUEUE_BATCH || '10', 10); const MAX_RETRIES = 3; -let pollHandle = null; + /** * Resolve a Meta template language code from whatever's in the message_data @@ -301,29 +301,9 @@ async function processWhatsAppQueue() { } } -function startWhatsAppQueueProcessor() { - if (pollHandle) { - logger.info('WhatsApp queue processor already running — skipping start'); - return; - } - // Fire once shortly after boot so the first message in a fresh install - // doesn't wait the full poll interval. - setTimeout(() => { - processWhatsAppQueue().catch((e) => logger.error('WhatsApp queue initial run failed', e)); - }, 5000); - pollHandle = setInterval(() => { - processWhatsAppQueue().catch((e) => logger.error('WhatsApp queue cycle failed', e)); - }, POLL_INTERVAL_MS); - logger.info(`WhatsApp queue processor started (poll every ${POLL_INTERVAL_MS}ms)`); -} - -function stopWhatsAppQueueProcessor() { - if (pollHandle) { - clearInterval(pollHandle); - pollHandle = null; - logger.info('WhatsApp queue processor stopped'); - } -} +const whatsappTask = require('./scheduledTask').scheduledTask(processWhatsAppQueue, { interval: POLL_INTERVAL_MS, initialDelay: 5000 }); +function startWhatsAppQueueProcessor() { whatsappTask.start(); } +const stopWhatsAppQueueProcessor = () => whatsappTask.stop(); module.exports = { queueWhatsapp, diff --git a/backend/src/services/workerManager.js b/backend/src/services/workerManager.js index 716da38d..6f1133de 100644 --- a/backend/src/services/workerManager.js +++ b/backend/src/services/workerManager.js @@ -34,24 +34,23 @@ async function startWorkers() { logger.info('All background workers started successfully'); } catch (error) { logger.error('Failed to start background workers:', error); - process.exit(1); + process.exitCode = 1; + await handleShutdown('startup failure'); } } -function handleShutdown(signal) { - if (isShuttingDown) { - logger.info('Shutdown already in progress...'); - return; - } - +async function handleShutdown(signal) { + if (isShuttingDown) return; isShuttingDown = true; logger.info(`Received ${signal}. Shutting down gracefully...`); - - // Give time for cleanup - setTimeout(() => { + try { + await require('./serviceShutdown').stopServices(); + await require('../database/db').db.destroy(); logger.info('Worker manager shutdown complete'); - process.exit(0); - }, 1000); + } catch (error) { + logger.error('Worker shutdown failed', { error: error.message }); + process.exitCode = 1; + } } // Handle shutdown signals diff --git a/backend/src/utils/authSecurity.js b/backend/src/utils/authSecurity.js index 04f3e0c1..9ebb42bf 100644 --- a/backend/src/utils/authSecurity.js +++ b/backend/src/utils/authSecurity.js @@ -342,15 +342,12 @@ async function cleanupOldAttempts() { /** * Initialize cleanup job */ -function initializeCleanupJob() { - // Run cleanup every 24 hours - setInterval(cleanupOldAttempts, 24 * 60 * 60 * 1000); - - // Run initial cleanup - cleanupOldAttempts(); -} +const cleanupTask = require('../services/scheduledTask').scheduledTask(cleanupOldAttempts, { interval: 24 * 60 * 60 * 1000, initialDelay: 0 }); +function initializeCleanupJob() { cleanupTask.start(); } +const stopCleanupJob = () => cleanupTask.stop(); module.exports = { + stopCleanupJob, trackFailedAttempt, trackSuccessfulLogin, checkAccountLockout, diff --git a/backend/src/utils/cleanupTempUploads.js b/backend/src/utils/cleanupTempUploads.js index 2e3ea552..21396d88 100644 --- a/backend/src/utils/cleanupTempUploads.js +++ b/backend/src/utils/cleanupTempUploads.js @@ -60,19 +60,10 @@ async function cleanupTempUploads() { * Start periodic cleanup of temp uploads * Runs every hour */ -function startTempUploadCleanup() { - // Run immediately on startup - cleanupTempUploads(); - - // Then run every hour - setInterval(() => { - cleanupTempUploads(); - }, 60 * 60 * 1000); // 1 hour - - logger.info('Temp upload cleanup service started'); -} +const cleanupTask = require('../services/scheduledTask').scheduledTask(cleanupTempUploads, { + interval: 60 * 60 * 1000, initialDelay: 0 +}); +function startTempUploadCleanup() { cleanupTask.start(); } +function stopTempUploadCleanup() { return cleanupTask.stop(); } -module.exports = { - cleanupTempUploads, - startTempUploadCleanup -}; \ No newline at end of file +module.exports = { cleanupTempUploads, startTempUploadCleanup, stopTempUploadCleanup }; diff --git a/backend/src/utils/dateNormalize.js b/backend/src/utils/dateNormalize.js index fb67dbbe..b5245023 100644 --- a/backend/src/utils/dateNormalize.js +++ b/backend/src/utils/dateNormalize.js @@ -33,4 +33,15 @@ function toIso(value) { return value; } -module.exports = { toIso }; +// Shared comparison boundary for SQLite epoch values and PostgreSQL Dates. +// Invalid input stays NaN so access-control callers can fail closed. +function toTimestamp(value) { + if (value === null || value === undefined || value === '') return NaN; + try { + return new Date(toIso(value)).getTime(); + } catch (_) { + return NaN; + } +} + +module.exports = { toIso, toTimestamp }; diff --git a/backend/src/utils/galleryLifecycle.js b/backend/src/utils/galleryLifecycle.js new file mode 100644 index 00000000..10098fe5 --- /dev/null +++ b/backend/src/utils/galleryLifecycle.js @@ -0,0 +1,38 @@ +const { toTimestamp } = require('./dateNormalize'); +const { AppError } = require('./errors'); +const logger = require('./logger'); + +const warnedExpiry = new Set(); + +const isTrue = (value) => value === true || value === 1 || value === '1'; + +function isGalleryExpired(event, now = Date.now()) { + if (event.expires_at == null || event.expires_at === '') return false; + const expiry = toTimestamp(event.expires_at); + if (!Number.isFinite(expiry)) { + // Fail closed, but name the row once so an operator can repair it. + if (!warnedExpiry.has(event.id)) { + warnedExpiry.add(event.id); + logger.warn('Unparseable events.expires_at treated as expired', { eventId: event.id, expires_at: String(event.expires_at) }); + } + return true; + } + return expiry <= now; +} + +function requiresGalleryPassword(event) { + return !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); +} + +function isGalleryAvailable(event, { adminPreview = false } = {}) { + return !!event && isTrue(event.is_active) && !isTrue(event.is_archived) + && (adminPreview || (!isTrue(event.is_draft) && !isGalleryExpired(event))); +} + +function assertGalleryAvailable(event, { adminPreview = false } = {}) { + if (!isGalleryAvailable(event, { adminPreview })) { + throw new AppError('Gallery not found or expired', 404, 'GALLERY_UNAVAILABLE'); + } +} + +module.exports = { isGalleryAvailable, assertGalleryAvailable, isGalleryExpired, requiresGalleryPassword }; diff --git a/backend/src/utils/networkValidation.js b/backend/src/utils/networkValidation.js index 8ad21af7..f71cd4a0 100644 --- a/backend/src/utils/networkValidation.js +++ b/backend/src/utils/networkValidation.js @@ -190,30 +190,30 @@ function validateExternalUrl(urlString) { * literal isPrivateIP check alone can't see that. Fails closed on resolution * failure. IP literals are decided by isPrivateIP without a lookup. * - * Residual: a determined attacker who controls DNS can still rebind between - * this check and the client's own resolution (TOCTOU). Fully closing that - * needs pinning the connection to the vetted IP, which the underlying - * clients (nodemailer/imap/ssh/aws-sdk) don't cleanly support; these actions - * are admin-only, so resolve-and-vet is the proportionate mitigation. + * HTTP clients for admin-configured URLs (webhook delivery, the email webhook + * transport) must use the returned addresses from validateExternalUrlAsync + * with pinnedRequestOptions; a separate preflight alone cannot stop rebinding. + * The analytics tracker proxy, the tracker adapters and OIDC discovery still + * rely on the preflight only. * * @param {string} hostname * @returns {Promise} true when safe to connect */ -async function classifyHost(hostname) { - if (!hostname || typeof hostname !== 'string') return 'invalid'; - // Literal check first: IP literals, blocked names, .internal/.local/.localhost. - if (isPrivateIP(hostname)) return 'private'; - // An IP literal is fully decided above — no name to resolve. +async function resolveHost(hostname) { + if (!hostname || typeof hostname !== 'string') return { reason: 'invalid' }; + if (isPrivateIP(hostname)) return { reason: 'private' }; const bare = hostname.replace(/^\[|\]$/g, ''); - if (net.isIP(bare)) return 'ok'; + if (net.isIP(bare)) return { reason: 'ok', addresses: [{ address: bare, family: net.isIP(bare) }] }; let addresses; - try { - addresses = await dns.lookup(hostname, { all: true }); - } catch { - return 'unresolved'; // transient/NXDOMAIN — caller decides retry vs reject - } - if (!addresses.length) return 'unresolved'; - return addresses.every((a) => !isPrivateIP(a.address)) ? 'ok' : 'private'; + try { addresses = await dns.lookup(hostname, { all: true }); } + catch { return { reason: 'unresolved' }; } + if (!addresses.length) return { reason: 'unresolved' }; + if (addresses.some(a => !net.isIP(a.address) || isPrivateIP(a.address))) return { reason: 'private' }; + return { reason: 'ok', addresses }; +} + +async function classifyHost(hostname) { + return (await resolveHost(hostname)).reason; } async function isHostAllowed(hostname) { @@ -237,11 +237,14 @@ async function validateExternalUrlAsync(urlString) { } catch { return { valid: false, error: 'Invalid URL format', reason: 'invalid' }; } - const reason = await classifyHost(parsed.hostname); + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) { + return { valid: false, error: 'HTTP(S) URL without credentials required', reason: 'invalid' }; + } + const { reason, addresses } = await resolveHost(parsed.hostname); if (reason !== 'ok') { return { valid: false, error: 'URL points to a private or internal network address', reason }; } - return { valid: true, reason: 'ok' }; + return { valid: true, reason: 'ok', hostname: parsed.hostname.replace(/^\[|\]$/g, ''), addresses }; } module.exports = { isPrivateIP, validateExternalUrl, isHostAllowed, validateExternalUrlAsync, classifyHost }; diff --git a/backend/src/utils/pinnedRequest.js b/backend/src/utils/pinnedRequest.js new file mode 100644 index 00000000..2b3750d2 --- /dev/null +++ b/backend/src/utils/pinnedRequest.js @@ -0,0 +1,28 @@ +/** Axios/Node lookup: connect only to the addresses vetted for this delivery. + * Keep the original URL for Host, TLS SNI and certificate verification. + * Disable environment proxies (which would resolve the destination themselves) + * and redirects. No reusable agent/socket can carry an old DNS decision. + */ +const http = require('http'); +const https = require('https'); +function pinnedRequestOptions(check) { + if (!check?.valid || !check.hostname || !check.addresses?.length) { + throw new Error('A validated destination is required'); + } + const addresses = check.addresses.map(({ address, family }) => ({ address, family })); + const lookup = (hostname, options, callback) => { + if (typeof options === 'function') { callback = options; options = {}; } + if (hostname !== check.hostname) return callback(new Error('Destination hostname changed')); + const family = typeof options === 'number' ? options : options?.family; + const matches = family ? addresses.filter(a => a.family === family) : addresses; + if (!matches.length) return callback(new Error('No validated address for requested family')); + if (options?.all) return callback(null, matches); + callback(null, matches[0].address, matches[0].family); + }; + return { + proxy: false, maxRedirects: 0, + httpAgent: new http.Agent({ lookup, keepAlive: false }), + httpsAgent: new https.Agent({ lookup, keepAlive: false }), + }; +} +module.exports = { pinnedRequestOptions }; diff --git a/backend/src/utils/rateLimitSecurity.js b/backend/src/utils/rateLimitSecurity.js index 0ba002c4..052d334f 100644 --- a/backend/src/utils/rateLimitSecurity.js +++ b/backend/src/utils/rateLimitSecurity.js @@ -1,3 +1,4 @@ +const { requestLogPath } = require('./requestLogPath'); /** * Rate Limiting Security Utilities * Provides secure rate limiting that prevents bypass attempts @@ -42,7 +43,7 @@ function hasValidAdminToken(req) { // Must be admin type to skip rate limiting if (decoded.type !== 'admin') { logger.warn('Non-admin token attempted to bypass rate limit', { - path: req.path, + path: requestLogPath(req.originalUrl || req.path), tokenType: decoded.type, ip: req.ip }); @@ -55,7 +56,7 @@ function hasValidAdminToken(req) { if (tokenAge > maxAge) { logger.warn('Old admin token attempted to bypass rate limit', { - path: req.path, + path: requestLogPath(req.originalUrl || req.path), tokenAge: Math.floor(tokenAge / 1000 / 60) + ' minutes', ip: req.ip }); @@ -70,7 +71,7 @@ function hasValidAdminToken(req) { // Log attempts with invalid tokens (potential attacks) if (error.name === 'JsonWebTokenError') { logger.warn('Invalid token attempted to bypass rate limit', { - path: req.path, + path: requestLogPath(req.originalUrl || req.path), error: error.message, ip: req.ip }); @@ -105,7 +106,7 @@ function createSecureSkipFunction() { function logRateLimitHit(req, res) { logger.warn('Rate limit exceeded', { ip: req.ip, - path: req.path, + path: requestLogPath(req.originalUrl || req.path), userAgent: req.headers['user-agent'], remaining: res.getHeader('X-RateLimit-Remaining'), limit: res.getHeader('X-RateLimit-Limit') diff --git a/backend/src/utils/requestLogPath.js b/backend/src/utils/requestLogPath.js new file mode 100644 index 00000000..f102541d --- /dev/null +++ b/backend/src/utils/requestLogPath.js @@ -0,0 +1,12 @@ +/** Log the path without query values or bearer capabilities embedded in it. */ +function requestLogPath(value) { + const path = String(value || '/').split(/[?#]/, 1)[0]; + return path + .replace(/(\/(?:signed|verify-token|show|download-jobs|invite|accept-invite|password-reset|unsubscribe)\/)[^/]+/gi, '$1[redacted]') + .replace(/(\/api\/public\/[^/]+\/)[^/]+/gi, '$1[redacted]') + .replace(/(\/(?:secure|secure-download)\/[^/]+\/)[^/]+/gi, '$1[redacted]') + .replace(/\b(?:[a-f0-9]{32,}|eyJ[A-Za-z0-9_.-]+)\b/gi, '[redacted]') + // eslint-disable-next-line no-control-regex -- strip log injection control bytes + .replace(/[\r\n\x00-\x1f]/g, ''); +} +module.exports = { requestLogPath }; diff --git a/backend/src/utils/requestOrigin.js b/backend/src/utils/requestOrigin.js index ad70ff35..012bf4c4 100644 --- a/backend/src/utils/requestOrigin.js +++ b/backend/src/utils/requestOrigin.js @@ -21,22 +21,27 @@ function isAllowedOrigin(origin) { return allowedOrigins.indexOf(origin) !== -1; } -// Origin check for multipart bodies (see the Content-Type gate below). -// Same-origin installs proxy /api through nginx and may not have FRONTEND_URL -// set, so an Origin matching the request Host is accepted alongside the CORS -// allowlist; Sec-Fetch-Site is authoritative when a browser sends it. -function multipartOriginAllowed(req) { +// Check every browser mutation, including an empty form POST. Explicitly +// configured frontend origins may be cross-site; a sibling origin alone is +// not trusted. Non-browser clients without Origin/Fetch Metadata still work. +function mutationOriginAllowed(req) { + // Fetch Metadata is set by the browser and cannot be forged cross-site, so a + // same-origin request is trusted before the Origin/Host/scheme comparison, + // which depends on trust proxy and X-Forwarded-Proto being configured. const site = req.headers['sec-fetch-site']; - if (site) return site !== 'cross-site'; + if (site === 'same-origin') return true; const origin = req.headers.origin; - if (!origin) return true; - if (isAllowedOrigin(origin)) return true; - try { - return new URL(origin).host === req.headers.host; - } catch { - return false; + if (origin) { + if (isAllowedOrigin(origin)) return true; + try { + const parsed = new URL(origin); + return parsed.origin !== 'null' && parsed.host === req.headers.host + && (!req.protocol || parsed.protocol === `${req.protocol}:`); + } catch { return false; } } + return !site || site === 'none'; } - -module.exports = { isAllowedOrigin, multipartOriginAllowed }; +// Compatibility export for existing callers. +const multipartOriginAllowed = mutationOriginAllowed; +module.exports = { isAllowedOrigin, mutationOriginAllowed, multipartOriginAllowed }; diff --git a/backend/src/utils/routeHelpers.js b/backend/src/utils/routeHelpers.js index 43f3b798..6254b239 100644 --- a/backend/src/utils/routeHelpers.js +++ b/backend/src/utils/routeHelpers.js @@ -1,3 +1,4 @@ +const { requestLogPath } = require('../utils/requestLogPath'); /** * Route helper utilities for standardized request handling. * Provides async error wrapping, validation, and response formatting. @@ -93,7 +94,7 @@ const successResponse = (res, data, statusCode = 200, message = null) => { */ const errorResponse = (res, error, statusCode = 500, publicMessage) => { const message = publicMessage || (error instanceof Error ? error.message : String(error)); - const route = res.req ? `${res.req.method} ${res.req.originalUrl}` : null; + const route = res.req ? `${res.req.method} ${requestLogPath(res.req.originalUrl)}` : null; logger.error(route ? `${route} - ${message}` : message, { error: error instanceof Error ? error.message : error, stack: error instanceof Error ? error.stack : undefined diff --git a/backend/src/utils/tokenRevocation.js b/backend/src/utils/tokenRevocation.js index 222ba0d6..64b2a89e 100644 --- a/backend/src/utils/tokenRevocation.js +++ b/backend/src/utils/tokenRevocation.js @@ -145,15 +145,12 @@ async function cleanupExpiredRevocations() { /** * Initialize cleanup job for expired revocations */ -function initializeRevocationCleanup() { - // Run cleanup every 6 hours - setInterval(cleanupExpiredRevocations, 6 * 60 * 60 * 1000); - - // Run initial cleanup - cleanupExpiredRevocations(); -} +const cleanupTask = require('../services/scheduledTask').scheduledTask(cleanupExpiredRevocations, { interval: 6 * 60 * 60 * 1000, initialDelay: 0 }); +function initializeRevocationCleanup() { cleanupTask.start(); } +const stopRevocationCleanup = () => cleanupTask.stop(); -module.exports = { +module.exports = { buildTokenId, + stopRevocationCleanup, revokeToken, isTokenRevoked, revokeAllUserTokens, diff --git a/docs/usage-coverage.v5.json b/docs/usage-coverage.v5.json index cc4ffab4..b7de6177 100644 --- a/docs/usage-coverage.v5.json +++ b/docs/usage-coverage.v5.json @@ -1413,32 +1413,7 @@ "decision": "excluded", "signals": [], "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", - "route_signatures": [ - "GET /resolve/:identifier", - "GET /:slug/verify-token/:token", - "GET /:slug/info", - "GET /:slug/show/:token/session", - "GET /:slug/show/:token/state", - "GET /:slug/photos", - "GET /:slug/people", - "PATCH /:slug/photos/:photoId/visibility", - "PATCH /:slug/photos/visibility/bulk", - "GET /:slug/download/:photoId", - "GET /:slug/download-all", - "POST /:slug/download-selected", - "POST /:slug/download-jobs", - "GET /:slug/download-jobs/:token", - "GET /:slug/download-jobs/:token/file", - "POST /:slug/photo/:photoId/view", - "GET /:slug/photo/:photoId", - "GET /:slug/thumbnail/:photoId", - "GET /:slug/hero/:photoId", - "GET /:slug/preview/:photoId", - "GET /:slug/stats", - "POST /:eventId/upload", - "GET /:slug/uploads/status", - "GET /:slug/css-template" - ] + "route_signatures": [] }, "galleryFeedback.js": { "decision": "excluded", @@ -1603,6 +1578,86 @@ "GET /events/:id/share-link", "GET /events/:id/photos" ] + }, + "gallery/downloads.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /:slug/download/:photoId", + "GET /:slug/download-all", + "POST /:slug/download-selected", + "POST /:slug/download-jobs", + "GET /:slug/download-jobs/:token", + "GET /:slug/download-jobs/:token/file" + ] + }, + "gallery/media.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "POST /:slug/photo/:photoId/view", + "GET /:slug/photo/:photoId", + "GET /:slug/thumbnail/:photoId", + "GET /:slug/hero/:photoId", + "GET /:slug/preview/:photoId" + ] + }, + "gallery/metadata.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /resolve/:identifier", + "GET /:slug/verify-token/:token", + "GET /:slug/info" + ] + }, + "gallery/photos.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /:slug/photos", + "GET /:slug/people", + "PATCH /:slug/photos/:photoId/visibility", + "PATCH /:slug/photos/visibility/bulk" + ] + }, + "gallery/slideshow.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /:slug/show/:token/session", + "GET /:slug/show/:token/state" + ] + }, + "gallery/stats.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /:slug/stats" + ] + }, + "gallery/styles.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "GET /:slug/css-template" + ] + }, + "gallery/uploads.js": { + "decision": "excluded", + "signals": [], + "reason": "Public/customer/gallery/visitor surface or existing optional third-party analytics proxy: no product-usage middleware, callbacks, counters or report triggers.", + "route_signatures": [ + "POST /:eventId/upload", + "GET /:slug/uploads/status" + ] } }, "feature_flags": { diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index e6899209..44404911 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -22,7 +22,7 @@ export default tseslint.config([ rules: { '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], - 'react-hooks/rules-of-hooks': 'off', + 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'warn', 'no-useless-escape': 'off', 'no-case-declarations': 'off', diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 92427a70..e9629363 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,3 +1,11 @@ + # URLs and Referer can contain gallery, image and customer bearer tokens. + map $uri $request_surface { + ~*^/api/(?[a-z-]+)(?:/|$) /api/$picpeak_surface; + default /; + } + log_format picpeak_safe '$remote_addr "$http_x_forwarded_for" [$time_local] "$request_method $request_surface" ' + '$status $body_bytes_sent $request_time'; + # Honour the outer reverse proxy's X-Forwarded-Proto when present (e.g. NPM, # Traefik, Caddy in front of PicPeak). Falls back to nginx's own $scheme when # the header is absent (direct access / no outer proxy). Without this the @@ -10,6 +18,10 @@ map $http_x_forwarded_proto $real_proto { } server { + access_log /var/log/nginx/access.log picpeak_safe; + # Native request errors include bearer URLs even at crit/alert severity. + # Safe access logs retain status/timing; process diagnostics stay at main level. + error_log /dev/null; listen 80; server_name localhost; server_tokens off; diff --git a/frontend/nginx.dev.conf b/frontend/nginx.dev.conf index 7506f943..074b8faa 100644 --- a/frontend/nginx.dev.conf +++ b/frontend/nginx.dev.conf @@ -1,3 +1,11 @@ + # URLs and Referer can contain gallery, image and customer bearer tokens. + map $uri $request_surface { + ~*^/api/(?[a-z-]+)(?:/|$) /api/$picpeak_surface; + default /; + } + log_format picpeak_safe '$remote_addr "$http_x_forwarded_for" [$time_local] "$request_method $request_surface" ' + '$status $body_bytes_sent $request_time'; + # Honour outer reverse-proxy's X-Forwarded-Proto when present (see #547 / # frontend/nginx.conf for full rationale). map $http_x_forwarded_proto $real_proto { @@ -6,6 +14,10 @@ map $http_x_forwarded_proto $real_proto { } server { + access_log /var/log/nginx/access.log picpeak_safe; + # Native request errors include bearer URLs even at crit/alert severity. + # Safe access logs retain status/timing; process diagnostics stay at main level. + error_log /dev/null; listen 80; server_name localhost; root /usr/share/nginx/html; @@ -55,4 +67,4 @@ server { location / { try_files $uri $uri/ /index.html; } -} \ No newline at end of file +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cb7763c4..7fc73e0b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "picpeak-frontend", - "version": "3.122.6-beta.0", + "version": "3.130.1-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-frontend", - "version": "3.122.6-beta.0", + "version": "3.130.1-beta.0", "dependencies": { "@dagrejs/dagre": "^3.0.0", "@fullcalendar/core": "^6.1.20", @@ -15,14 +15,14 @@ "@fullcalendar/react": "^6.1.20", "@fullcalendar/timegrid": "^6.1.20", "@tanstack/react-query": "^5.0.0", - "@tiptap/extension-character-count": "^2.26.1", - "@tiptap/extension-code-block-lowlight": "^2.26.1", - "@tiptap/extension-hard-break": "^2.26.1", - "@tiptap/extension-link": "^2.25.0", - "@tiptap/extension-placeholder": "^2.26.1", - "@tiptap/extension-text-align": "^2.26.1", - "@tiptap/react": "^2.25.0", - "@tiptap/starter-kit": "^2.25.0", + "@tiptap/extension-character-count": "^3.31.3", + "@tiptap/extension-code-block-lowlight": "^3.31.3", + "@tiptap/extension-hard-break": "^3.31.3", + "@tiptap/extension-link": "^3.31.3", + "@tiptap/extension-placeholder": "^3.31.3", + "@tiptap/extension-text-align": "^3.31.3", + "@tiptap/react": "^3.31.3", + "@tiptap/starter-kit": "^3.31.3", "@types/dompurify": "^3.0.5", "@types/lodash": "^4.17.20", "@types/react-google-recaptcha": "^2.1.9", @@ -50,7 +50,7 @@ "react-image-gallery": "^1.2.11", "react-intersection-observer": "^9.4.3", "react-photo-album": "^3.4.0", - "react-router-dom": "^6.8.0", + "react-router-dom": "^7.18.3", "react-toastify": "11.0.5", "signature_pad": "^5.1.3", "swiper": "^12.1.0", @@ -159,7 +159,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -525,7 +524,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -549,7 +547,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1175,12 +1172,39 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT", + "optional": true + }, "node_modules/@fullcalendar/core": { "version": "6.1.20", "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", "integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==", "license": "MIT", - "peer": true, "dependencies": { "preact": "~10.12.1" } @@ -2181,31 +2205,6 @@ "node": ">= 8" } }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, - "node_modules/@remirror/core-constants": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", - "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", - "license": "MIT" - }, - "node_modules/@remix-run/router": { - "version": "1.23.4", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", - "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2924,6 +2923,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -2933,7 +2933,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", @@ -2998,406 +2999,422 @@ } }, "node_modules/@tiptap/core": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz", - "integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.3.tgz", + "integrity": "sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/pm": "^2.7.0" + "@tiptap/pm": "3.31.3" } }, "node_modules/@tiptap/extension-blockquote": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz", - "integrity": "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.3.tgz", + "integrity": "sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" } }, "node_modules/@tiptap/extension-bold": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz", - "integrity": "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.3.tgz", + "integrity": "sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "3.31.3" } }, "node_modules/@tiptap/extension-bubble-menu": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.2.tgz", - "integrity": "sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.31.3.tgz", + "integrity": "sha512-EV6ZnwKc++2OM/OcD54n8s1B7C9LP7GKAtdEPwu0t3BYJf3sG8Ueoinf7SgGPO9oMPg96GneOkDNm1urMV167g==", "license": "MIT", + "optional": true, "dependencies": { - "tippy.js": "^6.3.7" + "@floating-ui/dom": "^1.0.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" } }, "node_modules/@tiptap/extension-bullet-list": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz", - "integrity": "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.3.tgz", + "integrity": "sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/extension-list": "3.31.3" } }, "node_modules/@tiptap/extension-character-count": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-2.27.2.tgz", - "integrity": "sha512-EcQRIvbLbMDDzo7uFqXYgh1CfgedS9sYX4BllktY2OlXLPdNpwo9t8WMK/a7soESNv0Le3WZ5pNvnNhv7Z2YdA==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-3.31.3.tgz", + "integrity": "sha512-PVYEnEUqzjwSMfhHW+TzgoNB4UqYQQLKsY97CBsfuRswEfsqeViN3kMEuJEMMzN2fFnkB0hkRTpGlnMYcbd6Ag==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@tiptap/extensions": "3.31.3" } }, "node_modules/@tiptap/extension-code": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz", - "integrity": "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.3.tgz", + "integrity": "sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "3.31.3" } }, "node_modules/@tiptap/extension-code-block": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz", - "integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.3.tgz", + "integrity": "sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" } }, "node_modules/@tiptap/extension-code-block-lowlight": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.27.2.tgz", - "integrity": "sha512-v6NKStBbQ/XCc1NnCi3ObsL1DsxadSIBtUQNA/B+urkPgn5LEy72HAGlf0xwjRaNkAGSaTASLKmc84L5q5zlGQ==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-3.31.3.tgz", + "integrity": "sha512-DN21CYEL4bm01vyB/wiyDPKrpiyalI5okTWx90jRuTqRzbnkPlnng1Vdo9L220w3GXct5Exe6iI01E7F19bC5A==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/extension-code-block": "^2.7.0", - "@tiptap/pm": "^2.7.0", + "@tiptap/core": "3.31.3", + "@tiptap/extension-code-block": "3.31.3", + "@tiptap/pm": "3.31.3", "highlight.js": "^11", "lowlight": "^2 || ^3" } }, "node_modules/@tiptap/extension-document": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz", - "integrity": "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz", + "integrity": "sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "3.31.3" } }, "node_modules/@tiptap/extension-dropcursor": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz", - "integrity": "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.3.tgz", + "integrity": "sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@tiptap/extensions": "3.31.3" } }, "node_modules/@tiptap/extension-floating-menu": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.2.tgz", - "integrity": "sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.3.tgz", + "integrity": "sha512-rd4VJ9PGSP9Eop8ZTEwaLZcMzMXLuJKe3hUNf58rq+zANpwM+9fI+vB7g9MAc3eXwUNDxNDVACFIL2oeBqpQ3A==", "license": "MIT", - "dependencies": { - "tippy.js": "^6.3.7" - }, + "optional": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@floating-ui/dom": "^1.0.0", + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" } }, "node_modules/@tiptap/extension-gapcursor": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz", - "integrity": "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.3.tgz", + "integrity": "sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@tiptap/extensions": "3.31.3" } }, "node_modules/@tiptap/extension-hard-break": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz", - "integrity": "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.3.tgz", + "integrity": "sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "3.31.3" } }, "node_modules/@tiptap/extension-heading": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz", - "integrity": "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.3.tgz", + "integrity": "sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" - } - }, - "node_modules/@tiptap/extension-history": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz", - "integrity": "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@tiptap/core": "3.31.3" } }, "node_modules/@tiptap/extension-horizontal-rule": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz", - "integrity": "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.3.tgz", + "integrity": "sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" } }, "node_modules/@tiptap/extension-italic": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz", - "integrity": "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.3.tgz", + "integrity": "sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "3.31.3" } }, "node_modules/@tiptap/extension-link": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz", - "integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.3.tgz", + "integrity": "sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==", "license": "MIT", "dependencies": { - "linkifyjs": "^4.3.2" + "linkifyjs": "^4.3.3" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.3.tgz", + "integrity": "sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" } }, "node_modules/@tiptap/extension-list-item": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz", - "integrity": "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.3.tgz", + "integrity": "sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.3.tgz", + "integrity": "sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" } }, "node_modules/@tiptap/extension-ordered-list": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz", - "integrity": "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.3.tgz", + "integrity": "sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/extension-list": "3.31.3" } }, "node_modules/@tiptap/extension-paragraph": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz", - "integrity": "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.3.tgz", + "integrity": "sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "3.31.3" } }, "node_modules/@tiptap/extension-placeholder": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.27.2.tgz", - "integrity": "sha512-IjsgSVYJRjpAKmIoapU0E2R4E2FPY3kpvU7/1i7PUYisylqejSJxmtJPGYw0FOMQY9oxnEEvfZHMBA610tqKpg==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.31.3.tgz", + "integrity": "sha512-9jYtR8ELEw7GVaruyrm4oFkPcjig9Q+crc+dpmarhBNXUmxagCdlhVzNwCJ2WJRzvBAtx59sEYqNTU38Wx8S3A==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" + "@tiptap/extensions": "3.31.3" } }, "node_modules/@tiptap/extension-strike": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz", - "integrity": "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.3.tgz", + "integrity": "sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "3.31.3" } }, "node_modules/@tiptap/extension-text": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz", - "integrity": "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz", + "integrity": "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "3.31.3" } }, "node_modules/@tiptap/extension-text-align": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.27.2.tgz", - "integrity": "sha512-0Pyks6Hu+Q/+9+5/osoSv0SP6jIerdWMYbi13aaZLsJoj3lBj5WNaE11JtAwSFN5sx0IbqhDSlp1zkvRnzgZ8g==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.31.3.tgz", + "integrity": "sha512-26BJ7c28EyvHzJovNpuenKiIgfRI4KsT/OjBB00y58MyZ0kRXc6dwZVclw8y66clupKTLTWYCDUsy0P/xHj+cA==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "3.31.3" } }, - "node_modules/@tiptap/extension-text-style": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz", - "integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==", + "node_modules/@tiptap/extension-underline": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.3.tgz", + "integrity": "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^2.7.0" + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.3.tgz", + "integrity": "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" } }, "node_modules/@tiptap/pm": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz", - "integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.3.tgz", + "integrity": "sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==", "license": "MIT", - "peer": true, "dependencies": { - "prosemirror-changeset": "^2.3.0", - "prosemirror-collab": "^1.3.1", - "prosemirror-commands": "^1.6.2", - "prosemirror-dropcursor": "^1.8.1", - "prosemirror-gapcursor": "^1.3.2", - "prosemirror-history": "^1.4.1", - "prosemirror-inputrules": "^1.4.0", - "prosemirror-keymap": "^1.2.2", - "prosemirror-markdown": "^1.13.1", - "prosemirror-menu": "^1.2.4", - "prosemirror-model": "^1.23.0", - "prosemirror-schema-basic": "^1.2.3", - "prosemirror-schema-list": "^1.4.1", - "prosemirror-state": "^1.4.3", - "prosemirror-tables": "^1.6.4", - "prosemirror-trailing-node": "^3.0.0", - "prosemirror-transform": "^1.10.2", - "prosemirror-view": "^1.37.0" + "prosemirror-changeset": "^2.4.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.11", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.42.3" }, "funding": { "type": "github", @@ -3405,55 +3422,62 @@ } }, "node_modules/@tiptap/react": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.2.tgz", - "integrity": "sha512-0EAs8Cpkfbvben1PZ34JN2Nd79Dhioynm2jML27DBbf1VWPk+FFWFGTMLUT0bu+Np5iVxio8fqV9t0mc4D6thA==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.31.3.tgz", + "integrity": "sha512-QiwQqvaLFLm5EMFu5tg7nAgXJxCUiUTLD8EsK+TqVV5P4bqOoMOCM39khbhXTJyahCuYpiFWx5YOSDtC/JiPtg==", "license": "MIT", "dependencies": { - "@tiptap/extension-bubble-menu": "^2.27.2", - "@tiptap/extension-floating-menu": "^2.27.2", "@types/use-sync-external-store": "^0.0.6", - "fast-deep-equal": "^3", - "use-sync-external-store": "^1" + "fast-equals": "^5.3.3", + "use-sync-external-store": "^1.4.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, + "optionalDependencies": { + "@tiptap/extension-bubble-menu": "^3.31.3", + "@tiptap/extension-floating-menu": "^3.31.3" + }, "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0", + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/@tiptap/starter-kit": { - "version": "2.27.2", - "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz", - "integrity": "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==", + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.3.tgz", + "integrity": "sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==", "license": "MIT", "dependencies": { - "@tiptap/core": "^2.27.2", - "@tiptap/extension-blockquote": "^2.27.2", - "@tiptap/extension-bold": "^2.27.2", - "@tiptap/extension-bullet-list": "^2.27.2", - "@tiptap/extension-code": "^2.27.2", - "@tiptap/extension-code-block": "^2.27.2", - "@tiptap/extension-document": "^2.27.2", - "@tiptap/extension-dropcursor": "^2.27.2", - "@tiptap/extension-gapcursor": "^2.27.2", - "@tiptap/extension-hard-break": "^2.27.2", - "@tiptap/extension-heading": "^2.27.2", - "@tiptap/extension-history": "^2.27.2", - "@tiptap/extension-horizontal-rule": "^2.27.2", - "@tiptap/extension-italic": "^2.27.2", - "@tiptap/extension-list-item": "^2.27.2", - "@tiptap/extension-ordered-list": "^2.27.2", - "@tiptap/extension-paragraph": "^2.27.2", - "@tiptap/extension-strike": "^2.27.2", - "@tiptap/extension-text": "^2.27.2", - "@tiptap/extension-text-style": "^2.27.2", - "@tiptap/pm": "^2.27.2" + "@tiptap/core": "3.31.3", + "@tiptap/extension-blockquote": "3.31.3", + "@tiptap/extension-bold": "3.31.3", + "@tiptap/extension-bullet-list": "3.31.3", + "@tiptap/extension-code": "3.31.3", + "@tiptap/extension-code-block": "3.31.3", + "@tiptap/extension-document": "3.31.3", + "@tiptap/extension-dropcursor": "3.31.3", + "@tiptap/extension-gapcursor": "3.31.3", + "@tiptap/extension-hard-break": "3.31.3", + "@tiptap/extension-heading": "3.31.3", + "@tiptap/extension-horizontal-rule": "3.31.3", + "@tiptap/extension-italic": "3.31.3", + "@tiptap/extension-link": "3.31.3", + "@tiptap/extension-list": "3.31.3", + "@tiptap/extension-list-item": "3.31.3", + "@tiptap/extension-list-keymap": "3.31.3", + "@tiptap/extension-ordered-list": "3.31.3", + "@tiptap/extension-paragraph": "3.31.3", + "@tiptap/extension-strike": "3.31.3", + "@tiptap/extension-text": "3.31.3", + "@tiptap/extension-underline": "3.31.3", + "@tiptap/extensions": "3.31.3", + "@tiptap/pm": "3.31.3" }, "funding": { "type": "github", @@ -3465,7 +3489,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3611,41 +3636,18 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "license": "MIT" - }, "node_modules/@types/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==", "license": "MIT" }, - "node_modules/@types/markdown-it": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", - "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", - "license": "MIT", - "dependencies": { - "@types/linkify-it": "^5", - "@types/mdurl": "^2" - } - }, - "node_modules/@types/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "license": "MIT" - }, "node_modules/@types/node": { "version": "25.6.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz", "integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -3661,7 +3663,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -3671,9 +3672,7 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -3751,7 +3750,6 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -4160,7 +4158,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4211,6 +4208,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -4276,6 +4274,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -4455,7 +4454,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -4733,11 +4731,18 @@ "dev": true, "license": "MIT" }, - "node_modules/crelt": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", - "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", - "license": "MIT" + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } }, "node_modules/cross-env": { "version": "10.1.0", @@ -4885,7 +4890,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -5051,6 +5055,7 @@ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -5106,18 +5111,6 @@ "dev": true, "license": "ISC" }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -5226,6 +5219,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -5240,7 +5234,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5463,8 +5456,18 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.2.tgz", + "integrity": "sha512-Ywe6jodPTWOTL9/k0bV7gdfP8twKL5Y8I8CZ933fAY5gBekICZSUQTbyH6ut2NZCNyB05mSUwAuEqdEIaOOlDQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -6040,7 +6043,6 @@ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz", "integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=12.0.0" } @@ -6143,7 +6145,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -6307,7 +6308,6 @@ "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6646,7 +6646,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6862,29 +6861,10 @@ "dev": true, "license": "MIT" }, - "node_modules/linkify-it": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", - "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/markdown-it" - } - ], - "license": "MIT", - "dependencies": { - "uc.micro": "^2.0.0" - } - }, "node_modules/linkifyjs": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", - "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", "license": "MIT" }, "node_modules/locate-path": { @@ -6957,7 +6937,6 @@ "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-2.9.0.tgz", "integrity": "sha512-OpcaUTCLmHuVuBcyNckKfH5B0oA4JUavb/M/8n9iAvanJYNQkrVm4pvyX0SUaqkBG4dnWHKt7p50B3ngAG2Rfw==", "license": "MIT", - "peer": true, "dependencies": { "@types/hast": "^2.0.0", "fault": "^2.0.0", @@ -6993,6 +6972,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -7007,33 +6987,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/markdown-it": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", - "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/markdown-it" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1", - "entities": "^4.4.0", - "linkify-it": "^5.0.1", - "mdurl": "^2.0.0", - "punycode.js": "^2.3.1", - "uc.micro": "^2.1.0" - }, - "bin": { - "markdown-it": "bin/markdown-it.mjs" - } - }, "node_modules/marked": { "version": "15.0.12", "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", @@ -7055,12 +7008,6 @@ "node": ">= 0.4" } }, - "node_modules/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", - "license": "MIT" - }, "node_modules/memfs": { "version": "4.57.2", "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.2.tgz", @@ -7667,7 +7614,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", @@ -7837,6 +7783,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -7852,6 +7799,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -7864,7 +7812,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/pretty-ms": { "version": "9.3.0", @@ -7894,23 +7843,14 @@ } }, "node_modules/prosemirror-changeset": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz", - "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz", + "integrity": "sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w==", "license": "MIT", "dependencies": { "prosemirror-transform": "^1.0.0" } }, - "node_modules/prosemirror-collab": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", - "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", - "license": "MIT", - "dependencies": { - "prosemirror-state": "^1.0.0" - } - }, "node_modules/prosemirror-commands": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", @@ -7934,9 +7874,9 @@ } }, "node_modules/prosemirror-gapcursor": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz", - "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", "license": "MIT", "dependencies": { "prosemirror-keymap": "^1.0.0", @@ -7977,48 +7917,15 @@ "w3c-keyname": "^2.2.0" } }, - "node_modules/prosemirror-markdown": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz", - "integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==", - "license": "MIT", - "dependencies": { - "@types/markdown-it": "^14.0.0", - "markdown-it": "^14.0.0", - "prosemirror-model": "^1.25.0" - } - }, - "node_modules/prosemirror-menu": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz", - "integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==", - "license": "MIT", - "dependencies": { - "crelt": "^1.0.0", - "prosemirror-commands": "^1.0.0", - "prosemirror-history": "^1.0.0", - "prosemirror-state": "^1.0.0" - } - }, "node_modules/prosemirror-model": { - "version": "1.25.4", - "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", - "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } }, - "node_modules/prosemirror-schema-basic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", - "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", - "license": "MIT", - "dependencies": { - "prosemirror-model": "^1.25.0" - } - }, "node_modules/prosemirror-schema-list": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", @@ -8035,7 +7942,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -8043,50 +7949,34 @@ } }, "node_modules/prosemirror-tables": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.1.tgz", - "integrity": "sha512-DAgDoUYHCcc6tOGpLVPSU1k84kCUWTWnfWX3UDy2Delv4ryH0KqTD6RBI6k4yi9j9I8gl3j8MkPpRD/vWPZbug==", + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", "license": "MIT", "dependencies": { - "prosemirror-keymap": "^1.2.2", - "prosemirror-model": "^1.25.0", - "prosemirror-state": "^1.4.3", - "prosemirror-transform": "^1.10.3", - "prosemirror-view": "^1.39.1" - } - }, - "node_modules/prosemirror-trailing-node": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", - "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", - "license": "MIT", - "dependencies": { - "@remirror/core-constants": "3.0.0", - "escape-string-regexp": "^4.0.0" - }, - "peerDependencies": { - "prosemirror-model": "^1.22.1", - "prosemirror-state": "^1.4.2", - "prosemirror-view": "^1.33.8" + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" } }, "node_modules/prosemirror-transform": { - "version": "1.10.5", - "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.5.tgz", - "integrity": "sha512-RPDQCxIDhIBb1o36xxwsaeAvivO8VLJcgBtzmOwQ64bMtsVFh5SSuJ6dWSxO1UsHTiTXPCgQm3PDJt7p6IOLbw==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.1.tgz", + "integrity": "sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.21.0" } }, "node_modules/prosemirror-view": { - "version": "1.41.3", - "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.3.tgz", - "integrity": "sha512-SqMiYMUQNNBP9kfPhLO8WXEk/fon47vc52FQsUiJzTBuyjKgEcoAwMyF04eQ4WZ2ArMn7+ReypYL60aKngbACQ==", + "version": "1.42.3", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.3.tgz", + "integrity": "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==", "license": "MIT", - "peer": true, "dependencies": { - "prosemirror-model": "^1.20.0", + "prosemirror-model": "^1.25.8", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } @@ -8110,15 +8000,6 @@ "node": ">=6" } }, - "node_modules/punycode.js": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", - "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -8145,7 +8026,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -8184,7 +8064,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -8294,35 +8173,41 @@ } }, "node_modules/react-router": { - "version": "6.30.6", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", - "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.4" + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8" + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } } }, "node_modules/react-router-dom": { - "version": "6.30.6", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", - "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz", + "integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.4", - "react-router": "6.30.6" + "react-router": "7.18.3" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "react": ">=18", + "react-dom": ">=18" } }, "node_modules/react-toastify": { @@ -8588,6 +8473,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -8870,7 +8761,6 @@ "integrity": "sha512-6A2rnmW5xZMdw11LYjhcI5846rt9pbLSabY5XPxo+XWdxwZaFEn47Go4NzFiHu9sNNmr/kXivP1vStfvMaK1GQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -9004,15 +8894,6 @@ "node": ">=14.0.0" } }, - "node_modules/tippy.js": { - "version": "6.3.7", - "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", - "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", - "license": "MIT", - "dependencies": { - "@popperjs/core": "^2.9.0" - } - }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -9106,8 +8987,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/type-check": { "version": "0.4.0", @@ -9128,7 +9008,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9161,12 +9040,6 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/uc.micro": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", - "license": "MIT" - }, "node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", @@ -9250,7 +9123,6 @@ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", diff --git a/frontend/package.json b/frontend/package.json index 1c3a91c4..55075c96 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,14 +22,14 @@ "@fullcalendar/react": "^6.1.20", "@fullcalendar/timegrid": "^6.1.20", "@tanstack/react-query": "^5.0.0", - "@tiptap/extension-character-count": "^2.26.1", - "@tiptap/extension-code-block-lowlight": "^2.26.1", - "@tiptap/extension-hard-break": "^2.26.1", - "@tiptap/extension-link": "^2.25.0", - "@tiptap/extension-placeholder": "^2.26.1", - "@tiptap/extension-text-align": "^2.26.1", - "@tiptap/react": "^2.25.0", - "@tiptap/starter-kit": "^2.25.0", + "@tiptap/extension-character-count": "^3.31.3", + "@tiptap/extension-code-block-lowlight": "^3.31.3", + "@tiptap/extension-hard-break": "^3.31.3", + "@tiptap/extension-link": "^3.31.3", + "@tiptap/extension-placeholder": "^3.31.3", + "@tiptap/extension-text-align": "^3.31.3", + "@tiptap/react": "^3.31.3", + "@tiptap/starter-kit": "^3.31.3", "@types/dompurify": "^3.0.5", "@types/lodash": "^4.17.20", "@types/react-google-recaptcha": "^2.1.9", @@ -57,7 +57,7 @@ "react-image-gallery": "^1.2.11", "react-intersection-observer": "^9.4.3", "react-photo-album": "^3.4.0", - "react-router-dom": "^6.8.0", + "react-router-dom": "^7.18.3", "react-toastify": "11.0.5", "signature_pad": "^5.1.3", "swiper": "^12.1.0", diff --git a/frontend/src/components/admin/AdminPhotoViewer.tsx b/frontend/src/components/admin/AdminPhotoViewer.tsx index 4e8da529..3f2607f9 100644 --- a/frontend/src/components/admin/AdminPhotoViewer.tsx +++ b/frontend/src/components/admin/AdminPhotoViewer.tsx @@ -1,3 +1,4 @@ +import { usePhotoSelection } from '../../hooks/usePhotoSelection'; import React, { useState } from 'react'; import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, CheckCircle, XCircle, AlertCircle } from 'lucide-react'; import { toast } from 'react-toastify'; @@ -29,15 +30,21 @@ interface AdminPhotoViewerProps { categories: Array<{ id: number; name: string; slug: string }>; } -export const AdminPhotoViewer: React.FC = ({ - photos, - initialIndex, - eventId, - onClose, - onPhotoDeleted, - categories +export const AdminPhotoViewer: React.FC = (props) => { + const selection = usePhotoSelection(props.photos, props.initialIndex); + if (!selection.currentPhoto) return null; + return ; +}; + +type ViewerContentProps = AdminPhotoViewerProps & { + currentPhoto: AdminPhoto; + currentIndex: number; + setCurrentIndex: React.Dispatch>; +}; + +const AdminPhotoViewerContent: React.FC = ({ + photos, eventId, onClose, onPhotoDeleted, categories, currentPhoto, currentIndex, setCurrentIndex }) => { - const [currentIndex, setCurrentIndex] = useState(initialIndex); const [isDeleting, setIsDeleting] = useState(false); const { t } = useTranslation(); // The photographer's own triage mark (#1044 follow-up). Held locally and @@ -49,7 +56,6 @@ export const AdminPhotoViewer: React.FC = ({ const queryClient = useQueryClient(); const { formatDateTime: fmtDateTime } = useLocalizedDate(); - const currentPhoto = photos[currentIndex]; const isVideo = currentPhoto ? (currentPhoto.media_type === 'video' || (currentPhoto.mime_type && String(currentPhoto.mime_type).startsWith('video/')) || @@ -59,10 +65,6 @@ export const AdminPhotoViewer: React.FC = ({ const likeCount = currentPhoto?.like_count ?? 0; const favoriteCount = currentPhoto?.favorite_count ?? 0; - if (!currentPhoto) { - return null; - } - // Fetch feedback for current photo const { data: feedbackData } = useQuery({ queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id], @@ -231,7 +233,7 @@ export const AdminPhotoViewer: React.FC = ({ window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [currentIndex]); + }, [currentIndex, setCurrentIndex, photos.length, onClose]); return (
diff --git a/frontend/src/components/admin/CMSEditor.tsx b/frontend/src/components/admin/CMSEditor.tsx index e6dc5fe0..1eab36aa 100644 --- a/frontend/src/components/admin/CMSEditor.tsx +++ b/frontend/src/components/admin/CMSEditor.tsx @@ -52,6 +52,29 @@ interface CMSEditorProps { type ViewMode = 'edit' | 'preview' | 'split'; +const MenuButton: React.FC<{ + onClick: () => void; + active?: boolean; + children: React.ReactNode; + title: string; + disabled?: boolean; +}> = ({ onClick, active, children, title, disabled }) => ( + +); + export const CMSEditor: React.FC = ({ content, onChange, onSave, isSaving }) => { const { t } = useTranslation(); const [linkUrl, setLinkUrl] = useState(''); @@ -64,8 +87,13 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, const toolbarRef = React.useRef(null); const editor = useEditor({ + shouldRerenderOnTransaction: true, extensions: [ StarterKit.configure({ + link: false, + // v2 StarterKit had no TrailingNode; v3 would append an empty

to + // documents ending in a heading/list/code block and persist it. + trailingNode: false, hardBreak: false, // We'll use the separate HardBreak extension codeBlock: false, // We'll use CodeBlockLowlight instead }), @@ -148,7 +176,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, React.useEffect(() => { if (editor && content !== editor.getHTML()) { - editor.commands.setContent(content); + editor.commands.setContent(content, { emitUpdate: false }); } }, [content, editor]); @@ -164,27 +192,7 @@ export const CMSEditor: React.FC = ({ content, onChange, onSave, } }; - const MenuButton: React.FC<{ - onClick: () => void; - active?: boolean; - children: React.ReactNode; - title: string; - disabled?: boolean; - }> = ({ onClick, active, children, title, disabled }) => ( - - ); + const toggleFullscreen = () => { setIsFullscreen(!isFullscreen); diff --git a/frontend/src/components/admin/EmailTemplateEditor.tsx b/frontend/src/components/admin/EmailTemplateEditor.tsx index 45936628..fe2d6c1f 100644 --- a/frontend/src/components/admin/EmailTemplateEditor.tsx +++ b/frontend/src/components/admin/EmailTemplateEditor.tsx @@ -31,6 +31,29 @@ interface EmailTemplateEditorProps { variables?: string[]; } +const MenuButton: React.FC<{ + onClick: () => void; + active?: boolean; + children: React.ReactNode; + title: string; + disabled?: boolean; +}> = ({ onClick, active, children, title, disabled }) => ( + +); + export const EmailTemplateEditor: React.FC = ({ content, onChange, @@ -44,8 +67,13 @@ export const EmailTemplateEditor: React.FC = ({ const [showVariables, setShowVariables] = useState(false); const editor = useEditor({ + shouldRerenderOnTransaction: true, extensions: [ StarterKit.configure({ + link: false, + // v2 StarterKit had no TrailingNode; v3 would append an empty

to + // templates ending in a heading/list and persist it in the email HTML. + trailingNode: false, hardBreak: false, }), HardBreak.configure({ @@ -75,7 +103,7 @@ export const EmailTemplateEditor: React.FC = ({ // Sync editor when content prop changes externally React.useEffect(() => { if (editor && !isSourceMode && content !== editor.getHTML()) { - editor.commands.setContent(content); + editor.commands.setContent(content, { emitUpdate: false }); setSourceContent(content); } }, [content, editor, isSourceMode]); @@ -134,27 +162,7 @@ export const EmailTemplateEditor: React.FC = ({ return null; } - const MenuButton: React.FC<{ - onClick: () => void; - active?: boolean; - children: React.ReactNode; - title: string; - disabled?: boolean; - }> = ({ onClick, active, children, title, disabled }) => ( - - ); + return (

diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index b5273206..7ff719e5 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -1,4 +1,8 @@ -import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; +import { useGalleryFiltering, resolveMediaType } from './hooks/useGalleryFiltering'; +import { useGalleryUpload } from './hooks/useGalleryUpload'; +import { useGallerySelection } from './hooks/useGallerySelection'; +import { UploadProcessingNotice } from './UploadProcessingNotice'; +import React, { useState, useMemo, useEffect, useCallback } from 'react'; import { differenceInDays, parseISO } from 'date-fns'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -34,9 +38,7 @@ import { GuestIdentityProvider } from '../../contexts/GuestIdentityContext'; import type { FilterType, FeedbackFilterType } from './GalleryFilter'; import { analyticsService } from '../../services/analytics.service'; import { useDevToolsProtection } from '../../hooks/useDevToolsProtection'; -import { api } from '../../config/api'; -import { Upload, Menu, Eye, EyeOff, Shield, X, Download, ChevronLeft, Loader2 } from 'lucide-react'; -import { toast } from 'react-toastify'; +import { Upload, Menu, Eye, EyeOff, Shield, X, Download, ChevronLeft } from 'lucide-react'; import { galleryService } from '../../services/gallery.service'; import { feedbackService, type ColorLabel } from '../../services/feedback.service'; import { useWatermarkSettings } from '../../hooks/useWatermarkSettings'; @@ -122,8 +124,6 @@ export const GalleryView: React.FC = ({ slug, event, requiresP const [brandingSettings, setBrandingSettings] = useState(null); const [showUploadModal, setShowUploadModal] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false); - const [isSelectionMode, setIsSelectionMode] = useState(false); - const [selectedPhotos, setSelectedPhotos] = useState>(new Set()); const [feedbackEnabled, setFeedbackEnabled] = useState(false); const [isMobile, setIsMobile] = useState(window.innerWidth < 768); const { watermarkEnabled } = useWatermarkSettings(); @@ -188,19 +188,6 @@ export const GalleryView: React.FC = ({ slug, event, requiresP const [guestId, setGuestId] = useState(''); const [staticHeroPhoto, setStaticHeroPhoto] = useState(null); - const resolveMediaType = (photo: Photo) => { - if (photo.media_type === 'video' || photo.media_type === 'photo') { - return photo.media_type; - } - if (photo.mime_type && photo.mime_type.startsWith('video/')) { - return 'video'; - } - if ((photo as any).type === 'video') { - return 'video'; - } - return 'photo'; - }; - // Generate a unique guest ID for this session useEffect(() => { // Use existing guest ID from localStorage or generate new one @@ -215,6 +202,7 @@ export const GalleryView: React.FC = ({ slug, event, requiresP // Fetch photos WITHOUT filter (always get all photos, filter on frontend) // This ensures counts are always calculated from the full dataset const { data, isLoading, error, refetch } = useGalleryPhotos(slug, 'all', guestId); + const { isSelectionMode, setIsSelectionMode, selectedPhotos, setSelectedPhotos } = useGallerySelection(data?.photos); // Set protection level when data is available useEffect(() => { @@ -251,109 +239,9 @@ export const GalleryView: React.FC = ({ slug, event, requiresP return () => { timers.forEach(clearTimeout); clearInterval(interval); }; }, [hiddenUntilReveal, revealArmed, revealAtMs, refetch]); - // Post-upload refresh (P4-E.01). A guest upload is *queued*: the route - // answers 202 and the row lands as `processing_status: 'pending'`, while - // the photo list only returns completed rows. A single immediate refetch - // therefore comes back with a byte-identical payload (which the browser is - // answered with a 304), so the guest saw their upload silently vanish until - // they hard-reloaded. - // - // The first fix polled the photo list blind against a count baseline, which - // cannot tell a slow worker from a photo that failed processing — it just - // stopped after 60s with nothing on screen either way. Poll the upload - // group's real processing status instead (B7): it drives the "processing…" - // notice, refetches the grid as photos land rather than only at the end, and - // reports a failure instead of a silence. - const uploadRefreshTimerRef = useRef | null>(null); - const [uploadProcessing, setUploadProcessing] = useState<{ complete: number; total: number } | null>(null); - const stopUploadRefresh = () => { - if (uploadRefreshTimerRef.current) { - clearInterval(uploadRefreshTimerRef.current); - uploadRefreshTimerRef.current = null; - } - }; - useEffect(() => stopUploadRefresh, []); + const { uploadProcessing, handleUploadComplete } = useGalleryUpload(slug, refetch, () => setShowUploadModal(false)); - const handleUploadComplete = (uploadIds: string[] = []) => { - setShowUploadModal(false); - stopUploadRefresh(); - - // Nothing to follow (no id came back, e.g. every file failed on the wire). - // Refetch once rather than polling something unknowable. - if (uploadIds.length === 0) { - void refetch(); - return; - } - - setUploadProcessing({ complete: 0, total: uploadIds.length }); - const deadline = Date.now() + 120_000; - let lastComplete = 0; - let inFlight = false; - - const finish = async (announce?: () => void) => { - stopUploadRefresh(); - setUploadProcessing(null); - await refetch(); - announce?.(); - }; - - const poll = async () => { - // The interval keeps firing while a slow request is open; without this - // the requests stack up for the whole deadline. - if (inFlight) return; - inFlight = true; - try { - const status = await galleryService.getUploadStatus(slug, uploadIds); - setUploadProcessing({ - complete: status.complete + status.failed, - total: status.total || uploadIds.length, - }); - - // Refetch as each photo lands, not only once the batch settles, so a - // large upload fills the grid progressively. - if (status.complete > lastComplete) { - lastComplete = status.complete; - void refetch(); - } - - if (status.pending === 0 && status.processing === 0) { - await finish(() => { - if (status.failed > 0) { - toast.error(t('upload.processingFailed', { count: status.failed })); - } - }); - } else if (Date.now() > deadline) { - // Bounded. The worker is genuinely still running, so say that rather - // than leaving the guest with a grid that quietly never updated. - await finish(() => toast.info(t('upload.processingStillRunning'))); - } - } catch { - // The status signal is a convenience — the photos are stored either - // way — so a failing status call degrades to the plain refetch. - await finish(); - } finally { - inFlight = false; - } - }; - - uploadRefreshTimerRef.current = setInterval(poll, 2000); - void poll(); - }; - - // The two layout branches below that render the photo grid have no shared - // wrapper, so the notice is shared as a value rather than as markup. - const uploadProcessingNotice = uploadProcessing ? ( -
- - - {t('upload.processing')}{' '} - {t('upload.processingProgress', { - complete: uploadProcessing.complete, - total: uploadProcessing.total, - })} - -
- ) : null; + const uploadProcessingNotice = ; // Get individual protection settings from event const disableRightClick = data?.event?.disable_right_click === true; @@ -427,8 +315,7 @@ export const GalleryView: React.FC = ({ slug, event, requiresP queryFn: async () => { try { // Use public endpoint to get feedback settings - const response = await api.get(`/gallery/${slug}/feedback-settings`); - return response.data; + return await feedbackService.getGalleryFeedbackSettings(slug); } catch (error) { console.error('Error fetching feedback settings:', error); // If endpoint doesn't exist or returns error, default to disabled @@ -755,7 +642,7 @@ export const GalleryView: React.FC = ({ slug, event, requiresP setSelectedPersonIds([]); setPeopleMatchAny(false); window.scrollTo({ top: 0, behavior: 'smooth' }); - }, []); + }, [setSelectedPhotos]); // The address bar is the source of truth, so Back/Forward walk in and out of // folders instead of leaving the gallery. @@ -769,130 +656,14 @@ export const GalleryView: React.FC = ({ slug, event, requiresP }; window.addEventListener('popstate', onPop); return () => window.removeEventListener('popstate', onPop); - }, []); + }, [setSelectedPhotos]); - // Filter and sort photos - const filteredPhotos = useMemo(() => { - if (!data?.photos) return []; - - // Folder containment (#1160) comes FIRST: at root this drops every photo that - // lives in a folder, inside a folder it keeps only that folder's photos. - // Everything below narrows within that scope, so a search or a feedback chip - // never reaches across a folder boundary. - let photos = photosInScope(data.photos, data.categories, openFolder?.id ?? null); - - if (mediaFilter === 'photo') { - photos = photos.filter(photo => resolveMediaType(photo) !== 'video'); - } else if (mediaFilter === 'video') { - photos = photos.filter(photo => resolveMediaType(photo) === 'video'); - } - - // Apply category filter. Only meaningful at root — inside a folder every - // photo already shares the folder's category. - if (selectedCategoryId && !openFolder) { - photos = photos.filter(photo => photo.category_id === selectedCategoryId); - } - - // Apply search filter - if (searchTerm) { - const term = searchTerm.toLowerCase(); - photos = photos.filter(photo => - photo.filename.toLowerCase().includes(term) - ); - } - - // Apply feedback filters. Multi-select (#889): a photo matching ANY - // active filter passes (OR-combined); an empty set means no feedback - // filtering. In guest identity mode each filter has to scope to the - // *current guest's* interactions (#538 bug 1) — the aggregate counts - // on each photo row are global across all guests, which gave an empty - // grid when the guest had liked photos that nobody else had touched. - // Falls back to the aggregate-count check in simple/non-guest mode - // where there's no per-person identity to scope by. - if (activeFilters.length > 0) { - const matchers: Record boolean> = { - liked: (photo) => isGuestIdentityMode - ? myFeedbackPhotoIds.liked.has(photo.id) - : (photo.like_count || 0) > 0, - favorited: (photo) => isGuestIdentityMode - ? myFeedbackPhotoIds.favorited.has(photo.id) - : (photo.favorite_count || 0) > 0, - rated: (photo) => isGuestIdentityMode - ? myFeedbackPhotoIds.rated.has(photo.id) - : (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0, - commented: (photo) => isGuestIdentityMode - ? myFeedbackPhotoIds.commented.has(photo.id) - : (photo.comment_count || 0) > 0, - }; - photos = photos.filter(photo => activeFilters.some(filter => matchers[filter](photo))); - } - - // Apply people filter (#1074). Composes with every filter above rather - // than replacing them, so "photos of Anna that I liked" works. - // - // Two people selected means AND by default ("photos with both Anna and - // Ben") — that is what someone picking a second face is almost always - // asking for. `peopleMatchAny` flips it to OR for the couple-shots case. - if (selectedPersonIds.length > 0) { - photos = photos.filter(photo => { - const ids = photo.person_ids || []; - return peopleMatchAny - ? selectedPersonIds.some(id => ids.includes(id)) - : selectedPersonIds.every(id => ids.includes(id)); - }); - } - - // Apply colour-label filters (#1044). Guest-scoped by construction: - // `my_color_label` is the requesting viewer's own label, which is what a - // proofing client means by "show me my greens". Composes with (ANDs - // against) every filter above, like the people filter. - if (activeColorFilters.length > 0) { - photos = photos.filter(photo => - !!photo.my_color_label && activeColorFilters.includes(photo.my_color_label as ColorLabel) - ); - } - - // Apply sorting - // Each comparator defaults to its natural order (desc for dates/size/rating, asc for name). - // The flip multiplier reverses that when sortDesc differs from the natural order. - const flip = sortDesc ? 1 : -1; - photos.sort((a, b) => { - switch (sortBy) { - case 'name': - // Natural order is ascending (A-Z); flip when sortDesc=true - return (sortDesc ? -1 : 1) * a.filename.localeCompare(b.filename); - case 'size': - return flip * (b.size - a.size); - case 'rating': { - const ratingA = a.average_rating || 0; - const ratingB = b.average_rating || 0; - if (ratingA !== ratingB) { - return flip * (ratingB - ratingA); - } - return flip * ((b.comment_count || 0) - (a.comment_count || 0)); - } - case 'capture_date': { - const captureDateA = a.captured_at || a.uploaded_at; - const captureDateB = b.captured_at || b.uploaded_at; - return flip * (new Date(captureDateB).getTime() - new Date(captureDateA).getTime()); - } - case 'date': - default: - return flip * (new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime()); - } - }); - - // Transform full-size URLs for watermarks if enabled - // Note: Thumbnails are watermarked server-side at the thumbnail endpoint - if (watermarkEnabled) { - photos = photos.map(photo => ({ - ...photo, - url: `/api/gallery/${slug}/photo/${photo.id}` - })); - } - - return photos; - }, [data?.photos, data?.categories, openFolder, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, activeFilters, activeColorFilters, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds, selectedPersonIds, peopleMatchAny]); + const filteredPhotos = useGalleryFiltering({ + sourcePhotos: data?.photos, categories: data?.categories, folderId: openFolder?.id ?? null, + selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, + activeFilters, activeColorFilters, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds, + selectedPersonIds, peopleMatchAny, + }); // Counts shown in the filter chips ("Liked (N)", etc.). In guest // mode these need to mirror the per-guest filter behaviour above — diff --git a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx index b0ea6459..0e95b8b6 100644 --- a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx +++ b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx @@ -336,14 +336,6 @@ export const PhotoGridWithLayouts: React.FC = ({ // Gallery Premium and Gallery Story layouts have their own integrated hero/header const isFullPageLayout = galleryLayout === 'gallery-premium' || galleryLayout === 'gallery-story'; - // Folder-only root (#1160). The full-bleed layouts own the hero/logout chrome, - // so they are mounted even with an empty set. Every other layout is skipped - // instead: CarouselGalleryLayout returns before four of its useState calls, so - // driving one instance between empty and non-empty changes its hook count and - // React throws. Skipping only the child keeps this component's own HeroHeader - // and welcome message on screen. - const skipEmptyLayoutChild = photos.length === 0 && suppressEmptyState && !isFullPageLayout; - return ( <> {/* Hero Header - shown when headerStyle is 'hero' (skip for full-page layouts with integrated hero) */} @@ -432,7 +424,7 @@ export const PhotoGridWithLayouts: React.FC = ({ )} {/* Render the selected layout */} - {skipEmptyLayoutChild ? null : } + {/* Lightbox - skip for full-page layouts which have their own lightbox */} {selectedPhotoIndex !== null && !isFullPageLayout && ( diff --git a/frontend/src/components/gallery/UploadProcessingNotice.tsx b/frontend/src/components/gallery/UploadProcessingNotice.tsx new file mode 100644 index 00000000..829ca850 --- /dev/null +++ b/frontend/src/components/gallery/UploadProcessingNotice.tsx @@ -0,0 +1,17 @@ +import { Loader2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +export function UploadProcessingNotice({ processing }: { processing: { complete: number; total: number } | null }) { + const { t } = useTranslation(); + return processing ? ( +
+ + + {t('upload.processing')}{' '} + {t('upload.processingProgress', { + complete: processing.complete, + total: processing.total, + })} + +
+ ) : null; +} diff --git a/frontend/src/components/gallery/__tests__/CarouselLifecycle.test.tsx b/frontend/src/components/gallery/__tests__/CarouselLifecycle.test.tsx new file mode 100644 index 00000000..fc0e9fd4 --- /dev/null +++ b/frontend/src/components/gallery/__tests__/CarouselLifecycle.test.tsx @@ -0,0 +1,18 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { expect, it, vi } from 'vitest'; +import type { Photo } from '../../../types'; +vi.mock('../../../contexts/ThemeContext', () => ({ useTheme: () => ({ theme: { gallerySettings: { carouselShowThumbnails: false } } }) })); +vi.mock('../../common', () => ({ AuthenticatedImage: ({ alt }: { alt: string }) => {alt}, Button: ({ children, ...props }: any) => })); +vi.mock('../../../contexts/GuestIdentityContext', () => ({ useGuestIdentityOptional: () => null })); +import { CarouselGalleryLayout } from '../layouts/CarouselGalleryLayout'; +const photo = (id: number) => ({ id, filename: `photo-${id}`, url: '/photo' } as Photo); +it('keeps the carousel usable when a refetch empties, reorders or removes photos', () => { + const props = { slug: 'g', onPhotoClick: vi.fn(), onDownload: vi.fn(), photos: [] as Photo[] }; + const { rerender } = render(); + rerender(); + fireEvent.click(screen.getByLabelText('Next photo')); expect(screen.getByAltText('photo-2')).toBeTruthy(); + rerender(); expect(screen.getByAltText('photo-2')).toBeTruthy(); + rerender(); expect(screen.getByAltText('photo-1')).toBeTruthy(); + rerender(); + rerender(); expect(screen.getByAltText('photo-3')).toBeTruthy(); +}); diff --git a/frontend/src/components/gallery/__tests__/galleryUploadRefresh.test.ts b/frontend/src/components/gallery/__tests__/galleryUploadRefresh.test.ts index d975c8b6..ec2bab12 100644 --- a/frontend/src/components/gallery/__tests__/galleryUploadRefresh.test.ts +++ b/frontend/src/components/gallery/__tests__/galleryUploadRefresh.test.ts @@ -1,95 +1,92 @@ -/** - * A guest upload must show up in the grid on its own — and say so while it is - * still being worked on. - * - * Guest uploads are queued: `POST /gallery/:id/upload` answers 202 and the row - * lands as `processing_status: 'pending'`, while `GET /gallery/:slug/photos` - * only returns completed rows. The old handler refetched exactly once (via a - * full `window.location.reload()`), which always raced the background worker — - * the payload was still byte-identical, the browser was answered 304, and the - * guest's photo silently vanished until they hard-reloaded (QA P4-E.01). - * - * The follow-up (B7) replaced the blind count-baseline poll with one driven by - * the real processing status of the guest's own upload group, so the UI can - * show "processing…" and report a failure instead of timing out in silence. - * - * GalleryView needs its providers, the router and a dozen child components to - * render, so this pins the contract at source level (same approach as - * facePreviewRendition.test.ts). - */ -import { describe, it, expect } from 'vitest'; -import fs from 'fs'; -import path from 'path'; +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { galleryService } from '../../../services/gallery.service'; +import { toast } from 'react-toastify'; +import { useGalleryUpload } from '../hooks/useGalleryUpload'; -const read = (...parts: string[]) => - fs.readFileSync(path.join(__dirname, '..', ...parts), 'utf8'); +vi.mock('../../../services/gallery.service', () => ({ galleryService: { getUploadStatus: vi.fn() } })); +vi.mock('react-toastify', () => ({ toast: { error: vi.fn(), info: vi.fn() } })); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +const pending = { total: 2, pending: 1, processing: 1, complete: 0, failed: 0 }; +const status = vi.mocked(galleryService.getUploadStatus); +beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); status.mockReset().mockResolvedValue(pending); }); +afterEach(() => { vi.clearAllTimers(); vi.useRealTimers(); }); +function setup(slug = 'wedding') { + const refetch = vi.fn().mockResolvedValue(undefined); + const close = vi.fn(); + return { ...renderHook(({ slug }) => useGalleryUpload(slug, refetch, close), { initialProps: { slug } }), refetch, close }; +} +async function tick(ms = 2000) { await act(async () => { await vi.advanceTimersByTimeAsync(ms); }); } -const source = read('GalleryView.tsx'); -const uploadSource = read('UserPhotoUpload.tsx'); -const serviceSource = fs.readFileSync( - path.join(__dirname, '..', '..', '..', 'services', 'gallery.service.ts'), - 'utf8' -); - -const handler = source.slice( - source.indexOf('const handleUploadComplete'), - source.indexOf('const uploadProcessingNotice') -); - -describe('post-upload photo refresh', () => { - it('never reloads the page to pick up an upload', () => { - expect(source).not.toContain('window.location.reload'); +describe('post-upload refresh', () => { + it('tracks uploads, refreshes progressively and stops after completion', async () => { + const { result, refetch, close, unmount } = setup(); + await act(async () => { result.current.handleUploadComplete(['one', 'two']); }); + expect(close).toHaveBeenCalledOnce(); + expect(status).toHaveBeenLastCalledWith('wedding', ['one', 'two']); + expect(result.current.uploadProcessing).toEqual({ complete: 0, total: 2 }); + status.mockResolvedValue({ ...pending, pending: 0, complete: 1 }); + await tick(); + expect(refetch).toHaveBeenCalledOnce(); + expect(result.current.uploadProcessing).toEqual({ complete: 1, total: 2 }); + status.mockResolvedValue({ ...pending, pending: 0, processing: 0, complete: 2 }); + await tick(); + expect(result.current.uploadProcessing).toBeNull(); + const calls = status.mock.calls.length; + await tick(10_000); + expect(status).toHaveBeenCalledTimes(calls); + expect(toast.error).not.toHaveBeenCalled(); + unmount(); }); - - it('drives the refresh off the upload group\'s processing status', () => { - expect(handler).toContain('galleryService.getUploadStatus(slug, uploadIds)'); - // Refetch as photos land, not only once the whole batch settles. - expect(handler).toContain('status.complete > lastComplete'); - expect(handler).toMatch(/setInterval\(poll/); + it('reports failed processing after the batch settles', async () => { + status.mockResolvedValue({ ...pending, pending: 0, processing: 0, failed: 2 }); + const { result, refetch, unmount } = setup(); + await act(async () => { result.current.handleUploadComplete(['one', 'two']); }); + expect(toast.error).toHaveBeenCalledWith('upload.processingFailed'); + expect(result.current.uploadProcessing).toBeNull(); + expect(refetch).toHaveBeenCalledOnce(); + unmount(); }); - - it('stops on the real terminal condition rather than a count baseline', () => { - expect(handler).toContain('status.pending === 0 && status.processing === 0'); - // Still bounded, so a wedged worker can never leave a poll running forever. - expect(handler).toContain('Date.now() > deadline'); + it('bounds a pending batch and announces ongoing processing', async () => { + const { result, refetch, unmount } = setup(); + await act(async () => { result.current.handleUploadComplete(['one', 'two']); }); + await tick(122_000); + expect(result.current.uploadProcessing).toBeNull(); + expect(toast.info).toHaveBeenCalledWith('upload.processingStillRunning'); + expect(refetch).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + unmount(); }); - - it('tells the guest when a photo failed processing or is still queued', () => { - expect(handler).toContain("toast.error(t('upload.processingFailed'"); - expect(handler).toContain("toast.info(t('upload.processingStillRunning')"); - // ...and renders a "processing…" notice while the poll runs. - expect(source).toContain("t('upload.processing')"); - expect(source).toContain("t('upload.processingProgress'"); + it('falls back to one refresh if status cannot be read', async () => { + status.mockRejectedValue(new Error('offline')); + const { result, refetch, unmount } = setup(); + await act(async () => { result.current.handleUploadComplete(['one']); }); + expect(refetch).toHaveBeenCalledOnce(); + expect(result.current.uploadProcessing).toBeNull(); + expect(vi.getTimerCount()).toBe(0); + unmount(); }); - - it('degrades to a plain refetch when the status call itself fails', () => { - expect(handler).toContain('} catch {'); - expect(handler).toContain('await finish();'); + it('refreshes once without polling when there are no accepted uploads', async () => { + const { result, refetch, unmount } = setup(); + await act(async () => { result.current.handleUploadComplete([]); }); + expect(refetch).toHaveBeenCalledOnce(); + expect(status).not.toHaveBeenCalled(); + unmount(); }); - - it('wires the polling handler into the upload modals that render the grid', () => { - const wired = source.match(/onUploadComplete=\{handleUploadComplete\}/g) || []; - expect(wired.length).toBeGreaterThanOrEqual(2); - // The notice is rendered next to each of them; the two layout branches - // have no shared wrapper to hang it on. - const shown = source.match(/\{uploadProcessingNotice\}/g) || []; - expect(shown.length).toBe(wired.length); - }); - - it('clears the poll when the gallery unmounts', () => { - expect(source).toContain('useEffect(() => stopUploadRefresh, [])'); - }); -}); - -describe('upload id plumbing', () => { - it('hands the 202 upload ids to the gallery', () => { - expect(uploadSource).toContain('onUploadComplete: (uploadIds: string[]) => void'); - expect(uploadSource).toContain('uploadIds.push(response.data.upload_id)'); - expect(uploadSource).toContain('onUploadComplete(uploadIds)'); - }); - - it('asks the gallery-scoped status route, batching the ids into one request', () => { - expect(serviceSource).toContain('`/gallery/${slug}/uploads/status`'); - expect(serviceSource).toContain("params: { ids: uploadIds.join(',') }"); + it.each(['unmount', 'navigate', 'new batch'] as const)('ignores an old request after %s', async (action) => { + let resolve!: (value: typeof pending) => void; + status.mockReturnValueOnce(new Promise(done => { resolve = done; })); + const { result, refetch, rerender, unmount } = setup(); + await act(async () => { result.current.handleUploadComplete(['old']); }); + await tick(6000); + expect(status).toHaveBeenCalledOnce(); + if (action === 'unmount') unmount(); + else if (action === 'navigate') rerender({ slug: 'another' }); + else await act(async () => { result.current.handleUploadComplete(['new']); }); + await act(async () => { resolve({ ...pending, pending: 0, processing: 0, complete: 1, failed: 1 }); }); + expect(refetch).not.toHaveBeenCalled(); + expect(toast.error).not.toHaveBeenCalled(); + if (action !== 'unmount') unmount(); + expect(vi.getTimerCount()).toBe(0); }); }); diff --git a/frontend/src/components/gallery/hooks/useGalleryFiltering.ts b/frontend/src/components/gallery/hooks/useGalleryFiltering.ts new file mode 100644 index 00000000..272034d1 --- /dev/null +++ b/frontend/src/components/gallery/hooks/useGalleryFiltering.ts @@ -0,0 +1,138 @@ +import { useMemo } from 'react'; +import type { Photo, PhotoCategory } from '../../../types'; +import type { ColorLabel } from '../../../services/feedback.service'; +import type { FeedbackFilterType } from '../GalleryFilter'; +import { photosInScope } from '../folders'; +export type GallerySort = 'date' | 'name' | 'size' | 'rating' | 'capture_date'; +export interface GalleryFilterOptions { + sourcePhotos?: Photo[]; categories?: PhotoCategory[]; folderId: number | string | null; + selectedCategoryId: number | string | null; searchTerm: string; sortBy: GallerySort; sortDesc: boolean; + watermarkEnabled: boolean; slug: string; activeFilters: FeedbackFilterType[]; activeColorFilters: ColorLabel[]; + mediaFilter: 'all' | 'photo' | 'video'; isGuestIdentityMode: boolean; + myFeedbackPhotoIds: Record>; selectedPersonIds: number[]; peopleMatchAny: boolean; +} +export const resolveMediaType = (photo: Photo): 'photo' | 'video' => + photo.media_type === 'video' || photo.mime_type?.startsWith('video/') || photo.type === 'video' ? 'video' : 'photo'; +export function useGalleryFiltering({ sourcePhotos, categories, folderId, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, activeFilters, activeColorFilters, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds, selectedPersonIds, peopleMatchAny }: GalleryFilterOptions) { + return useMemo(() => { + if (!sourcePhotos) return []; + + // Folder containment (#1160) comes FIRST: at root this drops every photo that + // lives in a folder, inside a folder it keeps only that folder's photos. + // Everything below narrows within that scope, so a search or a feedback chip + // never reaches across a folder boundary. + let photos = photosInScope(sourcePhotos, categories, folderId); + + if (mediaFilter === 'photo') { + photos = photos.filter(photo => resolveMediaType(photo) !== 'video'); + } else if (mediaFilter === 'video') { + photos = photos.filter(photo => resolveMediaType(photo) === 'video'); + } + + // Apply category filter. Only meaningful at root — inside a folder every + // photo already shares the folder's category. + if (selectedCategoryId && !folderId) { + photos = photos.filter(photo => photo.category_id === selectedCategoryId); + } + + // Apply search filter + if (searchTerm) { + const term = searchTerm.toLowerCase(); + photos = photos.filter(photo => + photo.filename.toLowerCase().includes(term) + ); + } + + // Apply feedback filters. Multi-select (#889): a photo matching ANY + // active filter passes (OR-combined); an empty set means no feedback + // filtering. In guest identity mode each filter has to scope to the + // *current guest's* interactions (#538 bug 1) — the aggregate counts + // on each photo row are global across all guests, which gave an empty + // grid when the guest had liked photos that nobody else had touched. + // Falls back to the aggregate-count check in simple/non-guest mode + // where there's no per-person identity to scope by. + if (activeFilters.length > 0) { + const matchers: Record boolean> = { + liked: (photo) => isGuestIdentityMode + ? myFeedbackPhotoIds.liked.has(photo.id) + : (photo.like_count || 0) > 0, + favorited: (photo) => isGuestIdentityMode + ? myFeedbackPhotoIds.favorited.has(photo.id) + : (photo.favorite_count || 0) > 0, + rated: (photo) => isGuestIdentityMode + ? myFeedbackPhotoIds.rated.has(photo.id) + : (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0, + commented: (photo) => isGuestIdentityMode + ? myFeedbackPhotoIds.commented.has(photo.id) + : (photo.comment_count || 0) > 0, + }; + photos = photos.filter(photo => activeFilters.some(filter => matchers[filter](photo))); + } + + // Apply people filter (#1074). Composes with every filter above rather + // than replacing them, so "photos of Anna that I liked" works. + // + // Two people selected means AND by default ("photos with both Anna and + // Ben") — that is what someone picking a second face is almost always + // asking for. `peopleMatchAny` flips it to OR for the couple-shots case. + if (selectedPersonIds.length > 0) { + photos = photos.filter(photo => { + const ids = photo.person_ids || []; + return peopleMatchAny + ? selectedPersonIds.some(id => ids.includes(id)) + : selectedPersonIds.every(id => ids.includes(id)); + }); + } + + // Apply colour-label filters (#1044). Guest-scoped by construction: + // `my_color_label` is the requesting viewer's own label, which is what a + // proofing client means by "show me my greens". Composes with (ANDs + // against) every filter above, like the people filter. + if (activeColorFilters.length > 0) { + photos = photos.filter(photo => + !!photo.my_color_label && activeColorFilters.includes(photo.my_color_label as ColorLabel) + ); + } + + // Apply sorting + // Each comparator defaults to its natural order (desc for dates/size/rating, asc for name). + // The flip multiplier reverses that when sortDesc differs from the natural order. + const flip = sortDesc ? 1 : -1; + photos.sort((a, b) => { + switch (sortBy) { + case 'name': + // Natural order is ascending (A-Z); flip when sortDesc=true + return (sortDesc ? -1 : 1) * a.filename.localeCompare(b.filename); + case 'size': + return flip * (b.size - a.size); + case 'rating': { + const ratingA = a.average_rating || 0; + const ratingB = b.average_rating || 0; + if (ratingA !== ratingB) { + return flip * (ratingB - ratingA); + } + return flip * ((b.comment_count || 0) - (a.comment_count || 0)); + } + case 'capture_date': { + const captureDateA = a.captured_at || a.uploaded_at; + const captureDateB = b.captured_at || b.uploaded_at; + return flip * (new Date(captureDateB).getTime() - new Date(captureDateA).getTime()); + } + case 'date': + default: + return flip * (new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime()); + } + }); + + // Transform full-size URLs for watermarks if enabled + // Note: Thumbnails are watermarked server-side at the thumbnail endpoint + if (watermarkEnabled) { + photos = photos.map(photo => ({ + ...photo, + url: `/api/gallery/${slug}/photo/${photo.id}` + })); + } + + return photos; + }, [sourcePhotos, categories, folderId, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, activeFilters, activeColorFilters, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds, selectedPersonIds, peopleMatchAny]); +} diff --git a/frontend/src/components/gallery/hooks/useGallerySelection.ts b/frontend/src/components/gallery/hooks/useGallerySelection.ts new file mode 100644 index 00000000..28791c10 --- /dev/null +++ b/frontend/src/components/gallery/hooks/useGallerySelection.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from 'react'; +import type { Photo } from '../../../types'; +/** Selections survive a refetch but never retain deleted/restricted photo IDs. */ +export function useGallerySelection(photos?: Photo[]) { + const [isSelectionMode, setIsSelectionMode] = useState(false); + const [selectedPhotos, setSelectedPhotos] = useState>(new Set()); + useEffect(() => { + if (!photos) return; + const ids = new Set(photos.map(photo => photo.id)); + setSelectedPhotos(previous => { + const next = new Set([...previous].filter(id => ids.has(id))); + return next.size === previous.size ? previous : next; + }); + }, [photos]); + return { isSelectionMode, setIsSelectionMode, selectedPhotos, setSelectedPhotos }; +} diff --git a/frontend/src/components/gallery/hooks/useGalleryUpload.ts b/frontend/src/components/gallery/hooks/useGalleryUpload.ts new file mode 100644 index 00000000..5a7c8445 --- /dev/null +++ b/frontend/src/components/gallery/hooks/useGalleryUpload.ts @@ -0,0 +1,91 @@ +import { useState, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { galleryService } from '../../../services/gallery.service'; +export function useGalleryUpload(slug: string, refetch: (options?: { cancelRefetch?: boolean }) => Promise, onClose: () => void) { + const { t } = useTranslation(); + const uploadRefreshTimerRef = useRef | null>(null); + const [uploadProcessing, setUploadProcessing] = useState<{ complete: number; total: number } | null>(null); + const generation = useRef(0); + const stopUploadRefresh = () => { + generation.current++; + if (uploadRefreshTimerRef.current) { + clearInterval(uploadRefreshTimerRef.current); + uploadRefreshTimerRef.current = null; + } + }; + useEffect(() => stopUploadRefresh, [slug]); + + const handleUploadComplete = (uploadIds: string[] = []) => { + onClose(); + stopUploadRefresh(); + + // Nothing to follow (no id came back, e.g. every file failed on the wire). + // Refetch once rather than polling something unknowable. + if (uploadIds.length === 0) { + void refetch(); + return; + } + + setUploadProcessing({ complete: 0, total: uploadIds.length }); + const batch = generation.current; + const deadline = Date.now() + 120_000; + let lastComplete = 0; + let inFlight = false; + + const finish = async (announce?: () => void) => { + stopUploadRefresh(); + setUploadProcessing(null); + await refetch(); + if (generation.current === batch + 1) announce?.(); + }; + + const poll = async () => { + // The interval keeps firing while a slow request is open; without this + // the requests stack up for the whole deadline. + if (inFlight) return; + inFlight = true; + try { + const status = await galleryService.getUploadStatus(slug, uploadIds); + if (batch !== generation.current) return; + setUploadProcessing({ + complete: status.complete + status.failed, + total: status.total || uploadIds.length, + }); + + // Refetch as each photo lands, not only once the batch settles, so a + // large upload fills the grid progressively. + if (status.complete > lastComplete) { + lastComplete = status.complete; + // Default cancelRefetch aborts the multi-page fetch still in flight + // from the previous poll, so a large gallery would never fill in. + void refetch({ cancelRefetch: false }); + } + + if (status.pending === 0 && status.processing === 0) { + await finish(() => { + if (status.failed > 0) { + toast.error(t('upload.processingFailed', { count: status.failed })); + } + }); + } else if (Date.now() > deadline) { + // Bounded. The worker is genuinely still running, so say that rather + // than leaving the guest with a grid that quietly never updated. + await finish(() => toast.info(t('upload.processingStillRunning'))); + } + } catch { + if (batch !== generation.current) return; + // The status signal is a convenience — the photos are stored either + // way — so a failing status call degrades to the plain refetch. + await finish(); + } finally { + inFlight = false; + } + }; + + uploadRefreshTimerRef.current = setInterval(poll, 2000); + void poll(); + }; + + return { uploadProcessing, handleUploadComplete }; +} diff --git a/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx b/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx index d0da929e..4c7c8595 100644 --- a/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/CarouselGalleryLayout.tsx @@ -1,3 +1,4 @@ +import { usePhotoSelection } from '../../../hooks/usePhotoSelection'; import React, { useState, useEffect, useRef } from 'react'; import { ChevronLeft, ChevronRight, Download, Maximize2, Play, Pause, Heart, MessageSquare } from 'lucide-react'; import { useTheme } from '../../../contexts/ThemeContext'; @@ -19,7 +20,7 @@ export const CarouselGalleryLayout: React.FC = ({ feedbackOptions }) => { const { theme } = useTheme(); - const [currentIndex, setCurrentIndex] = useState(0); + const { currentPhoto, currentIndex, setCurrentIndex } = usePhotoSelection(photos); const [isPlaying, setIsPlaying] = useState(false); const intervalRef = useRef | null>(null); @@ -41,7 +42,7 @@ export const CarouselGalleryLayout: React.FC = ({ return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; - }, [isPlaying, photos.length, interval]); + }, [isPlaying, photos.length, interval, setCurrentIndex]); // Start autoplay if enabled useEffect(() => { @@ -62,9 +63,6 @@ export const CarouselGalleryLayout: React.FC = ({ setIsPlaying(!isPlaying); }; - if (photos.length === 0) return null; - - const currentPhoto = photos[currentIndex]; const [showIdentityModal, setShowIdentityModal] = useState(false); const [pendingAction, setPendingAction] = useState(null); const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); @@ -80,6 +78,8 @@ export const CarouselGalleryLayout: React.FC = ({ }, [photos]); const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback); + if (!currentPhoto) return null; + return (
{/* Main Carousel */} diff --git a/frontend/src/hooks/__tests__/usePhotoSelection.test.tsx b/frontend/src/hooks/__tests__/usePhotoSelection.test.tsx new file mode 100644 index 00000000..0857755e --- /dev/null +++ b/frontend/src/hooks/__tests__/usePhotoSelection.test.tsx @@ -0,0 +1,15 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { usePhotoSelection } from '../usePhotoSelection'; +describe('photo selection through changing query results', () => { + it('handles empty → populated → reordered → removed → empty without changing hook order', () => { + const { result, rerender } = renderHook(({ photos }) => usePhotoSelection(photos, 1), { initialProps: { photos: [] as { id: number }[] } }); + expect(result.current.currentPhoto).toBeUndefined(); + rerender({ photos: [{ id: 1 }, { id: 2 }, { id: 3 }] }); expect(result.current.currentPhoto?.id).toBe(2); + rerender({ photos: [{ id: 3 }, { id: 2 }, { id: 1 }] }); expect(result.current.currentPhoto?.id).toBe(2); + act(() => result.current.setCurrentIndex(2)); expect(result.current.currentPhoto?.id).toBe(1); + rerender({ photos: [{ id: 3 }, { id: 2 }] }); expect(result.current.currentPhoto?.id).toBe(2); + rerender({ photos: [] }); expect(result.current.currentPhoto).toBeUndefined(); + rerender({ photos: [{ id: 2 }, { id: 4 }] }); expect(result.current.currentPhoto?.id).toBe(2); + }); +}); diff --git a/frontend/src/hooks/useGallery.ts b/frontend/src/hooks/useGallery.ts index 1e163ffc..6fd43cb7 100644 --- a/frontend/src/hooks/useGallery.ts +++ b/frontend/src/hooks/useGallery.ts @@ -26,7 +26,7 @@ export const useGalleryPhotos = ( return useQuery({ queryKey: ['gallery-photos', slug, filter, guestId], // Pass guestId so backend can filter per-guest views when needed - queryFn: () => galleryService.getGalleryPhotos(slug, filter, guestId), + queryFn: ({ signal }) => galleryService.getGalleryPhotos(slug, filter, guestId, signal), enabled, retry: 1, staleTime: 5 * 60 * 1000, // 5 minutes diff --git a/frontend/src/hooks/usePhotoSelection.ts b/frontend/src/hooks/usePhotoSelection.ts new file mode 100644 index 00000000..d3ef40aa --- /dev/null +++ b/frontend/src/hooks/usePhotoSelection.ts @@ -0,0 +1,26 @@ +import { useCallback, useEffect, useState, type SetStateAction } from 'react'; + +/** Keep the selected photo through reordering/refetches; clamp after removal. */ +export function usePhotoSelection(photos: T[], initialIndex = 0) { + const [selection, setSelection] = useState(() => ({ id: photos[initialIndex]?.id, index: initialIndex })); + const found = photos.findIndex(photo => photo.id === selection.id); + const currentIndex = found >= 0 ? found : Math.max(0, Math.min(selection.index, photos.length - 1)); + const currentPhoto = photos[currentIndex]; + + useEffect(() => { + if (currentPhoto && (currentPhoto.id !== selection.id || currentIndex !== selection.index)) { + setSelection({ id: currentPhoto.id, index: currentIndex }); + } + }, [currentPhoto, currentIndex, selection.id, selection.index]); + + const setCurrentIndex = useCallback((next: SetStateAction) => { + setSelection(previous => { + const previousIndex = photos.findIndex(photo => photo.id === previous.id); + const index = previousIndex >= 0 ? previousIndex : Math.max(0, Math.min(previous.index, photos.length - 1)); + const requested = typeof next === 'function' ? next(index) : next; + const clamped = Math.max(0, Math.min(requested, photos.length - 1)); + return { id: photos[clamped]?.id, index: clamped }; + }); + }, [photos]); + return { currentPhoto, currentIndex, setCurrentIndex }; +} diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 93ce7a68..83f338f4 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -433,7 +433,6 @@ export const EventDetailsPage: React.FC = () => { disable_right_click: event.disable_right_click ?? true, allow_downloads: event.allow_downloads ?? true, watermark_downloads: event.watermark_downloads ?? false, - allow_presigned_download: (event as { allow_presigned_download?: boolean }).allow_presigned_download ?? false, enable_devtools_protection: event.enable_devtools_protection ?? true, use_canvas_rendering: event.use_canvas_rendering ?? false, // Load hero logo settings from event. Preserve null = "inherit global" @@ -574,7 +573,6 @@ export const EventDetailsPage: React.FC = () => { disable_right_click: editForm.disable_right_click, allow_downloads: editForm.allow_downloads, watermark_downloads: editForm.watermark_downloads, - allow_presigned_download: editForm.allow_presigned_download, enable_devtools_protection: editForm.enable_devtools_protection, use_canvas_rendering: editForm.use_canvas_rendering, // Hero logo settings diff --git a/frontend/src/pages/admin/event-details/EventInformationCard.tsx b/frontend/src/pages/admin/event-details/EventInformationCard.tsx index 56a47650..bac2f5bc 100644 --- a/frontend/src/pages/admin/event-details/EventInformationCard.tsx +++ b/frontend/src/pages/admin/event-details/EventInformationCard.tsx @@ -647,10 +647,6 @@ export const EventInformationCard: React.FC = ({ onChange={(e) => setEditForm(prev => ({ ...prev, watermark_downloads: e.target.checked, - // Watermarking and presigned URLs are mutually - // exclusive — presigned URLs serve raw bytes from - // S3 without going through the watermark pipeline. - allow_presigned_download: e.target.checked ? false : prev.allow_presigned_download, }))} className="w-4 h-4 text-accent border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500" /> @@ -658,25 +654,7 @@ export const EventInformationCard: React.FC = ({ {t('events.watermarkDownloads', 'Add watermark to downloads')} - +