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

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