Merge remote-tracking branch 'origin/main' into feat/guest-upload-dng-raw

# Conflicts:
#	backend/src/services/uploadSettings.js
#	backend/src/utils/fileSecurityUtils.js
#	frontend/src/utils/fileTypes.ts
This commit is contained in:
Paul Nothaft
2026-07-18 20:52:08 +02:00
23 changed files with 197 additions and 21 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "3.90.1-beta.0" ".": "3.90.2-beta.0"
} }
+14
View File
@@ -5,6 +5,20 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.90.2-beta.0](https://github.com/PicPeak/picpeak/compare/v3.90.1-beta.0...v3.90.2-beta.0) (2026-07-17)
### Bug Fixes
* **events:** accept hero_logo_visible: null on create/update ([#822](https://github.com/PicPeak/picpeak/issues/822)) ([0245e44](https://github.com/PicPeak/picpeak/commit/0245e445cafd165ada3c5a15abb258ae2c1c857e))
* **events:** accept hero_logo_visible: null on create/update ([#822](https://github.com/PicPeak/picpeak/issues/822)) ([b97b130](https://github.com/PicPeak/picpeak/commit/b97b130cadebaef38e59cc227fa6578ac886110f))
* **update:** target docker-compose.production.yml in dashboard update steps ([51a505e](https://github.com/PicPeak/picpeak/commit/51a505e3798895e544f943673e81a365265f319c))
* **update:** target docker-compose.production.yml in dashboard update steps + gate mailhog ([2a0361a](https://github.com/PicPeak/picpeak/commit/2a0361a83b4ca0a600bb4fd447e338533ce63420))
* **uploads:** apply configured max file size to guest uploads ([#613](https://github.com/PicPeak/picpeak/issues/613) follow-up) ([29f1d23](https://github.com/PicPeak/picpeak/commit/29f1d23a0a645208f22453e62d99fe79b55c7db4))
* **uploads:** apply configured max file size to guest uploads ([#613](https://github.com/PicPeak/picpeak/issues/613) follow-up) ([1e38d84](https://github.com/PicPeak/picpeak/commit/1e38d84808ee2a2b176c75d5ec4975fba710e63c))
* **uploads:** tighten guest max-file-size setting (codex review of [#823](https://github.com/PicPeak/picpeak/issues/823)) ([43c6d22](https://github.com/PicPeak/picpeak/commit/43c6d22bdd93179865703da6350094c9b95388d8))
* **uploads:** tighten guest max-file-size setting (codex review of [#823](https://github.com/PicPeak/picpeak/issues/823)) ([e03d13e](https://github.com/PicPeak/picpeak/commit/e03d13efde843c7a7275cd41c855b402538756e7))
## [3.90.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.90.0-beta.0...v3.90.1-beta.0) (2026-07-17) ## [3.90.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.90.0-beta.0...v3.90.1-beta.0) (2026-07-17)
@@ -180,6 +180,27 @@ describe('admin events CRUD endpoints (smoke)', () => {
}); });
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
// #822 — hero_logo_visible/position are nullable (null = "inherit the global
// branding toggle"), but the validator used .optional() without
// { nullable: true }, so an explicit null was rejected with 400.
it('accepts hero_logo_visible: null and stores NULL (inherit)', async () => {
const id = await insertEvent(db, adminId, { hero_logo_visible: 1 });
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
hero_logo_visible: null,
});
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.hero_logo_visible).toBeNull();
});
it('still rejects a non-boolean hero_logo_visible', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
hero_logo_visible: 'maybe',
});
expect(res.status).toBe(400);
});
}); });
describe('DELETE /:id', () => { describe('DELETE /:id', () => {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "picpeak-backend", "name": "picpeak-backend",
"version": "3.90.1-beta.0", "version": "3.90.2-beta.0",
"description": "Backend for PicPeak event photo sharing platform", "description": "Backend for PicPeak event photo sharing platform",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
+6 -4
View File
@@ -94,7 +94,7 @@ module.exports = (router) => {
body('allow_presigned_download').optional().isBoolean(), body('allow_presigned_download').optional().isBoolean(),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(), body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
// Hero logo settings // Hero logo settings
body('hero_logo_visible').optional().isBoolean(), body('hero_logo_visible').optional({ nullable: true }).isBoolean(),
body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']), body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']), body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout) // Header style settings (decoupled from layout)
@@ -342,8 +342,10 @@ module.exports = (router) => {
// hero_logo_visible: store NULL ("inherit") unless the admin explicitly // hero_logo_visible: store NULL ("inherit") unless the admin explicitly
// set it, so the global branding_logo_display_hero toggle keeps // set it, so the global branding_logo_display_hero toggle keeps
// controlling this gallery afterwards (#756). Only an explicit per-event // controlling this gallery afterwards (#756). Only an explicit per-event
// choice overrides the global. // choice overrides the global. `!= null` treats an explicit null the same
const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined // as omitted (both → inherit); otherwise formatBoolean(null) would coerce
// to 0/false on SQLite instead of NULL (the PUT handler already does this).
const effectiveHeroLogoVisible = req.body.hero_logo_visible != null
? formatBoolean(hero_logo_visible) ? formatBoolean(hero_logo_visible)
: null; : null;
// NULL = inherit the global branding_logo_size (#756), resolved at read // NULL = inherit the global branding_logo_size (#756), resolved at read
@@ -1224,7 +1226,7 @@ module.exports = (router) => {
}), }),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(), body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
// Hero logo settings // Hero logo settings
body('hero_logo_visible').optional().isBoolean(), body('hero_logo_visible').optional({ nullable: true }).isBoolean(),
body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']), body('hero_logo_size').optional({ nullable: true }).isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']), body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout) // Header style settings (decoupled from layout)
+20 -1
View File
@@ -25,7 +25,7 @@ const { resetSecurityConfigCache } = require('../utils/authSecurity');
const { errorResponse } = require('../utils/routeHelpers'); const { errorResponse } = require('../utils/routeHelpers');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const router = express.Router(); const router = express.Router();
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings'); const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD, clearMaxFileSizeCache, MAX_ALLOWED_FILE_SIZE_MB } = require('../services/uploadSettings');
const watermarkService = require('../services/watermarkService'); const watermarkService = require('../services/watermarkService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService'); const watermarkGeneratorService = require('../services/watermarkGeneratorService');
@@ -1095,6 +1095,24 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
settings.general_max_files_per_upload = normalizedValue; settings.general_max_files_per_upload = normalizedValue;
} }
// Per-file size limit (MB). Validate/clamp on save, mirroring the count
// above, so an out-of-range value can't be persisted — otherwise the public
// endpoint would advertise the raw value while getMaxFileSizeMb() normalizes
// it, and the guest UI would reject files the backend actually accepts.
if (Object.prototype.hasOwnProperty.call(settings, 'general_max_file_size_mb')) {
uploadLimitTouched = true;
const rawValue = Number(settings.general_max_file_size_mb);
const normalizedValue = Number.isFinite(rawValue) ? Math.floor(rawValue) : NaN;
if (!Number.isInteger(normalizedValue) || normalizedValue < 1 || normalizedValue > MAX_ALLOWED_FILE_SIZE_MB) {
return res.status(400).json({
error: `general_max_file_size_mb must be an integer between 1 and ${MAX_ALLOWED_FILE_SIZE_MB}`
});
}
settings.general_max_file_size_mb = normalizedValue;
}
if (publicSiteKeysTouched) { if (publicSiteKeysTouched) {
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) { if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || ''); settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
@@ -1151,6 +1169,7 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
} }
if (uploadLimitTouched) { if (uploadLimitTouched) {
clearMaxFilesPerUploadCache(); clearMaxFilesPerUploadCache();
clearMaxFileSizeCache();
} }
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) { if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
clearShareLinkSettingsCache(); clearShareLinkSettingsCache();
+19 -1
View File
@@ -38,6 +38,24 @@ const {
} = require('../services/downloadFilenameService'); } = require('../services/downloadFilenameService');
const { buildContentDisposition } = require('../utils/filenameSanitizer'); const { buildContentDisposition } = require('../utils/filenameSanitizer');
const { getStorage } = require('../services/storage'); const { getStorage } = require('../services/storage');
// Formats whose ORIGINAL bytes a browser can't render in an <img> (HEIC/HEIF,
// camera RAW/DNG). For these the lightbox must be served the generated JPEG
// preview instead of `url` (the original) — otherwise it shows a broken image.
// So we force `preview_url` for them regardless of the lightbox_preview_enabled
// toggle. Detection is by MIME first, extension as a fallback (browsers report
// these MIMEs inconsistently). EXPERIMENTAL: whether a preview actually renders
// still depends on the backend being able to decode the source (HEVC-in-HEIC on
// the prod image; exiftool for DNG) — see #821.
const NON_DISPLAYABLE_ORIGINAL_EXT = new Set(['heic', 'heif', 'dng']);
const NON_DISPLAYABLE_ORIGINAL_MIME = new Set(['image/heic', 'image/heif', 'image/x-adobe-dng']);
function originalNeedsPreview(photo) {
const mime = (photo.mime_type || '').toLowerCase();
if (NON_DISPLAYABLE_ORIGINAL_MIME.has(mime)) return true;
const name = photo.original_filename || photo.filename || '';
const ext = name.includes('.') ? name.split('.').pop().toLowerCase() : '';
return NON_DISPLAYABLE_ORIGINAL_EXT.has(ext);
}
const { setGalleryAuthCookies } = require('../utils/tokenUtils'); const { setGalleryAuthCookies } = require('../utils/tokenUtils');
// Read globals from app_settings (the real table) — settingsService.getSetting // Read globals from app_settings (the real table) — settingsService.getSetting
// queries a non-existent `settings` table and throws. // queries a non-existent `settings` table and throws.
@@ -726,7 +744,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
// installs that haven't opted in keep loading the original // installs that haven't opted in keep loading the original
// (current behaviour). Skipped for videos since they don't // (current behaviour). Skipped for videos since they don't
// get a preview tier; lightbox will use the original .url. // get a preview tier; lightbox will use the original .url.
preview_url: lightboxPreviewEnabled preview_url: (lightboxPreviewEnabled || originalNeedsPreview(photo))
&& photo.media_type !== 'video' && photo.media_type !== 'video'
&& (!photo.mime_type || !photo.mime_type.startsWith('video/')) && (!photo.mime_type || !photo.mime_type.startsWith('video/'))
? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}` ? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}`
+5
View File
@@ -29,6 +29,11 @@ const EXTENSION_TO_MIME = {
'webm': 'video/webm', 'webm': 'video/webm',
'mov': 'video/quicktime', 'mov': 'video/quicktime',
'avi': 'video/x-msvideo', 'avi': 'video/x-msvideo',
// HEIC/HEIF (iPhone). Sharp's bundled libvips decodes `heif` input, so
// thumbnails generate fine. (iOS Safari usually transcodes to JPEG at file
// selection, but a genuine .heic upload is handled when it does arrive.)
'heic': 'image/heic',
'heif': 'image/heif',
// Camera RAW / Apple ProRAW. Not sharp-decodable directly — the processing // Camera RAW / Apple ProRAW. Not sharp-decodable directly — the processing
// pipeline extracts the embedded JPEG preview (exiftool) for thumbnails/ // pipeline extracts the embedded JPEG preview (exiftool) for thumbnails/
// display, keeping the original for download. Browsers send DNG as // display, keeping the original for download. Browsers send DNG as
+16
View File
@@ -80,6 +80,22 @@ const ALLOWED_IMAGE_TYPES = {
// SVG files are XML-based text files, so we skip magic number validation // SVG files are XML-based text files, so we skip magic number validation
magicNumbers: null magicNumbers: null
}, },
// HEIC/HEIF (iPhone). ISO-BMFF container: bytes 4-7 are the "ftyp" box marker,
// present in every HEIF/HEIC file (single entry — the magic check is `.every`,
// so alternatives can't be listed as separate entries). Sharp's libvips
// decodes these; extension + MIME are already gated by validateFileType.
'image/heic': {
extensions: ['.heic'],
magicNumbers: [
{ offset: 4, bytes: [0x66, 0x74, 0x79, 0x70] } // "ftyp"
]
},
'image/heif': {
extensions: ['.heif'],
magicNumbers: [
{ offset: 4, bytes: [0x66, 0x74, 0x79, 0x70] } // "ftyp"
]
},
// Camera RAW / Apple ProRAW (#821). DNG is a TIFF container, so it carries the // Camera RAW / Apple ProRAW (#821). DNG is a TIFF container, so it carries the
// TIFF magic (little-endian "II*\0" or big-endian "MM\0*"). The pipeline can't // TIFF magic (little-endian "II*\0" or big-endian "MM\0*"). The pipeline can't
// sharp-decode it directly — it extracts the embedded JPEG preview (exiftool) // sharp-decode it directly — it extracts the embedded JPEG preview (exiftool)
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "picpeak-frontend", "name": "picpeak-frontend",
"private": true, "private": true,
"version": "3.90.1-beta.0", "version": "3.90.2-beta.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+11 -2
View File
@@ -8,7 +8,7 @@ import { useQuery } from '@tanstack/react-query';
import { categoriesService } from '../../services/categories.service'; import { categoriesService } from '../../services/categories.service';
import { settingsService } from '../../services/settings.service'; import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes'; import { extensionsToMimeTypes, extensionsToAcceptString, extensionsToLabel } from '../../utils/fileTypes';
import { useUploadProgress } from '../../hooks/useUploadProgress'; import { useUploadProgress } from '../../hooks/useUploadProgress';
interface PhotoUploadProps { interface PhotoUploadProps {
@@ -118,6 +118,15 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
[settings?.general_allowed_file_types] [settings?.general_allowed_file_types]
); );
const formatsLabel = useMemo(
() => extensionsToLabel(settings?.general_allowed_file_types),
[settings?.general_allowed_file_types]
);
const maxFileSizeMb = Number.isFinite(Number(settings?.general_max_file_size_mb))
? Number(settings?.general_max_file_size_mb)
: 50;
const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0); const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
@@ -511,7 +520,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
{t('upload.clickToUpload')} {t('upload.clickToUpload')}
</p> </p>
<p className="text-sm text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-500 dark:text-neutral-400">
{t('upload.fileRequirements', { limit: maxFilesPerUpload })} {t('upload.fileRequirements', { formats: formatsLabel, limit: maxFilesPerUpload, sizeLimit: maxFileSizeMb })}
</p> </p>
<p <p
className={clsx( className={clsx(
@@ -5,7 +5,7 @@ import { toast } from 'react-toastify';
import { Button } from '../common'; import { Button } from '../common';
import { api } from '../../config/api'; import { api } from '../../config/api';
import { usePublicSettings } from '../../hooks/usePublicSettings'; import { usePublicSettings } from '../../hooks/usePublicSettings';
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes'; import { extensionsToMimeTypes, extensionsToAcceptString, extensionsToLabel } from '../../utils/fileTypes';
interface UserPhotoUploadProps { interface UserPhotoUploadProps {
eventId: number; eventId: number;
@@ -61,6 +61,13 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
[publicSettings?.allowed_file_types] [publicSettings?.allowed_file_types]
); );
// #821 — the requirements hint used to hardcode "JPEG, PNG or WebP"; render
// the actually-configured formats so it never contradicts what's accepted.
const formatsLabel = useMemo(
() => extensionsToLabel(publicSettings?.allowed_file_types),
[publicSettings?.allowed_file_types]
);
// Shared filter pipeline for both <input> change and drag-and-drop (#504). // Shared filter pipeline for both <input> change and drag-and-drop (#504).
const addFiles = (incoming: File[]) => { const addFiles = (incoming: File[]) => {
const validFiles = incoming.filter((file) => { const validFiles = incoming.filter((file) => {
@@ -236,7 +243,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
{/* #613 — pass { limit } so `{{limit}}` interpolates {/* #613 — pass { limit } so `{{limit}}` interpolates
with the real number from settings instead of with the real number from settings instead of
rendering literally. */} rendering literally. */}
{t('upload.fileRequirements', { limit: maxFilesPerUpload, sizeLimit: maxFileSizeMb })} {t('upload.fileRequirements', { formats: formatsLabel, limit: maxFilesPerUpload, sizeLimit: maxFileSizeMb })}
</p> </p>
<input <input
type="file" type="file"
+1 -1
View File
@@ -159,7 +159,7 @@
"noCategory": "Keine Kategorie", "noCategory": "Keine Kategorie",
"eventSpecific": "(Veranstaltungsspezifisch)", "eventSpecific": "(Veranstaltungsspezifisch)",
"clickToUpload": "Klicken zum Hochladen oder per Drag & Drop", "clickToUpload": "Klicken zum Hochladen oder per Drag & Drop",
"fileRequirements": "JPEG, PNG oder WebP (max. {{sizeLimit}}MB pro Datei, {{limit}} Dateien pro Upload)", "fileRequirements": "{{formats}} (max. {{sizeLimit}}MB pro Datei, {{limit}} Dateien pro Upload)",
"selectedFiles": "Ausgewählte Dateien", "selectedFiles": "Ausgewählte Dateien",
"uploading": "Wird hochgeladen...", "uploading": "Wird hochgeladen...",
"transferring": "Übertragung", "transferring": "Übertragung",
+1 -1
View File
@@ -159,7 +159,7 @@
"noCategory": "No category", "noCategory": "No category",
"eventSpecific": "(Event specific)", "eventSpecific": "(Event specific)",
"clickToUpload": "Click to upload or drag and drop", "clickToUpload": "Click to upload or drag and drop",
"fileRequirements": "JPEG, PNG or WebP (max {{sizeLimit}}MB per file, {{limit}} files per upload)", "fileRequirements": "{{formats}} (max {{sizeLimit}}MB per file, {{limit}} files per upload)",
"selectedFiles": "Selected files", "selectedFiles": "Selected files",
"uploading": "Uploading...", "uploading": "Uploading...",
"transferring": "Transferring", "transferring": "Transferring",
+1 -1
View File
@@ -126,7 +126,7 @@
"noCategory": "Sin categoría", "noCategory": "Sin categoría",
"eventSpecific": "(Específico del evento)", "eventSpecific": "(Específico del evento)",
"clickToUpload": "Haz clic para subir o arrastra y suelta", "clickToUpload": "Haz clic para subir o arrastra y suelta",
"fileRequirements": "JPEG, PNG o WebP (máx. {{sizeLimit}}MB por archivo, {{limit}} archivos por subida)", "fileRequirements": "{{formats}} (máx. {{sizeLimit}}MB por archivo, {{limit}} archivos por subida)",
"fileRequirementsMedia": "Imágenes JPEG, PNG o WebP y videos MP4/MOV/WEBM (máx. 50MB por archivo, {{limit}} archivos por subida)", "fileRequirementsMedia": "Imágenes JPEG, PNG o WebP y videos MP4/MOV/WEBM (máx. 50MB por archivo, {{limit}} archivos por subida)",
"unsupportedFiles": "Algunos archivos se omitieron porque el formato no es compatible (usa JPEG/PNG/WebP/MP4/MOV/WEBM).", "unsupportedFiles": "Algunos archivos se omitieron porque el formato no es compatible (usa JPEG/PNG/WebP/MP4/MOV/WEBM).",
"selectedFiles": "Archivos seleccionados", "selectedFiles": "Archivos seleccionados",
+1 -1
View File
@@ -131,7 +131,7 @@
"noCategory": "Aucune catégorie", "noCategory": "Aucune catégorie",
"eventSpecific": "(Spécifique à l'événement)", "eventSpecific": "(Spécifique à l'événement)",
"clickToUpload": "Cliquez pour téléverser ou glissez-déposez", "clickToUpload": "Cliquez pour téléverser ou glissez-déposez",
"fileRequirements": "JPEG, PNG ou WebP (max {{sizeLimit}} Mo par fichier, {{limit}} fichiers par téléversement)", "fileRequirements": "{{formats}} (max {{sizeLimit}} Mo par fichier, {{limit}} fichiers par téléversement)",
"selectedFiles": "Fichiers sélectionnés", "selectedFiles": "Fichiers sélectionnés",
"uploading": "Téléversement en cours...", "uploading": "Téléversement en cours...",
"transferring": "Transfert en cours", "transferring": "Transfert en cours",
+1 -1
View File
@@ -131,7 +131,7 @@
"noCategory": "Geen categorie", "noCategory": "Geen categorie",
"eventSpecific": "(Evenement-specifiek)", "eventSpecific": "(Evenement-specifiek)",
"clickToUpload": "Klik om te uploaden of sleep bestanden hierheen", "clickToUpload": "Klik om te uploaden of sleep bestanden hierheen",
"fileRequirements": "JPEG, PNG of WebP (max. {{sizeLimit}} MB per bestand, {{limit}} bestanden per upload)", "fileRequirements": "{{formats}} (max. {{sizeLimit}} MB per bestand, {{limit}} bestanden per upload)",
"selectedFiles": "Geselecteerde bestanden", "selectedFiles": "Geselecteerde bestanden",
"uploading": "Uploaden...", "uploading": "Uploaden...",
"uploadComplete": "Upload voltooid!", "uploadComplete": "Upload voltooid!",
+1 -1
View File
@@ -131,7 +131,7 @@
"noCategory": "Sem categoria", "noCategory": "Sem categoria",
"eventSpecific": "(Específico do evento)", "eventSpecific": "(Específico do evento)",
"clickToUpload": "Clique para enviar ou arraste e solte", "clickToUpload": "Clique para enviar ou arraste e solte",
"fileRequirements": "JPEG, PNG ou WebP (máx. {{sizeLimit}}MB por arquivo, {{limit}} arquivos por envio)", "fileRequirements": "{{formats}} (máx. {{sizeLimit}}MB por arquivo, {{limit}} arquivos por envio)",
"selectedFiles": "Arquivos selecionados", "selectedFiles": "Arquivos selecionados",
"uploading": "Enviando...", "uploading": "Enviando...",
"uploadComplete": "Envio concluído!", "uploadComplete": "Envio concluído!",
+1 -1
View File
@@ -131,7 +131,7 @@
"noCategory": "Без категории", "noCategory": "Без категории",
"eventSpecific": "(Для конкретного события)", "eventSpecific": "(Для конкретного события)",
"clickToUpload": "Нажмите для загрузки или перетащите файлы", "clickToUpload": "Нажмите для загрузки или перетащите файлы",
"fileRequirements": "JPEG, PNG или WebP (макс. {{sizeLimit}} МБ на файл, {{limit}} файлов за загрузку)", "fileRequirements": "{{formats}} (макс. {{sizeLimit}} МБ на файл, {{limit}} файлов за загрузку)",
"selectedFiles": "Выбранные файлы", "selectedFiles": "Выбранные файлы",
"uploading": "Загрузка...", "uploading": "Загрузка...",
"uploadComplete": "Загрузка завершена!", "uploadComplete": "Загрузка завершена!",
+1 -1
View File
@@ -131,7 +131,7 @@
"noCategory": "Brez kategorije", "noCategory": "Brez kategorije",
"eventSpecific": "(specifično za dogodek)", "eventSpecific": "(specifično za dogodek)",
"clickToUpload": "Kliknite za nalaganje ali povlecite in spustite", "clickToUpload": "Kliknite za nalaganje ali povlecite in spustite",
"fileRequirements": "JPEG, PNG ali WebP (največ {{sizeLimit}} MB na datoteko, {{limit}} datotek na nalaganje)", "fileRequirements": "{{formats}} (največ {{sizeLimit}} MB na datoteko, {{limit}} datotek na nalaganje)",
"selectedFiles": "Izbrane datoteke", "selectedFiles": "Izbrane datoteke",
"uploading": "Nalaganje...", "uploading": "Nalaganje...",
"transferring": "Prenašanje", "transferring": "Prenašanje",
@@ -82,6 +82,9 @@ export interface PublicSettings {
// modal can render the real number in `upload.fileRequirements` and refuse // modal can render the real number in `upload.fileRequirements` and refuse
// oversized batches client-side. Backend enforces the same value too. // oversized batches client-side. Backend enforces the same value too.
general_max_files_per_upload?: number; general_max_files_per_upload?: number;
// #613 follow-up — per-file size limit (MB), surfaced so the guest upload
// modal shows the real limit and guards client-side. Backend enforces it too.
general_max_file_size_mb?: number;
// Event field requirements // Event field requirements
event_require_customer_name?: boolean; event_require_customer_name?: boolean;
event_require_customer_email?: boolean; event_require_customer_email?: boolean;
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { extensionsToMimeTypes, extensionsToAcceptString, extensionsToLabel } from '../fileTypes';
describe('fileTypes', () => {
describe('extensionsToMimeTypes', () => {
it('maps known extensions to MIME types', () => {
expect(extensionsToMimeTypes('jpg,png,mov')).toEqual(['image/jpeg', 'image/png', 'video/quicktime']);
});
it('supports HEIC/HEIF (#821)', () => {
expect(extensionsToMimeTypes('heic,heif')).toEqual(['image/heic', 'image/heif']);
});
it('supports DNG (#821)', () => {
expect(extensionsToMimeTypes('dng')).toEqual(['image/x-adobe-dng']);
});
it('drops unknown extensions and falls back to default when nothing maps', () => {
expect(extensionsToMimeTypes('abc,xyz')).toEqual(['image/jpeg', 'image/png', 'image/webp']);
});
});
describe('extensionsToLabel', () => {
it('renders a de-duplicated, upper-cased list of the configured formats', () => {
expect(extensionsToLabel('jpg,jpeg,png,webp,mov')).toBe('JPG, JPEG, PNG, WEBP, MOV');
});
it('only lists supported extensions (drops unknowns like xyz)', () => {
expect(extensionsToLabel('jpg,png,xyz')).toBe('JPG, PNG');
});
it('falls back to the default set when empty', () => {
expect(extensionsToLabel('')).toBe('JPG, JPEG, PNG, WEBP');
expect(extensionsToLabel(null)).toBe('JPG, JPEG, PNG, WEBP');
});
});
describe('extensionsToAcceptString', () => {
it('joins MIME types for the input accept attribute', () => {
expect(extensionsToAcceptString('jpg,heic')).toBe('image/jpeg,image/heic');
});
});
});
+24
View File
@@ -12,6 +12,9 @@ const EXTENSION_TO_MIME: Record<string, string> = {
webm: 'video/webm', webm: 'video/webm',
mov: 'video/quicktime', mov: 'video/quicktime',
avi: 'video/x-msvideo', avi: 'video/x-msvideo',
// HEIC/HEIF (iPhone) — kept in sync with the backend EXTENSION_TO_MIME.
heic: 'image/heic',
heif: 'image/heif',
// Camera RAW / Apple ProRAW — backend extracts the embedded JPEG preview. // Camera RAW / Apple ProRAW — backend extracts the embedded JPEG preview.
dng: 'image/x-adobe-dng', dng: 'image/x-adobe-dng',
}; };
@@ -48,3 +51,24 @@ export function extensionsToMimeTypes(extString?: string | null): string[] {
export function extensionsToAcceptString(extString?: string | null): string { export function extensionsToAcceptString(extString?: string | null): string {
return extensionsToMimeTypes(extString).join(','); return extensionsToMimeTypes(extString).join(',');
} }
/**
* Human-readable, de-duplicated list of the configured extensions for the
* upload requirements hint, e.g. "JPG, PNG, WEBP, MOV". Only extensions the
* app actually supports (present in EXTENSION_TO_MIME) are shown, so the hint
* never advertises a format the backend would reject.
*/
export function extensionsToLabel(extString?: string | null): string {
const input = extString?.trim() || DEFAULT_ALLOWED;
const seen = new Set<string>();
const labels: string[] = [];
input.split(',').forEach(ext => {
const cleaned = ext.trim().toLowerCase().replace(/^\./, '');
if (cleaned && EXTENSION_TO_MIME[cleaned] && !seen.has(cleaned)) {
seen.add(cleaned);
labels.push(cleaned.toUpperCase());
}
});
if (labels.length === 0) return extensionsToLabel(DEFAULT_ALLOWED);
return labels.join(', ');
}