From c50a295290e7da4cd3b13baa5cc29fb33482edac Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Mon, 6 Jul 2026 12:24:23 +0500 Subject: [PATCH] Add /api/pdf route with key/session auth and limits Adds GET /api/pdf: resolves the caller via a Bearer sbpdf_ API key (PdfApiKey table) or a Better Auth session cookie, then gates on paid course access, burst/monthly usage limits, generates the PDF via generateCleanPdf, records a ToolUsage row, and streams the file with X-Uses-Count/X-Max-Uses headers. Errors map to 401/403/422/429/504 JSON responses. Whitelists /api/pdf in middleware PUBLIC_ROUTES so the route can perform its own auth instead of being redirected to /login. --- src/app/api/pdf/route.ts | 83 ++++++++++++++++++++++++++++++++++++++++ src/middleware.ts | 2 +- 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 src/app/api/pdf/route.ts diff --git a/src/app/api/pdf/route.ts b/src/app/api/pdf/route.ts new file mode 100644 index 0000000..a18b806 --- /dev/null +++ b/src/app/api/pdf/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { + BURST_LIMIT, PDF_TOOL_ID, getBurstUsage, getMonthlyLimit, getMonthlyUsage, hasPaidAccess, +} from "@/lib/clean-pdf/access"; +import { BlockedUrlError } from "@/lib/clean-pdf/ssrf"; +import { EmptyContentError, RenderError, generateCleanPdf } from "@/lib/clean-pdf/generate"; +import { safePdfFilename } from "@/lib/clean-pdf/template"; +import { PDF_KEY_PREFIX } from "@/lib/clean-pdf/api-key"; + +export const dynamic = "force-dynamic"; + +const querySchema = z.object({ + url: z.string().min(1).max(2000), + format: z.enum(["A4", "Letter"]).default("A4"), + theme: z.enum(["light", "dark"]).default("light"), +}); + +function jsonError(status: number, error: string, extra?: Record) { + return NextResponse.json({ error }, { status, headers: extra }); +} + +async function resolveUserId(req: NextRequest): Promise { + const authHeader = req.headers.get("authorization"); + if (authHeader?.startsWith("Bearer ")) { + const key = authHeader.slice("Bearer ".length).trim(); + if (!key.startsWith(PDF_KEY_PREFIX)) return null; + const record = await prisma.pdfApiKey.findUnique({ where: { key }, select: { userId: true } }); + return record?.userId ?? null; + } + const session = await auth.api.getSession({ headers: req.headers }); + return session?.user.id ?? null; +} + +export async function GET(req: NextRequest) { + const userId = await resolveUserId(req); + if (!userId) return jsonError(401, "Нужен API-ключ или вход в аккаунт школы"); + + if (!(await hasPaidAccess(userId))) { + return jsonError(403, "Инструмент доступен студентам платных курсов школы"); + } + + const parsed = querySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams)); + if (!parsed.success) return jsonError(422, "Проверьте параметры: url, format (A4|Letter), theme (light|dark)"); + + if ((await getBurstUsage(userId)) >= BURST_LIMIT) { + return jsonError(429, "Слишком часто: подождите минуту"); + } + const limit = getMonthlyLimit(); + const used = await getMonthlyUsage(userId); + if (used >= limit) { + return jsonError(429, `Лимит ${limit} PDF в месяц исчерпан`, { + "X-Uses-Count": String(used), "X-Max-Uses": String(limit), + }); + } + + try { + const { pdf, title } = await generateCleanPdf(parsed.data); + await prisma.toolUsage.create({ data: { userId, tool: PDF_TOOL_ID } }); + + const filename = safePdfFilename(title); + return new NextResponse(new Uint8Array(pdf), { + status: 200, + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `attachment; filename="document.pdf"; filename*=UTF-8''${encodeURIComponent(filename)}.pdf`, + "X-Uses-Count": String(used + 1), + "X-Max-Uses": String(limit), + }, + }); + } catch (e) { + if (e instanceof BlockedUrlError || e instanceof EmptyContentError) { + return jsonError(422, e.message); + } + if (e instanceof RenderError) { + return jsonError(504, "Не получилось отрендерить страницу, попробуйте позже"); + } + console.error("[clean-pdf]", e); + return jsonError(504, "Не получилось сгенерировать PDF, попробуйте позже"); + } +} diff --git a/src/middleware.ts b/src/middleware.ts index 293b790..be920db 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getSessionCookie } from "better-auth/cookies"; -const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/api/register", "/api/internal", "/maintenance", "/share/wrapped"]; +const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/api/register", "/api/internal", "/api/pdf", "/maintenance", "/share/wrapped"]; export function middleware(request: NextRequest) { const { pathname } = request.nextUrl;