Merge pull request #832 from PicPeak/feat/guest-upload-heic-dynamic-hint
feat(uploads): HEIC/HEIF support + dynamic format hint on guest upload (#821)
This commit is contained in:
@@ -38,6 +38,24 @@ const {
|
||||
} = require('../services/downloadFilenameService');
|
||||
const { buildContentDisposition } = require('../utils/filenameSanitizer');
|
||||
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');
|
||||
// Read globals from app_settings (the real table) — settingsService.getSetting
|
||||
// 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
|
||||
// (current behaviour). Skipped for videos since they don't
|
||||
// get a preview tier; lightbox will use the original .url.
|
||||
preview_url: lightboxPreviewEnabled
|
||||
preview_url: (lightboxPreviewEnabled || originalNeedsPreview(photo))
|
||||
&& photo.media_type !== 'video'
|
||||
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
|
||||
? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}`
|
||||
|
||||
@@ -29,6 +29,11 @@ const EXTENSION_TO_MIME = {
|
||||
'webm': 'video/webm',
|
||||
'mov': 'video/quicktime',
|
||||
'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',
|
||||
};
|
||||
|
||||
const DEFAULT_ALLOWED_FILE_TYPES = 'jpg,jpeg,png,webp';
|
||||
|
||||
@@ -79,6 +79,22 @@ const ALLOWED_IMAGE_TYPES = {
|
||||
extensions: ['.svg'],
|
||||
// SVG files are XML-based text files, so we skip magic number validation
|
||||
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"
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { categoriesService } from '../../services/categories.service';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
|
||||
import { extensionsToMimeTypes, extensionsToAcceptString, extensionsToLabel } from '../../utils/fileTypes';
|
||||
import { useUploadProgress } from '../../hooks/useUploadProgress';
|
||||
|
||||
interface PhotoUploadProps {
|
||||
@@ -118,6 +118,15 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
[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 [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
@@ -511,7 +520,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
{t('upload.clickToUpload')}
|
||||
</p>
|
||||
<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
|
||||
className={clsx(
|
||||
|
||||
@@ -5,7 +5,7 @@ import { toast } from 'react-toastify';
|
||||
import { Button } from '../common';
|
||||
import { api } from '../../config/api';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
|
||||
import { extensionsToMimeTypes, extensionsToAcceptString, extensionsToLabel } from '../../utils/fileTypes';
|
||||
|
||||
interface UserPhotoUploadProps {
|
||||
eventId: number;
|
||||
@@ -61,6 +61,13 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
[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).
|
||||
const addFiles = (incoming: File[]) => {
|
||||
const validFiles = incoming.filter((file) => {
|
||||
@@ -236,7 +243,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
{/* #613 — pass { limit } so `{{limit}}` interpolates
|
||||
with the real number from settings instead of
|
||||
rendering literally. */}
|
||||
{t('upload.fileRequirements', { limit: maxFilesPerUpload, sizeLimit: maxFileSizeMb })}
|
||||
{t('upload.fileRequirements', { formats: formatsLabel, limit: maxFilesPerUpload, sizeLimit: maxFileSizeMb })}
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
"noCategory": "Keine Kategorie",
|
||||
"eventSpecific": "(Veranstaltungsspezifisch)",
|
||||
"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",
|
||||
"uploading": "Wird hochgeladen...",
|
||||
"transferring": "Übertragung",
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
"noCategory": "No category",
|
||||
"eventSpecific": "(Event specific)",
|
||||
"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",
|
||||
"uploading": "Uploading...",
|
||||
"transferring": "Transferring",
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
"noCategory": "Sin categoría",
|
||||
"eventSpecific": "(Específico del evento)",
|
||||
"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)",
|
||||
"unsupportedFiles": "Algunos archivos se omitieron porque el formato no es compatible (usa JPEG/PNG/WebP/MP4/MOV/WEBM).",
|
||||
"selectedFiles": "Archivos seleccionados",
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
"noCategory": "Aucune catégorie",
|
||||
"eventSpecific": "(Spécifique à l'événement)",
|
||||
"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",
|
||||
"uploading": "Téléversement en cours...",
|
||||
"transferring": "Transfert en cours",
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
"noCategory": "Geen categorie",
|
||||
"eventSpecific": "(Evenement-specifiek)",
|
||||
"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",
|
||||
"uploading": "Uploaden...",
|
||||
"uploadComplete": "Upload voltooid!",
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
"noCategory": "Sem categoria",
|
||||
"eventSpecific": "(Específico do evento)",
|
||||
"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",
|
||||
"uploading": "Enviando...",
|
||||
"uploadComplete": "Envio concluído!",
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
"noCategory": "Без категории",
|
||||
"eventSpecific": "(Для конкретного события)",
|
||||
"clickToUpload": "Нажмите для загрузки или перетащите файлы",
|
||||
"fileRequirements": "JPEG, PNG или WebP (макс. {{sizeLimit}} МБ на файл, {{limit}} файлов за загрузку)",
|
||||
"fileRequirements": "{{formats}} (макс. {{sizeLimit}} МБ на файл, {{limit}} файлов за загрузку)",
|
||||
"selectedFiles": "Выбранные файлы",
|
||||
"uploading": "Загрузка...",
|
||||
"uploadComplete": "Загрузка завершена!",
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
"noCategory": "Brez kategorije",
|
||||
"eventSpecific": "(specifično za dogodek)",
|
||||
"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",
|
||||
"uploading": "Nalaganje...",
|
||||
"transferring": "Prenašanje",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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('drops unknown extensions and falls back to default when nothing maps', () => {
|
||||
expect(extensionsToMimeTypes('dng,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 dng)', () => {
|
||||
expect(extensionsToLabel('jpg,png,dng')).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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,9 @@ const EXTENSION_TO_MIME: Record<string, string> = {
|
||||
webm: 'video/webm',
|
||||
mov: 'video/quicktime',
|
||||
avi: 'video/x-msvideo',
|
||||
// HEIC/HEIF (iPhone) — kept in sync with the backend EXTENSION_TO_MIME.
|
||||
heic: 'image/heic',
|
||||
heif: 'image/heif',
|
||||
};
|
||||
|
||||
const DEFAULT_ALLOWED = 'jpg,jpeg,png,webp';
|
||||
@@ -46,3 +49,24 @@ export function extensionsToMimeTypes(extString?: string | null): string[] {
|
||||
export function extensionsToAcceptString(extString?: string | null): string {
|
||||
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(', ');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user