mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-26 01:44:53 +10:00
feat(security): harden auth, oauth, and printer endpoints
Add stricter URL and redirect validation, endpoint rate limiting, safer defaults for printer and compose config, and CSP protections across server and API surfaces. Made-with: Cursor
This commit is contained in:
@@ -10,10 +10,34 @@ import { type ResumeData, resumeDataSchema } from "@/schema/resume/data";
|
||||
import { tailorOutputSchema } from "@/schema/tailor";
|
||||
|
||||
import { protectedProcedure } from "../context";
|
||||
import { aiCredentialsSchema, aiProviderSchema, aiService, fileInputSchema } from "../services/ai";
|
||||
import { aiRequestRateLimit } from "../rate-limit";
|
||||
import { aiCredentialsSchema, aiService, fileInputSchema } from "../services/ai";
|
||||
import { resumeService } from "../services/resume";
|
||||
|
||||
type AIProvider = z.infer<typeof aiProviderSchema>;
|
||||
type AIProvider = z.infer<typeof aiCredentialsSchema.shape.provider>;
|
||||
|
||||
function isInvalidAiBaseUrlError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message === "INVALID_AI_BASE_URL";
|
||||
}
|
||||
|
||||
function isAiProviderGatewayError(error: unknown): boolean {
|
||||
return error instanceof AISDKError || error instanceof OllamaError;
|
||||
}
|
||||
|
||||
function throwAiProviderGatewayError(): never {
|
||||
throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider." });
|
||||
}
|
||||
|
||||
function throwAiProviderConfigError(): never {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Invalid AI provider configuration." });
|
||||
}
|
||||
|
||||
function throwResumeStructureError(error: ZodError): never {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Invalid resume data structure",
|
||||
cause: flattenError(error),
|
||||
});
|
||||
}
|
||||
|
||||
export const aiRouter = {
|
||||
testConnection: protectedProcedure
|
||||
@@ -29,25 +53,26 @@ export const aiRouter = {
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
provider: aiProviderSchema,
|
||||
model: z.string(),
|
||||
apiKey: z.string(),
|
||||
baseURL: z.string(),
|
||||
...aiCredentialsSchema.shape,
|
||||
}),
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
.errors({
|
||||
BAD_GATEWAY: {
|
||||
message: "The AI provider returned an error or is unreachable.",
|
||||
status: 502,
|
||||
},
|
||||
BAD_REQUEST: {
|
||||
message: "Invalid AI provider configuration.",
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
return await aiService.testConnection(input);
|
||||
} catch (error) {
|
||||
if (error instanceof AISDKError || error instanceof OllamaError) {
|
||||
throw new ORPCError("BAD_GATEWAY", { message: error.message });
|
||||
}
|
||||
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
|
||||
|
||||
throw error;
|
||||
}
|
||||
@@ -70,6 +95,7 @@ export const aiRouter = {
|
||||
file: fileInputSchema,
|
||||
}),
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
.errors({
|
||||
BAD_GATEWAY: {
|
||||
message: "The AI provider returned an error or is unreachable.",
|
||||
@@ -84,16 +110,10 @@ export const aiRouter = {
|
||||
try {
|
||||
return await aiService.parsePdf(input);
|
||||
} catch (error) {
|
||||
if (error instanceof AISDKError) {
|
||||
throw new ORPCError("BAD_GATEWAY", { message: error.message });
|
||||
}
|
||||
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||
if (error instanceof AISDKError) throwAiProviderGatewayError();
|
||||
|
||||
if (error instanceof ZodError) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Invalid resume data structure",
|
||||
cause: flattenError(error),
|
||||
});
|
||||
}
|
||||
if (error instanceof ZodError) throwResumeStructureError(error);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
@@ -119,6 +139,7 @@ export const aiRouter = {
|
||||
]),
|
||||
}),
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
.errors({
|
||||
BAD_GATEWAY: {
|
||||
message: "The AI provider returned an error or is unreachable.",
|
||||
@@ -133,16 +154,10 @@ export const aiRouter = {
|
||||
try {
|
||||
return await aiService.parseDocx(input);
|
||||
} catch (error) {
|
||||
if (error instanceof AISDKError) {
|
||||
throw new ORPCError("BAD_GATEWAY", { message: error.message });
|
||||
}
|
||||
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||
if (error instanceof AISDKError) throwAiProviderGatewayError();
|
||||
|
||||
if (error instanceof ZodError) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Invalid resume data structure",
|
||||
cause: flattenError(error),
|
||||
});
|
||||
}
|
||||
if (error instanceof ZodError) throwResumeStructureError(error);
|
||||
|
||||
throw error;
|
||||
}
|
||||
@@ -168,13 +183,13 @@ export const aiRouter = {
|
||||
resumeData: ResumeData;
|
||||
}>(),
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
.handler(async ({ input }) => {
|
||||
try {
|
||||
return await aiService.chat(input);
|
||||
} catch (error) {
|
||||
if (error instanceof AISDKError || error instanceof OllamaError) {
|
||||
throw new ORPCError("BAD_GATEWAY", { message: error.message });
|
||||
}
|
||||
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
|
||||
|
||||
throw error;
|
||||
}
|
||||
@@ -198,6 +213,7 @@ export const aiRouter = {
|
||||
job: jobResultSchema,
|
||||
}),
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
.output(tailorOutputSchema)
|
||||
.errors({
|
||||
BAD_GATEWAY: {
|
||||
@@ -213,9 +229,8 @@ export const aiRouter = {
|
||||
try {
|
||||
return await aiService.tailorResume(input);
|
||||
} catch (error) {
|
||||
if (error instanceof AISDKError || error instanceof OllamaError) {
|
||||
throw new ORPCError("BAD_GATEWAY", { message: error.message });
|
||||
}
|
||||
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
|
||||
|
||||
if (error instanceof ZodError) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
@@ -246,6 +261,7 @@ export const aiRouter = {
|
||||
resumeData: resumeDataSchema,
|
||||
}),
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
.output(storedResumeAnalysisSchema)
|
||||
.errors({
|
||||
BAD_GATEWAY: {
|
||||
@@ -282,9 +298,8 @@ export const aiRouter = {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AISDKError || error instanceof OllamaError) {
|
||||
throw new ORPCError("BAD_GATEWAY", { message: error.message });
|
||||
}
|
||||
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
|
||||
|
||||
if (error instanceof ZodError) {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
|
||||
@@ -29,8 +29,9 @@ export const jobsRouter = {
|
||||
try {
|
||||
return await jobsService.testConnection(input.apiKey);
|
||||
} catch (error) {
|
||||
console.error("[jobs.testConnection] Failed to test JSearch connection:", error);
|
||||
throw new ORPCError("BAD_GATEWAY", {
|
||||
message: error instanceof Error ? error.message : "Connection test failed",
|
||||
message: "The JSearch API returned an error or is unreachable.",
|
||||
});
|
||||
}
|
||||
}),
|
||||
@@ -71,8 +72,9 @@ export const jobsRouter = {
|
||||
|
||||
return { data: jobs, rapidApiQuota: response.rapidApiQuota };
|
||||
} catch (error) {
|
||||
console.error("[jobs.search] Failed to search jobs via JSearch:", error);
|
||||
throw new ORPCError("BAD_GATEWAY", {
|
||||
message: error instanceof Error ? error.message : "Search failed",
|
||||
message: "The JSearch API returned an error or is unreachable.",
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import z from "zod";
|
||||
|
||||
import { protectedProcedure, publicProcedure } from "../context";
|
||||
import { protectedProcedure } from "../context";
|
||||
import { pdfExportRateLimit } from "../rate-limit";
|
||||
import { printerService } from "../services/printer";
|
||||
import { resumeService } from "../services/resume";
|
||||
|
||||
async function getResumeScreenshotUrl(input: { id: string; currentUserId: string }): Promise<string | null> {
|
||||
try {
|
||||
const { id, data, userId, updatedAt } = await resumeService.getByIdForPrinter(input);
|
||||
return await printerService.getResumeScreenshot({ id, data, userId, updatedAt });
|
||||
} catch {
|
||||
// ignore errors, as the screenshot is not critical
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const printerRouter = {
|
||||
printResumeAsPDF: publicProcedure
|
||||
printResumeAsPDF: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/resumes/{id}/pdf",
|
||||
@@ -13,19 +24,19 @@ export const printerRouter = {
|
||||
operationId: "exportResumePdf",
|
||||
summary: "Export resume as PDF",
|
||||
description:
|
||||
"Generates a PDF from the specified resume and uploads it to storage. Returns a URL to download the generated PDF file. If the request is made by an unauthenticated user (e.g. via a public share link), the resume's download count is incremented. Authentication is optional.",
|
||||
"Generates a PDF from the specified resume and uploads it to storage. Returns a URL to download the generated PDF file. Requires authentication.",
|
||||
successDescription: "The PDF was generated successfully. Returns a URL to download the file.",
|
||||
})
|
||||
.input(z.object({ id: z.string().describe("The unique identifier of the resume to export.") }))
|
||||
.use(pdfExportRateLimit)
|
||||
.output(z.object({ url: z.string().describe("The URL to download the generated PDF file.") }))
|
||||
.handler(async ({ input, context }) => {
|
||||
const { id, data, userId } = await resumeService.getByIdForPrinter({ id: input.id, userId: context.user?.id });
|
||||
const { id, data, userId } = await resumeService.getByIdForPrinter({
|
||||
id: input.id,
|
||||
currentUserId: context.user.id,
|
||||
});
|
||||
const url = await printerService.printResumeAsPDF({ id, data, userId });
|
||||
|
||||
if (!context.user) {
|
||||
await resumeService.statistics.increment({ id: input.id, downloads: true });
|
||||
}
|
||||
|
||||
return { url };
|
||||
}),
|
||||
|
||||
@@ -43,19 +54,7 @@ export const printerRouter = {
|
||||
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
|
||||
.output(z.object({ url: z.string().nullable().describe("The URL to the screenshot image, or null.") }))
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
const { id, data, userId, updatedAt } = await resumeService.getByIdForPrinter({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
});
|
||||
|
||||
const url = await printerService.getResumeScreenshot({ id, data, userId, updatedAt });
|
||||
|
||||
return { url };
|
||||
} catch {
|
||||
// ignore errors, as the screenshot is not critical
|
||||
}
|
||||
|
||||
return { url: null };
|
||||
const url = await getResumeScreenshotUrl({ id: input.id, currentUserId: context.user.id });
|
||||
return { url };
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -4,8 +4,9 @@ import { storedResumeAnalysisSchema } from "@/schema/resume/analysis";
|
||||
import { sampleResumeData } from "@/schema/resume/sample";
|
||||
import { generateRandomName, slugify } from "@/utils/string";
|
||||
|
||||
import { protectedProcedure, publicProcedure, serverOnlyProcedure } from "../context";
|
||||
import { protectedProcedure, publicProcedure } from "../context";
|
||||
import { resumeDto } from "../dto/resume";
|
||||
import { resumePasswordRateLimit } from "../rate-limit";
|
||||
import { resumeService } from "../services/resume";
|
||||
|
||||
const tagsRouter = {
|
||||
@@ -22,7 +23,7 @@ const tagsRouter = {
|
||||
})
|
||||
.output(z.array(z.string()))
|
||||
.handler(async ({ context }) => {
|
||||
return await resumeService.tags.list({ userId: context.user.id });
|
||||
return resumeService.tags.list({ userId: context.user.id });
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -49,14 +50,7 @@ const statisticsRouter = {
|
||||
}),
|
||||
)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.statistics.getById({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
|
||||
increment: publicProcedure
|
||||
.route({ tags: ["Internal"], operationId: "incrementResumeStatistics", 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);
|
||||
return resumeService.statistics.getById({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -75,7 +69,7 @@ const analysisRouter = {
|
||||
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
|
||||
.output(storedResumeAnalysisSchema.nullable())
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.analysis.getById({ id: input.id, userId: context.user.id });
|
||||
return resumeService.analysis.getById({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -98,7 +92,7 @@ export const resumeRouter = {
|
||||
.input(resumeDto.list.input.optional().default({ tags: [], sort: "lastUpdatedAt" }))
|
||||
.output(resumeDto.list.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return await resumeService.list({
|
||||
return resumeService.list({
|
||||
userId: context.user.id,
|
||||
tags: input.tags,
|
||||
sort: input.sort,
|
||||
@@ -119,14 +113,22 @@ export const resumeRouter = {
|
||||
.input(resumeDto.getById.input)
|
||||
.output(resumeDto.getById.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.getById({ id: input.id, userId: context.user.id });
|
||||
return resumeService.getById({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
|
||||
getByIdForPrinter: serverOnlyProcedure
|
||||
getByIdForPrinter: publicProcedure
|
||||
.route({ tags: ["Internal"], operationId: "getResumeForPrinter", summary: "Get resume by ID for printer" })
|
||||
.input(resumeDto.getById.input)
|
||||
.handler(async ({ input }) => {
|
||||
return await resumeService.getByIdForPrinter({ id: input.id });
|
||||
.input(
|
||||
resumeDto.getById.input.extend({
|
||||
token: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.handler(async ({ input, context }) => {
|
||||
return resumeService.getByIdForPrinter({
|
||||
id: input.id,
|
||||
currentUserId: context.user?.id,
|
||||
printerToken: input.token,
|
||||
});
|
||||
}),
|
||||
|
||||
getBySlug: publicProcedure
|
||||
@@ -143,7 +145,7 @@ export const resumeRouter = {
|
||||
.input(resumeDto.getBySlug.input)
|
||||
.output(resumeDto.getBySlug.output)
|
||||
.handler(async ({ input, context }) => {
|
||||
return await resumeService.getBySlug({ ...input, currentUserId: context.user?.id });
|
||||
return resumeService.getBySlug({ ...input, currentUserId: context.user?.id });
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
@@ -166,7 +168,7 @@ export const resumeRouter = {
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.create({
|
||||
return resumeService.create({
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
tags: input.tags,
|
||||
@@ -199,7 +201,7 @@ export const resumeRouter = {
|
||||
const name = generateRandomName();
|
||||
const slug = slugify(name);
|
||||
|
||||
return await resumeService.create({
|
||||
return resumeService.create({
|
||||
name,
|
||||
slug,
|
||||
tags: [],
|
||||
@@ -229,7 +231,7 @@ export const resumeRouter = {
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.update({
|
||||
return resumeService.update({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
name: input.name,
|
||||
@@ -260,7 +262,7 @@ export const resumeRouter = {
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.patch({
|
||||
return resumeService.patch({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
operations: input.operations,
|
||||
@@ -281,7 +283,7 @@ export const resumeRouter = {
|
||||
.input(resumeDto.setLocked.input)
|
||||
.output(resumeDto.setLocked.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.setLocked({
|
||||
return resumeService.setLocked({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
isLocked: input.isLocked,
|
||||
@@ -302,7 +304,7 @@ export const resumeRouter = {
|
||||
.input(resumeDto.setPassword.input)
|
||||
.output(resumeDto.setPassword.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.setPassword({
|
||||
return resumeService.setPassword({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
password: input.password,
|
||||
@@ -327,9 +329,10 @@ export const resumeRouter = {
|
||||
password: z.string().min(1).describe("The password to verify."),
|
||||
}),
|
||||
)
|
||||
.use(resumePasswordRateLimit)
|
||||
.output(z.boolean())
|
||||
.handler(async ({ input }): Promise<boolean> => {
|
||||
return await resumeService.verifyPassword({
|
||||
return resumeService.verifyPassword({
|
||||
username: input.username,
|
||||
slug: input.slug,
|
||||
password: input.password,
|
||||
@@ -350,7 +353,7 @@ export const resumeRouter = {
|
||||
.input(resumeDto.removePassword.input)
|
||||
.output(resumeDto.removePassword.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.removePassword({
|
||||
return resumeService.removePassword({
|
||||
id: input.id,
|
||||
userId: context.user.id,
|
||||
});
|
||||
@@ -372,7 +375,7 @@ export const resumeRouter = {
|
||||
.handler(async ({ context, input }) => {
|
||||
const original = await resumeService.getById({ id: input.id, userId: context.user.id });
|
||||
|
||||
return await resumeService.create({
|
||||
return resumeService.create({
|
||||
userId: context.user.id,
|
||||
name: input.name ?? original.name,
|
||||
slug: input.slug ?? original.slug,
|
||||
@@ -396,6 +399,6 @@ export const resumeRouter = {
|
||||
.input(resumeDto.delete.input)
|
||||
.output(resumeDto.delete.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
return await resumeService.delete({ id: input.id, userId: context.user.id });
|
||||
return resumeService.delete({ id: input.id, userId: context.user.id });
|
||||
}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user