feat(admin): external media import modal + thumbnail fixes for reference events\n\n- Photos tab: replace inline external folder picker with a modal opened via "Import from External Folder" button next to "Upload Photos"; add info that all pictures in the selected folder will be imported.\n- Admin thumbnails: align list endpoint to /api/admin/photos/:eventId/photos and always return thumbnail_url to trigger on-demand generation; normalize external paths to avoid duplicated folder segments (e.g., individual/individual) that broke resolver; improve thumbnail logging.\n- Use authenticated image fetching on admin feedback pages to prevent 401s in automation.\n- i18n: add backup.external.warning strings; complete German backup/restore coverage; add common keys (notSet, of, up, select, selected).\n- Docs: add Local (npm) setup for EXTERNAL_MEDIA_ROOT in deployment guide.\n\nRefs #17 – gallery feature request: https://github.com/the-luap/picpeak/issues/17
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Failing after 1m50s
Version and Release / version-bump (push) Successful in 1m1s
Version and Release / trigger-drone (push) Successful in 3s

This commit is contained in:
2025-09-05 23:44:30 +02:00
parent 1d826accdc
commit 49c77785e7
13 changed files with 858 additions and 34 deletions
+112
View File
@@ -0,0 +1,112 @@
const express = require('express');
const path = require('path');
const fs = require('fs').promises;
const { adminAuth } = require('../middleware/auth');
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
const { db, logActivity } = require('../database/db');
const router = express.Router();
// GET /api/admin/external-media/list?path=relative/dir
router.get('/list', adminAuth, async (req, res) => {
try {
const relPath = (req.query.path || '').replace(/^\/+/, '');
const result = await list(relPath);
res.json(result);
} catch (error) {
res.status(400).json({ error: 'Invalid path', details: error.message });
}
});
// Helper to recursively collect files under a directory, filtered by image extensions
async function walkDir(dir, baseDir) {
const results = [];
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const e of entries) {
if (e.name.startsWith('.')) continue;
const full = path.join(dir, e.name);
if (e.isDirectory()) {
results.push(...await walkDir(full, baseDir));
} else if (e.isFile()) {
const ext = path.extname(e.name).toLowerCase();
if (['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) {
const rel = path.relative(baseDir, full);
results.push({ full, rel, name: e.name });
}
}
}
return results;
}
// POST /api/admin/events/:id/import-external
// Body: { external_path: string, recursive?: boolean, map?: { individual?: string, collages?: string } }
router.post('/events/:id/import-external', adminAuth, async (req, res) => {
try {
const eventId = parseInt(req.params.id);
const { external_path, recursive = true, map = { individual: 'individual', collages: 'collages' } } = req.body || {};
if (!external_path) return res.status(400).json({ error: 'external_path is required' });
// Load event
const event = await db('events').where('id', eventId).first();
if (!event) return res.status(404).json({ error: 'Event not found' });
const baseAbs = resolveExternalPath({ external_path }, '');
// Collect files
const files = recursive ? await walkDir(baseAbs, baseAbs) : (await fs.readdir(baseAbs, { withFileTypes: true }))
.filter(e => e.isFile())
.map(e => ({ full: path.join(baseAbs, e.name), rel: e.name, name: e.name }))
.filter(f => ['.jpg', '.jpeg', '.png', '.webp'].includes(path.extname(f.name).toLowerCase()));
let imported = 0;
let skipped = 0;
// Insert photos
for (const f of files) {
// Infer type by subfolder names
const segs = f.rel.split(path.sep);
let type = 'individual';
if (segs[0] === map.collages) type = 'collage';
if (segs[0] === map.individual) type = 'individual';
try {
// Check if already exists (by external_relpath)
const exists = await db('photos')
.where({ event_id: eventId, external_relpath: f.rel })
.first();
if (exists) { skipped++; continue; }
const stats = await fs.stat(f.full);
const inserted = await db('photos')
.insert({
event_id: eventId,
filename: f.name,
// Keep path as a hint for legacy code but not used for resolution in external mode
path: path.join(event.slug, f.name),
thumbnail_path: null,
type,
size_bytes: stats.size,
source_origin: 'external',
external_relpath: f.rel
})
.returning('id');
imported += (inserted?.length ? 1 : 0);
} catch (e) {
skipped++;
}
}
// Update event fields
await db('events').where('id', eventId).update({ source_mode: 'reference', external_path });
// Queue thumbnail generation lazily by reading thumbnails via ensure endpoint as needed
await logActivity('external_import_completed', { event_id: eventId, imported, skipped, external_path }, eventId, { type: 'admin' });
res.json({ imported, skipped, thumbnailsQueued: 0 });
} catch (error) {
res.status(500).json({ error: 'Failed to import external media', details: error.message });
}
});
module.exports = router;
+11 -7
View File
@@ -606,8 +606,9 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) =>
return res.status(404).json({ error: 'Photo not found' });
}
const storagePath = getStoragePath();
const filePath = path.join(storagePath, 'events/active', photo.path);
const { resolvePhotoFilePath } = require('../services/photoResolver');
const event = await db('events').where('id', eventId).first();
const filePath = resolvePhotoFilePath(event, photo);
// Check if file exists
try {
@@ -684,8 +685,10 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
photos: photos.map(photo => ({
id: photo.id,
filename: photo.filename,
url: `/admin/events/${eventId}/photo/${photo.id}`,
thumbnail_url: photo.thumbnail_path ? `/admin/events/${eventId}/thumbnail/${photo.id}` : null,
// Use the correct admin photos router base for serving images
url: `/admin/photos/${eventId}/photo/${photo.id}`,
// Always expose a thumbnail URL; backend will generate on demand if missing
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
type: photo.type,
category_id: photo.type,
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
@@ -719,8 +722,9 @@ router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => {
return res.status(404).json({ error: 'Photo not found' });
}
const storagePath = getStoragePath();
const filePath = path.join(storagePath, 'events/active', photo.path);
const { resolvePhotoFilePath } = require('../services/photoResolver');
const event = await db('events').where('id', eventId).first();
const filePath = resolvePhotoFilePath(event, photo);
// Check if file exists
try {
@@ -802,4 +806,4 @@ router.get('/:eventId/debug', adminAuth, async (req, res) => {
}
});
module.exports = router;
module.exports = router;
@@ -0,0 +1,67 @@
const fs = require('fs').promises;
const path = require('path');
const { safePathJoin } = require('../utils/fileSecurityUtils');
function getExternalMediaRoot() {
return process.env.EXTERNAL_MEDIA_ROOT || '/external-media';
}
function isUnderRoot(p) {
const root = path.resolve(getExternalMediaRoot());
const resolved = path.resolve(p);
return resolved === root || resolved.startsWith(root + path.sep);
}
async function list(relativePath = '') {
const root = getExternalMediaRoot();
// Normalize and ensure safe join under root
const targetDir = safePathJoin(root, relativePath || '.');
const entries = [];
try {
const dirents = await fs.readdir(targetDir, { withFileTypes: true });
for (const d of dirents) {
// Skip hidden files and directories
if (d.name.startsWith('.')) continue;
const full = path.join(targetDir, d.name);
const stat = await fs.stat(full).catch(() => null);
if (!stat) continue;
if (d.isDirectory()) {
entries.push({ name: d.name, type: 'dir' });
} else if (d.isFile()) {
const ext = path.extname(d.name).toLowerCase();
if (['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) {
entries.push({ name: d.name, type: 'file', size: stat.size, mtime: stat.mtime });
}
}
}
} catch (e) {
// Propagate errors for caller to handle (e.g., invalid path)
throw e;
}
const rootResolved = path.resolve(root);
const currentResolved = path.resolve(targetDir);
const canNavigateUp = currentResolved !== rootResolved;
// Return normalized relative path from root
const relFromRoot = path.relative(rootResolved, currentResolved);
return { path: relFromRoot, entries, canNavigateUp };
}
function resolveExternalPath(event, relpath) {
const root = getExternalMediaRoot();
const base = event?.external_path ? path.join(event.external_path) : '';
const combined = base ? path.join(base, relpath || '') : (relpath || '');
return safePathJoin(root, combined);
}
module.exports = {
getExternalMediaRoot,
isUnderRoot,
list,
resolveExternalPath,
};
+14 -3
View File
@@ -132,7 +132,8 @@ async function generateThumbnail(imagePath, options = {}) {
return path.relative(getStoragePath(), thumbnailPath);
} catch (error) {
logger.error(`Failed to generate thumbnail for ${filename}:`, error.message);
const msg = (error && error.message) ? error.message : String(error);
logger.error(`Failed to generate thumbnail for ${filename}: ${msg}`);
// Clean up any partially created file
try {
@@ -171,8 +172,18 @@ async function isThumbnailValid(thumbnailPath) {
* Regenerate thumbnail if it's broken or missing
*/
async function ensureThumbnail(photo) {
const storagePath = getStoragePath();
const originalPath = path.join(storagePath, 'events/active', photo.path);
const { db } = require('../database/db');
const { resolvePhotoFilePath } = require('./photoResolver');
let originalPath;
try {
const event = await db('events').where('id', photo.event_id).first();
originalPath = resolvePhotoFilePath(event, photo);
logger.info(`Ensuring thumbnail for photo ${photo.id} from source: ${originalPath}`);
} catch (e) {
const msg = (e && e.message) ? e.message : String(e);
logger.error(`Failed to resolve original path for thumbnail (photo ${photo.id}): ${msg}`);
return null;
}
// Check if thumbnail exists and is valid
if (photo.thumbnail_path) {
+44
View File
@@ -0,0 +1,44 @@
const path = require('path');
const { resolveExternalPath } = require('./externalMediaService');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
/**
* Resolve absolute photo file path based on event + photo origin
* Managed: storage/events/active + photo.path (legacy variants supported)
* External reference: EXTERNAL_MEDIA_ROOT + event.external_path + photo.external_relpath
*/
function resolvePhotoFilePath(event, photo) {
if (!event || !photo) throw new Error('resolvePhotoFilePath requires event and photo');
const mode = (event.source_mode || photo.source_origin || 'managed');
if (mode === 'reference' || photo.source_origin === 'external') {
if (!photo.external_relpath) {
throw new Error('Missing external_relpath for external photo');
}
// Normalize duplicate leaf segments (e.g., event.external_path ends with 'individual'
// and external_relpath starts with 'individual/') to avoid double segment like
// '/external-media/.../individual/individual/file.jpg'
let rel = photo.external_relpath;
try {
const lastSeg = path.basename(event.external_path || '');
const firstSeg = rel.split(path.sep)[0];
if (lastSeg && firstSeg && lastSeg === firstSeg) {
rel = rel.split(path.sep).slice(1).join(path.sep) || '';
}
} catch (_) {
// ignore normalization errors
}
return resolveExternalPath(event, rel);
}
const storagePath = getStoragePath();
if (photo.path && photo.path.startsWith('events/active/')) {
return path.join(storagePath, photo.path);
}
return path.join(storagePath, 'events/active', photo.path || '');
}
module.exports = {
resolvePhotoFilePath,
};