Files
TREK/server/src/routes/backup.ts
T
Julien G. 1f5deeba6c Bug fixes - April 27th 2026 (#907)
* fix: clean up dangling FK references before deleting a user

Resolves FOREIGN KEY constraint failed (500) on DELETE /api/admin/users/:id
and DELETE /api/auth/me when the target user had rows in trip_members.invited_by,
share_tokens.created_by, budget_items.paid_by_user_id, journeys.user_id,
journey_entries.author_id, journey_contributors.user_id, or
journey_share_tokens.created_by — none of which had ON DELETE clauses.

Introduces deleteUserCompletely() in userCleanupService.ts which wraps all
cleanup and the final DELETE FROM users in a single transaction. Both
adminService.deleteUser and authService.deleteAccount now call it instead of
the bare DELETE. Tests ADMIN-005b and AUTH-040 cover all reference types
including notification sender/recipient and notice dismissals.

* test: extend FK deletion tests to cover journeys, files, and photos

ADMIN-005b and AUTH-040 now also seed and assert:
- owned journey with entries (cascade-deleted via journeys.user_id cleanup)
- trip_files.uploaded_by (SET NULL — file survives, attribution cleared)
- trek_photos.owner_id (SET NULL — photo record survives, owner cleared)
- trip_photos.user_id (CASCADE — photo association removed)

* test: extend user deletion tests to cover all FK relationships

ADMIN-005b and AUTH-040 now seed and assert every user FK relationship:

CASCADE (row deleted): trips, trip_members, tags, mcp_tokens, oauth_tokens,
oauth_consents, vacay_plans, vacay_plan_members, bucket_list,
visited_countries, visited_regions, packing_templates, invite_tokens,
collab_notes, settings, password_reset_tokens, notification_channel_preferences

SET NULL (row survives, column nulled): categories, todo_items.assigned_user_id,
packing_bags, audit_log

Caught and fixed: notification_preferences was dropped in migration 72;
correct table is notification_channel_preferences.

* fix: preserve URL hash and OIDC redirect target through login flow

- Include location.hash in redirect param at all three producer sites
  (ProtectedRoute, axios 401 interceptor, OAuthAuthorizePage) so
  hash fragments survive the login bounce
- Stash redirectTarget in sessionStorage before any OIDC provider
  redirect and restore it after the code exchange, since the IdP
  strips the original ?redirect= param during the roundtrip
- Clear sessionStorage on OIDC error to avoid stale state
- Add tests covering sessionStorage stash on mount, navigate to saved
  redirect after OIDC exchange, fallback to /dashboard, and cleanup
  on error

* fix: use day position instead of ID for accommodation date range clamping

Math.min/Math.max over raw day IDs breaks the start/end picker when a
trip's day IDs are non-monotonic relative to day_number (normal after
repeated generateDays extend/shrink cycles). Replaced with findIndex
lookups so clamping is always based on positional order.

Closes #889

* fix: normalize env var comparisons to be case-insensitive

All NODE_ENV, DEMO_MODE, OIDC_ONLY, FORCE_HTTPS, COOKIE_SECURE, and
ALLOW_INTERNAL_NETWORK checks now use .toLowerCase() so values like
'Production' or 'True' behave identically to their lowercase forms.
Also adds APP_VERSION to the startup banner.

* fix: delete surplus days when shortening a trip

When shrinking a trip's date range, surplus days are now deleted along
with their assignments, notes, and accommodations (cascade). Places
remain in the trip pool; reservations keep their day reference nulled
by the existing ON DELETE SET NULL constraint (issue #909).

Updates TRIP-SVC-011 to reflect the new behaviour; adds TRIP-SVC-016
as a regression test for the empty-day case.

* fix: auto-backup retention deletes itself and manual backups on Docker

Two bugs in cleanupOldBackups:
1. Filter was .endsWith('.zip') — swept manual backup-*.zip files too.
   Now restricted to auto-backup-* prefix.
2. Age was derived from stat.birthtimeMs, which is 0 on overlayfs
   (Docker default), making every backup appear epoch-old and get
   deleted immediately. Age is now parsed from the filename timestamp
   and falls back to mtimeMs (reliable on overlayfs).

Also converts inline require('./services/auditLog') calls to a static
import throughout scheduler.ts, and adds 8 unit tests covering the
fixed retention logic including the overlayfs regression case.

* test: update TRIP-024 to match delete behavior on trip shrink

* feat: add bypass-branch-check label to skip branch enforcement
2026-04-28 05:17:20 +02:00

198 lines
6.0 KiB
TypeScript

import express, { Request, Response, NextFunction } from 'express';
import multer from 'multer';
import fs from 'fs';
import { authenticate, adminOnly } from '../middleware/auth';
import { AuthRequest } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
import {
listBackups,
createBackup,
restoreFromZip,
getAutoSettings,
updateAutoSettings,
deleteBackup,
isValidBackupFilename,
backupFilePath,
backupFileExists,
checkRateLimit,
getUploadTmpDir,
BACKUP_RATE_WINDOW,
MAX_BACKUP_UPLOAD_SIZE,
} from '../services/backupService';
const router = express.Router();
router.use(authenticate, adminOnly);
// ---------------------------------------------------------------------------
// Rate-limiter middleware (HTTP concern wrapping service-level check)
// ---------------------------------------------------------------------------
function backupRateLimiter(maxAttempts: number, windowMs: number) {
return (req: Request, res: Response, next: NextFunction) => {
const key = req.ip || 'unknown';
if (!checkRateLimit(key, maxAttempts, windowMs)) {
return res.status(429).json({ error: 'Too many backup requests. Please try again later.' });
}
next();
};
}
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
router.get('/list', (_req: Request, res: Response) => {
try {
res.json({ backups: listBackups() });
} catch (err: unknown) {
res.status(500).json({ error: 'Error loading backups' });
}
});
router.post('/create', backupRateLimiter(3, BACKUP_RATE_WINDOW), async (req: Request, res: Response) => {
try {
const backup = await createBackup();
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'backup.create',
resource: backup.filename,
ip: getClientIp(req),
details: { size: backup.size },
});
res.json({ success: true, backup });
} catch (err: unknown) {
res.status(500).json({ error: 'Error creating backup' });
}
});
router.get('/download/:filename', (req: Request, res: Response) => {
const { filename } = req.params;
if (!isValidBackupFilename(filename)) {
return res.status(400).json({ error: 'Invalid filename' });
}
if (!backupFileExists(filename)) {
return res.status(404).json({ error: 'Backup not found' });
}
res.download(backupFilePath(filename), filename);
});
router.post('/restore/:filename', async (req: Request, res: Response) => {
const { filename } = req.params;
if (!isValidBackupFilename(filename)) {
return res.status(400).json({ error: 'Invalid filename' });
}
const zipPath = backupFilePath(filename);
if (!backupFileExists(filename)) {
return res.status(404).json({ error: 'Backup not found' });
}
try {
const result = await restoreFromZip(zipPath);
if (!result.success) {
return res.status(result.status || 400).json({ error: result.error });
}
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'backup.restore',
resource: filename,
ip: getClientIp(req),
});
res.json({ success: true });
} catch (err: unknown) {
if (!res.headersSent) res.status(500).json({ error: 'Error restoring backup' });
}
});
const uploadTmp = multer({
dest: getUploadTmpDir(),
fileFilter: (_req, file, cb) => {
if (file.originalname.endsWith('.zip')) cb(null, true);
else cb(new Error('Only ZIP files allowed'));
},
limits: { fileSize: MAX_BACKUP_UPLOAD_SIZE },
});
router.post('/upload-restore', uploadTmp.single('backup'), async (req: Request, res: Response) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const zipPath = req.file.path;
const origName = req.file.originalname || 'upload.zip';
try {
const result = await restoreFromZip(zipPath);
if (!result.success) {
return res.status(result.status || 400).json({ error: result.error });
}
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'backup.upload_restore',
resource: origName,
ip: getClientIp(req),
});
res.json({ success: true });
} catch (err: unknown) {
if (!res.headersSent) res.status(500).json({ error: 'Error restoring backup' });
} finally {
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
}
});
router.get('/auto-settings', (_req: Request, res: Response) => {
try {
const data = getAutoSettings();
res.json(data);
} catch (err: unknown) {
console.error('[backup] GET auto-settings:', err);
res.status(500).json({ error: 'Could not load backup settings' });
}
});
router.put('/auto-settings', (req: Request, res: Response) => {
try {
const settings = updateAutoSettings((req.body || {}) as Record<string, unknown>);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'backup.auto_settings',
ip: getClientIp(req),
details: { enabled: settings.enabled, interval: settings.interval, keep_days: settings.keep_days },
});
res.json({ settings });
} catch (err: unknown) {
console.error('[backup] PUT auto-settings:', err);
const msg = err instanceof Error ? err.message : String(err);
res.status(500).json({
error: 'Could not save auto-backup settings',
detail: process.env.NODE_ENV?.toLowerCase() !== 'production' ? msg : undefined,
});
}
});
router.delete('/:filename', (req: Request, res: Response) => {
const { filename } = req.params;
if (!isValidBackupFilename(filename)) {
return res.status(400).json({ error: 'Invalid filename' });
}
if (!backupFileExists(filename)) {
return res.status(404).json({ error: 'Backup not found' });
}
deleteBackup(filename);
const authReq = req as AuthRequest;
writeAudit({
userId: authReq.user.id,
action: 'backup.delete',
resource: filename,
ip: getClientIp(req),
});
res.json({ success: true });
});
export default router;