From 820f4835f1f5a41cbef6816c387ef9ec3dafd526 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Thu, 18 Jun 2026 22:29:35 +0200 Subject: [PATCH] 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. */} + + +
); })} diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index 81daa7cf..e210be06 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -106,6 +106,11 @@ export const PhotoLightbox: React.FC = ({ // unsupported browsers fall through to a regular . const downloadPhotoMutation = useSavePhotoToDevice(); const currentPhoto = photos[currentIndex]; + // Per-category download permission (#640). AND'd with the event-level + // allowDownloads — disabling at either level hides the download button. + // Defaults true for uncategorised photos and pre-migration-135 categories. + const photoAllowsDownload = + allowDownloads && currentPhoto?.category_allow_downloads !== false; // DevTools protection - enabled by individual setting OR legacy protection level const devToolsEnabled = enableDevtoolsProtection || (useEnhancedProtection && (protectionLevel === 'enhanced' || protectionLevel === 'maximum')); @@ -171,7 +176,7 @@ export const PhotoLightbox: React.FC = ({ break; case 'd': case 'D': - if (allowDownloads) { + if (photoAllowsDownload) { handleDownload(); } break; @@ -353,7 +358,7 @@ export const PhotoLightbox: React.FC = ({ }; const handleDownload = () => { - if (!allowDownloads) return; + if (!photoAllowsDownload) return; downloadPhotoMutation.mutate({ slug, photoId: currentPhoto.id, @@ -680,7 +685,7 @@ export const PhotoLightbox: React.FC = ({
- {allowDownloads && ( + {photoAllowsDownload && (