fix(backup): ссылка на архив переживает F5 и повторное скачивание
HEAD-проба в downloadBackup() сжигала одноразовый тикет: Express 4 прогоняет HEAD через GET-хендлер /api/backup/:token, который удалял тикет и файл до отдачи архива, поэтому настоящий GET всегда получал 404 «Ссылка на бэкап устарела». - server.js: GET /api/backup/:token больше не удаляет тикет и файл — ссылка живёт BACKUP_TTL_MS (30 мин), Range-докачка работает - вынес dropBackupTicket(), fs.rmSync обёрнут в try/catch - BACKUP_TICKETS_MAX = 3: лишние тикеты вычищаются по возрасту - sweepBackupStorage() режет с запасом 5 минут сверх TTL - settings.js: убрана HEAD-проба, в #backupStatus рендерится реальная кликабельная ссылка вместо невидимого синтетического <a> - AGENTS.md: раздел 8 — новое поведение + предупреждение про HEAD
This commit is contained in:
@@ -2135,23 +2135,33 @@ const uploadBackup = multer({
|
||||
|
||||
|
||||
const BACKUP_TTL_MS = 30 * 60 * 1000;
|
||||
const BACKUP_TICKETS_MAX = 3;
|
||||
const BACKUP_DIR = path.join(os.tmpdir(), 'wido-backups');
|
||||
const backupTickets = new Map();
|
||||
|
||||
function dropBackupTicket(token) {
|
||||
const t = backupTickets.get(token);
|
||||
if (!t) return;
|
||||
backupTickets.delete(token);
|
||||
try { fs.rmSync(t.file, { force: true }); } catch (e) {
|
||||
console.error('backup cleanup failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function pruneBackupTickets() {
|
||||
const now = Date.now();
|
||||
for (const [token, t] of backupTickets) {
|
||||
if (t.expiresAt <= now) {
|
||||
backupTickets.delete(token);
|
||||
fs.rmSync(t.file, { force: true });
|
||||
}
|
||||
if (t.expiresAt <= now) dropBackupTicket(token);
|
||||
}
|
||||
if (backupTickets.size <= BACKUP_TICKETS_MAX) return;
|
||||
const live = [...backupTickets.entries()].sort((a, b) => a[1].expiresAt - b[1].expiresAt);
|
||||
for (const [token] of live.slice(0, live.length - BACKUP_TICKETS_MAX)) dropBackupTicket(token);
|
||||
}
|
||||
|
||||
function sweepBackupStorage() {
|
||||
try {
|
||||
if (!fs.existsSync(BACKUP_DIR)) return;
|
||||
const cutoff = Date.now() - BACKUP_TTL_MS;
|
||||
const cutoff = Date.now() - BACKUP_TTL_MS - 5 * 60 * 1000;
|
||||
for (const f of fs.readdirSync(BACKUP_DIR)) {
|
||||
const fp = path.join(BACKUP_DIR, f);
|
||||
const st = fs.statSync(fp);
|
||||
@@ -2228,11 +2238,11 @@ function sendBackupArchive(res, archive, onDone) {
|
||||
|
||||
app.post('/api/backup', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
pruneBackupTickets();
|
||||
const archive = await buildBackupArchive();
|
||||
const token = crypto.randomBytes(24).toString('hex');
|
||||
const expiresAt = Date.now() + BACKUP_TTL_MS;
|
||||
backupTickets.set(token, { file: archive.file, name: archive.name, size: archive.size, expiresAt });
|
||||
pruneBackupTickets();
|
||||
await logAudit(req, 'backup.download', { size: archive.size, counts: archive.counts });
|
||||
await pushNotification({
|
||||
type: 'backup.create',
|
||||
@@ -2259,22 +2269,14 @@ app.get('/api/backup/:token', apiLimiter, (req, res) => {
|
||||
const token = typeof req.params.token === 'string' ? req.params.token : '';
|
||||
const ticket = backupTickets.get(token);
|
||||
if (!ticket || ticket.expiresAt <= Date.now()) {
|
||||
if (ticket) {
|
||||
backupTickets.delete(token);
|
||||
fs.rmSync(ticket.file, { force: true });
|
||||
}
|
||||
if (ticket) dropBackupTicket(token);
|
||||
return res.status(404).json({ error: 'Ссылка на бэкап устарела. Сформируйте архив заново.' });
|
||||
}
|
||||
if (!fs.existsSync(ticket.file)) {
|
||||
backupTickets.delete(token);
|
||||
dropBackupTicket(token);
|
||||
return res.status(410).json({ error: 'Файл бэкапа больше недоступен. Сформируйте архив заново.' });
|
||||
}
|
||||
backupTickets.delete(token);
|
||||
sendBackupArchive(res, ticket, () => {
|
||||
try { fs.rmSync(ticket.file, { force: true }); } catch (e) {
|
||||
console.error('backup cleanup failed:', e.message);
|
||||
}
|
||||
});
|
||||
sendBackupArchive(res, ticket);
|
||||
});
|
||||
|
||||
app.get('/api/backup', requireAdmin, async (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user