diff --git a/client/src/api/backup.ts b/client/src/api/backup.ts new file mode 100644 index 0000000..7a285b4 --- /dev/null +++ b/client/src/api/backup.ts @@ -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('/backup/import', formData); +} diff --git a/client/src/pages/AdminUsersPage.tsx b/client/src/pages/AdminUsersPage.tsx index fc744a1..709c267 100644 --- a/client/src/pages/AdminUsersPage.tsx +++ b/client/src/pages/AdminUsersPage.tsx @@ -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(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) { + 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 (

Users

@@ -183,6 +213,29 @@ export function AdminUsersPage() { {submitting ? 'Creating…' : 'Create user'} + +
+

Backup

+

+ Export all cities, users, and photos as a zip file. Importing a backup permanently + replaces all existing data. +

+
+ + Export backup + + +
+
); } diff --git a/client/src/styles/global.css b/client/src/styles/global.css index 983815c..6c6ed1e 100644 --- a/client/src/styles/global.css +++ b/client/src/styles/global.css @@ -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; +} diff --git a/package-lock.json b/package-lock.json index e9e675c..67cd77e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/server/package.json b/server/package.json index 1763d14..03daf1c 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/app.ts b/server/src/app.ts index cfb3127..de19412 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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)); diff --git a/server/src/routes/backup.routes.ts b/server/src/routes/backup.routes.ts new file mode 100644 index 0000000..6392e88 --- /dev/null +++ b/server/src/routes/backup.routes.ts @@ -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(); +}); diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts new file mode 100644 index 0000000..fc35242 --- /dev/null +++ b/server/src/services/backup.service.ts @@ -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()); + } +}