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:
2026-07-23 11:11:08 +00:00
commit b46dbbe889
26 changed files with 9651 additions and 0 deletions
+99
View File
@@ -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 ?? [];
}