photos phase 3
All checks were successful
Build and Push Docker Image / build (push) Successful in 35s

This commit is contained in:
jason
2026-03-30 10:27:50 -05:00
parent 155e7849e1
commit 5431177b7a
3 changed files with 605 additions and 16 deletions

View File

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