harden anti-spam and file serving; backup restore to disk; json body limit

- Add honeypot field to public submission form + server-side check
- Serve shared files only in context of a valid share link (/api/share/:shareToken/files/:fileToken)
- Switch backup restore upload to diskStorage (50MB) with temp-dir cleanup
- Limit JSON body to 1mb
- Document fixed audit items
This commit is contained in:
dev
2026-09-08 11:36:34 +03:00
parent 57f2ea4f41
commit 8a50b46b7b
4 changed files with 81 additions and 18 deletions
+69 -9
View File
@@ -46,7 +46,7 @@ if (!ADMIN_PASSWORD) {
}
app.use(helmet({ contentSecurityPolicy: false }));
app.use(express.json());
app.use(express.json({ limit: '1mb' }));
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
app.use(express.static(path.join(__dirname, 'public')));
@@ -186,12 +186,21 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
});
// --- Backup / Restore ---
const gunzipAsync = require('util').promisify(require('zlib').gunzip);
const gunzipFile = require('util').promisify(require('zlib').gunzipFile);
const tar = require('tar');
const os = require('os');
const uploadBackup = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 300 * 1024 * 1024 },
storage: multer.diskStorage({
destination: (_, __, cb) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-up-'));
cb(null, dir);
},
filename: (_, file, cb) => {
const ext = path.extname(file.originalname) || '.bin';
cb(null, `backup-${Date.now()}${ext}`);
},
}),
limits: { fileSize: 50 * 1024 * 1024 },
});
const SAFE_NAME = /^[\w,.()-]+$/;
@@ -332,28 +341,34 @@ app.get('/api/backup', requireAdmin, async (_, res) => {
}
});
function cleanupUpload(req) {
try {
if (req?.file?.destination) fs.rmSync(req.file.destination, { recursive: true, force: true });
} catch {}
}
app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'backup file required' });
let data;
let legacyPhotos = [];
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'wido-rst-'));
try {
const gunz = await gunzipAsync(req.file.buffer).catch(() => null);
const gunz = await gunzipFile(req.file.path).catch(() => null);
if (gunz && gunz[0] === 0x7b) {
data = JSON.parse(gunz.toString('utf8'));
legacyPhotos = data.photos || [];
} else {
const inPath = path.join(staging, 'input.tar.gz');
fs.writeFileSync(inPath, req.file.buffer);
await tar.x({ file: inPath, cwd: staging });
await tar.x({ file: req.file.path, cwd: staging });
data = JSON.parse(fs.readFileSync(path.join(staging, 'data.json'), 'utf8'));
}
} catch {
fs.rmSync(staging, { recursive: true, force: true });
cleanupUpload(req);
return res.status(400).json({ error: 'Неверный файл бэкапа' });
}
if (!data || data.version !== 1 || !Array.isArray(data.groups)) {
fs.rmSync(staging, { recursive: true, force: true });
cleanupUpload(req);
return res.status(400).json({ error: 'Неверный формат бэкапа' });
}
let ndata;
@@ -361,6 +376,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
ndata = normalizeRestoreData(data);
} catch (e) {
fs.rmSync(staging, { recursive: true, force: true });
cleanupUpload(req);
return res.status(400).json({ error: 'Неверный формат бэкапа: ' + e.message });
}
const client = await pool.connect();
@@ -407,6 +423,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
} catch (e) {
await client.query('ROLLBACK');
fs.rmSync(staging, { recursive: true, force: true });
cleanupUpload(req);
throw e;
} finally {
client.release();
@@ -429,6 +446,7 @@ app.post('/api/restore', requireAdmin, uploadBackup.single('backup'), async (req
}
}
fs.rmSync(staging, { recursive: true, force: true });
cleanupUpload(req);
await sweepOrphanedUploads().catch(err => console.error('Upload sweep:', err));
res.json({ ok: true });
});
@@ -609,6 +627,43 @@ app.get('/api/share/:token', fileLimiter, async (req, res) => {
});
});
app.get('/api/share/:shareToken/files/:fileToken', fileLimiter, async (req, res) => {
const { shareToken, fileToken } = req.params;
const { rows: shareRows } = await pool.query(
`SELECT l.* FROM share_links l WHERE l.token = $1`, [shareToken]
);
if (!shareRows.length) return res.status(404).json({ error: 'Ссылка не найдена' });
const l = shareRows[0];
if (l.expires_at && new Date(l.expires_at) < new Date()) {
return res.status(410).json({ error: 'Срок действия ссылки истёк' });
}
if (l.access_password_hash) {
const providedPassword = req.headers['x-share-password'] || req.query.password;
if (!providedPassword) return res.status(401).json({ error: 'Требуется пароль' });
const valid = await bcrypt.compare(providedPassword, l.access_password_hash);
if (!valid) return res.status(401).json({ error: 'Неверный пароль' });
}
const conditions = ['e.deleted_at IS NULL'];
const params = [];
if (l.group_id) { params.push(l.group_id); conditions.push(`e.group_id = $${params.length}`); }
if (l.student_name) { params.push(l.student_name); conditions.push(`e.student_name = $${params.length}`); }
if (l.date_from) { params.push(l.date_from); conditions.push(`e.created_at >= $${params.length}::date`); }
if (l.date_to) { params.push(l.date_to); conditions.push(`e.created_at < ($${params.length}::date + interval '1 day')`); }
params.push(fileToken);
const { rows } = await pool.query(
`SELECT pf.path, pf.name FROM project_files pf
JOIN entries e ON e.id = pf.entry_id
WHERE pf.token = $${params.length} AND ${conditions.join(' AND ')}`,
params
);
if (!rows.length) return res.status(404).json({ error: 'Not found' });
const r = rows[0];
const fp = path.join(__dirname, r.path);
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'File missing' });
if (isImageName(r.name)) res.sendFile(fp);
else res.download(fp, r.name);
});
app.get('/s/:token', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'share.html'));
});
@@ -1054,9 +1109,14 @@ app.post('/api/entries', entryLimiter, (req, res, next) => {
return res.status(400).json({ error: 'Недопустимый файл' });
});
}, async (req, res) => {
const { student_name, group_id, description } = req.body;
const { student_name, group_id, description, website } = req.body;
const photo = req.files?.photo?.[0] || null;
const projectFiles = req.files?.files || [];
if (website) {
removeUpload(photo);
projectFiles.forEach(removeUpload);
return res.status(400).json({ error: 'Spam detected' });
}
if (!student_name?.trim() || !group_id || !description?.trim()) {
removeUpload(photo);
projectFiles.forEach(removeUpload);