add share-link password/expiry, journal touch-ups, ddns helper and audit docs

This commit is contained in:
dev
2026-09-08 10:54:24 +03:00
parent dd5a2ea288
commit 57f2ea4f41
11 changed files with 626 additions and 63 deletions
+88 -9
View File
@@ -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,
});