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:
parent
9b35f6222f
commit
8d4448b28c
8 changed files with 327 additions and 1 deletions
9
client/src/api/backup.ts
Normal file
9
client/src/api/backup.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
9
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
41
server/src/routes/backup.routes.ts
Normal file
41
server/src/routes/backup.routes.ts
Normal 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();
|
||||
});
|
||||
177
server/src/services/backup.service.ts
Normal file
177
server/src/services/backup.service.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue