From 4f77905b87bea474b3d2496350996deaad041230 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Mon, 27 Apr 2026 17:20:29 +0200 Subject: [PATCH] feat: customisable 404 + gallery-not-found pages via CMS (#324) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 404 catch-all and the "gallery not found" branches in GalleryPage were hard-coded English strings on a default-themed background — the one place where a white-labelled deployment leaked the PicPeak default look. Pluggable now via the existing CMS Pages mechanism. Backend: - Seed two new default CMS pages: `not-found` and `gallery-not-found`, with sensible English/German copy admins can edit in /admin/cms. - Add `cms_pages.logo_url` (nullable) for per-page logo override; online migration on existing deployments. Null falls back to the global branding logo. - New per-page logo upload (POST /api/admin/cms/pages/:slug/logo) + clear endpoint (DELETE …/logo). Reuses the existing /uploads/logos storage location with a `cms--` filename prefix. - adminCMS PUT now accepts logo_url; publicCMS GET returns it. Frontend: - New component renders the CMS page in the standard branded shell (logo precedence: page → branding → bundled default), with DOMPurified content and footer/legal links. - App.tsx: `path="*"` catch-all routes through CMSContentBlock("not-found"). - GalleryPage: collapses the two "gallery not found" branches (invalid identifier + infoError archived/missing) into a single CMSContentBlock("gallery-not-found"), so admins can edit one source of truth. - Admin CMS Page editor gains an "Upload Logo / Use site default" control per page; falls back to the page's own English title in the page list when no `legal.` translation is registered. --- backend/src/database/db.js | 25 +++ backend/src/routes/adminCMS.js | 145 +++++++++++++++--- backend/src/routes/publicCMS.js | 3 + frontend/src/App.tsx | 7 +- .../src/components/common/CMSContentBlock.tsx | 134 ++++++++++++++++ frontend/src/components/common/index.ts | 1 + frontend/src/pages/GalleryPage.tsx | 123 ++------------- frontend/src/pages/admin/CMSPage.tsx | 90 ++++++++++- frontend/src/services/cms.service.ts | 30 +++- 9 files changed, 417 insertions(+), 141 deletions(-) create mode 100644 frontend/src/components/common/CMSContentBlock.tsx diff --git a/backend/src/database/db.js b/backend/src/database/db.js index f338798c..7b0ab1ef 100644 --- a/backend/src/database/db.js +++ b/backend/src/database/db.js @@ -463,8 +463,15 @@ async function ensureGlobalCategories() { table.text('title_de'); table.text('content_en'); table.text('content_de'); + table.string('logo_url').nullable(); table.timestamp('updated_at').defaultTo(db.fn.now()); }); + } else if (!(await db.schema.hasColumn('cms_pages', 'logo_url'))) { + // Online migration for existing deployments — see issue #324, per-page + // logo override for admin-customisable error pages. + await db.schema.alterTable('cms_pages', (table) => { + table.string('logo_url').nullable(); + }); } const categoryCountRow = await db('photo_categories').count({ count: 'id' }).first(); @@ -501,6 +508,24 @@ async function ensureGlobalCategories() { content_de: '

Datenschutzerklärung

Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.

', updated_at: new Date(), }, + // Customisable error pages — issue #324. Generic copy by default; + // admins can edit text + logo per page in the CMS Pages tab. + { + slug: 'not-found', + title_en: 'Page Not Found', + title_de: 'Seite nicht gefunden', + content_en: '

Page Not Found

The page you are looking for does not exist or has been moved.

', + content_de: '

Seite nicht gefunden

Die gesuchte Seite existiert nicht oder wurde verschoben.

', + updated_at: new Date(), + }, + { + slug: 'gallery-not-found', + title_en: 'Gallery Not Found', + title_de: 'Galerie nicht gefunden', + content_en: '

Gallery Not Found

This gallery could not be found. The link may be incorrect, or the gallery may have expired or been archived. Please contact the organiser if you believe this is a mistake.

', + content_de: '

Galerie nicht gefunden

Diese Galerie konnte nicht gefunden werden. Der Link ist möglicherweise nicht korrekt, oder die Galerie ist abgelaufen oder wurde archiviert. Bitte kontaktieren Sie den Veranstalter, falls Sie glauben, dass dies ein Fehler ist.

', + updated_at: new Date(), + }, ]; for (const page of defaultPages) { diff --git a/backend/src/routes/adminCMS.js b/backend/src/routes/adminCMS.js index 0fc64085..647037b9 100644 --- a/backend/src/routes/adminCMS.js +++ b/backend/src/routes/adminCMS.js @@ -1,10 +1,42 @@ const express = require('express'); +const path = require('path'); +const fs = require('fs').promises; +const multer = require('multer'); const { body, validationResult } = require('express-validator'); const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); +const { validateFileType } = require('../utils/fileSecurityUtils'); const router = express.Router(); +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + +// Multer config for per-page logo uploads. Stores into the same +// /uploads/logos directory the global branding logo uses, with a +// per-slug filename so a page swap doesn't fight an unrelated upload. +const pageLogoStorage = multer.diskStorage({ + destination: async (_req, _file, cb) => { + const dir = path.join(getStoragePath(), 'uploads/logos'); + await fs.mkdir(dir, { recursive: true }); + cb(null, dir); + }, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname); + const safeSlug = (req.params.slug || 'page').replace(/[^a-z0-9-]/gi, ''); + cb(null, `cms-${safeSlug}-${Date.now()}${ext}`); + } +}); + +const pageLogoUpload = multer({ + storage: pageLogoStorage, + limits: { fileSize: 5 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml']; + if (validateFileType(file.originalname, file.mimetype, allowed)) cb(null, true); + else cb(new Error('Only JPEG, PNG, GIF and SVG image files are allowed')); + } +}); + // Get all CMS pages router.get('/pages', adminAuth, requirePermission('cms.view'), async (req, res) => { try { @@ -21,11 +53,11 @@ router.get('/pages/:slug', adminAuth, requirePermission('cms.view'), async (req, try { const { slug } = req.params; const page = await db('cms_pages').where('slug', slug).first(); - + if (!page) { return res.status(404).json({ error: 'Page not found' }); } - + res.json(page); } catch (error) { console.error('Error fetching CMS page:', error); @@ -38,42 +70,46 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [ body('title_en').optional().isString(), body('title_de').optional().isString(), body('content_en').optional().isString(), - body('content_de').optional().isString() + body('content_de').optional().isString(), + body('logo_url').optional({ nullable: true }).isString() ], async (req, res) => { try { const errors = validationResult(req); if (!errors.isEmpty()) { return res.status(400).json({ errors: errors.array() }); } - + const { slug } = req.params; - const { title_en, title_de, content_en, content_de } = req.body; - + const { title_en, title_de, content_en, content_de, logo_url } = req.body; + const page = await db('cms_pages').where('slug', slug).first(); if (!page) { return res.status(404).json({ error: 'Page not found' }); } - - // Update the page - await db('cms_pages') - .where('slug', slug) - .update({ - title_en, - title_de, - content_en, - content_de, - updated_at: new Date() - }); - + + const updateFields = { + title_en, + title_de, + content_en, + content_de, + updated_at: new Date() + }; + // Only touch logo_url when explicitly present so partial updates + // (e.g. text-only edits) don't accidentally clear the upload. + if (Object.prototype.hasOwnProperty.call(req.body, 'logo_url')) { + updateFields.logo_url = logo_url || null; + } + + await db('cms_pages').where('slug', slug).update(updateFields); + const updated = await db('cms_pages').where('slug', slug).first(); - - // Log activity + await logActivity('cms_page_updated', { page: slug }, null, { type: 'admin', id: req.admin.id, name: req.admin.username } ); - + res.json(updated); } catch (error) { console.error('Error updating CMS page:', error); @@ -81,4 +117,69 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [ } }); -module.exports = router; \ No newline at end of file +// Upload a per-page logo (#324). Persists the URL to cms_pages.logo_url +// and returns it so the client can re-render without a refetch. +router.post( + '/pages/:slug/logo', + adminAuth, + requirePermission('cms.edit'), + pageLogoUpload.single('logo'), + async (req, res) => { + try { + const { slug } = req.params; + if (!req.file) { + return res.status(400).json({ error: 'No file uploaded' }); + } + + const page = await db('cms_pages').where('slug', slug).first(); + if (!page) { + // Best-effort cleanup of the orphaned upload before erroring. + await fs.unlink(req.file.path).catch(() => {}); + return res.status(404).json({ error: 'Page not found' }); + } + + const logoUrl = `/uploads/logos/${path.basename(req.file.path)}`; + await db('cms_pages').where('slug', slug).update({ + logo_url: logoUrl, + updated_at: new Date() + }); + + await logActivity('cms_page_logo_uploaded', + { page: slug }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + res.json({ logo_url: logoUrl }); + } catch (error) { + console.error('Error uploading CMS page logo:', error); + res.status(500).json({ error: 'Failed to upload logo' }); + } + } +); + +// Clear a per-page logo override (revert to global branding logo). +router.delete( + '/pages/:slug/logo', + adminAuth, + requirePermission('cms.edit'), + async (req, res) => { + try { + const { slug } = req.params; + const page = await db('cms_pages').where('slug', slug).first(); + if (!page) return res.status(404).json({ error: 'Page not found' }); + + await db('cms_pages').where('slug', slug).update({ + logo_url: null, + updated_at: new Date() + }); + + res.json({ logo_url: null }); + } catch (error) { + console.error('Error clearing CMS page logo:', error); + res.status(500).json({ error: 'Failed to clear logo' }); + } + } +); + +module.exports = router; diff --git a/backend/src/routes/publicCMS.js b/backend/src/routes/publicCMS.js index 8a69f27a..b999ed0f 100644 --- a/backend/src/routes/publicCMS.js +++ b/backend/src/routes/publicCMS.js @@ -22,6 +22,9 @@ router.get('/pages/:slug', async (req, res) => { title, content, slug: page.slug, + // Per-page logo override (#324). Null means "fall back to global + // branding logo" — the consumer decides. + logo_url: page.logo_url || null, updated_at: page.updated_at }); } catch (error) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 688150bb..ed0f113f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,7 +30,7 @@ import { } from './pages/admin'; import { AcceptInvitePage } from './pages/public/AcceptInvitePage'; import { AdminLayout, AdminAuthWrapper } from './components/admin'; -import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags } from './components/common'; +import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock } from './components/common'; import { MaintenanceWrapper } from './components/MaintenanceWrapper'; import { GlobalThemeProvider } from './components/GlobalThemeProvider'; import { getApiBaseUrl } from './utils/url'; @@ -165,6 +165,11 @@ function App() { {/* Default redirect */} } /> + + {/* Customisable 404 (#324) — caught here for any path that + didn't match. Top-level `/:slug` is consumed above by + LegalPage; this picks up deeper unknown paths. */} + } /> diff --git a/frontend/src/components/common/CMSContentBlock.tsx b/frontend/src/components/common/CMSContentBlock.tsx new file mode 100644 index 00000000..ee93cf3c --- /dev/null +++ b/frontend/src/components/common/CMSContentBlock.tsx @@ -0,0 +1,134 @@ +import React, { useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import DOMPurify from 'dompurify'; +import { Card } from './Card'; +import { Loading } from './Loading'; +import { cmsService } from '../../services/cms.service'; +import { api } from '../../config/api'; +import { buildResourceUrl } from '../../utils/url'; +import '../../styles/prose-overrides.css'; + +interface CMSContentBlockProps { + /** CMS page slug, e.g. "not-found" or "gallery-not-found". */ + slug: string; + /** Rendered when the slug doesn't exist or the fetch fails so the + * caller is never left with a blank screen during cold deployments. */ + fallback?: React.ReactNode; +} + +const ALLOWED_TAGS = [ + 'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'ul', 'ol', 'li', 'blockquote', 'a', 'em', 'strong', + 'code', 'pre', 'hr', 'div', 'span', 'img', +]; +const ALLOWED_ATTR = ['href', 'target', 'rel', 'class', 'style', 'src', 'alt', 'title']; + +/** + * Renders a CMS page inside the standard branded shell. Used for the + * customisable 404 and gallery-not-found pages (#324). Logo precedence: + * per-page logo → global branding logo → bundled placeholder. + */ +export const CMSContentBlock: React.FC = ({ slug, fallback }) => { + const { i18n } = useTranslation(); + + const { data: settings } = useQuery({ + queryKey: ['public-settings'], + queryFn: async () => { + const response = await api.get('/public/settings'); + return response.data; + }, + staleTime: 5 * 60 * 1000, + }); + + const lang = settings?.default_language || i18n.language || 'en'; + + const { data: page, isLoading, error } = useQuery({ + queryKey: ['cms-public-page', slug, lang], + queryFn: () => cmsService.getPublicPage(slug, lang), + enabled: !!slug, + retry: false, + }); + + useEffect(() => { + if (page?.title) document.title = `${page.title} - ${settings?.branding_company_name || 'PicPeak'}`; + }, [page?.title, settings?.branding_company_name]); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error || !page) { + return <>{fallback ?? null}; + } + + // Logo: per-page override beats global branding logo. + const rawLogo = page.logo_url || settings?.branding_logo_url || '/picpeak-logo-transparent.png'; + const logoSrc = rawLogo.startsWith('http') || rawLogo.startsWith('/picpeak-') + ? rawLogo + : buildResourceUrl(rawLogo); + const companyName = settings?.branding_company_name || 'PicPeak'; + + return ( +
+
+ {companyName} +
+ +
+
+ +

+ {page.title} +

+
+
+ + {lang === 'de' ? '← Zur Startseite' : '← Back to home'} + +
+ +
+
+ +
+
+ + {lang === 'de' ? 'Impressum' : 'Legal Notice'} + + + + {lang === 'de' ? 'Datenschutz' : 'Privacy Policy'} + +
+ {!settings?.branding_hide_powered_by && ( +

+ Powered by PicPeak +

+ )} +
+
+ ); +}; diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index 4251fb50..1ac81226 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -1,4 +1,5 @@ export { Button } from './Button'; +export { CMSContentBlock } from './CMSContentBlock'; export { Input } from './Input'; export { Card, CardHeader, CardContent, CardFooter } from './Card'; export { Loading, LoadingSkeleton } from './Loading'; diff --git a/frontend/src/pages/GalleryPage.tsx b/frontend/src/pages/GalleryPage.tsx index c37aae3e..f440bbc7 100644 --- a/frontend/src/pages/GalleryPage.tsx +++ b/frontend/src/pages/GalleryPage.tsx @@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'; import { useLocalizedDate } from '../hooks/useLocalizedDate'; import { useQuery } from '@tanstack/react-query'; -import { Card, CardContent, Input, Button, ReCaptcha } from '../components/common'; +import { Card, CardContent, Input, Button, ReCaptcha, CMSContentBlock } from '../components/common'; import { useGalleryAuth, useTheme } from '../contexts'; import { useGalleryInfo } from '../hooks/useGallery'; import { GalleryView } from '../components/gallery'; @@ -266,117 +266,16 @@ export const GalleryPage: React.FC = () => { return ; } - if (identifierError && !resolvedSlug && !isResolvingIdentifier) { - return ( -
-
- {settingsData?.branding_logo_url && ( -
- {settingsData.branding_company_name -
- )} - -
- - - -

- {t('errors.galleryNotFound')} -

-

- {identifierError} -

-
-
-
- -
-
- - {t('legal.impressum')} - - | - - {t('legal.datenschutz')} - -
-

- Powered by PicPeak -

-
-
-
- ); - } - - // Show error state - if (infoError) { - // Check if it's an archived gallery error - const errorMessage = (infoError as any)?.response?.data?.error; - const isArchived = errorMessage?.includes('archived'); - - return ( -
-
- {/* Logo at top */} - {settingsData?.branding_logo_url && ( -
- {settingsData.branding_company_name -
- )} - -
- - - -

- {t(isArchived ? 'errors.galleryArchived' : 'errors.galleryNotFound')} -

-

- {t(isArchived ? 'errors.galleryArchivedMessage' : 'errors.galleryNotFoundMessage')} -

-
-
-
- - {/* Legal Links */} -
-
- - {t('legal.impressum')} - - | - - {t('legal.datenschutz')} - -
-

- Powered by PicPeak -

-
-
-
- ); + // Gallery missing / archived / expired-link / unresolvable identifier all + // collapse into the customisable "gallery-not-found" CMS page (#324). + // Admins can edit the title, body, and logo from the CMS Pages tab; the + // seeded default copy is intentionally generic so any of those reasons + // reads correctly. + if ( + (identifierError && !resolvedSlug && !isResolvingIdentifier) || + infoError + ) { + return ; } // Show expired state diff --git a/frontend/src/pages/admin/CMSPage.tsx b/frontend/src/pages/admin/CMSPage.tsx index 804071a1..40c75fbe 100644 --- a/frontend/src/pages/admin/CMSPage.tsx +++ b/frontend/src/pages/admin/CMSPage.tsx @@ -1,7 +1,7 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'react-toastify'; -import { FileText, Globe, Clock, Sparkles, ShieldCheck } from 'lucide-react'; +import { FileText, Globe, Clock, Sparkles, ShieldCheck, Image as ImageIcon, Trash2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { debounce } from 'lodash'; import DOMPurify from 'dompurify'; @@ -11,6 +11,7 @@ import { CMSEditor } from '../../components/admin/CMSEditor'; import { cmsService } from '../../services/cms.service'; import type { CMSPage as CMSPageType } from '../../services/cms.service'; import { settingsService, PublicSiteBranding } from '../../services/settings.service'; +import { buildResourceUrl } from '../../utils/url'; export const CMSPage: React.FC = () => { const { t } = useTranslation(); @@ -181,6 +182,28 @@ export const CMSPage: React.FC = () => { setHasUnsavedChanges(true); }; + // Per-page logo upload (#324). Only meaningful for the customisable + // error pages right now, but harmless if exposed for any slug. + const logoInputRef = useRef(null); + const uploadLogoMutation = useMutation({ + mutationFn: async (file: File) => cmsService.uploadPageLogo(selectedPage, file), + onSuccess: ({ logo_url }) => { + setEditForm(prev => ({ ...prev, logo_url })); + queryClient.invalidateQueries({ queryKey: ['cms-pages'] }); + toast.success(t('cms.logoUploaded', 'Logo uploaded')); + }, + onError: () => toast.error(t('toast.uploadError')), + }); + const clearLogoMutation = useMutation({ + mutationFn: async () => cmsService.clearPageLogo(selectedPage), + onSuccess: () => { + setEditForm(prev => ({ ...prev, logo_url: null })); + queryClient.invalidateQueries({ queryKey: ['cms-pages'] }); + toast.success(t('cms.logoCleared', 'Logo cleared')); + }, + onError: () => toast.error(t('toast.saveError')), + }); + // Warn before leaving with unsaved changes useEffect(() => { const handleBeforeUnload = (e: BeforeUnloadEvent) => { @@ -483,7 +506,12 @@ export const CMSPage: React.FC = () => { >
-

{t(`legal.${page.slug}`)}

+ {/* Fall back to the page's own English title for slugs + that don't have a fixed translation key (e.g. the new + not-found / gallery-not-found error pages). */} +

+ {t(`legal.${page.slug}`, { defaultValue: page.title_en || page.slug })} +

/{page.slug}

{selectedPage === page.slug && hasUnsavedChanges && ( @@ -550,7 +578,7 @@ export const CMSPage: React.FC = () => {

- {t('cms.editPage', { page: t(`legal.${selectedPage}`) })} + {t('cms.editPage', { page: t(`legal.${selectedPage}`, { defaultValue: currentPage?.title_en || selectedPage }) })}

{/* Language Tabs */} @@ -603,6 +631,60 @@ export const CMSPage: React.FC = () => { isSaving={updateMutation.isPending} />
+ + {/* Per-page logo override (#324) */} +
+ +

+ {t('cms.pageLogoHelp', 'Optional. If set, used in place of the global branding logo on this page.')} +

+
+ {editForm.logo_url ? ( + Page logo + ) : ( +
+ {t('cms.noLogo', 'no override')} +
+ )} + { + const file = e.target.files?.[0]; + if (file) uploadLogoMutation.mutate(file); + if (logoInputRef.current) logoInputRef.current.value = ''; + }} + /> + + {editForm.logo_url && ( + + )} +
+
{currentPage?.updated_at && ( diff --git a/frontend/src/services/cms.service.ts b/frontend/src/services/cms.service.ts index bd209104..0feccbc9 100644 --- a/frontend/src/services/cms.service.ts +++ b/frontend/src/services/cms.service.ts @@ -7,6 +7,15 @@ export interface CMSPage { title_de: string; content_en: string; content_de: string; + logo_url: string | null; + updated_at: string; +} + +export interface PublicCMSPage { + title: string; + content: string; + slug: string; + logo_url: string | null; updated_at: string; } @@ -30,10 +39,27 @@ export const cmsService = { }, // Get public CMS page (no auth required) - async getPublicPage(slug: string, lang: string = 'en'): Promise<{ title: string; content: string }> { - const response = await api.get<{ title: string; content: string }>(`/public/pages/${slug}`, { + async getPublicPage(slug: string, lang: string = 'en'): Promise { + const response = await api.get(`/public/pages/${slug}`, { params: { lang } }); return response.data; + }, + + // Upload a per-page logo (#324) + async uploadPageLogo(slug: string, file: File): Promise<{ logo_url: string }> { + const formData = new FormData(); + formData.append('logo', file); + const response = await api.post<{ logo_url: string }>( + `/admin/cms/pages/${slug}/logo`, + formData, + { headers: { 'Content-Type': 'multipart/form-data' } } + ); + return response.data; + }, + + // Clear a per-page logo override (revert to global branding logo). + async clearPageLogo(slug: string): Promise { + await api.delete(`/admin/cms/pages/${slug}/logo`); } }; \ No newline at end of file