mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-11 21:45:04 +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,6 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { basename, extname, normalize } from "node:path";
|
||||
import { getStorageService } from "@reactive-resume/api/features/storage";
|
||||
import { getStorageService, inferContentType } from "@reactive-resume/api/features/storage";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
|
||||
export async function handleUpload(request: Request) {
|
||||
@@ -18,41 +18,32 @@ export async function handleUpload(request: Request) {
|
||||
|
||||
const filename = filePath.split("/").pop() ?? filePath;
|
||||
const ext = extname(filename).toLowerCase();
|
||||
const contentType = storedFile.contentType ?? inferContentTypeFromExtension(ext);
|
||||
const contentType = storedFile.contentType ?? inferContentType(filename);
|
||||
const etag = createEtag(storedFile);
|
||||
|
||||
if (isNotModified(request.headers, etag)) return makeNotModifiedResponse(etag);
|
||||
|
||||
const shouldForceDownload = [".pdf"].includes(ext);
|
||||
const headers = buildResponseHeaders({
|
||||
filename,
|
||||
storedFile,
|
||||
contentType,
|
||||
etag,
|
||||
shouldForceDownload,
|
||||
});
|
||||
|
||||
const buffer = toArrayBuffer(storedFile.data);
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
|
||||
headers.set("Content-Length", storedFile.size.toString());
|
||||
|
||||
return new Response(buffer, { headers });
|
||||
}
|
||||
|
||||
function inferContentTypeFromExtension(ext: string): string {
|
||||
switch (ext) {
|
||||
case ".webp":
|
||||
return "image/webp";
|
||||
case ".png":
|
||||
return "image/png";
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
return "image/jpeg";
|
||||
case ".gif":
|
||||
return "image/gif";
|
||||
case ".pdf":
|
||||
return "application/pdf";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
if (shouldForceDownload) {
|
||||
headers.set("Content-Disposition", `attachment; filename="${encodeURIComponent(basename(filename))}"`);
|
||||
}
|
||||
|
||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||
headers.set("ETag", etag);
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
headers.set("Cross-Origin-Resource-Policy", "same-site");
|
||||
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
headers.set("X-Frame-Options", "DENY");
|
||||
headers.set("X-Download-Options", "noopen");
|
||||
headers.set("Access-Control-Allow-Origin", env.APP_URL);
|
||||
|
||||
return new Response(toArrayBuffer(storedFile.data), { headers });
|
||||
}
|
||||
|
||||
function parseRouteParams(url: string): { userId: string | undefined; filePath: string | undefined } {
|
||||
@@ -101,43 +92,6 @@ function makeNotModifiedResponse(etag: string): Response {
|
||||
});
|
||||
}
|
||||
|
||||
type BuildResponseHeaderArgs = {
|
||||
filename: string;
|
||||
storedFile: { size: number };
|
||||
contentType: string;
|
||||
etag: string;
|
||||
shouldForceDownload: boolean;
|
||||
};
|
||||
|
||||
function buildResponseHeaders({
|
||||
filename,
|
||||
storedFile,
|
||||
contentType,
|
||||
etag,
|
||||
shouldForceDownload,
|
||||
}: BuildResponseHeaderArgs): Headers {
|
||||
const headers = new Headers();
|
||||
|
||||
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
|
||||
headers.set("Content-Length", storedFile.size.toString());
|
||||
|
||||
if (shouldForceDownload) {
|
||||
headers.set("Content-Disposition", `attachment; filename="${encodeURIComponent(basename(filename))}"`);
|
||||
}
|
||||
|
||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||
headers.set("ETag", etag);
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
headers.set("Cross-Origin-Resource-Policy", "same-site");
|
||||
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
headers.set("X-Frame-Options", "DENY");
|
||||
headers.set("X-Download-Options", "noopen");
|
||||
headers.set("Access-Control-Allow-Origin", env.APP_URL);
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
|
||||
return data.byteOffset === 0 && data.byteLength === data.buffer.byteLength
|
||||
? (data.buffer as ArrayBuffer)
|
||||
|
||||
@@ -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