From 7830784694b6f5a2bd0118f076f40fb16b79842d Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Tue, 15 Sep 2026 21:47:51 +0330 Subject: [PATCH] feat: manage persistent uploads and coordinated backups --- scripts/maintenance.ts | 44 +++++++++++++++++++++++++++ src/app/api/admin/media/route.ts | 30 +++++++++++++++++++ src/app/media/[key]/route.ts | 20 +++++++++++++ src/server/backup.ts | 43 +++++++++++++++++++++++++++ src/server/media.ts | 51 ++++++++++++++++++++++++++++++++ 5 files changed, 188 insertions(+) create mode 100644 scripts/maintenance.ts create mode 100644 src/app/api/admin/media/route.ts create mode 100644 src/app/media/[key]/route.ts create mode 100644 src/server/backup.ts create mode 100644 src/server/media.ts diff --git a/scripts/maintenance.ts b/scripts/maintenance.ts new file mode 100644 index 0000000..98c1eda --- /dev/null +++ b/scripts/maintenance.ts @@ -0,0 +1,44 @@ +import { createInterface } from 'node:readline/promises'; +import { Writable } from 'node:stream'; +import { dataDirectory } from '../src/server/db'; +import { initialize } from '../src/server/initialize'; +import { provisionOwner } from '../src/server/owner'; +import { importLegacy } from '../src/server/legacy-import'; +import { backup, restore } from '../src/server/backup'; +import { withContentLock } from '../src/server/write-lock'; + +async function credentials() { + if (process.env.OWNER_EMAIL && process.env.OWNER_PASSWORD) return { email: process.env.OWNER_EMAIL, password: process.env.OWNER_PASSWORD }; + if (!process.stdin.isTTY) throw new Error('Use an interactive terminal, or supply OWNER_EMAIL and OWNER_PASSWORD through the environment'); + let muted = false; + const output = new Writable({ write(chunk, _encoding, callback) { if (!muted) process.stdout.write(chunk); callback(); } }); + const prompt = createInterface({ input: process.stdin, output, terminal: true }); + try { + const email = (await prompt.question('Owner email: ')).trim(); + process.stdout.write('Password (12–128 characters, hidden): '); muted = true; + const password = await prompt.question(''); muted = false; process.stdout.write('\n'); + process.stdout.write('Confirm password (hidden): '); muted = true; + const confirmation = await prompt.question(''); muted = false; process.stdout.write('\n'); + if (password !== confirmation) throw new Error('Passwords do not match'); + return { email, password }; + } finally { prompt.close(); } +} +async function main() { + const [command, argument, target] = process.argv.slice(2); + if (command === 'restore') { + if (!argument || !target) throw new Error('Usage: restore BACKUP_DIRECTORY NEW_DATA_DIRECTORY'); + restore(argument, target); console.log('Restored into the new directory. Stop the app before switching its data volume.'); return; + } + if (!['init', 'create-owner', 'reset-password', 'import-legacy', 'backup'].includes(command)) throw new Error('Commands: init, create-owner, reset-password, import-legacy, backup DIRECTORY, restore BACKUP_DIRECTORY NEW_DATA_DIRECTORY'); + const connection = initialize(); + try { + if (command === 'create-owner' || command === 'reset-password') { + const { email, password } = await credentials(); + await withContentLock(() => provisionOwner(connection, email, password, command === 'reset-password')); + console.log(command === 'create-owner' ? 'Owner created.' : 'Password updated. All sessions revoked.'); + } else if (command === 'import-legacy') { await withContentLock(() => importLegacy(connection)); console.log('Existing portfolio imported.'); } + else if (command === 'backup') { if (!argument) throw new Error('Supply a new backup directory'); await backup(connection, dataDirectory(), argument); console.log('Backup complete.'); } + else console.log('Database initialized.'); + } finally { connection.sqlite.close(); } +} +main().catch(error => { console.error(error instanceof Error ? error.message : 'Maintenance failed'); process.exitCode = 1; }); diff --git a/src/app/api/admin/media/route.ts b/src/app/api/admin/media/route.ts new file mode 100644 index 0000000..1dcaefd --- /dev/null +++ b/src/app/api/admin/media/route.ts @@ -0,0 +1,30 @@ +import { getDatabase } from '@/server/db'; +import { media } from '@/server/schema'; +import { apiError, json, requireOwner, boundedBody } from '@/server/http'; +import { deleteUpload, storeUpload } from '@/server/media'; +import { withContentLock } from '@/server/write-lock'; +import { ContentError } from '@/server/content'; + +export async function GET(request: Request) { + try { await requireOwner(request); return json(getDatabase().db.select().from(media).all().map(item => ({ ...item, url: `/media/${item.key}` }))); } + catch (error) { return apiError(error); } +} +export async function POST(request: Request) { + try { + await requireOwner(request); + if (!request.headers.get('content-type')?.startsWith('multipart/form-data;')) throw new ContentError('Expected a file upload', 415); + const body = await boundedBody(request, 11 * 1024 * 1024); + const form = await new Response(new Uint8Array(body), { headers: { 'Content-Type': request.headers.get('content-type')! } }).formData(); + const file = form.get('file'); + if (!(file instanceof File)) throw new ContentError('Choose a file'); + const bytes = Buffer.from(await file.arrayBuffer()); + return json(await withContentLock(() => storeUpload(getDatabase(), file.name, bytes)), 201); + } catch (error) { return apiError(error); } +} +export async function DELETE(request: Request) { + try { + await requireOwner(request); + await withContentLock(() => deleteUpload(getDatabase(), new URL(request.url).searchParams.get('key') || '')); + return json({ ok: true }); + } catch (error) { return apiError(error); } +} diff --git a/src/app/media/[key]/route.ts b/src/app/media/[key]/route.ts new file mode 100644 index 0000000..c1d9e93 --- /dev/null +++ b/src/app/media/[key]/route.ts @@ -0,0 +1,20 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { eq } from 'drizzle-orm'; +import { dataDirectory, getDatabase } from '@/server/db'; +import { media } from '@/server/schema'; + +export async function GET(_request: Request, { params }: { params: Promise<{ key: string }> }) { + const { key } = await params; + if (!/^[0-9a-f-]{36}\.(webp|pdf)$/.test(key)) return new Response(null, { status: 404 }); + const record = getDatabase().db.select().from(media).where(eq(media.key, key)).get(); + if (!record) return new Response(null, { status: 404 }); + try { + const bytes = await readFile(/* turbopackIgnore: true */ path.join(dataDirectory(), 'uploads', key)); + return new Response(bytes, { headers: { + 'Content-Type': record.mime, 'Content-Length': String(bytes.length), + 'Cache-Control': 'public, max-age=31536000, immutable', 'X-Content-Type-Options': 'nosniff', + ...(record.mime === 'application/pdf' ? { 'Content-Disposition': `attachment; filename="${key}"`, 'Content-Security-Policy': "sandbox; default-src 'none'" } : {}), + } }); + } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return new Response(null, { status: 404 }); throw error; } +} diff --git a/src/server/backup.ts b/src/server/backup.ts new file mode 100644 index 0000000..e606921 --- /dev/null +++ b/src/server/backup.ts @@ -0,0 +1,43 @@ +import { cpSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import Database from 'better-sqlite3'; +import type { DatabaseConnection } from './db'; +import { ContentError } from './content'; +import { withContentLock } from './write-lock'; + +function separatePaths(source: string, target: string) { + const a = path.resolve(source); const b = path.resolve(target); + if (a === b || a.startsWith(b + path.sep) || b.startsWith(a + path.sep)) throw new ContentError('Backup and data directories must be separate, non-overlapping paths'); + return { source: a, target: b }; +} +export async function backup(connection: DatabaseConnection, dataDir: string, targetDir: string) { + const { source, target } = separatePaths(dataDir, targetDir); + if (existsSync(target)) throw new ContentError('Choose a new backup directory'); + return withContentLock(async () => { + mkdirSync(target, { recursive: true, mode: 0o700 }); + await connection.sqlite.backup(path.join(target, 'portfolio.sqlite')); + if (existsSync(path.join(source, 'uploads'))) cpSync(path.join(source, 'uploads'), path.join(target, 'uploads'), { recursive: true }); + if (existsSync(path.join(source, 'auth-secret'))) cpSync(path.join(source, 'auth-secret'), path.join(target, 'auth-secret')); + writeFileSync(path.join(target, 'manifest.json'), JSON.stringify({ format: 1, createdAt: new Date().toISOString() }) + '\n', { mode: 0o600 }); + }, source); +} +export function restore(sourceDir: string, targetDir: string) { + const { source, target } = separatePaths(sourceDir, targetDir); + // Restore into a NEW directory. The operator swaps volumes while the app is stopped. + // Never overwrite an open SQLite database or recursively delete user data. + if (existsSync(target)) throw new ContentError('Restore requires a new, empty destination path'); + const manifest = JSON.parse(readFileSync(path.join(source, 'manifest.json'), 'utf8')); + if (manifest.format !== 1) throw new ContentError('Unsupported backup format'); + const check = new Database(path.join(source, 'portfolio.sqlite'), { readonly: true, fileMustExist: true }); + try { + if (check.pragma('integrity_check', { simple: true }) !== 'ok') throw new ContentError('Database integrity check failed'); + const rows = check.prepare('SELECT key FROM media').all() as { key: string }[]; + if (rows.some(row => !/^[0-9a-f-]{36}\.(pdf|webp)$/.test(row.key) || !existsSync(path.join(source, 'uploads', row.key)))) throw new ContentError('Backup is missing uploaded files'); + } finally { check.close(); } + const staging = `${target}.restore-${Date.now()}`; + mkdirSync(staging, { recursive: true, mode: 0o700 }); + for (const name of ['portfolio.sqlite', 'uploads', 'auth-secret']) if (existsSync(path.join(source, name))) cpSync(path.join(source, name), path.join(staging, name), { recursive: true }); + const restored = new Database(path.join(staging, 'portfolio.sqlite')); + try { restored.prepare('DELETE FROM session').run(); } finally { restored.close(); } + renameSync(staging, target); +} diff --git a/src/server/media.ts b/src/server/media.ts new file mode 100644 index 0000000..8918b4c --- /dev/null +++ b/src/server/media.ts @@ -0,0 +1,51 @@ +import { randomUUID } from 'node:crypto'; +import { mkdirSync, writeFileSync, unlinkSync } from 'node:fs'; +import path from 'node:path'; +import sharp from 'sharp'; +import { eq } from 'drizzle-orm'; +import type { DatabaseConnection } from './db'; +import { dataDirectory } from './db'; +import { media } from './schema'; +import { ContentError, readContent } from './content'; + +export async function storeUpload(connection: DatabaseConnection, filename: string, bytes: Buffer, directory = dataDirectory()) { + if (bytes.length === 0 || bytes.length > 10 * 1024 * 1024) throw new ContentError('Uploads must be between 1 byte and 10 MB', 413); + let mime: string; let extension: string; let width: number | null = null; let height: number | null = null; + if (bytes.subarray(0, 5).toString() === '%PDF-') { + if (!bytes.subarray(-2048).includes(Buffer.from('%%EOF'))) throw new ContentError('Invalid PDF'); + mime = 'application/pdf'; extension = 'pdf'; + } else { + try { + const image = sharp(bytes, { limitInputPixels: 40_000_000 }); + const metadata = await image.metadata(); + if (!['jpeg', 'png', 'webp'].includes(metadata.format || '') || (metadata.pages ?? 1) !== 1) throw new Error('Unsupported format'); + // Decode and re-encode to verify the image and remove metadata and trailing payloads. + const encoded = await image.rotate().webp({ quality: 90 }).toBuffer({ resolveWithObject: true }); + bytes = encoded.data; width = encoded.info.width; height = encoded.info.height; + mime = 'image/webp'; extension = 'webp'; + } catch { throw new ContentError('Use a valid, non-animated JPEG, PNG, WebP image, or PDF'); } + } + const key = `${randomUUID()}.${extension}`; + const uploads = path.join(directory, 'uploads'); + mkdirSync(uploads, { recursive: true, mode: 0o700 }); + const record = { key, originalName: path.basename(filename).slice(0, 200), mime, width, height, size: bytes.length, createdAt: Date.now() }; + writeFileSync(path.join(uploads, key), bytes, { flag: 'wx', mode: 0o600 }); + try { connection.db.insert(media).values(record).run(); } + catch (error) { unlinkSync(path.join(uploads, key)); throw error; } + return { ...record, url: `/media/${key}` }; +} +function containsReference(value: unknown, reference: string): boolean { + if (typeof value === 'string') return value === reference; + if (Array.isArray(value)) return value.some(item => containsReference(item, reference)); + if (value && typeof value === 'object') return Object.values(value).some(item => containsReference(item, reference)); + return false; +} +export function deleteUpload(connection: DatabaseConnection, key: string, directory = dataDirectory()) { + const record = connection.db.select().from(media).where(eq(media.key, key)).get(); + if (!record) throw new ContentError('Upload not found', 404); + if (containsReference(readContent(connection), `/media/${key}`)) throw new ContentError('This upload is still used by portfolio content', 409); + // Remove the row first. A filesystem failure leaves an unreferenced file, never a broken reference. + connection.db.delete(media).where(eq(media.key, key)).run(); + try { unlinkSync(path.join(directory, 'uploads', record.key)); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } +}