feat: customisable 404 + gallery-not-found pages via CMS (#324)

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-<slug>-` filename prefix.
- adminCMS PUT now accepts logo_url; publicCMS GET returns it.

Frontend:
- New <CMSContentBlock slug fallback> 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.<slug>` translation is registered.
This commit is contained in:
Paul Nothaft
2026-04-27 22:38:00 +02:00
parent b63a8774c4
commit 4f77905b87
9 changed files with 417 additions and 141 deletions
+123 -22
View File
@@ -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;
// 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;
+3
View File
@@ -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) {