feat(m14): backup & admin (zip via fflate, admin guard, tests, e2e)

- BackupService (admin-only: createBackup zips DB+uploads via fflate to
  backups/backup-<ts>.zip, listBackups newest-first, getBackupPath with
  path-traversal guard)
- Admin backup APIs: GET/POST /api/admin/backups, GET /api/admin/backups/:filename
- Admin backups screen (admin-only) + BackupCreateButton
- Playwright globalSetup seeds a system_admin user for admin E2E
- add fflate dependency
- Unit tests (BackupService: zip content, list, admin guard, traversal) +
  e2e backup (create/list/download + non-admin 403)
This commit is contained in:
Ken Yasue
2026-06-25 02:35:35 +02:00
parent 384e61386a
commit 0026edd22b
9 changed files with 346 additions and 1 deletions

112
services/BackupService.ts Normal file
View File

@ -0,0 +1,112 @@
import fs from 'node:fs';
import path from 'node:path';
import { zipSync } from 'fflate';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
import type { UserRole } from '@/lib/types';
export interface BackupActor {
id: number;
role: UserRole;
}
export interface BackupFile {
filename: string;
size: number;
createdAt: string;
}
const FILENAME_RE = /^backup-[\w-]+\.zip$/;
/**
* バックアップ作成・一覧・ダウンロードを担うService。
* SQLite DBファイル + uploadsディレクトリをZIP化し、backups/ に保存する。
* すべての操作は system_admin ロールに限定される。
*/
export class BackupService {
constructor(
private readonly dbPath: string,
private readonly uploadsDir: string,
private readonly backupsDir: string
) {}
createBackup(actor: BackupActor): BackupFile {
this.requireAdmin(actor);
fs.mkdirSync(this.backupsDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `backup-${timestamp}.zip`;
const files: Record<string, Uint8Array> = {};
// DBファイル
if (fs.existsSync(this.dbPath)) {
files['app.db'] = new Uint8Array(fs.readFileSync(this.dbPath));
}
// uploadsディレクトリ
this.collectUploads(files, this.uploadsDir, 'uploads');
const zip = zipSync(files);
const fullPath = path.join(this.backupsDir, filename);
fs.writeFileSync(fullPath, zip);
const stat = fs.statSync(fullPath);
return {
filename,
size: stat.size,
createdAt: stat.mtime.toISOString(),
};
}
listBackups(actor: BackupActor): BackupFile[] {
this.requireAdmin(actor);
if (!fs.existsSync(this.backupsDir)) return [];
const entries = fs
.readdirSync(this.backupsDir)
.filter((f) => f.endsWith('.zip') && FILENAME_RE.test(f))
.map((filename) => {
const stat = fs.statSync(path.join(this.backupsDir, filename));
return {
filename,
size: stat.size,
createdAt: stat.mtime.toISOString(),
};
})
.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1));
return entries;
}
getBackupPath(actor: BackupActor, filename: string): string {
this.requireAdmin(actor);
if (!FILENAME_RE.test(filename)) {
throw new NotFoundError('Backup', filename);
}
const fullPath = path.join(this.backupsDir, filename);
if (!fs.existsSync(fullPath)) {
throw new NotFoundError('Backup', filename);
}
return fullPath;
}
private requireAdmin(actor: BackupActor): void {
if (actor.role !== 'system_admin') {
throw new ForbiddenError('管理者のみアクセス可能です');
}
}
private collectUploads(
files: Record<string, Uint8Array>,
dir: string,
prefix: string
): void {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir)) {
const fullPath = path.join(dir, entry);
const rel = `${prefix}/${entry}`;
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
this.collectUploads(files, fullPath, rel);
} else {
files[rel] = new Uint8Array(fs.readFileSync(fullPath));
}
}
}
}