feat(transfers): add PicTransfer — cross-event file transfers (#998)

Closes #997.

Send original files from any event as a token-protected download link, with an
optional client-upload channel. Strictly opt-in behind a new `transfers`
feature flag, default OFF.

Migrations 170-172 (transfers, transfer_files, transfer_extra_files,
transfer_uploads, transfer_recipients, transfer_downloads, default settings and
two email templates) — all hasTable/hasColumn-guarded and idempotent, with
destructive statements confined to down().

Backend: transferService (CRUD, 256-bit download token, 6-char upload token,
cross-event ZIP streaming of originals), admin CRUD routes, and two public
token routes. transferCleanupService runs an hourly retention sweep; source-event
photos are never touched. All three routers fail closed via
requireFeatureFlag('transfers').

Review closed two ownership blockers, both the same root cause — permissions
used where ownership was needed:

- photoIds arrived from the request body and were validated only for existence,
  so a scoped admin could bundle any event's originals and hand them out through
  the public download token. filterOwnedPhotoIds now resolves ids to their events
  and gates them through filterOwnedEventIds, on both the create and add-files
  paths.
- The transfer list was unscoped and carried each row's download token, so any
  admin with events.view could read another's token and fetch their originals.
  The list is now scoped by created_by, the token/url fields are stripped from
  the list payload, and a single router.use('/:id', requireTransferOwnership)
  covers all twelve /:id routes, 404ing foreign and missing alike.

The admin photo picker filters its event list to the same rule, so the UI stops
offering picks the API would discard.

Fork-PR workflows had not been approved since the fix commits, so the PR's green
checks were stale against the pre-fix head. Verified by dispatching tests.yml
against the actual head: backend and frontend both green.

Follow-up: neither ownership guard has a regression test yet.

Co-authored-by: Luca-Timo <[email protected]>
This commit is contained in:
Luca
2026-08-09 13:40:03 +02:00
committed by GitHub
co-authored by Luca-Timo
parent e2d8ec86bd
commit 2e495d7c48
23 changed files with 4180 additions and 0 deletions
+112
View File
@@ -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": "[email protected], [email protected]",
"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.",
+112
View File
@@ -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": "[email protected], [email protected]",
"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.",