feat(17-01): add S3 storage service with upload, delete, and presigned URL support
- Create storage.service.ts wrapping @aws-sdk/client-s3 with forcePathStyle for MinIO - Export uploadImage, deleteImage, getImageUrl, withImageUrl, withImageUrls - Add unit tests with mocked S3Client (8 tests passing) - Install @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner
This commit is contained in:
83
src/server/services/storage.service.ts
Normal file
83
src/server/services/storage.service.ts
Normal 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)));
|
||||
}
|
||||
Reference in New Issue
Block a user