add share-link password/expiry, journal touch-ups, ddns helper and audit docs
This commit is contained in:
@@ -3,6 +3,7 @@ const { Pool, types } = require('pg');
|
||||
const multer = require('multer');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const helmet = require('helmet');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const https = require('https');
|
||||
const path = require('path');
|
||||
@@ -448,25 +449,71 @@ app.get('/api/links', requireAdmin, async (_, res) => {
|
||||
});
|
||||
|
||||
app.post('/api/links', requireAdmin, async (req, res) => {
|
||||
const { name, group_id, student_name, date_from, date_to } = req.body;
|
||||
const { name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||||
const token = crypto.randomBytes(20).toString('hex');
|
||||
let passwordHash = null;
|
||||
if (access_password && access_password.trim()) {
|
||||
passwordHash = await bcrypt.hash(access_password.trim(), 10);
|
||||
}
|
||||
let expiresAt = null;
|
||||
if (expires_at) {
|
||||
const parsed = new Date(expires_at);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
return res.status(400).json({ error: 'Неверный формат даты истечения' });
|
||||
}
|
||||
expiresAt = parsed.toISOString();
|
||||
} else {
|
||||
// Default 7 days from now
|
||||
const defaultExp = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
expiresAt = defaultExp.toISOString();
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO share_links (token, name, group_id, student_name, date_from, date_to)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||
[token, name.trim(), group_id || null, student_name || null, date_from || null, date_to || null]
|
||||
`INSERT INTO share_links (token, name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password_hash)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
|
||||
[token, name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, anonymize_names || false, expiresAt, passwordHash]
|
||||
);
|
||||
res.status(201).json(normDates(rows[0]));
|
||||
});
|
||||
|
||||
app.put('/api/links/:id', requireAdmin, async (req, res) => {
|
||||
const { name, group_id, student_name, date_from, date_to } = req.body;
|
||||
const { name, group_id, student_name, date_from, date_to, anonymize_names, expires_at, access_password } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
|
||||
let passwordHash = undefined;
|
||||
if (access_password !== undefined) {
|
||||
if (access_password && access_password.trim()) {
|
||||
passwordHash = await bcrypt.hash(access_password.trim(), 10);
|
||||
} else {
|
||||
passwordHash = null; // Clear password if empty string sent
|
||||
}
|
||||
}
|
||||
let expiresAt = undefined;
|
||||
if (expires_at !== undefined) {
|
||||
if (expires_at) {
|
||||
const parsed = new Date(expires_at);
|
||||
if (isNaN(parsed.getTime())) {
|
||||
return res.status(400).json({ error: 'Неверный формат даты истечения' });
|
||||
}
|
||||
expiresAt = parsed.toISOString();
|
||||
} else {
|
||||
expiresAt = null; // Clear expiry if null sent
|
||||
}
|
||||
}
|
||||
const fields = ['name = $1', 'group_id = $2', 'student_name = $3', 'date_from = $4', 'date_to = $5', 'anonymize_names = $6'];
|
||||
const values = [name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, anonymize_names || false];
|
||||
let paramIdx = 7;
|
||||
if (passwordHash !== undefined) {
|
||||
fields.push(`access_password_hash = $${paramIdx++}`);
|
||||
values.push(passwordHash);
|
||||
}
|
||||
if (expiresAt !== undefined) {
|
||||
fields.push(`expires_at = $${paramIdx++}`);
|
||||
values.push(expiresAt);
|
||||
}
|
||||
values.push(req.params.id);
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE share_links SET
|
||||
name = $1, group_id = $2, student_name = $3, date_from = $4, date_to = $5
|
||||
WHERE id = $6 RETURNING *`,
|
||||
[name.trim(), group_id || null, student_name || null, date_from || null, date_to || null, req.params.id]
|
||||
`UPDATE share_links SET ${fields.join(', ')} WHERE id = $${paramIdx} RETURNING *`,
|
||||
values
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'Не найдено' });
|
||||
res.json(normDates(rows[0]));
|
||||
@@ -486,6 +533,24 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
|
||||
if (!rows.length) return res.status(404).json({ error: 'Ссылка не найдена' });
|
||||
normDates(rows[0]);
|
||||
const l = rows[0];
|
||||
|
||||
// Check expiry
|
||||
if (l.expires_at && new Date(l.expires_at) < new Date()) {
|
||||
return res.status(410).json({ error: 'Срок действия ссылки истёк' });
|
||||
}
|
||||
|
||||
// Check password
|
||||
if (l.access_password_hash) {
|
||||
const providedPassword = req.headers['x-share-password'] || req.query.password;
|
||||
if (!providedPassword) {
|
||||
return res.status(401).json({ error: 'Требуется пароль', passwordRequired: true });
|
||||
}
|
||||
const valid = await bcrypt.compare(providedPassword, l.access_password_hash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Неверный пароль' });
|
||||
}
|
||||
}
|
||||
|
||||
const conditions = [];
|
||||
const params = [];
|
||||
if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); }
|
||||
@@ -508,6 +573,19 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
|
||||
fRes.rows.forEach(f => { (files[f.entry_id] = files[f.entry_id] || []).push({ token: f.token, name: f.name }); });
|
||||
}
|
||||
entries.forEach(r => { r.files = files[r.id] || []; });
|
||||
|
||||
// Anonymize names if enabled
|
||||
if (l.anonymize_names) {
|
||||
const nameMap = new Map();
|
||||
let counter = 1;
|
||||
entries.forEach(e => {
|
||||
if (!nameMap.has(e.student_name)) {
|
||||
nameMap.set(e.student_name, `Ученик ${counter++}`);
|
||||
}
|
||||
e.student_name = nameMap.get(e.student_name);
|
||||
});
|
||||
}
|
||||
|
||||
let photos = [];
|
||||
if (l.group_id) {
|
||||
const pRes = await pool.query(
|
||||
@@ -525,6 +603,7 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
|
||||
date_from: l.date_from,
|
||||
date_to: l.date_to,
|
||||
created_at: l.created_at,
|
||||
anonymize_names: l.anonymize_names,
|
||||
entries,
|
||||
photos,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user