feat: scaffold klarbild foundation (astro+postgres+s3, auth, migrations, health)
- Astro 5 SSR (node standalone) + React, OKLCH tokens (no tailwind) - migrations/001_init.sql: full schema per 03-datenmodell-api - lib: db+migrations, crypto (AES-256-GCM), auth (argon2+signed session, ratelimit), storage (S3/MinIO, presigned URLs), openrouter (POST /v1/images, cost) - middleware: init-once + session guard + admin gate; /api/health (db+storage) - login + studio placeholder; seeds (till/lea, default recipes); Dockerfile - verified: astro build passes
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import argon2 from 'argon2';
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { one } from './db';
|
||||
|
||||
export interface SessionUser { uid: string; role: 'user' | 'admin'; name: string; }
|
||||
|
||||
const COOKIE = 'kb_session';
|
||||
const MAX_AGE = 60 * 60 * 24 * 30; // 30 Tage
|
||||
|
||||
export const hashPassword = (pw: string) => argon2.hash(pw, { type: argon2.argon2id });
|
||||
export const verifyPassword = (hash: string, pw: string) => argon2.verify(hash, pw).catch(() => false);
|
||||
|
||||
function secret(): string {
|
||||
const s = process.env.SESSION_SECRET;
|
||||
if (!s) throw new Error('SESSION_SECRET fehlt');
|
||||
return s;
|
||||
}
|
||||
|
||||
function sign(data: string): string {
|
||||
return createHmac('sha256', secret()).update(data).digest('base64url');
|
||||
}
|
||||
|
||||
export function makeSessionCookie(u: SessionUser): string {
|
||||
const payload = Buffer.from(JSON.stringify({ ...u, iat: Math.floor(Date.now() / 1000) }))
|
||||
.toString('base64url');
|
||||
const value = `${payload}.${sign(payload)}`;
|
||||
return `${COOKIE}=${value}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${MAX_AGE}`;
|
||||
}
|
||||
|
||||
export const clearSessionCookie = () =>
|
||||
`${COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;
|
||||
|
||||
export function readSession(cookieHeader?: string | null): SessionUser | null {
|
||||
if (!cookieHeader) return null;
|
||||
const raw = cookieHeader.split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1);
|
||||
if (!raw) return null;
|
||||
const [payload, sig] = raw.split('.');
|
||||
if (!payload || !sig) return null;
|
||||
const expected = sign(payload);
|
||||
const a = Buffer.from(sig), b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
try {
|
||||
const obj = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
||||
return { uid: obj.uid, role: obj.role, name: obj.name };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string): Promise<SessionUser | null> {
|
||||
const u = await one<{ id: string; password_hash: string; role: 'user' | 'admin'; display_name: string }>(
|
||||
'SELECT id, password_hash, role, display_name FROM users WHERE username=$1', [username]);
|
||||
if (!u) return null;
|
||||
if (!(await verifyPassword(u.password_hash, password))) return null;
|
||||
return { uid: u.id, role: u.role, name: u.display_name || username };
|
||||
}
|
||||
|
||||
// --- einfacher In-Memory-Ratelimiter: max 5 Fehlversuche / 15 Min pro IP ---
|
||||
const attempts = new Map<string, { count: number; until: number }>();
|
||||
export function rateLimited(ip: string): boolean {
|
||||
const e = attempts.get(ip);
|
||||
return !!e && e.count >= 5 && Date.now() < e.until;
|
||||
}
|
||||
export function noteFailure(ip: string): void {
|
||||
const now = Date.now();
|
||||
const e = attempts.get(ip);
|
||||
if (!e || now >= e.until) attempts.set(ip, { count: 1, until: now + 15 * 60_000 });
|
||||
else e.count++;
|
||||
}
|
||||
export function clearFailures(ip: string): void { attempts.delete(ip); }
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
||||
|
||||
// AES-256-GCM. ENCRYPTION_KEY = 64 Hex-Zeichen (32 Byte).
|
||||
function key(): Buffer {
|
||||
const hex = process.env.ENCRYPTION_KEY || '';
|
||||
if (hex.length !== 64) {
|
||||
throw new Error('ENCRYPTION_KEY muss 64 Hex-Zeichen (32 Byte) lang sein');
|
||||
}
|
||||
return Buffer.from(hex, 'hex');
|
||||
}
|
||||
|
||||
/** Verschlüsselt Klartext → "iv.tag.ciphertext" (base64url-Teile). */
|
||||
export function encrypt(plain: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key(), iv);
|
||||
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return [iv, tag, enc].map((b) => b.toString('base64url')).join('.');
|
||||
}
|
||||
|
||||
export function decrypt(payload: string): string {
|
||||
const [ivB, tagB, dataB] = payload.split('.');
|
||||
const decipher = createDecipheriv('aes-256-gcm', key(), Buffer.from(ivB, 'base64url'));
|
||||
decipher.setAuthTag(Buffer.from(tagB, 'base64url'));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(dataB, 'base64url')),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
}
|
||||
|
||||
/** Maskiert einen Schlüssel für die Anzeige: sk-or-…4f2a */
|
||||
export function maskSecret(secret: string): string {
|
||||
if (!secret) return '';
|
||||
const tail = secret.slice(-4);
|
||||
const head = secret.slice(0, Math.min(5, secret.length));
|
||||
return `${head}…${tail}`;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import pg from 'pg';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: 10,
|
||||
});
|
||||
|
||||
export async function query<T = any>(text: string, params?: any[]): Promise<T[]> {
|
||||
const res = await pool.query(text, params);
|
||||
return res.rows as T[];
|
||||
}
|
||||
|
||||
export async function one<T = any>(text: string, params?: any[]): Promise<T | null> {
|
||||
const rows = await query<T>(text, params);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/** Wendet migrations/*.sql der Reihe nach an, verfolgt in schema_migrations. */
|
||||
export async function runMigrations(): Promise<void> {
|
||||
await pool.query(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`);
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const dir = join(here, '..', '..', 'migrations');
|
||||
const files = readdirSync(dir).filter((f) => f.endsWith('.sql')).sort();
|
||||
for (const file of files) {
|
||||
const done = await one('SELECT name FROM schema_migrations WHERE name=$1', [file]);
|
||||
if (done) continue;
|
||||
const sql = readFileSync(join(dir, file), 'utf8');
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query(sql);
|
||||
await client.query('INSERT INTO schema_migrations(name) VALUES($1)', [file]);
|
||||
await client.query('COMMIT');
|
||||
console.log(`[migrate] applied ${file}`);
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { runMigrations } from './db';
|
||||
import { ensureBucket } from './storage';
|
||||
import { seed } from './seed';
|
||||
|
||||
let started: Promise<void> | null = null;
|
||||
|
||||
/** Einmalige Server-Initialisierung (idempotent, beim ersten Request ausgelöst). */
|
||||
export function ensureInit(): Promise<void> {
|
||||
if (!started) {
|
||||
started = (async () => {
|
||||
await runMigrations();
|
||||
try { await ensureBucket(); } catch (e) { console.error('[init] S3 nicht bereit:', e); }
|
||||
await seed();
|
||||
console.log('[init] Klarbild bereit.');
|
||||
// TODO (Phase Bildkern): pg-boss-Worker hier starten.
|
||||
})().catch((e) => { started = null; throw e; });
|
||||
}
|
||||
return started;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { one } from './db';
|
||||
import { decrypt } from './crypto';
|
||||
|
||||
const BASE = 'https://openrouter.ai/api/v1';
|
||||
|
||||
/** Schlüssel: DB-Wert (Admin) hat Vorrang vor der Umgebungsvariable. */
|
||||
export async function resolveKey(): Promise<string> {
|
||||
const row = await one<{ openrouter_key_enc: string | null }>(
|
||||
'SELECT openrouter_key_enc FROM settings WHERE id=1');
|
||||
if (row?.openrouter_key_enc) {
|
||||
try { return decrypt(row.openrouter_key_enc); } catch { /* fällt auf ENV zurück */ }
|
||||
}
|
||||
return process.env.OPENROUTER_API_KEY || '';
|
||||
}
|
||||
|
||||
export class OpenRouterError extends Error {
|
||||
constructor(public status: number, public friendly: string, msg?: string) {
|
||||
super(msg || friendly);
|
||||
}
|
||||
}
|
||||
|
||||
function friendlyFor(status: number): string {
|
||||
if (status === 402) return 'Guthaben erschöpft — gerade nicht möglich.';
|
||||
if (status === 429) return 'Dienst überlastet — wird automatisch erneut versucht.';
|
||||
if (status >= 500) return 'Dienst vorübergehend gestört.';
|
||||
if (status === 401 || status === 403) return 'Zugang zum Bilddienst nicht möglich.';
|
||||
return 'Bildbearbeitung fehlgeschlagen.';
|
||||
}
|
||||
|
||||
export interface GenerateOpts {
|
||||
model: string;
|
||||
prompt: string;
|
||||
inputUrl?: string; // vorsignierte URL des Originals
|
||||
aspectRatio?: string; // "16:9", "3:4" …
|
||||
resolution?: string; // "2K" | "4K" — NICHT zusammen mit expliziten Pixeln
|
||||
background?: 'transparent' | 'opaque';
|
||||
outputFormat?: 'png' | 'jpeg' | 'webp';
|
||||
seed?: number;
|
||||
}
|
||||
|
||||
export interface GenerateResult {
|
||||
buffer: Buffer;
|
||||
mediaType: string;
|
||||
cost: number;
|
||||
model: string;
|
||||
}
|
||||
|
||||
/** Dedizierter Bild-Endpunkt POST /api/v1/images (nicht chat/completions). */
|
||||
export async function generateImage(opts: GenerateOpts): Promise<GenerateResult> {
|
||||
const key = await resolveKey();
|
||||
if (!key) throw new OpenRouterError(401, friendlyFor(401), 'Kein OpenRouter-Key hinterlegt');
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
model: opts.model,
|
||||
prompt: opts.prompt,
|
||||
n: 1,
|
||||
output_format: opts.outputFormat || 'png',
|
||||
};
|
||||
if (opts.inputUrl) body.input_references = [{ url: opts.inputUrl }];
|
||||
if (opts.aspectRatio) body.aspect_ratio = opts.aspectRatio;
|
||||
if (opts.resolution && !opts.aspectRatio) body.resolution = opts.resolution; // nie beides
|
||||
if (opts.background) body.background = opts.background;
|
||||
if (typeof opts.seed === 'number') body.seed = opts.seed;
|
||||
|
||||
const res = await fetch(`${BASE}/images`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Title': 'Klarbild',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new OpenRouterError(res.status, friendlyFor(res.status),
|
||||
`OpenRouter ${res.status}: ${await res.text().catch(() => '')}`.slice(0, 300));
|
||||
}
|
||||
|
||||
const json: any = await res.json();
|
||||
const b64 = json?.data?.[0]?.b64_json;
|
||||
if (!b64) throw new OpenRouterError(502, 'Kein Bild erhalten.', 'Antwort ohne b64_json');
|
||||
return {
|
||||
buffer: Buffer.from(b64, 'base64'),
|
||||
mediaType: json.data[0].media_type || 'image/png',
|
||||
cost: Number(json?.usage?.cost ?? 0),
|
||||
model: opts.model,
|
||||
};
|
||||
}
|
||||
|
||||
/** Verfügbare Bildmodelle (für die Admin-Modellliste). */
|
||||
export async function listImageModels(): Promise<any[]> {
|
||||
const key = await resolveKey();
|
||||
const res = await fetch(`${BASE}/images/models`, {
|
||||
headers: { Authorization: `Bearer ${key}` },
|
||||
});
|
||||
if (!res.ok) throw new OpenRouterError(res.status, friendlyFor(res.status));
|
||||
return (await res.json())?.data ?? [];
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { one, query } from './db';
|
||||
import { hashPassword } from './auth';
|
||||
|
||||
/** Legt beim Erststart Nutzer, Standard-Rezepte, Einstellungen und Modelle an. */
|
||||
export async function seed(): Promise<void> {
|
||||
// Einstellungen (eine Zeile)
|
||||
await query(`INSERT INTO settings (id) VALUES (1) ON CONFLICT (id) DO NOTHING`);
|
||||
|
||||
// Nutzer
|
||||
const till = await one(`SELECT id FROM users WHERE username='till'`);
|
||||
if (!till) {
|
||||
await query(
|
||||
`INSERT INTO users (username, role, display_name, password_hash) VALUES ($1,'admin','Till',$2)`,
|
||||
['till', await hashPassword(process.env.SEED_TILL_PASSWORD || 'klarbild-till')]);
|
||||
}
|
||||
const lea = await one(`SELECT id FROM users WHERE username='lea'`);
|
||||
if (!lea) {
|
||||
await query(
|
||||
`INSERT INTO users (username, role, display_name, password_hash) VALUES ($1,'user','Lea',$2)`,
|
||||
['lea', await hashPassword(process.env.SEED_LEA_PASSWORD || 'klarbild-lea')]);
|
||||
}
|
||||
|
||||
// Standard-Rezepte
|
||||
const count = await one<{ n: string }>(`SELECT count(*)::text AS n FROM recipes`);
|
||||
if (count && Number(count.n) === 0) {
|
||||
const admin = await one<{ id: string }>(`SELECT id FROM users WHERE username='till'`);
|
||||
const by = admin?.id ?? null;
|
||||
const R = (name: string, tasks: string[], fmt: string, orient: string, extra: Record<string, unknown> = {}) =>
|
||||
query(
|
||||
`INSERT INTO recipes (name, tasks, output_format, orientation, crop_mode, dpi, delivery, is_default, created_by, contour_mm)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
|
||||
[name, JSON.stringify(tasks), fmt, orient, (extra.crop_mode as string) || 'crop', 300,
|
||||
(extra.delivery as string) || 'library', !!extra.is_default, by, (extra.contour_mm as number) ?? null]);
|
||||
|
||||
await R('Nur bereinigen', ['clean'], 'keep', 'landscape', { is_default: true });
|
||||
await R('Poster 30×40', ['clean', 'format'], '30x40', 'portrait');
|
||||
await R('The Frame', ['clean', 'format'], 'theframe', 'landscape');
|
||||
await R('Sticker 5 cm', ['clean', 'cutout', 'format', 'contour'], 'sticker5', 'landscape',
|
||||
{ contour_mm: 3 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand,
|
||||
CreateBucketCommand, HeadBucketCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
|
||||
const bucket = process.env.S3_BUCKET || 'klarbild';
|
||||
|
||||
export const s3 = new S3Client({
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
region: process.env.S3_REGION || 'us-east-1',
|
||||
forcePathStyle: (process.env.S3_FORCE_PATH_STYLE ?? 'true') === 'true',
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY || '',
|
||||
secretAccessKey: process.env.S3_SECRET_KEY || '',
|
||||
},
|
||||
});
|
||||
|
||||
export async function ensureBucket(): Promise<void> {
|
||||
try {
|
||||
await s3.send(new HeadBucketCommand({ Bucket: bucket }));
|
||||
} catch {
|
||||
await s3.send(new CreateBucketCommand({ Bucket: bucket }));
|
||||
}
|
||||
}
|
||||
|
||||
export async function putObject(key: string, body: Buffer, contentType: string): Promise<string> {
|
||||
await s3.send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: body, ContentType: contentType }));
|
||||
return key;
|
||||
}
|
||||
|
||||
export async function getObject(key: string): Promise<Buffer> {
|
||||
const res = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const c of res.Body as any) chunks.push(Buffer.from(c));
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
export async function deleteObject(key: string): Promise<void> {
|
||||
await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
|
||||
}
|
||||
|
||||
/** Vorsignierte GET-URL (Standard 15 Min) — u.a. für OpenRouter input_references. */
|
||||
export function presignGet(key: string, expiresIn = 900): Promise<string> {
|
||||
return getSignedUrl(s3, new GetObjectCommand({ Bucket: bucket, Key: key }), { expiresIn });
|
||||
}
|
||||
|
||||
export function sourceKey(uuid: string, ext: string): string {
|
||||
return `sources/${new Date().getFullYear()}/${uuid}.${ext}`;
|
||||
}
|
||||
export function resultKey(uuid: string): string {
|
||||
return `results/${new Date().getFullYear()}/${uuid}.png`;
|
||||
}
|
||||
Reference in New Issue
Block a user