serve project files by token hash, preview images inline and download other files
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user