mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 10:13:47 +10:00
refactor(api): share cookie, signature and AI error handling instead of copying them
This commit is contained in:
@@ -1,13 +1,3 @@
|
||||
export function getCookie(request: Request, name: string): string | undefined {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
if (!cookieHeader) return;
|
||||
|
||||
for (const part of cookieHeader.split(";")) {
|
||||
const [rawName, ...rawValue] = part.trim().split("=");
|
||||
if (rawName === name && rawValue.length > 0) return rawValue.join("=");
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeResponseHeaders(response: Response, headers: Headers): Response {
|
||||
if ([...headers].length === 0) return response;
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function handlePublicResumePdf(
|
||||
request: Request,
|
||||
username: string,
|
||||
slug: string,
|
||||
trustedClient = "unknown",
|
||||
trustedClient: string,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const result = await createPublicResumePdf({
|
||||
|
||||
@@ -25,7 +25,7 @@ const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
|
||||
],
|
||||
});
|
||||
|
||||
export async function handleOpenApi(request: Request, trustedClient = "unknown") {
|
||||
export async function handleOpenApi(request: Request, trustedClient: string) {
|
||||
if (request.method === "GET" && (request.url.endsWith("/spec.json") || request.url.endsWith("/spec"))) {
|
||||
return Response.json(await generateOpenApiSpec({ appUrl: env.APP_URL, version: appVersion }));
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ const rpcHandler = new RPCHandler(router, {
|
||||
],
|
||||
});
|
||||
|
||||
export async function handleRpc(request: Request, trustedClient = "unknown") {
|
||||
export async function handleRpc(request: Request, trustedClient: string) {
|
||||
const resHeaders = new Headers();
|
||||
const { response } = await rpcHandler.handle(request, {
|
||||
prefix: "/api/rpc",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Locale } from "@reactive-resume/utils/locale";
|
||||
import { parse } from "hono/utils/cookie";
|
||||
import { defaultLocale, isLocale } from "@reactive-resume/utils/locale";
|
||||
import { getCookie } from "../http/headers";
|
||||
|
||||
export function getRequestLocale(request: Request): Locale {
|
||||
const locale = getCookie(request, "locale");
|
||||
const locale = parse(request.headers.get("cookie") ?? "", "locale").locale;
|
||||
return isLocale(locale) ? locale : defaultLocale;
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@ export const serveWebDistStatic = serveStatic({
|
||||
});
|
||||
|
||||
function getFallbackResponseHeaders(pathname: string) {
|
||||
if (pathname === "/" && env.ROOT_RESUME_ID?.trim()) {
|
||||
if (pathname === "/" && env.ROOT_RESUME_ID) {
|
||||
return {
|
||||
"Content-Type": "text/html; charset=UTF-8",
|
||||
"X-Robots-Tag": "noindex, follow",
|
||||
@@ -308,7 +308,7 @@ export async function handleWebApp(request: Request) {
|
||||
const html = await fs.readFile(indexHtmlPath, "utf-8");
|
||||
const canonicalUrl = new URL("/", env.APP_URL).toString();
|
||||
|
||||
if (pathname === "/" && env.ROOT_RESUME_ID?.trim()) {
|
||||
if (pathname === "/" && env.ROOT_RESUME_ID) {
|
||||
// Root configuration never discloses a target in the HTML shell. The public API
|
||||
// gates data and browser metadata; shell requests must not count extra views.
|
||||
const shell = html
|
||||
|
||||
@@ -9,39 +9,28 @@ import { atsReviewInputSchema, atsReviewOutputSchema, reviewResumeText } from ".
|
||||
import { improveInputSchema, improveLine, improveOutputSchema } from "./improve";
|
||||
import { aiService, fileInputSchema } from "./service";
|
||||
|
||||
function isInvalidAiBaseUrlError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message === "INVALID_AI_BASE_URL";
|
||||
/**
|
||||
* Every AI procedure fails the same ways: no ENCRYPTION_SECRET, a bad base URL, the provider erroring (its cause kept
|
||||
* for upstream error reporters), or the model returning a shape that can't be used, named per procedure.
|
||||
*/
|
||||
function rethrowAiError(error: unknown, invalidStructure: string): never {
|
||||
if (error instanceof Error && error.message === "AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE")
|
||||
throw new ORPCError("PRECONDITION_FAILED", {
|
||||
message: "AI providers are unavailable because ENCRYPTION_SECRET is not configured.",
|
||||
});
|
||||
if (error instanceof Error && error.message === "INVALID_AI_BASE_URL")
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Invalid AI provider configuration." });
|
||||
if (error instanceof AISDKError)
|
||||
throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider.", cause: error });
|
||||
if (error instanceof ZodError)
|
||||
throw new ORPCError("BAD_REQUEST", { message: invalidStructure, cause: flattenError(error) });
|
||||
throw error;
|
||||
}
|
||||
|
||||
function isAiProviderGatewayError(error: unknown): boolean {
|
||||
return error instanceof AISDKError;
|
||||
}
|
||||
|
||||
function isCredentialEncryptionUnavailable(error: unknown): boolean {
|
||||
return error instanceof Error && error.message === "AI_CREDENTIAL_ENCRYPTION_UNAVAILABLE";
|
||||
}
|
||||
|
||||
/** Throws a BAD_GATEWAY ORPCError, preserving the original cause for upstream error reporters. */
|
||||
function throwAiProviderGatewayError(cause?: unknown): never {
|
||||
throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider.", cause });
|
||||
}
|
||||
|
||||
function throwAiProviderConfigError(): never {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Invalid AI provider configuration." });
|
||||
}
|
||||
|
||||
function throwCredentialEncryptionUnavailable(): never {
|
||||
throw new ORPCError("PRECONDITION_FAILED", {
|
||||
message: "AI providers are unavailable because ENCRYPTION_SECRET is not configured.",
|
||||
});
|
||||
}
|
||||
|
||||
function throwResumeStructureError(error: ZodError): never {
|
||||
throw new ORPCError("BAD_REQUEST", {
|
||||
message: "Invalid resume data structure",
|
||||
cause: flattenError(error),
|
||||
});
|
||||
}
|
||||
const aiErrors = {
|
||||
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
|
||||
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
|
||||
} as const;
|
||||
|
||||
async function getRunnableProvider(userId: string, aiProviderId?: string) {
|
||||
const provider = aiProviderId
|
||||
@@ -67,10 +56,7 @@ export const aiRouter = {
|
||||
})
|
||||
.input(z.object({ aiProviderId: z.string().optional(), file: fileInputSchema }))
|
||||
.use(aiRequestRateLimit)
|
||||
.errors({
|
||||
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
|
||||
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
|
||||
})
|
||||
.errors(aiErrors)
|
||||
.handler(async ({ context, input }): Promise<ResumeData> => {
|
||||
try {
|
||||
const provider = await getRunnableProvider(context.user.id, input.aiProviderId);
|
||||
@@ -82,11 +68,7 @@ export const aiRouter = {
|
||||
file: input.file,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
|
||||
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error);
|
||||
if (error instanceof ZodError) throwResumeStructureError(error);
|
||||
throw error;
|
||||
rethrowAiError(error, "Invalid resume data structure");
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -112,10 +94,7 @@ export const aiRouter = {
|
||||
}),
|
||||
)
|
||||
.use(aiRequestRateLimit)
|
||||
.errors({
|
||||
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
|
||||
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
|
||||
})
|
||||
.errors(aiErrors)
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
const provider = await getRunnableProvider(context.user.id, input.aiProviderId);
|
||||
@@ -128,11 +107,7 @@ export const aiRouter = {
|
||||
file: input.file,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
|
||||
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error);
|
||||
if (error instanceof ZodError) throwResumeStructureError(error);
|
||||
throw error;
|
||||
rethrowAiError(error, "Invalid resume data structure");
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -150,10 +125,7 @@ export const aiRouter = {
|
||||
.input(atsReviewInputSchema)
|
||||
.use(aiRequestRateLimit)
|
||||
.output(atsReviewOutputSchema)
|
||||
.errors({
|
||||
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
|
||||
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
|
||||
})
|
||||
.errors(aiErrors)
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
const provider = await getRunnableProvider(context.user.id, input.aiProviderId);
|
||||
@@ -166,13 +138,7 @@ export const aiRouter = {
|
||||
baseURL: provider.baseURL ?? "",
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
|
||||
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error);
|
||||
if (error instanceof ZodError) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Invalid ATS review structure", cause: flattenError(error) });
|
||||
}
|
||||
throw error;
|
||||
rethrowAiError(error, "Invalid ATS review structure");
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -190,10 +156,7 @@ export const aiRouter = {
|
||||
.input(improveInputSchema)
|
||||
.use(aiRequestRateLimit)
|
||||
.output(improveOutputSchema)
|
||||
.errors({
|
||||
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
|
||||
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
|
||||
})
|
||||
.errors(aiErrors)
|
||||
.handler(async ({ context, input }) => {
|
||||
try {
|
||||
const provider = await getRunnableProvider(context.user.id, input.aiProviderId);
|
||||
@@ -206,13 +169,7 @@ export const aiRouter = {
|
||||
baseURL: provider.baseURL ?? "",
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable();
|
||||
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
|
||||
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error);
|
||||
if (error instanceof ZodError) {
|
||||
throw new ORPCError("BAD_REQUEST", { message: "Invalid suggestion structure", cause: flattenError(error) });
|
||||
}
|
||||
throw error;
|
||||
rethrowAiError(error, "Invalid suggestion structure");
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ const dbMock = vi.hoisted(() => ({
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
}));
|
||||
const resumeGetByIdMock = vi.hoisted(() => vi.fn());
|
||||
@@ -62,9 +61,10 @@ const existing = {
|
||||
coverLetterUrl: "/api/uploads/user-1/pictures/cover.pdf",
|
||||
};
|
||||
|
||||
// Awaitable directly, or after `.for("update")` for the reads that lock their row.
|
||||
const createSelectChain = (rows: unknown[]) => ({
|
||||
from: () => ({
|
||||
where: () => Promise.resolve(rows),
|
||||
where: () => Object.assign(Promise.resolve(rows), { for: () => Promise.resolve(rows) }),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -80,7 +80,6 @@ beforeEach(() => {
|
||||
dbMock.insert.mockReset();
|
||||
dbMock.update.mockReset();
|
||||
dbMock.delete.mockReset();
|
||||
dbMock.execute.mockReset();
|
||||
dbMock.transaction.mockReset();
|
||||
dbMock.transaction.mockImplementation((callback) => callback(dbMock));
|
||||
resumeGetByIdMock.mockReset();
|
||||
@@ -287,7 +286,6 @@ describe("applicationService timeline entries", () => {
|
||||
at: new Date("2026-07-10T15:45:00.000Z"),
|
||||
});
|
||||
expect(dbMock.transaction).toHaveBeenCalled();
|
||||
expect(dbMock.execute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes JSONB date strings when editing timeline dates", async () => {
|
||||
@@ -396,7 +394,6 @@ describe("applicationService timeline entries", () => {
|
||||
).deleteTimelineEntry({ id: "app-1", userId: "user-1", entryId: "stage-2" }),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
expect(dbMock.transaction).toHaveBeenCalled();
|
||||
expect(dbMock.execute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes older stage entries", async () => {
|
||||
|
||||
@@ -528,16 +528,11 @@ export const applicationService = {
|
||||
const patch = Object.fromEntries(Object.entries(details).filter(([, value]) => value !== undefined));
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.execute(sql`
|
||||
select 1 from ${schema.application}
|
||||
where ${schema.application.id} = ${id} and ${schema.application.userId} = ${userId}
|
||||
for update
|
||||
`);
|
||||
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(schema.application)
|
||||
.where(and(eq(schema.application.id, id), eq(schema.application.userId, userId)));
|
||||
.where(and(eq(schema.application.id, id), eq(schema.application.userId, userId)))
|
||||
.for("update");
|
||||
if (!existing) throw new ORPCError("NOT_FOUND");
|
||||
|
||||
const target = existing.activity.find((entry) => entry.id === entryId);
|
||||
@@ -569,16 +564,11 @@ export const applicationService = {
|
||||
text?: string | undefined;
|
||||
}) => {
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.execute(sql`
|
||||
select 1 from ${schema.application}
|
||||
where ${schema.application.id} = ${input.id} and ${schema.application.userId} = ${input.userId}
|
||||
for update
|
||||
`);
|
||||
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(schema.application)
|
||||
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)));
|
||||
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)))
|
||||
.for("update");
|
||||
if (!existing) throw new ORPCError("NOT_FOUND");
|
||||
|
||||
const activity = existing.activity.map((entry) => {
|
||||
@@ -621,16 +611,11 @@ export const applicationService = {
|
||||
|
||||
deleteTimelineEntry: (input: { id: string; userId: string; entryId: string }) => {
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.execute(sql`
|
||||
select 1 from ${schema.application}
|
||||
where ${schema.application.id} = ${input.id} and ${schema.application.userId} = ${input.userId}
|
||||
for update
|
||||
`);
|
||||
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(schema.application)
|
||||
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)));
|
||||
.where(and(eq(schema.application.id, input.id), eq(schema.application.userId, input.userId)))
|
||||
.for("update");
|
||||
if (!existing) throw new ORPCError("NOT_FOUND");
|
||||
|
||||
const entry = existing.activity.find((item) => item.id === input.entryId);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import { parseCookies } from "better-auth/cookies";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
|
||||
const RESUME_ACCESS_COOKIE_PREFIX = "resume_access";
|
||||
@@ -9,55 +10,27 @@ const getResumeAccessCookieName = (resumeId: string) => `${RESUME_ACCESS_COOKIE_
|
||||
const signResumeAccessToken = (resumeId: string, passwordHash: string): string =>
|
||||
createHash("sha256").update(`${resumeId}:${passwordHash}`).digest("hex");
|
||||
|
||||
const safeEquals = (value: string, expected: string) => {
|
||||
export 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);
|
||||
};
|
||||
|
||||
const parseCookieHeader = (cookieHeader: string | null): Map<string, string> => {
|
||||
const cookies = new Map<string, string>();
|
||||
if (!cookieHeader) return cookies;
|
||||
|
||||
for (const part of cookieHeader.split(";")) {
|
||||
const [rawName, ...rawValue] = part.trim().split("=");
|
||||
if (!rawName || rawValue.length === 0) continue;
|
||||
|
||||
cookies.set(rawName, rawValue.join("="));
|
||||
}
|
||||
|
||||
return cookies;
|
||||
};
|
||||
|
||||
const serializeCookie = (
|
||||
name: string,
|
||||
value: string,
|
||||
options: { path: string; httpOnly: boolean; sameSite: "lax"; maxAge: number; secure: boolean },
|
||||
) => {
|
||||
const parts = [`${name}=${value}`, `Path=${options.path}`, `Max-Age=${options.maxAge}`, "SameSite=Lax"];
|
||||
if (options.httpOnly) parts.push("HttpOnly");
|
||||
if (options.secure) parts.push("Secure");
|
||||
return parts.join("; ");
|
||||
};
|
||||
|
||||
export const hasResumeAccess = (requestHeaders: Headers, resumeId: string, passwordHash: string | null) => {
|
||||
if (!passwordHash) return false;
|
||||
const cookieName = getResumeAccessCookieName(resumeId);
|
||||
const cookieValue = parseCookieHeader(requestHeaders.get("cookie")).get(cookieName);
|
||||
const cookieValue = parseCookies(requestHeaders.get("cookie") ?? "").get(cookieName);
|
||||
if (!cookieValue) return false;
|
||||
const expected = signResumeAccessToken(resumeId, passwordHash);
|
||||
return safeEquals(cookieValue, expected);
|
||||
};
|
||||
|
||||
export const grantResumeAccess = (responseHeaders: Headers, resumeId: string, passwordHash: string) => {
|
||||
const cookie = serializeCookie(getResumeAccessCookieName(resumeId), signResumeAccessToken(resumeId, passwordHash), {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
maxAge: RESUME_ACCESS_TTL_SECONDS,
|
||||
secure: env.APP_URL.startsWith("https"),
|
||||
});
|
||||
|
||||
responseHeaders.append("Set-Cookie", cookie);
|
||||
const value = `${getResumeAccessCookieName(resumeId)}=${signResumeAccessToken(resumeId, passwordHash)}`;
|
||||
const secure = env.APP_URL.startsWith("https") ? "; Secure" : "";
|
||||
responseHeaders.append(
|
||||
"Set-Cookie",
|
||||
`${value}; Path=/; Max-Age=${RESUME_ACCESS_TTL_SECONDS}; SameSite=Lax; HttpOnly${secure}`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,11 +6,7 @@ import { pdfExportRateLimit } from "../../middleware/rate-limit";
|
||||
import { parseStoredResumeData } from "./resume-data-validation";
|
||||
import { resumeService } from "./service";
|
||||
|
||||
export {
|
||||
createResumePdfDownloadUrl,
|
||||
MAX_PDF_DOWNLOAD_URL_TTL_SECONDS,
|
||||
verifyResumePdfDownloadToken,
|
||||
} from "./pdf-download-url";
|
||||
export { createResumePdfDownloadUrl, verifyResumePdfDownloadToken } from "./pdf-download-url";
|
||||
|
||||
type CreateResumePdfDownloadInput = {
|
||||
id: string;
|
||||
|
||||
@@ -8,25 +8,21 @@ vi.mock("@reactive-resume/env/server", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const { MAX_PDF_DOWNLOAD_URL_TTL_SECONDS, createResumePdfDownloadUrl, verifyResumePdfDownloadToken } = await import(
|
||||
"./pdf-download-url"
|
||||
);
|
||||
const { createResumePdfDownloadUrl, verifyResumePdfDownloadToken } = await import("./pdf-download-url");
|
||||
|
||||
describe("resume PDF signed download URLs", () => {
|
||||
it("creates a URL with a token that is capped at 10 minutes", () => {
|
||||
it("creates a URL with a token that lasts 10 minutes", () => {
|
||||
const now = new Date("2026-06-01T10:00:00.000Z");
|
||||
|
||||
const result = createResumePdfDownloadUrl({
|
||||
resumeId: "resume-1",
|
||||
userId: "user-1",
|
||||
now,
|
||||
ttlSeconds: 60 * 60,
|
||||
});
|
||||
|
||||
const url = new URL(result.url);
|
||||
const token = url.searchParams.get("token");
|
||||
|
||||
expect(MAX_PDF_DOWNLOAD_URL_TTL_SECONDS).toBe(600);
|
||||
expect(url.origin).toBe("https://example.com");
|
||||
expect(url.pathname).toBe("/api/resumes/resume-1/pdf");
|
||||
expect(token).toBeTruthy();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { createHmac } from "node:crypto";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { safeEquals } from "./access";
|
||||
|
||||
export const MAX_PDF_DOWNLOAD_URL_TTL_SECONDS = 10 * 60;
|
||||
// Long enough to click, short enough that a leaked link is useless soon after.
|
||||
const TTL_SECONDS = 10 * 60;
|
||||
|
||||
type PdfDownloadTokenPayload = {
|
||||
v: 1;
|
||||
@@ -15,7 +17,6 @@ type CreateResumePdfDownloadUrlInput = {
|
||||
resumeId: string;
|
||||
userId: string;
|
||||
now?: Date;
|
||||
ttlSeconds?: number;
|
||||
};
|
||||
|
||||
type VerifyResumePdfDownloadTokenInput = {
|
||||
@@ -36,11 +37,6 @@ type VerifyResumePdfDownloadTokenResult =
|
||||
reason: "expired" | "invalid_signature" | "malformed" | "resume_mismatch";
|
||||
};
|
||||
|
||||
function resolveTtlSeconds(ttlSeconds: number | undefined) {
|
||||
if (ttlSeconds === undefined || !Number.isFinite(ttlSeconds)) return MAX_PDF_DOWNLOAD_URL_TTL_SECONDS;
|
||||
return Math.min(Math.max(Math.floor(ttlSeconds), 1), MAX_PDF_DOWNLOAD_URL_TTL_SECONDS);
|
||||
}
|
||||
|
||||
function encodeJson(value: unknown) {
|
||||
return Buffer.from(JSON.stringify(value), "utf8").toString("base64url");
|
||||
}
|
||||
@@ -53,13 +49,6 @@ function sign(payload: string) {
|
||||
return createHmac("sha256", env.AUTH_SECRET).update(payload).digest("base64url");
|
||||
}
|
||||
|
||||
function signaturesMatch(actual: string, expected: string) {
|
||||
const actualBuffer = Buffer.from(actual);
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
|
||||
return actualBuffer.byteLength === expectedBuffer.byteLength && timingSafeEqual(actualBuffer, expectedBuffer);
|
||||
}
|
||||
|
||||
function parsePayload(value: unknown): PdfDownloadTokenPayload | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
|
||||
@@ -73,13 +62,8 @@ function parsePayload(value: unknown): PdfDownloadTokenPayload | null {
|
||||
return payload as PdfDownloadTokenPayload;
|
||||
}
|
||||
|
||||
export function createResumePdfDownloadUrl({
|
||||
resumeId,
|
||||
userId,
|
||||
now = new Date(),
|
||||
ttlSeconds,
|
||||
}: CreateResumePdfDownloadUrlInput) {
|
||||
const expiresInSeconds = resolveTtlSeconds(ttlSeconds);
|
||||
export function createResumePdfDownloadUrl({ resumeId, userId, now = new Date() }: CreateResumePdfDownloadUrlInput) {
|
||||
const expiresInSeconds = TTL_SECONDS;
|
||||
const expiresAt = new Date(now.getTime() + expiresInSeconds * 1000);
|
||||
const payload = encodeJson({
|
||||
v: 1,
|
||||
@@ -106,7 +90,7 @@ export function verifyResumePdfDownloadToken({
|
||||
}: VerifyResumePdfDownloadTokenInput): VerifyResumePdfDownloadTokenResult {
|
||||
const [payload, signature, extra] = token.split(".");
|
||||
if (!payload || !signature || extra !== undefined) return { ok: false, reason: "malformed" };
|
||||
if (!signaturesMatch(signature, sign(payload))) return { ok: false, reason: "invalid_signature" };
|
||||
if (!safeEquals(signature, sign(payload))) return { ok: false, reason: "invalid_signature" };
|
||||
|
||||
try {
|
||||
const parsed = parsePayload(decodeJson(payload));
|
||||
|
||||
@@ -4,7 +4,7 @@ import { env } from "@reactive-resume/env/server";
|
||||
let redis: Redis | undefined;
|
||||
|
||||
export function getRedis(): Redis | null {
|
||||
const url = env.REDIS_URL?.trim();
|
||||
const url = env.REDIS_URL;
|
||||
if (!url) return null;
|
||||
if (!redis) {
|
||||
redis = new Redis(url, {
|
||||
|
||||
@@ -13,7 +13,6 @@ vi.mock("@reactive-resume/api/context", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/api/features/resume/export", () => ({
|
||||
MAX_PDF_DOWNLOAD_URL_TTL_SECONDS: 600,
|
||||
createResumePdfDownloadUrl: mocks.createResumePdfDownloadUrl,
|
||||
}));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user