feat: protect management APIs with owner authentication

This commit is contained in:
2026-09-15 21:47:48 +03:30
parent 77114c57cf
commit 199f0d9a3f
7 changed files with 168 additions and 0 deletions

View File

@@ -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); }
}

View File

@@ -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); }
}

View File

@@ -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 }); }
}

27
src/server/auth.ts Normal file
View File

@@ -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<typeof makeAuth> | undefined;
export const getAuth = () => auth ??= makeAuth();

38
src/server/http.ts Normal file
View File

@@ -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);
}

26
src/server/owner.ts Normal file
View File

@@ -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' });
}

17
src/server/write-lock.ts Normal file
View File

@@ -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<T>(operation: () => T | Promise<T>, directory = dataDirectory()): Promise<T> {
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); }
}