Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
// Storage-Adapter (Story B4-2 → Epic S1). Gekapselte Schnittstelle für die Datei-
|
||||
// Persistenz hochgeladener eigener Richtlinien und Audit-Nachweise. Aufrufer nutzen
|
||||
// ausschließlich `storage`, sodass das Backend ohne UI-/Action-Änderung austauschbar
|
||||
// bleibt (Konsumenten: policy-upload.ts, audit-evidence.ts).
|
||||
//
|
||||
// Backend-Wahl zur Laufzeit (Graceful Fallback):
|
||||
// - Sind alle S3_*-Env gesetzt → echter S3StorageAdapter (Garage/S3-kompatibel).
|
||||
// - Sonst → StubStorageAdapter (nur Metadaten/Key, keine Bytes) — kein Crash.
|
||||
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export interface StoredObject {
|
||||
/** Referenz zum späteren Abruf (Schema abhängig vom Backend). */
|
||||
storageKey: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
contentType: string | null;
|
||||
}
|
||||
|
||||
export interface PutInput {
|
||||
tenantId: string;
|
||||
filename: string;
|
||||
contentType?: string | null;
|
||||
/** Roh-Bytes; im Stub NICHT persistiert (nur Größe wird erfasst). */
|
||||
bytes?: Uint8Array | null;
|
||||
}
|
||||
|
||||
/** Abrufbares Objekt: Web-Stream + Metadaten für die Download-Route. */
|
||||
export interface StoredContent {
|
||||
stream: ReadableStream<Uint8Array>;
|
||||
contentType: string | null;
|
||||
size: number | null;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export interface StorageAdapter {
|
||||
put(input: PutInput): Promise<StoredObject>;
|
||||
/**
|
||||
* Objekt anhand seines Keys abrufen. `null`, wenn nicht vorhanden oder das
|
||||
* Backend keine Bytes persistiert (Stub). Der Aufrufer (Download-Route) MUSS
|
||||
* die Mandantenbindung des Keys vorab prüfen.
|
||||
*/
|
||||
get(key: string): Promise<StoredContent | null>;
|
||||
}
|
||||
|
||||
/** Dateinamen auf ein ASCII-/pfadsicheres Fragment reduzieren (auch S3-Metadata-tauglich). */
|
||||
function safeFilename(filename: string): string {
|
||||
return filename.replace(/[^\w.\-]+/g, "_").slice(0, 120) || "datei";
|
||||
}
|
||||
|
||||
/** Stub: vergibt einen deterministischen Key, speichert keine Bytes. */
|
||||
class StubStorageAdapter implements StorageAdapter {
|
||||
async put({ tenantId, filename, contentType, bytes }: PutInput): Promise<StoredObject> {
|
||||
const safe = safeFilename(filename);
|
||||
return {
|
||||
storageKey: `stub://${tenantId}/uploads/${safe}`,
|
||||
filename: safe,
|
||||
size: bytes?.byteLength ?? 0,
|
||||
contentType: contentType ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Stub persistiert keine Bytes → kein Abruf möglich. */
|
||||
async get(): Promise<StoredContent | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface S3Config {
|
||||
endpoint: string;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
bucket: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Echter Objektspeicher (Garage / S3-kompatibel). `forcePathStyle: true` ist zwingend
|
||||
* (Bucket im Pfad statt Subdomain) — Garage bedient ausschließlich Path-Style.
|
||||
*
|
||||
* Der Bucket wird beim ersten Zugriff nur noch VERIFIZIERT (HeadBucket), NICHT mehr
|
||||
* angelegt: Garage verwaltet Buckets/Keys über seine Admin-API/CLI, nicht über die
|
||||
* S3-Operation `CreateBucket`. Die Provisionierung erfolgt out-of-band (Init-Job
|
||||
* `garage-provision`, scripts/garage-provision.ts). Fehlt der Bucket, wird ein klarer
|
||||
* Konfigurationsfehler geworfen statt still zu heilen.
|
||||
*
|
||||
* Key-Schema: `<tenantId>/uploads/<uuid>-<safeFilename>` — mandantenpräfixiert, sodass
|
||||
* die Download-Route die Tenant-Zugehörigkeit rein am Key-Präfix verifizieren kann.
|
||||
* Der Original-(bereinigte) Dateiname wird zusätzlich als Objekt-Metadatum abgelegt
|
||||
* (für den Content-Disposition-Header beim Abruf).
|
||||
*/
|
||||
class S3StorageAdapter implements StorageAdapter {
|
||||
private readonly client: S3Client;
|
||||
private readonly bucket: string;
|
||||
private bucketReady: Promise<void> | null = null;
|
||||
|
||||
constructor(cfg: S3Config) {
|
||||
this.bucket = cfg.bucket;
|
||||
this.client = new S3Client({
|
||||
endpoint: cfg.endpoint,
|
||||
region: cfg.region,
|
||||
forcePathStyle: true,
|
||||
credentials: { accessKeyId: cfg.accessKey, secretAccessKey: cfg.secretKey },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket einmal je Prozess VERIFIZIEREN (HeadBucket). Kein `CreateBucket` mehr:
|
||||
* Garage legt Buckets nicht über die S3-API an (siehe Klassen-Doc) → fehlt der
|
||||
* Bucket, ist das ein Konfigurationsfehler (Provisioning nicht gelaufen), kein
|
||||
* selbstheilbarer Zustand.
|
||||
*/
|
||||
private ensureBucket(): Promise<void> {
|
||||
if (!this.bucketReady) {
|
||||
this.bucketReady = (async () => {
|
||||
try {
|
||||
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
||||
} catch (err) {
|
||||
this.bucketReady = null; // erneuten Versuch beim nächsten Aufruf zulassen
|
||||
const name = (err as { name?: string })?.name ?? String(err);
|
||||
throw new Error(
|
||||
`Objektspeicher-Bucket „${this.bucket}" nicht erreichbar oder nicht provisioniert ` +
|
||||
`(${name}). Garage legt Buckets NICHT über die S3-API an — bitte das ` +
|
||||
`Garage-Provisioning ausführen (Compose-Service „garage-provision").`,
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
return this.bucketReady;
|
||||
}
|
||||
|
||||
async put({ tenantId, filename, contentType, bytes }: PutInput): Promise<StoredObject> {
|
||||
await this.ensureBucket();
|
||||
const safe = safeFilename(filename);
|
||||
const key = `${tenantId}/uploads/${randomUUID()}-${safe}`;
|
||||
const body = bytes ?? new Uint8Array(0);
|
||||
|
||||
await this.client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: body,
|
||||
ContentType: contentType ?? undefined,
|
||||
Metadata: { filename: safe },
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
storageKey: key,
|
||||
filename: safe,
|
||||
size: body.byteLength,
|
||||
contentType: contentType ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async get(key: string): Promise<StoredContent | null> {
|
||||
try {
|
||||
const res = await this.client.send(
|
||||
new GetObjectCommand({ Bucket: this.bucket, Key: key }),
|
||||
);
|
||||
if (!res.Body) return null;
|
||||
// AWS SDK v3 (Node): Body ist ein Stream mit transformToWebStream().
|
||||
const stream = (res.Body as {
|
||||
transformToWebStream: () => ReadableStream<Uint8Array>;
|
||||
}).transformToWebStream();
|
||||
return {
|
||||
stream,
|
||||
contentType: res.ContentType ?? null,
|
||||
size: typeof res.ContentLength === "number" ? res.ContentLength : null,
|
||||
filename: res.Metadata?.filename ?? key.slice(key.lastIndexOf("/") + 1),
|
||||
};
|
||||
} catch (err) {
|
||||
const name = (err as { name?: string })?.name ?? "";
|
||||
if (name === "NoSuchKey" || name === "NotFound") return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** S3-Konfiguration aus der Umgebung lesen; nur vollständig gesetzt → S3-Backend. */
|
||||
function readS3Config(): S3Config | null {
|
||||
const endpoint = process.env.S3_ENDPOINT?.trim();
|
||||
const accessKey = process.env.S3_ACCESS_KEY?.trim();
|
||||
const secretKey = process.env.S3_SECRET_KEY?.trim();
|
||||
const bucket = process.env.S3_BUCKET?.trim();
|
||||
if (!endpoint || !accessKey || !secretKey || !bucket) return null;
|
||||
return {
|
||||
endpoint,
|
||||
accessKey,
|
||||
secretKey,
|
||||
bucket,
|
||||
region: process.env.S3_REGION?.trim() || "us-east-1",
|
||||
};
|
||||
}
|
||||
|
||||
/** Backend-Auswahl zur Laufzeit: echtes S3, sonst Stub-Fallback. */
|
||||
function createStorage(): StorageAdapter {
|
||||
const cfg = readS3Config();
|
||||
return cfg ? new S3StorageAdapter(cfg) : new StubStorageAdapter();
|
||||
}
|
||||
|
||||
/** Prozessweiter Adapter. */
|
||||
export const storage: StorageAdapter = createStorage();
|
||||
@@ -0,0 +1,336 @@
|
||||
// ── Objektspeicher für Backup-Artefakte (Garage/S3, Prefix je Mandant) ────────
|
||||
//
|
||||
// Getrennt vom Upload-Adapter (adapter.ts), weil Backups arbiträre Keys,
|
||||
// Auflistung und Löschung brauchen. Key-Schema: `<tenantId>/backups/<snapshot>/…`
|
||||
// — mandantenpräfixiert, damit die Zugehörigkeit rein am Prefix verifizierbar ist.
|
||||
//
|
||||
// Backend-Wahl zur Laufzeit (Lane „Konfigurierbarer Backup-Zielspeicher"):
|
||||
// Präzedenz: DB-Config (PlatformSetting) → Env (S3_*/BACKUP_LOCAL_DIR)
|
||||
// → lokaler Default `<cwd>/.backups`.
|
||||
// - DB-Config zählt als explizit, wenn `backupTarget = s3` ODER `backupTarget =
|
||||
// local` MIT gesetztem `backupLocalDir`. Ein unberührter Default-Datensatz
|
||||
// (target=local, dir=NULL) fällt bewusst auf Env zurück → bestehende
|
||||
// Env-Deployments (S3_*) bleiben unverändert lauffähig (Rückwärtskompatibilität).
|
||||
// - Fail-secure: `backupTarget = s3` mit unvollständiger Config wirft einen
|
||||
// klaren Fehler und fällt NICHT still auf lokal.
|
||||
// - Das S3-Secret liegt in der DB nur verschlüsselt (secret-crypto) und wird
|
||||
// erst hier — im Speicher — entschlüsselt. Der lokale Datei-Store persistiert
|
||||
// Bytes echt (Export→Restore end-to-end; anders als der Upload-Stub).
|
||||
//
|
||||
// Nutzung: IMMER `await getBackupStore()` aufrufen (kein statisches Singleton mehr),
|
||||
// damit eine geänderte Betreiber-Konfiguration ohne Redeploy greift (Cache über
|
||||
// PlatformSetting.updatedAt invalidiert).
|
||||
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
ListObjectsV2Command,
|
||||
DeleteObjectsCommand,
|
||||
HeadBucketCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync, existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { prisma } from "../db";
|
||||
import { decryptSecret } from "../secret-crypto";
|
||||
|
||||
export interface BackupStore {
|
||||
put(key: string, bytes: Buffer): Promise<void>;
|
||||
get(key: string): Promise<Buffer | null>;
|
||||
list(prefix: string): Promise<string[]>;
|
||||
remove(prefix: string): Promise<number>;
|
||||
}
|
||||
|
||||
interface S3Config {
|
||||
endpoint: string;
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
bucket: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
function readS3Config(): S3Config | null {
|
||||
const endpoint = process.env.S3_ENDPOINT?.trim();
|
||||
const accessKey = process.env.S3_ACCESS_KEY?.trim();
|
||||
const secretKey = process.env.S3_SECRET_KEY?.trim();
|
||||
const bucket = process.env.S3_BUCKET?.trim();
|
||||
if (!endpoint || !accessKey || !secretKey || !bucket) return null;
|
||||
return {
|
||||
endpoint,
|
||||
accessKey,
|
||||
secretKey,
|
||||
bucket,
|
||||
region: process.env.S3_REGION?.trim() || "us-east-1",
|
||||
};
|
||||
}
|
||||
|
||||
class S3BackupStore implements BackupStore {
|
||||
private readonly client: S3Client;
|
||||
private readonly bucket: string;
|
||||
private ensured = false;
|
||||
|
||||
constructor(cfg: S3Config) {
|
||||
this.bucket = cfg.bucket;
|
||||
this.client = new S3Client({
|
||||
endpoint: cfg.endpoint,
|
||||
region: cfg.region,
|
||||
forcePathStyle: true,
|
||||
credentials: { accessKeyId: cfg.accessKey, secretAccessKey: cfg.secretKey },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket beim ersten Zugriff nur noch VERIFIZIEREN (HeadBucket), NICHT anlegen:
|
||||
* Garage verwaltet Buckets/Keys über seine Admin-API/CLI, nicht über die S3-
|
||||
* Operation `CreateBucket`. Die Provisionierung erfolgt out-of-band (Init-Job
|
||||
* `garage-provision`). Fehlt der Bucket, wird ein sprechender Konfigurationsfehler
|
||||
* geworfen — statt (wie früher) den `CreateBucket`-Fehler still zu schlucken, was
|
||||
* spätere Puts erst kryptisch scheitern ließ.
|
||||
*/
|
||||
private async ensureBucket(): Promise<void> {
|
||||
if (this.ensured) return;
|
||||
try {
|
||||
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
||||
} catch (err) {
|
||||
const name = (err as { name?: string })?.name ?? String(err);
|
||||
throw new Error(
|
||||
`Backup-Bucket „${this.bucket}" nicht erreichbar oder nicht provisioniert (${name}). ` +
|
||||
`Garage legt Buckets NICHT über die S3-API an — bitte das Garage-Provisioning ausführen ` +
|
||||
`(Compose-Service „garage-provision") oder den Backup-Zielspeicher auf „Lokal" stellen.`,
|
||||
);
|
||||
}
|
||||
this.ensured = true;
|
||||
}
|
||||
|
||||
async put(key: string, bytes: Buffer): Promise<void> {
|
||||
await this.ensureBucket();
|
||||
await this.client.send(
|
||||
new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: bytes }),
|
||||
);
|
||||
}
|
||||
|
||||
async get(key: string): Promise<Buffer | null> {
|
||||
try {
|
||||
const res = await this.client.send(
|
||||
new GetObjectCommand({ Bucket: this.bucket, Key: key }),
|
||||
);
|
||||
if (!res.Body) return null;
|
||||
const bytes = await (res.Body as { transformToByteArray: () => Promise<Uint8Array> }).transformToByteArray();
|
||||
return Buffer.from(bytes);
|
||||
} catch (err) {
|
||||
const name = (err as { name?: string })?.name ?? "";
|
||||
if (name === "NoSuchKey" || name === "NotFound") return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async list(prefix: string): Promise<string[]> {
|
||||
const keys: string[] = [];
|
||||
let token: string | undefined;
|
||||
do {
|
||||
const res = await this.client.send(
|
||||
new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix, ContinuationToken: token }),
|
||||
);
|
||||
for (const o of res.Contents ?? []) if (o.Key) keys.push(o.Key);
|
||||
token = res.IsTruncated ? res.NextContinuationToken : undefined;
|
||||
} while (token);
|
||||
return keys;
|
||||
}
|
||||
|
||||
async remove(prefix: string): Promise<number> {
|
||||
const keys = await this.list(prefix);
|
||||
if (!keys.length) return 0;
|
||||
// DeleteObjects: max 1000 pro Aufruf.
|
||||
for (let i = 0; i < keys.length; i += 1000) {
|
||||
await this.client.send(
|
||||
new DeleteObjectsCommand({
|
||||
Bucket: this.bucket,
|
||||
Delete: { Objects: keys.slice(i, i + 1000).map((Key) => ({ Key })) },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return keys.length;
|
||||
}
|
||||
}
|
||||
|
||||
class LocalBackupStore implements BackupStore {
|
||||
private readonly root: string;
|
||||
constructor(root: string) {
|
||||
this.root = root;
|
||||
}
|
||||
private path(key: string): string {
|
||||
return join(this.root, key);
|
||||
}
|
||||
async put(key: string, bytes: Buffer): Promise<void> {
|
||||
const p = this.path(key);
|
||||
mkdirSync(dirname(p), { recursive: true });
|
||||
writeFileSync(p, bytes);
|
||||
}
|
||||
async get(key: string): Promise<Buffer | null> {
|
||||
const p = this.path(key);
|
||||
return existsSync(p) ? readFileSync(p) : null;
|
||||
}
|
||||
async list(prefix: string): Promise<string[]> {
|
||||
const base = this.root;
|
||||
const out: string[] = [];
|
||||
const walk = (dir: string) => {
|
||||
if (!existsSync(dir)) return;
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else {
|
||||
const rel = full.slice(base.length + 1).split("\\").join("/");
|
||||
if (rel.startsWith(prefix)) out.push(rel);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(base);
|
||||
return out.sort();
|
||||
}
|
||||
async remove(prefix: string): Promise<number> {
|
||||
const keys = await this.list(prefix);
|
||||
for (const k of keys) rmSync(this.path(k), { force: true });
|
||||
return keys.length;
|
||||
}
|
||||
}
|
||||
|
||||
/** Lokaler Default-Ordner, wenn weder DB- noch Env-Config ein Ziel vorgibt. */
|
||||
function defaultLocalDir(): string {
|
||||
return process.env.BACKUP_LOCAL_DIR?.trim() || join(process.cwd(), ".backups");
|
||||
}
|
||||
|
||||
/**
|
||||
* Betreiber-Zielkonfiguration (aus PlatformSetting) MIT bereits ENTSCHLÜSSELTEM
|
||||
* S3-Secret (Klartext nur im Speicher). Rein datenhaltend, damit die Auflösung
|
||||
* (`resolveBackupStore`) ohne DB testbar bleibt.
|
||||
*/
|
||||
export interface BackupTargetConfig {
|
||||
backupTarget: string; // "local" | "s3"
|
||||
backupLocalDir: string | null;
|
||||
backupS3Endpoint: string | null;
|
||||
backupS3Bucket: string | null;
|
||||
backupS3Region: string | null;
|
||||
backupS3AccessKey: string | null;
|
||||
/** ENTSCHLÜSSELTES S3-Secret (nie aus der DB im Klartext). */
|
||||
backupS3SecretKey: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zählt die DB-Config als explizite Betreiber-Wahl (→ hat Vorrang vor Env)?
|
||||
* - `s3` : immer explizit (Vollständigkeit prüft resolveBackupStore, fail-secure).
|
||||
* - `local` : nur explizit, wenn ein `backupLocalDir` persistiert wurde. Ein
|
||||
* unberührter Default-Datensatz (local/NULL) fällt auf Env zurück.
|
||||
*/
|
||||
function isDbConfigured(cfg: BackupTargetConfig): boolean {
|
||||
if (cfg.backupTarget === "s3") return true;
|
||||
if (cfg.backupTarget === "local") return !!cfg.backupLocalDir?.trim();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reine Auflösung Config→Store (ohne DB/IO). Präzedenz: explizite DB-Config →
|
||||
* Env → lokaler Default. Wirft bei `backupTarget = s3` mit unvollständiger Config
|
||||
* (fail-secure: NICHT still auf lokal fallen).
|
||||
*/
|
||||
export function resolveBackupStore(cfg: BackupTargetConfig | null): BackupStore {
|
||||
if (cfg && isDbConfigured(cfg)) {
|
||||
if (cfg.backupTarget === "s3") {
|
||||
const endpoint = cfg.backupS3Endpoint?.trim();
|
||||
const bucket = cfg.backupS3Bucket?.trim();
|
||||
const accessKey = cfg.backupS3AccessKey?.trim();
|
||||
const secretKey = cfg.backupS3SecretKey?.trim();
|
||||
const missing = [
|
||||
!endpoint && "Endpoint",
|
||||
!bucket && "Bucket",
|
||||
!accessKey && "Access-Key",
|
||||
!secretKey && "Secret-Key",
|
||||
].filter(Boolean);
|
||||
if (missing.length) {
|
||||
throw new Error(
|
||||
`Backup-Zielspeicher „S3" ist unvollständig konfiguriert (fehlt: ${missing.join(", ")}). ` +
|
||||
`Konfiguration im Betreiber-Portal vervollständigen oder Ziel auf „Lokal" stellen.`,
|
||||
);
|
||||
}
|
||||
return new S3BackupStore({
|
||||
endpoint: endpoint!,
|
||||
bucket: bucket!,
|
||||
accessKey: accessKey!,
|
||||
secretKey: secretKey!,
|
||||
region: cfg.backupS3Region?.trim() || "us-east-1",
|
||||
});
|
||||
}
|
||||
// Explizit lokal (backupLocalDir garantiert gesetzt).
|
||||
return new LocalBackupStore(cfg.backupLocalDir!.trim());
|
||||
}
|
||||
|
||||
// Keine explizite DB-Config → Env-Fallback (Rückwärtskompatibilität).
|
||||
const env = readS3Config();
|
||||
if (env) return new S3BackupStore(env);
|
||||
return new LocalBackupStore(defaultLocalDir());
|
||||
}
|
||||
|
||||
// Cache: der aufgelöste Store wird memoisiert und über PlatformSetting.updatedAt
|
||||
// invalidiert (Betreiber ändert das Ziel → nächster Aufruf baut neu, kein Redeploy).
|
||||
let cached: { key: string; store: BackupStore } | null = null;
|
||||
|
||||
/** Setzt den Store-Cache zurück (nach Config-Änderung; auch für Tests). */
|
||||
export function invalidateBackupStore(): void {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert den zur Laufzeit gültigen Backup-Store. Liest PlatformSetting,
|
||||
* entschlüsselt das S3-Secret und wendet die Präzedenz DB→Env→Default an.
|
||||
* Ergebnis wird bis zur nächsten Settings-Änderung (updatedAt) gecacht.
|
||||
*/
|
||||
export async function getBackupStore(): Promise<BackupStore> {
|
||||
let setting: {
|
||||
backupTarget: string;
|
||||
backupLocalDir: string | null;
|
||||
backupS3Endpoint: string | null;
|
||||
backupS3Bucket: string | null;
|
||||
backupS3Region: string | null;
|
||||
backupS3AccessKey: string | null;
|
||||
backupS3SecretKeyEnc: string | null;
|
||||
updatedAt: Date;
|
||||
} | null = null;
|
||||
try {
|
||||
setting = await prisma.platformSetting.findUnique({
|
||||
where: { id: "singleton" },
|
||||
select: {
|
||||
backupTarget: true,
|
||||
backupLocalDir: true,
|
||||
backupS3Endpoint: true,
|
||||
backupS3Bucket: true,
|
||||
backupS3Region: true,
|
||||
backupS3AccessKey: true,
|
||||
backupS3SecretKeyEnc: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// DB (noch) nicht erreichbar → Env-Fallback statt harter Fehler beim Import-Kontext.
|
||||
setting = null;
|
||||
}
|
||||
|
||||
const key = setting ? `db:${setting.updatedAt.getTime()}` : "env";
|
||||
if (cached && cached.key === key) return cached.store;
|
||||
|
||||
const cfg: BackupTargetConfig | null = setting
|
||||
? {
|
||||
backupTarget: setting.backupTarget,
|
||||
backupLocalDir: setting.backupLocalDir,
|
||||
backupS3Endpoint: setting.backupS3Endpoint,
|
||||
backupS3Bucket: setting.backupS3Bucket,
|
||||
backupS3Region: setting.backupS3Region,
|
||||
backupS3AccessKey: setting.backupS3AccessKey,
|
||||
backupS3SecretKey: setting.backupS3SecretKeyEnc
|
||||
? decryptSecret(setting.backupS3SecretKeyEnc)
|
||||
: null,
|
||||
}
|
||||
: null;
|
||||
|
||||
const store = resolveBackupStore(cfg);
|
||||
cached = { key, store };
|
||||
return store;
|
||||
}
|
||||
Reference in New Issue
Block a user