mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-17 08:39:47 +10:00
initial commit of v5
This commit is contained in:
@@ -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>;
|
||||
@@ -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"),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}),
|
||||
};
|
||||
@@ -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 });
|
||||
}),
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
}),
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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");
|
||||
}),
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user