+ {/* Drag overlay */}
+
+ {isDragging && (
+
+
+
+
Drop photos to upload
+
+
+ )}
+
+
+ {/* ── Header ───────────────────────────────────────────────────────── */}
+
+
+
+
+
Photos
+ {data?.configured && (
+
+ {data.count} photo{data.count !== 1 ? 's' : ''}
+
+ )}
+
+
+ {configured && (
+
+ )}
+
+
+ {/* Hidden file input */}
+
+
+ {/* ── Upload progress banner ────────────────────────────────────────── */}
+
+ {uploadOpen && uploadQueue.length > 0 && (
+
+
+
+ {uploadMutation.isPending ? (
+ <>
+
+
+ Uploading {uploadQueue.length} photo{uploadQueue.length !== 1 ? 's' : ''}…
+
+ >
+ ) : errorCount > 0 ? (
+ <>
+
+
+ {errorCount} upload{errorCount !== 1 ? 's' : ''} failed
+ {doneCount > 0 && ` · ${doneCount} succeeded`}
+
+ >
+ ) : (
+ <>
+
+
+ {doneCount} photo{doneCount !== 1 ? 's' : ''} uploaded successfully
+
+ >
+ )}
+
+
+
+
+ {/* Per-file list — shown while pending or on error */}
+ {(uploadMutation.isPending || errorCount > 0) && (
+
+ {uploadQueue.map((s, i) => (
+ -
+ {s.state === 'pending' && (
+
+ )}
+ {s.state === 'done' && }
+ {s.state === 'error' && }
+ {s.file.name}
+ ({formatBytes(s.file.size)})
+ {s.message && {s.message}}
+
+ ))}
+
+ )}
+
+ )}
+
+
+ {/* ── Content ───────────────────────────────────────────────────────── */}
+
+ {isLoading ? (
+
+ {Array.from({ length: 12 }).map((_, i) => (
+
+ ))}
+
+ ) : !data?.configured ? (
+ /* ── Not configured ─────────────────────────────────────────── */
+
+
📂
+
Photo folder not configured
+
+ Set a photo folder path in Settings, then come back to upload and manage your photos.
+
+
+
Go to Settings
+
+
+ ) : photos.length === 0 ? (
+ /* ── Empty — drop zone ──────────────────────────────────────── */
+
+
+
+ ) : (
+ /* ── Photo grid ─────────────────────────────────────────────── */
+
+
+ Drag & drop images anywhere on this page, or use the Upload button to add more photos.
+
+
+
+ {photos.map((photo, index) => (
+ setLightboxIndex(index)}
+ >
+
+ {/* Hover overlay */}
+
+ {/* Delete button */}
+
+ {/* Filename on hover */}
+
+
+ ))}
+
+
+
+ )}
+
+
+ {/* ── Lightbox ──────────────────────────────────────────────────────── */}
+
+ {lightboxIndex !== null && photos[lightboxIndex] && (
+ setLightboxIndex(null)}
+ onKeyDown={handleLightboxKey}
+ tabIndex={-1}
+ ref={(el) => el?.focus()}
+ >
+ {/* Close */}
+
+
+ {/* Counter */}
+
+ {lightboxIndex + 1} / {photos.length}
+
+
+ {/* Prev */}
+ {photos.length > 1 && (
+
+ )}
+
+ {/* Image */}
+ e.stopPropagation()}
+ />
+
+ {/* Next */}
+ {photos.length > 1 && (
+
+ )}
+
+ {/* Filename */}
+
+ {photos[lightboxIndex].name}
+
+
+ )}
+
+
+ {/* ── Delete confirmation ────────────────────────────────────────────── */}
+
+ {deleteTarget && (
+ setDeleteTarget(null)}
+ >
+ e.stopPropagation()}
+ >
+ Delete photo?
+
+ {deleteTarget.name} will be permanently
+ deleted from your photo folder. This cannot be undone.
+
+
+
+
+
+
+
+ )}
+
+
+ );
}
diff --git a/apps/server/src/routes/photos.ts b/apps/server/src/routes/photos.ts
index 3b5d2f0..85cf81c 100644
--- a/apps/server/src/routes/photos.ts
+++ b/apps/server/src/routes/photos.ts
@@ -2,6 +2,7 @@ import { Router } from 'express';
import db from '../db/db';
import fs from 'fs';
import path from 'path';
+import multer from 'multer';
const router = Router();
@@ -24,38 +25,133 @@ function scanDir(dir: string): string[] {
}
// PHOTOS_DIR env var (set in Docker) overrides the DB setting.
-// This lets Unraid users bind-mount their photo library to /photos
-// without having to change the settings in the UI.
function resolvePhotoFolder(): string {
if (process.env.PHOTOS_DIR) return process.env.PHOTOS_DIR;
const row = db.prepare("SELECT value FROM settings WHERE key = 'photo_folder'").get() as any;
return row?.value ?? '';
}
-router.get('/', (_req, res) => {
- const folder = resolvePhotoFolder();
- const files = scanDir(folder);
- res.json({ folder, count: files.length, files: files.map((f) => path.basename(f)) });
+// Resolve and validate a relative path inside the photo folder.
+// rawRel may be percent-encoded (e.g. %2F for subdirectory slashes).
+// Returns the absolute filepath or null if it would escape the folder.
+function resolveFilePath(folder: string, rawRel: string): string | null {
+ let rel: string;
+ try { rel = decodeURIComponent(rawRel); } catch { rel = rawRel; }
+ rel = rel.replace(/\\/g, '/');
+ // Reject traversal attempts
+ if (rel.split('/').some((p) => p === '..')) return null;
+ const filepath = path.normalize(path.join(folder, rel));
+ const base = path.normalize(folder);
+ if (!filepath.startsWith(base + path.sep) && filepath !== base) return null;
+ return filepath;
+}
+
+// Multer storage — saves directly into the photo folder root
+const upload = multer({
+ storage: multer.diskStorage({
+ destination: (_req, _file, cb) => {
+ const folder = resolvePhotoFolder();
+ if (!folder) return cb(new Error('Photo folder not configured'), '');
+ try { fs.mkdirSync(folder, { recursive: true }); } catch { /* already exists */ }
+ cb(null, folder);
+ },
+ filename: (_req, file, cb) => {
+ const ext = path.extname(file.originalname).toLowerCase();
+ const base = path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9._-]/g, '_');
+ cb(null, `${base}_${Date.now()}${ext}`);
+ },
+ }),
+ limits: { fileSize: 50 * 1024 * 1024 }, // 50 MB per file
+ fileFilter: (_req, file, cb) => {
+ if (IMAGE_EXTS.has(path.extname(file.originalname).toLowerCase())) {
+ cb(null, true);
+ } else {
+ cb(new Error(`Unsupported file type: ${path.extname(file.originalname) || file.originalname}`));
+ }
+ },
});
-router.get('/file/:filename', (req, res) => {
+// ── List all photos (recursive) ───────────────────────────────────────────
+router.get('/', (_req, res) => {
+ const folder = resolvePhotoFolder();
+ const configured = !!folder;
+ if (!configured) return res.json({ configured: false, count: 0, photos: [] });
+
+ const norm = path.normalize(folder);
+ const files = scanDir(folder);
+ const photos = files.map((f) => {
+ const rel = path.relative(norm, f).replace(/\\/g, '/');
+ return { name: path.basename(f), rel, url: `/api/photos/file/${encodeURIComponent(rel)}` };
+ });
+ res.json({ configured: true, folder, count: photos.length, photos });
+});
+
+// ── Serve a photo by encoded relative path ────────────────────────────────
+// Express 4 wildcard: req.params[0] captures everything after /file/
+router.get('/file/*', (req, res) => {
const folder = resolvePhotoFolder();
if (!folder) return res.status(404).json({ error: 'Photo folder not configured' });
- // Security: prevent path traversal
- const filename = path.basename(req.params.filename);
- const filepath = path.join(folder, filename);
- if (!filepath.startsWith(folder)) return res.status(403).json({ error: 'Forbidden' });
+ const filepath = resolveFilePath(folder, (req.params as any)[0] ?? '');
+ if (!filepath) return res.status(403).json({ error: 'Forbidden' });
if (!fs.existsSync(filepath)) return res.status(404).json({ error: 'File not found' });
res.sendFile(filepath);
});
-// Return all photos as a flat list with their relative paths for the slideshow
+// ── Slideshow list ────────────────────────────────────────────────────────
router.get('/slideshow', (_req, res) => {
const folder = resolvePhotoFolder();
+ if (!folder) return res.json({ count: 0, photos: [] });
+
+ const norm = path.normalize(folder);
const files = scanDir(folder);
- const urls = files.map((f) => `/api/photos/file/${encodeURIComponent(path.relative(folder, f).replace(/\\/g, '/'))}`);
- res.json({ count: urls.length, urls });
+ const photos = files.map((f) => {
+ const rel = path.relative(norm, f).replace(/\\/g, '/');
+ return { name: path.basename(f), rel, url: `/api/photos/file/${encodeURIComponent(rel)}` };
+ });
+ res.json({ count: photos.length, photos });
+});
+
+// ── Batch upload ──────────────────────────────────────────────────────────
+router.post('/upload', (req, res) => {
+ const folder = resolvePhotoFolder();
+ if (!folder) {
+ return res.status(400).json({ error: 'Photo folder not configured. Set it in Settings first.' });
+ }
+
+ upload.array('photos', 200)(req, res, (err) => {
+ if (err instanceof multer.MulterError) {
+ return res.status(400).json({ error: `Upload error: ${err.message}` });
+ }
+ if (err) {
+ return res.status(400).json({ error: err.message });
+ }
+
+ const norm = path.normalize(folder);
+ const files = (req.files as Express.Multer.File[]) ?? [];
+ const uploaded = files.map((f) => {
+ const rel = path.relative(norm, f.path).replace(/\\/g, '/');
+ return { name: f.filename, rel, url: `/api/photos/file/${encodeURIComponent(rel)}` };
+ });
+ res.status(201).json({ count: uploaded.length, uploaded });
+ });
+});
+
+// ── Delete a photo ────────────────────────────────────────────────────────
+router.delete('/file/*', (req, res) => {
+ const folder = resolvePhotoFolder();
+ if (!folder) return res.status(404).json({ error: 'Photo folder not configured' });
+
+ const filepath = resolveFilePath(folder, (req.params as any)[0] ?? '');
+ if (!filepath) return res.status(403).json({ error: 'Forbidden' });
+ if (!fs.existsSync(filepath)) return res.status(404).json({ error: 'File not found' });
+
+ try {
+ fs.unlinkSync(filepath);
+ res.status(204).end();
+ } catch {
+ res.status(500).json({ error: 'Failed to delete file' });
+ }
});
export default router;