add detach files feature, utf8 filename fix, and error pages
This commit is contained in:
@@ -25,6 +25,14 @@ function requireAdmin(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
function fixFilename(str) {
|
||||
try {
|
||||
return Buffer.from(str, 'latin1').toString('utf8');
|
||||
} catch {
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (_, __, cb) => {
|
||||
@@ -32,12 +40,14 @@ const upload = multer({
|
||||
cb(null, 'uploads');
|
||||
},
|
||||
filename: (_, file, cb) => {
|
||||
const ext = path.extname(file.originalname) || '.jpg';
|
||||
const original = fixFilename(file.originalname);
|
||||
const ext = path.extname(original) || '.jpg';
|
||||
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
file.originalname = fixFilename(file.originalname);
|
||||
if (file.fieldname === 'photo' && !file.mimetype.startsWith('image/')) cb(new Error('Only images'));
|
||||
else cb(null, true);
|
||||
},
|
||||
@@ -609,7 +619,7 @@ app.get('/api/entries', requireAdmin, async (req, res) => {
|
||||
if (rows.length) {
|
||||
const ids = rows.map(r => r.id);
|
||||
const fRes = await pool.query(
|
||||
'SELECT entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id',
|
||||
'SELECT id, entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id',
|
||||
[ids]
|
||||
);
|
||||
files = {};
|
||||
@@ -623,7 +633,7 @@ app.get('/api/entries', requireAdmin, async (req, res) => {
|
||||
|
||||
app.get('/api/entries/:id/files', requireAdmin, async (req, res) => {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT token, name FROM project_files WHERE entry_id = $1 ORDER BY id',
|
||||
'SELECT id, token, name FROM project_files WHERE entry_id = $1 ORDER BY id',
|
||||
[req.params.id]
|
||||
);
|
||||
res.json(rows);
|
||||
@@ -668,6 +678,51 @@ app.get('/api/files', requireAdmin, async (req, res) => {
|
||||
res.json({ files, total });
|
||||
});
|
||||
|
||||
app.get('/api/files/detached', requireAdmin, async (req, res) => {
|
||||
const { search, limit, offset } = req.query;
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
conditions.push('entry_id IS NULL');
|
||||
if (search) { params.push(`%${search}%`); conditions.push(`name ILIKE $${params.length}`); }
|
||||
const where = conditions.join(' AND ');
|
||||
const { rows: crows } = await pool.query(
|
||||
`SELECT count(*)::int AS n FROM project_files WHERE ${where}`,
|
||||
params
|
||||
);
|
||||
const total = crows[0].n;
|
||||
let q = `SELECT id, token, name, path, created_at FROM project_files
|
||||
WHERE ${where} ORDER BY created_at DESC`;
|
||||
const qparams = params.slice();
|
||||
const lim = parseInt(limit, 10);
|
||||
if (lim > 0) { qparams.push(lim); q += ` LIMIT $${qparams.length}`; }
|
||||
const off = parseInt(offset, 10);
|
||||
if (off > 0) { qparams.push(off); q += ` OFFSET $${qparams.length}`; }
|
||||
const { rows } = await pool.query(q, qparams);
|
||||
const files = rows.map(r => {
|
||||
let size = 0;
|
||||
try { size = fs.statSync(path.join(__dirname, r.path)).size; } catch {}
|
||||
return { id: r.id, token: r.token, name: r.name, created_at: r.created_at, size };
|
||||
});
|
||||
res.json({ files, total });
|
||||
});
|
||||
|
||||
app.post('/api/files/:id/detach', requireAdmin, async (req, res) => {
|
||||
const { rows } = await pool.query(
|
||||
'UPDATE project_files SET entry_id = NULL, detached_at = now() WHERE id = $1 RETURNING *',
|
||||
[req.params.id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.delete('/api/files/:id', requireAdmin, async (req, res) => {
|
||||
const { rows } = await pool.query('SELECT path FROM project_files WHERE id = $1', [req.params.id]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||||
safeUnlink(rows[0].path);
|
||||
await pool.query('DELETE FROM project_files WHERE id = $1', [req.params.id]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/files/:token', async (req, res) => {
|
||||
const { rows } = await pool.query('SELECT path, name FROM project_files WHERE token = $1', [req.params.token]);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Not found' });
|
||||
@@ -866,7 +921,7 @@ app.get('/api/trash', requireAdmin, async (req, res) => {
|
||||
let files = {};
|
||||
if (rows.length) {
|
||||
const fRes = await pool.query(
|
||||
'SELECT entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id',
|
||||
'SELECT id, entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id',
|
||||
[rows.map(r => r.id)]
|
||||
);
|
||||
fRes.rows.forEach(f => { (files[f.entry_id] = files[f.entry_id] || []).push(f); });
|
||||
@@ -887,6 +942,43 @@ app.delete('/api/trash', requireAdmin, async (req, res) => {
|
||||
res.json({ ok: true, deleted: d.rowCount });
|
||||
});
|
||||
|
||||
// --- Error handlers ---
|
||||
const ERROR_HTML = fs.readFileSync(path.join(__dirname, 'public', 'error.html'), 'utf8');
|
||||
|
||||
function isApiRoute(req) {
|
||||
return req.path.startsWith('/api/') || req.path.startsWith('/s/');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function renderErrorPage(code, title, message, details) {
|
||||
return ERROR_HTML
|
||||
.replace('id="errorCode">404', `id="errorCode">${code}`)
|
||||
.replace('id="errorTitle">Страница не найдена', `id="errorTitle">${title}`)
|
||||
.replace('id="errorMessage">Запрашиваемая страница не существует или была перемещена.', `id="errorMessage">${message}`)
|
||||
.replace('style="display:none"', details ? '' : 'style="display:none"')
|
||||
.replace('<pre id="errorStack"></pre>', details ? `<pre id="errorStack">${escapeHtml(details)}</pre>` : '<pre id="errorStack"></pre>');
|
||||
}
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (isApiRoute(req)) {
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
res.status(404).send(renderErrorPage(404, 'Страница не найдена', 'Запрашиваемая страница не существует или была перемещена.'));
|
||||
});
|
||||
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Error:', err);
|
||||
if (isApiRoute(req)) {
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
const msg = process.env.NODE_ENV === 'production' ? 'Произошла ошибка на сервере.' : (err?.message || 'Internal server error');
|
||||
const details = process.env.NODE_ENV === 'production' ? '' : (err?.stack || '');
|
||||
res.status(500).send(renderErrorPage(500, 'Ошибка сервера', msg, details));
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const HTTPS_PORT = process.env.HTTPS_PORT || 3443;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user