refactor(api,server): storage — sync S3 client, dead upload types, shared inferContentType

- S3StorageService: replace async createClient/getClient/clientPromise chain with a
  synchronous constructor field; new S3Client() is synchronous (finding 7)
- uploadFile: delete speculative screenshot/pdf upload types (never called); hardcode
  picture key and simplify to 3 lines (finding 6)
- Export inferContentType from @reactive-resume/api/features/storage (finding 8)
- uploads.ts: import inferContentType from storage instead of local duplicate; inline
  buildResponseHeaders into handleUpload (findings 11, 12)

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
This commit is contained in:
Amruth Pillai
2026-07-04 20:33:34 +02:00
parent 3f050e5213
commit 19470c8cd2
4 changed files with 37 additions and 146 deletions
+1 -1
View File
@@ -1 +1 @@
export { getStorageService } from "./service";
export { getStorageService, inferContentType } from "./service";
+1 -6
View File
@@ -56,12 +56,7 @@ export const storageRouter = {
contentType = originalMimeType;
}
const result = await uploadFile({
userId: context.user.id,
data,
contentType,
type: "picture",
});
const result = await uploadFile({ userId: context.user.id, data, contentType });
return {
url: result.url,
+16 -74
View File
@@ -55,22 +55,11 @@ const DEFAULT_CONTENT_TYPE = "application/octet-stream";
const IMAGE_MIME_TYPES = ["image/gif", "image/png", "image/jpeg", "image/webp"];
// Key builders for different upload types
function buildPictureKey(userId: string): string {
const timestamp = Date.now();
return `uploads/${userId}/pictures/${timestamp}.jpeg`;
}
function buildScreenshotKey(userId: string, resumeId: string): string {
const timestamp = Date.now();
return `uploads/${userId}/screenshots/${resumeId}/${timestamp}.jpeg`;
}
function buildPdfKey(userId: string, resumeId: string): string {
const timestamp = Date.now();
return `uploads/${userId}/pdfs/${resumeId}/${timestamp}.pdf`;
}
function buildPublicUrl(path: string): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const apiPath = normalizedPath.startsWith("/api/") ? normalizedPath : `/api${normalizedPath}`;
@@ -224,10 +213,7 @@ class LocalStorageService implements StorageService {
class S3StorageService implements StorageService {
private readonly bucket: string;
private readonly accessKeyId: string;
private readonly secretAccessKey: string;
private readonly endpoint: string | undefined;
private readonly clientPromise: Promise<S3Client>;
private readonly client: S3Client;
constructor() {
if (!env.S3_ACCESS_KEY_ID || !env.S3_SECRET_ACCESS_KEY || !env.S3_BUCKET) {
@@ -235,38 +221,25 @@ class S3StorageService implements StorageService {
}
this.bucket = env.S3_BUCKET;
this.accessKeyId = env.S3_ACCESS_KEY_ID;
this.secretAccessKey = env.S3_SECRET_ACCESS_KEY;
this.endpoint = env.S3_ENDPOINT;
this.clientPromise = this.createClient();
}
private async createClient(): Promise<S3Client> {
return new S3Client({
this.client = new S3Client({
region: env.S3_REGION,
forcePathStyle: env.S3_FORCE_PATH_STYLE,
...(this.endpoint ? { endpoint: this.endpoint } : {}),
...(env.S3_ENDPOINT ? { endpoint: env.S3_ENDPOINT } : {}),
credentials: {
accessKeyId: this.accessKeyId,
secretAccessKey: this.secretAccessKey,
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
},
});
}
private async getClient(): Promise<S3Client> {
return this.clientPromise;
}
async list(prefix: string): Promise<string[]> {
const client = await this.getClient();
const command = new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix });
const response = await client.send(command);
const response = await this.client.send(command);
if (!response.Contents) return [];
return response.Contents.map((object) => object.Key ?? "");
}
async write({ key, data, contentType, private: isPrivate }: StorageWriteInput): Promise<void> {
const client = await this.getClient();
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
@@ -275,14 +248,13 @@ class S3StorageService implements StorageService {
ContentType: contentType,
});
await client.send(command);
await this.client.send(command);
}
async read(key: string): Promise<StorageReadResult | null> {
try {
const client = await this.getClient();
const command = new GetObjectCommand({ Bucket: this.bucket, Key: key });
const response = await client.send(command);
const response = await this.client.send(command);
if (!response.Body) return null;
const arrayBuffer = await response.Body.transformToByteArray();
@@ -301,13 +273,13 @@ class S3StorageService implements StorageService {
async delete(keyOrPrefix: string): Promise<boolean> {
// Use list to find all matching keys (handles both single file and folder/prefix)
const [client, keys] = await Promise.all([this.getClient(), this.list(keyOrPrefix)]);
const keys = await this.list(keyOrPrefix);
if (keys.length === 0) return false;
// Delete all matching keys using Promise.allSettled
const deleteCommands = keys.map((k) => new DeleteObjectCommand({ Bucket: this.bucket, Key: k }));
const results = await Promise.allSettled(deleteCommands.map((c) => client.send(c)));
const results = await Promise.allSettled(deleteCommands.map((c) => this.client.send(c)));
// Return true if at least one deletion succeeded
return results.some((r) => r.status === "fulfilled");
@@ -315,12 +287,11 @@ class S3StorageService implements StorageService {
async healthcheck(): Promise<StorageHealthResult> {
try {
const client = await this.getClient();
const putCommand = new PutObjectCommand({ Bucket: this.bucket, Key: "healthcheck", Body: "OK" });
await client.send(putCommand);
await this.client.send(putCommand);
const deleteCommand = new DeleteObjectCommand({ Bucket: this.bucket, Key: "healthcheck" });
await client.send(deleteCommand);
await this.client.send(deleteCommand);
return {
type: "s3",
@@ -355,15 +326,10 @@ export function getStorageService(): StorageService {
return cachedService;
}
// High-level upload types
type UploadType = "picture" | "screenshot" | "pdf";
interface UploadFileInput {
userId: string;
data: Uint8Array;
contentType: string;
type: UploadType;
resumeId?: string;
}
interface UploadFileResult {
@@ -371,33 +337,9 @@ interface UploadFileResult {
key: string;
}
// ponytail: only "picture" uploads exist in production; screenshot/pdf types were speculative dead code
export async function uploadFile(input: UploadFileInput): Promise<UploadFileResult> {
const storageService = getStorageService();
let key: string;
switch (input.type) {
case "picture":
key = buildPictureKey(input.userId);
break;
case "screenshot":
if (!input.resumeId) throw new Error("resumeId is required for screenshot uploads");
key = buildScreenshotKey(input.userId, input.resumeId);
break;
case "pdf":
if (!input.resumeId) throw new Error("resumeId is required for pdf uploads");
key = buildPdfKey(input.userId, input.resumeId);
break;
}
await storageService.write({
key,
data: input.data,
contentType: input.contentType,
});
return {
key,
url: buildPublicUrl(key),
};
const key = buildPictureKey(input.userId);
await getStorageService().write({ key, data: input.data, contentType: input.contentType });
return { key, url: buildPublicUrl(key) };
}