From 2e495d7c489c3195c6e1c042ec4ac35fe90cf4ba Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Sun, 9 Aug 2026 14:40:03 +0300 Subject: [PATCH] =?UTF-8?q?feat(transfers):=20add=20PicTransfer=20?= =?UTF-8?q?=E2=80=94=20cross-event=20file=20transfers=20(#998)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #997. Send original files from any event as a token-protected download link, with an optional client-upload channel. Strictly opt-in behind a new `transfers` feature flag, default OFF. Migrations 170-172 (transfers, transfer_files, transfer_extra_files, transfer_uploads, transfer_recipients, transfer_downloads, default settings and two email templates) — all hasTable/hasColumn-guarded and idempotent, with destructive statements confined to down(). Backend: transferService (CRUD, 256-bit download token, 6-char upload token, cross-event ZIP streaming of originals), admin CRUD routes, and two public token routes. transferCleanupService runs an hourly retention sweep; source-event photos are never touched. All three routers fail closed via requireFeatureFlag('transfers'). Review closed two ownership blockers, both the same root cause — permissions used where ownership was needed: - photoIds arrived from the request body and were validated only for existence, so a scoped admin could bundle any event's originals and hand them out through the public download token. filterOwnedPhotoIds now resolves ids to their events and gates them through filterOwnedEventIds, on both the create and add-files paths. - The transfer list was unscoped and carried each row's download token, so any admin with events.view could read another's token and fetch their originals. The list is now scoped by created_by, the token/url fields are stripped from the list payload, and a single router.use('/:id', requireTransferOwnership) covers all twelve /:id routes, 404ing foreign and missing alike. The admin photo picker filters its event list to the same rule, so the UI stops offering picks the API would discard. Fork-PR workflows had not been approved since the fix commits, so the PR's green checks were stale against the pre-fix head. Verified by dispatching tests.yml against the actual head: backend and frontend both green. Follow-up: neither ownership guard has a regression test yet. Co-authored-by: Luca-Timo --- .../services/transferService.gating.test.js | 78 ++ backend/migrations/core/170_add_transfers.js | 244 ++++ .../171_transfer_extra_files_and_delivery.js | 157 +++ .../core/172_reword_transfer_ready_email.js | 109 ++ backend/server.js | 8 + backend/src/routes/adminFeatureFlags.js | 6 + backend/src/routes/adminTransfers.js | 394 +++++++ backend/src/routes/publicTransfer.js | 97 ++ backend/src/routes/publicTransferUpload.js | 187 +++ .../src/services/transferCleanupService.js | 147 +++ backend/src/services/transferService.js | 1014 +++++++++++++++++ frontend/src/App.tsx | 13 + .../src/components/admin/AdminSidebar.tsx | 2 + .../components/admin/TransferPhotoPicker.tsx | 254 +++++ frontend/src/contexts/FeatureFlagsContext.tsx | 3 + .../features/settings/tabs/FeaturesTab.tsx | 15 + frontend/src/i18n/locales/de.json | 112 ++ frontend/src/i18n/locales/en.json | 112 ++ .../pages/admin/transfers/TransfersPage.tsx | 655 +++++++++++ .../src/pages/public/TransferDownloadPage.tsx | 139 +++ .../src/pages/public/TransferUploadPage.tsx | 189 +++ frontend/src/services/featureFlags.service.ts | 5 + frontend/src/services/transfers.service.ts | 240 ++++ 23 files changed, 4180 insertions(+) create mode 100644 backend/__tests__/services/transferService.gating.test.js create mode 100644 backend/migrations/core/170_add_transfers.js create mode 100644 backend/migrations/core/171_transfer_extra_files_and_delivery.js create mode 100644 backend/migrations/core/172_reword_transfer_ready_email.js create mode 100644 backend/src/routes/adminTransfers.js create mode 100644 backend/src/routes/publicTransfer.js create mode 100644 backend/src/routes/publicTransferUpload.js create mode 100644 backend/src/services/transferCleanupService.js create mode 100644 backend/src/services/transferService.js create mode 100644 frontend/src/components/admin/TransferPhotoPicker.tsx create mode 100644 frontend/src/pages/admin/transfers/TransfersPage.tsx create mode 100644 frontend/src/pages/public/TransferDownloadPage.tsx create mode 100644 frontend/src/pages/public/TransferUploadPage.tsx create mode 100644 frontend/src/services/transfers.service.ts diff --git a/backend/__tests__/services/transferService.gating.test.js b/backend/__tests__/services/transferService.gating.test.js new file mode 100644 index 00000000..7b356d91 --- /dev/null +++ b/backend/__tests__/services/transferService.gating.test.js @@ -0,0 +1,78 @@ +/** + * Unit tests for the pure gating logic in transferService (PicTransfer, #997). + * These exercise the download/upload eligibility rules without touching the DB. + */ +const transferService = require('../../src/services/transferService'); + +const HOUR = 60 * 60 * 1000; + +function make(overrides = {}) { + return { + id: 1, + title: 'T', + is_active: true, + deleted_at: null, + expires_at: new Date(Date.now() + 24 * HOUR), + max_downloads: null, + download_count: 0, + allow_uploads: false, + upload_expires_at: null, + ...overrides, + }; +} + +describe('transferService.downloadsRemaining', () => { + it('returns null (unlimited) when no cap or zero cap', () => { + expect(transferService.downloadsRemaining(make({ max_downloads: null }))).toBeNull(); + expect(transferService.downloadsRemaining(make({ max_downloads: 0 }))).toBeNull(); + }); + + it('returns the remaining count and never goes negative', () => { + expect(transferService.downloadsRemaining(make({ max_downloads: 5, download_count: 2 }))).toBe(3); + expect(transferService.downloadsRemaining(make({ max_downloads: 5, download_count: 9 }))).toBe(0); + }); +}); + +describe('transferService.computeStatus', () => { + it('is deleted when deleted_at set, regardless of activity', () => { + expect(transferService.computeStatus(make({ deleted_at: new Date(), is_active: true }))).toBe('deleted'); + }); + it('is expired when inactive or past expiry', () => { + expect(transferService.computeStatus(make({ is_active: false }))).toBe('expired'); + expect(transferService.computeStatus(make({ expires_at: new Date(Date.now() - HOUR) }))).toBe('expired'); + }); + it('is active within the window', () => { + expect(transferService.computeStatus(make())).toBe('active'); + }); +}); + +describe('transferService.assertDownloadable', () => { + it('allows a live, in-window, uncapped transfer', () => { + expect(transferService.assertDownloadable(make()).ok).toBe(true); + }); + it('404s a missing/deleted transfer', () => { + expect(transferService.assertDownloadable(null)).toMatchObject({ ok: false, status: 404 }); + expect(transferService.assertDownloadable(make({ deleted_at: new Date() }))).toMatchObject({ ok: false, status: 404 }); + }); + it('410s when disabled or expired', () => { + expect(transferService.assertDownloadable(make({ is_active: false }))).toMatchObject({ ok: false, code: 'TRANSFER_DISABLED', status: 410 }); + expect(transferService.assertDownloadable(make({ expires_at: new Date(Date.now() - HOUR) }))).toMatchObject({ ok: false, code: 'TRANSFER_EXPIRED', status: 410 }); + }); + it('410s when the download cap is reached', () => { + expect(transferService.assertDownloadable(make({ max_downloads: 2, download_count: 2 }))) + .toMatchObject({ ok: false, code: 'DOWNLOAD_LIMIT_REACHED', status: 410 }); + }); +}); + +describe('transferService.assertUploadable', () => { + it('403s when uploads are disabled', () => { + expect(transferService.assertUploadable(make({ allow_uploads: false }))).toMatchObject({ ok: false, code: 'UPLOADS_DISABLED', status: 403 }); + }); + it('allows when uploads enabled and not expired', () => { + expect(transferService.assertUploadable(make({ allow_uploads: true })).ok).toBe(true); + }); + it('410s when the upload window has passed', () => { + expect(transferService.assertUploadable(make({ allow_uploads: true, upload_expires_at: new Date(Date.now() - HOUR) }))) + .toMatchObject({ ok: false, code: 'UPLOAD_EXPIRED', status: 410 }); + }); +}); diff --git a/backend/migrations/core/170_add_transfers.js b/backend/migrations/core/170_add_transfers.js new file mode 100644 index 00000000..109ea4bb --- /dev/null +++ b/backend/migrations/core/170_add_transfers.js @@ -0,0 +1,244 @@ +/** + * Migration 170: PicTransfer — cross-event file transfers (#997). + * + * Adds the tables that back the "send these files to someone" feature: + * + * transfers One share link. Bundles photos picked from ANY event, + * protected by a 64-hex recipient token. Optionally opens + * a 6-char upload token so the client can send files back + * (logos etc.). Disabled after `expires_at`; files are + * kept `grace_days` days past disable, then hard-deleted. + * transfer_files Join rows: which photos are in a transfer (cross-event). + * photo_id → photos CASCADE, so removing the underlying + * photo just drops it from the transfer; the reverse + * (deleting a transfer) never touches the source photos. + * transfer_uploads Files the client uploaded through the upload token. + * These have their own bytes on disk (uploads/transfers/…) + * and are what the retention sweep deletes. + * transfer_downloads Lightweight audit of recipient downloads (count + IP). + * + * Downloads always serve ORIGINAL files (never watermarked) — a transfer is a + * deliberate "here are your files" hand-off. Reuses the same original-file + * resolution + archiver streaming as the gallery download-all path. + */ + +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('transfers'))) { + await knex.schema.createTable('transfers', (table) => { + table.increments('id').primary(); + // Recipient download token — 64 hex chars = 32 bytes = 256 bits. + table.string('token', 64).notNullable().unique(); + table.string('title', 255).notNullable().defaultTo(''); + table.text('message'); + table.integer('created_by').unsigned() + .references('id').inTable('admin_users').onDelete('SET NULL'); + // Link is disabled once this passes (the "set time period" cap). + table.timestamp('expires_at').notNullable(); + // Optional download cap. NULL or 0 = unlimited within the window. + table.integer('max_downloads'); + table.integer('download_count').notNullable().defaultTo(0); + table.boolean('is_active').notNullable().defaultTo(true); + // When the link flipped inactive — starts the retention clock. + table.timestamp('disabled_at'); + // Keep files this many days after disable, then hard-delete. + table.integer('grace_days').notNullable().defaultTo(7); + table.timestamp('admin_notified_at'); + table.timestamp('deleted_at'); + // Optional client-upload channel (6-char token). + table.boolean('allow_uploads').notNullable().defaultTo(false); + table.string('upload_token', 16).unique(); + table.timestamp('upload_expires_at'); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.index(['is_active', 'expires_at'], 'transfers_active_expiry_idx'); + table.index(['deleted_at'], 'transfers_deleted_idx'); + }); + } + + if (!(await knex.schema.hasTable('transfer_files'))) { + await knex.schema.createTable('transfer_files', (table) => { + table.increments('id').primary(); + table.integer('transfer_id').unsigned().notNullable() + .references('id').inTable('transfers').onDelete('CASCADE'); + table.integer('photo_id').unsigned().notNullable() + .references('id').inTable('photos').onDelete('CASCADE'); + table.integer('sort_order').notNullable().defaultTo(0); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.index(['transfer_id'], 'transfer_files_transfer_idx'); + // A photo can only appear once per transfer. + table.unique(['transfer_id', 'photo_id'], 'transfer_files_unique'); + }); + } + + if (!(await knex.schema.hasTable('transfer_uploads'))) { + await knex.schema.createTable('transfer_uploads', (table) => { + table.increments('id').primary(); + table.integer('transfer_id').unsigned().notNullable() + .references('id').inTable('transfers').onDelete('CASCADE'); + table.string('original_filename', 512).notNullable(); + // Storage-relative key, e.g. uploads/transfers/{id}/{stored-name}. + table.string('stored_path', 1024).notNullable(); + table.integer('size_bytes'); + table.string('mime_type', 100); + table.string('uploader_ip', 45); + table.timestamp('uploaded_at').defaultTo(knex.fn.now()); + table.index(['transfer_id'], 'transfer_uploads_transfer_idx'); + }); + } + + if (!(await knex.schema.hasTable('transfer_downloads'))) { + await knex.schema.createTable('transfer_downloads', (table) => { + table.increments('id').primary(); + table.integer('transfer_id').unsigned().notNullable() + .references('id').inTable('transfers').onDelete('CASCADE'); + table.string('kind', 20).notNullable().defaultTo('all'); // 'all' | 'single' + table.integer('photo_id').unsigned(); + table.string('ip', 45); + table.timestamp('downloaded_at').defaultTo(knex.fn.now()); + table.index(['transfer_id'], 'transfer_downloads_transfer_idx'); + }); + } + + // Defaults for the create-transfer form + retention/upload behaviour. + const settings = [ + { setting_key: 'transfer_default_expiry_days', setting_value: JSON.stringify(14), setting_type: 'number' }, + { setting_key: 'transfer_default_grace_days', setting_value: JSON.stringify(7), setting_type: 'number' }, + { setting_key: 'transfer_default_max_downloads', setting_value: JSON.stringify(0), setting_type: 'number' }, + { setting_key: 'transfer_max_upload_size_mb', setting_value: JSON.stringify(50), setting_type: 'number' }, + { + setting_key: 'transfer_upload_allowed_mime', + setting_value: JSON.stringify([ + 'image/jpeg', 'image/png', 'image/webp', 'image/gif', + 'image/tiff', 'application/pdf', 'application/zip', + ]), + setting_type: 'general', + }, + ]; + for (const s of settings) { + const exists = await knex('app_settings').where('setting_key', s.setting_key).first(); + if (!exists) { + await knex('app_settings').insert({ ...s, updated_at: knex.fn.now() }); + } + } + + // Feature flag — PicTransfer is a strictly opt-in module like slideshow / + // workflows: the sidebar entry, the /admin/transfers area and every + // transfer route (admin + public) stay dark until an admin turns it on + // under Settings → Features. Default OFF; idempotent seed. + if (await knex.schema.hasTable('feature_flags')) { + const existingFlag = await knex('feature_flags').where({ key: 'transfers' }).first(); + if (!existingFlag) { + await knex('feature_flags').insert({ key: 'transfers', value: false }); + } + } + + // Admin notification when a transfer link expires (EN + DE, matching the + // convention of the other admin-notification templates — see migration 087). + const existingTemplate = await knex('email_templates') + .where('template_key', 'transfer_link_expired') + .first(); + if (!existingTemplate) { + await knex('email_templates').insert({ + template_key: 'transfer_link_expired', + subject_en: 'A transfer link has expired — {{transfer_title}}', + subject_de: 'Ein Transfer-Link ist abgelaufen — {{transfer_title}}', + body_html_en: ` +

A transfer link has expired

+ +

The following file transfer is no longer downloadable by its recipient:

+ +
+

Transfer: {{transfer_title}}

+

Expired at: {{expiry_date}}

+

Files included: {{file_count}}

+

Client uploads received: {{upload_count}}

+
+ +

The files will be kept for {{grace_days}} more days (until {{delete_date}}) +so you can re-share or retrieve anything you still need, then they are +automatically deleted.

+ +

Open PicTransfer in the admin area

+ +

Best regards,
+Your PicPeak Installation

`, + body_text_en: `A transfer link has expired + +The following file transfer is no longer downloadable by its recipient: + +Transfer: {{transfer_title}} +Expired at: {{expiry_date}} +Files included: {{file_count}} +Client uploads received: {{upload_count}} + +The files will be kept for {{grace_days}} more days (until {{delete_date}}) so +you can re-share or retrieve anything you still need, then they are +automatically deleted. + +Open PicTransfer in the admin area: {{admin_url}} + +Best regards, +Your PicPeak Installation`, + body_html_de: ` +

Ein Transfer-Link ist abgelaufen

+ +

Der folgende Datei-Transfer kann vom Empfänger nicht mehr heruntergeladen werden:

+ +
+

Transfer: {{transfer_title}}

+

Abgelaufen am: {{expiry_date}}

+

Enthaltene Dateien: {{file_count}}

+

Empfangene Kunden-Uploads: {{upload_count}}

+
+ +

Die Dateien werden noch {{grace_days}} Tage aufbewahrt (bis {{delete_date}}), +damit Sie alles Benötigte erneut teilen oder abrufen können; danach werden sie +automatisch gelöscht.

+ +

PicTransfer im Admin-Bereich öffnen

+ +

Mit freundlichen Grüßen,
+Ihre PicPeak-Installation

`, + body_text_de: `Ein Transfer-Link ist abgelaufen + +Der folgende Datei-Transfer kann vom Empfänger nicht mehr heruntergeladen werden: + +Transfer: {{transfer_title}} +Abgelaufen am: {{expiry_date}} +Enthaltene Dateien: {{file_count}} +Empfangene Kunden-Uploads: {{upload_count}} + +Die Dateien werden noch {{grace_days}} Tage aufbewahrt (bis {{delete_date}}), +danach werden sie automatisch gelöscht. + +PicTransfer im Admin-Bereich öffnen: {{admin_url}} + +Mit freundlichen Grüßen, +Ihre PicPeak-Installation`, + variables: JSON.stringify([ + 'transfer_title', 'expiry_date', 'file_count', 'upload_count', + 'grace_days', 'delete_date', 'admin_url', + ]), + }); + } +}; + +exports.down = async function (knex) { + if (await knex.schema.hasTable('feature_flags')) { + await knex('feature_flags').where({ key: 'transfers' }).del(); + } + await knex('email_templates').where('template_key', 'transfer_link_expired').del(); + await knex('app_settings') + .whereIn('setting_key', [ + 'transfer_default_expiry_days', + 'transfer_default_grace_days', + 'transfer_default_max_downloads', + 'transfer_max_upload_size_mb', + 'transfer_upload_allowed_mime', + ]) + .del(); + await knex.schema.dropTableIfExists('transfer_downloads'); + await knex.schema.dropTableIfExists('transfer_uploads'); + await knex.schema.dropTableIfExists('transfer_files'); + await knex.schema.dropTableIfExists('transfers'); +}; diff --git a/backend/migrations/core/171_transfer_extra_files_and_delivery.js b/backend/migrations/core/171_transfer_extra_files_and_delivery.js new file mode 100644 index 00000000..ff9226a4 --- /dev/null +++ b/backend/migrations/core/171_transfer_extra_files_and_delivery.js @@ -0,0 +1,157 @@ +/** + * Migration 171: PicTransfer — admin-uploaded deliverable files + email delivery + * (follow-up to #997). + * + * 170 shipped the base feature; this adds two things the create flow now needs: + * + * transfer_extra_files Files the ADMIN uploads straight into a transfer at + * creation (or later), stored as the transfer's own + * bytes under `transfers/{id}/files/…`. Unlike + * transfer_files (which reference gallery `photos`), + * these have no event/photo — they are the operator's + * own attachments and are part of the recipient's + * download alongside the picked event photos. Deleted + * with the transfer (retention sweep / hard delete). + * transfer_recipients When a transfer is delivered by email, the recipient + * address(es) it was sent to (audit + "sent to …" in the + * detail panel). CASCADE with the transfer. + * + * Plus `transfers.delivery_method` ('link' | 'email', default 'link') and a + * recipient-facing `transfer_ready` email template (EN/DE). + * + * 170 is already applied on existing installs, so this is a separate, additive + * migration (Knex won't re-run 170). Fully guarded + idempotent. + */ + +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('transfer_extra_files'))) { + await knex.schema.createTable('transfer_extra_files', (table) => { + table.increments('id').primary(); + table.integer('transfer_id').unsigned().notNullable() + .references('id').inTable('transfers').onDelete('CASCADE'); + table.string('original_filename', 512).notNullable(); + // Storage-relative key, e.g. transfers/{id}/files/{stored-name}. + table.string('stored_path', 1024).notNullable(); + table.integer('size_bytes'); + table.string('mime_type', 100); + table.integer('sort_order').notNullable().defaultTo(0); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.index(['transfer_id'], 'transfer_extra_files_transfer_idx'); + }); + } + + if (!(await knex.schema.hasTable('transfer_recipients'))) { + await knex.schema.createTable('transfer_recipients', (table) => { + table.increments('id').primary(); + table.integer('transfer_id').unsigned().notNullable() + .references('id').inTable('transfers').onDelete('CASCADE'); + table.string('email', 320).notNullable(); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('last_sent_at'); + table.index(['transfer_id'], 'transfer_recipients_transfer_idx'); + }); + } + + const hasDeliveryMethod = await knex.schema.hasColumn('transfers', 'delivery_method'); + if (!hasDeliveryMethod) { + await knex.schema.alterTable('transfers', (table) => { + // 'link' (default — the operator copies/shares the link themselves) or + // 'email' (PicPeak emailed the download link to transfer_recipients). + table.string('delivery_method', 10).notNullable().defaultTo('link'); + }); + } + + // Recipient-facing "your files are ready" email (EN + DE), sent when a + // transfer is created with delivery_method='email'. Mirrors the convention + // of the existing transfer_link_expired admin template (migration 170). + const existingTemplate = await knex('email_templates') + .where('template_key', 'transfer_ready') + .first(); + if (!existingTemplate) { + await knex('email_templates').insert({ + template_key: 'transfer_ready', + subject_en: 'Your files are ready — {{transfer_title}}', + subject_de: 'Ihre Dateien sind bereit — {{transfer_title}}', + body_html_en: ` +

Your files are ready

+ +

{{transfer_title}} has been shared with you.

+ +
+

{{message}}

+
+ +

+ Download your files +

+ +

Files: {{file_count}}
+Available until: {{expiry_date}}

+ +

If the button doesn't work, copy this link into your browser:
{{download_url}}

+ +

Best regards,
+Your PicPeak Installation

`, + body_text_en: `Your files are ready + +{{transfer_title}} has been shared with you. + +{{message}} + +Download your files: {{download_url}} + +Files: {{file_count}} +Available until: {{expiry_date}} + +Best regards, +Your PicPeak Installation`, + body_html_de: ` +

Ihre Dateien sind bereit

+ +

{{transfer_title}} wurde mit Ihnen geteilt.

+ +
+

{{message}}

+
+ +

+ Dateien herunterladen +

+ +

Dateien: {{file_count}}
+Verfügbar bis: {{expiry_date}}

+ +

Falls die Schaltfläche nicht funktioniert, kopieren Sie diesen Link in Ihren Browser:
{{download_url}}

+ +

Mit freundlichen Grüßen,
+Ihre PicPeak-Installation

`, + body_text_de: `Ihre Dateien sind bereit + +{{transfer_title}} wurde mit Ihnen geteilt. + +{{message}} + +Dateien herunterladen: {{download_url}} + +Dateien: {{file_count}} +Verfügbar bis: {{expiry_date}} + +Mit freundlichen Grüßen, +Ihre PicPeak-Installation`, + variables: JSON.stringify([ + 'transfer_title', 'message', 'download_url', 'file_count', 'expiry_date', + ]), + }); + } +}; + +exports.down = async function (knex) { + await knex('email_templates').where('template_key', 'transfer_ready').del(); + if (await knex.schema.hasColumn('transfers', 'delivery_method')) { + await knex.schema.alterTable('transfers', (table) => { + table.dropColumn('delivery_method'); + }); + } + await knex.schema.dropTableIfExists('transfer_recipients'); + await knex.schema.dropTableIfExists('transfer_extra_files'); +}; diff --git a/backend/migrations/core/172_reword_transfer_ready_email.js b/backend/migrations/core/172_reword_transfer_ready_email.js new file mode 100644 index 00000000..39c90112 --- /dev/null +++ b/backend/migrations/core/172_reword_transfer_ready_email.js @@ -0,0 +1,109 @@ +/** + * Migration 172: reword the recipient `transfer_ready` email (follow-up to 171). + * + * Two fixes to the copy seeded in 171: + * 1. Warmer, less robotic wording (greeting + natural phrasing + friendly + * sign-off) instead of the terse "has been shared with you" notice. + * 2. The message block is wrapped in `{{#if message}}` so a transfer sent + * WITHOUT a personal note no longer renders an empty coloured box (the + * "grey bar" some clients showed for the always-present empty
). + * + * This is a content UPDATE rather than an edit to 171 because 171 has already + * been applied on existing installs — Knex won't re-run it, so the seeded row + * would otherwise keep the old copy. UPDATE reaches both existing rows and + * fresh installs (which run 171's insert first, then this). + */ + +const HTML_EN = ` +

Your files are ready

+ +

Hi,

+ +

{{transfer_title}} is ready for you — you can grab everything with a single click below.

+ +{{#if message}} +
{{message}}
+{{/if}} + +

+ Download your files +

+ +

The link stays active until {{expiry_date}} and includes {{file_count}} file(s).

+ +

Button not working? Just copy this link into your browser:
{{download_url}}

+ +

Enjoy your photos!

`; + +const TEXT_EN = `Your files are ready + +Hi, + +{{transfer_title}} is ready for you — grab everything with the link below. +{{#if message}} + +{{message}} +{{/if}} + +Download your files: +{{download_url}} + +The link stays active until {{expiry_date}} and includes {{file_count}} file(s). + +Enjoy your photos!`; + +const HTML_DE = ` +

Ihre Dateien sind bereit

+ +

Hallo,

+ +

{{transfer_title}} ist für Sie bereit — mit einem Klick unten können Sie alles herunterladen.

+ +{{#if message}} +
{{message}}
+{{/if}} + +

+ Dateien herunterladen +

+ +

Der Link ist bis zum {{expiry_date}} gültig und enthält {{file_count}} Datei(en).

+ +

Funktioniert die Schaltfläche nicht? Kopieren Sie einfach diesen Link in Ihren Browser:
{{download_url}}

+ +

Viel Freude mit Ihren Fotos!

`; + +const TEXT_DE = `Ihre Dateien sind bereit + +Hallo, + +{{transfer_title}} ist für Sie bereit — laden Sie alles über den Link unten herunter. +{{#if message}} + +{{message}} +{{/if}} + +Dateien herunterladen: +{{download_url}} + +Der Link ist bis zum {{expiry_date}} gültig und enthält {{file_count}} Datei(en). + +Viel Freude mit Ihren Fotos!`; + +exports.up = async function (knex) { + if (!(await knex.schema.hasTable('email_templates'))) return; + await knex('email_templates') + .where('template_key', 'transfer_ready') + .update({ + subject_en: '{{transfer_title}} — your files are ready to download', + subject_de: '{{transfer_title}} — Ihre Dateien stehen bereit', + body_html_en: HTML_EN, + body_text_en: TEXT_EN, + body_html_de: HTML_DE, + body_text_de: TEXT_DE, + }); +}; + +// Content-only refresh — nothing structural to reverse. The previous copy is +// preserved in migration 171's insert for reference. +exports.down = async function () {}; diff --git a/backend/server.js b/backend/server.js index e0c36750..d7695369 100644 --- a/backend/server.js +++ b/backend/server.js @@ -20,6 +20,7 @@ const path = require('path'); const { initializeDatabase, db } = require('./src/database/db'); const { startFileWatcher } = require('./src/services/fileWatcher'); const { startExpirationChecker } = require('./src/services/expirationChecker'); +const { startTransferCleanup } = require('./src/services/transferCleanupService'); const { startRevealScheduler } = require('./src/services/revealScheduler'); const { startInvoiceScheduler } = require('./src/services/invoiceSchedulerService'); const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor'); @@ -785,8 +786,12 @@ app.use('/api/admin/ledger', require('./src/routes/adminLedger')); app.use('/api/admin/vat-codes', require('./src/routes/adminVatCodes')); app.use('/api/admin/system-health', require('./src/routes/adminSystemHealth')); app.use('/api/admin/dev', require('./src/routes/adminDev')); +app.use('/api/admin/transfers', require('./src/routes/adminTransfers')); app.use('/api/public/quotes', require('./src/routes/publicQuotes')); app.use('/api/public/contracts', require('./src/routes/publicContracts')); +// PicTransfer (#997): recipient download + client upload, token-authenticated. +app.use('/api/public/transfer', require('./src/routes/publicTransfer')); +app.use('/api/public/transfer-upload', require('./src/routes/publicTransferUpload')); app.use('/api/public/payment-check', require('./src/routes/publicPaymentCheck')); app.use('/api/public/workflow-approvals', require('./src/routes/publicWorkflowApprovals')); app.use('/api/admin/event-types', require('./src/routes/adminEventTypes')); @@ -904,6 +909,9 @@ async function startServer() { // Start expiration checker startExpirationChecker(); + // PicTransfer retention sweep (#997): expire links, notify admins, and + // hard-delete client uploads once the grace window elapses. + startTransferCleanup(); // Reveal-mode scheduler (#838): minutely stamp for scheduled reveals. startRevealScheduler(); // CRM invoice scheduler: hourly tick to flush scheduled-send invoices diff --git a/backend/src/routes/adminFeatureFlags.js b/backend/src/routes/adminFeatureFlags.js index 65114458..9555db84 100644 --- a/backend/src/routes/adminFeatureFlags.js +++ b/backend/src/routes/adminFeatureFlags.js @@ -88,6 +88,11 @@ const KNOWN_FLAGS = [ // per-event-type presets and global watermark defaults tab. Strictly opt-in; // gates all slideshow admin UI (per-event card, type preset, settings tab). 'slideshow', + // PicTransfer (migration 170) — cross-event file transfers + // (recipient download link + optional client-upload channel). Strictly + // opt-in; gates the sidebar entry, the /admin/transfers area AND every + // transfer route (admin + public token routes). + 'transfers', // Workflow / automation engine — admin-configurable visual flows (triggers, // conditions, branches, loops, approval gates). Strictly opt-in; master // kill-switch for the Workflows admin area AND the engine's runtime side @@ -122,6 +127,7 @@ const DEFAULT_FLAGS = { projects: false, whatsapp: false, slideshow: false, + transfers: false, workflows: false, }; diff --git a/backend/src/routes/adminTransfers.js b/backend/src/routes/adminTransfers.js new file mode 100644 index 00000000..cdc5167a --- /dev/null +++ b/backend/src/routes/adminTransfers.js @@ -0,0 +1,394 @@ +/** + * Admin → Transfers routes (PicTransfer, #997). + * + * Mounted at /api/admin/transfers. A transfer bundles ORIGINAL photos picked + * from any event into a token-protected download link, and can optionally open + * a short upload token so the client can send files back. + * + * Read = `events.view`; write = `events.edit` (transfers are an + * events/photos-adjacent admin tool, so they ride the same permissions as the + * projects cockpit rather than inventing a new permission). + */ + +const express = require('express'); +const { body, param } = require('express-validator'); +const multer = require('multer'); +const path = require('path'); +const { adminAuth } = require('../middleware/auth'); +const { requirePermission } = require('../middleware/permissions'); +const { requireFeatureFlag } = require('../middleware/requireFeatureFlag'); +const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); +const { validateFileType } = require('../utils/fileSecurityUtils'); +const { sanitizeFilename } = require('../utils/filenameSanitizer'); +const { getAppSetting } = require('../utils/appSettings'); +const { getStorage } = require('../services/storage'); +const transferService = require('../services/transferService'); +const logger = require('../utils/logger'); +const fs = require('fs'); + +const router = express.Router(); + +// --- Admin deliverable-file upload (the files dropped into a transfer) -------- +// Bytes are written to a temp dir, handed to the storage backend (so S3 works), +// then the temp copy is removed — same shape as the public client-upload route. +const ADMIN_MAX_FILES = 50; +const DEFAULT_ALLOWED = ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/tiff', 'application/pdf', 'application/zip']; +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + +const tempStorage = multer.diskStorage({ + destination: (req, file, cb) => { + const dir = path.join(getStoragePath(), 'temp', 'transfer-admin-uploads'); + fs.mkdirSync(dir, { recursive: true }); + cb(null, dir); + }, + filename: (req, file, cb) => { + const safe = sanitizeFilename(path.basename(file.originalname), 80) || 'file'; + cb(null, `${Date.now()}-${Math.round(Math.random() * 1e6)}-${safe}`); + }, +}); + +function buildAdminUploader(maxSizeBytes, allowed) { + return multer({ + storage: tempStorage, + limits: { fileSize: maxSizeBytes, files: ADMIN_MAX_FILES }, + fileFilter: (req, file, cb) => { + if (validateFileType(file.originalname, file.mimetype, allowed)) return cb(null, true); + return cb(new Error('This file type is not allowed')); + }, + }).array('files', ADMIN_MAX_FILES); +} + +/** + * Run multer for a transfer request, reading the size/type limits from settings. + * Resolves { ok:true } or sends a 4xx and resolves { ok:false }. + */ +async function runAdminUpload(req, res) { + const maxSizeMb = Number(await getAppSetting('transfer_max_upload_size_mb', 50)) || 50; + const allowedSetting = await getAppSetting('transfer_upload_allowed_mime', DEFAULT_ALLOWED); + const allowed = Array.isArray(allowedSetting) ? allowedSetting : DEFAULT_ALLOWED; + const uploader = buildAdminUploader(maxSizeMb * 1024 * 1024, allowed); + try { + await new Promise((resolve, reject) => uploader(req, res, (err) => (err ? reject(err) : resolve()))); + return { ok: true }; + } catch (err) { + const msg = err && err.code === 'LIMIT_FILE_SIZE' + ? `Each file must be ${maxSizeMb} MB or smaller` + : (err && err.message) || 'Upload failed'; + if (!res.headersSent) res.status(400).json({ error: msg, code: 'UPLOAD_REJECTED' }); + return { ok: false }; + } +} + +/** Persist the uploaded temp files as the transfer's deliverable extra files. */ +async function storeExtraFiles(transferId, files) { + if (!files || !files.length) return; + const storage = getStorage(); + let i = 0; + for (const file of files) { + i += 1; + const safeName = sanitizeFilename(path.basename(file.originalname), 120) || 'file'; + const key = path.posix.join(transferService.extraFilesDirKey(transferId), `${Date.now()}-${i}-${safeName}`); + try { + await storage.putFromFile(key, file.path); + await transferService.addExtraFile(transferId, { + originalFilename: file.originalname, + storedPath: key, + sizeBytes: file.size, + mimeType: file.mimetype, + }); + } catch (err) { + logger.error('adminTransfers: failed to store deliverable file', { transferId, error: err.message }); + } finally { + try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch (_) { /* noop */ } + } + } +} + +/** Parse a multipart field that carries a JSON array (photoIds, recipientEmails). */ +function parseJsonArrayField(value) { + if (Array.isArray(value)) return value; + if (typeof value !== 'string' || !value.trim()) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch (_) { + // Fallback: comma-separated (e.g. a raw "a@x.com, b@y.com" email field). + return value.split(',').map((s) => s.trim()).filter(Boolean); + } +} + +router.use(adminAuth); +// PicTransfer is a strictly opt-in module — refuse every admin transfer route +// when the `transfers` feature flag is off, so a disabled feature is never +// actable even by a direct API hit (the sidebar already hides the surface). +router.use(requireFeatureFlag('transfers')); + +/** + * Ownership guard for every `/:id` route. A non-super_admin may only touch a + * transfer they created (or an ownerless legacy row). Foreign AND missing ids + * both 404 so the endpoint isn't an existence oracle — the same posture + * filterOwnedEventIds takes. super_admin is unrestricted. + */ +async function requireTransferOwnership(req, res, next) { + try { + if (req.admin.roleName === 'super_admin') return next(); + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' }); + const owner = await transferService.getTransferOwner(id); + if (!owner) return res.status(404).json({ error: 'Transfer not found' }); + if (owner.created_by != null && owner.created_by !== req.admin.id) { + return res.status(404).json({ error: 'Transfer not found' }); + } + return next(); + } catch (err) { + return next(err); + } +} + +// List +router.get('/', requirePermission('events.view'), handleAsync(async (req, res) => { + const transfers = await transferService.listTransfers({ search: req.query.q || '', admin: req.admin }); + return successResponse(res, { transfers }); +})); + +// Create. multipart/form-data: text fields + optional `files` (the operator's +// own deliverable files) + `photoIds`/`recipientEmails` as JSON-array fields. +// Uploaded files land as transfer_extra_files; delivery_method='email' emails +// the recipients the download link. +router.post('/', + requirePermission('events.edit'), + handleAsync(async (req, res) => { + const up = await runAdminUpload(req, res); + if (!up.ok) return; // 4xx already sent + + const b = req.body || {}; + const photoIds = parseJsonArrayField(b.photoIds) + .map(Number).filter((n) => Number.isInteger(n) && n > 0).slice(0, 5000); + const recipientEmails = parseJsonArrayField(b.recipientEmails) + .map((e) => String(e || '').trim()).filter(Boolean).slice(0, 100); + const deliveryMethod = b.deliveryMethod === 'email' ? 'email' : 'link'; + + const transfer = await transferService.createTransfer({ + title: b.title, + message: b.message, + expiresInDays: b.expiresInDays, + maxDownloads: b.maxDownloads, + graceDays: b.graceDays, + allowUploads: b.allowUploads === 'true' || b.allowUploads === true, + uploadExpiresInDays: b.uploadExpiresInDays, + photoIds, + deliveryMethod, + }, req.admin); + + await storeExtraFiles(transfer.id, req.files); + + if (deliveryMethod === 'email' && recipientEmails.length) { + await transferService.sendTransferEmails(transfer.id, recipientEmails); + } + + const fresh = await transferService.getTransfer(transfer.id); + return successResponse(res, { transfer: fresh }, 201, 'Transfer created'); + }), +); + +// Ownership guard for every `/:id`, `/:id/files`, `/:id/download`, … route. +// One mount covers them all — the POST `/` create + GET `/` list above are not +// matched (no :id), and each route keeps its own requirePermission. +router.use('/:id', requireTransferOwnership); + +// Detail +router.get('/:id', + requirePermission('events.view'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const transfer = await transferService.getTransfer(parseInt(req.params.id, 10)); + if (!transfer) return res.status(404).json({ error: 'Transfer not found' }); + return successResponse(res, { transfer }); + }), +); + +// Update +router.patch('/:id', + requirePermission('events.edit'), + [ + param('id').isInt({ min: 1 }), + body('title').optional({ nullable: true }).isString().isLength({ max: 255 }), + body('message').optional({ nullable: true }).isString().isLength({ max: 5000 }), + body('maxDownloads').optional({ nullable: true }).isInt({ min: 0, max: 1000000 }), + body('graceDays').optional({ nullable: true }).isInt({ min: 0, max: 365 }), + body('expiresInDays').optional({ nullable: true }).isInt({ min: 1, max: 3650 }), + body('expiresAt').optional({ nullable: true }).isISO8601(), + body('isActive').optional().isBoolean(), + ], + handleAsync(async (req, res) => { + validateRequest(req); + const transfer = await transferService.updateTransfer(parseInt(req.params.id, 10), { + title: req.body.title, + message: req.body.message, + maxDownloads: req.body.maxDownloads, + graceDays: req.body.graceDays, + expiresInDays: req.body.expiresInDays, + expiresAt: req.body.expiresAt, + isActive: req.body.isActive, + }); + if (!transfer) return res.status(404).json({ error: 'Transfer not found' }); + return successResponse(res, { transfer }, 200, 'Transfer updated'); + }), +); + +// Delete +router.delete('/:id', + requirePermission('events.edit'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const ok = await transferService.deleteTransfer(parseInt(req.params.id, 10)); + if (!ok) return res.status(404).json({ error: 'Transfer not found' }); + return successResponse(res, { deleted: true }, 200, 'Transfer deleted'); + }), +); + +// Add photos (cross-event) to a transfer +router.post('/:id/files', + requirePermission('events.edit'), + [ + param('id').isInt({ min: 1 }), + body('photoIds').isArray({ min: 1, max: 5000 }), + body('photoIds.*').isInt({ min: 1 }), + ], + handleAsync(async (req, res) => { + validateRequest(req); + const existing = await transferService.getTransfer(parseInt(req.params.id, 10)); + if (!existing) return res.status(404).json({ error: 'Transfer not found' }); + const transfer = await transferService.addFiles(parseInt(req.params.id, 10), req.body.photoIds, req.admin); + return successResponse(res, { transfer }, 200, 'Files added'); + }), +); + +// Remove one file from a transfer +router.delete('/:id/files/:fileId', + requirePermission('events.edit'), + [param('id').isInt({ min: 1 }), param('fileId').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const transfer = await transferService.removeFile( + parseInt(req.params.id, 10), parseInt(req.params.fileId, 10), + ); + if (!transfer) return res.status(404).json({ error: 'Transfer not found' }); + return successResponse(res, { transfer }, 200, 'File removed'); + }), +); + +// Upload deliverable files into an existing transfer (multipart `files`). +router.post('/:id/upload-files', + requirePermission('events.edit'), + handleAsync(async (req, res) => { + const id = parseInt(req.params.id, 10); + if (!Number.isInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid id' }); + const existing = await transferService.getTransfer(id); + if (!existing) return res.status(404).json({ error: 'Transfer not found' }); + const up = await runAdminUpload(req, res); + if (!up.ok) return; + if (!req.files || !req.files.length) { + return res.status(400).json({ error: 'No files uploaded', code: 'NO_FILES' }); + } + await storeExtraFiles(id, req.files); + const transfer = await transferService.getTransfer(id); + return successResponse(res, { transfer }, 200, 'Files added'); + }), +); + +// Remove one admin-uploaded deliverable file from a transfer +router.delete('/:id/extra-files/:extraId', + requirePermission('events.edit'), + [param('id').isInt({ min: 1 }), param('extraId').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const transfer = await transferService.removeExtraFile( + parseInt(req.params.id, 10), parseInt(req.params.extraId, 10), + ); + if (!transfer) return res.status(404).json({ error: 'Transfer not found' }); + return successResponse(res, { transfer }, 200, 'File removed'); + }), +); + +// Admin download of a single admin-uploaded deliverable file +router.get('/:id/extra-files/:extraId/download', + requirePermission('events.view'), + [param('id').isInt({ min: 1 }), param('extraId').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const transfer = await transferService.getTransfer(parseInt(req.params.id, 10)); + if (!transfer) return res.status(404).json({ error: 'Transfer not found' }); + const ok = await transferService.streamTransferExtraFile( + { id: transfer.id }, parseInt(req.params.extraId, 10), res, + ); + if (!ok && !res.headersSent) return res.status(404).json({ error: 'File not found' }); + }), +); + +// Enable / regenerate the client-upload link +router.post('/:id/upload-link', + requirePermission('events.edit'), + [param('id').isInt({ min: 1 }), body('uploadExpiresInDays').optional({ nullable: true }).isInt({ min: 1, max: 3650 })], + handleAsync(async (req, res) => { + validateRequest(req); + const transfer = await transferService.enableUploads( + parseInt(req.params.id, 10), { uploadExpiresInDays: req.body.uploadExpiresInDays }, + ); + if (!transfer) return res.status(404).json({ error: 'Transfer not found' }); + return successResponse(res, { transfer }, 200, 'Upload link enabled'); + }), +); + +// Disable the client-upload link +router.delete('/:id/upload-link', + requirePermission('events.edit'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const transfer = await transferService.disableUploads(parseInt(req.params.id, 10)); + if (!transfer) return res.status(404).json({ error: 'Transfer not found' }); + return successResponse(res, { transfer }, 200, 'Upload link disabled'); + }), +); + +// Admin download of the whole transfer (ZIP of originals). No expiry/limit +// gate — this is the operator retrieving their own bundle. +router.get('/:id/download', + requirePermission('photos.download'), + [param('id').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const transfer = await transferService.getTransfer(parseInt(req.params.id, 10)); + if (!transfer) return res.status(404).json({ error: 'Transfer not found' }); + // getTransfer returns the serialized view; streamTransferArchive only needs + // { id, title }, both present on it. + await transferService.streamTransferArchive(transfer, res); + }), +); + +// Admin download of a single client-uploaded file +router.get('/:id/uploads/:uploadId/download', + requirePermission('events.view'), + [param('id').isInt({ min: 1 }), param('uploadId').isInt({ min: 1 })], + handleAsync(async (req, res) => { + validateRequest(req); + const upload = await transferService.getUpload( + parseInt(req.params.id, 10), parseInt(req.params.uploadId, 10), + ); + if (!upload) return res.status(404).json({ error: 'Upload not found' }); + res.setHeader('Content-Type', upload.mime_type || 'application/octet-stream'); + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(upload.original_filename)}"`); + if (upload.localPath && fs.existsSync(upload.localPath)) { + return fs.createReadStream(upload.localPath).pipe(res); + } + // S3 / non-local backend: stream via the storage abstraction. + const { getStorage } = require('../services/storage'); + const stream = await getStorage().get(upload.stored_path); + return stream.pipe(res); + }), +); + +module.exports = router; diff --git a/backend/src/routes/publicTransfer.js b/backend/src/routes/publicTransfer.js new file mode 100644 index 00000000..0c1cbb1d --- /dev/null +++ b/backend/src/routes/publicTransfer.js @@ -0,0 +1,97 @@ +/** + * Public → Transfer download routes (PicTransfer, #997). + * + * Mounted at /api/public/transfer. NO authentication — the 64-hex token in the + * recipient's link is the only secret. The recipient page has NO thumbnails by + * design; this API exposes filenames + sizes only, never image URLs. + * + * Surface: + * GET /:token metadata view (title, message, file list, expiry) + * GET /:token/download ZIP of all ORIGINAL files + * GET /:token/download/:fileId single ORIGINAL file + */ + +const express = require('express'); +const rateLimit = require('express-rate-limit'); +const { param } = require('express-validator'); +const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); +const { requireFeatureFlag } = require('../middleware/requireFeatureFlag'); +const { clientIpForAudit } = require('../utils/clientIp'); +const transferService = require('../services/transferService'); + +const router = express.Router(); + +// Belt-and-braces: a recipient link must stop resolving the moment an admin +// turns PicTransfer off under Settings → Features, same as every other gated +// module. The token is still the only secret; this just fails closed. +router.use(requireFeatureFlag('transfers')); + +const viewLimiter = rateLimit({ windowMs: 60 * 1000, max: 60, standardHeaders: true, legacyHeaders: false }); +const downloadLimiter = rateLimit({ windowMs: 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false }); + +const tokenValidator = [param('token').isString().isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i)]; + +// Recipient view. Always resolves for a live (non-deleted) transfer so the page +// can render an "expired" state; file list is only included while downloadable. +router.get('/:token', viewLimiter, tokenValidator, handleAsync(async (req, res) => { + validateRequest(req); + const transfer = await transferService.getTransferByToken(req.params.token); + if (!transfer) return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' }); + + const gate = transferService.assertDownloadable(transfer); + if (!gate.ok) { + return successResponse(res, { + transfer: { + title: transfer.title || 'Transfer', + status: gate.code === 'DOWNLOAD_LIMIT_REACHED' ? 'limit_reached' : 'expired', + expires_at: transfer.expires_at, + downloadable: false, + }, + }); + } + + const view = await transferService.getPublicView(transfer); + return successResponse(res, { transfer: { ...view, status: 'active', downloadable: true } }); +})); + +// Download all as a ZIP of originals. +router.get('/:token/download', downloadLimiter, tokenValidator, handleAsync(async (req, res) => { + validateRequest(req); + const transfer = await transferService.getTransferByToken(req.params.token); + const gate = transferService.assertDownloadable(transfer); + if (!gate.ok) { + return res.status(gate.status).json({ error: 'This link is no longer available', code: gate.code }); + } + // Count the download BEFORE streaming so a mid-stream disconnect still + // counts against the cap (matches the "disable after N downloads" intent). + await transferService.recordDownload(transfer, { kind: 'all', ip: clientIpForAudit(req) }); + await transferService.streamTransferArchive(transfer, res); +})); + +// Download a single original file. The file id is prefixed — `p` for a +// referenced gallery photo, `x` for an admin-uploaded deliverable file (a +// bare number is tolerated as a photo id) — so the service reads the right table. +router.get('/:token/download/:fileId', downloadLimiter, + [...tokenValidator, param('fileId').matches(/^[px]?[0-9]{1,15}$/i)], + handleAsync(async (req, res) => { + validateRequest(req); + const transfer = await transferService.getTransferByToken(req.params.token); + const gate = transferService.assertDownloadable(transfer); + if (!gate.ok) { + return res.status(gate.status).json({ error: 'This link is no longer available', code: gate.code }); + } + const ok = await transferService.streamTransferFile( + transfer, req.params.fileId, res, + ); + if (!ok && !res.headersSent) { + return res.status(404).json({ error: 'File not found', code: 'FILE_NOT_FOUND' }); + } + if (ok) { + await transferService.recordDownload(transfer, { + kind: 'single', photoId: null, ip: clientIpForAudit(req), + }); + } + }), +); + +module.exports = router; diff --git a/backend/src/routes/publicTransferUpload.js b/backend/src/routes/publicTransferUpload.js new file mode 100644 index 00000000..5be65a15 --- /dev/null +++ b/backend/src/routes/publicTransferUpload.js @@ -0,0 +1,187 @@ +/** + * Public → Transfer upload routes (PicTransfer client uploads, #997). + * + * Mounted at /api/public/transfer-upload. NO authentication — a short (6-char) + * upload token in the link is the only secret. This lets a photographer send a + * client "here's a code, upload your logo / files here". Because the token is + * low-entropy, brute force is mitigated by a tight per-route rate limiter plus + * the shared per-IP bad-attempt lockout, and the guard runs BEFORE multer so a + * bad token never costs a disk write. + * + * Surface: + * GET /:token metadata (transfer title, allowed types, size limit) + * POST /:token multipart upload (field name: files) + */ + +const express = require('express'); +const fs = require('fs'); +const path = require('path'); +const multer = require('multer'); +const rateLimit = require('express-rate-limit'); +const { param } = require('express-validator'); +const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); +const { requireFeatureFlag } = require('../middleware/requireFeatureFlag'); +const { clientIpForAudit } = require('../utils/clientIp'); +const { validateFileType } = require('../utils/fileSecurityUtils'); +const { sanitizeFilename } = require('../utils/filenameSanitizer'); +const { getAppSetting } = require('../utils/appSettings'); +const { getStorage } = require('../services/storage'); +const transferService = require('../services/transferService'); +const { _internal: tokenLock } = require('../utils/publicTokenGuards'); +const logger = require('../utils/logger'); + +const router = express.Router(); + +// Fail closed when PicTransfer is off — no client upload accepted (or even +// probed) once an admin disables the feature under Settings → Features. +router.use(requireFeatureFlag('transfers')); + +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); +const MAX_FILES_PER_UPLOAD = 25; +const DEFAULT_ALLOWED = ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/tiff', 'application/pdf', 'application/zip']; + +const infoLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false }); +const uploadLimiter = rateLimit({ windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false }); + +// Upload tokens are drawn from an unambiguous alphabet (see transferService). +// Accept a small range of lengths so a future longer token still validates. +const TOKEN_RE = /^[A-Za-z0-9]{4,16}$/; + +async function loadUploadTransfer(req, res) { + const ip = clientIpForAudit(req); + if (tokenLock.isIpLocked(ip)) { + res.status(429).json({ error: 'Too many invalid attempts. Try again later.', code: 'TOKEN_LOOKUP_LOCKED' }); + return null; + } + const token = req.params.token; + if (!token || !TOKEN_RE.test(token)) { + res.status(400).json({ error: 'Invalid token format', code: 'BAD_TOKEN' }); + return null; + } + const transfer = await transferService.getTransferByUploadToken(token); + if (!transfer) { + tokenLock.recordBadAttempt(ip); + res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' }); + return null; + } + const gate = transferService.assertUploadable(transfer); + if (!gate.ok) { + res.status(gate.status).json({ error: 'This upload link is no longer available', code: gate.code }); + return null; + } + return transfer; +} + +// Metadata for the upload page. +router.get('/:token', infoLimiter, [param('token').matches(TOKEN_RE)], handleAsync(async (req, res) => { + validateRequest(req); + const transfer = await loadUploadTransfer(req, res); + if (!transfer) return; + const maxSizeMb = Number(await getAppSetting('transfer_max_upload_size_mb', 50)) || 50; + const allowed = await getAppSetting('transfer_upload_allowed_mime', DEFAULT_ALLOWED); + return successResponse(res, { + transfer: { + title: transfer.title || 'Upload', + message: transfer.message || null, + expires_at: transfer.upload_expires_at || transfer.expires_at, + max_size_mb: maxSizeMb, + max_files: MAX_FILES_PER_UPLOAD, + allowed_mime: Array.isArray(allowed) ? allowed : DEFAULT_ALLOWED, + }, + }); +})); + +// Pre-multer guard: validates the token + upload eligibility BEFORE any bytes +// touch disk, and stashes the transfer for the destination/handler. +async function preUploadGuard(req, res, next) { + try { + const transfer = await loadUploadTransfer(req, res); + if (!transfer) return; // response already sent + req.transferRow = transfer; + next(); + } catch (err) { + logger.error('preUploadGuard error', { error: err.message }); + if (!res.headersSent) res.status(500).json({ error: 'Internal error' }); + } +} + +// Multer writes to a per-transfer temp dir; we then hand files to the storage +// backend (so S3 works too) and delete the temp copy. +const tempStorage = multer.diskStorage({ + destination: (req, file, cb) => { + const dir = path.join(getStoragePath(), 'temp', 'transfer-uploads'); + fs.mkdirSync(dir, { recursive: true }); + cb(null, dir); + }, + filename: (req, file, cb) => { + const safe = sanitizeFilename(path.basename(file.originalname), 60) || 'file'; + cb(null, `${Date.now()}-${Math.round(Math.random() * 1e6)}-${safe}`); + }, +}); + +function buildUploader(maxSizeBytes, allowed) { + return multer({ + storage: tempStorage, + limits: { fileSize: maxSizeBytes, files: MAX_FILES_PER_UPLOAD }, + fileFilter: (req, file, cb) => { + if (validateFileType(file.originalname, file.mimetype, allowed)) return cb(null, true); + return cb(new Error('This file type is not allowed')); + }, + }).array('files', MAX_FILES_PER_UPLOAD); +} + +router.post('/:token', uploadLimiter, [param('token').matches(TOKEN_RE)], preUploadGuard, handleAsync(async (req, res) => { + const transfer = req.transferRow; + const maxSizeMb = Number(await getAppSetting('transfer_max_upload_size_mb', 50)) || 50; + const allowedSetting = await getAppSetting('transfer_upload_allowed_mime', DEFAULT_ALLOWED); + const allowed = Array.isArray(allowedSetting) ? allowedSetting : DEFAULT_ALLOWED; + const uploader = buildUploader(maxSizeMb * 1024 * 1024, allowed); + + try { + await new Promise((resolve, reject) => { + uploader(req, res, (err) => (err ? reject(err) : resolve())); + }); + } catch (err) { + // Translate multer errors to a clean 4xx. + const msg = err && err.code === 'LIMIT_FILE_SIZE' + ? `Each file must be ${maxSizeMb} MB or smaller` + : (err && err.message) || 'Upload failed'; + if (!res.headersSent) res.status(400).json({ error: msg, code: 'UPLOAD_REJECTED' }); + return; + } + + if (!req.files || !req.files.length) { + return res.status(400).json({ error: 'No files uploaded', code: 'NO_FILES' }); + } + + const storage = getStorage(); + const ip = clientIpForAudit(req); + const saved = []; + for (const file of req.files) { + const safeName = sanitizeFilename(path.basename(file.originalname), 120) || 'file'; + const key = path.posix.join(transferService.uploadDirKey(transfer.id), `${Date.now()}-${saved.length}-${safeName}`); + try { + await storage.putFromFile(key, file.path); + await transferService.addUpload(transfer.id, { + originalFilename: file.originalname, + storedPath: key, + sizeBytes: file.size, + mimeType: file.mimetype, + ip, + }); + saved.push({ filename: file.originalname, size_bytes: file.size }); + } catch (err) { + logger.error('transfer upload: failed to store file', { transferId: transfer.id, error: err.message }); + } finally { + // Remove the temp copy regardless of outcome. + try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch (_) { /* noop */ } + } + } + + if (!saved.length) { + return res.status(500).json({ error: 'Could not store the uploaded files', code: 'STORE_FAILED' }); + } + return successResponse(res, { uploaded: saved.length, files: saved }, 201, 'Files uploaded'); +})); + +module.exports = router; diff --git a/backend/src/services/transferCleanupService.js b/backend/src/services/transferCleanupService.js new file mode 100644 index 00000000..b419449a --- /dev/null +++ b/backend/src/services/transferCleanupService.js @@ -0,0 +1,147 @@ +/** + * transferCleanupService — retention lifecycle for PicTransfer (#997). + * + * Runs hourly (offset from the gallery expiration checker so the two don't + * collide) and drives three transitions: + * + * 1. Expire — an active transfer past `expires_at` is disabled + * (is_active=false, disabled_at=now). This is the "disable the + * link after the set time period" behaviour. A transfer + * disabled early by its download cap is already in this state. + * 2. Notify — the admin is emailed once when a transfer becomes inactive + * (admin_notified_at stamped so it never repeats). + * 3. Delete — `grace_days` after disable, the client-uploaded files are + * removed and the transfer record is dropped. (The gallery + * originals a transfer pointed at are owned by their events and + * are never touched — only the transfer's own ad-hoc uploads + * are deleted, which is what the retention cap is about.) + */ + +const cron = require('node-cron'); +const { db } = require('../database/db'); +const logger = require('../utils/logger'); +const { formatBoolean } = require('../utils/dbCompat'); +const { sendTemplateEmail } = require('./emailProcessor'); +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'); +} + +async function runTransferCleanup() { + try { + await expireTransfers(); + await notifyExpiredTransfers(); + await deleteRetiredTransfers(); + } catch (err) { + logger.error('Transfer cleanup error', { error: err.message }); + } +} + +/** Disable links whose time window has passed. */ +async function expireTransfers() { + const now = new Date(); + const due = await db('transfers') + .where('is_active', formatBoolean(true)) + .whereNull('deleted_at') + .whereNotNull('expires_at') + .where('expires_at', '<=', now); + + for (const t of due) { + await db('transfers').where({ id: t.id }).update({ + is_active: formatBoolean(false), + disabled_at: t.disabled_at || now, + updated_at: now, + }); + logger.info(`Transfer ${t.id} expired`); + } +} + +/** Email the admin(s) once per transfer that has become inactive. */ +async function notifyExpiredTransfers() { + const pending = await db('transfers') + .where('is_active', formatBoolean(false)) + .whereNull('deleted_at') + .whereNull('admin_notified_at') + .whereNotNull('disabled_at'); + + if (!pending.length) return; + + const admins = await db('admin_users') + .where('is_active', formatBoolean(true)) + .whereNotNull('email') + .select('email'); + const adminUrl = `${transferService.getFrontendUrl()}/admin/transfers`; + + for (const t of pending) { + const fileCount = await db('transfer_files').where('transfer_id', t.id).count('* as c').first(); + const uploadCount = await db('transfer_uploads').where('transfer_id', t.id).count('* as c').first(); + const grace = Number(t.grace_days) || 0; + const deleteDate = new Date(new Date(t.disabled_at).getTime() + grace * DAY_MS); + + const vars = { + transfer_title: t.title || `Transfer #${t.id}`, + expiry_date: new Date(t.disabled_at).toISOString().slice(0, 10), + file_count: String(Number(fileCount?.c) || 0), + upload_count: String(Number(uploadCount?.c) || 0), + grace_days: String(grace), + delete_date: deleteDate.toISOString().slice(0, 10), + admin_url: adminUrl, + }; + + let sent = false; + for (const { email } of admins) { + try { + await sendTemplateEmail(email, 'transfer_link_expired', vars); + sent = true; + } catch (err) { + // Email not configured / SMTP down — don't spin forever retrying; just + // stamp so the sweep moves on. The transfer still expires + deletes. + logger.warn('Failed to send transfer_link_expired notification', { + transferId: t.id, email, error: err.message, + }); + } + } + + // Stamp regardless so we notify at most once even if delivery failed + // (avoids an unbounded retry loop every hour). + await db('transfers').where({ id: t.id }).update({ admin_notified_at: new Date() }); + if (sent) logger.info(`Notified admins that transfer ${t.id} expired`); + } +} + +/** Hard-delete transfers whose retention window has fully elapsed. */ +async function deleteRetiredTransfers() { + const candidates = await db('transfers') + .where('is_active', formatBoolean(false)) + .whereNull('deleted_at') + .whereNotNull('disabled_at'); + + const now = Date.now(); + for (const t of candidates) { + const grace = Number(t.grace_days) || 0; + const deleteAt = new Date(t.disabled_at).getTime() + grace * DAY_MS; + if (deleteAt > now) continue; + try { + await transferService.deleteTransfer(t.id); + logger.info(`Transfer ${t.id} deleted after ${grace}-day retention`); + } catch (err) { + logger.error('Failed to delete retired transfer', { transferId: t.id, error: err.message }); + } + } +} + +module.exports = { + startTransferCleanup, + // exported for tests / manual invocation + runTransferCleanup, + expireTransfers, + notifyExpiredTransfers, + deleteRetiredTransfers, +}; diff --git a/backend/src/services/transferService.js b/backend/src/services/transferService.js new file mode 100644 index 00000000..f12fa1be --- /dev/null +++ b/backend/src/services/transferService.js @@ -0,0 +1,1014 @@ +/** + * transferService — PicTransfer (#997). + * + * A "transfer" is a share link that bundles ORIGINAL photos picked from any + * number of events and hands them to a recipient as a download link. It can + * also open a short (6-char) upload token so the client can send files back + * (logos etc.). + * + * Design decisions (from the issue): + * - Downloads always serve ORIGINAL files, never watermarked — a transfer is + * a deliberate hand-off, not a preview. + * - The ZIP is built on demand by replicating the gallery download-selected + * loop (resolvePhotoStorageKey → storage.get → archiver), generalised to + * span multiple events. No pre-generation / caching. + * - The link is simply disabled after `expires_at`; an optional max-downloads + * cap can disable it earlier. Files are kept `grace_days` days past disable + * (retention), then the cleanup sweep hard-deletes them. + */ + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const archiver = require('archiver'); + +const { db } = require('../database/db'); +const logger = require('../utils/logger'); +const { formatBoolean } = require('../utils/dbCompat'); +const { getAppSetting } = require('../utils/appSettings'); +const { getStorage } = require('./storage'); +const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver'); +const { getUseOriginalFilenames, getZipEntryNames } = require('./downloadFilenameService'); +const { sanitizeForZipEntry } = require('../utils/filenameSanitizer'); +const { filterOwnedEventIds } = require('../middleware/ownership'); + +// Unambiguous alphabet for the client upload token — no 0/O/1/I/L to keep it +// easy to read aloud / type from an email. 6 chars ≈ 31 bits; brute force is +// mitigated by the per-route rate limiter + IP lockout on the upload endpoint. +const UPLOAD_TOKEN_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; +const UPLOAD_TOKEN_LENGTH = 6; + +const DAY_MS = 24 * 60 * 60 * 1000; + +function getFrontendUrl() { + return (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/+$/, ''); +} + +function generateDownloadToken() { + return crypto.randomBytes(32).toString('hex'); // 64 hex chars +} + +function generateUploadTokenCandidate() { + let out = ''; + for (let i = 0; i < UPLOAD_TOKEN_LENGTH; i += 1) { + // crypto.randomInt is unbiased over [0, len); a plain byte % len would + // over-represent the first (256 % len) characters of the alphabet. + out += UPLOAD_TOKEN_ALPHABET[crypto.randomInt(0, UPLOAD_TOKEN_ALPHABET.length)]; + } + return out; +} + +/** + * Return the subset of `photoIds` whose event the admin may act on. Mirrors the + * event-ownership rule used everywhere else (super_admin unrestricted; others + * get events they created plus ownerless legacy events) so a scoped admin can + * never bundle — and then hand out via a public token — originals from an event + * they don't own. Foreign and non-existent ids are both dropped. + */ +async function filterOwnedPhotoIds(admin, photoIds) { + const ids = [...new Set((photoIds || []).map((n) => parseInt(n, 10)).filter(Boolean))]; + if (!ids.length) return []; + const photos = await db('photos').whereIn('id', ids).select('id', 'event_id'); + const eventIds = [...new Set(photos.map((p) => p.event_id))]; + if (!eventIds.length) return []; + const { allowed } = await filterOwnedEventIds(admin, eventIds); + const allowedEvents = new Set(allowed.map(Number)); + return photos.filter((p) => allowedEvents.has(Number(p.event_id))).map((p) => p.id); +} + +async function generateUniqueUploadToken(conn = db) { + for (let attempt = 0; attempt < 12; attempt += 1) { + const candidate = generateUploadTokenCandidate(); + const clash = await conn('transfers').where({ upload_token: candidate }).first('id'); + if (!clash) return candidate; + } + // Astronomically unlikely; fall back to a longer token so we never loop. + return generateUploadTokenCandidate() + generateUploadTokenCandidate(); +} + +/** Storage-relative directory that holds a transfer's client uploads. */ +function uploadDirKey(transferId) { + return path.posix.join('uploads/transfers', String(transferId)); +} + +/** + * Storage-relative directory for the admin's own deliverable files — the files + * dropped straight into a transfer at creation (transfer_extra_files), as + * opposed to the gallery photos it references or the client's return uploads. + */ +function extraFilesDirKey(transferId) { + return path.posix.join('transfers', String(transferId), 'files'); +} + +/** + * Derive the recipient-facing/admin status of a transfer row. + * Never mutates — the cron sweep is what actually flips is_active/deleted_at. + */ +function computeStatus(transfer) { + if (transfer.deleted_at) return 'deleted'; + const now = Date.now(); + const expired = !transfer.is_active + || (transfer.expires_at && new Date(transfer.expires_at).getTime() <= now); + if (expired) return 'expired'; + return 'active'; +} + +function downloadsRemaining(transfer) { + const cap = Number(transfer.max_downloads) || 0; + if (cap <= 0) return null; // unlimited + return Math.max(0, cap - (Number(transfer.download_count) || 0)); +} + +// --------------------------------------------------------------------------- +// Admin CRUD +// --------------------------------------------------------------------------- + +async function createTransfer(input, admin) { + const adminId = admin && admin.id ? admin.id : null; + const { + title = '', + message = null, + expiresInDays, + maxDownloads, + graceDays, + allowUploads = false, + uploadExpiresInDays, + photoIds = [], + deliveryMethod = 'link', + } = input || {}; + + const defaultExpiry = await getAppSetting('transfer_default_expiry_days', 14); + const defaultGrace = await getAppSetting('transfer_default_grace_days', 7); + const defaultMax = await getAppSetting('transfer_default_max_downloads', 0); + + const expiryDays = Number.isFinite(Number(expiresInDays)) && Number(expiresInDays) > 0 + ? Number(expiresInDays) : Number(defaultExpiry) || 14; + const grace = Number.isFinite(Number(graceDays)) && Number(graceDays) >= 0 + ? Number(graceDays) : Number(defaultGrace) || 7; + const cap = Number.isFinite(Number(maxDownloads)) && Number(maxDownloads) > 0 + ? Number(maxDownloads) : (Number(defaultMax) > 0 ? Number(defaultMax) : null); + + const now = new Date(); + const expiresAt = new Date(now.getTime() + expiryDays * DAY_MS); + + const row = { + token: generateDownloadToken(), + title: String(title || '').slice(0, 255), + message: message || null, + created_by: adminId || null, + expires_at: expiresAt, + max_downloads: cap, + download_count: 0, + is_active: formatBoolean(true), + grace_days: grace, + allow_uploads: formatBoolean(!!allowUploads), + delivery_method: deliveryMethod === 'email' ? 'email' : 'link', + created_at: now, + updated_at: now, + }; + + if (allowUploads) { + row.upload_token = await generateUniqueUploadToken(); + const uploadDays = Number.isFinite(Number(uploadExpiresInDays)) && Number(uploadExpiresInDays) > 0 + ? Number(uploadExpiresInDays) : expiryDays; + row.upload_expires_at = new Date(now.getTime() + uploadDays * DAY_MS); + } + + const [id] = await db('transfers').insert(row).returning('id'); + const transferId = typeof id === 'object' && id !== null ? id.id : id; + + if (Array.isArray(photoIds) && photoIds.length) { + await addFiles(transferId, photoIds, admin); + } + + return getTransfer(transferId); +} + +async function listTransfers({ search = '', admin } = {}) { + let query = db('transfers').whereNull('deleted_at'); + + // Non-super_admins only see their own transfers (plus ownerless legacy rows). + // Otherwise the list — which used to carry each transfer's download token — + // handed every admin a public link to everyone else's originals. + if (admin && admin.roleName !== 'super_admin') { + query = query.where((q) => q.whereNull('created_by').orWhere('created_by', admin.id)); + } + if (search) { + query = query.where('title', 'like', `%${search}%`); + } + query = query.orderBy('created_at', 'desc'); + + const rows = await query; + const ids = rows.map((r) => r.id); + + // File + upload counts in two grouped queries rather than N+1. + const fileCounts = ids.length + ? await db('transfer_files').whereIn('transfer_id', ids) + .select('transfer_id').count('* as count').groupBy('transfer_id') + : []; + const uploadCounts = ids.length + ? await db('transfer_uploads').whereIn('transfer_id', ids) + .select('transfer_id').count('* as count').groupBy('transfer_id') + : []; + // Admin-uploaded deliverable files count toward file_count alongside photos. + const extraCounts = ids.length + ? await db('transfer_extra_files').whereIn('transfer_id', ids) + .select('transfer_id').count('* as count').groupBy('transfer_id') + : []; + const fileCountMap = new Map(fileCounts.map((r) => [r.transfer_id, Number(r.count)])); + const uploadCountMap = new Map(uploadCounts.map((r) => [r.transfer_id, Number(r.count)])); + const extraCountMap = new Map(extraCounts.map((r) => [r.transfer_id, Number(r.count)])); + + return rows.map((r) => { + // The list view never needs the secrets — a row is a summary, and the + // recipient/upload links live on the detail response. Strip them so the + // list can't be used to read another (or one's own, over-broadly) token. + const safe = serializeTransfer(r); + delete safe.token; + delete safe.upload_token; + delete safe.download_url; + delete safe.upload_url; + safe.file_count = (fileCountMap.get(r.id) || 0) + (extraCountMap.get(r.id) || 0); + safe.upload_count = uploadCountMap.get(r.id) || 0; + return safe; + }); +} + +function serializeTransfer(row) { + return { + id: row.id, + token: row.token, + title: row.title, + message: row.message, + created_by: row.created_by, + expires_at: row.expires_at, + max_downloads: row.max_downloads || null, + download_count: row.download_count || 0, + downloads_remaining: downloadsRemaining(row), + is_active: row.is_active === true || row.is_active === 1, + disabled_at: row.disabled_at || null, + grace_days: row.grace_days, + deleted_at: row.deleted_at || null, + allow_uploads: row.allow_uploads === true || row.allow_uploads === 1, + delivery_method: row.delivery_method === 'email' ? 'email' : 'link', + upload_token: row.upload_token || null, + upload_expires_at: row.upload_expires_at || null, + created_at: row.created_at, + updated_at: row.updated_at, + status: computeStatus(row), + download_url: `/transfer/${row.token}`, + upload_url: row.upload_token ? `/transfer-upload/${row.upload_token}` : null, + }; +} + +/** Full detail: transfer + its files (with photo/event info) + client uploads. */ +async function getTransfer(id) { + const row = await db('transfers').where({ id }).first(); + if (!row) return null; + + const files = await db('transfer_files') + .join('photos', 'photos.id', 'transfer_files.photo_id') + .join('events', 'events.id', 'photos.event_id') + .where('transfer_files.transfer_id', id) + .orderBy('transfer_files.sort_order', 'asc') + .orderBy('transfer_files.id', 'asc') + .select( + 'transfer_files.id as file_id', + 'transfer_files.sort_order', + 'photos.id as photo_id', + 'photos.filename', + 'photos.original_filename', + 'photos.type', + 'photos.size_bytes', + 'photos.event_id', + 'events.event_name', + 'events.slug as event_slug', + ); + + const uploads = await db('transfer_uploads') + .where('transfer_id', id) + .orderBy('uploaded_at', 'desc') + .select('id', 'original_filename', 'size_bytes', 'mime_type', 'uploader_ip', 'uploaded_at'); + + // Admin-uploaded deliverable files (no photo/event — the operator's own bytes). + const extraFiles = await db('transfer_extra_files') + .where('transfer_id', id) + .orderBy('sort_order', 'asc') + .orderBy('id', 'asc') + .select('id', 'original_filename', 'size_bytes', 'mime_type', 'created_at'); + + const recipients = await db('transfer_recipients') + .where('transfer_id', id) + .orderBy('id', 'asc') + .select('id', 'email', 'last_sent_at'); + + return { + ...serializeTransfer(row), + file_count: files.length + extraFiles.length, + upload_count: uploads.length, + extra_files: extraFiles.map((f) => ({ + id: f.id, + filename: f.original_filename, + size_bytes: f.size_bytes, + mime_type: f.mime_type, + })), + recipients: recipients.map((r) => ({ id: r.id, email: r.email, last_sent_at: r.last_sent_at || null })), + files: files.map((f) => ({ + file_id: f.file_id, + photo_id: f.photo_id, + filename: f.original_filename || f.filename, + type: f.type, + size_bytes: f.size_bytes, + event_id: f.event_id, + event_name: f.event_name, + event_slug: f.event_slug, + // Admin picker previews thumbnails via the existing admin photo endpoint. + thumbnail_url: `/admin/photos/${f.event_id}/thumbnail/${f.photo_id}`, + })), + uploads, + }; +} + +/** Minimal row for the ownership guard: { id, created_by } or undefined. */ +async function getTransferOwner(id) { + return db('transfers').where({ id }).whereNull('deleted_at').first('id', 'created_by'); +} + +async function updateTransfer(id, fields) { + const row = await db('transfers').where({ id }).first(); + if (!row) return null; + + const update = { updated_at: new Date() }; + if (fields.title !== undefined) update.title = String(fields.title || '').slice(0, 255); + if (fields.message !== undefined) update.message = fields.message || null; + if (fields.maxDownloads !== undefined) { + const cap = Number(fields.maxDownloads); + update.max_downloads = Number.isFinite(cap) && cap > 0 ? cap : null; + } + if (fields.graceDays !== undefined) { + const grace = Number(fields.graceDays); + if (Number.isFinite(grace) && grace >= 0) update.grace_days = grace; + } + if (fields.expiresAt !== undefined) { + update.expires_at = new Date(fields.expiresAt); + } else if (fields.expiresInDays !== undefined) { + const days = Number(fields.expiresInDays); + if (Number.isFinite(days) && days > 0) { + update.expires_at = new Date(Date.now() + days * DAY_MS); + } + } + if (fields.isActive !== undefined) { + update.is_active = formatBoolean(!!fields.isActive); + // Re-activating clears the retention clock; disabling starts it. + if (fields.isActive) { + update.disabled_at = null; + update.admin_notified_at = null; + } else if (!row.disabled_at) { + update.disabled_at = new Date(); + } + } + + await db('transfers').where({ id }).update(update); + return getTransfer(id); +} + +async function deleteTransfer(id) { + const row = await db('transfers').where({ id }).first(); + if (!row) return false; + await removeUploadedFiles(id); + await removeExtraFiles(id); + // transfer_files / transfer_uploads / transfer_downloads / transfer_extra_files + // / transfer_recipients cascade on the FK, but we delete explicitly too so the + // feature works even where SQLite FK enforcement is off. + await db('transfer_files').where({ transfer_id: id }).del(); + await db('transfer_uploads').where({ transfer_id: id }).del(); + await db('transfer_downloads').where({ transfer_id: id }).del(); + await db('transfer_extra_files').where({ transfer_id: id }).del(); + await db('transfer_recipients').where({ transfer_id: id }).del(); + await db('transfers').where({ id }).del(); + return true; +} + +async function addFiles(transferId, photoIds, admin) { + const ids = [...new Set((photoIds || []).map((n) => parseInt(n, 10)).filter(Boolean))]; + if (!ids.length) return getTransfer(transferId); + + // Only photos whose event the caller owns (ownership implies existence). + // Without this a scoped admin could bundle any event's originals and hand + // them out through the public download token — every ownership control + // bypassed. Mirrors the GHSA-wrg5 fix pattern. + const ownedIds = await filterOwnedPhotoIds(admin, ids); + const validIds = new Set(ownedIds); + + // Skip photos already attached (the unique index would reject them anyway). + const already = await db('transfer_files') + .where('transfer_id', transferId) + .whereIn('photo_id', ids) + .select('photo_id'); + const alreadySet = new Set(already.map((r) => r.photo_id)); + + const maxOrderRow = await db('transfer_files') + .where('transfer_id', transferId) + .max('sort_order as max') + .first(); + let order = (maxOrderRow && Number(maxOrderRow.max)) || 0; + + const rows = ids + .filter((pid) => validIds.has(pid) && !alreadySet.has(pid)) + .map((pid) => { + order += 1; + return { transfer_id: transferId, photo_id: pid, sort_order: order, created_at: new Date() }; + }); + + if (rows.length) { + await db('transfer_files').insert(rows); + await db('transfers').where({ id: transferId }).update({ updated_at: new Date() }); + } + return getTransfer(transferId); +} + +async function removeFile(transferId, fileId) { + await db('transfer_files').where({ id: fileId, transfer_id: transferId }).del(); + await db('transfers').where({ id: transferId }).update({ updated_at: new Date() }); + return getTransfer(transferId); +} + +async function enableUploads(transferId, { uploadExpiresInDays } = {}) { + const row = await db('transfers').where({ id: transferId }).first(); + if (!row) return null; + const now = new Date(); + const days = Number.isFinite(Number(uploadExpiresInDays)) && Number(uploadExpiresInDays) > 0 + ? Number(uploadExpiresInDays) + : Math.max(1, Math.ceil((new Date(row.expires_at).getTime() - now.getTime()) / DAY_MS)); + const update = { + allow_uploads: formatBoolean(true), + upload_token: row.upload_token || (await generateUniqueUploadToken()), + upload_expires_at: new Date(now.getTime() + days * DAY_MS), + updated_at: now, + }; + await db('transfers').where({ id: transferId }).update(update); + return getTransfer(transferId); +} + +async function disableUploads(transferId) { + await db('transfers').where({ id: transferId }).update({ + allow_uploads: formatBoolean(false), + upload_token: null, + upload_expires_at: null, + updated_at: new Date(), + }); + return getTransfer(transferId); +} + +// --------------------------------------------------------------------------- +// Public lookups (token-authenticated) +// --------------------------------------------------------------------------- + +async function getTransferByToken(token) { + return db('transfers').where({ token }).whereNull('deleted_at').first(); +} + +async function getTransferByUploadToken(uploadToken) { + return db('transfers').where({ upload_token: uploadToken }).whereNull('deleted_at').first(); +} + +/** + * Recipient-facing projection — filenames + sizes only. The download page has + * NO thumbnails by design, so we deliberately don't expose any image URLs. + */ +async function getPublicView(transfer) { + const files = await db('transfer_files') + .join('photos', 'photos.id', 'transfer_files.photo_id') + .where('transfer_files.transfer_id', transfer.id) + .orderBy('transfer_files.sort_order', 'asc') + .orderBy('transfer_files.id', 'asc') + .select( + 'transfer_files.id as file_id', + 'photos.filename', + 'photos.original_filename', + 'photos.size_bytes', + ); + + const extraFiles = await db('transfer_extra_files') + .where('transfer_id', transfer.id) + .orderBy('sort_order', 'asc') + .orderBy('id', 'asc') + .select('id', 'original_filename', 'size_bytes'); + + const useOriginal = await getUseOriginalFilenames(); + const totalBytes = files.reduce((sum, f) => sum + (Number(f.size_bytes) || 0), 0) + + extraFiles.reduce((sum, f) => sum + (Number(f.size_bytes) || 0), 0); + + // Public file ids are prefixed so the single-file route knows which table to + // read: `p` = a referenced gallery photo, `x` = an admin-uploaded file. + const photoEntries = files.map((f) => ({ + file_id: `p${f.file_id}`, + filename: (useOriginal && f.original_filename) ? f.original_filename : f.filename, + size_bytes: f.size_bytes || null, + })); + const extraEntries = extraFiles.map((f) => ({ + file_id: `x${f.id}`, + filename: f.original_filename, + size_bytes: f.size_bytes || null, + })); + + return { + title: transfer.title || 'Transfer', + message: transfer.message || null, + expires_at: transfer.expires_at, + file_count: files.length + extraFiles.length, + total_bytes: totalBytes, + downloads_remaining: downloadsRemaining(transfer), + files: [...photoEntries, ...extraEntries], + }; +} + +/** + * Whether a transfer can currently be downloaded. Returns a reason code so the + * route can map it to a clean 403/410. + */ +function assertDownloadable(transfer) { + if (!transfer || transfer.deleted_at) return { ok: false, code: 'NOT_FOUND', status: 404 }; + const isActive = transfer.is_active === true || transfer.is_active === 1; + if (!isActive) return { ok: false, code: 'TRANSFER_DISABLED', status: 410 }; + if (transfer.expires_at && new Date(transfer.expires_at).getTime() <= Date.now()) { + return { ok: false, code: 'TRANSFER_EXPIRED', status: 410 }; + } + const remaining = downloadsRemaining(transfer); + if (remaining !== null && remaining <= 0) { + return { ok: false, code: 'DOWNLOAD_LIMIT_REACHED', status: 410 }; + } + return { ok: true }; +} + +/** Record one download and, if it hit the cap, flip the link inactive. */ +async function recordDownload(transfer, { kind = 'all', photoId = null, ip = null } = {}) { + await db('transfer_downloads').insert({ + transfer_id: transfer.id, + kind, + photo_id: photoId, + ip, + downloaded_at: new Date(), + }); + await db('transfers').where({ id: transfer.id }).increment('download_count', 1); + + const cap = Number(transfer.max_downloads) || 0; + if (cap > 0) { + // Evaluate the cap against the freshly-incremented persisted count, not the + // stale in-memory `transfer.download_count` — two concurrent downloads + // reading the same snapshot would otherwise both think they're under the + // cap and blow past it. The disable is idempotent, so a double-trip here is + // harmless. + const fresh = await db('transfers').where({ id: transfer.id }) + .first('download_count', 'is_active'); + const count = Number(fresh && fresh.download_count) || 0; + const stillActive = fresh && (fresh.is_active === true || fresh.is_active === 1); + if (count >= cap && stillActive) { + // Cap reached — disable and start the retention clock. + await db('transfers').where({ id: transfer.id }).update({ + is_active: formatBoolean(false), + disabled_at: new Date(), + updated_at: new Date(), + }); + } + } +} + +// --------------------------------------------------------------------------- +// ZIP building — cross-event, originals only +// --------------------------------------------------------------------------- + +/** Load the ordered photos for a transfer, each joined to its event. */ +async function loadTransferPhotos(transferId) { + const rows = await db('transfer_files') + .join('photos', 'photos.id', 'transfer_files.photo_id') + .join('events', 'events.id', 'photos.event_id') + .where('transfer_files.transfer_id', transferId) + .orderBy('transfer_files.sort_order', 'asc') + .orderBy('transfer_files.id', 'asc') + .select( + 'photos.*', + 'events.slug as event_slug', + 'events.event_name as event_name', + 'events.source_mode as event_source_mode', + 'events.external_path as event_external_path', + ); + return rows; +} + +/** + * Stream a ZIP of a transfer's ORIGINAL files to `res`. Mirrors the gallery + * download-selected loop but spans events: each photo carries its own event + * fields (aliased above) so the resolver gets the right event. Photos are + * grouped into per-event subfolders to keep same-named files apart. + * + * Returns the number of files successfully appended. + */ +async function streamTransferArchive(transfer, res) { + const photos = await loadTransferPhotos(transfer.id); + + const archiveName = `${sanitizeForZipEntry(transfer.title || 'transfer') || 'transfer'}.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('transferService: archive error', { transferId: transfer.id, error: err.message }); + try { res.destroy(err); } catch (_) { /* noop */ } + }); + archive.pipe(res); + + const storage = getStorage(); + const useOriginal = await getUseOriginalFilenames(); + const entryNames = getZipEntryNames(photos, useOriginal); + const multiEvent = new Set(photos.map((p) => p.event_id)).size > 1; + + let appended = 0; + for (let i = 0; i < photos.length; i += 1) { + const photo = photos[i]; + const event = { + id: photo.event_id, + slug: photo.event_slug, + source_mode: photo.event_source_mode, + external_path: photo.event_external_path, + }; + let name = entryNames[i] || `photo-${photo.id}.jpg`; + // Only foldered when the transfer actually spans multiple events, so a + // single-event transfer stays flat. + if (multiEvent) { + const folder = sanitizeForZipEntry(photo.event_name || photo.event_slug || `event-${photo.event_id}`); + name = `${folder}/${name}`; + } + try { + const storageKey = resolvePhotoStorageKey(event, photo); + 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(event, photo))) { + throw new Error('Photo file missing on disk'); + } + + if (storageKey) { + const stream = await storage.get(storageKey); + archive.append(stream, { name }); + } else { + archive.file(resolvePhotoFilePath(event, photo), { name }); + } + appended += 1; + } catch (err) { + logger.warn('transferService: skipping photo in transfer archive', { + transferId: transfer.id, photoId: photo.id, error: err.message, + }); + } + } + + // Admin-uploaded deliverable files. Foldered under files/ only when the + // transfer also spans multiple events, to match the photo foldering above. + const extraFiles = await loadTransferExtraFiles(transfer.id); + const usedNames = new Set(); + for (const extra of extraFiles) { + let base = sanitizeForZipEntry(extra.original_filename) || `file-${extra.id}`; + if (usedNames.has(base)) base = `${extra.id}-${base}`; // keep same-named uploads apart + usedNames.add(base); + const name = multiEvent ? `files/${base}` : base; + try { + const srcStat = storage.kind() === 'local' ? await storage.stat(extra.stored_path) : true; + if (!srcStat) throw new Error(`Extra file missing in storage: ${extra.stored_path}`); + const stream = await storage.get(extra.stored_path); + archive.append(stream, { name }); + appended += 1; + } catch (err) { + logger.warn('transferService: skipping extra file in transfer archive', { + transferId: transfer.id, extraId: extra.id, error: err.message, + }); + } + } + + await archive.finalize(); + return appended; +} + +/** Load the admin-uploaded deliverable files for a transfer, in order. */ +async function loadTransferExtraFiles(transferId) { + return db('transfer_extra_files') + .where('transfer_id', transferId) + .orderBy('sort_order', 'asc') + .orderBy('id', 'asc') + .select('id', 'original_filename', 'stored_path', 'size_bytes', 'mime_type'); +} + +/** + * Stream a single ORIGINAL file from a transfer to `res`. Returns false when + * the file id isn't part of this transfer or the bytes are missing. + */ +async function streamTransferFile(transfer, rawFileId, res) { + // Public file ids are prefixed (see getPublicView): `p` = referenced + // gallery photo, `x` = admin-uploaded file. Tolerate a bare number as a + // photo id for safety. + const idStr = String(rawFileId || ''); + const prefix = /^[a-z]/i.test(idStr) ? idStr[0].toLowerCase() : 'p'; + const numId = parseInt(/^[a-z]/i.test(idStr) ? idStr.slice(1) : idStr, 10); + if (!Number.isFinite(numId) || numId <= 0) return false; + + if (prefix === 'x') { + return streamTransferExtraFile(transfer, numId, res); + } + + const row = await db('transfer_files') + .join('photos', 'photos.id', 'transfer_files.photo_id') + .join('events', 'events.id', 'photos.event_id') + .where('transfer_files.transfer_id', transfer.id) + .where('transfer_files.id', numId) + .select( + 'photos.*', + 'events.slug as event_slug', + 'events.source_mode as event_source_mode', + 'events.external_path as event_external_path', + ) + .first(); + if (!row) return false; + + const event = { + id: row.event_id, + slug: row.event_slug, + source_mode: row.event_source_mode, + external_path: row.event_external_path, + }; + const useOriginal = await getUseOriginalFilenames(); + const [name] = getZipEntryNames([row], useOriginal); + const filename = name || row.filename || `photo-${row.id}.jpg`; + + // Resolve + verify the source exists BEFORE writing any response header, so a + // missing file yields a clean 404 rather than a truncated 200. The joined row + // carries photos.* (path / source_origin / external_relpath), so it is a + // valid photo object for the resolver as-is. + const storage = getStorage(); + const storageKey = resolvePhotoStorageKey(event, row); + let source; // { type: 'stream' | 'file', value } + if (storageKey) { + if (storage.kind() === 'local') { + const srcStat = await storage.stat(storageKey); + if (!srcStat) return false; + } + source = { type: 'stream', value: await storage.get(storageKey) }; + } else { + const abs = resolvePhotoFilePath(event, row); + if (!fs.existsSync(abs)) return false; + source = { type: 'file', value: abs }; + } + + res.setHeader('Content-Type', row.mime_type || 'application/octet-stream'); + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`); + if (source.type === 'stream') { + source.value.pipe(res); + } else { + fs.createReadStream(source.value).pipe(res); + } + return true; +} + +/** Stream a single admin-uploaded deliverable file from storage to `res`. */ +async function streamTransferExtraFile(transfer, extraId, res) { + const row = await db('transfer_extra_files') + .where({ id: extraId, transfer_id: transfer.id }) + .first(); + if (!row) return false; + + const storage = getStorage(); + if (storage.kind() === 'local') { + const srcStat = await storage.stat(row.stored_path); + if (!srcStat) return false; + } + const stream = await storage.get(row.stored_path); + res.setHeader('Content-Type', row.mime_type || 'application/octet-stream'); + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(row.original_filename)}"`); + stream.pipe(res); + return true; +} + +// --------------------------------------------------------------------------- +// Admin-uploaded deliverable files +// --------------------------------------------------------------------------- + +/** Record an admin-uploaded deliverable file (bytes already written to storage). */ +async function addExtraFile(transferId, { originalFilename, storedPath, sizeBytes, mimeType }) { + const maxOrderRow = await db('transfer_extra_files') + .where('transfer_id', transferId) + .max('sort_order as max') + .first(); + const order = ((maxOrderRow && Number(maxOrderRow.max)) || 0) + 1; + const [id] = await db('transfer_extra_files').insert({ + transfer_id: transferId, + original_filename: String(originalFilename || 'file').slice(0, 512), + stored_path: storedPath, + size_bytes: sizeBytes || null, + mime_type: mimeType || null, + sort_order: order, + created_at: new Date(), + }).returning('id'); + await db('transfers').where({ id: transferId }).update({ updated_at: new Date() }); + return typeof id === 'object' && id !== null ? id.id : id; +} + +/** Remove one admin-uploaded deliverable file (row + bytes). */ +async function removeExtraFile(transferId, extraId) { + const row = await db('transfer_extra_files').where({ id: extraId, transfer_id: transferId }).first(); + if (!row) return getTransfer(transferId); + try { + await getStorage().delete(row.stored_path); + } catch (err) { + logger.warn('transferService: failed to delete extra file', { + transferId, path: row.stored_path, error: err.message, + }); + } + await db('transfer_extra_files').where({ id: extraId, transfer_id: transferId }).del(); + await db('transfers').where({ id: transferId }).update({ updated_at: new Date() }); + return getTransfer(transferId); +} + +/** Delete all admin-uploaded deliverable bytes for a transfer (hard delete). */ +async function removeExtraFiles(transferId) { + const rows = await db('transfer_extra_files').where({ transfer_id: transferId }).select('stored_path'); + const storage = getStorage(); + for (const r of rows) { + if (!r.stored_path) continue; + try { + await storage.delete(r.stored_path); + } catch (err) { + logger.warn('transferService: failed to delete extra file', { + transferId, path: r.stored_path, error: err.message, + }); + } + } + try { + if (storage.kind() === 'local') { + const dir = storage.resolveLocalPath(extraFilesDirKey(transferId)); + if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); + } + } catch (_) { /* noop */ } +} + +// --------------------------------------------------------------------------- +// Email delivery +// --------------------------------------------------------------------------- + +/** + * Email the download link to one or more recipients and record them. Sending is + * best-effort per address (a bad SMTP config must not fail the whole create); + * `sendTemplateEmail` is required lazily to avoid a service-load cycle. + */ +async function sendTransferEmails(transferId, emails) { + const clean = [...new Set((emails || []) + .map((e) => String(e || '').trim()) + .filter((e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)))]; + if (!clean.length) return { sent: 0, recipients: [] }; + + const transfer = await db('transfers').where({ id: transferId }).first(); + if (!transfer) return { sent: 0, recipients: [] }; + + const fileCountRow = await db('transfer_files').where('transfer_id', transferId).count('* as c').first(); + const extraCountRow = await db('transfer_extra_files').where('transfer_id', transferId).count('* as c').first(); + const fileCount = (Number(fileCountRow?.c) || 0) + (Number(extraCountRow?.c) || 0); + + const { sendTemplateEmail } = require('./emailProcessor'); + const downloadUrl = `${getFrontendUrl()}/transfer/${transfer.token}`; + const vars = { + transfer_title: transfer.title || `Transfer #${transferId}`, + message: transfer.message || '', + download_url: downloadUrl, + file_count: String(fileCount), + expiry_date: transfer.expires_at ? new Date(transfer.expires_at).toISOString().slice(0, 10) : '', + }; + + let sent = 0; + for (const email of clean) { + try { + await sendTemplateEmail(email, 'transfer_ready', vars); + sent += 1; + } catch (err) { + logger.warn('transferService: failed to send transfer_ready email', { + transferId, email, error: err.message, + }); + } + // Record the recipient regardless of delivery so the detail panel shows who + // it was addressed to (and a future resend has the list). + const existing = await db('transfer_recipients').where({ transfer_id: transferId, email }).first(); + if (existing) { + await db('transfer_recipients').where({ id: existing.id }).update({ last_sent_at: new Date() }); + } else { + await db('transfer_recipients').insert({ + transfer_id: transferId, email, created_at: new Date(), last_sent_at: new Date(), + }); + } + } + return { sent, recipients: clean }; +} + +// --------------------------------------------------------------------------- +// Client uploads +// --------------------------------------------------------------------------- + +function assertUploadable(transfer) { + if (!transfer || transfer.deleted_at) return { ok: false, code: 'NOT_FOUND', status: 404 }; + const allow = transfer.allow_uploads === true || transfer.allow_uploads === 1; + if (!allow) return { ok: false, code: 'UPLOADS_DISABLED', status: 403 }; + const exp = transfer.upload_expires_at || transfer.expires_at; + if (exp && new Date(exp).getTime() <= Date.now()) { + return { ok: false, code: 'UPLOAD_EXPIRED', status: 410 }; + } + return { ok: true }; +} + +/** Record a client-uploaded file (bytes already written by the route/multer). */ +async function addUpload(transferId, { originalFilename, storedPath, sizeBytes, mimeType, ip }) { + const [id] = await db('transfer_uploads').insert({ + transfer_id: transferId, + original_filename: String(originalFilename || 'file').slice(0, 512), + stored_path: storedPath, + size_bytes: sizeBytes || null, + mime_type: mimeType || null, + uploader_ip: ip || null, + uploaded_at: new Date(), + }).returning('id'); + await db('transfers').where({ id: transferId }).update({ updated_at: new Date() }); + return typeof id === 'object' && id !== null ? id.id : id; +} + +/** Resolve the on-disk path of a stored upload for admin download / deletion. */ +async function getUpload(transferId, uploadId) { + const upload = await db('transfer_uploads') + .where({ id: uploadId, transfer_id: transferId }) + .first(); + if (!upload) return null; + const storage = getStorage(); + let localPath = null; + try { + localPath = storage.kind() === 'local' ? storage.resolveLocalPath(upload.stored_path) : null; + } catch (_) { + localPath = null; + } + return { ...upload, localPath }; +} + +/** Delete all client-uploaded bytes for a transfer (retention / hard delete). */ +async function removeUploadedFiles(transferId) { + const uploads = await db('transfer_uploads').where({ transfer_id: transferId }).select('stored_path'); + const storage = getStorage(); + for (const u of uploads) { + if (!u.stored_path) continue; + try { + await storage.delete(u.stored_path); + } catch (err) { + logger.warn('transferService: failed to delete upload file', { + transferId, path: u.stored_path, error: err.message, + }); + } + } + // Best-effort: remove the now-empty per-transfer directory on local storage. + try { + if (storage.kind() === 'local') { + const dir = storage.resolveLocalPath(uploadDirKey(transferId)); + if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); + } + } catch (_) { /* noop */ } +} + +module.exports = { + // constants / helpers + UPLOAD_TOKEN_LENGTH, + getFrontendUrl, + uploadDirKey, + extraFilesDirKey, + computeStatus, + downloadsRemaining, + // admin CRUD + createTransfer, + listTransfers, + getTransfer, + getTransferOwner, + filterOwnedPhotoIds, + updateTransfer, + deleteTransfer, + addFiles, + removeFile, + addExtraFile, + removeExtraFile, + removeExtraFiles, + enableUploads, + disableUploads, + sendTransferEmails, + // public + getTransferByToken, + getTransferByUploadToken, + getPublicView, + assertDownloadable, + recordDownload, + streamTransferArchive, + streamTransferFile, + streamTransferExtraFile, + // uploads + assertUploadable, + addUpload, + getUpload, + removeUploadedFiles, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 99ab31de..51f32475 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -56,6 +56,9 @@ import { ContractDetailPage } from './pages/admin/contracts/ContractDetailPage'; import { BlockLibraryPage } from './pages/admin/contracts/BlockLibraryPage'; import { PaymentCheckPage } from './pages/public/PaymentCheckPage'; import { AcceptInvitePage } from './pages/public/AcceptInvitePage'; +import { TransfersPage } from './pages/admin/transfers/TransfersPage'; +import { TransferDownloadPage } from './pages/public/TransferDownloadPage'; +import { TransferUploadPage } from './pages/public/TransferUploadPage'; import { CustomerLoginPage, CustomerDashboardPage, @@ -239,6 +242,11 @@ function App() { } /> } /> } /> + {/* PicTransfer (#997) — cross-event file transfers. + Gated by the `transfers` flag (strictly opt-in). */} + }> + } /> + {/* Feature-gated surfaces — redirect to /admin/dashboard when flag is off. */} }> @@ -412,6 +420,11 @@ function App() { check email. */} } /> + {/* PicTransfer (#997) — recipient download + client upload, + token-only, no auth. */} + } /> + } /> + {/* Customer surface (#354). Strictly separate provider / cookie / API surface from /admin/*. The customerPortal feature flag hides the *admin-side* surfaces (sidebar diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index 7552beb2..fb35a537 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -16,6 +16,7 @@ import { PanelLeftClose, PanelLeftOpen, Github, + Send, } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -67,6 +68,7 @@ const navigation: NavItem[] = [ { nameKey: 'navigation.dashboard', href: '/admin/dashboard', icon: LayoutDashboard, permission: false }, { nameKey: 'navigation.events', href: '/admin/events', icon: Calendar, permission: 'events.view' }, { nameKey: 'navigation.archives', href: '/admin/archives', icon: Archive, permission: 'archives.view' }, + { nameKey: 'navigation.transfers', href: '/admin/transfers', icon: Send, permission: 'events.view', featureFlag: 'transfers' }, { nameKey: 'navigation.messages', href: '/admin/messages', icon: Mail, permission: 'email.view', featureFlag: 'messaging' }, { nameKey: 'admin.analytics', href: '/admin/analytics', icon: BarChart3, permission: 'analytics.view', featureFlag: 'analytics' }, { nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' }, diff --git a/frontend/src/components/admin/TransferPhotoPicker.tsx b/frontend/src/components/admin/TransferPhotoPicker.tsx new file mode 100644 index 00000000..5aac50f5 --- /dev/null +++ b/frontend/src/components/admin/TransferPhotoPicker.tsx @@ -0,0 +1,254 @@ +/** + * TransferPhotoPicker — cross-event image picker for PicTransfer (#997). + * + * A modal that lets the admin browse ANY event's photos and pick images to add + * to a transfer. Thumbnails are previewed (recipient page has none); a lightbox + * toggle switches image clicks between "select" and "preview". Selection + * persists as the admin hops between events. + */ +import React, { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { X, Check, Image as ImageIcon, Maximize2, Search } from 'lucide-react'; + +import { Button, Input, Loading } from '../common'; +import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; +import { eventsService } from '../../services/events.service'; +import { photosService, type AdminPhoto } from '../../services/photos.service'; +import { useAdminAuth } from '../../contexts/AdminAuthContext'; + +export interface PickedPhoto { + id: number; + filename: string; + event_id: number; + event_name: string; + thumbnail_url: string; +} + +interface TransferPhotoPickerProps { + onClose: () => void; + onConfirm: (photos: PickedPhoto[]) => void; + excludePhotoIds?: number[]; + isSaving?: boolean; +} + +export const TransferPhotoPicker: React.FC = ({ + onClose, + onConfirm, + excludePhotoIds = [], + isSaving = false, +}) => { + const { t } = useTranslation(); + const { user } = useAdminAuth(); + const [eventSearch, setEventSearch] = useState(''); + const [selectedEventId, setSelectedEventId] = useState(null); + const [selectedEventName, setSelectedEventName] = useState(''); + const [lightboxEnabled, setLightboxEnabled] = useState(false); + const [previewPhoto, setPreviewPhoto] = useState(null); + // Persist selection (with metadata) across events. + const [selected, setSelected] = useState>(new Map()); + + const excluded = useMemo(() => new Set(excludePhotoIds), [excludePhotoIds]); + + const { data: eventsData, isLoading: eventsLoading } = useQuery({ + queryKey: ['transfer-picker-events', eventSearch], + queryFn: () => eventsService.getEvents(1, 100, undefined, eventSearch || undefined), + }); + // Only offer events the caller may bundle — mirrors the backend's + // filterOwnedEventIds gate (super_admin unrestricted; others get their own + // events plus ownerless legacy ones). Without this the picker would show + // events whose photos the API silently drops on create — a dead control. + // The backend is still the enforcer; this just keeps the UI honest. + const roleName = user?.roleName || user?.role?.name; + const isSuperAdmin = roleName === 'super_admin'; + const events = (eventsData?.events || []).filter((ev) => { + if (isSuperAdmin) return true; + const owner = (ev as { created_by?: number | null }).created_by; + return owner == null || owner === user?.id; + }); + + const { data: photos, isLoading: photosLoading } = useQuery({ + queryKey: ['transfer-picker-photos', selectedEventId], + queryFn: () => photosService.getEventPhotos(selectedEventId as number), + enabled: !!selectedEventId, + }); + + const togglePhoto = (photo: AdminPhoto) => { + if (excluded.has(photo.id)) return; + setSelected((prev) => { + const next = new Map(prev); + if (next.has(photo.id)) { + next.delete(photo.id); + } else { + next.set(photo.id, { + id: photo.id, + filename: photo.original_filename || photo.filename, + event_id: selectedEventId as number, + event_name: selectedEventName, + thumbnail_url: photo.thumbnail_url || '', + }); + } + return next; + }); + }; + + const handlePhotoClick = (photo: AdminPhoto) => { + if (lightboxEnabled) setPreviewPhoto(photo); + else togglePhoto(photo); + }; + + return ( +
+
+ {/* Header */} +
+

+ {t('transfers.picker.title', 'Select images from other events')} +

+
+ + +
+
+ +
+ {/* Event list */} +
+
+ } + placeholder={t('transfers.picker.searchEvents', 'Search events…')} + value={eventSearch} + onChange={(e) => setEventSearch(e.target.value)} + /> +
+
+ {eventsLoading ? ( +
+ ) : ( + events.map((ev) => ( + + )) + )} +
+
+ + {/* Photo grid */} +
+ {!selectedEventId ? ( +
+
+ +

{t('transfers.picker.pickEvent', 'Pick an event to browse its photos')}

+
+
+ ) : photosLoading ? ( + + ) : !photos || photos.length === 0 ? ( +
+ {t('transfers.picker.noPhotos', 'No photos in this event')} +
+ ) : ( +
+ {photos.map((photo) => { + const isSelected = selected.has(photo.id); + const isExcluded = excluded.has(photo.id); + return ( +
handlePhotoClick(photo)} + > + {photo.thumbnail_url ? ( + + ) : ( +
+ +
+ )} + {isExcluded && ( + + {t('transfers.picker.alreadyAdded', 'Added')} + + )} + {isSelected && ( + + + + )} +
+ ); + })} +
+ )} +
+
+ + {/* Footer */} +
+ + {t('transfers.picker.selectedCount', '{{count}} selected', { count: selected.size })} + +
+ + +
+
+
+ + {/* Simple lightbox preview */} + {previewPhoto && ( +
setPreviewPhoto(null)} + > + +
e.stopPropagation()}> + +
+ {previewPhoto.original_filename || previewPhoto.filename} + +
+
+
+ )} +
+ ); +}; diff --git a/frontend/src/contexts/FeatureFlagsContext.tsx b/frontend/src/contexts/FeatureFlagsContext.tsx index 218d81d2..80a53af2 100644 --- a/frontend/src/contexts/FeatureFlagsContext.tsx +++ b/frontend/src/contexts/FeatureFlagsContext.tsx @@ -64,6 +64,9 @@ export const DEFAULT_FLAGS: FeatureFlags = { whatsapp: false, // Live Slideshow ("Diashow") — opt-in; gates all slideshow admin UI. slideshow: false, + // PicTransfer — opt-in; gates the Transfers sidebar entry, the + // /admin/transfers area and the public recipient/upload pages. + transfers: false, // Workflow / automation engine — opt-in; gates the Workflows admin area // and the engine runtime (triggers/actions/gates). workflows: false, diff --git a/frontend/src/features/settings/tabs/FeaturesTab.tsx b/frontend/src/features/settings/tabs/FeaturesTab.tsx index 5cfd9e66..3f2a67f9 100644 --- a/frontend/src/features/settings/tabs/FeaturesTab.tsx +++ b/frontend/src/features/settings/tabs/FeaturesTab.tsx @@ -23,6 +23,7 @@ import { Wallet, FolderKanban, MonitorPlay, + Send, Workflow, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; @@ -133,6 +134,20 @@ export const FeaturesTab: React.FC = () => { enabled={staged.slideshow} onToggle={(next) => setFlag('slideshow', next)} /> + + setFlag('transfers', next)} + /> {/* Automation — the visual workflow engine. Master kill-switch for the diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index ec008460..758c7afc 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -203,6 +203,7 @@ "settings": "Einstellungen", "systemHealth": "Systemzustand", "archives": "Archive", + "transfers": "PicTransfer", "emailSettings": "E-Mail-Einstellungen", "branding": "Markenidentität", "eventTypes": "Veranstaltungstypen", @@ -215,6 +216,112 @@ "workflows": "Workflows", "betaTag": "Beta" }, + "transfers": { + "title": "PicTransfer", + "subtitle": "Originaldateien aus beliebigen Events als Download-Link versenden.", + "new": "Neuer Transfer", + "create": "Transfer erstellen", + "created": "Transfer erstellt", + "createFailed": "Transfer konnte nicht erstellt werden", + "empty": "Noch keine Transfers. Erstellen Sie einen, um Dateien zu teilen.", + "untitled": "Unbenannter Transfer", + "linkCopied": "Link in die Zwischenablage kopiert", + "copyFailed": "Link konnte nicht kopiert werden", + "copyLink": "Link kopieren", + "downloadAll": "Alle herunterladen", + "expiresOn": "Läuft ab", + "disableLink": "Link deaktivieren", + "reactivate": "Reaktivieren (14 Tage)", + "reactivated": "Link reaktiviert", + "disabled": "Link deaktiviert", + "filesAdded": "Dateien hinzugefügt", + "deleted": "Transfer gelöscht", + "deleteConfirmTitle": "Transfer löschen?", + "deleteConfirmBody": "Dies entfernt den Link und alle Kunden-Uploads. Die Fotos der Quell-Events sind nicht betroffen.", + "addImages": "Bilder hinzufügen", + "clientUpload": "Kunden-Upload", + "disableUploads": "Deaktivieren", + "enableUploads": "Upload-Link aktivieren", + "uploadEnabled": "Upload-Link aktiviert", + "noUploads": "Der Kunde hat noch keine Dateien hochgeladen.", + "uploadHint": "Aktivieren Sie dies, um dem Kunden einen 6-stelligen Code zu geben, mit dem er Ihnen Dateien (Logos etc.) senden kann.", + "createAndSend": "Erstellen & senden", + "uploadedFiles": "Hochgeladene Dateien", + "addFiles": "Dateien hinzufügen", + "noUploadedFiles": "Keine hochgeladenen Dateien. Fügen Sie Dateien von Ihrem Computer hinzu, um sie in den Download aufzunehmen.", + "sentTo": "Per E-Mail an", + "delivery": { + "link": "Link teilen", + "email": "Per E-Mail senden" + }, + "col": { + "title": "Titel", + "files": "Dateien", + "status": "Status", + "downloads": "Downloads", + "expires": "Läuft ab", + "uploads": "Uploads" + }, + "status": { + "active": "Aktiv", + "expired": "Abgelaufen", + "deleted": "Gelöscht" + }, + "field": { + "title": "Titel", + "titlePlaceholder": "z. B. Hochzeitsfinals für Familie Schmidt", + "message": "Nachricht (optional)", + "messagePlaceholder": "Wird dem Empfänger auf der Download-Seite angezeigt", + "expiresInDays": "Link aktiv für (Tage)", + "maxDownloads": "Max. Downloads (0 = unbegrenzt)", + "allowUploads": "Dem Kunden zusätzlich einen Upload-Link geben (für Logos etc.)", + "files": "Dateien", + "noFiles": "Noch keine Bilder ausgewählt.", + "uploadFiles": "Eigene Dateien hochladen", + "chooseFiles": "Dateien auswählen", + "noUploadFiles": "Optional Dateien von Ihrem Computer hinzufügen, die mitgesendet werden.", + "delivery": "Zustellung", + "recipients": "E-Mail-Adressen der Empfänger", + "recipientsPlaceholder": "anna@example.com, ben@example.com", + "recipientsCount": "{{count}} Empfänger — jeder erhält den Download-Link", + "recipientsHint": "Mehrere Adressen durch Kommas trennen. Jeder Empfänger erhält den Download-Link." + }, + "picker": { + "title": "Bilder aus anderen Events auswählen", + "searchEvents": "Events suchen…", + "pickEvent": "Wählen Sie ein Event, um dessen Fotos zu durchsuchen", + "noPhotos": "Keine Fotos in diesem Event", + "select": "Auswählen", + "deselect": "Abwählen", + "alreadyAdded": "Hinzugefügt", + "selectedCount": "{{count}} ausgewählt", + "addSelected": "Auswahl hinzufügen", + "lightbox": "Lightbox" + }, + "public": { + "notFoundTitle": "Link nicht gefunden", + "notFoundBody": "Dieser Transfer-Link ist ungültig oder wurde entfernt.", + "expiredTitle": "Dieser Link ist abgelaufen", + "expiredBody": "Bitte fordern Sie beim Absender einen neuen Link an.", + "limitTitle": "Download-Limit erreicht", + "limitBody": "Dieser Transfer hat die maximale Anzahl an Downloads erreicht.", + "availableUntil": "Verfügbar bis {{date}}", + "fileSummary": "{{count}} Dateien", + "downloadFile": "Herunterladen" + }, + "upload": { + "unavailableTitle": "Upload-Link nicht verfügbar", + "unavailableBody": "Dieser Upload-Link ist ungültig oder abgelaufen.", + "doneTitle": "Vielen Dank!", + "doneBody": "Ihre Dateien wurden erfolgreich hochgeladen.", + "uploadMore": "Weitere hochladen", + "dropzone": "Zum Auswählen klicken oder Dateien hierher ziehen", + "limits": "Bis zu {{files}} Dateien, je {{mb}} MB", + "tooBig": "Jede Datei darf höchstens {{mb}} MB groß sein", + "send": "{{count}} Dateien hochladen", + "failed": "Upload fehlgeschlagen. Bitte erneut versuchen." + } + }, "workflows": { "title": "Workflows", "subtitle": "Visuelle Automatisierungen – Auslöser, Bedingungen, Freigaben und Aktionen.", @@ -1993,6 +2100,11 @@ "title": "Live-Diashow", "description": "Ein separater Vollbild-„Diashow“-Link pro Event für Beamer bei Live-Events – übernimmt neue Uploads automatisch, mit Voreinstellungen je Event-Typ und globalen Wasserzeichen-Vorgaben unter Einstellungen → Diashow." }, + "transfers": { + "title": "PicTransfer", + "description": "Originaldateien aus beliebigen Events als sicheren, Token-geschützten Download-Link versenden – mit optionalem Kunden-Upload-Code, über den Kunden Ihnen Logos und Dateien zurücksenden können. Strikt optional.", + "sidebar": "PicTransfer" + }, "workflows": { "title": "Workflows", "description": "Visuelle Automatisierungen auf einer Canvas erstellen – Auslöser, Bedingungen, Verzweigungen, Schleifen und Freigabe-Gates für Admins. Deine Mahnstufen und Buchungsschritte werden zu bearbeitbaren Abläufen. Strikt optional.", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index dbedf69d..7a3fdbfd 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -200,6 +200,7 @@ "dashboard": "Dashboard", "events": "Events", "archives": "Archives", + "transfers": "PicTransfer", "messages": "Messages", "settings": "Settings", "systemHealth": "System health", @@ -215,6 +216,112 @@ "workflows": "Workflows", "betaTag": "Beta" }, + "transfers": { + "title": "PicTransfer", + "subtitle": "Send original files from any event as a download link.", + "new": "New transfer", + "create": "Create transfer", + "created": "Transfer created", + "createFailed": "Could not create transfer", + "empty": "No transfers yet. Create one to share files.", + "untitled": "Untitled transfer", + "linkCopied": "Link copied to clipboard", + "copyFailed": "Could not copy link", + "copyLink": "Copy link", + "downloadAll": "Download all", + "expiresOn": "Expires", + "disableLink": "Disable link", + "reactivate": "Re-activate (14 days)", + "reactivated": "Link re-activated", + "disabled": "Link disabled", + "filesAdded": "Files added", + "deleted": "Transfer deleted", + "deleteConfirmTitle": "Delete transfer?", + "deleteConfirmBody": "This removes the link and any client uploads. Source event photos are not affected.", + "addImages": "Add images", + "clientUpload": "Client upload", + "disableUploads": "Disable", + "enableUploads": "Enable upload link", + "uploadEnabled": "Upload link enabled", + "noUploads": "No files uploaded by the client yet.", + "uploadHint": "Enable this to give the client a 6-character code to send you files (logos etc.).", + "createAndSend": "Create & send", + "uploadedFiles": "Uploaded files", + "addFiles": "Add files", + "noUploadedFiles": "No uploaded files. Add files from your computer to include them in the download.", + "sentTo": "Emailed to", + "delivery": { + "link": "Share a link", + "email": "Send by email" + }, + "col": { + "title": "Title", + "files": "Files", + "status": "Status", + "downloads": "Downloads", + "expires": "Expires", + "uploads": "Uploads" + }, + "status": { + "active": "Active", + "expired": "Expired", + "deleted": "Deleted" + }, + "field": { + "title": "Title", + "titlePlaceholder": "e.g. Wedding finals for the Smiths", + "message": "Message (optional)", + "messagePlaceholder": "Shown to the recipient on the download page", + "expiresInDays": "Link active for (days)", + "maxDownloads": "Max downloads (0 = unlimited)", + "allowUploads": "Also give the client an upload link (to send logos etc.)", + "files": "Files", + "noFiles": "No images selected yet.", + "uploadFiles": "Upload your own files", + "chooseFiles": "Choose files", + "noUploadFiles": "Optionally add files from your computer to send along.", + "delivery": "Delivery", + "recipients": "Recipient email addresses", + "recipientsPlaceholder": "anna@example.com, ben@example.com", + "recipientsCount": "{{count}} recipient(s) — each gets the download link", + "recipientsHint": "Separate multiple addresses with commas. Each recipient gets the download link." + }, + "picker": { + "title": "Select images from other events", + "searchEvents": "Search events…", + "pickEvent": "Pick an event to browse its photos", + "noPhotos": "No photos in this event", + "select": "Select", + "deselect": "Deselect", + "alreadyAdded": "Added", + "selectedCount": "{{count}} selected", + "addSelected": "Add selected", + "lightbox": "Lightbox" + }, + "public": { + "notFoundTitle": "Link not found", + "notFoundBody": "This transfer link is invalid or has been removed.", + "expiredTitle": "This link has expired", + "expiredBody": "Please ask the sender for a new link.", + "limitTitle": "Download limit reached", + "limitBody": "This transfer has reached its maximum number of downloads.", + "availableUntil": "Available until {{date}}", + "fileSummary": "{{count}} files", + "downloadFile": "Download" + }, + "upload": { + "unavailableTitle": "Upload link unavailable", + "unavailableBody": "This upload link is invalid or has expired.", + "doneTitle": "Thank you!", + "doneBody": "Your files were uploaded successfully.", + "uploadMore": "Upload more", + "dropzone": "Click to choose files or drag them here", + "limits": "Up to {{files}} files, {{mb}} MB each", + "tooBig": "Each file must be {{mb}} MB or smaller", + "send": "Upload {{count}} files", + "failed": "Upload failed. Please try again." + } + }, "workflows": { "title": "Workflows", "subtitle": "Visual automations — triggers, conditions, gates and actions.", @@ -1538,6 +1645,11 @@ "title": "Live Slideshow", "description": "A separate fullscreen \"Diashow\" link per event for projectors at live events — auto-picks-up new uploads, with per-event-type presets and global watermark defaults under Settings → Slideshow." }, + "transfers": { + "title": "PicTransfer", + "description": "Send original files from any event(s) as a secure, token-protected download link, with an optional client-upload code so clients can send you logos and files back. Strictly opt-in.", + "sidebar": "PicTransfer" + }, "workflows": { "title": "Workflows", "description": "Build visual automations on a canvas — triggers, conditions, branches, loops and admin approval gates. Your reminder ladder and booking steps become editable flows. Strictly opt-in.", diff --git a/frontend/src/pages/admin/transfers/TransfersPage.tsx b/frontend/src/pages/admin/transfers/TransfersPage.tsx new file mode 100644 index 00000000..3fe92534 --- /dev/null +++ b/frontend/src/pages/admin/transfers/TransfersPage.tsx @@ -0,0 +1,655 @@ +/** + * Admin → PicTransfer page (#997). + * + * List of transfers + a create flow (with the cross-event image picker) + a + * detail panel to manage files, the recipient link, the client-upload link and + * retention. Recipient downloads always contain ORIGINAL files. + */ +import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { + Plus, Send, Link2, Download, Trash2, Upload, X, Copy, Image as ImageIcon, + Clock, Ban, RefreshCw, Mail, Paperclip, FileText, +} from 'lucide-react'; + +import { Button, Input, Card, CardContent, Loading, useConfirm } from '../../../components/common'; +import { AdminAuthenticatedImage } from '../../../components/admin/AdminAuthenticatedImage'; +import { TransferPhotoPicker, type PickedPhoto } from '../../../components/admin/TransferPhotoPicker'; +import { useMutationWithToast } from '../../../hooks/useMutationWithToast'; +import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; +import { transfersService } from '../../../services/transfers.service'; + +function formatBytes(bytes: number | null | undefined): string { + if (!bytes) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} +function recipientUrl(token: string): string { + return `${window.location.origin}/transfer/${token}`; +} +function uploadUrl(uploadToken: string): string { + return `${window.location.origin}/transfer-upload/${uploadToken}`; +} + +const STATUS_STYLES: Record = { + active: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', + expired: 'bg-neutral-200 text-neutral-600 dark:bg-neutral-700 dark:text-neutral-300', + deleted: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', +}; + +export const TransfersPage: React.FC = () => { + const { t } = useTranslation(); + const confirm = useConfirm(); + const { formatDateTime } = useLocalizedDate(); + const fmtDate = (d: string | null) => (d ? formatDateTime(d) : '—'); + const [showCreate, setShowCreate] = useState(false); + const [detailId, setDetailId] = useState(null); + + const { data: transfers, isLoading, refetch } = useQuery({ + queryKey: ['admin-transfers'], + queryFn: () => transfersService.list(), + }); + + const copyLink = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + toast.success(t('transfers.linkCopied', 'Link copied to clipboard')); + } catch { + toast.error(t('transfers.copyFailed', 'Could not copy link')); + } + }; + + return ( +
+
+
+

+ {t('transfers.title', 'PicTransfer')} +

+

+ {t('transfers.subtitle', 'Send original files from any event as a download link.')} +

+
+ +
+ + {isLoading ? ( + + ) : !transfers || transfers.length === 0 ? ( + + + +

{t('transfers.empty', 'No transfers yet. Create one to share files.')}

+
+
+ ) : ( + +
+ + + + + + + + + + + + + {transfers.map((tr) => ( + setDetailId(tr.id)} + > + + + + + + + + ))} + +
{t('transfers.col.title', 'Title')}{t('transfers.col.files', 'Files')}{t('transfers.col.status', 'Status')}{t('transfers.col.downloads', 'Downloads')}{t('transfers.col.expires', 'Expires')}{t('transfers.col.uploads', 'Uploads')}
+ {tr.title || t('transfers.untitled', 'Untitled transfer')} + {tr.file_count} + + {t(`transfers.status.${tr.status}`, tr.status)} + + + {tr.download_count}{tr.max_downloads ? ` / ${tr.max_downloads}` : ''} + {fmtDate(tr.expires_at)}{tr.allow_uploads ? tr.upload_count : '—'}
+
+
+ )} + + {showCreate && ( + setShowCreate(false)} + onCreated={() => { setShowCreate(false); refetch(); }} + /> + )} + {detailId !== null && ( + { setDetailId(null); refetch(); }} + onCopy={copyLink} + confirm={confirm} + /> + )} +
+ ); +}; + +// --------------------------------------------------------------------------- +// Create modal +// --------------------------------------------------------------------------- + +const CreateTransferModal: React.FC<{ onClose: () => void; onCreated: () => void }> = ({ onClose, onCreated }) => { + const { t } = useTranslation(); + const [title, setTitle] = useState(''); + const [message, setMessage] = useState(''); + const [expiresInDays, setExpiresInDays] = useState('14'); + const [maxDownloads, setMaxDownloads] = useState(''); + const [allowUploads, setAllowUploads] = useState(false); + const [picked, setPicked] = useState([]); + const [showPicker, setShowPicker] = useState(false); + const [files, setFiles] = useState([]); + const [deliveryMethod, setDeliveryMethod] = useState<'link' | 'email'>('link'); + const [emails, setEmails] = useState(''); + + // Split the free-text recipient field on comma / semicolon / whitespace and + // keep only well-formed addresses. Used both to send and to gate the button. + const parsedEmails = emails + .split(/[,;\s]+/) + .map((e) => e.trim()) + .filter((e) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)); + + const createMutation = useMutationWithToast({ + mutationFn: () => transfersService.create({ + title: title.trim(), + message: message.trim() || null, + expiresInDays: parseInt(expiresInDays, 10) || 14, + maxDownloads: maxDownloads ? parseInt(maxDownloads, 10) : null, + allowUploads, + photoIds: picked.map((p) => p.id), + files, + deliveryMethod, + recipientEmails: deliveryMethod === 'email' ? parsedEmails : [], + }), + successMessage: t('transfers.created', 'Transfer created'), + errorMessage: t('transfers.createFailed', 'Could not create transfer'), + onSuccess: onCreated, + }); + + const addFilesToList = (list: FileList | null) => { + if (!list || !list.length) return; + setFiles((prev) => [...prev, ...Array.from(list)]); + }; + + const addPicked = (photos: PickedPhoto[]) => { + setPicked((prev) => { + const map = new Map(prev.map((p) => [p.id, p])); + photos.forEach((p) => map.set(p.id, p)); + return Array.from(map.values()); + }); + setShowPicker(false); + }; + + return ( +
+
+
+

{t('transfers.new', 'New transfer')}

+ +
+ +
+ setTitle(e.target.value)} placeholder={t('transfers.field.titlePlaceholder', 'e.g. Wedding finals for the Smiths')} /> +
+ +