diff --git a/backend/migrations/core/072_add_max_upload_batch_size.js b/backend/migrations/core/072_add_max_upload_batch_size.js new file mode 100644 index 00000000..abd381d9 --- /dev/null +++ b/backend/migrations/core/072_add_max_upload_batch_size.js @@ -0,0 +1,38 @@ +/** + * Migration: re-add the configurable upload batch size setting (#509). + * + * Originally shipped via PR #214 (#208 fix) — users behind Cloudflare + * Tunnel and other reverse proxies with per-request size caps need to + * bound the chunked-upload size so they don't lose every batch >100MB. + * That migration + frontend wiring was lost during a `Merge main into + * beta for release/beta-to-main` resolution that picked main's older + * tree over beta's, silently deleting the file and reinstating the + * hardcoded 500MB chunk in PhotoUpload.tsx. + * + * Re-introducing the exact same migration here. Idempotent: skips the + * insert if the row already exists (e.g. installs that did go through + * the original 072 between #214 merge and the main-into-beta merge, + * where the migrations-table row was preserved even after the file + * was deleted). + */ + +exports.up = async function(knex) { + const exists = await knex('app_settings') + .where({ setting_key: 'general_max_upload_batch_size_mb' }) + .first(); + + if (!exists) { + await knex('app_settings').insert({ + setting_key: 'general_max_upload_batch_size_mb', + setting_value: JSON.stringify(95), + setting_type: 'general', + updated_at: new Date() + }); + } +}; + +exports.down = async function(knex) { + await knex('app_settings') + .where({ setting_key: 'general_max_upload_batch_size_mb' }) + .del(); +}; diff --git a/backend/migrations/core/106_seed_es_email_template_translations.js b/backend/migrations/core/106_seed_es_email_template_translations.js new file mode 100644 index 00000000..06db0e78 --- /dev/null +++ b/backend/migrations/core/106_seed_es_email_template_translations.js @@ -0,0 +1,121 @@ +/** + * Migration: seed Spanish (es) email-template translations (#510). + * + * Contributed by @AloePacci on issue #510. Covers the four + * customer-facing gallery delivery templates that already had + * en/de/nl/pt/ru rows from migration 075. Templates without an `es` + * row (admin_*, backup_*, restore_*, customer_*, version_update_*) + * continue to fall back to `en` via the resolution chain in + * emailProcessor.processTemplate — no functional gap, just untranslated + * copy until someone fills them in. + * + * Same idempotency pattern as 099_seed_missing_email_template_translations: + * checks (template_id, language) before inserting so re-runs are safe. + */ + +const TRANSLATIONS = { + gallery_created: { + es: { + subject: 'Su galería de fotos está lista!', + body_html: `

Galería creada con éxito

+

Estimado {{host_name}},

+

Su galería de fotos "{{event_name}}" ha sido creada con éxito!

+

Detalles de la galería:

+ +

Comparta este enlace y contraseña con sus invitados para que puedan ver y descargar las fotos.

+{{#if welcome_message}}

{{welcome_message}}

{{/if}}`, + body_text: 'Galería creada con éxito\n\nEstimado {{host_name}},\n\nSu galería de fotos "{{event_name}}" ha sido creada con éxito!\n\nEnlace de la galería: {{gallery_link}}\nContraseña: {{gallery_password}}\nExpira en: {{expiry_date}}', + }, + }, + + expiration_warning: { + es: { + subject: 'Su galería de fotos expirará pronto', + body_html: `

Galería expirando pronto

+

Estimado {{host_name}},

+

Su galería de fotos "{{event_name}}" expirará en {{days_remaining}} días.

+

Después de la expiración, la galería será archivada y ya no estará accesible para los invitados.

+

Visitar galería

`, + body_text: 'Galería expirando pronto\n\nEstimado {{host_name}},\n\nSu galería de fotos "{{event_name}}" expirará en {{days_remaining}} días.\n\nGalería: {{gallery_link}}', + }, + }, + + gallery_expired: { + es: { + subject: 'Su galería de fotos está caducada', + body_html: `

Galería vencida

+

Estimado {{host_name}},

+

Su galería de fotos "{{event_name}}" ha caducado y por tanto ya no es accesible.

+

Las fotos han sido archivadas. Si necesita acceso, por favor póngase en contacto con el administrador a través de {{admin_email}}.

`, + body_text: 'Galería caducada\n\nEstimado {{host_name}},\n\nSu galería de fotos "{{event_name}}" ha caducado y ya no está accesible.\n\nContacto: {{admin_email}}', + }, + }, + + archive_complete: { + es: { + subject: 'Archivado completado: {{event_name}}', + body_html: `

Archivado completado

+

Estimado {{host_name}},

+

Su galería de fotos "{{event_name}}" ha sido archivada con éxito.

+

Detalles del archivo:

+`, + body_text: 'Archivado completado\n\nEstimado {{host_name}},\n\nSu galería de fotos "{{event_name}}" ha sido archivada con éxito.\n\nFotos: {{photo_count}}\nTamaño: {{archive_size}}', + }, + }, +}; + +exports.up = async function(knex) { + if (!(await knex.schema.hasTable('email_templates'))) return; + if (!(await knex.schema.hasTable('email_template_translations'))) return; + + const rows = await knex('email_templates') + .whereIn('template_key', Object.keys(TRANSLATIONS)) + .select('id', 'template_key'); + const keyToId = Object.fromEntries(rows.map((r) => [r.template_key, r.id])); + + let inserted = 0; + let skipped = 0; + + for (const [key, perLocale] of Object.entries(TRANSLATIONS)) { + const templateId = keyToId[key]; + if (!templateId) continue; + + for (const [language, content] of Object.entries(perLocale)) { + const existing = await knex('email_template_translations') + .where({ template_id: templateId, language }) + .first(); + if (existing) { + skipped += 1; + continue; + } + await knex('email_template_translations').insert({ + template_id: templateId, + language, + subject: content.subject, + body_html: content.body_html, + body_text: content.body_text, + created_at: new Date(), + updated_at: new Date(), + }); + inserted += 1; + } + } + + console.log(`106_seed_es_email_template_translations: inserted=${inserted}, skipped=${skipped}`); +}; + +exports.down = async function(knex) { + // No-op: same rationale as 099. An admin may have hand-edited the + // `es` rows in the Templates UI after this migration ran, and we + // can't tell apart inserted-by-us rows from edited-by-admin rows. + // Rollback by hand if you truly need to drop them. +}; diff --git a/backend/server.js b/backend/server.js index 7746487b..832ce91c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -161,7 +161,12 @@ const corsOptions = { callback(null, false); } }, - credentials: true + credentials: true, + // Expose Content-Disposition so split (cross-origin) frontend + // deployments can read the server's chosen download filename. Used + // by the gallery/admin download flows to honour the #493 "original + // camera filename" toggle on individual photo downloads (#507). + exposedHeaders: ['Content-Disposition'], }; // Only attach CORS to API endpoints, not static assets diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index d954c2c3..e155da0a 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -430,6 +430,13 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { // keeps working with the original. logger.debug to avoid noise. logger.debug('lightbox_preview_enabled lookup failed, treating as off', { error: e?.message }); } + + // #508: when the admin has flipped the "use original camera filenames" + // toggle (#493), the lightbox surfaces each photo's original_filename + // alongside the position counter so the photographer can map a guest's + // selection back to source files. Tied to the same toggle as downloads — + // one switch controls both surfaces. + const useOriginalFilenames = await getUseOriginalFilenames(); res.json({ @@ -458,6 +465,9 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { hero_image_anchor: req.event.hero_image_anchor || 'center', default_photo_sort: req.event.default_photo_sort || 'upload_date_desc', download_zip_ready: !!(req.event.download_zip_path && req.event.download_zip_generated_at), + // Mirror of the admin-side toggle so the lightbox can decide + // whether to surface original camera filenames (#508). + use_original_filenames: useOriginalFilenames, ...protectionSettings }, categories: categories, @@ -472,6 +482,9 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => { return { id: photo.id, filename: photo.filename, + // Raw camera filename (or null for pre-migration-062 uploads). + // The lightbox renders it when `use_original_filenames` is on. + original_filename: photo.original_filename || null, url: photoUrl, thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}${wmQuery}` : null, // Hero-optimized image URL (1920x1080) for full-width hero sections diff --git a/backend/src/routes/secureImages.js b/backend/src/routes/secureImages.js index 81689e59..79093eee 100644 --- a/backend/src/routes/secureImages.js +++ b/backend/src/routes/secureImages.js @@ -8,6 +8,11 @@ const { formatBoolean } = require('../utils/dbCompat'); const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../services/photoResolver'); const { withLocalCopy } = require('../services/imageProcessor'); const { getStorage } = require('../services/storage'); +const { + getUseOriginalFilenames, + pickRawDownloadName, +} = require('../services/downloadFilenameService'); +const { buildContentDisposition } = require('../utils/filenameSanitizer'); const router = express.Router(); @@ -339,9 +344,16 @@ router.get('/:slug/secure-download/:photoId/:token', 'download' ); + // #493/#507: respect the original-filename toggle here too. The + // regular `/gallery/:slug/download/:photoId` route already does + // this — secure-images was missed in the original PR and ran + // even when the admin had opted into original camera filenames. + const useOriginal = await getUseOriginalFilenames(); + const downloadName = pickRawDownloadName(photo, useOriginal); + res.set({ 'Content-Type': photo.mime_type || 'image/jpeg', - 'Content-Disposition': `attachment; filename="${photo.filename}"`, + 'Content-Disposition': buildContentDisposition(downloadName), 'Content-Length': fileBuffer.length, 'X-Download-Protected': 'true' }); diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index ecab43cb..27cca8de 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -140,6 +140,7 @@ async function getRecipientLanguage(email, eventId = null) { { domains: ['.nl', '.be'], language: 'nl' }, { domains: ['.br', '.pt'], language: 'pt' }, { domains: ['.ru', '.su'], language: 'ru' }, + { domains: ['.es'], language: 'es' }, ]; for (const { domains, language: lang } of domainLanguageMap) { if (domains.some(d => domain.endsWith(d))) { @@ -497,6 +498,7 @@ async function processTemplate(template, variables, language = 'en') { nl: '(Om veiligheidsredenen niet weergegeven)', pt: '(Não exibido por motivos de segurança)', ru: '(Не показано в целях безопасности)', + es: '(No se muestra por razones de seguridad)', }; const noPasswordI18n = { en: 'No password required', @@ -504,6 +506,7 @@ async function processTemplate(template, variables, language = 'en') { nl: 'Geen wachtwoord vereist', pt: 'Nenhuma senha necessária', ru: 'Пароль не требуется', + es: 'No se requiere contraseña', }; // Sent by the publish-from-draft flow (adminEvents.js): by the time the // event is published, only the bcrypt hash is stored, so the plaintext @@ -515,6 +518,7 @@ async function processTemplate(template, variables, language = 'en') { nl: 'Het wachtwoord dat u bij het aanmaken van de galerij hebt ingesteld', pt: 'A senha definida ao criar a galeria', ru: 'Пароль, заданный при создании галереи', + es: 'La contraseña que estableciste al crear la galería', }; if (processedVariables.gallery_password === '{{password_security_message}}') { diff --git a/frontend/src/components/admin/PhotoUpload.tsx b/frontend/src/components/admin/PhotoUpload.tsx index b5c5d43f..2cae80c8 100644 --- a/frontend/src/components/admin/PhotoUpload.tsx +++ b/frontend/src/components/admin/PhotoUpload.tsx @@ -79,14 +79,16 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl ); const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0); + const [isDragOver, setIsDragOver] = useState(false); + + // Shared filter + per-upload-limit pipeline used by both the file-input + // change handler and the drop handler. #504 — without the drop handler + // the dashed-border zone looked draggable but silently fell through to + // the browser's default "open the file in a new tab" behaviour. + const addFiles = (incoming: File[]) => { + const imageFiles = incoming.filter((file) => allowedMimeTypes.includes(file.type)); + if (imageFiles.length === 0) return; - const handleFileSelect = (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []); - const imageFiles = files.filter(file => - allowedMimeTypes.includes(file.type) - ); - - // Check total file count with existing files const totalFiles = selectedFiles.length + imageFiles.length; if (totalFiles > maxFilesPerUpload) { const allowedNewFiles = maxFilesPerUpload - selectedFiles.length; @@ -101,11 +103,44 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) || `Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})` ); - setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]); + setSelectedFiles((prev) => [...prev, ...imageFiles.slice(0, allowedNewFiles)]); return; } - - setSelectedFiles(prev => [...prev, ...imageFiles]); + + setSelectedFiles((prev) => [...prev, ...imageFiles]); + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + addFiles(Array.from(e.target.files || [])); + // Reset the input so picking the same files again still fires onChange. + if (e.target.value) e.target.value = ''; + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + // dropEffect must be set on every dragover for the cursor to render + // the "copy" affordance in Chrome/Firefox. + e.dataTransfer.dropEffect = 'copy'; + if (!isDragOver) setIsDragOver(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + // dragleave fires for every child node the cursor passes — only flip + // the highlight off when the cursor leaves the zone itself, otherwise + // it strobes on/off as the user moves over the icon and text. + if (e.currentTarget.contains(e.relatedTarget as Node | null)) return; + setIsDragOver(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + const files = Array.from(e.dataTransfer.files || []); + addFiles(files); }; const removeFile = (index: number) => { @@ -128,9 +163,14 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl setUploadProgress(0); setUploadIds([]); - // For large uploads, chunk the files by both count AND size to prevent memory/network issues + // For large uploads, chunk the files by both count AND size to prevent memory/network issues. + // #509: the per-chunk byte cap MUST be tunable so users behind Cloudflare Tunnel and other + // reverse proxies with request-size limits can drop it below their proxy's cap. Falls back + // to 95MB (Cloudflare-safe headroom under 100MB) when the setting is unset — that matches + // the value the migration seeds and is what worked in #208's resolution. const MAX_FILES_PER_CHUNK = Math.max(1, Math.min(50, maxFilesPerUpload)); // Max 50 files per chunk - const MAX_BYTES_PER_CHUNK = 500 * 1024 * 1024; // Max 500MB per chunk (nginx limit is 1GB) + const maxBatchSizeMb = Number(settings?.general_max_upload_batch_size_mb) || 95; + const MAX_BYTES_PER_CHUNK = maxBatchSizeMb * 1024 * 1024; const chunks: File[][] = []; let currentChunk: File[] = []; @@ -349,14 +389,22 @@ export const PhotoUpload: React.FC = ({ eventId, onUploadCompl - {/* File Input Area */} + {/* File Input Area — accepts both click-to-pick and drag-and-drop (#504). */}
0 ? "border-accent-dark bg-accent-dark/15" : "border-neutral-300 dark:border-neutral-600" + isDragOver + ? "border-accent-dark bg-accent-dark/25" + : selectedFiles.length > 0 + ? "border-accent-dark bg-accent-dark/15" + : "border-neutral-300 dark:border-neutral-600" )} onClick={() => fileInputRef.current?.click()} + onDragOver={handleDragOver} + onDragEnter={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={handleDrop} >

diff --git a/frontend/src/components/common/LanguageSelector.tsx b/frontend/src/components/common/LanguageSelector.tsx index 56598ba8..bb587e75 100644 --- a/frontend/src/components/common/LanguageSelector.tsx +++ b/frontend/src/components/common/LanguageSelector.tsx @@ -54,6 +54,14 @@ const FRFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => ); +const ESFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => ( + + + + + +); + export const SUPPORTED_LANGUAGES = [ { code: 'en', name: 'English', Flag: GBFlag }, { code: 'de', name: 'Deutsch', Flag: DEFlag }, @@ -61,6 +69,7 @@ export const SUPPORTED_LANGUAGES = [ { code: 'pt', name: 'Português', Flag: PTBRFlag }, { code: 'nl', name: 'Nederlands', Flag: NLFlag }, { code: 'fr', name: 'Français', Flag: FRFlag }, + { code: 'es', name: 'Español', Flag: ESFlag }, ]; export const LanguageSelector: React.FC = () => { diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 22bc4c6f..6d6c1c09 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -143,6 +143,9 @@ export const GalleryView: React.FC = ({ slug, event }) => { const disableRightClick = data?.event?.disable_right_click === true; const enableDevtoolsProtection = data?.event?.enable_devtools_protection === true; const useCanvasRendering = data?.event?.use_canvas_rendering === true; + // #508 — surface original camera filenames in the lightbox when the + // admin has flipped the same toggle that drives original-name downloads. + const showOriginalFilename = data?.event?.use_original_filenames === true; // DevTools protection - enabled by individual setting OR legacy protection level const devToolsEnabled = enableDevtoolsProtection || protectionLevel === 'enhanced' || protectionLevel === 'maximum'; @@ -677,6 +680,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { heroImageAnchor={data?.event?.hero_image_anchor || 'center'} welcomeMessage={event.welcome_message} onLogout={logout} + showOriginalFilename={showOriginalFilename} /> {/* Upload Modal for full-page layouts */} @@ -918,6 +922,7 @@ export const GalleryView: React.FC = ({ slug, event }) => { welcomeMessage={event.welcome_message} isClient={isClient} onToggleVisibility={isClient ? handleToggleVisibility : undefined} + showOriginalFilename={showOriginalFilename} />

diff --git a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx index 15ee1c96..214b70ba 100644 --- a/frontend/src/components/gallery/PhotoGridWithLayouts.tsx +++ b/frontend/src/components/gallery/PhotoGridWithLayouts.tsx @@ -71,6 +71,9 @@ interface PhotoGridWithLayoutsProps { // Client visibility controls (#172) isClient?: boolean; onToggleVisibility?: (photoId: number, currentVisibility: string) => void; + // Mirror of the admin original-filename toggle (#508). When true, the + // lightbox bottom toolbar surfaces each photo's original camera name. + showOriginalFilename?: boolean; } export const PhotoGridWithLayouts: React.FC = ({ @@ -105,7 +108,8 @@ export const PhotoGridWithLayouts: React.FC = ({ welcomeMessage, onLogout, isClient = false, - onToggleVisibility + onToggleVisibility, + showOriginalFilename = false, }) => { const { t } = useTranslation(); const { theme } = useTheme(); @@ -238,6 +242,7 @@ export const PhotoGridWithLayouts: React.FC = ({ onLogout, isClient, onToggleVisibility, + showOriginalFilename, }; // Determine if we should show hero header (decoupled from layout) @@ -379,6 +384,7 @@ export const PhotoGridWithLayouts: React.FC = ({ enableDevtoolsProtection={enableDevtoolsProtection} initialShowFeedback={openFeedbackInitially} onFeedbackChange={onFeedbackChange} + showOriginalFilename={showOriginalFilename} /> )} diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index 2b5b9a4e..141c0244 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -24,6 +24,11 @@ interface PhotoLightboxProps { onFeedbackChange?: () => void; disableRightClick?: boolean; enableDevtoolsProtection?: boolean; + // When true, surface each photo's original camera filename in the + // bottom toolbar — useful for photographers matching guest selections + // back to source files (#508). Tied to the admin-side toggle that + // also drives original-filename downloads (#493). + showOriginalFilename?: boolean; } export const PhotoLightbox: React.FC = ({ @@ -40,6 +45,7 @@ export const PhotoLightbox: React.FC = ({ onFeedbackChange, disableRightClick = false, enableDevtoolsProtection = false, + showOriginalFilename = false, }) => { const [currentIndex, setCurrentIndex] = useState(initialIndex); const [zoom, setZoom] = useState(1); @@ -593,10 +599,23 @@ export const PhotoLightbox: React.FC = ({ }} >
-
+

{currentIndex + 1} / {photos.length}

+ {/* #508 — original camera filename next to the counter when + the admin has flipped the matching toggle. Falls back to + the storage filename only if `original_filename` is null + (pre-migration-062 uploads). truncate + max-w keep long + names from pushing the action row to another line. */} + {showOriginalFilename && (currentPhoto.original_filename || currentPhoto.filename) && ( +

+ {currentPhoto.original_filename || currentPhoto.filename} +

+ )}
@@ -696,20 +715,38 @@ export const PhotoLightbox: React.FC = ({ {(() => { const isVideoCurrent = currentPhoto.media_type === 'video'; - const renderSlide = (photo: Photo | null, isCurrent: boolean) => { + // Stable per-slide keys so React's reconciler can MOVE existing + // DOM nodes across slot positions on commit rather than + // re-fetching the AuthenticatedImage at the new position (#505 — + // that re-fetch is what caused the black blink during swipe). + // Edge case: 2-photo galleries assign the same photo to both + // `prev` and `next`; fall back to slot-prefixed keys to keep + // siblings unique. >2-photo galleries (the common case) get + // plain photo.id keys so a "next becomes current" commit + // preserves the loaded image instance. + const slideKey = (photo: Photo | null, slot: 'prev' | 'current' | 'next') => { + if (!photo) return `empty-${slot}`; + if (photos.length === 2) return `${slot}-${photo.id}`; + return `photo-${photo.id}`; + }; + + const renderSlide = (photo: Photo | null, isCurrent: boolean, slot: 'prev' | 'current' | 'next') => { // Reserve the slot even when there's no neighbour (single-photo // gallery) so the flex layout keeps slides aligned. if (!photo) { - return