feat(categories): per-category download permissions (#640 part B)
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)
This commit is contained in:
@@ -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')
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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<EventCategoryManagerProps> = ({ 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<EventCategoryManagerProps> = ({ even
|
||||
</button>
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300 truncate">{category.name}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
title={t('categories.deleteCategoryTitle')}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<X className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 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. */}
|
||||
<button
|
||||
onClick={() => downloadToggleMutation.mutate({
|
||||
category,
|
||||
allow: category.allow_downloads === false,
|
||||
})}
|
||||
className={`p-1 transition-colors ${
|
||||
category.allow_downloads === false
|
||||
? 'text-neutral-400 dark:text-neutral-500 hover:text-green-600 dark:hover:text-green-400'
|
||||
: 'text-green-600 dark:text-green-400 hover:text-neutral-400'
|
||||
}`}
|
||||
title={
|
||||
category.allow_downloads === false
|
||||
? t('categories.enableDownloadsTitle', 'Click to enable downloads for this category')
|
||||
: t('categories.disableDownloadsTitle', 'Click to disable downloads for this category')
|
||||
}
|
||||
disabled={downloadToggleMutation.isPending}
|
||||
>
|
||||
{downloadToggleMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : category.allow_downloads === false ? (
|
||||
<Download className="w-3 h-3" />
|
||||
) : (
|
||||
<DownloadCloud className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
title={t('categories.deleteCategoryTitle')}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<X className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -106,6 +106,11 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
// unsupported browsers fall through to a regular <a download>.
|
||||
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<PhotoLightboxProps> = ({
|
||||
break;
|
||||
case 'd':
|
||||
case 'D':
|
||||
if (allowDownloads) {
|
||||
if (photoAllowsDownload) {
|
||||
handleDownload();
|
||||
}
|
||||
break;
|
||||
@@ -353,7 +358,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!allowDownloads) return;
|
||||
if (!photoAllowsDownload) return;
|
||||
downloadPhotoMutation.mutate({
|
||||
slug,
|
||||
photoId: currentPhoto.id,
|
||||
@@ -680,7 +685,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
|
||||
<div className="w-px h-6 bg-white/20 mx-2" />
|
||||
|
||||
{allowDownloads && (
|
||||
{photoAllowsDownload && (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors"
|
||||
|
||||
@@ -827,7 +827,12 @@
|
||||
"coverPhotoSet": "Titelbild erfolgreich festgelegt",
|
||||
"coverPhotoRemoved": "Titelbild entfernt",
|
||||
"failedToSetCoverPhoto": "Titelbild konnte nicht festgelegt werden",
|
||||
"categoryHeroHint": "Wenn kein Titelbild für eine Kategorie festgelegt ist, wird das Standard-Hero-Foto verwendet."
|
||||
"categoryHeroHint": "Wenn kein Titelbild für eine Kategorie festgelegt ist, wird das Standard-Hero-Foto verwendet.",
|
||||
"downloadsEnabled": "Downloads für diese Kategorie aktiviert",
|
||||
"downloadsDisabled": "Downloads für diese Kategorie deaktiviert",
|
||||
"enableDownloadsTitle": "Klicken zum Aktivieren der Downloads für diese Kategorie",
|
||||
"disableDownloadsTitle": "Klicken zum Deaktivieren der Downloads für diese Kategorie",
|
||||
"failedToToggleDownloads": "Aktualisierung der Download-Berechtigung fehlgeschlagen"
|
||||
},
|
||||
"events": {
|
||||
"totalPhotos": "Gesamtfotos",
|
||||
|
||||
@@ -385,7 +385,12 @@
|
||||
"coverPhotoSet": "Cover photo set successfully",
|
||||
"coverPhotoRemoved": "Cover photo removed",
|
||||
"failedToSetCoverPhoto": "Failed to set cover photo",
|
||||
"categoryHeroHint": "If no cover photo is set for a category, the default hero photo will be used."
|
||||
"categoryHeroHint": "If no cover photo is set for a category, the default hero photo will be used.",
|
||||
"downloadsEnabled": "Downloads enabled for this category",
|
||||
"downloadsDisabled": "Downloads disabled for this category",
|
||||
"enableDownloadsTitle": "Click to enable downloads for this category",
|
||||
"disableDownloadsTitle": "Click to disable downloads for this category",
|
||||
"failedToToggleDownloads": "Failed to update download permission"
|
||||
},
|
||||
"events": {
|
||||
"title": "Events",
|
||||
|
||||
@@ -7,6 +7,9 @@ export interface PhotoCategory {
|
||||
is_global: boolean;
|
||||
event_id: number | null;
|
||||
hero_photo_id?: number | null;
|
||||
// Per-category download permission (#640). Defaults true (server-side) so
|
||||
// categories created before migration 135 keep working.
|
||||
allow_downloads?: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -36,9 +39,16 @@ export const categoriesService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update a category
|
||||
async updateCategory(id: number, name: string): Promise<PhotoCategory> {
|
||||
const response = await api.put<PhotoCategory>(`/admin/categories/${id}`, { name });
|
||||
// Update a category. `name` is required by the backend validator; the other
|
||||
// fields are optional patches. Per-category `allow_downloads` is the #640
|
||||
// hook so admins can disable downloads for one category while keeping
|
||||
// everything else downloadable.
|
||||
async updateCategory(
|
||||
id: number,
|
||||
name: string,
|
||||
patch?: { allow_downloads?: boolean }
|
||||
): Promise<PhotoCategory> {
|
||||
const response = await api.put<PhotoCategory>(`/admin/categories/${id}`, { name, ...patch });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -112,6 +112,11 @@ export interface Photo {
|
||||
category_id?: number | string | null;
|
||||
category_name?: string;
|
||||
category_slug?: string;
|
||||
// Per-category download permission (#640). Defaults true for uncategorised
|
||||
// photos and for categories that pre-date migration 135. The frontend hides
|
||||
// the lightbox download button when this is false (event-level allow_downloads
|
||||
// also has to be true — they AND together).
|
||||
category_allow_downloads?: boolean;
|
||||
size: number;
|
||||
uploaded_at: string;
|
||||
captured_at?: string; // EXIF capture date (if available)
|
||||
|
||||
Reference in New Issue
Block a user