serve project files by token hash, preview images inline and download other files

This commit is contained in:
dev
2026-09-06 11:33:33 +03:00
parent 034c92b160
commit bd396195a8
5 changed files with 45 additions and 13 deletions
+1
View File
@@ -48,6 +48,7 @@ ON CONFLICT (name) DO NOTHING;
CREATE TABLE IF NOT EXISTS project_files ( CREATE TABLE IF NOT EXISTS project_files (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, entry_id INT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
token VARCHAR(64) UNIQUE NOT NULL,
path VARCHAR(255) NOT NULL, path VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now() created_at TIMESTAMPTZ DEFAULT now()
+13
View File
@@ -31,3 +31,16 @@ BEGIN
SELECT DISTINCT student_name FROM entries WHERE student_name IS NOT NULL AND student_name <> '' SELECT DISTINCT student_name FROM entries WHERE student_name IS NOT NULL AND student_name <> ''
ON CONFLICT (name) DO NOTHING; ON CONFLICT (name) DO NOTHING;
END $$; 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);
+6 -1
View File
@@ -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 .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 .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} .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{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:hover{border-color:var(--accent);color:var(--accent)}
.entry-files a::before{content:'📎'} .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{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px;flex-shrink:0}
.card .del:hover{color:#ef4444} .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} .card .edit{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.05rem;padding:4px;flex-shrink:0}
+7 -1
View File
@@ -107,10 +107,16 @@ let totalEntries = 0;
let searchTimer = null; let searchTimer = null;
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list'; 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) { function filesHTML(files) {
if (!files || !files.length) return ''; if (!files || !files.length) return '';
return `<div class="entry-files">${files.map(f => return `<div class="entry-files">${files.map(f =>
`<a href="${API}/api/files/${f.id}" download>${esc(f.name)}</a>` isImageFile(f.name)
? `<a class="f-img" href="${API}/api/files/${f.token}" target="_blank" title="${esc(f.name)}"><img src="${API}/api/files/${f.token}" alt="${esc(f.name)}"></a>`
: `<a class="f-link" href="${API}/api/files/${f.token}" download title="${esc(f.name)}"><span>${esc(f.name)}</span></a>`
).join('')}</div>`; ).join('')}</div>`;
} }
+17 -10
View File
@@ -194,8 +194,8 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
} }
for (const x of data.project_files || []) { for (const x of data.project_files || []) {
await client.query( await client.query(
'INSERT INTO project_files (id, entry_id, path, name, created_at) VALUES ($1,$2,$3,$4,$5)', '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.path, x.name, x.created_at] [x.id, x.entry_id, x.token, x.path, x.name, x.created_at]
); );
} }
for (const [k, v] of Object.entries(data.settings || {})) { for (const [k, v] of Object.entries(data.settings || {})) {
@@ -481,7 +481,7 @@ app.get('/api/entries', requireAdmin, async (req, res) => {
if (rows.length) { if (rows.length) {
const ids = rows.map(r => r.id); const ids = rows.map(r => r.id);
const fRes = await pool.query( 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] [ids]
); );
files = {}; files = {};
@@ -495,18 +495,24 @@ app.get('/api/entries', requireAdmin, async (req, res) => {
app.get('/api/entries/:id/files', requireAdmin, async (req, res) => { app.get('/api/entries/:id/files', requireAdmin, async (req, res) => {
const { rows } = await pool.query( 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] [req.params.id]
); );
res.json(rows); res.json(rows);
}); });
app.get('/api/files/:id', requireAdmin, async (req, res) => { function isImageName(name) {
const { rows } = await pool.query('SELECT path, name FROM project_files WHERE id = $1', [req.params.id]); 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' }); 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' }); 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) => { 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] [student_name.trim(), group_id, description.trim(), photo_path]
); );
for (const f of projectFiles) { for (const f of projectFiles) {
const token = crypto.randomBytes(16).toString('hex');
await client.query( await client.query(
'INSERT INTO project_files (entry_id, path, name) VALUES ($1, $2, $3)', 'INSERT INTO project_files (entry_id, token, path, name) VALUES ($1, $2, $3, $4)',
[rows[0].id, `/uploads/${f.filename}`, f.originalname] [rows[0].id, token, `/uploads/${f.filename}`, f.originalname]
); );
} }
await client.query('COMMIT'); await client.query('COMMIT');