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,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 ?? [];
|
||||
}
|
||||
Reference in New Issue
Block a user