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')} /> +
+ +