Merge remote-tracking branch 'origin/main' into pr-834
# Conflicts: # frontend/src/components/gallery/UserPhotoUpload.tsx # frontend/src/i18n/locales/de.json # frontend/src/i18n/locales/en.json # frontend/src/i18n/locales/es.json # frontend/src/i18n/locales/fr.json # frontend/src/i18n/locales/nl.json # frontend/src/i18n/locales/pt.json # frontend/src/i18n/locales/ru.json # frontend/src/i18n/locales/sl.json # frontend/src/services/publicSettings.service.ts # frontend/src/utils/__tests__/fileTypes.test.ts
This commit is contained in:
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "3.90.1-beta.0"
|
".": "3.91.0-beta.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,33 @@ 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.91.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.90.2-beta.0...v3.91.0-beta.0) (2026-07-18)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **uploads:** HEIC/HEIF support + dynamic format hint on guest upload ([#821](https://github.com/PicPeak/picpeak/issues/821)) ([ee9d2f7](https://github.com/PicPeak/picpeak/commit/ee9d2f70d3342d65edb795a688f0f5f611429964))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **gallery:** serve JPEG preview for non-displayable originals in lightbox (codex review of [#832](https://github.com/PicPeak/picpeak/issues/832)) ([808d305](https://github.com/PicPeak/picpeak/commit/808d3055497bb4e4a372acafa49ef9baf257f008))
|
||||||
|
* **uploads:** register HEIC/HEIF with the file validator + fix admin format hint (codex review of [#832](https://github.com/PicPeak/picpeak/issues/832)) ([c9b64d9](https://github.com/PicPeak/picpeak/commit/c9b64d9c1a8744c9ee5e068366a500ae0dab36bc))
|
||||||
|
|
||||||
|
## [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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -67,8 +67,11 @@ RUN npm install -g npm@11
|
|||||||
# malicious) PDF. pdftoppm does not execute embedded JS or fetch remote
|
# malicious) PDF. pdftoppm does not execute embedded JS or fetch remote
|
||||||
# resources, so it doubles as the SSRF/phone-home guard for untrusted inbound
|
# resources, so it doubles as the SSRF/phone-home guard for untrusted inbound
|
||||||
# documents (see docs/accounting-inbound-invoices.md).
|
# documents (see docs/accounting-inbound-invoices.md).
|
||||||
|
# exiftool extracts the embedded full-res JPEG preview from RAW/DNG uploads
|
||||||
|
# (Apple ProRAW etc.) — sharp's libvips has no raw loader, so the pipeline
|
||||||
|
# thumbnails/displays that preview while keeping the original for download.
|
||||||
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \
|
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec \
|
||||||
fontconfig ttf-dejavu ttf-liberation poppler-utils && \
|
fontconfig ttf-dejavu ttf-liberation poppler-utils exiftool && \
|
||||||
fc-cache -f
|
fc-cache -f
|
||||||
|
|
||||||
# Create non-root user
|
# Create non-root user
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ RUN apk upgrade --no-cache
|
|||||||
# Install dumb-init for proper signal handling and ffmpeg for video uploads.
|
# Install dumb-init for proper signal handling and ffmpeg for video uploads.
|
||||||
# Alpine's ffmpeg ships both ffmpeg + ffprobe built natively against musl;
|
# Alpine's ffmpeg ships both ffmpeg + ffprobe built natively against musl;
|
||||||
# the npm-bundled binary doesn't run reliably on Alpine. Match production.
|
# the npm-bundled binary doesn't run reliably on Alpine. Match production.
|
||||||
RUN apk add --no-cache dumb-init ffmpeg
|
# exiftool: extract embedded JPEG previews from RAW/DNG uploads (#821) — kept in
|
||||||
|
# sync with the production Dockerfile so dev/native runtimes don't accept a DNG
|
||||||
|
# and then fail it with ENOENT.
|
||||||
|
RUN apk add --no-cache dumb-init ffmpeg exiftool
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for the RAW/DNG handling helpers (#821). The actual exiftool
|
||||||
|
* extraction can only be exercised in the built image (exiftool isn't a dev
|
||||||
|
* dependency), so these cover the gating logic: which files are treated as RAW,
|
||||||
|
* and that ordinary images pass through untouched (zero cost / no extraction).
|
||||||
|
*/
|
||||||
|
const path = require('path');
|
||||||
|
const { isRawFilename, withProcessableImage, RAW_EXTENSIONS } = require('../../src/services/imageProcessor');
|
||||||
|
|
||||||
|
describe('isRawFilename', () => {
|
||||||
|
it('recognises common RAW / DNG extensions', () => {
|
||||||
|
for (const ext of ['dng', 'cr2', 'cr3', 'nef', 'arw', 'raf', 'rw2', 'orf']) {
|
||||||
|
expect(isRawFilename(`IMG_1234.${ext}`)).toBe(true);
|
||||||
|
expect(isRawFilename(`IMG_1234.${ext.toUpperCase()}`)).toBe(true); // case-insensitive
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat ordinary images/videos as RAW', () => {
|
||||||
|
for (const name of ['photo.jpg', 'photo.jpeg', 'photo.png', 'photo.webp', 'clip.mp4', 'clip.mov', 'photo.heic']) {
|
||||||
|
expect(isRawFilename(name)).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null/empty safe', () => {
|
||||||
|
expect(isRawFilename(null)).toBe(false);
|
||||||
|
expect(isRawFilename('')).toBe(false);
|
||||||
|
expect(isRawFilename('noextension')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('RAW_EXTENSIONS includes dng (Apple ProRAW)', () => {
|
||||||
|
expect(RAW_EXTENSIONS.has('dng')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('withProcessableImage', () => {
|
||||||
|
it('passes ordinary images through with no extraction and a no-op cleanup', async () => {
|
||||||
|
const localPath = '/tmp/whatever/photo.jpg';
|
||||||
|
const proc = await withProcessableImage(localPath, 'photo.jpg');
|
||||||
|
expect(proc.path).toBe(localPath); // unchanged — sharp reads it directly
|
||||||
|
expect(proc.outputBasename).toBeUndefined(); // generators keep their default naming
|
||||||
|
await expect(Promise.resolve(proc.cleanup())).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes RAW files to extraction (which fails cleanly without exiftool/preview)', async () => {
|
||||||
|
// In the dev sandbox exiftool isn't installed, so extraction throws — the
|
||||||
|
// caller turns that into a normal processing failure. In the built image
|
||||||
|
// (exiftool present) this instead returns the embedded JPEG preview.
|
||||||
|
await expect(withProcessableImage('/tmp/whatever/IMG_1234.dng', 'IMG_1234.dng')).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -75,6 +75,13 @@ jest.mock('../../src/services/imageProcessor', () => {
|
|||||||
withLocalCopy: jest.fn(async (key, fn) =>
|
withLocalCopy: jest.fn(async (key, fn) =>
|
||||||
fn(`/tmp/local-copy-${require('path').basename(key)}`)
|
fn(`/tmp/local-copy-${require('path').basename(key)}`)
|
||||||
),
|
),
|
||||||
|
// Pass-through for ordinary (non-RAW) images: returns the path unchanged
|
||||||
|
// with a no-op cleanup, matching the real helper's behaviour for jpg/png.
|
||||||
|
withProcessableImage: jest.fn(async (localPath) => ({
|
||||||
|
path: localPath,
|
||||||
|
outputBasename: undefined,
|
||||||
|
cleanup: () => {},
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "3.90.1-beta.0",
|
"version": "3.91.0-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": {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
|||||||
@@ -7,11 +7,80 @@ const crypto = require('crypto');
|
|||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { getStorage } = require('./storage');
|
const { getStorage } = require('./storage');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
// Configure sharp for better memory management with large batches
|
// Configure sharp for better memory management with large batches
|
||||||
sharp.cache(false); // Disable cache to prevent memory buildup
|
sharp.cache(false); // Disable cache to prevent memory buildup
|
||||||
sharp.concurrency(2); // Limit concurrent operations
|
sharp.concurrency(2); // Limit concurrent operations
|
||||||
|
|
||||||
|
// Camera RAW / DNG formats. Sharp's bundled libvips has no raw loader, so these
|
||||||
|
// can't be fed to sharp() directly — instead we extract the full-resolution JPEG
|
||||||
|
// preview that every RAW file embeds (via exiftool) and process THAT. Gated
|
||||||
|
// strictly by extension, so nothing here runs for ordinary jpg/png/webp photos.
|
||||||
|
const RAW_EXTENSIONS = new Set([
|
||||||
|
'dng', 'cr2', 'cr3', 'nef', 'nrw', 'arw', 'sr2', 'srf',
|
||||||
|
'raf', 'rw2', 'orf', 'pef', 'srw', 'raw', '3fr', 'dcr', 'kdc'
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isRawFilename(name) {
|
||||||
|
if (!name || typeof name !== 'string') return false;
|
||||||
|
const ext = path.extname(name).toLowerCase().replace(/^\./, '');
|
||||||
|
return RAW_EXTENSIONS.has(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the embedded full-resolution JPEG preview from a RAW/DNG file to a
|
||||||
|
* temp .jpg and return its path. Tries the largest previews first
|
||||||
|
* (JpgFromRaw → PreviewImage → ThumbnailImage). Throws if none can be extracted
|
||||||
|
* or the result isn't a valid image — the caller treats that as a processing
|
||||||
|
* failure (photo → 'failed'), same as any unreadable upload.
|
||||||
|
*/
|
||||||
|
async function extractRawPreview(rawPath) {
|
||||||
|
const outDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-raw-'));
|
||||||
|
const outPath = path.join(outDir, `${crypto.randomBytes(4).toString('hex')}.jpg`);
|
||||||
|
const tags = ['-JpgFromRaw', '-PreviewImage', '-ThumbnailImage'];
|
||||||
|
let lastErr;
|
||||||
|
for (const tag of tags) {
|
||||||
|
try {
|
||||||
|
// `-b` writes the raw tag bytes to stdout; -w isn't reliable across tags,
|
||||||
|
// so capture stdout as a buffer and write it ourselves.
|
||||||
|
const { stdout } = await execFileAsync('exiftool', ['-b', tag, rawPath], {
|
||||||
|
encoding: 'buffer',
|
||||||
|
maxBuffer: 256 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
if (stdout && stdout.length > 0) {
|
||||||
|
await fsp.writeFile(outPath, stdout);
|
||||||
|
// Validate it's a real, decodable image before handing it to the pipeline.
|
||||||
|
const meta = await sharp(outPath).metadata();
|
||||||
|
if (meta.width && meta.height) {
|
||||||
|
return { path: outPath, cleanup: () => fsp.rm(outDir, { recursive: true, force: true }).catch(() => {}) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
lastErr = err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await fsp.rm(outDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
throw new Error(`No usable embedded preview in RAW file ${path.basename(rawPath)}: ${lastErr ? lastErr.message : 'no preview tag returned data'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Give a Sharp-processable local image path for `localPath`. For ordinary
|
||||||
|
* images it's a pass-through (no cost). For RAW/DNG (by `sourceName` extension)
|
||||||
|
* it extracts the embedded JPEG preview and returns that, plus the basename to
|
||||||
|
* use for generated outputs so thumbnails/previews stay named after the source
|
||||||
|
* rather than the random temp file. Always call `cleanup()` when done.
|
||||||
|
*/
|
||||||
|
async function withProcessableImage(localPath, sourceName) {
|
||||||
|
if (!isRawFilename(sourceName)) {
|
||||||
|
return { path: localPath, outputBasename: undefined, cleanup: () => {} };
|
||||||
|
}
|
||||||
|
const { path: previewPath, cleanup } = await extractRawPreview(localPath);
|
||||||
|
return { path: previewPath, outputBasename: path.basename(sourceName), cleanup };
|
||||||
|
}
|
||||||
|
|
||||||
// Default thumbnail settings
|
// Default thumbnail settings
|
||||||
const DEFAULT_THUMBNAIL_WIDTH = 300;
|
const DEFAULT_THUMBNAIL_WIDTH = 300;
|
||||||
const DEFAULT_THUMBNAIL_HEIGHT = 300;
|
const DEFAULT_THUMBNAIL_HEIGHT = 300;
|
||||||
@@ -297,9 +366,14 @@ async function ensureThumbnail(photo) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
logger.info(`Ensuring thumbnail for photo ${photo.id} from key: ${sourceKey}`);
|
logger.info(`Ensuring thumbnail for photo ${photo.id} from key: ${sourceKey}`);
|
||||||
newThumbnailPath = await withLocalCopy(sourceKey, (localPath) =>
|
newThumbnailPath = await withLocalCopy(sourceKey, async (localPath) => {
|
||||||
generateThumbnail(localPath, { regenerate: true })
|
const proc = await withProcessableImage(localPath, sourceKey);
|
||||||
);
|
try {
|
||||||
|
return await generateThumbnail(proc.path, { regenerate: true, outputBasename: proc.outputBasename });
|
||||||
|
} finally {
|
||||||
|
await proc.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newThumbnailPath) {
|
if (newThumbnailPath) {
|
||||||
@@ -366,7 +440,7 @@ async function generateVideoPlaceholder(originalFilename, options = {}) {
|
|||||||
* Outputs a 1920x1080 image suitable for full-width hero sections
|
* Outputs a 1920x1080 image suitable for full-width hero sections
|
||||||
*/
|
*/
|
||||||
async function generateHeroImage(imagePath, options = {}) {
|
async function generateHeroImage(imagePath, options = {}) {
|
||||||
const filename = path.basename(imagePath);
|
const filename = options.outputBasename || path.basename(imagePath);
|
||||||
const heroFilename = `hero_${filename}`;
|
const heroFilename = `hero_${filename}`;
|
||||||
const heroRelKey = path.posix.join('heroes', heroFilename);
|
const heroRelKey = path.posix.join('heroes', heroFilename);
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
@@ -469,9 +543,14 @@ async function ensureHeroImage(photo) {
|
|||||||
logger.warn(`Invalid hero image detected for photo ${photo.id}, regenerating...`);
|
logger.warn(`Invalid hero image detected for photo ${photo.id}, regenerating...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newHeroPath = await withLocalCopy(sourceKey, (localPath) =>
|
const newHeroPath = await withLocalCopy(sourceKey, async (localPath) => {
|
||||||
generateHeroImage(localPath, { regenerate: true })
|
const proc = await withProcessableImage(localPath, sourceKey);
|
||||||
);
|
try {
|
||||||
|
return await generateHeroImage(proc.path, { regenerate: true, outputBasename: proc.outputBasename });
|
||||||
|
} finally {
|
||||||
|
await proc.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (newHeroPath) {
|
if (newHeroPath) {
|
||||||
await db('photos')
|
await db('photos')
|
||||||
@@ -498,7 +577,7 @@ async function ensureHeroImage(photo) {
|
|||||||
* thumbnails or heroes.
|
* thumbnails or heroes.
|
||||||
*/
|
*/
|
||||||
async function generatePreviewImage(imagePath, options = {}) {
|
async function generatePreviewImage(imagePath, options = {}) {
|
||||||
const filename = path.basename(imagePath);
|
const filename = options.outputBasename || path.basename(imagePath);
|
||||||
const previewFilename = `preview_${filename}`;
|
const previewFilename = `preview_${filename}`;
|
||||||
const previewRelKey = path.posix.join('previews', previewFilename);
|
const previewRelKey = path.posix.join('previews', previewFilename);
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
@@ -599,9 +678,14 @@ async function ensurePreviewImage(photo) {
|
|||||||
logger.warn(`Invalid preview detected for photo ${photo.id}, regenerating…`);
|
logger.warn(`Invalid preview detected for photo ${photo.id}, regenerating…`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newPreviewPath = await withLocalCopy(sourceKey, (localPath) =>
|
const newPreviewPath = await withLocalCopy(sourceKey, async (localPath) => {
|
||||||
generatePreviewImage(localPath, { regenerate: true })
|
const proc = await withProcessableImage(localPath, sourceKey);
|
||||||
);
|
try {
|
||||||
|
return await generatePreviewImage(proc.path, { regenerate: true, outputBasename: proc.outputBasename });
|
||||||
|
} finally {
|
||||||
|
await proc.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (newPreviewPath) {
|
if (newPreviewPath) {
|
||||||
await db('photos').where({ id: photo.id }).update({ preview_path: newPreviewPath });
|
await db('photos').where({ id: photo.id }).update({ preview_path: newPreviewPath });
|
||||||
@@ -665,4 +749,8 @@ module.exports = {
|
|||||||
ensurePreviewImage,
|
ensurePreviewImage,
|
||||||
extractCaptureDate,
|
extractCaptureDate,
|
||||||
withLocalCopy,
|
withLocalCopy,
|
||||||
|
isRawFilename,
|
||||||
|
extractRawPreview,
|
||||||
|
withProcessableImage,
|
||||||
|
RAW_EXTENSIONS,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { generateThumbnail, extractCaptureDate, withLocalCopy } = require('./imageProcessor');
|
const { generateThumbnail, extractCaptureDate, withLocalCopy, withProcessableImage } = require('./imageProcessor');
|
||||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||||
const { processUploadedVideo, isVideoMimeType } = require('./videoProcessor');
|
const { processUploadedVideo, isVideoMimeType } = require('./videoProcessor');
|
||||||
const { getStorage } = require('./storage');
|
const { getStorage } = require('./storage');
|
||||||
@@ -145,10 +145,17 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
|||||||
videoMetadata = result.metadata;
|
videoMetadata = result.metadata;
|
||||||
thumbnailPath = result.thumbnailKey;
|
thumbnailPath = result.thumbnailKey;
|
||||||
} else {
|
} else {
|
||||||
thumbnailPath = await generateThumbnail(tempPath);
|
// RAW/DNG can't be fed to sharp directly (no raw loader), so extract the
|
||||||
|
// embedded JPEG preview first and thumbnail/measure THAT. Pass-through
|
||||||
|
// for ordinary images. The stored original stays the RAW (download).
|
||||||
|
// Use the unique stored filename (not the client-supplied original) so
|
||||||
|
// the RAW-derived thumbnail's global key can't collide across galleries.
|
||||||
|
const proc = await withProcessableImage(tempPath, newFilename);
|
||||||
|
try {
|
||||||
|
thumbnailPath = await generateThumbnail(proc.path, { outputBasename: proc.outputBasename });
|
||||||
try {
|
try {
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
const metadata = await sharp(tempPath).metadata();
|
const metadata = await sharp(proc.path).metadata();
|
||||||
if (metadata.width && metadata.height) {
|
if (metadata.width && metadata.height) {
|
||||||
imageMetadata = {
|
imageMetadata = {
|
||||||
width: metadata.width,
|
width: metadata.width,
|
||||||
@@ -158,6 +165,9 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
|||||||
} catch (metadataError) {
|
} catch (metadataError) {
|
||||||
logger.warn(`Could not extract image dimensions for ${file.originalname}:`, metadataError.message);
|
logger.warn(`Could not extract image dimensions for ${file.originalname}:`, metadataError.message);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
await proc.cleanup();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now upload the original through the storage backend and remove the
|
// Now upload the original through the storage backend and remove the
|
||||||
@@ -457,15 +467,22 @@ async function processPhoto(photoId) {
|
|||||||
if (result.metadata.height) updateData.height = result.metadata.height;
|
if (result.metadata.height) updateData.height = result.metadata.height;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// RAW/DNG can't be sharp-decoded directly — extract the embedded JPEG
|
||||||
|
// preview and thumbnail/measure that. Pass-through for ordinary images.
|
||||||
|
// This is the ASYNC worker path (backgroundProcessor → processPhoto), the
|
||||||
|
// one real uploads actually take; the synchronous processUploadedPhotos()
|
||||||
|
// has the same handling.
|
||||||
|
const proc = await withProcessableImage(localPath, photo.filename);
|
||||||
try {
|
try {
|
||||||
const thumbnailPath = await generateThumbnail(localPath);
|
try {
|
||||||
|
const thumbnailPath = await generateThumbnail(proc.path, { outputBasename: proc.outputBasename });
|
||||||
if (thumbnailPath) updateData.thumbnail_path = thumbnailPath;
|
if (thumbnailPath) updateData.thumbnail_path = thumbnailPath;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn(`processPhoto: thumbnail generation failed for ${photoId}`, { error: e.message });
|
logger.warn(`processPhoto: thumbnail generation failed for ${photoId}`, { error: e.message });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
const metadata = await sharp(localPath).metadata();
|
const metadata = await sharp(proc.path).metadata();
|
||||||
if (metadata.width && metadata.height) {
|
if (metadata.width && metadata.height) {
|
||||||
updateData.width = metadata.width;
|
updateData.width = metadata.width;
|
||||||
updateData.height = metadata.height;
|
updateData.height = metadata.height;
|
||||||
@@ -473,6 +490,9 @@ async function processPhoto(photoId) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn(`processPhoto: dimensions extraction failed for ${photoId}`, { error: e.message });
|
logger.warn(`processPhoto: dimensions extraction failed for ${photoId}`, { error: e.message });
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
await proc.cleanup();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const path = require('path');
|
|||||||
const fsp = require('fs/promises');
|
const fsp = require('fs/promises');
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { generateThumbnail, extractCaptureDate } = require('./imageProcessor');
|
const { generateThumbnail, extractCaptureDate, withProcessableImage } = require('./imageProcessor');
|
||||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||||
const watermarkGeneratorService = require('./watermarkGeneratorService');
|
const watermarkGeneratorService = require('./watermarkGeneratorService');
|
||||||
const { getStorage } = require('./storage');
|
const { getStorage } = require('./storage');
|
||||||
@@ -61,25 +61,33 @@ async function replacePhoto(existingPhoto, newFileTempPath, { originalFilename,
|
|||||||
// No EXIF — keep null
|
// No EXIF — keep null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stats = await fsp.stat(newFileTempPath);
|
||||||
|
|
||||||
|
// RAW/DNG isn't sharp-decodable — extract the embedded JPEG preview first
|
||||||
|
// (pass-through for ordinary images), then measure + thumbnail that. Mirrors
|
||||||
|
// the ingest paths (processPhoto / processUploadedPhotos).
|
||||||
let width = null;
|
let width = null;
|
||||||
let height = null;
|
let height = null;
|
||||||
|
let thumbnailPath = null;
|
||||||
|
// Detect/name by the unique stored filename (newFilename), not the
|
||||||
|
// client-supplied original, so RAW derivative keys can't collide.
|
||||||
|
const proc = await withProcessableImage(newFileTempPath, newFilename);
|
||||||
try {
|
try {
|
||||||
const metadata = await sharp(newFileTempPath).metadata();
|
try {
|
||||||
|
const metadata = await sharp(proc.path).metadata();
|
||||||
width = metadata.width || null;
|
width = metadata.width || null;
|
||||||
height = metadata.height || null;
|
height = metadata.height || null;
|
||||||
} catch {
|
} catch {
|
||||||
// Non-image or corrupt
|
// Non-image or corrupt
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = await fsp.stat(newFileTempPath);
|
|
||||||
|
|
||||||
// Generate new thumbnail FROM the local temp before uploading the original.
|
|
||||||
let thumbnailPath = null;
|
|
||||||
try {
|
try {
|
||||||
thumbnailPath = await generateThumbnail(newFileTempPath);
|
thumbnailPath = await generateThumbnail(proc.path, { outputBasename: proc.outputBasename });
|
||||||
} catch {
|
} catch {
|
||||||
logger.warn('Failed to generate thumbnail for replaced photo', { photoId: existingPhoto.id });
|
logger.warn('Failed to generate thumbnail for replaced photo', { photoId: existingPhoto.id });
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
await proc.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
// Delete old assets BEFORE uploading the new key — if they share the path
|
// Delete old assets BEFORE uploading the new key — if they share the path
|
||||||
// (rare but possible if filename collision), we want the new content.
|
// (rare but possible if filename collision), we want the new content.
|
||||||
|
|||||||
@@ -24,14 +24,21 @@ const EXTENSION_TO_MIME = {
|
|||||||
'png': 'image/png',
|
'png': 'image/png',
|
||||||
'webp': 'image/webp',
|
'webp': 'image/webp',
|
||||||
'gif': 'image/gif',
|
'gif': 'image/gif',
|
||||||
'dng': 'image/x-adobe-dng',
|
|
||||||
'heic': 'image/heic',
|
|
||||||
'heif': 'image/heif',
|
|
||||||
'mp4': 'video/mp4',
|
'mp4': 'video/mp4',
|
||||||
'm4v': 'video/mp4',
|
'm4v': 'video/mp4',
|
||||||
'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
|
||||||
|
// pipeline extracts the embedded JPEG preview (exiftool) for thumbnails/
|
||||||
|
// display, keeping the original for download. Browsers send DNG as
|
||||||
|
// image/x-adobe-dng, image/tiff, or an empty type, so accept the common set.
|
||||||
|
'dng': 'image/x-adobe-dng',
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_ALLOWED_FILE_TYPES = 'jpg,jpeg,png,webp';
|
const DEFAULT_ALLOWED_FILE_TYPES = 'jpg,jpeg,png,webp';
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const watermarkService = require('./watermarkService');
|
const watermarkService = require('./watermarkService');
|
||||||
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
|
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
|
||||||
const { withLocalCopy } = require('./imageProcessor');
|
const { withLocalCopy, isRawFilename } = require('./imageProcessor');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
class WatermarkGeneratorService {
|
class WatermarkGeneratorService {
|
||||||
@@ -52,6 +52,14 @@ class WatermarkGeneratorService {
|
|||||||
return { success: false, error: 'Videos do not support watermarks' };
|
return { success: false, error: 'Videos do not support watermarks' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip RAW/DNG (experimental, #821). The watermark path opens the original
|
||||||
|
// with sharp, which can't decode RAW — proceeding would fall back to the
|
||||||
|
// original bytes and falsely record the copy as watermarked. Skipping keeps
|
||||||
|
// the watermark state honest until RAW watermarking is properly supported.
|
||||||
|
if (isRawFilename(photo.filename)) {
|
||||||
|
return { success: false, error: 'RAW/DNG files are not watermarked yet' };
|
||||||
|
}
|
||||||
|
|
||||||
// Get watermark settings
|
// Get watermark settings
|
||||||
const settings = await watermarkService.getWatermarkSettings();
|
const settings = await watermarkService.getWatermarkSettings();
|
||||||
if (!settings || !settings.enabled) {
|
if (!settings || !settings.enabled) {
|
||||||
|
|||||||
@@ -75,24 +75,43 @@ const ALLOWED_IMAGE_TYPES = {
|
|||||||
{ offset: 0, bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61] } // GIF89a
|
{ offset: 0, bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61] } // GIF89a
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
// DNG and HEIF-family files use TIFF/ISO Base Media File Format containers,
|
|
||||||
// so their extension and declared MIME type are validated together here.
|
|
||||||
'image/x-adobe-dng': {
|
|
||||||
extensions: ['.dng'],
|
|
||||||
magicNumbers: null
|
|
||||||
},
|
|
||||||
'image/heic': {
|
|
||||||
extensions: ['.heic'],
|
|
||||||
magicNumbers: null
|
|
||||||
},
|
|
||||||
'image/heif': {
|
|
||||||
extensions: ['.heif'],
|
|
||||||
magicNumbers: null
|
|
||||||
},
|
|
||||||
'image/svg+xml': {
|
'image/svg+xml': {
|
||||||
extensions: ['.svg'],
|
extensions: ['.svg'],
|
||||||
// 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
|
||||||
|
// 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)
|
||||||
|
// for thumbnails/display while storing the original for download. Only reached
|
||||||
|
// when an admin adds `dng` to the allowed types AND the browser reports the
|
||||||
|
// DNG MIME (Chrome does; browsers that send an empty type won't get this far).
|
||||||
|
'image/x-adobe-dng': {
|
||||||
|
extensions: ['.dng'],
|
||||||
|
// Single entry: the magic check is `.every`, so listing both endianness
|
||||||
|
// variants would require BOTH to match (impossible). DNG is TIFF; Apple
|
||||||
|
// ProRAW and virtually all camera DNGs are little-endian ("II*\0"). A rare
|
||||||
|
// big-endian DNG would fail this check and be rejected — acceptable, since
|
||||||
|
// the embedded-preview extraction validates the real content downstream.
|
||||||
|
magicNumbers: [
|
||||||
|
{ offset: 0, bytes: [0x49, 0x49, 0x2A, 0x00] } // little-endian TIFF (II*\0)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "3.90.1-beta.0",
|
"version": "3.91.0-beta.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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, getSupportedExtensions } from '../../utils/fileTypes';
|
import { extensionsToMimeTypes, extensionsToAcceptString, extensionsToLabel } from '../../utils/fileTypes';
|
||||||
|
|
||||||
interface UserPhotoUploadProps {
|
interface UserPhotoUploadProps {
|
||||||
eventId: number;
|
eventId: number;
|
||||||
@@ -61,8 +61,10 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
|||||||
[publicSettings?.allowed_file_types]
|
[publicSettings?.allowed_file_types]
|
||||||
);
|
);
|
||||||
|
|
||||||
const allowedExtensions = useMemo(
|
// #821 — the requirements hint used to hardcode "JPEG, PNG or WebP"; render
|
||||||
() => getSupportedExtensions(publicSettings?.allowed_file_types),
|
// the actually-configured formats so it never contradicts what's accepted.
|
||||||
|
const formatsLabel = useMemo(
|
||||||
|
() => extensionsToLabel(publicSettings?.allowed_file_types),
|
||||||
[publicSettings?.allowed_file_types]
|
[publicSettings?.allowed_file_types]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -238,11 +240,10 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
|||||||
{t('upload.clickToUpload')}
|
{t('upload.clickToUpload')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-theme">
|
<p className="text-xs text-muted-theme">
|
||||||
{t('upload.fileRequirements', {
|
{/* #613 — pass { limit } so `{{limit}}` interpolates
|
||||||
types: allowedExtensions.map(extension => extension.toUpperCase()).join(', '),
|
with the real number from settings instead of
|
||||||
limit: maxFilesPerUpload,
|
rendering literally. */}
|
||||||
sizeLimit: maxFileSizeMb,
|
{t('upload.fileRequirements', { formats: formatsLabel, limit: maxFilesPerUpload, sizeLimit: maxFileSizeMb })}
|
||||||
})}
|
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="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": "{{types}} (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",
|
||||||
|
|||||||
@@ -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": "{{types}} (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",
|
||||||
|
|||||||
@@ -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": "{{types}} (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",
|
||||||
|
|||||||
@@ -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": "{{types}} (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",
|
||||||
|
|||||||
@@ -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": "{{types}} (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!",
|
||||||
|
|||||||
@@ -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": "{{types}} (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!",
|
||||||
|
|||||||
@@ -131,7 +131,7 @@
|
|||||||
"noCategory": "Без категории",
|
"noCategory": "Без категории",
|
||||||
"eventSpecific": "(Для конкретного события)",
|
"eventSpecific": "(Для конкретного события)",
|
||||||
"clickToUpload": "Нажмите для загрузки или перетащите файлы",
|
"clickToUpload": "Нажмите для загрузки или перетащите файлы",
|
||||||
"fileRequirements": "{{types}} (макс. {{sizeLimit}} МБ на файл, {{limit}} файлов за загрузку)",
|
"fileRequirements": "{{formats}} (макс. {{sizeLimit}} МБ на файл, {{limit}} файлов за загрузку)",
|
||||||
"selectedFiles": "Выбранные файлы",
|
"selectedFiles": "Выбранные файлы",
|
||||||
"uploading": "Загрузка...",
|
"uploading": "Загрузка...",
|
||||||
"uploadComplete": "Загрузка завершена!",
|
"uploadComplete": "Загрузка завершена!",
|
||||||
|
|||||||
@@ -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": "{{types}} (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,7 +82,8 @@ 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;
|
||||||
// Per-file size limit surfaced for the guest upload guard and requirements hint.
|
// #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;
|
general_max_file_size_mb?: number;
|
||||||
// Event field requirements
|
// Event field requirements
|
||||||
event_require_customer_name?: boolean;
|
event_require_customer_name?: boolean;
|
||||||
|
|||||||
@@ -1,43 +1,38 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import { extensionsToMimeTypes, extensionsToAcceptString, extensionsToLabel } from '../fileTypes';
|
||||||
extensionsToAcceptString,
|
|
||||||
extensionsToMimeTypes,
|
|
||||||
getSupportedExtensions,
|
|
||||||
} from '../fileTypes';
|
|
||||||
|
|
||||||
describe('file type settings', () => {
|
describe('fileTypes', () => {
|
||||||
it('keeps configured supported extensions for the upload hint', () => {
|
describe('extensionsToMimeTypes', () => {
|
||||||
expect(getSupportedExtensions('jpg, dng, heic, heif, mov, mp4, .webp')).toEqual([
|
it('maps known extensions to MIME types', () => {
|
||||||
'jpg',
|
expect(extensionsToMimeTypes('jpg,png,mov')).toEqual(['image/jpeg', 'image/png', 'video/quicktime']);
|
||||||
'dng',
|
});
|
||||||
'heic',
|
it('supports HEIC/HEIF (#821)', () => {
|
||||||
'heif',
|
expect(extensionsToMimeTypes('heic,heif')).toEqual(['image/heic', 'image/heif']);
|
||||||
'mov',
|
});
|
||||||
'mp4',
|
it('supports DNG (#821)', () => {
|
||||||
'webp',
|
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']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the same configured types for validation and file selection', () => {
|
describe('extensionsToLabel', () => {
|
||||||
expect(extensionsToMimeTypes('jpg,jpeg,dng,heic,heif,mov,mp4')).toEqual([
|
it('renders a de-duplicated, upper-cased list of the configured formats', () => {
|
||||||
'image/jpeg',
|
expect(extensionsToLabel('jpg,jpeg,png,webp,mov')).toBe('JPG, JPEG, PNG, WEBP, MOV');
|
||||||
'image/x-adobe-dng',
|
});
|
||||||
'image/heic',
|
it('only lists supported extensions (drops unknowns like xyz)', () => {
|
||||||
'image/heif',
|
expect(extensionsToLabel('jpg,png,xyz')).toBe('JPG, PNG');
|
||||||
'video/quicktime',
|
});
|
||||||
'video/mp4',
|
it('falls back to the default set when empty', () => {
|
||||||
]);
|
expect(extensionsToLabel('')).toBe('JPG, JPEG, PNG, WEBP');
|
||||||
expect(extensionsToAcceptString('dng,heic,heif')).toBe(
|
expect(extensionsToLabel(null)).toBe('JPG, JPEG, PNG, WEBP');
|
||||||
'image/x-adobe-dng,image/heic,image/heif'
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the default formats when no configured types are supported', () => {
|
describe('extensionsToAcceptString', () => {
|
||||||
expect(getSupportedExtensions('unknown')).toEqual([
|
it('joins MIME types for the input accept attribute', () => {
|
||||||
'jpg',
|
expect(extensionsToAcceptString('jpg,heic')).toBe('image/jpeg,image/heic');
|
||||||
'jpeg',
|
});
|
||||||
'png',
|
|
||||||
'webp',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,42 +7,41 @@ const EXTENSION_TO_MIME: Record<string, string> = {
|
|||||||
png: 'image/png',
|
png: 'image/png',
|
||||||
webp: 'image/webp',
|
webp: 'image/webp',
|
||||||
gif: 'image/gif',
|
gif: 'image/gif',
|
||||||
dng: 'image/x-adobe-dng',
|
|
||||||
heic: 'image/heic',
|
|
||||||
heif: 'image/heif',
|
|
||||||
mp4: 'video/mp4',
|
mp4: 'video/mp4',
|
||||||
m4v: 'video/mp4',
|
m4v: 'video/mp4',
|
||||||
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.
|
||||||
|
dng: 'image/x-adobe-dng',
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_ALLOWED = 'jpg,jpeg,png,webp';
|
const DEFAULT_ALLOWED = 'jpg,jpeg,png,webp';
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the configured supported extensions, excluding values the upload
|
|
||||||
* pipeline cannot validate. Falls back to the default list when none match.
|
|
||||||
*/
|
|
||||||
export function getSupportedExtensions(extString?: string | null): string[] {
|
|
||||||
const input = extString?.trim() || DEFAULT_ALLOWED;
|
|
||||||
const extensions = input
|
|
||||||
.split(',')
|
|
||||||
.map(ext => ext.trim().toLowerCase().replace(/^\./, ''))
|
|
||||||
.filter(ext => Boolean(EXTENSION_TO_MIME[ext]));
|
|
||||||
|
|
||||||
return extensions.length > 0
|
|
||||||
? Array.from(new Set(extensions))
|
|
||||||
: getSupportedExtensions(DEFAULT_ALLOWED);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a comma-separated extension string (e.g. "jpg,png,mp4") to an
|
* Convert a comma-separated extension string (e.g. "jpg,png,mp4") to an
|
||||||
* array of unique MIME types.
|
* array of unique MIME types.
|
||||||
*/
|
*/
|
||||||
export function extensionsToMimeTypes(extString?: string | null): string[] {
|
export function extensionsToMimeTypes(extString?: string | null): string[] {
|
||||||
return Array.from(new Set(
|
const input = extString?.trim() || DEFAULT_ALLOWED;
|
||||||
getSupportedExtensions(extString).map(extension => EXTENSION_TO_MIME[extension])
|
const mimeSet = new Set<string>();
|
||||||
));
|
|
||||||
|
input.split(',').forEach(ext => {
|
||||||
|
const cleaned = ext.trim().toLowerCase().replace(/^\./, '');
|
||||||
|
const mime = EXTENSION_TO_MIME[cleaned];
|
||||||
|
if (mime) {
|
||||||
|
mimeSet.add(mime);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mimeSet.size === 0) {
|
||||||
|
return extensionsToMimeTypes(DEFAULT_ALLOWED);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(mimeSet);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,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(', ');
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user