import { createReadStream, createWriteStream } from "node:fs"; import { mkdir, stat, writeFile } from "node:fs/promises"; /* eslint-disable @typescript-eslint/no-explicit-any */ import { dirname, normalize, relative, resolve } from "node:path"; import { pipeline } from "node:stream/promises"; import { Readable } from "node:stream"; import { CreateBucketCommand, CompleteMultipartUploadCommand, GetObjectCommand, HeadBucketCommand, HeadObjectCommand, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; import type { AppConfig } from "./config"; export interface StoredObject { body: Readable; sizeBytes: number; contentType: string; } export type UploadPlan = { mode: "single"; uploadUrl: string }; export interface StorageAdapter { createUploadPlan(key: string, contentType: string, sizeBytes: number): Promise; completeMultipartUpload(key: string, uploadId: string | undefined, parts: Array<{ partNumber: number; etag: string }> | undefined): Promise; writeUploadFromStream(key: string, stream: NodeJS.ReadableStream, sizeBytes?: number): Promise; writeObject(key: string, body: Buffer, contentType: string): Promise; writeStream(key: string, transform: NodeJS.ReadWriteStream, contentType: string, sizeBytes: number, source: NodeJS.ReadableStream): Promise; downloadToFile(key: string, filePath: string): Promise; getObject(key: string): Promise; headObject(key: string): Promise<{ sizeBytes: number; contentType: string } | null>; ensureBucket?(): Promise; } function contentTypeForPath(path: string) { if (path.endsWith(".json")) return "application/json; charset=utf-8"; if (path.endsWith(".ipa")) return "application/octet-stream"; return "application/octet-stream"; } export class LocalStorage implements StorageAdapter { private readonly root: string; constructor(root: string) { this.root = resolve(root); } private pathFor(key: string) { const candidate = resolve(this.root, normalize(key)); const relativePath = relative(this.root, candidate); if (!relativePath || relativePath.startsWith("..") || relativePath.includes("..")) { throw new Error("STORAGE_PATH_INVALID"); } return candidate; } async createUploadPlan(key: string) { return { mode: "single" as const, uploadUrl: `/api/v1/uploads/content/${encodeURIComponent(key)}` }; } async completeMultipartUpload() { return; } // eslint-disable-next-line @typescript-eslint/no-unused-vars -- sizeBytes is only needed by S3. async writeUploadFromStream(key: string, stream: NodeJS.ReadableStream, sizeBytes?: number) { const filePath = this.pathFor(key); await mkdir(dirname(filePath), { recursive: true }); await pipeline(stream, createWriteStream(filePath, { flags: "w" })); return Number((await stat(filePath)).size); } async writeObject(key: string, body: Buffer) { const filePath = this.pathFor(key); await mkdir(dirname(filePath), { recursive: true }); await writeFile(filePath, body); } async writeStream(key: string, transform: NodeJS.ReadWriteStream, _contentType: string, _sizeBytes: number, source: NodeJS.ReadableStream) { const filePath = this.pathFor(key); await mkdir(dirname(filePath), { recursive: true }); await pipeline(source, transform, createWriteStream(filePath, { flags: "w" })); } async downloadToFile(key: string, filePath: string) { await mkdir(dirname(filePath), { recursive: true }); await pipeline(createReadStream(this.pathFor(key)), createWriteStream(filePath, { flags: "w" })); } async getObject(key: string) { const filePath = this.pathFor(key); try { const fileStat = await stat(filePath); return { body: createReadStream(filePath), sizeBytes: Number(fileStat.size), contentType: contentTypeForPath(key) }; } catch { return null; } } async headObject(key: string) { try { const fileStat = await stat(this.pathFor(key)); return { sizeBytes: Number(fileStat.size), contentType: contentTypeForPath(key) }; } catch { return null; } } } export class S3Storage implements StorageAdapter { private readonly client: S3Client; constructor(private readonly appConfig: AppConfig) { this.client = new S3Client({ region: appConfig.S3_REGION, endpoint: appConfig.S3_ENDPOINT, forcePathStyle: appConfig.S3_FORCE_PATH_STYLE, credentials: appConfig.S3_ACCESS_KEY_ID && appConfig.S3_SECRET_ACCESS_KEY ? { accessKeyId: appConfig.S3_ACCESS_KEY_ID, secretAccessKey: appConfig.S3_SECRET_ACCESS_KEY } : undefined, }); } // Idempotently create the configured bucket so the service is self-contained // and does not depend on an external init step (e.g. a separate minio-init // container) before it can store objects. Safe to call on every startup. async ensureBucket() { const bucket = this.appConfig.S3_BUCKET; try { await this.client.send(new HeadBucketCommand({ Bucket: bucket })); return; } catch (error: any) { const notFound = error?.$metadata?.httpStatusCode === 404 || error?.name === "NotFound" || error?.name === "NoSuchBucket"; if (!notFound) { // Re-throw unexpected errors (auth, network) so they surface loudly. throw error; } } // us-east-1 / "auto" regions must NOT send a LocationConstraint. await this.client.send(new CreateBucketCommand({ Bucket: bucket })); } // Uploads always flow through the API (browser -> API -> MinIO), so there is // no presigned/multipart plan returned to the client. The API exposes a // single PUT endpoint that streams the body straight into object storage, // which keeps MinIO fully private and removes the need for a public S3 URL. async createUploadPlan(key: string) { return { mode: "single" as const, uploadUrl: `/api/v1/uploads/content/${encodeURIComponent(key)}` }; } async completeMultipartUpload(key: string, uploadId: string | undefined, parts: Array<{ partNumber: number; etag: string }> | undefined) { if (!uploadId || !parts?.length) throw new Error("MULTIPART_PARTS_REQUIRED"); await this.client.send(new CompleteMultipartUploadCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, UploadId: uploadId, MultipartUpload: { Parts: parts.sort((a, b) => a.partNumber - b.partNumber).map((part) => ({ PartNumber: part.partNumber, ETag: part.etag })) } })); } async writeUploadFromStream(key: string, stream: NodeJS.ReadableStream, sizeBytes?: number) { // ContentLength must be provided for streaming bodies, otherwise the SDK's // flexible-checksums middleware sends `x-amz-decoded-content-length: // undefined` and the upload fails with ERR_HTTP_INVALID_HEADER_VALUE. await this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: stream as any, ContentLength: sizeBytes })); const metadata = await this.headObject(key); return metadata?.sizeBytes || 0; } async writeObject(key: string, body: Buffer, contentType: string) { await this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: body, ContentType: contentType })); } async writeStream(key: string, transform: NodeJS.ReadWriteStream, contentType: string, sizeBytes: number, source: NodeJS.ReadableStream) { const upload = this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: transform as any, ContentType: contentType, ContentLength: sizeBytes })); await pipeline(source, transform); await upload; } async downloadToFile(key: string, filePath: string) { const response = await this.client.send(new GetObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key })); if (!response.Body) throw new Error("STORAGE_OBJECT_EMPTY"); await mkdir(dirname(filePath), { recursive: true }); const body = response.Body instanceof Readable ? response.Body : Readable.fromWeb(response.Body as any); await pipeline(body, createWriteStream(filePath, { flags: "w" })); } async getObject(key: string) { try { const response = await this.client.send(new GetObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key })); if (!response.Body) return null; const body = response.Body instanceof Readable ? response.Body : Readable.fromWeb(response.Body as any); return { body, sizeBytes: Number(response.ContentLength || 0), contentType: response.ContentType || contentTypeForPath(key) }; } catch { return null; } } async headObject(key: string) { try { const response = await this.client.send(new HeadObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key })); return { sizeBytes: Number(response.ContentLength || 0), contentType: response.ContentType || contentTypeForPath(key) }; } catch { return null; } } } export function createStorage(appConfig: AppConfig): StorageAdapter { return appConfig.STORAGE_MODE === "s3" ? new S3Storage(appConfig) : new LocalStorage(appConfig.LOCAL_STORAGE_DIR); }