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: `
+
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.
+
+Der folgende Datei-Transfer kann vom Empfänger nicht mehr heruntergeladen werden:
+
+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.
+
+{{transfer_title}} has been shared with you.
+
+{{transfer_title}} wurde mit Ihnen geteilt.
+
+Falls die Schaltfläche nicht funktioniert, kopieren Sie diesen Link in Ihren Browser: {{download_url}}
+
+).
+ *
+ * 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')}
+
+
+ }
+ onClick={() => setLightboxEnabled((v) => !v)}
+ >
+ {t('transfers.picker.lightbox', 'Lightbox')}
+
+
+
+
+
+
+
+
+ {/* Event list */}
+
+
+ }
+ placeholder={t('transfers.picker.searchEvents', 'Search events…')}
+ value={eventSearch}
+ onChange={(e) => setEventSearch(e.target.value)}
+ />
+
+
+ {eventsLoading ? (
+
+ ) : (
+ events.map((ev) => (
+
{ setSelectedEventId(ev.id); setSelectedEventName(ev.event_name); }}
+ className={`block w-full truncate px-4 py-2 text-left text-sm hover:bg-neutral-100 dark:hover:bg-neutral-800 ${
+ selectedEventId === ev.id ? 'bg-primary-50 font-medium text-primary-700 dark:bg-neutral-800' : 'text-neutral-700 dark:text-neutral-300'
+ }`}
+ >
+ {ev.event_name}
+
+ ))
+ )}
+
+
+
+ {/* 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 })}
+
+
+ {t('common.cancel', 'Cancel')}
+ onConfirm(Array.from(selected.values()))}
+ disabled={selected.size === 0}
+ isLoading={isSaving}
+ >
+ {t('transfers.picker.addSelected', 'Add selected')}
+
+
+
+
+
+ {/* Simple lightbox preview */}
+ {previewPhoto && (
+
setPreviewPhoto(null)}
+ >
+
+
+
+
e.stopPropagation()}>
+
+
+ {previewPhoto.original_filename || previewPhoto.filename}
+ togglePhoto(previewPhoto)}
+ >
+ {selected.has(previewPhoto.id) ? t('transfers.picker.deselect', 'Deselect') : t('transfers.picker.select', 'Select')}
+
+
+
+
+ )}
+
+ );
+};
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.')}
+
+
+
} onClick={() => setShowCreate(true)}>
+ {t('transfers.new', 'New transfer')}
+
+
+
+ {isLoading ? (
+
+ ) : !transfers || transfers.length === 0 ? (
+
+
+
+ {t('transfers.empty', 'No transfers yet. Create one to share files.')}
+
+
+ ) : (
+
+
+
+
+
+ {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')}
+
+
+
+ {transfers.map((tr) => (
+ setDetailId(tr.id)}
+ >
+
+ {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')} />
+
+ {t('transfers.field.message', 'Message (optional)')}
+
+
+ setExpiresInDays(e.target.value)} />
+ setMaxDownloads(e.target.value)} placeholder="0" />
+
+
+ setAllowUploads(e.target.checked)} className="rounded" />
+ {t('transfers.field.allowUploads', 'Also give the client an upload link (to send logos etc.)')}
+
+
+ {/* Picker entry + selection preview */}
+
+
+
+ {t('transfers.field.files', 'Files')} · {picked.length}
+
+ } onClick={() => setShowPicker(true)}>
+ {t('transfers.picker.title', 'Select images from other events')}
+
+
+ {picked.length === 0 ? (
+
{t('transfers.field.noFiles', 'No images selected yet.')}
+ ) : (
+
+ {picked.slice(0, 18).map((p) => (
+
+ {p.thumbnail_url ? (
+
+ ) :
}
+
setPicked((prev) => prev.filter((x) => x.id !== p.id))}
+ className="absolute right-0.5 top-0.5 rounded-full bg-black/60 p-0.5 text-white"
+ >
+
+ ))}
+ {picked.length > 18 && (
+
+ +{picked.length - 18}
+
+ )}
+
+ )}
+
+
+ {/* Upload your own files (deliverables not tied to an event) */}
+
+
+
+ {t('transfers.field.uploadFiles', 'Upload your own files')} · {files.length}
+
+
+
+ {t('transfers.field.chooseFiles', 'Choose files')}
+ { addFilesToList(e.target.files); e.target.value = ''; }}
+ />
+
+
+ {files.length === 0 ? (
+
{t('transfers.field.noUploadFiles', 'Optionally add files from your computer to send along.')}
+ ) : (
+
+ {files.map((f, idx) => (
+
+
+
+ {f.name}
+
+ setFiles((prev) => prev.filter((_, i) => i !== idx))}
+ className="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600 dark:hover:bg-neutral-700"
+ >
+
+ ))}
+
+ )}
+
+
+ {/* Delivery: copy a link yourself, or email it to recipients */}
+
+
+ {t('transfers.field.delivery', 'Delivery')}
+
+
+ }
+ onClick={() => setDeliveryMethod('link')}
+ >
+ {t('transfers.delivery.link', 'Share a link')}
+
+ }
+ onClick={() => setDeliveryMethod('email')}
+ >
+ {t('transfers.delivery.email', 'Send by email')}
+
+
+ {deliveryMethod === 'email' && (
+
+
+ {t('transfers.field.recipients', 'Recipient email addresses')}
+
+
+ )}
+
+
+
+
+ {t('common.cancel', 'Cancel')}
+ createMutation.mutate()}
+ isLoading={createMutation.isPending}
+ disabled={
+ (picked.length === 0 && files.length === 0 && !allowUploads)
+ || (deliveryMethod === 'email' && parsedEmails.length === 0)
+ }
+ >
+ {deliveryMethod === 'email'
+ ? t('transfers.createAndSend', 'Create & send')
+ : t('transfers.create', 'Create transfer')}
+
+
+
+
+ {showPicker && (
+
setShowPicker(false)}
+ onConfirm={addPicked}
+ excludePhotoIds={picked.map((p) => p.id)}
+ />
+ )}
+
+ );
+};
+
+// ---------------------------------------------------------------------------
+// Detail modal
+// ---------------------------------------------------------------------------
+
+interface DetailProps {
+ transferId: number;
+ onClose: () => void;
+ onCopy: (text: string) => void;
+ confirm: ReturnType;
+}
+
+const TransferDetailModal: React.FC = ({ transferId, onClose, onCopy, confirm }) => {
+ const { t } = useTranslation();
+ const { formatDateTime } = useLocalizedDate();
+ const fmtDate = (d: string | null) => (d ? formatDateTime(d) : '—');
+ const [showPicker, setShowPicker] = useState(false);
+
+ const { data: transfer, isLoading, refetch } = useQuery({
+ queryKey: ['admin-transfer', transferId],
+ queryFn: () => transfersService.get(transferId),
+ });
+
+ const addFilesMutation = useMutationWithToast({
+ mutationFn: (photoIds: number[]) => transfersService.addFiles(transferId, photoIds),
+ successMessage: t('transfers.filesAdded', 'Files added'),
+ onSuccess: () => refetch(),
+ });
+ const removeFileMutation = useMutationWithToast({
+ mutationFn: (fileId: number) => transfersService.removeFile(transferId, fileId),
+ onSuccess: () => refetch(),
+ });
+ const disableMutation = useMutationWithToast({
+ mutationFn: () => transfersService.update(transferId, { isActive: false }),
+ successMessage: t('transfers.disabled', 'Link disabled'),
+ onSuccess: () => refetch(),
+ });
+ const reactivateMutation = useMutationWithToast({
+ mutationFn: () => transfersService.update(transferId, { isActive: true, expiresInDays: 14 }),
+ successMessage: t('transfers.reactivated', 'Link re-activated'),
+ onSuccess: () => refetch(),
+ });
+ const enableUploadsMutation = useMutationWithToast({
+ mutationFn: () => transfersService.enableUploads(transferId),
+ successMessage: t('transfers.uploadEnabled', 'Upload link enabled'),
+ onSuccess: () => refetch(),
+ });
+ const disableUploadsMutation = useMutationWithToast({
+ mutationFn: () => transfersService.disableUploads(transferId),
+ onSuccess: () => refetch(),
+ });
+ const deleteMutation = useMutationWithToast({
+ mutationFn: () => transfersService.remove(transferId),
+ successMessage: t('transfers.deleted', 'Transfer deleted'),
+ onSuccess: onClose,
+ });
+ const uploadFilesMutation = useMutationWithToast({
+ mutationFn: (list: File[]) => transfersService.uploadFiles(transferId, list),
+ successMessage: t('transfers.filesAdded', 'Files added'),
+ onSuccess: () => refetch(),
+ });
+ const removeExtraFileMutation = useMutationWithToast({
+ mutationFn: (extraId: number) => transfersService.removeExtraFile(transferId, extraId),
+ onSuccess: () => refetch(),
+ });
+
+ const handleDelete = async () => {
+ const ok = await confirm({
+ title: t('transfers.deleteConfirmTitle', 'Delete transfer?'),
+ message: t('transfers.deleteConfirmBody', 'This removes the link and any client uploads. Source event photos are not affected.'),
+ variant: 'danger',
+ });
+ if (ok) deleteMutation.mutate();
+ };
+
+ return (
+
+
+
+
+ {transfer?.title || t('transfers.untitled', 'Untitled transfer')}
+
+
+
+
+ {isLoading || !transfer ? (
+
+ ) : (
+
+ {/* Prominent recipient link + download-all, up top */}
+
+
+
+ {t('transfers.expiresOn', 'Expires')}: {fmtDate(transfer.expires_at)}
+ {t('transfers.col.downloads', 'Downloads')}: {transfer.download_count}{transfer.max_downloads ? ` / ${transfer.max_downloads}` : ''}
+ {t(`transfers.status.${transfer.status}`, transfer.status)}
+
+
+ {transfer.is_active ? (
+ } onClick={() => disableMutation.mutate()} isLoading={disableMutation.isPending}>
+ {t('transfers.disableLink', 'Disable link')}
+
+ ) : (
+ } onClick={() => reactivateMutation.mutate()} isLoading={reactivateMutation.isPending}>
+ {t('transfers.reactivate', 'Re-activate (14 days)')}
+
+ )}
+ } onClick={handleDelete}>
+ {t('common.delete', 'Delete')}
+
+
+
+
+ {/* Files */}
+
+
+
{t('transfers.field.files', 'Files')} · {transfer.file_count}
+ } onClick={() => setShowPicker(true)}>
+ {t('transfers.addImages', 'Add images')}
+
+
+ {transfer.files && transfer.files.length > 0 ? (
+
+ {transfer.files.map((f) => (
+
+
+
removeFileMutation.mutate(f.file_id)}
+ className="absolute right-1 top-1 rounded-full bg-black/60 p-0.5 text-white opacity-0 transition group-hover:opacity-100"
+ title={t('common.remove', 'Remove')}
+ >
+
{f.event_name}
+
+ ))}
+
+ ) : (
+
{t('transfers.field.noFiles', 'No images selected yet.')}
+ )}
+
+
+ {/* Admin-uploaded deliverable files */}
+
+
+
+ {t('transfers.uploadedFiles', 'Uploaded files')} · {transfer.extra_files?.length || 0}
+
+
+
+ {t('transfers.addFiles', 'Add files')}
+ {
+ if (e.target.files?.length) uploadFilesMutation.mutate(Array.from(e.target.files));
+ e.target.value = '';
+ }}
+ />
+
+
+ {transfer.extra_files && transfer.extra_files.length > 0 ? (
+
+ {transfer.extra_files.map((f) => (
+
+
+
+ {f.filename}
+
+
+ {formatBytes(f.size_bytes)}
+
+
+
+ removeExtraFileMutation.mutate(f.id)}
+ className="rounded p-1 text-neutral-400 hover:bg-neutral-100 hover:text-red-600 dark:hover:bg-neutral-700"
+ title={t('common.remove', 'Remove')}
+ >
+
+
+ ))}
+
+ ) : (
+
{t('transfers.noUploadedFiles', 'No uploaded files. Add files from your computer to include them in the download.')}
+ )}
+
+
+ {/* Email recipients (when delivered by email) */}
+ {transfer.recipients && transfer.recipients.length > 0 && (
+
+
+ {t('transfers.sentTo', 'Emailed to')}
+
+
+ {transfer.recipients.map((r) => (
+
+ {r.email}
+
+ ))}
+
+
+ )}
+
+ {/* Client uploads */}
+
+
+
+ {t('transfers.clientUpload', 'Client upload')}
+
+ {transfer.allow_uploads ? (
+ disableUploadsMutation.mutate()}>
+ {t('transfers.disableUploads', 'Disable')}
+
+ ) : (
+ enableUploadsMutation.mutate()} isLoading={enableUploadsMutation.isPending}>
+ {t('transfers.enableUploads', 'Enable upload link')}
+
+ )}
+
+ {transfer.allow_uploads && transfer.upload_token ? (
+ <>
+
+
{transfer.upload_token}
+
+
} onClick={() => onCopy(uploadUrl(transfer.upload_token as string))}>
+ {t('transfers.copyLink', 'Copy link')}
+
+
+ {transfer.uploads && transfer.uploads.length > 0 ? (
+
+ {transfer.uploads.map((u) => (
+
+ {u.original_filename}
+
+ {formatBytes(u.size_bytes)}
+
+
+
+
+
+ ))}
+
+ ) : (
+
{t('transfers.noUploads', 'No files uploaded by the client yet.')}
+ )}
+ >
+ ) : (
+
+ {t('transfers.uploadHint', 'Enable this to give the client a 6-character code to send you files (logos etc.).')}
+
+ )}
+
+
+ )}
+
+
+ {showPicker && transfer && (
+
setShowPicker(false)}
+ onConfirm={(photos) => { addFilesMutation.mutate(photos.map((p) => p.id)); setShowPicker(false); }}
+ excludePhotoIds={(transfer.files || []).map((f) => f.photo_id)}
+ isSaving={addFilesMutation.isPending}
+ />
+ )}
+
+ );
+};
diff --git a/frontend/src/pages/public/TransferDownloadPage.tsx b/frontend/src/pages/public/TransferDownloadPage.tsx
new file mode 100644
index 00000000..4ee03b0c
--- /dev/null
+++ b/frontend/src/pages/public/TransferDownloadPage.tsx
@@ -0,0 +1,139 @@
+/**
+ * Public recipient download page for PicTransfer (#997).
+ *
+ * Token-only (no auth). By design there are NO thumbnails — just filenames,
+ * sizes and a prominent "Download all" button, plus per-file download.
+ * Files served are always ORIGINALS.
+ *
+ * Styling reads the branding theme CSS variables (`--color-*`) rather than
+ * Tailwind neutral/`dark:` utilities: this is a public page, so it never gets
+ * the admin `.dark` class — the only theming that reaches it is the instance
+ * branding (colours + light/dark) applied by GlobalThemeProvider. Reading the
+ * vars keeps it on-brand and correct in both light and dark.
+ */
+import React from 'react';
+import { useParams } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { Download, FileDown, Clock, PackageOpen, AlertCircle } from 'lucide-react';
+
+import { Button, Loading } from '../../components/common';
+import { useLocalizedDate } from '../../hooks/useLocalizedDate';
+import { transfersService } from '../../services/transfers.service';
+
+function formatBytes(bytes: number | null | undefined): string {
+ if (!bytes) return '';
+ 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]}`;
+}
+
+export const TransferDownloadPage: React.FC = () => {
+ const { t } = useTranslation();
+ const { format } = useLocalizedDate();
+ const { token } = useParams<{ token: string }>();
+
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ['public-transfer', token],
+ queryFn: () => transfersService.getPublic(token as string),
+ enabled: !!token,
+ retry: false,
+ });
+
+ const wrap = (children: React.ReactNode) => (
+
+ );
+
+ const muted = { color: 'var(--color-muted-text)' } as const;
+
+ if (isLoading) return wrap(
);
+
+ if (isError || !data) {
+ return wrap(
+
+
+
{t('transfers.public.notFoundTitle', 'Link not found')}
+
{t('transfers.public.notFoundBody', 'This transfer link is invalid or has been removed.')}
+
,
+ );
+ }
+
+ if (!data.downloadable) {
+ const isLimit = data.status === 'limit_reached';
+ return wrap(
+
+
+
+ {isLimit ? t('transfers.public.limitTitle', 'Download limit reached') : t('transfers.public.expiredTitle', 'This link has expired')}
+
+
+ {isLimit
+ ? t('transfers.public.limitBody', 'This transfer has reached its maximum number of downloads.')
+ : t('transfers.public.expiredBody', 'Please ask the sender for a new link.')}
+
+
,
+ );
+ }
+
+ return wrap(
+
+
+
+
{data.title}
+ {data.message &&
{data.message}
}
+
+ {t('transfers.public.fileSummary', '{{count}} files', { count: data.file_count || 0 })}
+ {data.total_bytes ? ` · ${formatBytes(data.total_bytes)}` : ''}
+
+
+
+ {/* Prominent download-all */}
+
+ } className="w-full">
+ {t('transfers.downloadAll', 'Download all')}
+
+
+
+ {data.files && data.files.length > 0 && (
+
+ {data.files.map((f) => (
+
+ {f.filename}
+
+ {f.size_bytes ? {formatBytes(f.size_bytes)} : null}
+
+
+
+
+
+ ))}
+
+ )}
+
+ {data.expires_at && (
+
+ {t('transfers.public.availableUntil', 'Available until {{date}}', { date: format(data.expires_at) })}
+
+ )}
+
,
+ );
+};
diff --git a/frontend/src/pages/public/TransferUploadPage.tsx b/frontend/src/pages/public/TransferUploadPage.tsx
new file mode 100644
index 00000000..a5f879f1
--- /dev/null
+++ b/frontend/src/pages/public/TransferUploadPage.tsx
@@ -0,0 +1,189 @@
+/**
+ * Public client-upload page for PicTransfer (#997).
+ *
+ * Token-only (6-char code, no auth). Lets a client send files back to the
+ * photographer (logos etc.). Reached via /transfer-upload/:token.
+ *
+ * Like the recipient download page, styling reads the branding theme CSS
+ * variables (`--color-*`) rather than Tailwind `dark:` utilities — a public
+ * page never gets the admin `.dark` class, so the branding theme (applied by
+ * GlobalThemeProvider) is what must drive colours and light/dark here.
+ */
+import React, { useRef, useState } from 'react';
+import { useParams } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { toast } from 'react-toastify';
+import { UploadCloud, CheckCircle, AlertCircle, X, File as FileIcon } from 'lucide-react';
+
+import { Button, Loading } from '../../components/common';
+import { transfersService } from '../../services/transfers.service';
+
+function formatBytes(bytes: number): string {
+ if (!bytes) return '0 B';
+ const units = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
+}
+
+export const TransferUploadPage: React.FC = () => {
+ const { t } = useTranslation();
+ const { token } = useParams<{ token: string }>();
+ const inputRef = useRef(null);
+ const [files, setFiles] = useState([]);
+ const [progress, setProgress] = useState(0);
+ const [uploading, setUploading] = useState(false);
+ const [done, setDone] = useState(false);
+
+ const { data, isLoading, isError } = useQuery({
+ queryKey: ['transfer-upload-info', token],
+ queryFn: () => transfersService.getUploadInfo(token as string),
+ enabled: !!token,
+ retry: false,
+ });
+
+ const muted = { color: 'var(--color-muted-text)' } as const;
+
+ const wrap = (children: React.ReactNode) => (
+
+ );
+
+ if (isLoading) return wrap(
);
+
+ if (isError || !data) {
+ return wrap(
+
+
+
{t('transfers.upload.unavailableTitle', 'Upload link unavailable')}
+
{t('transfers.upload.unavailableBody', 'This upload link is invalid or has expired.')}
+
,
+ );
+ }
+
+ const addFiles = (list: FileList | null) => {
+ if (!list) return;
+ const incoming = Array.from(list);
+ setFiles((prev) => {
+ const merged = [...prev, ...incoming].slice(0, data.max_files);
+ return merged;
+ });
+ };
+
+ const handleUpload = async () => {
+ if (!files.length) return;
+ const tooBig = files.find((f) => f.size > data.max_size_mb * 1024 * 1024);
+ if (tooBig) {
+ toast.error(t('transfers.upload.tooBig', 'Each file must be {{mb}} MB or smaller', { mb: data.max_size_mb }));
+ return;
+ }
+ setUploading(true);
+ setProgress(0);
+ try {
+ await transfersService.upload(token as string, files, setProgress);
+ setDone(true);
+ } catch (err) {
+ const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error
+ || t('transfers.upload.failed', 'Upload failed. Please try again.');
+ toast.error(msg);
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ if (done) {
+ return wrap(
+
+
+
{t('transfers.upload.doneTitle', 'Thank you!')}
+
{t('transfers.upload.doneBody', 'Your files were uploaded successfully.')}
+
{ setDone(false); setFiles([]); setProgress(0); }}>
+ {t('transfers.upload.uploadMore', 'Upload more')}
+
+
,
+ );
+ }
+
+ return wrap(
+
+
+
+
{data.title}
+ {data.message &&
{data.message}
}
+
+ {t('transfers.upload.limits', 'Up to {{files}} files, {{mb}} MB each', { files: data.max_files, mb: data.max_size_mb })}
+
+
+
+
inputRef.current?.click()}
+ className="flex w-full flex-col items-center justify-center rounded-lg border-2 border-dashed py-10 transition hover:opacity-80"
+ style={{ borderColor: 'var(--color-surface-border)', color: 'var(--color-muted-text)' }}
+ onDragOver={(e) => e.preventDefault()}
+ onDrop={(e) => { e.preventDefault(); addFiles(e.dataTransfer.files); }}
+ >
+
+ {t('transfers.upload.dropzone', 'Click to choose files or drag them here')}
+
+
{ addFiles(e.target.files); if (inputRef.current) inputRef.current.value = ''; }}
+ />
+
+ {files.length > 0 && (
+
+ {files.map((f, idx) => (
+
+
+
+ {f.name}
+
+
+ {formatBytes(f.size)}
+ {!uploading && (
+ setFiles((prev) => prev.filter((_, i) => i !== idx))} className="rounded p-1 hover:opacity-70">
+
+
+ )}
+
+
+ ))}
+
+ )}
+
+ {uploading && (
+
+ )}
+
+
}
+ onClick={handleUpload}
+ disabled={files.length === 0}
+ isLoading={uploading}
+ >
+ {t('transfers.upload.send', 'Upload {{count}} files', { count: files.length })}
+
+
,
+ );
+};
diff --git a/frontend/src/services/featureFlags.service.ts b/frontend/src/services/featureFlags.service.ts
index ee396eab..ab399b34 100644
--- a/frontend/src/services/featureFlags.service.ts
+++ b/frontend/src/services/featureFlags.service.ts
@@ -67,6 +67,11 @@ export type FeatureKey =
// Live Slideshow ("Diashow") — per-event fullscreen kiosk link + presets +
// global watermark settings tab. Strictly opt-in; gates all slideshow UI.
| 'slideshow'
+ // PicTransfer (migration 170) — cross-event file transfers:
+ // a token-protected recipient download link plus an optional client-upload
+ // channel. Strictly opt-in; gates the sidebar entry, the /admin/transfers
+ // area and every transfer route (admin + public).
+ | 'transfers'
// Workflow / automation engine — admin-configurable visual flows (triggers,
// conditions, branches, loops, approval gates) built on a canvas. Strictly
// opt-in; gates the Workflows admin area and the engine runtime.
diff --git a/frontend/src/services/transfers.service.ts b/frontend/src/services/transfers.service.ts
new file mode 100644
index 00000000..1bef3652
--- /dev/null
+++ b/frontend/src/services/transfers.service.ts
@@ -0,0 +1,240 @@
+/**
+ * Transfers API client (PicTransfer, #997).
+ *
+ * Three surfaces share one file:
+ * - Admin CRUD under /admin/transfers/* (cookie auth).
+ * - Public recipient download under /public/transfer/:token (token in URL).
+ * - Public client upload under /public/transfer-upload/:token (6-char token).
+ */
+import { api } from '../config/api';
+import { getApiBaseUrl } from '../utils/url';
+
+export interface TransferFile {
+ file_id: number;
+ photo_id: number;
+ filename: string;
+ type: string;
+ size_bytes: number | null;
+ event_id: number;
+ event_name: string;
+ event_slug: string;
+ thumbnail_url: string;
+}
+
+export interface TransferUpload {
+ id: number;
+ original_filename: string;
+ size_bytes: number | null;
+ mime_type: string | null;
+ uploader_ip: string | null;
+ uploaded_at: string;
+}
+
+/** An admin-uploaded deliverable file (not a referenced gallery photo). */
+export interface TransferExtraFile {
+ id: number;
+ filename: string;
+ size_bytes: number | null;
+ mime_type: string | null;
+}
+
+export interface TransferRecipient {
+ id: number;
+ email: string;
+ last_sent_at: string | null;
+}
+
+export interface Transfer {
+ id: number;
+ token: string;
+ title: string;
+ message: string | null;
+ expires_at: string;
+ max_downloads: number | null;
+ download_count: number;
+ downloads_remaining: number | null;
+ is_active: boolean;
+ disabled_at: string | null;
+ grace_days: number;
+ deleted_at: string | null;
+ allow_uploads: boolean;
+ delivery_method: 'link' | 'email';
+ upload_token: string | null;
+ upload_expires_at: string | null;
+ created_at: string;
+ updated_at: string;
+ status: 'active' | 'expired' | 'deleted';
+ download_url: string;
+ upload_url: string | null;
+ file_count: number;
+ upload_count: number;
+ files?: TransferFile[];
+ extra_files?: TransferExtraFile[];
+ recipients?: TransferRecipient[];
+ uploads?: TransferUpload[];
+}
+
+export interface CreateTransferInput {
+ title?: string;
+ message?: string | null;
+ expiresInDays?: number;
+ maxDownloads?: number | null;
+ graceDays?: number;
+ allowUploads?: boolean;
+ uploadExpiresInDays?: number;
+ photoIds?: number[];
+ /** 'link' (default) or 'email' — email the download link to recipientEmails. */
+ deliveryMethod?: 'link' | 'email';
+ recipientEmails?: string[];
+ /** The operator's own files to include in the transfer as deliverables. */
+ files?: File[];
+}
+
+export interface UpdateTransferInput {
+ title?: string;
+ message?: string | null;
+ maxDownloads?: number | null;
+ graceDays?: number;
+ expiresInDays?: number;
+ expiresAt?: string;
+ isActive?: boolean;
+}
+
+// --- Public shapes ---
+
+export interface PublicTransferFile {
+ // Prefixed on the server: `p` = gallery photo, `x` = uploaded file.
+ file_id: string;
+ filename: string;
+ size_bytes: number | null;
+}
+
+export interface PublicTransferView {
+ title: string;
+ message?: string | null;
+ status: 'active' | 'expired' | 'limit_reached';
+ downloadable: boolean;
+ expires_at: string;
+ file_count?: number;
+ total_bytes?: number;
+ downloads_remaining?: number | null;
+ files?: PublicTransferFile[];
+}
+
+export interface UploadInfo {
+ title: string;
+ message: string | null;
+ expires_at: string;
+ max_size_mb: number;
+ max_files: number;
+ allowed_mime: string[];
+}
+
+export const transfersService = {
+ // --- Admin ---
+ async list(search = ''): Promise {
+ const res = await api.get('/admin/transfers', { params: search ? { q: search } : {} });
+ return res.data.transfers;
+ },
+ async get(id: number): Promise {
+ const res = await api.get(`/admin/transfers/${id}`);
+ return res.data.transfer;
+ },
+ async create(input: CreateTransferInput, onProgress?: (pct: number) => void): Promise {
+ // multipart: the operator's own files ride along with the form fields.
+ const form = new FormData();
+ if (input.title != null) form.append('title', input.title);
+ if (input.message != null) form.append('message', input.message);
+ if (input.expiresInDays != null) form.append('expiresInDays', String(input.expiresInDays));
+ if (input.maxDownloads != null) form.append('maxDownloads', String(input.maxDownloads));
+ if (input.graceDays != null) form.append('graceDays', String(input.graceDays));
+ form.append('allowUploads', String(!!input.allowUploads));
+ if (input.uploadExpiresInDays != null) form.append('uploadExpiresInDays', String(input.uploadExpiresInDays));
+ form.append('photoIds', JSON.stringify(input.photoIds || []));
+ form.append('deliveryMethod', input.deliveryMethod || 'link');
+ form.append('recipientEmails', JSON.stringify(input.recipientEmails || []));
+ (input.files || []).forEach((f) => form.append('files', f));
+ const res = await api.post('/admin/transfers', form, {
+ onUploadProgress: (e) => {
+ if (onProgress && e.total) onProgress(Math.round((e.loaded / e.total) * 100));
+ },
+ });
+ return res.data.transfer;
+ },
+ /** Add deliverable files to an existing transfer. */
+ async uploadFiles(id: number, files: File[], onProgress?: (pct: number) => void): Promise {
+ const form = new FormData();
+ files.forEach((f) => form.append('files', f));
+ const res = await api.post(`/admin/transfers/${id}/upload-files`, form, {
+ onUploadProgress: (e) => {
+ if (onProgress && e.total) onProgress(Math.round((e.loaded / e.total) * 100));
+ },
+ });
+ return res.data.transfer;
+ },
+ async removeExtraFile(id: number, extraId: number): Promise {
+ const res = await api.delete(`/admin/transfers/${id}/extra-files/${extraId}`);
+ return res.data.transfer;
+ },
+ adminExtraFileDownloadUrl(id: number, extraId: number): string {
+ return `${getApiBaseUrl()}/admin/transfers/${id}/extra-files/${extraId}/download`;
+ },
+ async update(id: number, input: UpdateTransferInput): Promise {
+ const res = await api.patch(`/admin/transfers/${id}`, input);
+ return res.data.transfer;
+ },
+ async remove(id: number): Promise {
+ await api.delete(`/admin/transfers/${id}`);
+ },
+ async addFiles(id: number, photoIds: number[]): Promise {
+ const res = await api.post(`/admin/transfers/${id}/files`, { photoIds });
+ return res.data.transfer;
+ },
+ async removeFile(id: number, fileId: number): Promise {
+ const res = await api.delete(`/admin/transfers/${id}/files/${fileId}`);
+ return res.data.transfer;
+ },
+ async enableUploads(id: number, uploadExpiresInDays?: number): Promise {
+ const res = await api.post(`/admin/transfers/${id}/upload-link`, { uploadExpiresInDays });
+ return res.data.transfer;
+ },
+ async disableUploads(id: number): Promise {
+ const res = await api.delete(`/admin/transfers/${id}/upload-link`);
+ return res.data.transfer;
+ },
+ /** Absolute API URL for the admin ZIP download (cookie auth → usable as href). */
+ adminDownloadUrl(id: number): string {
+ return `${getApiBaseUrl()}/admin/transfers/${id}/download`;
+ },
+ adminUploadDownloadUrl(id: number, uploadId: number): string {
+ return `${getApiBaseUrl()}/admin/transfers/${id}/uploads/${uploadId}/download`;
+ },
+
+ // --- Public recipient ---
+ async getPublic(token: string): Promise {
+ const res = await api.get(`/public/transfer/${token}`);
+ return res.data.transfer;
+ },
+ publicDownloadAllUrl(token: string): string {
+ return `${getApiBaseUrl()}/public/transfer/${token}/download`;
+ },
+ publicFileUrl(token: string, fileId: string): string {
+ return `${getApiBaseUrl()}/public/transfer/${token}/download/${fileId}`;
+ },
+
+ // --- Public client upload ---
+ async getUploadInfo(token: string): Promise {
+ const res = await api.get(`/public/transfer-upload/${token}`);
+ return res.data.transfer;
+ },
+ async upload(token: string, files: File[], onProgress?: (pct: number) => void): Promise<{ uploaded: number }> {
+ const form = new FormData();
+ files.forEach((f) => form.append('files', f));
+ const res = await api.post(`/public/transfer-upload/${token}`, form, {
+ onUploadProgress: (e) => {
+ if (onProgress && e.total) onProgress(Math.round((e.loaded / e.total) * 100));
+ },
+ });
+ return res.data;
+ },
+};