Add admin backup export/import

Admins can export all cities, users, links, participants, and photos
as a zip for backups, and restore from one later. Import replaces all
existing data and clears sessions, so the admin has to log back in
afterward.
This commit is contained in:
Claudio Schaad 2026-07-21 20:38:37 +02:00
parent 9b35f6222f
commit 8d4448b28c
8 changed files with 327 additions and 1 deletions

9
client/src/api/backup.ts Normal file
View file

@ -0,0 +1,9 @@
import { api } from './client';
export const EXPORT_BACKUP_URL = '/api/backup/export';
export function importBackup(file: File) {
const formData = new FormData();
formData.append('backup', file);
return api.postForm<void>('/backup/import', formData);
}

View file

@ -1,12 +1,15 @@
import { useState, type FormEvent } from 'react';
import { useState, type ChangeEvent, type FormEvent } from 'react';
import { useUsers, useCreateUser, useUpdateUser, useResetPassword } from '../hooks/useUsers';
import { useAuth } from '../context/AuthContext';
import { ApiError } from '../api/client';
import * as backupApi from '../api/backup';
export function AdminUsersPage() {
const { data: users, isLoading } = useUsers();
const createUser = useCreateUser();
const updateUser = useUpdateUser();
const resetPassword = useResetPassword();
const { logout } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@ -14,6 +17,7 @@ export function AdminUsersPage() {
const [isAdmin, setIsAdmin] = useState(false);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [importing, setImporting] = useState(false);
async function handleCreate(e: FormEvent) {
e.preventDefault();
@ -86,6 +90,32 @@ export function AdminUsersPage() {
}
}
async function handleImportFile(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
if (
!window.confirm(
'Importing a backup permanently replaces all cities, users, and photos with the ' +
'contents of this file. This cannot be undone. Continue?',
)
) {
return;
}
setImporting(true);
try {
await backupApi.importBackup(file);
window.alert('Import complete. You will now be logged out — please log back in.');
await logout();
} catch {
window.alert('Could not import backup. Make sure the file is a CityTracker backup zip.');
} finally {
setImporting(false);
}
}
return (
<div className="admin-users-page">
<h1>Users</h1>
@ -183,6 +213,29 @@ export function AdminUsersPage() {
{submitting ? 'Creating…' : 'Create user'}
</button>
</form>
<section className="backup-section">
<h2>Backup</h2>
<p className="backup-hint">
Export all cities, users, and photos as a zip file. Importing a backup permanently
replaces all existing data.
</p>
<div className="backup-actions">
<a className="btn" href={backupApi.EXPORT_BACKUP_URL} download>
Export backup
</a>
<label className={`btn btn-danger ${importing ? 'btn-disabled' : ''}`}>
{importing ? 'Importing…' : 'Import backup'}
<input
type="file"
accept=".zip"
hidden
disabled={importing}
onChange={handleImportFile}
/>
</label>
</div>
</section>
</div>
);
}

View file

@ -701,3 +701,37 @@ a {
border: 1px solid var(--color-border);
background: var(--color-bg);
}
/* Backup */
.backup-section {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: var(--spacing-4);
max-width: 480px;
}
.backup-section h2 {
margin-top: 0;
}
.backup-hint {
color: var(--color-text-muted);
font-size: 0.9rem;
}
.backup-actions {
display: flex;
gap: var(--spacing-2);
flex-wrap: wrap;
}
.backup-actions a.btn {
text-decoration: none;
}
.btn-disabled {
opacity: 0.6;
cursor: not-allowed;
}

9
package-lock.json generated
View file

@ -2049,6 +2049,14 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/adm-zip": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.0.tgz",
"integrity": "sha512-XleryMhbuksdKtofnWZ9Sk+4CUTbms4Mb/EU32SZwToAyZ5RgVos/ki8n+yr0LWHOGKuakbXTuuYNHLQjhddgg==",
"engines": {
"node": ">=14.0"
}
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@ -6360,6 +6368,7 @@
"version": "1.0.0",
"dependencies": {
"@citytracker/shared": "*",
"adm-zip": "^0.6.0",
"argon2": "^0.40.3",
"better-sqlite3": "^11.3.0",
"express": "^4.21.0",

View file

@ -11,6 +11,7 @@
},
"dependencies": {
"@citytracker/shared": "*",
"adm-zip": "^0.6.0",
"argon2": "^0.40.3",
"better-sqlite3": "^11.3.0",
"express": "^4.21.0",

View file

@ -6,6 +6,7 @@ import { authRouter } from './routes/auth.routes';
import { usersRouter } from './routes/users.routes';
import { citiesRouter } from './routes/cities.routes';
import { geocodeRouter } from './routes/geocode.routes';
import { backupRouter } from './routes/backup.routes';
import { config } from './config';
export function createApp() {
@ -18,6 +19,7 @@ export function createApp() {
app.use('/api/users', usersRouter);
app.use('/api/cities', citiesRouter);
app.use('/api/geocode', geocodeRouter);
app.use('/api/backup', backupRouter);
app.use('/uploads', express.static(config.uploadDir));

View file

@ -0,0 +1,41 @@
import { Router } from 'express';
import multer from 'multer';
import { requireAuth } from '../middleware/requireAuth';
import { requireAdmin } from '../middleware/requireAdmin';
import * as backupService from '../services/backup.service';
export const backupRouter = Router();
backupRouter.use(requireAuth, requireAdmin);
const backupUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 500 * 1024 * 1024 },
});
backupRouter.get('/export', (_req, res) => {
const zipBuffer = backupService.exportBackup();
const filename = `citytracker-backup-${new Date().toISOString().slice(0, 10)}.zip`;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(zipBuffer);
});
backupRouter.post('/import', backupUpload.single('backup'), (req, res) => {
if (!req.file) {
res.status(400).json({ error: 'missing_file' });
return;
}
try {
backupService.importBackup(req.file.buffer);
} catch (err) {
if (err instanceof backupService.InvalidBackupError) {
res.status(400).json({ error: 'invalid_backup', message: err.message });
return;
}
throw err;
}
res.status(204).end();
});

View file

@ -0,0 +1,177 @@
import fs from 'node:fs';
import path from 'node:path';
import AdmZip from 'adm-zip';
import { db } from '../db/connection';
import { config } from '../config';
const BACKUP_VERSION = 1;
const UPLOADS_ENTRY_PREFIX = 'uploads/';
const DATA_ENTRY_NAME = 'data.json';
interface UserRow {
id: number;
username: string;
password_hash: string;
is_admin: number;
is_active: number;
display_name: string | null;
created_at: string;
}
interface CityRow {
id: number;
name: string;
country: string;
country_code: string | null;
lat: number;
lng: number;
status: string;
visit_date_start: string | null;
visit_date_end: string | null;
notes: string;
liked: number;
photo_filename: string | null;
created_by: number;
created_at: string;
updated_at: string;
}
interface LinkRow {
id: number;
city_id: number;
url: string;
label: string;
position: number;
}
interface ParticipantRow {
city_id: number;
user_id: number;
}
interface BackupData {
version: number;
exportedAt: string;
users: UserRow[];
cities: CityRow[];
cityLinks: LinkRow[];
cityParticipants: ParticipantRow[];
}
export class InvalidBackupError extends Error {}
export function exportBackup(): Buffer {
const data: BackupData = {
version: BACKUP_VERSION,
exportedAt: new Date().toISOString(),
users: db.prepare('SELECT * FROM users').all() as UserRow[],
cities: db.prepare('SELECT * FROM cities').all() as CityRow[],
cityLinks: db.prepare('SELECT * FROM city_links').all() as LinkRow[],
cityParticipants: db.prepare('SELECT * FROM city_participants').all() as ParticipantRow[],
};
const zip = new AdmZip();
zip.addFile(DATA_ENTRY_NAME, Buffer.from(JSON.stringify(data, null, 2)));
if (fs.existsSync(config.uploadDir)) {
for (const filename of fs.readdirSync(config.uploadDir)) {
const filePath = path.join(config.uploadDir, filename);
if (fs.statSync(filePath).isFile()) {
zip.addLocalFile(filePath, 'uploads');
}
}
}
return zip.toBuffer();
}
function parseBackup(buffer: Buffer): { data: BackupData; zip: AdmZip } {
let zip: AdmZip;
try {
zip = new AdmZip(buffer);
} catch {
throw new InvalidBackupError('The file is not a valid zip archive.');
}
const entry = zip.getEntry(DATA_ENTRY_NAME);
if (!entry) {
throw new InvalidBackupError('The zip is missing data.json — this is not a CityTracker backup.');
}
let data: BackupData;
try {
data = JSON.parse(zip.readAsText(entry));
} catch {
throw new InvalidBackupError('data.json is not valid JSON.');
}
if (
!data ||
!Array.isArray(data.users) ||
!Array.isArray(data.cities) ||
!Array.isArray(data.cityLinks) ||
!Array.isArray(data.cityParticipants)
) {
throw new InvalidBackupError('data.json is missing expected fields.');
}
return { data, zip };
}
export function importBackup(buffer: Buffer): void {
const { data, zip } = parseBackup(buffer);
const runImport = db.transaction(() => {
db.prepare('DELETE FROM city_participants').run();
db.prepare('DELETE FROM city_links').run();
db.prepare('DELETE FROM cities').run();
db.prepare('DELETE FROM users').run();
db.prepare('DELETE FROM sessions').run();
const insertUser = db.prepare(
`INSERT INTO users (id, username, password_hash, is_admin, is_active, display_name, created_at)
VALUES (@id, @username, @password_hash, @is_admin, @is_active, @display_name, @created_at)`,
);
for (const row of data.users) insertUser.run(row);
const insertCity = db.prepare(
`INSERT INTO cities
(id, name, country, country_code, lat, lng, status, visit_date_start, visit_date_end,
notes, liked, photo_filename, created_by, created_at, updated_at)
VALUES
(@id, @name, @country, @country_code, @lat, @lng, @status, @visit_date_start, @visit_date_end,
@notes, @liked, @photo_filename, @created_by, @created_at, @updated_at)`,
);
for (const row of data.cities) insertCity.run(row);
const insertLink = db.prepare(
`INSERT INTO city_links (id, city_id, url, label, position)
VALUES (@id, @city_id, @url, @label, @position)`,
);
for (const row of data.cityLinks) insertLink.run(row);
const insertParticipant = db.prepare(
'INSERT INTO city_participants (city_id, user_id) VALUES (@city_id, @user_id)',
);
for (const row of data.cityParticipants) insertParticipant.run(row);
});
try {
runImport();
} catch (err) {
throw new InvalidBackupError(
`Could not import backup — the data is inconsistent (${(err as Error).message}).`,
);
}
fs.mkdirSync(config.uploadDir, { recursive: true });
for (const filename of fs.readdirSync(config.uploadDir)) {
fs.unlinkSync(path.join(config.uploadDir, filename));
}
for (const zipEntry of zip.getEntries()) {
if (zipEntry.isDirectory || !zipEntry.entryName.startsWith(UPLOADS_ENTRY_PREFIX)) continue;
const filename = zipEntry.entryName.slice(UPLOADS_ENTRY_PREFIX.length);
if (!filename || filename !== path.basename(filename)) continue;
fs.writeFileSync(path.join(config.uploadDir, filename), zipEntry.getData());
}
}