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,33 @@
|
||||
# ---- Klarbild — Umgebungsvariablen (ohne echte Werte committen!) ----
|
||||
|
||||
# Datenbank (Postgres)
|
||||
DATABASE_URL=postgres://klarbild:CHANGEME@klarbild-db:5432/klarbild
|
||||
|
||||
# Objektspeicher (S3-kompatibel, z. B. MinIO)
|
||||
S3_ENDPOINT=http://klarbild-minio:9000
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=klarbild
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# Sitzungen & Verschlüsselung
|
||||
SESSION_SECRET= # zufällig, lang; signiert Session-Cookies
|
||||
ENCRYPTION_KEY= # 32 Byte hex (64 Zeichen); verschlüsselt Keys/Passwörter in der DB
|
||||
|
||||
# OpenRouter (Rückfallebene; DB-Wert aus dem Admin hat Vorrang)
|
||||
OPENROUTER_API_KEY=
|
||||
|
||||
# Telegram (Rückfallebene; DB-Wert hat Vorrang). Webhook wird nach Deploy registriert.
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
|
||||
# Optional
|
||||
N8N_WEBHOOK_URL=
|
||||
PUBLIC_BASE_URL=https://klarbild.heidrich-digital.de
|
||||
|
||||
# Erststart-Passwörter (nur beim ersten Seed genutzt, danach im Admin änderbar)
|
||||
SEED_TILL_PASSWORD=
|
||||
SEED_LEA_PASSWORD=
|
||||
|
||||
# Laufzeit
|
||||
PORT=4321
|
||||
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
node_modules
|
||||
dist
|
||||
.astro
|
||||
*.log
|
||||
uploads/
|
||||
@@ -0,0 +1,49 @@
|
||||
# CLAUDE.md — Klarbild
|
||||
|
||||
Arbeitsanweisung für den bauenden Agenten. Führend: `01-anforderungen.md` (Was),
|
||||
`06-technik-und-api.md` (Wie), `03-datenmodell-api.md` (Schema/Endpunkte),
|
||||
`07-repo-und-deployment.md` (Struktur/Deploy). Diese Docs liegen im Handover-Ordner
|
||||
`Imagetool-Lea/` (00,01,03,06,07; 02/04/05 stehen noch aus).
|
||||
|
||||
## Stack (fest)
|
||||
Astro 5 (`@astrojs/node`, `output: server`) + React-Inseln · Postgres · S3 (MinIO) ·
|
||||
pg-boss (Worker im selben Prozess, `worker.ts`) · sharp · grammY · argon2 · AES-256-GCM.
|
||||
Kein Tailwind. OKLCH-Tokens in `src/styles/tokens.css`. Gitea → Coolify → Hetzner.
|
||||
|
||||
## Konventionen
|
||||
- Commits **englisch**, `feat:`/`fix:`, **ein Commit je Bauschritt** (kein Riesen-Commit).
|
||||
- Migrationen als nummerierte SQL in `migrations/`, beim Start automatisch (kein ORM-Push).
|
||||
- **Niemals committen:** OpenRouter-Key, Picdrop-Zugang, Telegram-Token, SESSION_SECRET.
|
||||
Vor Push prüfen: `git log -p | grep -iE "sk-or-|password|token"`.
|
||||
- Bild-API: **`POST https://openrouter.ai/api/v1/images`** (nicht chat/completions).
|
||||
`aspect_ratio` **oder** `resolution`, nie beides. `background:"transparent"` fürs Freistellen.
|
||||
`usage.cost` je Position speichern → echter Kostendeckel.
|
||||
- Pipeline lokal mit **sharp**: `px = round(cm/2.54*dpi)`, `fit:'cover', position:'attention'`,
|
||||
`withMetadata({ density: dpi })`. Sticker-Kontur aus Alpha (Dilatation), ohne Modell.
|
||||
- Fortschritt per Polling (2s) oder SSE, **kein WebSocket**.
|
||||
|
||||
## Bau-Reihenfolge (je Schritt ein Commit)
|
||||
1. **Fundament** (fertig): Struktur, `001_init.sql`, db/crypto/auth/storage/openrouter, Middleware,
|
||||
`/api/health`, Login, Seeds, Dockerfile, Tokens.
|
||||
2. **Bildkern:** `pipeline.ts` (sharp, Formatberechnung, Kontur), `queue.ts` + `worker.ts` (pg-boss),
|
||||
Verarbeitungskette je Position, Dateinamen-Vorschlag.
|
||||
3. **Studio-UI:** Upload (Datei/DnD/Paste, HEIC, 100 Bilder), Rezept-Builder + Gültigkeitsregeln,
|
||||
Format + Beschnitt-Vorschau, Queue-Ansicht, Ergebnisse (Vorher/Nachher, Download/ZIP).
|
||||
4. **Bibliothek/Ordner/Druck/Freigabelinks.**
|
||||
5. **Picdrop** (`picdrop.ts` FTPS/SFTP) + **Sticker-Kontur**.
|
||||
6. **Admin** (Key/Picdrop/Modelle/Nutzer/Statistik/Kostendeckel/Voreinstellungen).
|
||||
7. **Telegram** (`telegram.ts` grammY-Webhook) — **vollwertiger Website-Ersatz**.
|
||||
8. **Tutorial/How-to (Seite + PDF), PWA.**
|
||||
9. **Deploy + 10 Nachweise (07 §6)** + Reviews (design-taste-frontend, ui-ux-design-pro, code-review).
|
||||
|
||||
## Infra (angelegt)
|
||||
- Repo: `till/klarbild` (privat, Gitea).
|
||||
- Coolify-Projekt **klarbild** `g7qykmucnh9kckoqvjnppgsq`, Env production `v653gw0as7ty44p87m2aj7vz`, Server CX33.
|
||||
- Postgres `nrddt6fyb86lok7nojjcb57v` (DB/User `klarbild`), interner Host = DB-UUID:5432.
|
||||
- MinIO: als Compose-Dienst (steht noch aus, S3_* in .env).
|
||||
- Telegram-Token vorhanden (Prototyp @Klarbildbot läuft per Polling bis Umschaltung auf Webhook).
|
||||
|
||||
## Nachweis „fertig" (07 §6) — erst danach „lauffähig" melden
|
||||
docker build ok · /api/health grün · beide Logins, Lea ohne /admin · Poster 30×40 = exakt 3543×4724 @300dpi ·
|
||||
Sticker mit Alpha + weißer Kontur · 20er-Stapel überlebt Browser-Neustart · Position einzeln wiederholbar ·
|
||||
Picdrop-Test grün · 20 Bilder an Bot → ein Auftrag, eine Rückmeldung · Kostendeckel greift.
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
FROM node:20-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production PORT=4321 HOST=0.0.0.0
|
||||
|
||||
# Build-Tools für native Module (argon2). sharp bringt libvips prebuilt mit.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 4321
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||4321)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Klarbild
|
||||
|
||||
Verwandelt Screenshots und Fundstücke in saubere, druckfertige Bilder — Rahmen und
|
||||
Shop-Oberflächen entfernen, exakte Fotoformate (30×40 cm etc.), The-Frame-Querformat,
|
||||
für den Cricut freistellen, und automatisch nach Picdrop ausliefern.
|
||||
|
||||
**Claim:** „Screenshot rein, sauberes Bild raus."
|
||||
|
||||
## Stack
|
||||
Astro 5 (SSR, Node standalone) + React-Inseln · Postgres · S3 (MinIO) · pg-boss ·
|
||||
sharp · grammY (Telegram) · OKLCH-Tokens, kein Tailwind. Deploy: Gitea → Coolify → Hetzner.
|
||||
|
||||
## Entwicklung
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env # Werte eintragen
|
||||
npm run dev
|
||||
```
|
||||
Migrationen und Seeds laufen beim Serverstart automatisch. Erststart-Passwörter über
|
||||
`SEED_TILL_PASSWORD` / `SEED_LEA_PASSWORD`.
|
||||
|
||||
## Status
|
||||
Im Aufbau. Reihenfolge und Nachweise siehe `CLAUDE.md`. Anforderungen im Handover-Paket
|
||||
(`Imagetool-Lea/00..07`). Führend: `01-anforderungen.md`.
|
||||
@@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import node from '@astrojs/node';
|
||||
import react from '@astrojs/react';
|
||||
|
||||
// Klarbild läuft als Node-Server (SSR) mit In-Process-Warteschlange.
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
adapter: node({ mode: 'standalone' }),
|
||||
integrations: [react()],
|
||||
server: { host: true, port: Number(process.env.PORT) || 4321 },
|
||||
vite: {
|
||||
ssr: {
|
||||
// Native Module nicht bündeln
|
||||
external: ['sharp', 'argon2', 'pg', 'pg-boss', 'ssh2-sftp-client', 'basic-ftp', 'heic-convert'],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
-- Klarbild — Initiales Schema (siehe 03-datenmodell-api.md)
|
||||
-- Wird beim Start automatisch angewandt (nummerierte SQL-Dateien, keine ORM-Magie).
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- Nutzerinnen -------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username text UNIQUE NOT NULL,
|
||||
password_hash text NOT NULL,
|
||||
role text NOT NULL DEFAULT 'user' CHECK (role IN ('user','admin')),
|
||||
display_name text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Rezepte (gespeicherte Voreinstellungen) --------------------------------
|
||||
CREATE TABLE IF NOT EXISTS recipes (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
tasks jsonb NOT NULL DEFAULT '[]'::jsonb, -- z.B. ["clean","format","deliver"]
|
||||
output_format text,
|
||||
orientation text,
|
||||
crop_mode text,
|
||||
dpi int NOT NULL DEFAULT 300,
|
||||
contour_mm numeric,
|
||||
model_key text,
|
||||
delivery text NOT NULL DEFAULT 'library' CHECK (delivery IN ('library','picdrop','both')),
|
||||
picdrop_gallery text,
|
||||
custom_instruction text,
|
||||
is_default bool NOT NULL DEFAULT false,
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Aufträge (ein Stapel) ---------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
origin text NOT NULL DEFAULT 'web' CHECK (origin IN ('web','telegram')),
|
||||
recipe_snapshot jsonb NOT NULL,
|
||||
status text NOT NULL DEFAULT 'queued'
|
||||
CHECK (status IN ('queued','running','paused','done','cancelled')),
|
||||
total int NOT NULL DEFAULT 0,
|
||||
done_count int NOT NULL DEFAULT 0,
|
||||
failed_count int NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
finished_at timestamptz
|
||||
);
|
||||
|
||||
-- Ordner ------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
picdrop_gallery text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Positionen (ein Bild in einem Auftrag) ---------------------------------
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
job_id uuid REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
position int NOT NULL DEFAULT 0,
|
||||
status text NOT NULL DEFAULT 'queued'
|
||||
CHECK (status IN ('queued','running','done','failed','skipped')),
|
||||
attempts int NOT NULL DEFAULT 0,
|
||||
error_message text,
|
||||
source_path text,
|
||||
result_path text,
|
||||
filename text,
|
||||
output_px text,
|
||||
source_quality text CHECK (source_quality IN ('original','compressed')),
|
||||
dpi int,
|
||||
has_alpha bool NOT NULL DEFAULT false,
|
||||
model_used text,
|
||||
prompt_used text,
|
||||
cost numeric, -- tatsächliche Kosten aus usage.cost
|
||||
folder_id uuid REFERENCES folders(id) ON DELETE SET NULL,
|
||||
delivery_status text NOT NULL DEFAULT 'none'
|
||||
CHECK (delivery_status IN ('none','pending','delivered','failed')),
|
||||
delivered_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS items_job_idx ON items(job_id);
|
||||
CREATE INDEX IF NOT EXISTS items_created_idx ON items(created_at DESC);
|
||||
|
||||
-- Druckaufträge -----------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS print_jobs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||
note text,
|
||||
status text NOT NULL DEFAULT 'open' CHECK (status IN ('open','done')),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS print_job_items (
|
||||
print_job_id uuid REFERENCES print_jobs(id) ON DELETE CASCADE,
|
||||
item_id uuid REFERENCES items(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (print_job_id, item_id)
|
||||
);
|
||||
|
||||
-- Freigabelinks -----------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS share_links (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
token text UNIQUE NOT NULL,
|
||||
scope_type text NOT NULL CHECK (scope_type IN ('selection','folder')),
|
||||
scope_ref jsonb NOT NULL,
|
||||
expires_at timestamptz,
|
||||
password_hash text,
|
||||
revoked bool NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Telegram ---------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS telegram_links (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
chat_id bigint UNIQUE NOT NULL,
|
||||
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
|
||||
default_recipe_id uuid REFERENCES recipes(id) ON DELETE SET NULL,
|
||||
active bool NOT NULL DEFAULT true,
|
||||
linked_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS telegram_pairing_codes (
|
||||
code text PRIMARY KEY,
|
||||
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
used_at timestamptz
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS telegram_drafts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
chat_id bigint NOT NULL,
|
||||
media_group_id text,
|
||||
file_refs jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
last_received_at timestamptz NOT NULL DEFAULT now(),
|
||||
status text NOT NULL DEFAULT 'collecting'
|
||||
CHECK (status IN ('collecting','awaiting_recipe','dispatched','discarded')),
|
||||
notice_message_id bigint
|
||||
);
|
||||
|
||||
-- Einstellungen (eine Zeile) ---------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id int PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
openrouter_key_enc text,
|
||||
picdrop_host text,
|
||||
picdrop_protocol text CHECK (picdrop_protocol IN ('ftps','sftp')),
|
||||
picdrop_port int,
|
||||
picdrop_user text,
|
||||
picdrop_password_enc text,
|
||||
picdrop_base_path text,
|
||||
default_dpi int NOT NULL DEFAULT 300,
|
||||
default_crop_mode text NOT NULL DEFAULT 'crop',
|
||||
concurrency int NOT NULL DEFAULT 2,
|
||||
cricut_sheet_cm text NOT NULL DEFAULT '17.1x23.5',
|
||||
monthly_budget numeric,
|
||||
n8n_webhook_url text,
|
||||
telegram_bot_token_enc text,
|
||||
telegram_webhook_secret text
|
||||
);
|
||||
|
||||
-- Modelle -----------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS models (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
model_id text NOT NULL,
|
||||
label text NOT NULL,
|
||||
description text,
|
||||
active bool NOT NULL DEFAULT true,
|
||||
is_default bool NOT NULL DEFAULT false,
|
||||
supports_alpha bool NOT NULL DEFAULT false,
|
||||
sort int NOT NULL DEFAULT 0
|
||||
);
|
||||
Generated
+8638
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "klarbild",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"start": "node ./dist/server/entry.mjs",
|
||||
"migrate": "node --loader tsx ./scripts/migrate.mjs",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^9.1.3",
|
||||
"@astrojs/react": "^4.2.1",
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
||||
"archiver": "^7.0.1",
|
||||
"argon2": "^0.41.1",
|
||||
"astro": "^5.5.0",
|
||||
"basic-ftp": "^5.0.5",
|
||||
"grammy": "^1.30.0",
|
||||
"heic-convert": "^2.1.0",
|
||||
"pg": "^8.13.1",
|
||||
"pg-boss": "^10.1.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"sharp": "^0.33.5",
|
||||
"ssh2-sftp-client": "^11.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.11.10",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
|
||||
declare namespace App {
|
||||
interface Locals {
|
||||
user: import('./lib/auth').SessionUser | null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
import '../styles/tokens.css';
|
||||
interface Props { title?: string }
|
||||
const { title = 'Klarbild' } = Astro.props;
|
||||
const user = Astro.locals.user;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0e5f6e" />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body>
|
||||
{user && (
|
||||
<header class="topbar">
|
||||
<a href="/" class="brand">Klarbild</a>
|
||||
<nav>
|
||||
<a href="/">Studio</a>
|
||||
<a href="/bibliothek">Bibliothek</a>
|
||||
<a href="/warteschlange">Warteschlange</a>
|
||||
{user.role === 'admin' && <a href="/admin">Admin</a>}
|
||||
</nav>
|
||||
<form method="post" action="/api/auth/logout" class="logout">
|
||||
<span>{user.name}</span>
|
||||
<button type="submit">Abmelden</button>
|
||||
</form>
|
||||
</header>
|
||||
)}
|
||||
<main class="wrap"><slot /></main>
|
||||
<style is:global>
|
||||
.wrap { max-width: var(--maxw); margin: 0 auto; padding: 24px 20px 80px; }
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 20px;
|
||||
padding: 12px 20px; border-bottom: 1px solid var(--line);
|
||||
background: var(--surface); position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
.brand { font-family: var(--font-display); font-weight: 600; font-size: 1.25rem;
|
||||
color: var(--ink); text-decoration: none; letter-spacing: -0.01em; }
|
||||
.topbar nav { display: flex; gap: 16px; margin-left: 8px; }
|
||||
.topbar nav a { color: var(--ink-soft); text-decoration: none; font-size: 0.95rem; }
|
||||
.topbar nav a:hover { color: var(--ink); }
|
||||
.logout { margin-left: auto; display: flex; align-items: center; gap: 10px;
|
||||
color: var(--ink-mute); font-size: 0.9rem; }
|
||||
.logout button { background: none; border: 1px solid var(--line); border-radius: 999px;
|
||||
padding: 5px 12px; color: var(--ink-soft); cursor: pointer; }
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineMiddleware } from 'astro:middleware';
|
||||
import { ensureInit } from './lib/init';
|
||||
import { readSession } from './lib/auth';
|
||||
|
||||
const PUBLIC_PATHS = [/^\/login/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//, /^\/api\/telegram\/webhook/];
|
||||
|
||||
export const onRequest = defineMiddleware(async (ctx, next) => {
|
||||
// Health/Webhook dürfen laufen, auch wenn Init noch hakt — sonst blockiert nichts.
|
||||
try { await ensureInit(); } catch (e) { if (ctx.url.pathname !== '/api/health') throw e; }
|
||||
|
||||
const user = readSession(ctx.request.headers.get('cookie'));
|
||||
ctx.locals.user = user;
|
||||
|
||||
const path = ctx.url.pathname;
|
||||
const isPublic = PUBLIC_PATHS.some((r) => r.test(path));
|
||||
if (!isPublic && !user) {
|
||||
if (path.startsWith('/api/')) return new Response('Unauthorized', { status: 401 });
|
||||
return ctx.redirect('/login');
|
||||
}
|
||||
if (path.startsWith('/api/admin') || path.startsWith('/admin')) {
|
||||
if (user?.role !== 'admin') {
|
||||
if (path.startsWith('/api/')) return new Response('Forbidden', { status: 403 });
|
||||
return ctx.redirect('/');
|
||||
}
|
||||
}
|
||||
return next();
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { login, makeSessionCookie, rateLimited, noteFailure, clearFailures } from '../../../lib/auth';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request, clientAddress }) => {
|
||||
const ip = clientAddress || 'unknown';
|
||||
if (rateLimited(ip)) {
|
||||
return new Response(JSON.stringify({ error: 'Zu viele Versuche. Bitte in 15 Minuten erneut.' }),
|
||||
{ status: 429, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
let username = '', password = '';
|
||||
const ct = request.headers.get('content-type') || '';
|
||||
if (ct.includes('application/json')) {
|
||||
const b = await request.json().catch(() => ({}));
|
||||
username = b.username || ''; password = b.password || '';
|
||||
} else {
|
||||
const f = await request.formData();
|
||||
username = String(f.get('username') || ''); password = String(f.get('password') || '');
|
||||
}
|
||||
|
||||
const user = await login(username.trim(), password);
|
||||
if (!user) {
|
||||
noteFailure(ip);
|
||||
return new Response(JSON.stringify({ error: 'Benutzername oder Passwort falsch.' }),
|
||||
{ status: 401, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
clearFailures(ip);
|
||||
return new Response(JSON.stringify({ ok: true, role: user.role }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json', 'Set-Cookie': makeSessionCookie(user) },
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { clearSessionCookie } from '../../../lib/auth';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json', 'Set-Cookie': clearSessionCookie() },
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { pool } from '../../lib/db';
|
||||
import { s3 } from '../../lib/storage';
|
||||
import { HeadBucketCommand } from '@aws-sdk/client-s3';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async () => {
|
||||
const out: Record<string, string> = { service: 'klarbild' };
|
||||
let ok = true;
|
||||
|
||||
try { await pool.query('SELECT 1'); out.db = 'ok'; }
|
||||
catch { out.db = 'fehler'; ok = false; }
|
||||
|
||||
try {
|
||||
await s3.send(new HeadBucketCommand({ Bucket: process.env.S3_BUCKET || 'klarbild' }));
|
||||
out.storage = 'ok';
|
||||
} catch { out.storage = 'fehler'; ok = false; }
|
||||
|
||||
out.queue = 'todo'; // wird mit pg-boss-Phase geprüft
|
||||
out.status = ok ? 'ok' : 'degraded';
|
||||
return new Response(JSON.stringify(out), {
|
||||
status: ok ? 200 : 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
const user = Astro.locals.user!;
|
||||
---
|
||||
<Base title="Studio · Klarbild">
|
||||
<section>
|
||||
<h1 class="display">Studio</h1>
|
||||
<p class="lead">Willkommen, {user.name}. Ziehe Screenshots herein, wähle ein Rezept — Klarbild macht daraus druckfertige Bilder.</p>
|
||||
<div class="soon card">
|
||||
<strong>Aufbau läuft.</strong>
|
||||
<p>Das Fundament steht (Login, Datenbank, Objektspeicher, Health). Als Nächstes: Upload, Rezepte, Warteschlange und die Bildpipeline.</p>
|
||||
</div>
|
||||
</section>
|
||||
<style>
|
||||
.display { font-family: var(--font-display); font-size: 2rem; margin: 8px 0 6px; letter-spacing: -0.02em; }
|
||||
.lead { color: var(--ink-soft); max-width: 60ch; }
|
||||
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
|
||||
padding: 20px; box-shadow: var(--shadow); margin-top: 20px; max-width: 60ch; }
|
||||
.card p { color: var(--ink-mute); margin: 6px 0 0; }
|
||||
</style>
|
||||
</Base>
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
if (Astro.locals.user) return Astro.redirect('/');
|
||||
---
|
||||
<Base title="Anmelden · Klarbild">
|
||||
<section class="login">
|
||||
<h1 class="display">Klarbild</h1>
|
||||
<p class="claim">Screenshot rein, sauberes Bild raus.</p>
|
||||
<form id="f" class="card">
|
||||
<label>Benutzername<input name="username" autocomplete="username" required autofocus /></label>
|
||||
<label>Passwort<input name="password" type="password" autocomplete="current-password" required /></label>
|
||||
<button type="submit">Anmelden</button>
|
||||
<p id="err" class="err" hidden></p>
|
||||
</form>
|
||||
</section>
|
||||
<style>
|
||||
.login { max-width: 360px; margin: 8vh auto 0; text-align: center; }
|
||||
.display { font-family: var(--font-display); font-size: 2.4rem; margin: 0 0 4px; letter-spacing: -0.02em; }
|
||||
.claim { color: var(--ink-mute); margin: 0 0 28px; }
|
||||
.card { display: grid; gap: 14px; text-align: left; background: var(--surface);
|
||||
border: 1px solid var(--line); border-radius: var(--radius); padding: 22px; box-shadow: var(--shadow); }
|
||||
label { display: grid; gap: 6px; font-size: 0.9rem; color: var(--ink-soft); }
|
||||
input { padding: 11px 12px; border: 1px solid var(--line); border-radius: var(--radius-sm);
|
||||
background: var(--paper); color: var(--ink); font-size: 1rem; }
|
||||
button { margin-top: 4px; padding: 11px; border: none; border-radius: var(--radius-sm);
|
||||
background: var(--accent); color: white; font-size: 1rem; font-weight: 600; cursor: pointer; }
|
||||
.err { color: var(--err); font-size: 0.9rem; margin: 4px 0 0; }
|
||||
</style>
|
||||
<script>
|
||||
const f = document.getElementById('f'); const err = document.getElementById('err');
|
||||
f.addEventListener('submit', async (e) => {
|
||||
e.preventDefault(); err.hidden = true;
|
||||
const data = Object.fromEntries(new FormData(f));
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
|
||||
});
|
||||
if (res.ok) { location.href = '/'; }
|
||||
else { const j = await res.json().catch(() => ({})); err.textContent = j.error || 'Anmeldung fehlgeschlagen.'; err.hidden = false; }
|
||||
});
|
||||
</script>
|
||||
</Base>
|
||||
@@ -0,0 +1,64 @@
|
||||
/* Klarbild — Design-Tokens (OKLCH). Eigenständige, ruhige Produkt-Identität.
|
||||
Kein Tailwind. Feinschliff folgt in der Design-Phase. */
|
||||
:root {
|
||||
/* Flächen */
|
||||
--paper: oklch(98.5% 0.006 95); /* warmes Off-White */
|
||||
--surface: oklch(100% 0 0);
|
||||
--surface-2: oklch(96.5% 0.008 95);
|
||||
--line: oklch(89% 0.008 95);
|
||||
|
||||
/* Text */
|
||||
--ink: oklch(24% 0.02 260); /* warmes Fast-Schwarz */
|
||||
--ink-soft: oklch(45% 0.02 260);
|
||||
--ink-mute: oklch(62% 0.015 260);
|
||||
|
||||
/* Akzent — klares Petrol/Teal */
|
||||
--accent: oklch(62% 0.12 210);
|
||||
--accent-ink: oklch(30% 0.07 210);
|
||||
--accent-bg: oklch(95% 0.03 210);
|
||||
|
||||
/* Sekundär — warmer Sand */
|
||||
--sand: oklch(85% 0.05 75);
|
||||
|
||||
/* Zustände */
|
||||
--ok: oklch(65% 0.14 150);
|
||||
--warn: oklch(75% 0.14 75);
|
||||
--err: oklch(58% 0.17 25);
|
||||
|
||||
/* Form */
|
||||
--radius: 14px;
|
||||
--radius-sm: 9px;
|
||||
--shadow: 0 1px 2px oklch(24% 0.02 260 / 0.06), 0 8px 24px oklch(24% 0.02 260 / 0.06);
|
||||
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
--font-display: "Fraunces", Georgia, serif;
|
||||
--maxw: 1160px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--paper: oklch(22% 0.012 260);
|
||||
--surface: oklch(26% 0.014 260);
|
||||
--surface-2: oklch(30% 0.014 260);
|
||||
--line: oklch(36% 0.014 260);
|
||||
--ink: oklch(96% 0.006 95);
|
||||
--ink-soft: oklch(82% 0.01 95);
|
||||
--ink-mute: oklch(66% 0.012 95);
|
||||
--accent: oklch(72% 0.11 210);
|
||||
--accent-bg: oklch(32% 0.05 210);
|
||||
--shadow: 0 1px 2px oklch(0% 0 0 / 0.3), 0 10px 30px oklch(0% 0 0 / 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--paper); color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 16px; line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: var(--accent-ink); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
|
||||
}
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] }
|
||||
},
|
||||
"include": ["src", "scripts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user