Add server-side image thumbnails (sharp): /uploads/thumb/<name> route, ?thumb=1 on file endpoints, disk-cached 480px WebP previews; grids now load thumbnails with lazy loading

This commit is contained in:
dev
2026-09-12 00:02:23 +03:00
parent 9798872f20
commit fd9b470c4c
11 changed files with 654 additions and 15 deletions
+54 -4
View File
@@ -114,6 +114,48 @@ app.use(helmet({
}
}));
app.use(express.json({ limit: '1mb' }));
const THUMBS_DIR = path.join(__dirname, 'uploads', '.thumbs');
const THUMB_WIDTH = 480;
let sharp = null;
try { sharp = require('sharp'); } catch {}
if (sharp) {
try { fs.mkdirSync(THUMBS_DIR, { recursive: true }); } catch {}
}
function thumbFileFor(fp) {
const base = path.basename(fp).replace(/\.[^.]+$/, '') + '.webp';
return path.join(THUMBS_DIR, base);
}
async function sendImageThumb(res, fp) {
if (!sharp) {
res.setHeader('Cache-Control', 'public, max-age=3600');
return res.sendFile(fp);
}
const tp = thumbFileFor(fp);
try {
if (!fs.existsSync(tp)) {
const tmp = tp + '.' + crypto.randomBytes(4).toString('hex') + '.tmp';
await sharp(fp).rotate().resize({ width: THUMB_WIDTH, withoutEnlargement: true }).webp({ quality: 72 }).toFile(tmp);
fs.renameSync(tmp, tp);
}
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
return res.sendFile(tp);
} catch {
try { if (fs.existsSync(tp)) fs.unlinkSync(tp); } catch {}
res.setHeader('Cache-Control', 'public, max-age=3600');
return res.sendFile(fp);
}
}
app.get('/uploads/thumb/:name', fileLimiter, async (req, res) => {
const name = req.params.name;
if (!/^[A-Za-z0-9._-]+$/.test(name)) return res.status(400).end();
const fp = path.join(__dirname, 'uploads', name);
if (!fs.existsSync(fp) || !fs.statSync(fp).isFile()) return res.status(404).end();
return sendImageThumb(res, fp);
});
app.use('/uploads', express.static(path.join(__dirname, 'uploads'), { maxAge: '365d', immutable: true }));
app.use('/vendor', express.static(path.join(__dirname, 'public', 'vendor'), { maxAge: '30d' }));
app.use(express.static(path.join(__dirname, 'public'), { maxAge: '1h' }));
@@ -1515,8 +1557,12 @@ app.get('/api/share/:shareToken/files/:fileToken', fileLimiter, async (req, res)
const r = rows[0];
const fp = path.join(__dirname, r.path);
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'File missing' });
if (isImageName(r.name)) res.sendFile(fp);
else res.download(fp, r.name);
if (isImageName(r.name)) {
if (req.query.thumb) return sendImageThumb(res, fp);
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
return res.sendFile(fp);
}
return res.download(fp, r.name);
});
app.get('/s/:token', (req, res) => {
@@ -2267,8 +2313,12 @@ app.get('/api/files/:token', fileLimiter, async (req, res) => {
const r = rows[0];
const fp = path.join(__dirname, r.path);
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'File missing' });
if (isImageName(r.name)) res.sendFile(fp);
else res.download(fp, r.name);
if (isImageName(r.name)) {
if (req.query.thumb) return sendImageThumb(res, fp);
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
return res.sendFile(fp);
}
return res.download(fp, r.name);
});
app.get('/api/stats', requireAuth, async (req, res) => {