feat: verschlüsselte Integrationseinstellungen, Haushaltsmitglieder mit Ersteller, Backup-Download
This commit is contained in:
1 parent
a1195223e0
commit
267f4fcf70
21 files changed
+724
-30
No files matched your search
@@ -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<string, string> = {
|
||||
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<string | null> {
|
||||
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<string> {
|
||||
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<SmtpSettings> {
|
||||
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<TelegramSettings> {
|
||||
return { botToken: await resolveValue("telegram_bot_token") };
|
||||
}
|
||||
|
||||
export async function getLogoSettings(): Promise<LogoSettings> {
|
||||
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<IntegrationSection, string[]> = {
|
||||
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, unknown>): 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<string, string>): Promise<string[]> {
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user