feat: manage persistent uploads and coordinated backups
This commit is contained in:
30
src/app/api/admin/media/route.ts
Normal file
30
src/app/api/admin/media/route.ts
Normal file
@@ -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); }
|
||||
}
|
||||
20
src/app/media/[key]/route.ts
Normal file
20
src/app/media/[key]/route.ts
Normal file
@@ -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; }
|
||||
}
|
||||
43
src/server/backup.ts
Normal file
43
src/server/backup.ts
Normal file
@@ -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);
|
||||
}
|
||||
51
src/server/media.ts
Normal file
51
src/server/media.ts
Normal file
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user