From 267f4fcf70ce87f4b889e064934cd396a49c90c5 Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 18 Oct 2026 14:19:03 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20verschl=C3=BCsselte=20Integrationseinst?= =?UTF-8?q?ellungen,=20Haushaltsmitglieder=20mit=20Ersteller,=20Backup-Dow?= =?UTF-8?q?nload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + app/api/members/route.ts | 30 ++++ app/api/settings/backup/route.ts | 37 +++++ app/api/settings/integrations/route.ts | 35 +++++ app/api/vouchers/[id]/route.ts | 2 +- app/api/vouchers/route.ts | 11 +- app/page.tsx | 6 +- app/settings/integrations-form.tsx | 184 +++++++++++++++++++++++++ app/settings/members.tsx | 96 +++++++++++++ app/settings/page.tsx | 21 ++- app/vouchers/[id]/page.tsx | 2 + app/vouchers/new/page.tsx | 32 ++++- lib/db-migrate.ts | 17 ++- lib/integration-settings.ts | 124 +++++++++++++++++ lib/logo.ts | 24 ++-- lib/mail.ts | 10 +- lib/settings-crypto.ts | 38 +++++ lib/telegram.ts | 8 +- prisma/schema.prisma | 13 ++ tests/logo.test.ts | 8 +- tests/settings-store.test.ts | 55 ++++++++ 21 files changed, 724 insertions(+), 30 deletions(-) create mode 100644 app/api/members/route.ts create mode 100644 app/api/settings/backup/route.ts create mode 100644 app/api/settings/integrations/route.ts create mode 100644 app/settings/integrations-form.tsx create mode 100644 app/settings/members.tsx create mode 100644 lib/integration-settings.ts create mode 100644 lib/settings-crypto.ts create mode 100644 tests/settings-store.test.ts diff --git a/.env.example b/.env.example index 1ca3497..f5f92cc 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ DATABASE_URL=file:/data/gutscheine.db APP_USER=haushalt APP_PASSWORD_HASH=$2b$10$REPLACE_ME SESSION_SECRET=REPLACE_WITH_32_CHARS_MIN +SETTINGS_ENCRYPTION_KEY=REPLACE_WITH_OWN_32_CHARS_MIN SMTP_HOST= SMTP_PORT=587 SMTP_USER= diff --git a/app/api/members/route.ts b/app/api/members/route.ts new file mode 100644 index 0000000..77a55c2 --- /dev/null +++ b/app/api/members/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; + +export async function GET() { + const db = getDb(); + const members = await db.householdMember.findMany({ orderBy: { name: "asc" } }); + return NextResponse.json(members); +} + +export async function POST(req: NextRequest) { + const body = await req.json().catch(() => ({})); + const name = String(body.name ?? "").trim(); + if (!name) return NextResponse.json({ errors: ["name"] }, { status: 400 }); + if (name.length > 100) return NextResponse.json({ errors: ["name"] }, { status: 400 }); + const db = getDb(); + const existing = await db.householdMember.findUnique({ where: { name } }); + if (existing) return NextResponse.json({ errors: ["name"] }, { status: 409 }); + const created = await db.householdMember.create({ data: { name } }); + return NextResponse.json(created, { status: 201 }); +} + +export async function DELETE(req: NextRequest) { + const id = Number(req.nextUrl.searchParams.get("id")); + if (!Number.isInteger(id)) return NextResponse.json({ error: "not found" }, { status: 404 }); + const db = getDb(); + const existing = await db.householdMember.findUnique({ where: { id } }); + if (!existing) return NextResponse.json({ error: "not found" }, { status: 404 }); + await db.householdMember.delete({ where: { id } }); + return NextResponse.json({ ok: true }); +} diff --git a/app/api/settings/backup/route.ts b/app/api/settings/backup/route.ts new file mode 100644 index 0000000..5d5f498 --- /dev/null +++ b/app/api/settings/backup/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { randomBytes } from "crypto"; +import { unlink } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; +import { getDb } from "@/lib/db"; + +/** + * Lädt einen konsistenten SQLite-Snapshot herunter (VACUUM INTO läuft + * transaktional, auch bei parallelen Schreibzugriffen). Enthält Gutscheine, + * Kontakte, Mitglieder, Regeln, Logs und verschlüsselte Einstellungen. + * Wiederherstellen: Datei als /data/gutscheine.db ablegen (gleicher + * SETTINGS_ENCRYPTION_KEY erforderlich). + */ +export async function GET() { + const db = getDb(); + const stamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, ""); + const tmpPath = join(tmpdir(), `gutscheintracker-backup-${stamp}-${randomBytes(4).toString("hex")}.db`); + try { + await db.$executeRawUnsafe(`VACUUM INTO '${tmpPath.replace(/'/g, "''")}'`); + const { readFile } = await import("fs/promises"); + const data = await readFile(tmpPath); + await unlink(tmpPath).catch(() => {}); + return new NextResponse(new Uint8Array(data), { + headers: { + "content-type": "application/x-sqlite3", + "content-disposition": `attachment; filename="gutscheintracker-backup-${stamp}.db"`, + }, + }); + } catch (e) { + await unlink(tmpPath).catch(() => {}); + return NextResponse.json( + { error: e instanceof Error ? e.message : "Backup fehlgeschlagen" }, + { status: 500 } + ); + } +} diff --git a/app/api/settings/integrations/route.ts b/app/api/settings/integrations/route.ts new file mode 100644 index 0000000..3ef32e1 --- /dev/null +++ b/app/api/settings/integrations/route.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + getIntegrationStatus, + saveIntegrationSection, + validateIntegrationSection, + type IntegrationSection, +} from "@/lib/integration-settings"; +import { canEncrypt } from "@/lib/settings-crypto"; + +export async function GET() { + return NextResponse.json(await getIntegrationStatus()); +} + +export async function POST(req: NextRequest) { + const body = await req.json().catch(() => ({})); + const section = String(body.section ?? ""); + const values = (body.values ?? {}) as Record; + const errs = validateIntegrationSection(section, values); + if (errs.length) return NextResponse.json({ errors: errs }, { status: 400 }); + const stringValues: Record = {}; + for (const [k, v] of Object.entries(values)) { + if (typeof v === "string") stringValues[k] = v; + } + const needsEncryption = Object.entries(stringValues).some( + ([k, v]) => v !== "" && (k === "smtp_pass" || k === "telegram_bot_token" || k === "logo_dev_secret_key") + ); + if (needsEncryption && !canEncrypt()) { + return NextResponse.json( + { errors: ["verschluesselung"], message: "SETTINGS_ENCRYPTION_KEY fehlt (min. 32 Zeichen in .env)" }, + { status: 503 } + ); + } + const saved = await saveIntegrationSection(section as IntegrationSection, stringValues); + return NextResponse.json({ ok: true, saved, status: await getIntegrationStatus() }); +} diff --git a/app/api/vouchers/[id]/route.ts b/app/api/vouchers/[id]/route.ts index 44733ae..3355f3c 100644 --- a/app/api/vouchers/[id]/route.ts +++ b/app/api/vouchers/[id]/route.ts @@ -9,7 +9,7 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: const db = getDb(); const v = await db.voucher.findUnique({ where: { id: numId }, include: { contacts: { include: { contact: true } } } }); if (!v) return NextResponse.json({ error: "not found" }, { status: 404 }); - return NextResponse.json(withLogoUrl(v)); + return NextResponse.json(await withLogoUrl(v)); } export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { diff --git a/app/api/vouchers/route.ts b/app/api/vouchers/route.ts index 7c7159a..8b7c163 100644 --- a/app/api/vouchers/route.ts +++ b/app/api/vouchers/route.ts @@ -17,7 +17,7 @@ export async function GET(req: NextRequest) { if (filter === "aktiv") return s === "aktiv"; return true; }); - return NextResponse.json(filtered.map(withLogoUrl)); + return NextResponse.json(await Promise.all(filtered.map(withLogoUrl))); } export async function POST(req: NextRequest) { @@ -44,6 +44,14 @@ export async function POST(req: NextRequest) { return NextResponse.json({ errors: ["contactIds"] }, { status: 400 }); } } + let createdBy: string | null = null; + if (body.memberId !== undefined && body.memberId !== null && String(body.memberId).trim() !== "") { + const memberId = Number(body.memberId); + if (!Number.isInteger(memberId)) return NextResponse.json({ errors: ["memberId"] }, { status: 400 }); + const member = await db.householdMember.findUnique({ where: { id: memberId } }); + if (!member) return NextResponse.json({ errors: ["memberId"] }, { status: 400 }); + createdBy = member.name; + } const providerDomain = normalizeDomain( body.providerDomain === undefined || body.providerDomain === null ? null : String(body.providerDomain) ); @@ -72,6 +80,7 @@ export async function POST(req: NextRequest) { kategorie: input.kategorie, notiz: input.notiz, providerDomain, + createdBy, contacts: contactIds.length ? { create: contactIds.map((contactId) => ({ contactId })) } : undefined, rules: { create: reminderDays.map((tageVorher) => ({ tageVorher })), diff --git a/app/page.tsx b/app/page.tsx index 6997fbf..c85aaf4 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -51,6 +51,7 @@ async function VoucherList({ filter }: { filter: string }) { if (filter === "aktiv") return s === "aktiv"; return true; }); + const withLogos = await Promise.all(filtered.map(withLogoUrl)); if (filtered.length === 0) { return (
@@ -66,7 +67,7 @@ async function VoucherList({ filter }: { filter: string }) { } return (
    - {filtered.map(withLogoUrl).map((v) => { + {withLogos.map((v) => { const s = getVoucherStatus(v); return (
  • @@ -81,6 +82,9 @@ async function VoucherList({ filter }: { filter: string }) { {v.anbieter} · {formatEuro(v.restCents)} von {formatEuro(v.wertCents)} {v.ablaufdatum ? ` · bis ${v.ablaufdatum.toLocaleDateString("de-DE")}` : ""}

    +

    + {v.createdBy ? `Angelegt von ${v.createdBy}` : "Ersteller nicht erfasst"} +

{statusLabel(s)} diff --git a/app/settings/integrations-form.tsx b/app/settings/integrations-form.tsx new file mode 100644 index 0000000..7a446f3 --- /dev/null +++ b/app/settings/integrations-form.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useEffect, useState } from "react"; + +type Status = { + smtp: { host: string; port: number; user: string; from: string; passGesetzt: boolean }; + telegram: { botTokenGesetzt: boolean }; + logo: { secretGesetzt: boolean; publishableKey: string }; + verschluesselungBereit: boolean; +}; + +const inputClass = + "mt-1.5 h-11 w-full rounded-field border border-line bg-white px-3.5 text-sm text-ink placeholder:text-muted/60"; + +async function saveSection(section: string, values: Record) { + const res = await fetch("/api/settings/integrations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ section, values }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.message ?? `Fehler: ${(data.errors ?? []).join(", ")}`); + return data.status as Status; +} + +function Section({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function SecretHint({ gesetzt }: { gesetzt: boolean }) { + return ( +

+ {gesetzt ? "Gespeichert. Leer lassen, um den Wert zu behalten." : "Noch nicht gespeichert."} +

+ ); +} + +export default function IntegrationsForm() { + const [status, setStatus] = useState(null); + const [loadError, setLoadError] = useState(""); + const [notice, setNotice] = useState(""); + const [smtp, setSmtp] = useState({ host: "", port: "587", user: "", pass: "", from: "" }); + const [telegramToken, setTelegramToken] = useState(""); + const [logoSecret, setLogoSecret] = useState(""); + const [logoPublic, setLogoPublic] = useState(""); + + useEffect(() => { + fetch("/api/settings/integrations") + .then((r) => (r.ok ? r.json() : Promise.reject(new Error("laden fehlgeschlagen")))) + .then((s: Status) => { + setStatus(s); + setSmtp({ host: s.smtp.host, port: String(s.smtp.port), user: s.smtp.user, pass: "", from: s.smtp.from }); + setLogoPublic(s.logo.publishableKey); + }) + .catch(() => setLoadError("Einstellungen konnten nicht geladen werden.")); + }, []); + + async function onSave(section: "smtp" | "telegram" | "logo", values: Record, okText: string) { + setNotice(""); + try { + const s = await saveSection(section, values); + setStatus(s); + if (section === "smtp") setSmtp((v) => ({ ...v, pass: "" })); + if (section === "telegram") setTelegramToken(""); + if (section === "logo") { + setLogoSecret(""); + setLogoPublic(s.logo.publishableKey); + } + setNotice(okText); + } catch (e) { + setNotice(e instanceof Error ? e.message : "Speichern fehlgeschlagen"); + } + } + + if (loadError) return

{loadError}

; + if (!status) return

Wird geladen …

; + + return ( +
+
+
+
+ + setSmtp({ ...smtp, host: e.target.value })} placeholder="mail.beispiel.de" autoComplete="off" className={inputClass} /> +
+
+ + setSmtp({ ...smtp, port: e.target.value })} placeholder="587" autoComplete="off" className={inputClass} /> +
+
+ + setSmtp({ ...smtp, user: e.target.value })} autoComplete="username" className={inputClass} /> +
+
+ + setSmtp({ ...smtp, pass: e.target.value })} autoComplete="new-password" placeholder={status.smtp.passGesetzt ? "••••••••" : ""} className={inputClass} /> + +
+
+ + setSmtp({ ...smtp, from: e.target.value })} placeholder="gutscheine@beispiel.de" autoComplete="email" className={inputClass} /> +
+
+ +
+ +
+ + setTelegramToken(e.target.value)} + autoComplete="off" + placeholder={status.telegram.botTokenGesetzt ? "••••••••" : "123456:ABC-DEF…"} + className={inputClass} + /> + + +
+ +
+
+
+ + setLogoSecret(e.target.value)} + autoComplete="off" + placeholder={status.logo.secretGesetzt ? "••••••••" : "sk_…"} + className={inputClass} + /> + +
+
+ + setLogoPublic(e.target.value)} + autoComplete="off" + placeholder="pk_…" + className={inputClass} + /> +
+
+ +
+ + {notice &&

{notice}

} +
+ ); +} diff --git a/app/settings/members.tsx b/app/settings/members.tsx new file mode 100644 index 0000000..be92f9c --- /dev/null +++ b/app/settings/members.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useState } from "react"; + +type Member = { id: number; name: string }; + +export default function MembersSection() { + const [members, setMembers] = useState([]); + const [name, setName] = useState(""); + const [error, setError] = useState(""); + + async function load() { + const res = await fetch("/api/members"); + if (res.ok) setMembers(await res.json()); + } + useEffect(() => { + load(); + }, []); + + async function add() { + setError(""); + const value = name.trim(); + if (!value) { + setError("Bitte einen Namen eingeben."); + return; + } + const res = await fetch("/api/members", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: value }), + }); + if (!res.ok) { + setError(res.status === 409 ? "Dieser Name existiert bereits." : "Speichern fehlgeschlagen."); + return; + } + setName(""); + load(); + } + + async function remove(id: number, memberName: string) { + if (!window.confirm(`„${memberName}“ aus der Haushaltsliste entfernen? Bereits erfasste Gutscheine behalten den Namen.`)) return; + const res = await fetch(`/api/members?id=${id}`, { method: "DELETE" }); + if (res.ok) load(); + } + + return ( +
+

Haushaltsmitglieder

+

Wer einen Gutschein anlegt, wählt hier seinen Namen aus.

+
+ setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + add(); + } + }} + placeholder="Name hinzufügen" + aria-label="Name hinzufügen" + maxLength={100} + className="h-11 min-w-0 flex-1 rounded-field border border-line bg-white px-3.5 text-sm text-ink" + /> + +
+ {error && ( +

+ {error} +

+ )} + {members.length > 0 && ( +
    + {members.map((m) => ( +
  • + {m.name} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/app/settings/page.tsx b/app/settings/page.tsx index a3b5350..2b35c6d 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -3,6 +3,8 @@ import Shell from "../components/shell"; import { getDb } from "@/lib/db"; import { DEFAULT_TAGE_VORHER } from "@/lib/settings"; import { TestButtons } from "./test-buttons"; +import IntegrationsForm from "./integrations-form"; +import MembersSection from "./members"; export const dynamic = "force-dynamic"; @@ -36,9 +38,11 @@ export default async function SettingsPage() { Kontakte verwalten +

Neue Gutscheine erhalten diese Erinnerungsregeln: {DEFAULT_TAGE_VORHER.join(", ")} Tage vorher.

+ {last ? ( @@ -50,9 +54,20 @@ export default async function SettingsPage() {

Noch keine Benachrichtigung versendet.

)}
- -

SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, MAIL_FROM, TELEGRAM_BOT_TOKEN, LOGO_DEV_SECRET_KEY, LOGO_DEV_PUBLISHABLE_KEY, CRON_SECRET, APP_URL.

-

SQLite-Datei sichern: Volume gutschein-data bzw. Datei /data/gutscheine.db regelmäßig kopieren.

+ +

Gesamte Datenbank als Datei sichern, inklusive Gutscheine, Kontakte, Mitglieder und verschlüsselte Einstellungen.

+ + Datenbank-Backup herunterladen + +

Wiederherstellen am Server: Datei als /data/gutscheine.db ablegen. Derselbe SETTINGS_ENCRYPTION_KEY muss erhalten bleiben.

+
+ +

APP_USER, APP_PASSWORD_HASH, SESSION_SECRET, SETTINGS_ENCRYPTION_KEY, DATABASE_URL, CRON_SECRET, APP_URL. SMTP, Telegram und Logo.dev lassen sich oben in der App verwalten; .env dient als Rückfall.

+

Zusätzlich manuell: Volume gutschein-data bzw. Datei /data/gutscheine.db regelmäßig kopieren.

diff --git a/app/vouchers/[id]/page.tsx b/app/vouchers/[id]/page.tsx index 1f9ce5d..48070c5 100644 --- a/app/vouchers/[id]/page.tsx +++ b/app/vouchers/[id]/page.tsx @@ -17,6 +17,7 @@ type Voucher = { kategorie: string | null; notiz: string | null; status: string; + createdBy?: string | null; providerDomain?: string | null; logoUrl?: string | null; contacts?: { contact: { id: number; name: string; email: string | null; telegramChatId: string | null } }[]; @@ -94,6 +95,7 @@ export default function VoucherDetailPage({ params }: { params: Promise<{ id: st {formatEuro(v.wertCents)} {formatEuro(v.restCents)} {status === "aktiv" ? "Aktiv" : status === "abgelaufen" ? "Abgelaufen" : "Eingelöst"} + {v.createdBy ? v.createdBy : "Nicht erfasst"} {v.ablaufdatum && ( {new Date(v.ablaufdatum).toLocaleDateString("de-DE")} )} diff --git a/app/vouchers/new/page.tsx b/app/vouchers/new/page.tsx index 36869c2..95388ba 100644 --- a/app/vouchers/new/page.tsx +++ b/app/vouchers/new/page.tsx @@ -31,6 +31,8 @@ export default function NewVoucherPage() { const [serverError, setServerError] = useState(""); const [contacts, setContacts] = useState<{ id: number; name: string }[]>([]); const [contactIds, setContactIds] = useState([]); + const [members, setMembers] = useState<{ id: number; name: string }[]>([]); + const [memberId, setMemberId] = useState(""); const [reminderDays, setReminderDays] = useState([...DEFAULT_REMINDER_DAYS]); const [candidates, setCandidates] = useState([]); const [logoSearching, setLogoSearching] = useState(false); @@ -43,6 +45,10 @@ export default function NewVoucherPage() { .then((r) => (r.ok ? r.json() : [])) .then(setContacts) .catch(() => {}); + fetch("/api/members") + .then((r) => (r.ok ? r.json() : [])) + .then(setMembers) + .catch(() => {}); }, []); function set(name: string, value: string) { @@ -101,7 +107,7 @@ export default function NewVoucherPage() { const res = await fetch("/api/vouchers", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ ...input, contactIds, reminderDays, providerDomain: selectedLogo?.domain ?? null }), + body: JSON.stringify({ ...input, contactIds, reminderDays, providerDomain: selectedLogo?.domain ?? null, memberId: memberId === "" ? null : Number(memberId) }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); @@ -121,6 +127,30 @@ export default function NewVoucherPage() {

Gutschein anlegen

Alle Angaben lassen sich später noch ändern.

+
+ + {members.length === 0 ? ( +

+ Noch keine Haushaltsmitglieder. Lege sie unter Einstellungen an. +

+ ) : ( + + )} +
{FIELDS.map((f) => (
diff --git a/lib/db-migrate.ts b/lib/db-migrate.ts index 25cf9bb..eb484aa 100644 --- a/lib/db-migrate.ts +++ b/lib/db-migrate.ts @@ -2,19 +2,32 @@ import { getDb } from "./db"; /** * Stellt additive Schema-Änderungen sicher, ohne dass auf dem Server ein - * separater `prisma migrate`-Lauf nötig ist. Idempotent: fehlende Spalten - * werden genau einmal ergänzt, alles andere bleibt unangetastet. + * separater `prisma migrate`-Lauf nötig ist. Idempotent: fehlende Tabellen + * und Spalten werden genau einmal ergänzt, alles andere bleibt unangetastet. * Best-effort: Fehler werden geloggt, der Start läuft trotzdem weiter. */ export async function ensureSchema(): Promise { try { const db = getDb(); + const tables = (await db.$queryRaw<{ name: string }[]>`SELECT name FROM sqlite_master WHERE type='table'`).map( + (t) => t.name + ); + if (!tables.includes("AppSetting")) { + await db.$executeRaw`CREATE TABLE "AppSetting" ("key" TEXT NOT NULL PRIMARY KEY, "value" TEXT NOT NULL, "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`; + } + if (!tables.includes("HouseholdMember")) { + await db.$executeRaw`CREATE TABLE "HouseholdMember" ("id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, "name" TEXT NOT NULL, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`; + await db.$executeRaw`CREATE UNIQUE INDEX "HouseholdMember_name_key" ON "HouseholdMember"("name")`; + } const cols = (await db.$queryRaw<{ name: string }[]>`SELECT name FROM pragma_table_info('Voucher')`).map( (c) => c.name ); if (!cols.includes("providerDomain")) { await db.$executeRaw`ALTER TABLE "Voucher" ADD COLUMN "providerDomain" TEXT`; } + if (!cols.includes("createdBy")) { + await db.$executeRaw`ALTER TABLE "Voucher" ADD COLUMN "createdBy" TEXT`; + } } catch (err) { console.error("ensureSchema fehlgeschlagen (Start läuft weiter):", err); } diff --git a/lib/integration-settings.ts b/lib/integration-settings.ts new file mode 100644 index 0000000..f2844a8 --- /dev/null +++ b/lib/integration-settings.ts @@ -0,0 +1,124 @@ +import { getDb } from "./db"; +import { canEncrypt, decryptSecret, encryptSecret } from "./settings-crypto"; + +export const INTEGRATION_SECTIONS = ["smtp", "telegram", "logo"] as const; +export type IntegrationSection = (typeof INTEGRATION_SECTIONS)[number]; + +const SECRET_KEYS = new Set(["smtp_pass", "telegram_bot_token", "logo_dev_secret_key"]); + +const ENV_FALLBACK: Record = { + smtp_host: "SMTP_HOST", + smtp_port: "SMTP_PORT", + smtp_user: "SMTP_USER", + smtp_pass: "SMTP_PASS", + mail_from: "MAIL_FROM", + telegram_bot_token: "TELEGRAM_BOT_TOKEN", + logo_dev_secret_key: "LOGO_DEV_SECRET_KEY", + logo_dev_publishable_key: "LOGO_DEV_PUBLISHABLE_KEY", +}; + +export type SmtpSettings = { host: string; port: number; user: string; pass: string; from: string }; +export type TelegramSettings = { botToken: string }; +export type LogoSettings = { secretKey: string; publishableKey: string }; + +async function readStored(key: string): Promise { + const db = getDb(); + const row = await db.appSetting.findUnique({ where: { key } }); + if (!row) return null; + if (SECRET_KEYS.has(key)) { + try { + return decryptSecret(row.value); + } catch { + return null; + } + } + return row.value; +} + +async function resolveValue(key: string): Promise { + const stored = await readStored(key); + if (stored !== null && stored !== "") return stored; + return process.env[ENV_FALLBACK[key] ?? ""] ?? ""; +} + +/** Volle SMTP-Konfiguration: UI-Werte zuerst, sonst .env-Rückfall. */ +export async function getSmtpSettings(): Promise { + const [host, port, user, pass, from] = await Promise.all([ + resolveValue("smtp_host"), + resolveValue("smtp_port"), + resolveValue("smtp_user"), + resolveValue("smtp_pass"), + resolveValue("mail_from"), + ]); + return { host, port: Number(port) || 587, user, pass, from }; +} + +export async function getTelegramSettings(): Promise { + return { botToken: await resolveValue("telegram_bot_token") }; +} + +export async function getLogoSettings(): Promise { + const [secretKey, publishableKey] = await Promise.all([ + resolveValue("logo_dev_secret_key"), + resolveValue("logo_dev_publishable_key"), + ]); + return { secretKey, publishableKey }; +} + +/** Status für die UI: Klartextwerte plus je Geheimnis nur ob gesetzt, nie der Wert. */ +export async function getIntegrationStatus() { + const [smtp, telegram, logo] = await Promise.all([getSmtpSettings(), getTelegramSettings(), getLogoSettings()]); + return { + smtp: { host: smtp.host, port: smtp.port, user: smtp.user, from: smtp.from, passGesetzt: smtp.pass !== "" }, + telegram: { botTokenGesetzt: telegram.botToken !== "" }, + logo: { secretGesetzt: logo.secretKey !== "", publishableKey: logo.publishableKey }, + verschluesselungBereit: canEncrypt(), + }; +} + +const SECTION_FIELDS: Record = { + smtp: ["smtp_host", "smtp_port", "smtp_user", "smtp_pass", "mail_from"], + telegram: ["telegram_bot_token"], + logo: ["logo_dev_secret_key", "logo_dev_publishable_key"], +}; + +export function validateIntegrationSection(section: string, values: Record): string[] { + if (!(INTEGRATION_SECTIONS as readonly string[]).includes(section)) return ["section"]; + const errs: string[] = []; + if (section === "smtp") { + if (typeof values.smtp_port !== "undefined" && values.smtp_port !== "" && !Number.isInteger(Number(values.smtp_port))) { + errs.push("smtp_port"); + } + if (typeof values.smtp_host === "string" && values.smtp_host.trim() !== "" && values.smtp_host.trim().length > 255) { + errs.push("smtp_host"); + } + } + for (const key of SECTION_FIELDS[section as IntegrationSection]) { + const v = values[key]; + if (typeof v !== "undefined" && typeof v !== "string") errs.push(key); + if (typeof v === "string" && v.length > 2000) errs.push(key); + } + return errs; +} + +/** + * Speichert einen Bereich. Leere Geheimnis-Felder lassen den bestehenden Wert + * unverändert (UI sendet dann ""), alle anderen Felder werden übernommen + * ("" löscht einen Klartextwert). Gibt die gespeicherten Schlüssel zurück. + */ +export async function saveIntegrationSection(section: IntegrationSection, values: Record): Promise { + const db = getDb(); + const saved: string[] = []; + for (const key of SECTION_FIELDS[section]) { + if (!(key in values)) continue; + const v = values[key]; + if (SECRET_KEYS.has(key)) { + if (v === "") continue; + await db.appSetting.upsert({ where: { key }, create: { key, value: encryptSecret(v) }, update: { value: encryptSecret(v) } }); + } else { + await db.appSetting.upsert({ where: { key }, create: { key, value: v }, update: { value: v } }); + } + saved.push(key); + } + return saved; +} diff --git a/lib/logo.ts b/lib/logo.ts index 289e268..e14a385 100644 --- a/lib/logo.ts +++ b/lib/logo.ts @@ -1,3 +1,5 @@ +import { getLogoSettings } from "./integration-settings"; + export type LogoCandidate = { name: string; domain: string; logoUrl: string | null }; const DOMAIN_RE = /^[a-z0-9.-]+\.[a-z]{2,}$/i; @@ -10,11 +12,11 @@ export function normalizeDomain(domain: string | null | undefined): string | nul } /** Baut die öffentliche Logo-URL für eine gespeicherte Domain. Key bleibt serverseitig. */ -export function logoImageUrl(domain: string | null | undefined): string | null { +export async function logoImageUrl(domain: string | null | undefined): Promise { const d = normalizeDomain(domain); - const token = process.env.LOGO_DEV_PUBLISHABLE_KEY; - if (!d || !token) return null; - return `https://img.logo.dev/${d}?token=${token}&size=128&format=png`; + const { publishableKey } = await getLogoSettings(); + if (!d || !publishableKey) return null; + return `https://img.logo.dev/${d}?token=${publishableKey}&size=128&format=png`; } /** Kürzel aus dem Anbieternamen als Fallback, z. B. "Therme Erding" → "TE". */ @@ -26,18 +28,20 @@ export function monogram(name: string): string { } /** Reichert ein Voucher-Objekt um die öffentliche logoUrl an (serverseitig). */ -export function withLogoUrl(v: T): T & { logoUrl: string | null } { - return { ...v, logoUrl: logoImageUrl(v.providerDomain) }; +export async function withLogoUrl( + v: T +): Promise { + return { ...v, logoUrl: await logoImageUrl(v.providerDomain) }; } /** Sucht Anbieter über Logo.dev (serverseitig, Secret-Key). Gibt max. 8 Treffer zurück. */ export async function searchLogos(query: string): Promise { - const key = process.env.LOGO_DEV_SECRET_KEY; - if (!key) throw new Error("LOGO_DEV_SECRET_KEY fehlt"); + const { secretKey } = await getLogoSettings(); + if (!secretKey) throw new Error("LOGO_DEV_SECRET_KEY fehlt"); const q = query.trim(); if (!q) return []; const res = await fetch(`https://api.logo.dev/search?q=${encodeURIComponent(q)}`, { - headers: { Authorization: `Bearer ${key}` }, + headers: { Authorization: `Bearer ${secretKey}` }, }); if (!res.ok) throw new Error(`Logosuche ${res.status}`); const data: unknown = await res.json(); @@ -49,7 +53,7 @@ export async function searchLogos(query: string): Promise { const domain = normalizeDomain(typeof rec.domain === "string" ? rec.domain : null); if (!domain) continue; const name = typeof rec.name === "string" && rec.name.trim() ? rec.name.trim() : domain; - out.push({ name, domain, logoUrl: logoImageUrl(domain) }); + out.push({ name, domain, logoUrl: await logoImageUrl(domain) }); if (out.length >= 8) break; } return out; diff --git a/lib/mail.ts b/lib/mail.ts index d9ebceb..5889887 100644 --- a/lib/mail.ts +++ b/lib/mail.ts @@ -1,10 +1,12 @@ import nodemailer from "nodemailer"; +import { getSmtpSettings } from "./integration-settings"; export async function sendMail(to: string, subject: string, text: string): Promise { + const smtp = await getSmtpSettings(); const t = nodemailer.createTransport({ - host: process.env.SMTP_HOST, - port: Number(process.env.SMTP_PORT ?? 587), - auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }, + host: smtp.host || undefined, + port: smtp.port, + auth: smtp.user || smtp.pass ? { user: smtp.user, pass: smtp.pass } : undefined, }); - await t.sendMail({ from: process.env.MAIL_FROM, to, subject, text }); + await t.sendMail({ from: smtp.from || undefined, to, subject, text }); } diff --git a/lib/settings-crypto.ts b/lib/settings-crypto.ts new file mode 100644 index 0000000..2e01fef --- /dev/null +++ b/lib/settings-crypto.ts @@ -0,0 +1,38 @@ +import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto"; + +const PREFIX = "gcm1:"; + +function encryptionKey(): Buffer { + const secret = process.env.SETTINGS_ENCRYPTION_KEY ?? ""; + if (secret.length < 32) { + throw new Error("SETTINGS_ENCRYPTION_KEY fehlt oder ist zu kurz (min. 32 Zeichen)"); + } + return createHash("sha256").update(secret, "utf8").digest(); +} + +/** Verschlüsselt einen Geheimnis-Wert mit AES-256-GCM. Format: gcm1: */ +export function encryptSecret(plain: string): string { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", encryptionKey(), iv); + const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return PREFIX + Buffer.concat([iv, tag, enc]).toString("base64"); +} + +/** Entschlüsselt einen mit encryptSecret erzeugten Wert. Wirft bei Manipulation/falschem Key. */ +export function decryptSecret(stored: string): string { + if (!stored.startsWith(PREFIX)) throw new Error("Unbekanntes Geheimnis-Format"); + const raw = Buffer.from(stored.slice(PREFIX.length), "base64"); + if (raw.length < 12 + 16 + 1) throw new Error("Beschädigter Geheimnis-Wert"); + const iv = raw.subarray(0, 12); + const tag = raw.subarray(12, 28); + const enc = raw.subarray(28); + const decipher = createDecipheriv("aes-256-gcm", encryptionKey(), iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(enc), decipher.final()]).toString("utf8"); +} + +/** true, wenn ein Wert verschlüsselt ablegbar ist (Key vorhanden). */ +export function canEncrypt(): boolean { + return (process.env.SETTINGS_ENCRYPTION_KEY ?? "").length >= 32; +} diff --git a/lib/telegram.ts b/lib/telegram.ts index dbac6e1..44050c0 100644 --- a/lib/telegram.ts +++ b/lib/telegram.ts @@ -1,7 +1,9 @@ +import { getTelegramSettings } from "./integration-settings"; + export async function sendTelegram(chatId: string, text: string): Promise { - const token = process.env.TELEGRAM_BOT_TOKEN; - if (!token) throw new Error("TELEGRAM_BOT_TOKEN fehlt"); - const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { + const { botToken } = await getTelegramSettings(); + if (!botToken) throw new Error("TELEGRAM_BOT_TOKEN fehlt"); + const res = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ chat_id: chatId, text }), diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dd46957..6fa0c67 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,6 +16,7 @@ model Voucher { kategorie String? notiz String? providerDomain String? + createdBy String? status String @default("aktiv") contacts VoucherContact[] rules ReminderRule[] @@ -56,3 +57,15 @@ model NotificationLog { rule ReminderRule @relation(fields: [ruleId], references: [id], onDelete: Cascade) @@unique([ruleId, kanal, empfaenger, zielDatum]) } + +model AppSetting { + key String @id + value String + updatedAt DateTime @updatedAt +} + +model HouseholdMember { + id Int @id @default(autoincrement()) + name String @unique + createdAt DateTime @default(now()) +} diff --git a/tests/logo.test.ts b/tests/logo.test.ts index ca3c503..603458d 100644 --- a/tests/logo.test.ts +++ b/tests/logo.test.ts @@ -13,12 +13,12 @@ describe("logo", () => { expect(normalizeDomain(" keinedomain ")).toBeNull(); expect(normalizeDomain(null)).toBeNull(); }); - it("logoImageUrl braucht Domain und öffentlichen Key", () => { + it("logoImageUrl braucht Domain und öffentlichen Key", async () => { process.env.LOGO_DEV_PUBLISHABLE_KEY = "pk_test"; - expect(logoImageUrl("stripe.com")).toBe("https://img.logo.dev/stripe.com?token=pk_test&size=128&format=png"); - expect(logoImageUrl(null)).toBeNull(); + expect(await logoImageUrl("stripe.com")).toBe("https://img.logo.dev/stripe.com?token=pk_test&size=128&format=png"); + expect(await logoImageUrl(null)).toBeNull(); delete process.env.LOGO_DEV_PUBLISHABLE_KEY; - expect(logoImageUrl("stripe.com")).toBeNull(); + expect(await logoImageUrl("stripe.com")).toBeNull(); }); it("monogram bildet Kürzel aus dem Namen", () => { expect(monogram("Therme Erding")).toBe("TE"); diff --git a/tests/settings-store.test.ts b/tests/settings-store.test.ts new file mode 100644 index 0000000..909952e --- /dev/null +++ b/tests/settings-store.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { canEncrypt, decryptSecret, encryptSecret } from "../lib/settings-crypto"; +import { validateIntegrationSection } from "../lib/integration-settings"; + +const ORIGINAL = process.env.SETTINGS_ENCRYPTION_KEY; + +beforeEach(() => { + process.env.SETTINGS_ENCRYPTION_KEY = "test-schluessel-mindestens-32-zeichen-lang!"; +}); + +afterEach(() => { + if (ORIGINAL === undefined) delete process.env.SETTINGS_ENCRYPTION_KEY; + else process.env.SETTINGS_ENCRYPTION_KEY = ORIGINAL; +}); + +describe("settings-crypto", () => { + it("roundtrip: entschlüsseln liefert Klartext", () => { + const enc = encryptSecret("smtp-geheim"); + expect(enc.startsWith("gcm1:")).toBe(true); + expect(decryptSecret(enc)).toBe("smtp-geheim"); + }); + it("gleicher Klartext ergibt verschiedene Chiffre (Zufalls-IV)", () => { + expect(encryptSecret("x")).not.toBe(encryptSecret("x")); + }); + it("manipulierter Wert wird abgelehnt", () => { + const enc = encryptSecret("wichtig"); + const tampered = enc.slice(0, -2) + (enc.endsWith("AA") ? "BB" : "AA"); + expect(() => decryptSecret(tampered)).toThrow(); + expect(() => decryptSecret("klartext")).toThrow(); + }); + it("ohne/zu kurzem Key kein Verschlüsseln", () => { + delete process.env.SETTINGS_ENCRYPTION_KEY; + expect(canEncrypt()).toBe(false); + expect(() => encryptSecret("x")).toThrow("SETTINGS_ENCRYPTION_KEY"); + process.env.SETTINGS_ENCRYPTION_KEY = "kurz"; + expect(canEncrypt()).toBe(false); + }); +}); + +describe("validateIntegrationSection", () => { + it("unbekannte Bereiche ablehnen", () => { + expect(validateIntegrationSection("login", {})).toEqual(["section"]); + }); + it("gültige SMTP-Werte bestehen", () => { + expect(validateIntegrationSection("smtp", { smtp_host: "mail.de", smtp_port: "587", smtp_pass: "" })).toEqual([]); + }); + it("ungültiger Port und Typen ablehnen", () => { + expect(validateIntegrationSection("smtp", { smtp_port: "abc" })).toEqual(["smtp_port"]); + expect(validateIntegrationSection("smtp", { smtp_host: 5 })).toEqual(["smtp_host"]); + }); + it("leeres Geheimnis-Feld ist ok (behält bestehenden Wert)", () => { + expect(validateIntegrationSection("telegram", { telegram_bot_token: "" })).toEqual([]); + expect(validateIntegrationSection("logo", { logo_dev_secret_key: "sk_x", logo_dev_publishable_key: "pk_x" })).toEqual([]); + }); +});