mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-22 16:03:29 +10:00
62f8270b3e
commit b2b0470a1d9267d042ec0ac66523c6635bf5b199
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 13:13:38 2026 +0200
chore: update .gitignore to include .vite-hooks and modify pnpm-lock.yaml for dependencies
commit d28fadb5cd8706c874e616102878b4a394ec84c1
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 13:08:04 2026 +0200
fix: remove timestamp conflict guard
commit c6998d9dbab19d09d3c8054feef1d2e4117555eb
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 12:11:51 2026 +0200
chore(release): v5.1.5
commit f33d168711804880e1f12e88d24290aae16cc258
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:58:35 2026 +0200
revert: compose.yml
commit d961e6535811a10c335525fb33a08d03e737278d
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:58:08 2026 +0200
refactor(agent): replace 'revert' terminology with 'restore' for clarity, resolves #3086
commit 17f351171be218e33f01c469d95e4164d4c8dc57
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 11:10:41 2026 +0200
refactor(pdf): simplify sidebar section filtering and update summary feature logic
commit d55179b9d76879e3204de185e8b53fadd0a107ed
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:53:37 2026 +0200
chore: update pnpm-lock.yaml and turbo.json
commit 7cade6980e1a04352536bd44ef773f338c4ef599
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:38:30 2026 +0200
fix(polyfill): add tested polyfill for Map Upsert methods
commit 26d175bb9c53d93225d1e907678445252c13d660
Merge: 1cf33dc6c 5b1297fa2
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:23:29 2026 +0200
Merge remote-tracking branch 'origin/main' into feat/explore-hono-orpc-migration
# Conflicts:
# packages/api/src/services/agent-url.ts
# packages/runtime-externals/package.json
commit 1cf33dc6c9d81735730ad656e16dab6501c6d6a1
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Tue May 19 09:22:12 2026 +0200
chore: preserve branch changes before main sync
commit b380a4b00fdbcdd81ff4f8ef72b330fd027ccda5
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Mon May 18 07:50:28 2026 +0200
chore: lot of fixes for monorepo migration
commit 8fcf0ec64e1c29572ebaff494338368bfcf75760
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 13:57:17 2026 +0200
chore: update knip version and refine web app routing with new SEO endpoints
commit 234e68086ff15610a93877354c98e2c020364533
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 12:10:06 2026 +0200
refactor(auth): update OAuth routes to include API prefix and remove unused schema endpoint
commit 91c84b9a8496b0ce21d71cae9f8b2a027638c9ac
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:54:29 2026 +0200
chore: update dependencies and enhance PWA metadata in web app
commit 150117d4a5a9dd6cd92c64891aad8cae90f6a7af
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:12:35 2026 +0200
docs: revise manifest-only pwa testing scope
commit 6b939a55661aec9dd8122b184e4b60a5c7325fb5
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:11:33 2026 +0200
docs: add manifest-only pwa design
commit 1422e1fc96c400948b273210a1067251087d15d4
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:05:04 2026 +0200
chore(dev): simplify server proxy config
commit bc2ff5a9f6fda41e6c40333c8f163aa23a6c5e48
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:04:50 2026 +0200
docs: add unsafe oauth redirect plan
commit 445359ebe9b96c1515bf1c4c3f73ba8a8448ec12
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 11:04:34 2026 +0200
feat(auth): add unsafe oauth redirect flag
commit 73fffdd24598e56b2793f7657919bc794835892e
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:55:02 2026 +0200
docs: design unsafe oauth redirect flag
commit c0066aa19c15fc8a4c8e5179ed49889c117519f4
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:22:04 2026 +0200
chore: update translation source paths
commit 9033da082418d252aafd6c2eed72f71f014be3d9
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 10:09:25 2026 +0200
refactor(arch): react spa + hono migration
commit 6f27936c11bda895977dc63ee550c3346d4ce24b
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Fri May 15 01:10:47 2026 +0200
docs: add docker nightly tagging design
commit ecc1fd9a88a0ee1dca2f1977dfc17f74527fe1da
Author: Amruth Pillai <im.amruth@gmail.com>
Date: Thu May 14 20:05:44 2026 +0200
feat: migrate to hono spa server
405 lines
11 KiB
TypeScript
405 lines
11 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import { dirname, extname, join } from "node:path";
|
|
import {
|
|
DeleteObjectCommand,
|
|
GetObjectCommand,
|
|
ListObjectsV2Command,
|
|
PutObjectCommand,
|
|
S3Client,
|
|
} from "@aws-sdk/client-s3";
|
|
import sharp from "sharp";
|
|
import { env } from "@reactive-resume/env/server";
|
|
import { getLocalDataDirectory } from "@reactive-resume/utils/monorepo.node";
|
|
|
|
interface StorageWriteInput {
|
|
key: string;
|
|
data: Uint8Array;
|
|
contentType: string;
|
|
private?: boolean;
|
|
}
|
|
|
|
interface StorageReadResult {
|
|
data: Uint8Array;
|
|
size: number;
|
|
etag?: string;
|
|
lastModified?: Date;
|
|
contentType?: string;
|
|
}
|
|
|
|
interface StorageService {
|
|
list(prefix: string): Promise<string[]>;
|
|
write(input: StorageWriteInput): Promise<void>;
|
|
read(key: string): Promise<StorageReadResult | null>;
|
|
delete(key: string): Promise<boolean>;
|
|
healthcheck(): Promise<StorageHealthResult>;
|
|
}
|
|
|
|
interface StorageHealthResult {
|
|
status: "healthy" | "unhealthy";
|
|
type: "local" | "s3";
|
|
message: string;
|
|
error?: string;
|
|
}
|
|
|
|
const CONTENT_TYPE_MAP: Record<string, string> = {
|
|
".webp": "image/webp",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".png": "image/png",
|
|
".gif": "image/gif",
|
|
".svg": "image/svg+xml",
|
|
".pdf": "application/pdf",
|
|
};
|
|
|
|
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}`;
|
|
return new URL(apiPath, env.APP_URL).toString();
|
|
}
|
|
|
|
export function inferContentType(filename: string): string {
|
|
const extension = extname(filename).toLowerCase();
|
|
return CONTENT_TYPE_MAP[extension] ?? DEFAULT_CONTENT_TYPE;
|
|
}
|
|
|
|
export function isImageFile(mimeType: string): boolean {
|
|
return IMAGE_MIME_TYPES.includes(mimeType);
|
|
}
|
|
|
|
interface ProcessedImage {
|
|
data: Uint8Array;
|
|
contentType: string;
|
|
}
|
|
|
|
export async function processImageForUpload(file: File): Promise<ProcessedImage> {
|
|
const fileBuffer = await file.arrayBuffer();
|
|
|
|
if (env.FLAG_DISABLE_IMAGE_PROCESSING) {
|
|
return {
|
|
data: new Uint8Array(fileBuffer),
|
|
contentType: file.type,
|
|
};
|
|
}
|
|
|
|
const processedBuffer = await sharp(fileBuffer)
|
|
.resize(800, 800, { fit: "inside", withoutEnlargement: true })
|
|
.jpeg({ quality: 80 })
|
|
.toBuffer();
|
|
|
|
return {
|
|
data: new Uint8Array(processedBuffer),
|
|
contentType: "image/jpeg",
|
|
};
|
|
}
|
|
|
|
class LocalStorageService implements StorageService {
|
|
private rootDirectory: string;
|
|
|
|
constructor() {
|
|
this.rootDirectory = getLocalDataDirectory(env.LOCAL_STORAGE_PATH);
|
|
}
|
|
|
|
async list(prefix: string): Promise<string[]> {
|
|
const fullPath = this.resolvePath(prefix);
|
|
|
|
try {
|
|
const files = await fs.readdir(fullPath, { recursive: true });
|
|
|
|
return files.map((file) => join(prefix, file));
|
|
} catch (error: unknown) {
|
|
// If directory doesn't exist, return empty array
|
|
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
return [];
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async write({ key, data, private: isPrivate }: StorageWriteInput): Promise<void> {
|
|
if (isPrivate) {
|
|
throw new Error(
|
|
"Private storage writes are not supported by the local filesystem backend. Configure S3 to store private attachments.",
|
|
);
|
|
}
|
|
|
|
const fullPath = this.resolvePath(key);
|
|
|
|
await fs.mkdir(dirname(fullPath), { recursive: true });
|
|
await fs.writeFile(fullPath, data);
|
|
}
|
|
|
|
async read(key: string): Promise<StorageReadResult | null> {
|
|
const fullPath = this.resolvePath(key);
|
|
try {
|
|
const [arrayBuffer, stats] = await Promise.all([fs.readFile(fullPath), fs.stat(fullPath)]);
|
|
|
|
return {
|
|
data: arrayBuffer,
|
|
size: stats.size,
|
|
etag: `"${stats.size}-${stats.mtime.getTime()}"`,
|
|
lastModified: stats.mtime,
|
|
contentType: inferContentType(key),
|
|
};
|
|
} catch (error: unknown) {
|
|
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
return null;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async delete(key: string): Promise<boolean> {
|
|
const fullPath = this.resolvePath(key);
|
|
|
|
// Check if the path exists and whether it's a file or folder
|
|
try {
|
|
const stats = await fs.stat(fullPath);
|
|
|
|
if (stats.isDirectory()) {
|
|
// Delete the directory and its contents recursively
|
|
await fs.rm(fullPath, { recursive: true });
|
|
return true;
|
|
}
|
|
await fs.unlink(fullPath);
|
|
return true;
|
|
} catch {
|
|
// Path does not exist
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async healthcheck(): Promise<StorageHealthResult> {
|
|
try {
|
|
await fs.mkdir(this.rootDirectory, { recursive: true });
|
|
await fs.access(this.rootDirectory, fs.constants.R_OK | fs.constants.W_OK);
|
|
|
|
return {
|
|
type: "local",
|
|
status: "healthy",
|
|
message: "Local filesystem storage is accessible and has read/write permission.",
|
|
};
|
|
} catch (error: unknown) {
|
|
return {
|
|
type: "local",
|
|
status: "unhealthy",
|
|
message: "Local filesystem storage is not accessible or lacks sufficient permissions.",
|
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
};
|
|
}
|
|
}
|
|
|
|
private resolvePath(key: string): string {
|
|
const normalizedKey = key.replace(/^\/*/, "");
|
|
const segments = normalizedKey
|
|
.split(/[/\\]+/)
|
|
.filter((segment) => segment.length > 0 && segment !== "." && segment !== "..");
|
|
|
|
if (segments.length === 0) throw new Error("Invalid storage key");
|
|
|
|
return join(this.rootDirectory, ...segments);
|
|
}
|
|
}
|
|
|
|
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>;
|
|
|
|
constructor() {
|
|
if (!env.S3_ACCESS_KEY_ID || !env.S3_SECRET_ACCESS_KEY || !env.S3_BUCKET) {
|
|
throw new Error("S3 credentials are not set");
|
|
}
|
|
|
|
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({
|
|
region: env.S3_REGION,
|
|
forcePathStyle: env.S3_FORCE_PATH_STYLE,
|
|
...(this.endpoint ? { endpoint: this.endpoint } : {}),
|
|
credentials: {
|
|
accessKeyId: this.accessKeyId,
|
|
secretAccessKey: this.secretAccessKey,
|
|
},
|
|
});
|
|
}
|
|
|
|
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);
|
|
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,
|
|
Body: data,
|
|
ACL: isPrivate ? "private" : "public-read",
|
|
ContentType: contentType,
|
|
});
|
|
|
|
await 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);
|
|
if (!response.Body) return null;
|
|
|
|
const arrayBuffer = await response.Body.transformToByteArray();
|
|
|
|
return {
|
|
data: arrayBuffer,
|
|
size: response.ContentLength ?? 0,
|
|
contentType: response.ContentType ?? inferContentType(key),
|
|
...(response.ETag !== undefined ? { etag: response.ETag } : {}),
|
|
...(response.LastModified !== undefined ? { lastModified: response.LastModified } : {}),
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async delete(keyOrPrefix: string): Promise<boolean> {
|
|
const client = await this.getClient();
|
|
// Use list to find all matching keys (handles both single file and folder/prefix)
|
|
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)));
|
|
|
|
// Return true if at least one deletion succeeded
|
|
return results.some((r) => r.status === "fulfilled");
|
|
}
|
|
|
|
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);
|
|
|
|
const deleteCommand = new DeleteObjectCommand({ Bucket: this.bucket, Key: "healthcheck" });
|
|
await client.send(deleteCommand);
|
|
|
|
return {
|
|
type: "s3",
|
|
status: "healthy",
|
|
message: "S3 storage is accessible and credentials are valid.",
|
|
};
|
|
} catch (error: unknown) {
|
|
return {
|
|
type: "s3",
|
|
status: "unhealthy",
|
|
message: "Failed to connect to S3 storage or invalid credentials.",
|
|
error: error instanceof Error ? error.message : "Unknown error",
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
function createStorageService(): StorageService {
|
|
if (env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY && env.S3_BUCKET) {
|
|
return new S3StorageService();
|
|
}
|
|
|
|
return new LocalStorageService();
|
|
}
|
|
|
|
let cachedService: StorageService | null = null;
|
|
|
|
export function getStorageService(): StorageService {
|
|
if (cachedService) return cachedService;
|
|
|
|
cachedService = createStorageService();
|
|
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 {
|
|
url: string;
|
|
key: string;
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|