mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 08:54:05 +10:00
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:
@@ -1 +1 @@
|
||||
export { getStorageService } from "./service";
|
||||
export { getStorageService, inferContentType } from "./service";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user