From 686d82319ea26f6e436fdb6db3eadc60119a6ea0 Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 7 Sep 2026 09:42:28 +0300 Subject: [PATCH] add detach files feature, utf8 filename fix, and error pages --- db/init.sql | 5 ++- db/migration.sql | 3 ++ public/admin.css | 11 +++++ public/error.html | 57 +++++++++++++++++++++++++ public/files.html | 74 ++++++++++++++++++++++++++------ public/journal.html | 31 ++++++++++++-- server.js | 100 ++++++++++++++++++++++++++++++++++++++++++-- 7 files changed, 258 insertions(+), 23 deletions(-) create mode 100644 public/error.html diff --git a/db/init.sql b/db/init.sql index de932ac..5364117 100644 --- a/db/init.sql +++ b/db/init.sql @@ -59,9 +59,10 @@ CREATE INDEX IF NOT EXISTS idx_group_photos_group_id ON group_photos(group_id); CREATE TABLE IF NOT EXISTS project_files ( id SERIAL PRIMARY KEY, - entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, + entry_id INT REFERENCES entries(id) ON DELETE CASCADE, token VARCHAR(64) UNIQUE NOT NULL, path VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, - created_at TIMESTAMPTZ DEFAULT now() + created_at TIMESTAMPTZ DEFAULT now(), + detached_at TIMESTAMPTZ ); diff --git a/db/migration.sql b/db/migration.sql index 4deb86f..826c82e 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -45,6 +45,9 @@ UPDATE project_files SET token = md5(random()::text || clock_timestamp()::text) ALTER TABLE project_files ALTER COLUMN token SET NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS project_files_token_key ON project_files(token); +ALTER TABLE project_files ALTER COLUMN entry_id DROP NOT NULL; +ALTER TABLE project_files ADD COLUMN IF NOT EXISTS detached_at TIMESTAMPTZ; + ALTER TABLE entries ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; CREATE TABLE IF NOT EXISTS group_photos ( diff --git a/public/admin.css b/public/admin.css index 03a94dc..8573f75 100644 --- a/public/admin.css +++ b/public/admin.css @@ -46,9 +46,20 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b .card .desc{color:var(--muted);font-size:.85rem;margin-top:8px;line-height:1.4;white-space:pre-wrap} .card .time{color:var(--muted);font-size:.75rem;margin-top:8px} .entry-files{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px} +.entry-files .f-item{display:inline-flex;align-items:center;gap:2px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:2px} +.entry-files .f-item:hover{border-color:var(--accent)} +.entry-files .f-item a{display:inline-flex;align-items:center;gap:4px;color:var(--text);text-decoration:none;padding:2px 6px;font-size:.75rem;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.entry-files .f-item a:hover{color:var(--accent)} +.entry-files .f-item a::before{content:'📎'} +.entry-files .f-detach{background:none;border:none;color:var(--muted);cursor:pointer;font-size:.75rem;line-height:1;padding:2px 5px;border-radius:4px} +.entry-files .f-detach:hover{color:#ef4444;background:rgba(239,68,68,.1)} .entry-files a{display:inline-flex;align-items:center;gap:4px;background:var(--bg);border:1px solid var(--border);color:var(--text);text-decoration:none;border-radius:6px;padding:4px 8px;font-size:.75rem;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .entry-files a:hover{border-color:var(--accent);color:var(--accent)} .entry-files a::before{content:'📎'} +.f-actions{display:flex;align-items:center;flex-shrink:0} +.f-action{background:none;border:1px solid var(--border);color:var(--muted);cursor:pointer;border-radius:8px;padding:6px 10px;font-size:.75rem;white-space:nowrap} +.f-action:hover{color:var(--accent);border-color:var(--accent)} +.f-action.f-del:hover{color:#ef4444;border-color:#ef4444} .card .del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px;flex-shrink:0} .card .del:hover{color:#ef4444} .card .edit{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.05rem;padding:4px;flex-shrink:0} diff --git a/public/error.html b/public/error.html new file mode 100644 index 0000000..fe967f7 --- /dev/null +++ b/public/error.html @@ -0,0 +1,57 @@ + + + + + +Ошибка — WhatIDo + + + + +
+
+
404
+

Страница не найдена

+

Запрашиваемая страница не существует или была перемещена.

+
+На главную + +
+ +
+
+ + + \ No newline at end of file diff --git a/public/files.html b/public/files.html index 9b26494..10e567e 100644 --- a/public/files.html +++ b/public/files.html @@ -23,6 +23,12 @@
+
+
+ + +
+
Загрузка...
@@ -43,6 +49,20 @@ let currentFiles = []; let groupsLoaded = false; let studentsLoaded = false; let searchTimer = null; +let mode = 'attached'; + +function setMode(m) { + mode = m === 'detached' ? 'detached' : 'attached'; + document.querySelectorAll('#tabToggle button').forEach(b => { + b.classList.toggle('active', b.dataset.mode === mode); + }); + const show = mode === 'attached'; + const keep = ['searchInput', 'studentFilter', 'groupFilter', 'dateFrom', 'dateTo'].map(id => document.getElementById(id)); + if (!show) { keep.forEach(el => { el.value = ''; }); } + keep.forEach(el => { el.parentElement.style.display = show ? '' : 'none'; }); + page = 1; + loadFiles(); +} function isImageFile(name) { return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || ''); @@ -63,14 +83,21 @@ function fileCardHTML(f) { const target = isImageFile(f.name) ? `href="#" onclick="showImg('${url}');return false;"` : `href="${url}" download`; + const meta = mode === 'detached' + ? `
Откреплён от записи, не привязан
` + : `
${esc(f.student_name)} · ${esc(f.group_name)}
`; + const action = mode === 'detached' + ? `` + : ``; return `
${thumb}
-
${esc(f.student_name)} · ${esc(f.group_name)}
+ ${meta}
${new Date(f.created_at).toLocaleString('ru')}${f.size ? ' · ' + humanSize(f.size) : ''}
+
${action}
`; } @@ -79,13 +106,29 @@ function renderGrid() { const grid = document.getElementById('grid'); if (!currentFiles.length) { grid.className = 'grid'; - grid.innerHTML = '
Файлов не найдено
'; + grid.innerHTML = mode === 'detached' + ? '
Откреплённых файлов нет
' + : '
Файлов не найдено
'; return; } grid.className = 'grid'; grid.innerHTML = currentFiles.map(fileCardHTML).join(''); } +async function detachFile(id) { + if (!confirm('Открепить файл от записи? Он переместится во вкладку «Откреплённые».')) return; + const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() }); + if (res.ok) { showToast('Файл откреплён'); loadFiles(); } + else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); } +} + +async function deleteFile(id) { + if (!confirm('Удалить файл навсегда? Отменить это действие нельзя.')) return; + const res = await fetch(`${API}/api/files/${id}`, { method: 'DELETE', headers: hdr() }); + if (res.ok) { showToast('Файл удалён'); loadFiles(); } + else { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); } +} + async function loadGroupOptions() { if (groupsLoaded) return; const sel = document.getElementById('groupFilter'); @@ -111,19 +154,21 @@ async function loadStudentOptions() { async function loadFiles() { const p = new URLSearchParams(); - const q = document.getElementById('searchInput').value; - const sn = document.getElementById('studentFilter').value; - const gid = document.getElementById('groupFilter').value; - const df = document.getElementById('dateFrom').value; - const dt = document.getElementById('dateTo').value; - if (q) p.set('search', q); - if (sn) p.set('student_name', sn); - if (gid) p.set('group_id', gid); - if (df) p.set('date_from', df); - if (dt) p.set('date_to', dt); + if (mode === 'attached') { + const q = document.getElementById('searchInput').value; + const sn = document.getElementById('studentFilter').value; + const gid = document.getElementById('groupFilter').value; + const df = document.getElementById('dateFrom').value; + const dt = document.getElementById('dateTo').value; + if (q) p.set('search', q); + if (sn) p.set('student_name', sn); + if (gid) p.set('group_id', gid); + if (df) p.set('date_from', df); + if (dt) p.set('date_to', dt); + } p.set('limit', PAGE_SIZE); p.set('offset', (page - 1) * PAGE_SIZE); - const res = await fetch(`${API}/api/files?${p}`, { headers: hdr() }); + const res = await fetch(`${API}/api/files${mode === 'detached' ? '/detached' : ''}?${p}`, { headers: hdr() }); const data = await res.json(); currentFiles = data.files; totalFiles = data.total; @@ -148,6 +193,9 @@ document.getElementById('searchInput').addEventListener('input', () => { (async () => { if (await checkAuth()) { buildSidebar(document.body.dataset.page); + document.querySelectorAll('#tabToggle button').forEach(b => { + b.classList.toggle('active', b.dataset.mode === mode); + }); loadGroupOptions(); loadStudentOptions(); loadFiles(); diff --git a/public/journal.html b/public/journal.html index 4edbbab..c85f74d 100644 --- a/public/journal.html +++ b/public/journal.html @@ -113,11 +113,34 @@ function isImageFile(name) { function filesHTML(files) { if (!files || !files.length) return ''; - return `
${files.map(f => - isImageFile(f.name) + return `
${files.map(f => { + const link = isImageFile(f.name) ? `${esc(f.name)}` - : `${esc(f.name)}` - ).join('')}
`; + : `${esc(f.name)}`; + return `${link}`; + }).join('')}
`; +} + +function findFile(id) { + for (const e of currentEntries) { + const f = (e.files || []).find(x => x.id === id); + if (f) return f; + } + return null; +} + +async function detachFile(id) { + const f = findFile(id); + if (!f) return; + if (!confirm(`Открепить файл «${f.name}» от записи? Он переместится в «Файлы → Откреплённые».`)) return; + const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() }); + if (res.ok) { + showToast('Файл откреплён'); + loadEntries(); + } else { + const err = await res.json().catch(() => ({})); + alert(err.error || 'Ошибка'); + } } function gridEntryHTML(e) { diff --git a/server.js b/server.js index 460d726..9620508 100644 --- a/server.js +++ b/server.js @@ -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, '''); +} + +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('
', details ? `
${escapeHtml(details)}
` : '
');
+}
+
+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;