Merge branch 'worktree-agent-a402d11d' into Develop

# Conflicts:
#	.env.example
#	.planning/STATE.md
#	bun.lock
#	docker-compose.dev.yml
#	docker-compose.yml
#	package.json
This commit is contained in:
2026-04-05 12:17:35 +02:00
10 changed files with 656 additions and 29 deletions

View File

@@ -0,0 +1,83 @@
import {
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
// MinIO GitHub repository was archived Feb 2026. The S3 API abstraction
// makes the underlying provider swappable (SeaweedFS, Garage, AWS S3, etc.)
// without code changes.
const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION ?? "us-east-1",
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
forcePathStyle: true, // REQUIRED for MinIO and most S3-compatible services
});
const bucket = process.env.S3_BUCKET ?? "gearbox-images";
const presignExpiry = Number.parseInt(
process.env.S3_PRESIGN_EXPIRY ?? "3600",
10,
);
export async function uploadImage(
buffer: Buffer | ArrayBuffer,
filename: string,
contentType: string,
): Promise<void> {
await s3.send(
new PutObjectCommand({
Bucket: bucket,
Key: filename,
Body: Buffer.from(buffer),
ContentType: contentType,
}),
);
}
export async function deleteImage(filename: string): Promise<void> {
await s3.send(
new DeleteObjectCommand({
Bucket: bucket,
Key: filename,
}),
);
}
export async function getImageUrl(filename: string): Promise<string> {
const command = new GetObjectCommand({
Bucket: bucket,
Key: filename,
});
return getSignedUrl(s3, command, { expiresIn: presignExpiry });
}
/**
* Enrich a record that has an imageFilename with a presigned imageUrl.
* Returns null imageUrl when imageFilename is null.
*/
export async function withImageUrl<
T extends { imageFilename: string | null },
>(record: T): Promise<T & { imageUrl: string | null }> {
return {
...record,
imageUrl: record.imageFilename
? await getImageUrl(record.imageFilename)
: null,
};
}
/**
* Batch version of withImageUrl. Uses Promise.all for parallelism.
*/
export async function withImageUrls<
T extends { imageFilename: string | null },
>(records: T[]): Promise<(T & { imageUrl: string | null })[]> {
return Promise.all(records.map((record) => withImageUrl(record)));
}