import { createHmac, randomUUID } from "node:crypto"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { DeleteObjectCommand, GetObjectCommand, HeadBucketCommand, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; export interface PresignedUpload { key: string; uploadUrl: string; method: "PUT"; headers: Record; expiresAt: string; } export interface StorageService { presignUpload(prefix: string, contentType: string): Promise; /** URL som workern/AAMOS kan läsa bilden från. */ getReadUrl(key: string): Promise; putObject(key: string, data: Buffer, contentType: string): Promise; getObject(key: string): Promise; /** GDPR / rättning: radera ett objekt. Får inte kasta om nyckeln saknas. */ deleteObject(key: string): Promise; /** * Försök extrahera lagringsnyckeln ur en läs-URL. Returnerar null om URL:en * inte känns igen (t.ex. extern bild). Används vid GDPR-radering så att vi * kan radera objekt även när endast URL:en är sparad. */ extractKeyFromUrl(url: string): string | null; } const MOCK_ROOT = path.resolve(process.cwd(), ".data/s3"); export class MockStorage implements StorageService { constructor( private readonly baseUrl: string, private readonly signingSecret: string, ) {} private sign(key: string): string { return createHmac("sha256", this.signingSecret).update(key).digest("hex").slice(0, 32); } async presignUpload(prefix: string, contentType: string): Promise { const ext = contentType.split("/")[1] ?? "bin"; const key = `${prefix}/${randomUUID()}.${ext}`; const sig = this.sign(key); return { key, uploadUrl: `${this.baseUrl}/v1/mock-s3/${encodeURIComponent(key)}?sig=${sig}`, method: "PUT", headers: { "content-type": contentType }, expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(), }; } async getReadUrl(key: string): Promise { return `${this.baseUrl}/v1/mock-s3/${encodeURIComponent(key)}?sig=${this.sign(key)}`; } async putObject(key: string, data: Buffer, _contentType?: string): Promise { const filePath = path.join(MOCK_ROOT, key); await mkdir(path.dirname(filePath), { recursive: true }); await writeFile(filePath, data); } async getObject(key: string): Promise { try { return await readFile(path.join(MOCK_ROOT, key)); } catch { return null; } } async deleteObject(key: string): Promise { await rm(path.join(MOCK_ROOT, key), { force: true }).catch(() => {}); } extractKeyFromUrl(url: string): string | null { try { const parsed = new URL(url); const prefix = "/v1/mock-s3/"; if (!parsed.pathname.startsWith(prefix)) return null; return decodeURIComponent(parsed.pathname.slice(prefix.length)); } catch { return null; } } verifySignature(key: string, sig: string): boolean { return this.sign(key) === sig; } } export interface AwsStorageConfig { bucket: string; region: string; endpoint?: string; accessKeyId?: string; secretAccessKey?: string; } /** Riktig S3 (AWS eller S3-kompatibel via S3_ENDPOINT). */ export class AwsStorage implements StorageService { private readonly client: S3Client; private readonly bucket: string; constructor(cfg: AwsStorageConfig) { this.bucket = cfg.bucket; this.client = new S3Client({ region: cfg.region, ...(cfg.endpoint ? { endpoint: cfg.endpoint, forcePathStyle: true } : {}), ...(cfg.accessKeyId && cfg.secretAccessKey ? { credentials: { accessKeyId: cfg.accessKeyId, secretAccessKey: cfg.secretAccessKey } } : {}), // annars IAM-roll/instansprofil (rekommenderat i AWS) }); } /** Snabb verifiering vid uppstart – hellre högljutt fel än trasiga uppladdningar. */ async healthCheck(): Promise { await this.client.send(new HeadBucketCommand({ Bucket: this.bucket })); } async presignUpload(prefix: string, contentType: string): Promise { const ext = contentType.split("/")[1] ?? "bin"; const key = `${prefix}/${randomUUID()}.${ext}`; const uploadUrl = await getSignedUrl( this.client, new PutObjectCommand({ Bucket: this.bucket, Key: key, ContentType: contentType }), { expiresIn: 15 * 60 }, ); return { key, uploadUrl, method: "PUT", headers: { "content-type": contentType }, expiresAt: new Date(Date.now() + 15 * 60_000).toISOString(), }; } async getReadUrl(key: string): Promise { return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), { expiresIn: 60 * 60, }); } async putObject(key: string, data: Buffer, contentType: string): Promise { await this.client.send( new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: data, ContentType: contentType }), ); } async getObject(key: string): Promise { try { const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key })); const bytes = await res.Body?.transformToByteArray(); return bytes ? Buffer.from(bytes) : null; } catch { return null; } } async deleteObject(key: string): Promise { try { await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key })); } catch (err) { const code = (err as { Code?: string }).Code; if (code !== "NoSuchKey" && code !== "NotFound") { throw err; } } } extractKeyFromUrl(url: string): string | null { try { const parsed = new URL(url); if (parsed.hostname.startsWith(`${this.bucket}.`)) { return decodeURIComponent(parsed.pathname.replace(/^\//, "")); } const pathPrefix = `/${this.bucket}/`; if (parsed.pathname.startsWith(pathPrefix)) { return decodeURIComponent(parsed.pathname.slice(pathPrefix.length)); } return null; } catch { return null; } } } /** * Skapa rätt lagringstjänst utifrån S3_MODE. Används av API och worker. */ export function createStorageService(cfg: { mode: "aws" | "mock"; baseUrl: string; signingSecret: string; s3Bucket: string; s3Region: string; s3Endpoint?: string; s3AccessKeyId?: string; s3SecretAccessKey?: string; }): StorageService { if (cfg.mode === "aws") { return new AwsStorage({ bucket: cfg.s3Bucket, region: cfg.s3Region, endpoint: cfg.s3Endpoint, accessKeyId: cfg.s3AccessKeyId, secretAccessKey: cfg.s3SecretAccessKey, }); } return new MockStorage(cfg.baseUrl, cfg.signingSecret); }