feat: storage abstraction (fs volume default | s3), health via storageOk

This commit is contained in:
2026-07-23 14:27:12 +00:00
parent d615bb38fe
commit 9d075b5109
3 changed files with 74 additions and 35 deletions
+5 -1
View File
@@ -3,7 +3,11 @@
# Datenbank (Postgres)
DATABASE_URL=postgres://klarbild:CHANGEME@klarbild-db:5432/klarbild
# Objektspeicher (S3-kompatibel, z. B. MinIO)
# Objektspeicher: fs (Volume, Default) oder s3 (MinIO)
STORAGE_DRIVER=fs
STORAGE_DIR=/data
# Nur bei STORAGE_DRIVER=s3 (MinIO):
S3_ENDPOINT=http://klarbild-minio:9000
S3_REGION=us-east-1
S3_BUCKET=klarbild
+66 -27
View File
@@ -1,48 +1,87 @@
import {
S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand,
CreateBucketCommand, HeadBucketCommand,
} from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
// Objektspeicher-Abstraktion. STORAGE_DRIVER=fs (Volume, Default) | s3 (MinIO/S3).
// Presigned URLs werden nicht benötigt — Originale gehen als Base64-Data-URL an die Bild-API.
import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises';
import { dirname, join } from 'node:path';
const DRIVER = (process.env.STORAGE_DRIVER || 'fs').toLowerCase();
const DIR = process.env.STORAGE_DIR || '/data';
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 || '',
},
});
// --- S3 (lazy, nur wenn genutzt) --------------------------------------------
let _s3: any = null;
async function s3client() {
if (!_s3) {
const { S3Client } = await import('@aws-sdk/client-s3');
_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 || '',
},
});
}
return _s3;
}
// --- API --------------------------------------------------------------------
export async function ensureBucket(): Promise<void> {
try {
await s3.send(new HeadBucketCommand({ Bucket: bucket }));
} catch {
await s3.send(new CreateBucketCommand({ Bucket: bucket }));
if (DRIVER === 's3') {
const { CreateBucketCommand, HeadBucketCommand } = await import('@aws-sdk/client-s3');
const s3 = await s3client();
try { await s3.send(new HeadBucketCommand({ Bucket: bucket })); }
catch { await s3.send(new CreateBucketCommand({ Bucket: bucket })); }
} else {
await mkdir(DIR, { recursive: true });
}
}
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 }));
if (DRIVER === 's3') {
const { PutObjectCommand } = await import('@aws-sdk/client-s3');
await (await s3client()).send(new PutObjectCommand({ Bucket: bucket, Key: key, Body: body, ContentType: contentType }));
} else {
const p = join(DIR, key);
await mkdir(dirname(p), { recursive: true });
await writeFile(p, body);
}
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);
if (DRIVER === 's3') {
const { GetObjectCommand } = await import('@aws-sdk/client-s3');
const res = await (await s3client()).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);
}
return readFile(join(DIR, key));
}
export async function deleteObject(key: string): Promise<void> {
await s3.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
if (DRIVER === 's3') {
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3');
await (await s3client()).send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
} else {
await unlink(join(DIR, key)).catch(() => {});
}
}
/** 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 });
/** Storage-Healthcheck (Verzeichnis beschreibbar bzw. Bucket erreichbar). */
export async function storageOk(): Promise<boolean> {
try {
if (DRIVER === 's3') {
const { HeadBucketCommand } = await import('@aws-sdk/client-s3');
await (await s3client()).send(new HeadBucketCommand({ Bucket: bucket }));
} else {
await mkdir(DIR, { recursive: true });
const probe = join(DIR, '.health');
await writeFile(probe, 'ok'); await unlink(probe).catch(() => {});
}
return true;
} catch { return false; }
}
export function sourceKey(uuid: string, ext: string): string {
+3 -7
View File
@@ -1,7 +1,6 @@
import type { APIRoute } from 'astro';
import { pool } from '../../lib/db';
import { s3 } from '../../lib/storage';
import { HeadBucketCommand } from '@aws-sdk/client-s3';
import { storageOk } from '../../lib/storage';
export const prerender = false;
@@ -12,12 +11,9 @@ export const GET: APIRoute = async () => {
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.storage = (await storageOk()) ? 'ok' : 'fehler';
if (out.storage !== 'ok') 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,