photos phase 3
All checks were successful
Build and Push Docker Image / build (push) Successful in 35s
All checks were successful
Build and Push Docker Image / build (push) Successful in 35s
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user