From e4e79a0b3a6d3ddbbc2f3cebdcadc89307147248 Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Thu, 18 Jun 2026 22:18:54 +0200
Subject: [PATCH 1/7] fix(archives): stream-extract restore for >2 GiB +
preserve original_filename via manifest (#640)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two related backup-integrity fixes from 8digit's fork (issue #640 items
#3 + #4), bundled because they touch the same two files and ship better
together than apart.
### Stream-extract restore for >2 GiB archives
`adminArchives.js:170` was using `adm-zip`, which loads the entire ZIP
into a Node Buffer before extracting. Node has a hard 2 GiB Buffer cap,
so any restore over that limit fails with `ERR_FS_FILE_TOO_LARGE` — and
since the frontend `onError` toast is the generic "Something went wrong",
the cause stays invisible. Real-world wedding archives routinely cross
2 GiB; affected restores have likely been silent failures.
Swapped `adm-zip` for `node-stream-zip` which streams each entry to disk
as it's processed — no full-file Buffer, no 2 GiB ceiling. API shape:
```js
const zip = new StreamZip.async({ file: archivePath });
const entries = Object.values(await zip.entries());
await zip.extract(null, eventDir);
await zip.close();
```
Re-import logic (photos, categories, sizes) unchanged; only field rename
`entry.entryName` → `entry.name`. Credit: 8digit/picpeak@69033c6.
### Preserve `original_filename` via photos manifest
Archive → restore round-trip currently loses `original_filename` (the
post-#508 column tracking the camera-side name) because the gallery
filenames are renamed on upload and can't be derived from the extracted
files. This matters now that the Lightroom export (#623) depends on
`original_filename` — a restored event lost that signal.
- **`archiveService.js`**: writes `photos_manifest.json` into the archive
containing per-photo `{filename, original_filename, type, uploaded_at,
category_name}`. Non-fatal: a manifest write failure falls through to
legacy behaviour (filename used as original_filename, same as before).
- **`adminArchives.js`**: reads the manifest on restore, builds a
`Map`, and assigns
`original_filename = manifest?.original_filename || filename`.
Archives produced before this lands have no manifest — restore logs a
one-shot notice and falls back to filename, preserving backward compat.
Credit: 8digit/picpeak@eb018aa.
### Deps
- Removed `adm-zip ^0.5.16`
- Added `node-stream-zip ^1.15.0`
### What's NOT in this PR
8digit's commit also fixed the production compose healthcheck (`curl`
isn't in our Alpine image); that's already been addressed upstream in
the meantime. The frontend `onError` swallow on the restore toast is a
separate small follow-up.
### Test plan
- [x] `node -c` on both files clean
- [x] `node-stream-zip` async API verified at load time
- [ ] Manual: archive a multi-GB event → restore → confirm photos
re-import with original_filename preserved
- [ ] Manual: restore an archive produced before this lands → confirm
fallback to filename works (no manifest path crashes)
- [ ] Manual: confirm the new photos_manifest.json is inside the
generated archive (`unzip -l .zip | grep manifest`)
---
backend/package-lock.json | 37 +++++++++-----
backend/package.json | 2 +-
backend/src/routes/adminArchives.js | 68 +++++++++++++++++++-------
backend/src/services/archiveService.js | 33 +++++++++++++
4 files changed, 110 insertions(+), 30 deletions(-)
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 04c4e230..ab876ed3 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -1,17 +1,16 @@
{
"name": "picpeak-backend",
- "version": "3.60.6-beta.0",
+ "version": "3.65.1-beta.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
- "version": "3.60.6-beta.0",
+ "version": "3.65.1-beta.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
"@aws-sdk/s3-request-presigner": "^3.850.0",
- "adm-zip": "^0.5.16",
"archiver": "^5.3.1",
"axios": "1.15.2",
"bcrypt": "6.0.0",
@@ -39,6 +38,7 @@
"mime-types": "^3.0.1",
"multer": "^2.0.2",
"node-cron": "^3.0.2",
+ "node-stream-zip": "^1.15.0",
"nodemailer": "^8.0.5",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
@@ -314,6 +314,7 @@
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1000.0.tgz",
"integrity": "sha512-7kPy33qNGq3NfwHC0412T6LDK1bp4+eiPzetX0sVd9cpTSXuQDKpoOFnB0Njj6uZjJDcLS3n2OeyarwwgkQ0Ow==",
"license": "Apache-2.0",
+ "peer": true,
"dependencies": {
"@aws-crypto/sha1-browser": "5.2.0",
"@aws-crypto/sha256-browser": "5.2.0",
@@ -1042,6 +1043,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -3804,6 +3806,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3821,15 +3824,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
- "node_modules/adm-zip": {
- "version": "0.5.16",
- "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz",
- "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==",
- "license": "MIT",
- "engines": {
- "node": ">=12.0"
- }
- },
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -4373,6 +4367,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5518,6 +5513,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -5765,6 +5761,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
@@ -6791,6 +6788,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -9058,6 +9056,19 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/node-stream-zip": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz",
+ "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/antelle"
+ }
+ },
"node_modules/nodemailer": {
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz",
@@ -9555,6 +9566,7 @@
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"crypto-js": "^4.2.0",
"fontkit": "^2.0.4",
@@ -10621,6 +10633,7 @@
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz",
"integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"parseley": "~0.13.1"
},
diff --git a/backend/package.json b/backend/package.json
index 5ac0ea74..9bac64d0 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -17,7 +17,6 @@
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
"@aws-sdk/s3-request-presigner": "^3.850.0",
- "adm-zip": "^0.5.16",
"archiver": "^5.3.1",
"axios": "1.15.2",
"bcrypt": "6.0.0",
@@ -45,6 +44,7 @@
"mime-types": "^3.0.1",
"multer": "^2.0.2",
"node-cron": "^3.0.2",
+ "node-stream-zip": "^1.15.0",
"nodemailer": "^8.0.5",
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
diff --git a/backend/src/routes/adminArchives.js b/backend/src/routes/adminArchives.js
index d290e26e..b9735550 100644
--- a/backend/src/routes/adminArchives.js
+++ b/backend/src/routes/adminArchives.js
@@ -7,7 +7,7 @@ const { slugify } = require('../utils/slug');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const archiver = require('archiver');
-const AdmZip = require('adm-zip');
+const StreamZip = require('node-stream-zip');
const { requireEventOwnership } = require('../middleware/ownership');
const router = express.Router();
@@ -167,32 +167,62 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
// Extract the archive
try {
- const zip = new AdmZip(fullArchivePath);
+ // node-stream-zip streams each entry to disk on extract — adm-zip used
+ // to load the whole archive into a Node Buffer up front, which capped
+ // restore at 2 GiB (ERR_FS_FILE_TOO_LARGE). Real-world wedding archives
+ // routinely cross that line. Credit: 8digit/picpeak@69033c6.
+ const zip = new StreamZip.async({ file: fullArchivePath });
const eventsDir = path.join(storagePath, 'events/active');
const eventDir = path.join(eventsDir, archive.slug);
-
+
// Create event directory if it doesn't exist
await fs.mkdir(eventDir, { recursive: true });
-
+
// Log ZIP contents for debugging
console.log(`Extracting archive to: ${eventDir}`);
- const entries = zip.getEntries();
+ const entries = Object.values(await zip.entries());
console.log(`Archive contains ${entries.length} entries`);
-
- // Extract files to the event directory
- zip.extractAllTo(eventDir, true);
-
+
+ // Stream-extract everything to disk
+ await zip.extract(null, eventDir);
+ await zip.close();
+
+ // Load photos manifest if present. The gallery filenames are renamed on
+ // upload, so `original_filename` (and category linkage) can't be derived
+ // from the extracted files alone — they're only recoverable from the
+ // manifest the archive process writes. Older archives have no manifest;
+ // we fall back to filename for those.
+ const manifestByFilename = new Map();
+ try {
+ const manifestRaw = await fs.readFile(
+ path.join(eventDir, 'photos_manifest.json'), 'utf8',
+ );
+ const parsed = JSON.parse(manifestRaw);
+ if (Array.isArray(parsed)) {
+ for (const m of parsed) {
+ if (m && m.filename) manifestByFilename.set(m.filename, m);
+ }
+ }
+ console.log(`Loaded photos manifest: ${manifestByFilename.size} entries`);
+ } catch (e) {
+ if (e.code !== 'ENOENT') {
+ console.warn('Photos manifest present but unreadable; falling back to filenames', e.message);
+ } else {
+ console.log('No photos manifest in archive (older archive); original_filename falls back to filename');
+ }
+ }
+
// Get list of extracted files to update database
const extractedPhotos = [];
-
+
// First, collect all category information from the ZIP structure
const categoriesMap = new Map();
-
+
for (const entry of entries) {
- if (!entry.isDirectory && entry.entryName.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
- const filename = path.basename(entry.entryName);
- const dirPath = path.dirname(entry.entryName);
- const actualFilePath = path.join(eventDir, entry.entryName);
+ if (!entry.isDirectory && entry.name.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
+ const filename = path.basename(entry.name);
+ const dirPath = path.dirname(entry.name);
+ const actualFilePath = path.join(eventDir, entry.name);
try {
// Check if file was extracted successfully
@@ -239,10 +269,14 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
if (!existingPhoto) {
// Store relative path from storage root
const relativePath = path.relative(storagePath, actualFilePath);
+ const manifestEntry = manifestByFilename.get(filename);
extractedPhotos.push({
event_id: archive.id,
filename: filename,
- original_filename: filename,
+ // Recover original_filename from the manifest if present;
+ // legacy archives without a manifest lose nothing (filename
+ // is what they had before).
+ original_filename: manifestEntry?.original_filename || filename,
path: relativePath,
thumbnail_path: null, // Will be regenerated by thumbnail service
type: path.extname(filename).substring(1).toLowerCase(),
@@ -253,7 +287,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
}
} catch (statError) {
console.error(`Failed to stat file: ${actualFilePath}`);
- console.error(`Entry name was: ${entry.entryName}`);
+ console.error(`Entry name was: ${entry.name}`);
console.error('Error:', statError.message);
// Skip this file if we can't stat it
continue;
diff --git a/backend/src/services/archiveService.js b/backend/src/services/archiveService.js
index 9c3ef978..1d411f3e 100644
--- a/backend/src/services/archiveService.js
+++ b/backend/src/services/archiveService.js
@@ -26,6 +26,36 @@ async function archiveEvent(event) {
const tmpArchive = path.join(tmpDir, `${crypto.randomBytes(4).toString('hex')}-${archiveName}`);
try {
+ // Photos manifest — the gallery filenames are renamed on upload, so
+ // `original_filename` (and category linkage) can't be derived from the
+ // extracted files alone. Persisting a manifest inside the archive lets a
+ // future restore round-trip recover those fields. Falls back to bare
+ // filename for archives produced before this lands (see restore path).
+ let photosManifestEntry = null;
+ try {
+ const manifestRows = await db('photos')
+ .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
+ .where('photos.event_id', event.id)
+ .select(
+ 'photos.filename',
+ 'photos.original_filename',
+ 'photos.type',
+ 'photos.uploaded_at',
+ 'photo_categories.name as category_name',
+ );
+ if (manifestRows.length > 0) {
+ photosManifestEntry = {
+ name: 'photos_manifest.json',
+ buffer: Buffer.from(JSON.stringify(manifestRows, null, 2), 'utf8'),
+ };
+ logger.info(`Photos manifest prepared: ${manifestRows.length} entries`);
+ }
+ } catch (error) {
+ logger.error(`Error building photos manifest for event ${event.slug}:`, error);
+ // Non-fatal — restore will fall back to filename as original_filename
+ // for events archived without a manifest, same as the legacy behaviour.
+ }
+
// Collect feedback data first so it can be included as in-memory entries.
const feedbackEntries = [];
const feedbackSettings = await feedbackService.getEventFeedbackSettings(event.id);
@@ -116,6 +146,9 @@ async function archiveEvent(event) {
for (const f of feedbackEntries) {
archive.append(f.buffer, { name: f.name });
}
+ if (photosManifestEntry) {
+ archive.append(photosManifestEntry.buffer, { name: photosManifestEntry.name });
+ }
archive.finalize();
};
From 820f4835f1f5a41cbef6816c387ef9ec3dafd526 Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Thu, 18 Jun 2026 22:29:35 +0200
Subject: [PATCH 2/7] feat(categories): per-category download permissions (#640
part B)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds an `allow_downloads` boolean to `photo_categories` so admins can
have different download policies per category — e.g. preview categories
public, originals client-only. AND's with the event-level `allow_downloads`,
so disabling at either level blocks downloads for that category's photos.
Defaults to true so categories created before migration 135 keep working
without admin intervention.
Credit: 8digit/picpeak@928164b + @751ec75.
### Backend
- **Migration 135**: additive `allow_downloads BOOLEAN NOT NULL DEFAULT true`
on `photo_categories`, hasColumn-guarded + sane down.
- **`adminCategories.js`**: PUT /:id accepts optional `allow_downloads` patch.
- **`gallery.js`**:
- `GET /:slug/photos` returns `allow_downloads` per category AND
`category_allow_downloads` per photo.
- `GET /:slug/download/:photoId` returns 403 when the photo's category
disables downloads.
- `GET /:slug/download-all` LEFT JOINs `photo_categories` and filters
`whereNull(category_id) OR allow_downloads=true OR allow_downloads IS NULL`.
The null check covers pre-migration-135 rows during the upgrade window.
- `POST /:slug/download-selected` same filter pattern.
### Frontend
- **`categories.service.ts`**: `updateCategory()` gains an optional `patch`
argument carrying `{ allow_downloads }`. PhotoCategory interface gains the
optional field.
- **`EventCategoryManager.tsx`**: new toggle button next to the delete X.
Green DownloadCloud icon when downloads are on, plain Download icon when
off. Click toggles via the new mutation; toast confirms.
- **`PhotoLightbox.tsx`**: `photoAllowsDownload = allowDownloads && currentPhoto?.category_allow_downloads !== false`. Hides the download button +
blocks the 'D' keyboard shortcut + early-returns from handleDownload.
- **Types**: Photo interface gains `category_allow_downloads`.
- **i18n**: 5 new EN + DE entries for the toggle button toast + tooltip.
No global-category surface change yet — global categories don't currently
have a UI for the toggle. Admins can still flip the column directly via SQL
or via a future global-categories editor.
### Test plan
- [x] Backend syntax + TS check clean
- [x] ESLint: no new warnings
- [ ] Manual: admin → event detail → categories panel → click DownloadCloud
icon → category flips, toast confirms
- [ ] Manual: gallery (guest) → photo in disabled category → lightbox shows
no download button, 'D' shortcut is a no-op
- [ ] Manual: download-all on a gallery with one disabled category →
ZIP excludes that category's photos
- [ ] Manual: download-selected including a disabled-category photo → 404
(filtered out) and the response carries only the allowed selection
- [ ] Manual: pre-migration-135 category (legacy row with NULL allow_downloads)
→ downloads still work (defaults true via fallback)
---
.../core/135_add_category_allow_downloads.js | 33 ++++++++
backend/src/routes/adminCategories.js | 8 +-
backend/src/routes/gallery.js | 44 ++++++++++-
.../components/admin/EventCategoryManager.tsx | 76 +++++++++++++++----
.../src/components/gallery/PhotoLightbox.tsx | 11 ++-
frontend/src/i18n/locales/de.json | 7 +-
frontend/src/i18n/locales/en.json | 7 +-
frontend/src/services/categories.service.ts | 16 +++-
frontend/src/types/index.ts | 5 ++
9 files changed, 181 insertions(+), 26 deletions(-)
create mode 100644 backend/migrations/core/135_add_category_allow_downloads.js
diff --git a/backend/migrations/core/135_add_category_allow_downloads.js b/backend/migrations/core/135_add_category_allow_downloads.js
new file mode 100644
index 00000000..b7f9aaff
--- /dev/null
+++ b/backend/migrations/core/135_add_category_allow_downloads.js
@@ -0,0 +1,33 @@
+/**
+ * Migration 135: per-category download permissions (#640).
+ *
+ * Adds an `allow_downloads` boolean to `photo_categories` so admins can have
+ * different download policies per category (e.g. preview categories public,
+ * originals client-only). The flag is an AND with the event-level
+ * `allow_downloads`: a category download is allowed only when BOTH the
+ * event AND the category say yes. Defaults to true so existing categories
+ * keep working without admin intervention.
+ *
+ * Additive + hasColumn-guarded.
+ */
+async function addColumn(knex, table, column, builder) {
+ if (!(await knex.schema.hasColumn(table, column))) {
+ await knex.schema.alterTable(table, builder);
+ }
+}
+
+exports.up = async function (knex) {
+ if (!(await knex.schema.hasTable('photo_categories'))) return;
+ await addColumn(knex, 'photo_categories', 'allow_downloads', (t) =>
+ t.boolean('allow_downloads').notNullable().defaultTo(true)
+ );
+};
+
+exports.down = async function (knex) {
+ if (!(await knex.schema.hasTable('photo_categories'))) return;
+ if (await knex.schema.hasColumn('photo_categories', 'allow_downloads')) {
+ await knex.schema.alterTable('photo_categories', (t) =>
+ t.dropColumn('allow_downloads')
+ );
+ }
+};
diff --git a/backend/src/routes/adminCategories.js b/backend/src/routes/adminCategories.js
index 84a162a9..55a18b25 100644
--- a/backend/src/routes/adminCategories.js
+++ b/backend/src/routes/adminCategories.js
@@ -112,7 +112,8 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
body('hero_photo_id').optional({ nullable: true }).custom((value) => {
if (value === null || value === undefined) return true;
return Number.isInteger(Number(value));
- }).withMessage('hero_photo_id must be an integer or null')
+ }).withMessage('hero_photo_id must be an integer or null'),
+ body('allow_downloads').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -144,6 +145,11 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
updateData.hero_photo_id = hero_photo_id || null;
}
+ // Per-category download permission (#640). AND with event-level allow_downloads.
+ if (Object.prototype.hasOwnProperty.call(req.body, 'allow_downloads')) {
+ updateData.allow_downloads = req.body.allow_downloads;
+ }
+
await db('photo_categories')
.where('id', id)
.update(updateData);
diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js
index 31bd918b..0ea362e0 100644
--- a/backend/src/routes/gallery.js
+++ b/backend/src/routes/gallery.js
@@ -396,7 +396,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
if (usedCategoryIds.length > 0) {
const categoryDetails = await db('photo_categories')
.whereIn('id', usedCategoryIds)
- .select('id', 'name', 'slug', 'is_global', 'hero_photo_id')
+ .select('id', 'name', 'slug', 'is_global', 'hero_photo_id', 'allow_downloads')
.orderBy('name', 'asc');
categories = categoryDetails.map(cat => ({
@@ -404,7 +404,11 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
name: cat.name,
slug: cat.slug,
is_global: cat.is_global,
- hero_photo_id: cat.hero_photo_id || null
+ hero_photo_id: cat.hero_photo_id || null,
+ // Per-category download flag (#640). false explicitly disables; the
+ // gallery hides the download button. Defaults true so categories
+ // created before migration 135 keep working.
+ allow_downloads: cat.allow_downloads !== false
}));
}
@@ -530,6 +534,11 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
type: photo.type,
category_id: photo.category_id || null,
category_name: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].name : null,
+ // Per-category download permission (#640). Defaults true for photos
+ // without a category or for categories that pre-date migration 135.
+ category_allow_downloads: photo.category_id && categoryMap[photo.category_id]
+ ? categoryMap[photo.category_id].allow_downloads !== false
+ : true,
category_slug: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].slug : null,
size: photo.size_bytes,
uploaded_at: photo.uploaded_at,
@@ -650,6 +659,18 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
return res.status(403).json({ error: 'Photo not available' });
}
+ // Per-category download permission (#640). Photos without a category are
+ // always downloadable when the event allows downloads — only categorised
+ // photos can opt out per-category.
+ if (photo.category_id) {
+ const cat = await db('photo_categories')
+ .where('id', photo.category_id)
+ .first('allow_downloads');
+ if (cat && cat.allow_downloads === false) {
+ return res.status(403).json({ error: 'Downloads are disabled for this category' });
+ }
+ }
+
// Update download count
await db('photos').where('id', photoId).increment('download_count', 1);
@@ -797,9 +818,17 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
logger.warn('Background zip generation failed', { eventId: req.event.id, error: err.message })
);
- // Fetch photos
+ // Fetch photos — exclude photos in categories that disabled downloads (#640).
+ // Uncategorised photos are always included; categories without the column
+ // (pre-migration-135) fall through the LEFT JOIN's null and are included.
const photos = await db('photos')
+ .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', req.event.id)
+ .where(function () {
+ this.whereNull('photos.category_id')
+ .orWhere('photo_categories.allow_downloads', true)
+ .orWhereNull('photo_categories.allow_downloads');
+ })
.select('photos.*')
.orderBy('photos.type', 'asc')
.orderBy('photos.uploaded_at', 'desc');
@@ -926,10 +955,17 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
return res.status(400).json({ error: 'No valid photo IDs provided' });
}
- // Fetch photos
+ // Fetch photos — exclude photos in categories that disabled downloads (#640).
+ // Same LEFT JOIN pattern as the download-all endpoint.
const photos = await db('photos')
+ .leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', req.event.id)
.whereIn('photos.id', photoIds)
+ .where(function () {
+ this.whereNull('photos.category_id')
+ .orWhere('photo_categories.allow_downloads', true)
+ .orWhereNull('photo_categories.allow_downloads');
+ })
.select('photos.*')
.orderBy('photos.uploaded_at', 'desc');
diff --git a/frontend/src/components/admin/EventCategoryManager.tsx b/frontend/src/components/admin/EventCategoryManager.tsx
index 791c47be..72845927 100644
--- a/frontend/src/components/admin/EventCategoryManager.tsx
+++ b/frontend/src/components/admin/EventCategoryManager.tsx
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Plus, X, Loader2, Image as ImageIcon, Check } from 'lucide-react';
+import { Plus, X, Loader2, Image as ImageIcon, Check, Download, DownloadCloud } from 'lucide-react';
import { toast } from 'react-toastify';
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { photosService } from '../../services/photos.service';
@@ -79,6 +79,25 @@ export const EventCategoryManager: React.FC = ({ even
},
});
+ // Toggle per-category download permission (#640). The backend AND's this
+ // with the event-level `allow_downloads`, so disabling at either level
+ // blocks downloads for this category's photos.
+ const downloadToggleMutation = useMutation({
+ mutationFn: ({ category, allow }: { category: PhotoCategory; allow: boolean }) =>
+ categoriesService.updateCategory(category.id, category.name, { allow_downloads: allow }),
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
+ toast.success(
+ variables.allow
+ ? t('categories.downloadsEnabled', 'Downloads enabled for this category')
+ : t('categories.downloadsDisabled', 'Downloads disabled for this category')
+ );
+ },
+ onError: (error: any) => {
+ toast.error(error.response?.data?.error || t('categories.failedToToggleDownloads', 'Failed to update download permission'));
+ },
+ });
+
const handleCreate = () => {
if (newCategoryName.trim()) {
createMutation.mutate(newCategoryName.trim());
@@ -202,18 +221,49 @@ export const EventCategoryManager: React.FC = ({ even
{category.name}
-
+
+ {/* Per-category downloads toggle (#640). Green DownloadCloud
+ icon when on, struck-through outline when off. The
+ event-level `allow_downloads` AND's with this — if the
+ whole event has downloads off, this toggle is cosmetic. */}
+
+
+
+ {/* Shape selector (#640 #6). Long is the existing per-action shape;
+ pivot is per-(photo, guest) for spreadsheet pivot tables. */}
+
+
+
+
Date: Thu, 18 Jun 2026 23:55:47 +0200
Subject: [PATCH 6/7] fix(settings): hoist tab-visibility useEffect above
isLoading early return
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Surfaced while exercising Part D (WhatsApp) end-to-end. Navigating to
Settings → WhatsApp triggered React error #310 ("Rendered more hooks
than during the previous render"). Root cause is pre-existing: the
SettingsPage redirect-to-visible-tab `useEffect` lived AFTER the
`if (isLoading) return ` early return, so on the
isLoading=true→false transition the hook count grew by one and React's
rules-of-hooks invariant blew up.
Move the effect above the early return so the hook count is stable
across renders. While here, switch the gating logic from "is the key in
the currently-visible nav list" (which the bundle couldn't reference
yet because the nav array is built lower down) to a small lookup keyed
by activeTab → matching dependency flag. That's an equivalent decision
for the four tabs we already gated (crm, contracts, reminderTemplates,
accounting) plus the new whatsapp tab.
Add `flagsLoading` from the FeatureFlags context to the deps so the
snap-back only fires once the server's actual flag values have arrived.
Without this, the initial render with the placeholder DEFAULT_FLAGS
would falsely snap away from any tab whose flag is "on" on the server
but absent from the placeholder.
Also add `whatsapp: false` to `DEFAULT_FLAGS` in FeatureFlagsContext
(was missing — TypeScript should have caught the Record violation but the build pipeline didn't surface it). Without
this, `flags.whatsapp` is undefined on the placeholder, which had
secondary effects on tab visibility and the snap-back logic.
Verified via Chrome DevTools: Settings → WhatsApp now loads cleanly
with all 5 form fields, the saved config values prefilled, the Save
button, and the Send-test card.
---
frontend/src/contexts/FeatureFlagsContext.tsx | 2 +
frontend/src/pages/admin/SettingsPage.tsx | 43 +++++++++++++------
2 files changed, 32 insertions(+), 13 deletions(-)
diff --git a/frontend/src/contexts/FeatureFlagsContext.tsx b/frontend/src/contexts/FeatureFlagsContext.tsx
index b9bc94ed..b5549205 100644
--- a/frontend/src/contexts/FeatureFlagsContext.tsx
+++ b/frontend/src/contexts/FeatureFlagsContext.tsx
@@ -60,6 +60,8 @@ export const DEFAULT_FLAGS: FeatureFlags = {
// the Project Overview cockpit. Off by default — admin opts in under
// Settings → Features once they want the CRM → Overview area.
projects: false,
+ // WhatsApp Business API delivery channel (migration 136, #640D).
+ whatsapp: false,
};
export const FEATURE_FLAGS_QUERY_KEY = ['feature-flags'] as const;
diff --git a/frontend/src/pages/admin/SettingsPage.tsx b/frontend/src/pages/admin/SettingsPage.tsx
index 89febb13..66bf688a 100644
--- a/frontend/src/pages/admin/SettingsPage.tsx
+++ b/frontend/src/pages/admin/SettingsPage.tsx
@@ -115,7 +115,7 @@ function isValidTab(value: string | null): value is TabType {
export const SettingsPage: React.FC = () => {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
- const { flags } = useFeatureFlags();
+ const { flags, isLoading: flagsLoading } = useFeatureFlags();
// Read ?tab=… on mount; default to Features per the redesign.
const initialTab: TabType = isValidTab(searchParams.get('tab'))
@@ -183,6 +183,33 @@ export const SettingsPage: React.FC = () => {
saveSeoMutation,
} = useSettingsState();
+ // If the active tab refers to an item that's now hidden (e.g. admin
+ // landed on ?tab=reminderTemplates after disabling reminderEmails),
+ // snap to the first key that the dependency-rule flags allow. Effect
+ // re-fires when flags toggle live. MUST stay above the isLoading early
+ // return so React's rules-of-hooks count stays consistent across renders
+ // (was previously after the early return — that's a hooks violation that
+ // surfaced as React error #310 once settled long enough for `isLoading`
+ // to transition true→false in the same mount, #640D pre-existing-bug fix).
+ useEffect(() => {
+ // Wait for the server's actual flag values before deciding whether the
+ // current tab is allowed — during initial load `flags` is the defaults
+ // placeholder which would falsely snap-back away from a tab the server
+ // has actually enabled.
+ if (flagsLoading) return;
+ const gatedOff: Record = {
+ crm: !(flags.quotes || flags.bills || flags.contracts),
+ contracts: !flags.contracts,
+ reminderTemplates: !flags.reminderEmails,
+ accounting: !flags.accounting,
+ whatsapp: !flags.whatsapp,
+ };
+ if (gatedOff[activeTab]) {
+ setActiveTab('features');
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [flagsLoading, flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, flags.accounting, flags.whatsapp, activeTab]);
+
if (isLoading) {
return (
@@ -275,18 +302,8 @@ export const SettingsPage: React.FC = () => {
const allItems = navGroups.flatMap((g) => g.items);
const activeItem = allItems.find((i) => i.key === activeTab) ?? allItems[0];
-
- // If the active tab refers to an item that's now hidden (e.g. admin
- // landed on ?tab=reminderTemplates after disabling reminderEmails),
- // snap to the first visible item so the content area doesn't render
- // a hidden tab's UI. Effect re-fires when flags toggle live.
- useEffect(() => {
- const visibleKeys = allItems.map((i) => i.key);
- if (!visibleKeys.includes(activeTab) && visibleKeys.length > 0) {
- setActiveTab(visibleKeys[0]);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [flags.quotes, flags.bills, flags.contracts, flags.reminderEmails, flags.accounting, activeTab]);
+ // (Visibility snap-back is handled in the useEffect above, which sits
+ // before the isLoading early return to keep hook ordering stable.)
// For tabs that mount existing top-level pages OR bring their own
// header (FeaturesTab has its own icon+title+description block), skip
From a8bb7b439f6f57af9653ce283c951070bd52f3c2 Mon Sep 17 00:00:00 2001
From: Paul Nothaft
Date: Fri, 19 Jun 2026 08:42:22 +0200
Subject: [PATCH 7/7] fix(i18n): wrap WhatsApp token show/hide aria-label
through t()
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
i18n audit caught one straggler — the eye-icon toggle on the access-token
input had a bare `aria-label={showToken ? 'Hide' : 'Show'}` that wouldn't
translate for screen readers on non-English locales. Switched to
`t('common.hide')` / `t('common.show')`; added the matching `common.show`
key in EN + DE (common.hide already existed).
The two remaining `placeholder=` literals in the WhatsApp tab are sample
ID strings (`123456789012345`, `gallery_ready`, `+49123456789`) — those
are identifier/value examples, not translatable English.
Other PR-touched UI surfaces passed the audit clean: 30 new i18n keys
across categories (5), settings.whatsapp (16), settings.features.whatsapp
(2), feedback (3), and the activity-log + bell entries (4) all exist in
both EN and DE.
---
frontend/src/features/settings/tabs/WhatsAppTab.tsx | 2 +-
frontend/src/i18n/locales/de.json | 3 ++-
frontend/src/i18n/locales/en.json | 3 ++-
3 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/frontend/src/features/settings/tabs/WhatsAppTab.tsx b/frontend/src/features/settings/tabs/WhatsAppTab.tsx
index e5642783..97f17ad5 100644
--- a/frontend/src/features/settings/tabs/WhatsAppTab.tsx
+++ b/frontend/src/features/settings/tabs/WhatsAppTab.tsx
@@ -144,7 +144,7 @@ export const WhatsAppTab: React.FC = () => {
type="button"
onClick={() => setShowToken((v) => !v)}
className="p-1"
- aria-label={showToken ? 'Hide' : 'Show'}
+ aria-label={showToken ? t('common.hide', 'Hide') : t('common.show', 'Show')}
>
{showToken ? : }
diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json
index a5c7e19b..a317244f 100644
--- a/frontend/src/i18n/locales/de.json
+++ b/frontend/src/i18n/locales/de.json
@@ -151,7 +151,8 @@
"preview": "Vorschau",
"duplicate": "Duplizieren",
"showAll": "Alle anzeigen",
- "confirm": "Bestätigen"
+ "confirm": "Bestätigen",
+ "show": "Einblenden"
},
"upload": {
"photoCategory": "Fotokategorie",
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json
index 389c4b2b..06092183 100644
--- a/frontend/src/i18n/locales/en.json
+++ b/frontend/src/i18n/locales/en.json
@@ -151,7 +151,8 @@
"preview": "Preview",
"duplicate": "Duplicate",
"showAll": "Show all",
- "confirm": "Confirm"
+ "confirm": "Confirm",
+ "show": "Show"
},
"upload": {
"photoCategory": "Photo Category",