From bd396195a85b495563c4343779455c1f5a758dce Mon Sep 17 00:00:00 2001 From: dev Date: Sun, 6 Sep 2026 11:33:33 +0300 Subject: [PATCH] serve project files by token hash, preview images inline and download other files --- db/init.sql | 1 + db/migration.sql | 15 ++++++++++++++- public/admin.css | 7 ++++++- public/journal.html | 8 +++++++- server.js | 27 +++++++++++++++++---------- 5 files changed, 45 insertions(+), 13 deletions(-) diff --git a/db/init.sql b/db/init.sql index 2486c39..b6c0d80 100644 --- a/db/init.sql +++ b/db/init.sql @@ -48,6 +48,7 @@ ON CONFLICT (name) DO NOTHING; CREATE TABLE IF NOT EXISTS project_files ( id SERIAL PRIMARY KEY, entry_id INT NOT NULL 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() diff --git a/db/migration.sql b/db/migration.sql index 495932a..0a78d76 100644 --- a/db/migration.sql +++ b/db/migration.sql @@ -30,4 +30,17 @@ BEGIN INSERT INTO students (name) SELECT DISTINCT student_name FROM entries WHERE student_name IS NOT NULL AND student_name <> '' ON CONFLICT (name) DO NOTHING; -END $$; \ No newline at end of file +END $$; + +CREATE TABLE IF NOT EXISTS project_files ( + id SERIAL PRIMARY KEY, + entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, + token VARCHAR(64), + path VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); + +UPDATE project_files SET token = md5(random()::text || clock_timestamp()::text) WHERE token IS NULL; +ALTER TABLE project_files ALTER COLUMN token SET NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS project_files_token_key ON project_files(token); \ No newline at end of file diff --git a/public/admin.css b/public/admin.css index 3f6f1ca..0240317 100644 --- a/public/admin.css +++ b/public/admin.css @@ -45,10 +45,15 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b .card .group{color:var(--accent);font-size:.8rem;margin-top:2px} .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{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;align-items:center} .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:'📎'} +.entry-files a.f-link{background:var(--bg);border:1px solid var(--border)} +.entry-files a.f-img{padding:2px;background:var(--card);border:1px solid var(--border)} +.entry-files a.f-img::before{content:none} +.entry-files a.f-img img{display:block;width:56px;height:42px;object-fit:cover;border-radius:4px} +.entry-files a.f-img:hover{border-color:var(--accent)} .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/journal.html b/public/journal.html index 2b9bbf5..24bb0a3 100644 --- a/public/journal.html +++ b/public/journal.html @@ -107,10 +107,16 @@ let totalEntries = 0; let searchTimer = null; let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list'; +function isImageFile(name) { + return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || ''); +} + function filesHTML(files) { if (!files || !files.length) return ''; return `
${files.map(f => - `${esc(f.name)}` + isImageFile(f.name) + ? `${esc(f.name)}` + : `${esc(f.name)}` ).join('')}
`; } diff --git a/server.js b/server.js index 345d418..aa1d213 100644 --- a/server.js +++ b/server.js @@ -194,8 +194,8 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req } for (const x of data.project_files || []) { await client.query( - 'INSERT INTO project_files (id, entry_id, path, name, created_at) VALUES ($1,$2,$3,$4,$5)', - [x.id, x.entry_id, x.path, x.name, x.created_at] + 'INSERT INTO project_files (id, entry_id, token, path, name, created_at) VALUES ($1,$2,$3,$4,$5,$6)', + [x.id, x.entry_id, x.token, x.path, x.name, x.created_at] ); } for (const [k, v] of Object.entries(data.settings || {})) { @@ -481,7 +481,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 id, entry_id, path, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id', + 'SELECT entry_id, token, name FROM project_files WHERE entry_id = ANY($1) ORDER BY id', [ids] ); files = {}; @@ -495,18 +495,24 @@ app.get('/api/entries', requireAdmin, async (req, res) => { app.get('/api/entries/:id/files', requireAdmin, async (req, res) => { const { rows } = await pool.query( - 'SELECT id, path, name FROM project_files WHERE entry_id = $1 ORDER BY id', + 'SELECT token, name FROM project_files WHERE entry_id = $1 ORDER BY id', [req.params.id] ); res.json(rows); }); -app.get('/api/files/:id', requireAdmin, async (req, res) => { - const { rows } = await pool.query('SELECT path, name FROM project_files WHERE id = $1', [req.params.id]); +function isImageName(name) { + return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || ''); +} + +app.get('/api/files/:token', requireAdmin, 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' }); - const fp = path.join(__dirname, rows[0].path); + const r = rows[0]; + const fp = path.join(__dirname, r.path); if (!fs.existsSync(fp)) return res.status(404).json({ error: 'File missing' }); - res.download(fp, rows[0].name); + if (isImageName(r.name)) res.sendFile(fp); + else res.download(fp, r.name); }); app.get('/api/stats', requireAdmin, async (_, res) => { @@ -559,9 +565,10 @@ app.post('/api/entries', upload.fields([{ name: 'photo', maxCount: 1 }, { name: [student_name.trim(), group_id, description.trim(), photo_path] ); for (const f of projectFiles) { + const token = crypto.randomBytes(16).toString('hex'); await client.query( - 'INSERT INTO project_files (entry_id, path, name) VALUES ($1, $2, $3)', - [rows[0].id, `/uploads/${f.filename}`, f.originalname] + 'INSERT INTO project_files (entry_id, token, path, name) VALUES ($1, $2, $3, $4)', + [rows[0].id, token, `/uploads/${f.filename}`, f.originalname] ); } await client.query('COMMIT');