From 199f0d9a3f73d5f48b1675d8904f74f47c47a0de Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Tue, 15 Sep 2026 21:47:48 +0330 Subject: [PATCH] feat: protect management APIs with owner authentication --- src/app/api/admin/[resource]/route.ts | 39 +++++++++++++++++++++++++++ src/app/api/auth/[...all]/route.ts | 14 ++++++++++ src/app/api/health/route.ts | 7 +++++ src/server/auth.ts | 27 +++++++++++++++++++ src/server/http.ts | 38 ++++++++++++++++++++++++++ src/server/owner.ts | 26 ++++++++++++++++++ src/server/write-lock.ts | 17 ++++++++++++ 7 files changed, 168 insertions(+) create mode 100644 src/app/api/admin/[resource]/route.ts create mode 100644 src/app/api/auth/[...all]/route.ts create mode 100644 src/app/api/health/route.ts create mode 100644 src/server/auth.ts create mode 100644 src/server/http.ts create mode 100644 src/server/owner.ts create mode 100644 src/server/write-lock.ts diff --git a/src/app/api/admin/[resource]/route.ts b/src/app/api/admin/[resource]/route.ts new file mode 100644 index 0000000..8741fdc --- /dev/null +++ b/src/app/api/admin/[resource]/route.ts @@ -0,0 +1,39 @@ +import { getDatabase } from '@/server/db'; +import { readContent, saveContent, deleteContent, ContentError } from '@/server/content'; +import { apiError, json, readJson, requireOwner } from '@/server/http'; +import { withContentLock } from '@/server/write-lock'; + +type Context = { params: Promise<{ resource: string }> }; +export async function GET(request: Request, context: Context) { + try { + await requireOwner(request); + const { resource } = await context.params; + const snapshot = readContent(getDatabase()); + if (!['settings', 'languages', 'sections', 'entries'].includes(resource)) throw new ContentError('Resource not found', 404); + return json(snapshot[resource as keyof typeof snapshot]); + } catch (error) { return apiError(error); } +} +export async function PUT(request: Request, context: Context) { + try { + await requireOwner(request); + const { resource } = await context.params; + const body = await readJson(request); + return json(await withContentLock(() => { + if (resource === 'entries' && new URL(request.url).searchParams.has('create')) { + const id = body && typeof body === 'object' && 'id' in body ? body.id : undefined; + if (readContent(getDatabase()).entries.some(entry => entry.id === id)) throw new ContentError('This ID is already in use. Choose another.', 409); + } + return saveContent(getDatabase(), resource, body); + })); + } catch (error) { return apiError(error); } +} +export async function DELETE(request: Request, context: Context) { + try { + await requireOwner(request); + const { resource } = await context.params; + const id = new URL(request.url).searchParams.get('id'); + if (!id) throw new ContentError('An ID is required'); + await withContentLock(() => deleteContent(getDatabase(), resource, id)); + return json({ ok: true }); + } catch (error) { return apiError(error); } +} diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..a19a21d --- /dev/null +++ b/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,14 @@ +import { getAuth, siteOrigin } from '@/server/auth'; +import { apiError, boundedBody } from '@/server/http'; +import { ContentError } from '@/server/content'; + +export async function GET(request: Request) { + try { return await getAuth().handler(request); } catch (error) { return apiError(error); } +} +export async function POST(request: Request) { + try { + if (request.headers.get('origin') !== siteOrigin()) throw new ContentError('Invalid request origin', 403); + const body = await boundedBody(request, 16 * 1024); + return await getAuth().handler(new Request(request.url, { method: 'POST', headers: request.headers, body: new Uint8Array(body) })); + } catch (error) { return apiError(error); } +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..ffec187 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,7 @@ +import { getDatabase } from '@/server/db'; +export async function GET() { + try { + const row = getDatabase().sqlite.prepare('SELECT id FROM site_settings WHERE id = 1').get(); + return Response.json({ ok: Boolean(row) }, { status: row ? 200 : 503, headers: { 'Cache-Control': 'no-store' } }); + } catch { return Response.json({ ok: false }, { status: 503 }); } +} diff --git a/src/server/auth.ts b/src/server/auth.ts new file mode 100644 index 0000000..dac80c4 --- /dev/null +++ b/src/server/auth.ts @@ -0,0 +1,27 @@ +import { betterAuth } from 'better-auth'; +import { drizzleAdapter } from 'better-auth/adapters/drizzle'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { dataDirectory, getDatabase } from './db'; +import * as schema from './schema'; + +export function siteOrigin() { + const value = process.env.SITE_URL || (process.env.NODE_ENV === 'production' ? '' : 'http://localhost:3000'); + if (!value) throw new Error('SITE_URL is required in production'); + return new URL(value).origin; +} +export function makeAuth() { + const secret = process.env.BETTER_AUTH_SECRET || readFileSync(/* turbopackIgnore: true */ path.join(dataDirectory(), 'auth-secret'), 'utf8').trim(); + if (secret.length < 32) throw new Error('Authentication secret must contain at least 32 characters'); + return betterAuth({ + appName: 'Portfolio Builder', baseURL: siteOrigin(), secret, + database: drizzleAdapter(getDatabase().db, { provider: 'sqlite', schema }), + emailAndPassword: { enabled: true, disableSignUp: true, minPasswordLength: 12, maxPasswordLength: 128 }, + trustedOrigins: [siteOrigin()], + session: { expiresIn: 60 * 60 * 24 * 7, updateAge: 60 * 60 * 24, cookieCache: { enabled: false } }, + rateLimit: { enabled: true, window: 60, max: 60, customRules: { '/sign-in/email': { window: 60, max: 5 } } }, + advanced: { useSecureCookies: siteOrigin().startsWith('https://') }, + }); +} +let auth: ReturnType | undefined; +export const getAuth = () => auth ??= makeAuth(); diff --git a/src/server/http.ts b/src/server/http.ts new file mode 100644 index 0000000..090ea44 --- /dev/null +++ b/src/server/http.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; +import { getAuth, siteOrigin } from './auth'; +import { ContentError } from './content'; + +export async function requireOwner(request: Request) { + const session = await getAuth().api.getSession({ headers: request.headers }); + if (!session) throw new ContentError('Sign in to continue', 401); + if (!['GET', 'HEAD'].includes(request.method) && request.headers.get('origin') !== siteOrigin()) throw new ContentError('Invalid request origin', 403); + return session.user; +} +export async function boundedBody(request: Request, limit = 2 * 1024 * 1024) { + if (Number(request.headers.get('content-length')) > limit) throw new ContentError('Request is too large', 413); + const reader = request.body?.getReader(); + if (!reader) return Buffer.alloc(0); + const chunks: Uint8Array[] = []; let size = 0; + try { + while (true) { + const { done, value } = await reader.read(); if (done) break; + size += value.length; + if (size > limit) { await reader.cancel(); throw new ContentError('Request is too large', 413); } + chunks.push(value); + } + } finally { reader.releaseLock(); } + return Buffer.concat(chunks); +} +export async function readJson(request: Request) { + if (!request.headers.get('content-type')?.startsWith('application/json')) throw new ContentError('Expected application/json', 415); + const body = await boundedBody(request); + try { return JSON.parse(body.toString('utf8')) as unknown; } + catch { throw new ContentError('Invalid JSON'); } +} +export function json(data: unknown, status = 200) { return Response.json(data, { status, headers: { 'Cache-Control': 'no-store' } }); } +export function apiError(error: unknown) { + if (error instanceof z.ZodError) return json({ error: 'Please correct the highlighted fields', issues: error.issues.map(issue => ({ path: issue.path.join('.'), message: issue.message })) }, 422); + if (error instanceof ContentError) return json({ error: error.message }, error.status); + console.error('Portfolio API failed', error instanceof Error ? error.message : 'Unknown error'); + return json({ error: 'The request could not be completed' }, 500); +} diff --git a/src/server/owner.ts b/src/server/owner.ts new file mode 100644 index 0000000..61e0223 --- /dev/null +++ b/src/server/owner.ts @@ -0,0 +1,26 @@ +import { randomUUID } from 'node:crypto'; +import { hashPassword } from 'better-auth/crypto'; +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; +import type { DatabaseConnection } from './db'; +import { user, account, session } from './schema'; +import { ContentError } from './content'; + +export async function provisionOwner(connection: DatabaseConnection, email: string, password: string, reset = false) { + z.email().parse(email); + z.string().min(12).max(128).parse(password); + const hash = await hashPassword(password); + connection.db.transaction(db => { + const owner = db.select().from(user).get(); + if (reset) { + if (!owner || owner.email !== email.toLowerCase()) throw new ContentError('Owner email does not match'); + db.update(account).set({ password: hash, updatedAt: new Date() }).where(eq(account.userId, owner.id)).run(); + db.delete(session).where(eq(session.userId, owner.id)).run(); + } else { + if (owner) throw new ContentError('An owner already exists; use reset-password'); + const id = randomUUID(); const now = new Date(); + db.insert(user).values({ id, name: 'Portfolio owner', email: email.toLowerCase(), emailVerified: true, createdAt: now, updatedAt: now }).run(); + db.insert(account).values({ id: randomUUID(), accountId: id, providerId: 'credential', userId: id, password: hash, createdAt: now, updatedAt: now }).run(); + } + }, { behavior: 'immediate' }); +} diff --git a/src/server/write-lock.ts b/src/server/write-lock.ts new file mode 100644 index 0000000..803598c --- /dev/null +++ b/src/server/write-lock.ts @@ -0,0 +1,17 @@ +import { closeSync, openSync, unlinkSync } from 'node:fs'; +import path from 'node:path'; +import { dataDirectory } from './db'; +import { ContentError } from './content'; + +// Serialize content writes with backups, including upload files and their database rows. +export async function withContentLock(operation: () => T | Promise, directory = dataDirectory()): Promise { + const filename = path.join(directory, 'content.lock'); + let handle: number; + try { handle = openSync(filename, 'wx', 0o600); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new ContentError('A save or backup is in progress. Try again shortly.', 503); + throw error; + } + try { return await operation(); } + finally { closeSync(handle); unlinkSync(filename); } +}