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.
This commit is contained in:
@@ -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<string, string>) {
|
||||||
|
return NextResponse.json({ error }, { status, headers: extra });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveUserId(req: NextRequest): Promise<string | null> {
|
||||||
|
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, попробуйте позже");
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getSessionCookie } from "better-auth/cookies";
|
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) {
|
export function middleware(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|||||||
Reference in New Issue
Block a user