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
@@ -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,
};