initial commit of v5

This commit is contained in:
Amruth Pillai
2026-01-19 23:31:54 +01:00
parent 55bdfd0067
commit cad390fa13
1132 changed files with 200807 additions and 165288 deletions
+54
View File
@@ -0,0 +1,54 @@
import { createORPCClient, onError } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import { BatchLinkPlugin } from "@orpc/client/plugins";
import { createRouterClient, type InferRouterInputs, type InferRouterOutputs, type RouterClient } from "@orpc/server";
import { createTanstackQueryUtils } from "@orpc/tanstack-query";
import { createIsomorphicFn } from "@tanstack/react-start";
import { getRequestHeaders } from "@tanstack/react-start/server";
import router from "@/integrations/orpc/router";
import { getLocale } from "@/utils/locale";
export const getORPCClient = createIsomorphicFn()
.server((): RouterClient<typeof router> => {
return createRouterClient(router, {
interceptors: [
onError((error) => {
console.error(error);
}),
],
context: async () => {
const locale = await getLocale();
const reqHeaders = getRequestHeaders();
// Add a custom header to identify server-side calls
reqHeaders.set("x-server-side-call", "true");
return { locale, reqHeaders };
},
});
})
.client((): RouterClient<typeof router> => {
const link = new RPCLink({
url: `${window.location.origin}/api/rpc`,
fetch: (request, init) => {
return fetch(request, { ...init, credentials: "include" });
},
interceptors: [
onError((error) => {
if (error instanceof DOMException) return;
console.error(error);
}),
],
plugins: [new BatchLinkPlugin({ groups: [{ condition: () => true, context: {} }] })],
});
return createORPCClient(link);
});
export const client = getORPCClient();
export const orpc = createTanstackQueryUtils(client);
export type RouterInput = InferRouterInputs<typeof router>;
export type RouterOutput = InferRouterOutputs<typeof router>;
+86
View File
@@ -0,0 +1,86 @@
import { ORPCError, os } from "@orpc/server";
import type { User } from "better-auth";
import { eq } from "drizzle-orm";
import { env } from "@/utils/env";
import type { Locale } from "@/utils/locale";
import { auth } from "../auth/config";
import { db } from "../drizzle/client";
import { user } from "../drizzle/schema";
interface ORPCContext {
locale: Locale;
reqHeaders?: Headers;
}
async function getUserFromHeaders(headers: Headers): Promise<User | null> {
try {
const result = await auth.api.getSession({ headers });
if (!result || !result.user) return null;
return result.user;
} catch {
return null;
}
}
async function getUserFromApiKey(apiKey: string): Promise<User | null> {
try {
const result = await auth.api.verifyApiKey({ body: { key: apiKey } });
if (!result.key || !result.valid) return null;
const [userResult] = await db.select().from(user).where(eq(user.id, result.key.userId)).limit(1);
if (!userResult) return null;
return userResult;
} catch {
return null;
}
}
const base = os.$context<ORPCContext>();
export const publicProcedure = base.use(async ({ context, next }) => {
const headers = context.reqHeaders ?? new Headers();
const apiKey = headers.get("x-api-key");
const user = apiKey ? await getUserFromApiKey(apiKey) : await getUserFromHeaders(headers);
return next({
context: {
...context,
user: user ?? null,
},
});
});
export const protectedProcedure = publicProcedure.use(async ({ context, next }) => {
if (!context.user) throw new ORPCError("UNAUTHORIZED");
return next({
context: {
...context,
user: context.user,
},
});
});
/**
* Server-only procedure that can only be called from server-side code (e.g., loaders).
* Rejects requests from the browser with a 401 UNAUTHORIZED error.
*/
export const serverOnlyProcedure = publicProcedure.use(async ({ context, next }) => {
const headers = context.reqHeaders ?? new Headers();
// Check for the custom header that indicates this is a server-side call
// Server-side calls using createRouterClient have this header set
const isServerSideCall = env.FLAG_DEBUG_PRINTER || headers.get("x-server-side-call") === "true";
// If the header is not present, this is a client-side HTTP request - reject it
if (!isServerSideCall) {
throw new ORPCError("UNAUTHORIZED", {
message: "This endpoint can only be called from server-side code",
});
}
return next({ context });
});
@@ -0,0 +1,36 @@
import { createHash, timingSafeEqual } from "node:crypto";
import { getCookie, setCookie } from "@tanstack/react-start/server";
import { env } from "@/utils/env";
const RESUME_ACCESS_COOKIE_PREFIX = "resume_access";
const RESUME_ACCESS_TTL_SECONDS = 60 * 10; // 10 minutes
const getResumeAccessCookieName = (resumeId: string) => `${RESUME_ACCESS_COOKIE_PREFIX}_${resumeId}`;
const signResumeAccessToken = (resumeId: string, passwordHash: string): string =>
createHash("sha256").update(`${resumeId}:${passwordHash}`).digest("hex");
const safeEquals = (value: string, expected: string) => {
const valueBuffer = Buffer.from(value);
const expectedBuffer = Buffer.from(expected);
if (valueBuffer.length !== expectedBuffer.length) return false;
return timingSafeEqual(valueBuffer, expectedBuffer);
};
export const hasResumeAccess = (resumeId: string, passwordHash: string | null) => {
if (!passwordHash) return false;
const cookieName = getResumeAccessCookieName(resumeId);
const cookieValue = getCookie(cookieName);
if (!cookieValue) return false;
const expected = signResumeAccessToken(resumeId, passwordHash);
return safeEquals(cookieValue, expected);
};
export const grantResumeAccess = (resumeId: string, passwordHash: string) =>
setCookie(getResumeAccessCookieName(resumeId), signResumeAccessToken(resumeId, passwordHash), {
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: RESUME_ACCESS_TTL_SECONDS,
secure: env.APP_URL.startsWith("https"),
});
+176
View File
@@ -0,0 +1,176 @@
import { createAnthropic } from "@ai-sdk/anthropic";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createOpenAI } from "@ai-sdk/openai";
import { createGateway, generateText, Output, streamText } from "ai";
import { createOllama } from "ai-sdk-ollama";
import { match } from "ts-pattern";
import z, { flattenError, ZodError } from "zod";
import docxParserSystemPrompt from "@/integrations/ai/prompts/docx-parser-system.md?raw";
import docxParserUserPrompt from "@/integrations/ai/prompts/docx-parser-user.md?raw";
import pdfParserSystemPrompt from "@/integrations/ai/prompts/pdf-parser-system.md?raw";
import pdfParserUserPrompt from "@/integrations/ai/prompts/pdf-parser-user.md?raw";
import { defaultResumeData, resumeDataSchema } from "@/schema/resume/data";
import { protectedProcedure } from "../context";
const aiProviderSchema = z.enum(["vercel-ai-gateway", "openai", "anthropic", "gemini", "ollama"]);
type AIProvider = z.infer<typeof aiProviderSchema>;
type GetModelInput = {
provider: AIProvider;
model: string;
apiKey: string;
baseURL?: string;
};
function getModel(input: GetModelInput) {
const { provider, model, apiKey, baseURL } = input;
return match(provider)
.with("vercel-ai-gateway", () => createGateway({ apiKey }).languageModel(model))
.with("openai", () => createOpenAI({ apiKey }).languageModel(model))
.with("anthropic", () => createAnthropic({ apiKey }).languageModel(model))
.with("gemini", () => createGoogleGenerativeAI({ apiKey }).languageModel(model))
.with("ollama", () => createOllama({ apiKey, baseURL }).languageModel(model))
.exhaustive();
}
const aiCredentialsSchema = z.object({
provider: aiProviderSchema,
model: z.string(),
apiKey: z.string(),
baseURL: z.string().optional(),
});
const fileInputSchema = z.object({
name: z.string(),
data: z.string(), // base64 encoded
});
export const aiRouter = {
testConnection: protectedProcedure
.input(
z.object({
provider: aiProviderSchema,
model: z.string(),
apiKey: z.string(),
baseURL: z.string().optional(),
}),
)
.handler(async function* ({ input }) {
const stream = streamText({
temperature: 0,
model: getModel(input),
messages: [{ role: "user", content: 'Respond with "1"' }],
});
yield* stream.textStream;
}),
parsePdf: protectedProcedure
.input(
z.object({
...aiCredentialsSchema.shape,
file: fileInputSchema,
}),
)
.handler(async ({ input }) => {
try {
const model = getModel(input);
const result = await generateText({
model,
maxRetries: 0,
output: Output.object({
schema: resumeDataSchema.omit({ picture: true, metadata: true, customSections: true }),
}),
messages: [
{
role: "system",
content: pdfParserSystemPrompt,
},
{
role: "user",
content: [
{ type: "text", text: pdfParserUserPrompt },
{
type: "file",
filename: input.file.name,
mediaType: "application/pdf",
data: input.file.data,
},
],
},
],
});
return resumeDataSchema.parse({
...result.output,
customSections: [],
picture: defaultResumeData.picture,
metadata: defaultResumeData.metadata,
});
} catch (error) {
if (error instanceof ZodError) {
const errors = flattenError(error);
throw new Error(JSON.stringify(errors));
}
throw error;
}
}),
parseDocx: protectedProcedure
.input(
z.object({
...aiCredentialsSchema.shape,
file: fileInputSchema,
mediaType: z.enum([
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
]),
}),
)
.handler(async ({ input }) => {
try {
const model = getModel(input);
const result = await generateText({
model,
maxRetries: 0,
output: Output.object({
schema: resumeDataSchema.omit({ picture: true, metadata: true, customSections: true }),
}),
messages: [
{ role: "system", content: docxParserSystemPrompt },
{
role: "user",
content: [
{ type: "text", text: docxParserUserPrompt },
{
type: "file",
filename: input.file.name,
mediaType: input.mediaType,
data: input.file.data,
},
],
},
],
});
return resumeDataSchema.parse({
...result.output,
customSections: [],
picture: defaultResumeData.picture,
metadata: defaultResumeData.metadata,
});
} catch (error) {
if (error instanceof ZodError) {
const errors = flattenError(error);
throw new Error(JSON.stringify(errors));
}
throw error;
}
}),
};
+56
View File
@@ -0,0 +1,56 @@
import z from "zod";
import { protectedProcedure, publicProcedure } from "../context";
import { authService, type ProviderList } from "../services/auth";
export const authRouter = {
providers: {
list: publicProcedure
.route({
method: "GET",
path: "/auth/providers/list",
tags: ["Authentication"],
summary: "List all auth providers",
description:
"A list of all authentication providers, and their display names, supported by the instance of Reactive Resume.",
})
.handler((): ProviderList => {
return authService.providers.list();
}),
},
verifyResumePassword: publicProcedure
.route({
method: "POST",
path: "/auth/verify-resume-password",
tags: ["Authentication", "Resume"],
summary: "Verify resume password",
description: "Verify a resume password, to grant access to the locked resume.",
})
.input(
z.object({
slug: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
}),
)
.output(z.boolean())
.handler(async ({ input }): Promise<boolean> => {
return await authService.verifyResumePassword({
slug: input.slug,
username: input.username,
password: input.password,
});
}),
deleteAccount: protectedProcedure
.route({
method: "DELETE",
path: "/auth/delete-account",
tags: ["Authentication"],
summary: "Delete user account",
description: "Delete the authenticated user's account and all associated data.",
})
.handler(async ({ context }): Promise<void> => {
return await authService.deleteAccount({ userId: context.user.id });
}),
};
+15
View File
@@ -0,0 +1,15 @@
import { aiRouter } from "./ai";
import { authRouter } from "./auth";
import { printerRouter } from "./printer";
import { resumeRouter } from "./resume";
import { statisticsRouter } from "./statistics";
import { storageRouter } from "./storage";
export default {
ai: aiRouter,
auth: authRouter,
resume: resumeRouter,
storage: storageRouter,
printer: printerRouter,
statistics: statisticsRouter,
};
+51
View File
@@ -0,0 +1,51 @@
import z from "zod";
import { protectedProcedure, publicProcedure } from "../context";
import { printerService } from "../services/printer";
import { resumeService } from "../services/resume";
export const printerRouter = {
printResumeAsPDF: publicProcedure
.route({
method: "GET",
path: "/printer/resume/{id}/pdf",
tags: ["Resume", "Printer"],
summary: "Export resume as PDF",
description: "Export a resume as a PDF. Returns a URL to download the PDF.",
})
.input(z.object({ id: z.string() }))
.output(z.object({ url: z.string() }))
.handler(async ({ input, context }) => {
// Get resume to find the owner's userId for storage key
const resume = await resumeService.getByIdForPrinter({ id: input.id });
const url = await printerService.printResumeAsPDF({
id: input.id,
userId: resume.userId,
});
if (!context.user) {
await resumeService.statistics.increment({ id: input.id, downloads: true });
}
return { url };
}),
getResumeScreenshot: protectedProcedure
.route({
method: "GET",
path: "/printer/resume/{id}/screenshot",
tags: ["Resume", "Printer"],
summary: "Get resume screenshot",
description: "Get a screenshot of a resume. Returns a URL to the screenshot image.",
})
.input(z.object({ id: z.string() }))
.output(z.object({ url: z.string() }))
.handler(async ({ input, context }) => {
const url = await printerService.getResumeScreenshot({
id: input.id,
userId: context.user.id,
});
return { url };
}),
};
+331
View File
@@ -0,0 +1,331 @@
import z from "zod";
import { resumeDataSchema, sampleResumeData } from "@/schema/resume/data";
import { generateRandomName, slugify } from "@/utils/string";
import { protectedProcedure, publicProcedure, serverOnlyProcedure } from "../context";
import { resumeService } from "../services/resume";
const tagsRouter = {
list: protectedProcedure
.route({
method: "GET",
path: "/resume/tags/list",
tags: ["Resume"],
summary: "List all resume tags",
description: "List all tags for the authenticated user's resumes. Used to populate the filter in the dashboard.",
})
.output(z.array(z.string()))
.handler(async ({ context }) => {
return await resumeService.tags.list({ userId: context.user.id });
}),
};
const statisticsRouter = {
getById: protectedProcedure
.route({
method: "GET",
path: "/resume/statistics/{id}",
tags: ["Resume"],
summary: "Get resume statistics",
description: "Get the statistics for a resume, such as number of views and downloads.",
})
.input(z.object({ id: z.string() }))
.output(
z.object({
isPublic: z.boolean(),
views: z.number(),
downloads: z.number(),
lastViewedAt: z.date().nullable(),
lastDownloadedAt: z.date().nullable(),
}),
)
.handler(async ({ context, input }) => {
return await resumeService.statistics.getById({ id: input.id, userId: context.user.id });
}),
increment: publicProcedure
.route({ tags: ["Internal"], summary: "Increment resume statistics" })
.input(z.object({ id: z.string(), views: z.boolean().default(false), downloads: z.boolean().default(false) }))
.handler(async ({ input }) => {
return await resumeService.statistics.increment(input);
}),
};
export const resumeRouter = {
tags: tagsRouter,
statistics: statisticsRouter,
list: protectedProcedure
.route({
method: "GET",
path: "/resume/list",
tags: ["Resume"],
summary: "List all resumes",
description: "List of all the resumes for the authenticated user.",
})
.input(
z
.object({
tags: z.array(z.string()).optional().default([]),
sort: z.enum(["lastUpdatedAt", "createdAt", "name"]).optional().default("lastUpdatedAt"),
})
.optional()
.default({ tags: [], sort: "lastUpdatedAt" }),
)
.output(
z.array(
z.object({
id: z.string(),
name: z.string(),
slug: z.string(),
tags: z.array(z.string()),
isPublic: z.boolean(),
isLocked: z.boolean(),
createdAt: z.date(),
updatedAt: z.date(),
}),
),
)
.handler(async ({ input, context }) => {
return await resumeService.list({
userId: context.user.id,
tags: input.tags,
sort: input.sort,
});
}),
getById: protectedProcedure
.route({
method: "GET",
path: "/resume/{id}",
tags: ["Resume"],
summary: "Get resume by ID",
description: "Get a resume, along with its data, by its ID.",
})
.input(z.object({ id: z.string() }))
.output(
z.object({
id: z.string(),
name: z.string(),
slug: z.string(),
tags: z.array(z.string()),
data: resumeDataSchema,
isPublic: z.boolean(),
isLocked: z.boolean(),
hasPassword: z.boolean(),
}),
)
.handler(async ({ context, input }) => {
return await resumeService.getById({ id: input.id, userId: context.user.id });
}),
getByIdForPrinter: serverOnlyProcedure
.route({ tags: ["Internal"], summary: "Get resume by ID for printer" })
.input(z.object({ id: z.string() }))
.handler(async ({ input }) => {
return await resumeService.getByIdForPrinter({ id: input.id });
}),
getBySlug: publicProcedure
.route({
method: "GET",
path: "/resume/{username}/{slug}",
tags: ["Resume"],
summary: "Get resume by username and slug",
description: "Get a resume, along with its data, by its username and slug.",
})
.input(z.object({ username: z.string(), slug: z.string() }))
.output(
z.object({
id: z.string(),
name: z.string(),
slug: z.string(),
tags: z.array(z.string()),
data: resumeDataSchema,
isPublic: z.boolean(),
isLocked: z.boolean(),
}),
)
.handler(async ({ input, context }) => {
return await resumeService.getBySlug({ ...input, currentUserId: context.user?.id });
}),
create: protectedProcedure
.route({
method: "POST",
path: "/resume/create",
tags: ["Resume"],
summary: "Create a new resume",
description: "Create a new resume, with the ability to initialize it with sample data.",
})
.input(
z.object({
name: z.string().min(1).max(64),
slug: z.string().min(1).max(64),
tags: z.array(z.string()),
withSampleData: z.boolean().default(false),
}),
)
.output(z.string().describe("The ID of the created resume."))
.handler(async ({ context, input }) => {
return await resumeService.create({
name: input.name,
slug: input.slug,
tags: input.tags,
locale: context.locale,
userId: context.user.id,
data: input.withSampleData ? sampleResumeData : undefined,
});
}),
import: protectedProcedure
.route({
method: "POST",
path: "/resume/import",
tags: ["Resume"],
summary: "Import a resume",
description: "Import a resume from a file.",
})
.input(z.object({ data: resumeDataSchema }))
.output(z.string().describe("The ID of the imported resume."))
.handler(async ({ context, input }) => {
const name = generateRandomName();
const slug = slugify(name);
return await resumeService.create({
name,
slug,
tags: [],
data: input.data,
locale: context.locale,
userId: context.user.id,
});
}),
update: protectedProcedure
.route({
method: "PUT",
path: "/resume/{id}",
tags: ["Resume"],
summary: "Update a resume",
description: "Update a resume, along with its data, by its ID.",
})
.input(
z.object({
id: z.string(),
name: z.string().optional(),
slug: z.string().optional(),
tags: z.array(z.string()).optional(),
data: resumeDataSchema.optional(),
isPublic: z.boolean().optional(),
}),
)
.output(z.void())
.handler(async ({ context, input }) => {
return await resumeService.update({
id: input.id,
userId: context.user.id,
name: input.name,
slug: input.slug,
tags: input.tags,
data: input.data,
isPublic: input.isPublic,
});
}),
setLocked: protectedProcedure
.route({
method: "POST",
path: "/resume/{id}/set-locked",
tags: ["Resume"],
summary: "Set resume locked status",
description: "Toggle the locked status of a resume, by its ID.",
})
.input(z.object({ id: z.string(), isLocked: z.boolean() }))
.output(z.void())
.handler(async ({ context, input }) => {
return await resumeService.setLocked({
id: input.id,
userId: context.user.id,
isLocked: input.isLocked,
});
}),
setPassword: protectedProcedure
.route({
method: "POST",
path: "/resume/{id}/set-password",
tags: ["Resume"],
summary: "Set password on a resume",
description: "Set a password on a resume to protect it from unauthorized access when shared publicly.",
})
.input(z.object({ id: z.string(), password: z.string().min(6).max(64) }))
.output(z.void())
.handler(async ({ context, input }) => {
return await resumeService.setPassword({
id: input.id,
userId: context.user.id,
password: input.password,
});
}),
removePassword: protectedProcedure
.route({
method: "POST",
path: "/resume/{id}/remove-password",
tags: ["Resume"],
summary: "Remove password from a resume",
description: "Remove password protection from a resume.",
})
.input(z.object({ id: z.string() }))
.output(z.void())
.handler(async ({ context, input }) => {
return await resumeService.removePassword({
id: input.id,
userId: context.user.id,
});
}),
duplicate: protectedProcedure
.route({
method: "POST",
path: "/resume/{id}/duplicate",
tags: ["Resume"],
summary: "Duplicate a resume",
description: "Duplicate a resume, by its ID.",
})
.input(
z.object({
id: z.string(),
name: z.string().optional(),
slug: z.string().optional(),
tags: z.array(z.string()).optional(),
}),
)
.output(z.string().describe("The ID of the duplicated resume."))
.handler(async ({ context, input }) => {
const original = await resumeService.getById({ id: input.id, userId: context.user.id });
return await resumeService.create({
userId: context.user.id,
name: input.name ?? original.name,
slug: input.slug ?? original.slug,
tags: input.tags ?? original.tags,
locale: context.locale,
data: original.data,
});
}),
delete: protectedProcedure
.route({
method: "DELETE",
path: "/resume/{id}",
tags: ["Resume"],
summary: "Delete a resume",
description: "Delete a resume, by its ID.",
})
.input(z.object({ id: z.string() }))
.output(z.void())
.handler(async ({ context, input }) => {
return await resumeService.delete({ id: input.id, userId: context.user.id });
}),
};
@@ -0,0 +1,54 @@
import z from "zod";
import { publicProcedure } from "../context";
import { statisticsService } from "../services/statistics";
const userRouter = {
getCount: publicProcedure
.route({
method: "GET",
path: "/statistics/user/count",
tags: ["Statistics"],
summary: "Get total number of users",
description: "Get the total number of users for the Reactive Resume.",
})
.output(z.number())
.handler(async (): Promise<number> => {
return await statisticsService.user.getCount();
}),
};
const resumeRouter = {
getCount: publicProcedure
.route({
method: "GET",
path: "/statistics/resume/count",
tags: ["Statistics"],
summary: "Get total number of resumes",
description: "Get the total number of resumes for the Reactive Resume.",
})
.output(z.number())
.handler(async (): Promise<number> => {
return await statisticsService.resume.getCount();
}),
};
const githubRouter = {
getStarCount: publicProcedure
.route({
method: "GET",
path: "/statistics/github/stars",
tags: ["Statistics"],
summary: "Get GitHub Repository stargazers count",
description: "Get the stargazers count for the Reactive Resume GitHub repository, at the time of writing.",
})
.output(z.number())
.handler(async (): Promise<number> => {
return await statisticsService.github.getStarCount();
}),
};
export const statisticsRouter = {
user: userRouter,
resume: resumeRouter,
github: githubRouter,
};
+69
View File
@@ -0,0 +1,69 @@
import { ORPCError } from "@orpc/server";
import z from "zod";
import { protectedProcedure } from "../context";
import { getStorageService, isImageFile, processImageForUpload, uploadFile } from "../services/storage";
const storageService = getStorageService();
const fileSchema = z.file().max(10 * 1024 * 1024, "File size must be less than 10MB");
const filenameSchema = z.object({ filename: z.string().min(1) });
export const storageRouter = {
uploadFile: protectedProcedure
.route({ tags: ["Internal"], summary: "Upload a file" })
.input(fileSchema)
.output(
z.object({
url: z.string(),
path: z.string(),
contentType: z.string(),
}),
)
.handler(async ({ context, input: file }) => {
const originalMimeType = file.type;
const isImage = isImageFile(originalMimeType);
let data: Uint8Array;
let contentType: string;
if (isImage) {
const processed = await processImageForUpload(file);
data = processed.data;
contentType = processed.contentType;
} else {
const fileBuffer = await file.arrayBuffer();
data = new Uint8Array(fileBuffer);
contentType = originalMimeType;
}
const result = await uploadFile({
userId: context.user.id,
data,
contentType,
type: "picture",
});
return {
url: result.url,
path: result.key,
contentType,
};
}),
deleteFile: protectedProcedure
.route({ tags: ["Internal"], summary: "Delete a file" })
.input(filenameSchema)
.output(z.void())
.handler(async ({ context, input }): Promise<void> => {
// The filename is now the full path from the URL (e.g., "uploads/userId/pictures/timestamp.webp")
// We need to extract just the path portion that matches the storage key
const key = input.filename.startsWith("uploads/")
? input.filename
: `uploads/${context.user.id}/pictures/${input.filename}`;
const deleted = await storageService.delete(key);
if (!deleted) throw new ORPCError("NOT_FOUND");
}),
};
+74
View File
@@ -0,0 +1,74 @@
import { ORPCError } from "@orpc/client";
import { and, eq, isNotNull } from "drizzle-orm";
import type { AuthProvider } from "@/integrations/auth/types";
import { schema } from "@/integrations/drizzle";
import { db } from "@/integrations/drizzle/client";
import { env } from "@/utils/env";
import { verifyPassword } from "@/utils/password";
import { grantResumeAccess } from "../helpers/resume-access";
import { getStorageService } from "./storage";
export type ProviderList = Partial<Record<AuthProvider, string>>;
const providers = {
list: (): ProviderList => {
const providers: ProviderList = { credential: "Password" };
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) providers.google = "Google";
if (env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) providers.github = "GitHub";
if (env.OAUTH_CLIENT_ID && env.OAUTH_CLIENT_SECRET) providers.custom = env.OAUTH_PROVIDER_NAME ?? "Custom OAuth";
return providers;
},
};
export const authService = {
providers,
verifyResumePassword: async (input: { slug: string; username: string; password: string }): Promise<boolean> => {
const [resume] = await db
.select({ id: schema.resume.id, password: schema.resume.password })
.from(schema.resume)
.innerJoin(schema.user, eq(schema.resume.userId, schema.user.id))
.where(
and(
isNotNull(schema.resume.password),
eq(schema.resume.slug, input.slug),
eq(schema.user.username, input.username),
),
);
if (!resume) throw new ORPCError("NOT_FOUND");
const passwordHash = resume.password as string;
const isValid = await verifyPassword(input.password, passwordHash);
if (!isValid) throw new ORPCError("INVALID_PASSWORD");
grantResumeAccess(resume.id, passwordHash);
return true;
},
deleteAccount: async (input: { userId: string }): Promise<void> => {
if (!input.userId || input.userId.length === 0) return;
const storageService = getStorageService();
// Delete all user files in one call (pictures, screenshots, pdfs)
// The storage service delete method supports recursive deletion via prefix
try {
await storageService.delete(`uploads/${input.userId}`);
} catch {
// Ignore error and proceed with deleting user
}
try {
await db.delete(schema.user).where(eq(schema.user.id, input.userId));
} catch (err) {
console.error(`Failed to delete user record for userId=${input.userId}:`, err);
throw new ORPCError("INTERNAL_SERVER_ERROR");
}
},
};
+172
View File
@@ -0,0 +1,172 @@
import { ORPCError } from "@orpc/server";
import { printMarginTemplates } from "@/schema/templates";
import { env } from "@/utils/env";
import { generatePrinterToken } from "@/utils/printer-token";
import { resumeService } from "./resume";
import { getStorageService, uploadFile } from "./storage";
const pageDimensions = {
a4: {
width: "210mm",
height: "297mm",
},
letter: {
width: "8.5in",
height: "11in",
},
} as const;
const SCREENSHOT_TTL = 1000 * 60 * 60; // 1 hour
export const printerService = {
printResumeAsPDF: async (input: { id: string; userId: string }): Promise<string> => {
const storageService = getStorageService();
// Delete any existing PDFs for this resume
const pdfPrefix = `uploads/${input.userId}/pdfs/${input.id}`;
await storageService.delete(pdfPrefix);
const resume = await resumeService.getByIdForPrinter({ id: input.id });
const format = resume.data.metadata.page.format;
const locale = resume.data.metadata.page.locale;
const baseUrl = env.PRINTER_APP_URL ?? env.APP_URL;
const domain = new URL(baseUrl).hostname;
const token = generatePrinterToken(input.id);
const url = `${baseUrl}/printer/${input.id}?token=${token}`;
const formData = new FormData();
const cookies = [{ name: "locale", value: locale, domain }];
const isPrintMargin = printMarginTemplates.includes(resume.data.metadata.template);
const marginX = isPrintMargin ? `${resume.data.metadata.page.marginX.toString()}pt` : "0";
const marginY = isPrintMargin ? `${resume.data.metadata.page.marginY.toString()}pt` : "0";
formData.append("url", url);
formData.append("marginTop", marginY);
formData.append("marginLeft", marginX);
formData.append("marginRight", marginX);
formData.append("marginBottom", marginY);
formData.append("printBackground", "true");
formData.append("skipNetworkIdleEvent", "false");
formData.append("cookies", JSON.stringify(cookies));
formData.append("paperWidth", pageDimensions[format].width);
formData.append("paperHeight", pageDimensions[format].height);
const headers = new Headers();
if (env.GOTENBERG_USERNAME && env.GOTENBERG_PASSWORD) {
const credentials = `${env.GOTENBERG_USERNAME}:${env.GOTENBERG_PASSWORD}`;
const encodedCredentials = btoa(credentials);
headers.set("Authorization", `Basic ${encodedCredentials}`);
}
const response = await fetch(`${env.GOTENBERG_ENDPOINT}/forms/chromium/convert/url`, {
headers,
method: "POST",
body: formData,
});
if (!response.ok) {
throw new ORPCError("UNAUTHORIZED", {
status: response.status,
message: response.statusText,
});
}
const pdfBuffer = await response.arrayBuffer();
// Store PDF and return URL
const result = await uploadFile({
userId: input.userId,
resumeId: input.id,
data: new Uint8Array(pdfBuffer),
contentType: "application/pdf",
type: "pdf",
});
return result.url;
},
getResumeScreenshot: async (input: { id: string; userId: string }): Promise<string> => {
const storageService = getStorageService();
const screenshotPrefix = `uploads/${input.userId}/screenshots/${input.id}`;
const existingScreenshots = await storageService.list(screenshotPrefix);
const now = Date.now();
if (existingScreenshots.length > 0) {
const sortedFiles = existingScreenshots
.map((path) => {
const filename = path.split("/").pop();
const match = filename?.match(/^(\d+)\.webp$/);
return match ? { path, timestamp: Number(match[1]) } : null;
})
.filter((item): item is { path: string; timestamp: number } => item !== null)
.sort((a, b) => b.timestamp - a.timestamp);
if (sortedFiles.length > 0) {
const latest = sortedFiles[0];
const age = now - latest.timestamp;
if (age < SCREENSHOT_TTL) {
// Return URL of cached screenshot
return new URL(latest.path, env.APP_URL).toString();
}
// Delete old screenshots
await Promise.all(sortedFiles.map((file) => storageService.delete(file.path)));
}
}
const baseUrl = env.PRINTER_APP_URL ?? env.APP_URL;
const token = generatePrinterToken(input.id);
const url = `${baseUrl}/printer/${input.id}?token=${token}`;
const formData = new FormData();
formData.append("url", url);
formData.append("clip", "true");
formData.append("width", "794");
formData.append("height", "1123");
formData.append("format", "webp");
formData.append("optimizeForSpeed", "true");
formData.append("skipNetworkIdleEvent", "false");
const headers = new Headers();
if (env.GOTENBERG_USERNAME && env.GOTENBERG_PASSWORD) {
const credentials = `${env.GOTENBERG_USERNAME}:${env.GOTENBERG_PASSWORD}`;
const encodedCredentials = btoa(credentials);
headers.set("Authorization", `Basic ${encodedCredentials}`);
}
const response = await fetch(`${env.GOTENBERG_ENDPOINT}/forms/chromium/screenshot/url`, {
headers,
method: "POST",
body: formData,
});
if (!response.ok) {
throw new ORPCError("UNAUTHORIZED", {
status: response.status,
message: response.statusText,
});
}
const imageBuffer = await response.arrayBuffer();
// Store screenshot and return URL
const result = await uploadFile({
userId: input.userId,
resumeId: input.id,
data: new Uint8Array(imageBuffer),
contentType: "image/webp",
type: "screenshot",
});
return result.url;
},
};
+308
View File
@@ -0,0 +1,308 @@
import { ORPCError } from "@orpc/client";
import { and, arrayContains, asc, desc, eq, sql } from "drizzle-orm";
import { match } from "ts-pattern";
import { schema } from "@/integrations/drizzle";
import { db } from "@/integrations/drizzle/client";
import type { ResumeData } from "@/schema/resume/data";
import { defaultResumeData } from "@/schema/resume/data";
import type { Locale } from "@/utils/locale";
import { hashPassword } from "@/utils/password";
import { generateId } from "@/utils/string";
import { hasResumeAccess } from "../helpers/resume-access";
import { getStorageService } from "./storage";
const tags = {
list: async (input: { userId: string }): Promise<string[]> => {
const result = await db
.select({ tags: schema.resume.tags })
.from(schema.resume)
.where(eq(schema.resume.userId, input.userId));
const uniqueTags = new Set(result.flatMap((tag) => tag.tags));
const sortedTags = Array.from(uniqueTags).sort((a, b) => a.localeCompare(b));
return sortedTags;
},
};
const statistics = {
getById: async (input: { id: string; userId: string }) => {
const [statistics] = await db
.select({
isPublic: schema.resume.isPublic,
views: schema.resumeStatistics.views,
downloads: schema.resumeStatistics.downloads,
lastViewedAt: schema.resumeStatistics.lastViewedAt,
lastDownloadedAt: schema.resumeStatistics.lastDownloadedAt,
})
.from(schema.resumeStatistics)
.rightJoin(schema.resume, eq(schema.resumeStatistics.resumeId, schema.resume.id))
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
return {
isPublic: statistics.isPublic,
views: statistics.views ?? 0,
downloads: statistics.downloads ?? 0,
lastViewedAt: statistics.lastViewedAt,
lastDownloadedAt: statistics.lastDownloadedAt,
};
},
increment: async (input: { id: string; views?: boolean; downloads?: boolean }): Promise<void> => {
const views = input.views ? 1 : 0;
const downloads = input.downloads ? 1 : 0;
const lastViewedAt = input.views ? sql`now()` : undefined;
const lastDownloadedAt = input.downloads ? sql`now()` : undefined;
await db
.insert(schema.resumeStatistics)
.values({
resumeId: input.id,
views,
downloads,
lastViewedAt,
lastDownloadedAt,
})
.onConflictDoUpdate({
target: [schema.resumeStatistics.resumeId],
set: {
views: sql`${schema.resumeStatistics.views} + ${views}`,
downloads: sql`${schema.resumeStatistics.downloads} + ${downloads}`,
lastViewedAt,
lastDownloadedAt,
},
});
},
};
export const resumeService = {
tags,
statistics,
list: async (input: { userId: string; tags: string[]; sort: "lastUpdatedAt" | "createdAt" | "name" }) => {
return await db
.select({
id: schema.resume.id,
name: schema.resume.name,
slug: schema.resume.slug,
tags: schema.resume.tags,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
createdAt: schema.resume.createdAt,
updatedAt: schema.resume.updatedAt,
})
.from(schema.resume)
.where(
and(
eq(schema.resume.userId, input.userId),
match(input.tags.length)
.with(0, () => undefined)
.otherwise(() => arrayContains(schema.resume.tags, input.tags)),
),
)
.orderBy(
match(input.sort)
.with("lastUpdatedAt", () => desc(schema.resume.updatedAt))
.with("createdAt", () => asc(schema.resume.createdAt))
.with("name", () => asc(schema.resume.name))
.exhaustive(),
);
},
getById: async (input: { id: string; userId: string }) => {
const [resume] = await db
.select({
id: schema.resume.id,
name: schema.resume.name,
slug: schema.resume.slug,
tags: schema.resume.tags,
data: schema.resume.data,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
})
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!resume) throw new ORPCError("NOT_FOUND");
return resume;
},
getByIdForPrinter: async (input: { id: string }) => {
const [resume] = await db
.select({
id: schema.resume.id,
userId: schema.resume.userId,
name: schema.resume.name,
slug: schema.resume.slug,
tags: schema.resume.tags,
data: schema.resume.data,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
})
.from(schema.resume)
.where(eq(schema.resume.id, input.id));
if (!resume) throw new ORPCError("NOT_FOUND");
return resume;
},
getBySlug: async (input: { username: string; slug: string; currentUserId?: string }) => {
const [resume] = await db
.select({
id: schema.resume.id,
name: schema.resume.name,
slug: schema.resume.slug,
tags: schema.resume.tags,
data: schema.resume.data,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
passwordHash: schema.resume.password,
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
})
.from(schema.resume)
.innerJoin(schema.user, eq(schema.resume.userId, schema.user.id))
.where(
and(
eq(schema.resume.slug, input.slug),
eq(schema.user.username, input.username),
input.currentUserId ? eq(schema.resume.userId, input.currentUserId) : eq(schema.resume.isPublic, true),
),
);
if (!resume) throw new ORPCError("NOT_FOUND");
if (!resume.hasPassword) {
await resumeService.statistics.increment({ id: resume.id, views: true });
return {
id: resume.id,
name: resume.name,
slug: resume.slug,
tags: resume.tags,
data: resume.data,
isPublic: resume.isPublic,
isLocked: resume.isLocked,
hasPassword: false as const,
};
}
if (hasResumeAccess(resume.id, resume.passwordHash)) {
await resumeService.statistics.increment({ id: resume.id, views: true });
return {
id: resume.id,
name: resume.name,
slug: resume.slug,
tags: resume.tags,
data: resume.data,
isPublic: resume.isPublic,
isLocked: resume.isLocked,
hasPassword: true as const,
};
}
throw new ORPCError("NEED_PASSWORD", {
status: 401,
data: { username: input.username, slug: input.slug },
});
},
create: async (input: {
userId: string;
name: string;
slug: string;
tags: string[];
locale: Locale;
data?: ResumeData;
}): Promise<string> => {
const id = generateId();
input.data = input.data ?? defaultResumeData;
input.data.metadata.page.locale = input.locale;
await db.insert(schema.resume).values({
id,
name: input.name,
slug: input.slug,
tags: input.tags,
userId: input.userId,
data: input.data,
});
return id;
},
update: async (input: {
id: string;
userId: string;
name?: string;
slug?: string;
tags?: string[];
data?: ResumeData;
isPublic?: boolean;
}): Promise<void> => {
const [resume] = await db
.select({ isLocked: schema.resume.isLocked })
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (resume?.isLocked) throw new ORPCError("RESUME_LOCKED");
const updateData: Partial<typeof schema.resume.$inferSelect> = {
name: input.name,
slug: input.slug,
tags: input.tags,
data: input.data,
isPublic: input.isPublic,
};
await db
.update(schema.resume)
.set(updateData)
.where(
and(eq(schema.resume.id, input.id), eq(schema.resume.isLocked, false), eq(schema.resume.userId, input.userId)),
);
},
setLocked: async (input: { id: string; userId: string; isLocked: boolean }): Promise<void> => {
await db
.update(schema.resume)
.set({ isLocked: input.isLocked })
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
},
setPassword: async (input: { id: string; userId: string; password: string }): Promise<void> => {
const hashedPassword = await hashPassword(input.password);
await db
.update(schema.resume)
.set({ password: hashedPassword })
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
},
removePassword: async (input: { id: string; userId: string }): Promise<void> => {
await db
.update(schema.resume)
.set({ password: null })
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
},
delete: async (input: { id: string; userId: string }): Promise<void> => {
const storageService = getStorageService();
const deleteResumePromise = db
.delete(schema.resume)
.where(
and(eq(schema.resume.id, input.id), eq(schema.resume.isLocked, false), eq(schema.resume.userId, input.userId)),
);
// Delete screenshots and PDFs using the new key format
const deleteScreenshotsPromise = storageService.delete(`uploads/${input.userId}/screenshots/${input.id}`);
const deletePdfsPromise = storageService.delete(`uploads/${input.userId}/pdfs/${input.id}`);
await Promise.allSettled([deleteResumePromise, deleteScreenshotsPromise, deletePdfsPromise]);
},
};
@@ -0,0 +1,93 @@
import fs from "node:fs/promises";
import { dirname, join } from "node:path";
import { count } from "drizzle-orm";
import { schema } from "@/integrations/drizzle";
import { db } from "@/integrations/drizzle/client";
const CACHE_DURATION_MS = 6 * 60 * 60 * 1000; // 6 hours
const GITHUB_API_URL = "https://api.github.com/repos/AmruthPillai/Reactive-Resume";
const LAST_KNOWN = {
users: 978_528,
resumes: 1_336_307,
stars: 34_073,
} as const;
const getCachePath = (key: string) => join(process.cwd(), "data", "statistics", `${key}.txt`);
const readCache = async (key: string): Promise<number | null> => {
try {
const filePath = getCachePath(key);
const [stats, contents] = await Promise.all([fs.stat(filePath), fs.readFile(filePath, "utf-8")]);
if (stats.mtimeMs < Date.now() - CACHE_DURATION_MS) return null;
const value = Number.parseInt(contents, 10);
return Number.isFinite(value) && value > 0 ? value : null;
} catch {
return null;
}
};
const writeCache = async (key: string, value: number) => {
try {
const filePath = getCachePath(key);
await fs.mkdir(dirname(filePath), { recursive: true });
await fs.writeFile(filePath, String(value), "utf-8");
} catch {
// Ignore errors, cache is not critical
}
};
const getCachedCount = async (
key: string,
lastKnown: number,
fetcher: () => Promise<number | null>,
): Promise<number> => {
const cached = await readCache(key);
if (cached !== null) return cached;
try {
const value = await fetcher();
if (value !== null) {
await writeCache(key, value);
return value;
}
} catch {
// Ignore errors, use last known value
}
return lastKnown;
};
const getCountFromDatabase = async (table: typeof schema.user | typeof schema.resume): Promise<number | null> => {
const [result] = await db.select({ count: count() }).from(table);
return result.count;
};
const getGitHubStars = async (): Promise<number | null> => {
const response = await fetch(GITHUB_API_URL, { headers: { Accept: "application/vnd.github+json" } });
if (!response.ok) return null;
const data = await response.json();
const stars = Number(data.stargazers_count);
return Number.isFinite(stars) && stars > 0 ? stars : null;
};
export const statisticsService = {
user: {
getCount: () => {
return getCachedCount("users", LAST_KNOWN.users, () => getCountFromDatabase(schema.user));
},
},
resume: {
getCount: () => {
return getCachedCount("resumes", LAST_KNOWN.resumes, () => getCountFromDatabase(schema.resume));
},
},
github: {
getStarCount: () => {
return getCachedCount("stars", LAST_KNOWN.stars, getGitHubStars);
},
},
};
+363
View File
@@ -0,0 +1,363 @@
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 "@/utils/env";
interface StorageWriteInput {
key: string;
data: Uint8Array;
contentType: string;
}
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}.webp`;
}
function buildScreenshotKey(userId: string, resumeId: string): string {
const timestamp = Date.now();
return `uploads/${userId}/screenshots/${resumeId}/${timestamp}.webp`;
}
function buildPdfKey(userId: string, resumeId: string): string {
const timestamp = Date.now();
return `uploads/${userId}/pdfs/${resumeId}/${timestamp}.pdf`;
}
function buildPublicUrl(path: string): string {
return new URL(path, 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);
}
export interface ProcessedImage {
data: Uint8Array;
contentType: string;
}
export async function processImageForUpload(file: File): Promise<ProcessedImage> {
const fileBuffer = await file.arrayBuffer();
const processedBuffer = await sharp(fileBuffer)
.resize(800, 800, { fit: "inside", withoutEnlargement: true })
.webp({ preset: "picture" })
.toBuffer();
return {
data: new Uint8Array(processedBuffer),
contentType: "image/webp",
};
}
class LocalStorageService implements StorageService {
private rootDirectory: string;
constructor() {
this.rootDirectory = join(process.cwd(), "data");
}
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 }: StorageWriteInput): Promise<void> {
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);
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),
};
}
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;
} else {
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 client: 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.client = new S3Client({
region: env.S3_REGION,
endpoint: env.S3_ENDPOINT,
forcePathStyle: env.S3_FORCE_PATH_STYLE,
credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
},
});
}
async list(prefix: string): Promise<string[]> {
const command = new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix });
const response = await this.client.send(command);
if (!response.Contents) return [];
return response.Contents.map((object) => object.Key ?? "");
}
async write({ key, data, contentType }: StorageWriteInput): Promise<void> {
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: data,
ACL: "public-read",
ContentType: contentType,
});
await this.client.send(command);
}
async read(key: string): Promise<StorageReadResult | null> {
try {
const command = new GetObjectCommand({ Bucket: this.bucket, Key: key });
const response = await this.client.send(command);
if (!response.Body) return null;
const arrayBuffer = await response.Body.transformToByteArray();
return {
data: arrayBuffer,
size: response.ContentLength ?? 0,
etag: response.ETag,
lastModified: response.LastModified,
contentType: response.ContentType ?? inferContentType(key),
};
} catch {
return null;
}
}
async delete(keyOrPrefix: string): Promise<boolean> {
// 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) => this.client.send(c)));
// Return true if at least one deletion succeeded
return results.some((r) => r.status === "fulfilled");
}
async healthcheck(): Promise<StorageHealthResult> {
try {
const putCommand = new PutObjectCommand({ Bucket: this.bucket, Key: "healthcheck", Body: "OK" });
await this.client.send(putCommand);
const deleteCommand = new DeleteObjectCommand({ Bucket: this.bucket, Key: "healthcheck" });
await this.client.send(deleteCommand);
return {
type: "s3",
status: "healthy",
message: "S3 storage is accessible and credentials are valid.",
};
} catch (error: unknown) {
console.error(error);
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";
export interface UploadFileInput {
userId: string;
data: Uint8Array;
contentType: string;
type: UploadType;
resumeId?: string;
}
export 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),
};
}