feat: protect management APIs with owner authentication
This commit is contained in:
39
src/app/api/admin/[resource]/route.ts
Normal file
39
src/app/api/admin/[resource]/route.ts
Normal 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); }
|
||||||
|
}
|
||||||
14
src/app/api/auth/[...all]/route.ts
Normal file
14
src/app/api/auth/[...all]/route.ts
Normal 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); }
|
||||||
|
}
|
||||||
7
src/app/api/health/route.ts
Normal file
7
src/app/api/health/route.ts
Normal 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
27
src/server/auth.ts
Normal 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
38
src/server/http.ts
Normal 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
26
src/server/owner.ts
Normal 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
17
src/server/write-lock.ts
Normal 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); }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user