Merge pull request #513 from the-luap/fix/bug-batch

fix/feat: bug batch — drag-drop, lightbox, likes, downloads, upload, i18n (#504-510)
This commit is contained in:
Paul Nothaft
2026-05-17 01:01:49 +02:00
committed by GitHub
28 changed files with 3013 additions and 81 deletions
@@ -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();
};
@@ -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: `<h2>Galería creada con éxito</h2>
<p>Estimado {{host_name}},</p>
<p>Su galería de fotos "{{event_name}}" ha sido creada con éxito!</p>
<p><strong>Detalles de la galería:</strong></p>
<ul>
<li>Fecha del evento: {{event_date}}</li>
<li>Enlace de la galería: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
<li>Contraseña: {{gallery_password}}</li>
<li>Expira en: {{expiry_date}}</li>
</ul>
<p>Comparta este enlace y contraseña con sus invitados para que puedan ver y descargar las fotos.</p>
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/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: `<h2>Galería expirando pronto</h2>
<p>Estimado {{host_name}},</p>
<p>Su galería de fotos "{{event_name}}" expirará en {{days_remaining}} días.</p>
<p>Después de la expiración, la galería será archivada y ya no estará accesible para los invitados.</p>
<p><a href="{{gallery_link}}">Visitar galería</a></p>`,
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: `<h2>Galería vencida</h2>
<p>Estimado {{host_name}},</p>
<p>Su galería de fotos "{{event_name}}" ha caducado y por tanto ya no es accesible.</p>
<p>Las fotos han sido archivadas. Si necesita acceso, por favor póngase en contacto con el administrador a través de {{admin_email}}.</p>`,
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: `<h2>Archivado completado</h2>
<p>Estimado {{host_name}},</p>
<p>Su galería de fotos "{{event_name}}" ha sido archivada con éxito.</p>
<p><strong>Detalles del archivo:</strong></p>
<ul>
<li>Número de fotos: {{photo_count}}</li>
<li>Tamaño del archivo: {{archive_size}}</li>
<li>Fecha del archivado: {{archive_date}}</li>
</ul>`,
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.
};
+6 -1
View File
@@ -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
+13
View File
@@ -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
+13 -1
View File
@@ -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'
});
+4
View File
@@ -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}}') {