Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c50a295290 | |||
| 0c56b0b809 | |||
| 89383fab1d | |||
| ef46cdbe29 | |||
| b49680c118 | |||
| 1d5b4f9251 | |||
| 6316c09993 | |||
| 9a74339087 | |||
| 2c619be043 | |||
| 52073fd914 |
@@ -45,3 +45,7 @@ WOW_MOMENT_LESSON_ID="obs-start-l2-006"
|
|||||||
# Obsidian Toolbox (/tools): "true" — показывать точки входа и роуты (staging);
|
# Obsidian Toolbox (/tools): "true" — показывать точки входа и роуты (staging);
|
||||||
# не задано/иное — скрыто, /tools редиректит на /dashboard (prod, пока дорабатываем)
|
# не задано/иное — скрыто, /tools редиректит на /dashboard (prod, пока дорабатываем)
|
||||||
TOOLBOX_VISIBLE=""
|
TOOLBOX_VISIBLE=""
|
||||||
|
|
||||||
|
# Чистый PDF (browserless на staging/prod; локально — SSH-туннель на staging)
|
||||||
|
BROWSER_WS_URL="ws://localhost:3333"
|
||||||
|
PDF_MONTHLY_LIMIT="100"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Чистый PDF — персональный API-ключ студента (Zotero/curl)
|
||||||
|
CREATE TABLE "PdfApiKey" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "PdfApiKey_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "PdfApiKey_userId_key" ON "PdfApiKey"("userId");
|
||||||
|
CREATE UNIQUE INDEX "PdfApiKey_key_key" ON "PdfApiKey"("key");
|
||||||
|
|
||||||
|
ALTER TABLE "PdfApiKey" ADD CONSTRAINT "PdfApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -47,6 +47,7 @@ model User {
|
|||||||
questionMessages StudentQuestionMessage[]
|
questionMessages StudentQuestionMessage[]
|
||||||
wrappedRuns WrappedRun[]
|
wrappedRuns WrappedRun[]
|
||||||
toolUsages ToolUsage[]
|
toolUsages ToolUsage[]
|
||||||
|
pdfApiKey PdfApiKey?
|
||||||
}
|
}
|
||||||
|
|
||||||
// Obsidian Wrapped — агрегаты прогона (БЕЗ имён/текстов заметок; обработка волта 100% client-side)
|
// Obsidian Wrapped — агрегаты прогона (БЕЗ имён/текстов заметок; обработка волта 100% client-side)
|
||||||
@@ -82,6 +83,16 @@ model ToolUsage {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Чистый PDF — персональный API-ключ студента (Zotero/curl)
|
||||||
|
model PdfApiKey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
key String @unique // sbpdf_<random>, показывается студенту на /tools/clean-pdf
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
@@ -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, попробуйте позже");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { decidePaidAccess, monthStartUtc } from "@/lib/clean-pdf/access";
|
||||||
|
|
||||||
|
const now = new Date("2026-07-06T10:00:00Z");
|
||||||
|
const freeSlug = "obsidian-start";
|
||||||
|
|
||||||
|
describe("decidePaidAccess", () => {
|
||||||
|
it("платный enrollment без срока — да", () => {
|
||||||
|
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now,
|
||||||
|
enrollments: [{ courseSlug: "zotero", expiresAt: null }] })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("только бесплатный курс — нет", () => {
|
||||||
|
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now,
|
||||||
|
enrollments: [{ courseSlug: "obsidian-start", expiresAt: null }] })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("без enrollments — нет", () => {
|
||||||
|
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now, enrollments: [] })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("истёкший платный — нет, живой срок — да", () => {
|
||||||
|
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now,
|
||||||
|
enrollments: [{ courseSlug: "zotero", expiresAt: new Date("2026-01-01") }] })).toBe(false);
|
||||||
|
expect(decidePaidAccess({ role: "student", banned: false, freeSlug, now,
|
||||||
|
enrollments: [{ courseSlug: "zotero", expiresAt: new Date("2027-01-01") }] })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("admin и curator — да даже без курсов", () => {
|
||||||
|
expect(decidePaidAccess({ role: "admin", banned: false, freeSlug, now, enrollments: [] })).toBe(true);
|
||||||
|
expect(decidePaidAccess({ role: "curator", banned: false, freeSlug, now, enrollments: [] })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("бан всё перекрывает", () => {
|
||||||
|
expect(decidePaidAccess({ role: "admin", banned: true, freeSlug, now, enrollments: [] })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("monthStartUtc", () => {
|
||||||
|
it("возвращает первое число месяца 00:00 UTC", () => {
|
||||||
|
expect(monthStartUtc(new Date("2026-07-06T23:59:59Z")).toISOString()).toBe("2026-07-01T00:00:00.000Z");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { PDF_KEY_PREFIX, generatePdfKey } from "@/lib/clean-pdf/api-key";
|
||||||
|
|
||||||
|
describe("generatePdfKey", () => {
|
||||||
|
it("формат sbpdf_<base64url>, достаточная длина", () => {
|
||||||
|
const key = generatePdfKey();
|
||||||
|
expect(key.startsWith(PDF_KEY_PREFIX)).toBe(true);
|
||||||
|
expect(key.length).toBeGreaterThanOrEqual(PDF_KEY_PREFIX.length + 32);
|
||||||
|
expect(key.slice(PDF_KEY_PREFIX.length)).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("две генерации различаются", () => {
|
||||||
|
expect(generatePdfKey()).not.toBe(generatePdfKey());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { generateCleanPdf } from "@/lib/clean-pdf/generate";
|
||||||
|
|
||||||
|
const enabled = process.env.RUN_PDF_INTEGRATION === "1";
|
||||||
|
|
||||||
|
describe.runIf(enabled)("generateCleanPdf (integration, нужен browserless)", () => {
|
||||||
|
it("генерирует PDF реальной статьи", async () => {
|
||||||
|
const { pdf, title } = await generateCleanPdf({
|
||||||
|
url: "https://habr.com/ru/articles/942236/",
|
||||||
|
format: "A4",
|
||||||
|
theme: "light",
|
||||||
|
});
|
||||||
|
expect(pdf.length).toBeGreaterThan(20_000);
|
||||||
|
expect(pdf.subarray(0, 5).toString()).toBe("%PDF-");
|
||||||
|
expect(title.length).toBeGreaterThan(3);
|
||||||
|
}, 120_000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { BlockedUrlError, assertPublicUrl, isPrivateAddress } from "@/lib/clean-pdf/ssrf";
|
||||||
|
|
||||||
|
describe("isPrivateAddress", () => {
|
||||||
|
const privateIps = [
|
||||||
|
"127.0.0.1", "0.0.0.0", "10.1.2.3", "100.64.0.1", "169.254.169.254",
|
||||||
|
"172.16.0.1", "172.31.255.255", "192.168.1.1", "192.0.0.8", "198.18.0.1",
|
||||||
|
"224.0.0.1", "255.255.255.255",
|
||||||
|
"::1", "::", "fc00::1", "fd12:3456::1", "fe80::1", "::ffff:10.0.0.1", "::ffff:127.0.0.1",
|
||||||
|
"::ffff:7f00:1", "::ffff:a00:1", "::ffff:c0a8:101", "::ffff:a9fe:a9fe",
|
||||||
|
];
|
||||||
|
const publicIps = ["93.184.216.34", "8.8.8.8", "172.32.0.1", "2606:2800:220:1::1", "::ffff:8.8.8.8", "::ffff:808:808"];
|
||||||
|
|
||||||
|
it.each(privateIps)("блокирует %s", (ip) => expect(isPrivateAddress(ip)).toBe(true));
|
||||||
|
it.each(publicIps)("пропускает %s", (ip) => expect(isPrivateAddress(ip)).toBe(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("assertPublicUrl", () => {
|
||||||
|
const publicResolve = async () => [{ address: "93.184.216.34", family: 4 }];
|
||||||
|
const privateResolve = async () => [{ address: "172.18.0.2", family: 4 }];
|
||||||
|
const mixedResolve = async () => [
|
||||||
|
{ address: "93.184.216.34", family: 4 },
|
||||||
|
{ address: "10.0.0.5", family: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("пропускает публичный https-URL", async () => {
|
||||||
|
const url = await assertPublicUrl("https://example.com/article", publicResolve);
|
||||||
|
expect(url.hostname).toBe("example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"ftp://example.com/x",
|
||||||
|
"chrome://settings",
|
||||||
|
"not a url",
|
||||||
|
])("блокирует %s", async (raw) => {
|
||||||
|
await expect(assertPublicUrl(raw, publicResolve)).rejects.toThrow(BlockedUrlError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("блокирует localhost и .local без резолва", async () => {
|
||||||
|
await expect(assertPublicUrl("http://localhost:3000/x", publicResolve)).rejects.toThrow(BlockedUrlError);
|
||||||
|
await expect(assertPublicUrl("http://printer.local/x", publicResolve)).rejects.toThrow(BlockedUrlError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("блокирует литеральный приватный IP", async () => {
|
||||||
|
await expect(assertPublicUrl("http://192.168.1.1/admin", publicResolve)).rejects.toThrow(BlockedUrlError);
|
||||||
|
await expect(assertPublicUrl("http://[::1]:8080/", publicResolve)).rejects.toThrow(BlockedUrlError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("блокирует хост, резолвящийся в приватный IP (включая частично)", async () => {
|
||||||
|
await expect(assertPublicUrl("https://evil.example/x", privateResolve)).rejects.toThrow(BlockedUrlError);
|
||||||
|
await expect(assertPublicUrl("https://evil.example/x", mixedResolve)).rejects.toThrow(BlockedUrlError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("блокирует хост, который не резолвится", async () => {
|
||||||
|
const failResolve = async () => { throw new Error("ENOTFOUND"); };
|
||||||
|
await expect(assertPublicUrl("https://nope.example/x", failResolve)).rejects.toThrow(BlockedUrlError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("блокирует v4-mapped IPv6 в bracket-нотации (SSRF-обход)", async () => {
|
||||||
|
for (const raw of [
|
||||||
|
"http://[::ffff:127.0.0.1]/",
|
||||||
|
"http://[::ffff:10.0.0.1]/",
|
||||||
|
"http://[::ffff:169.254.169.254]/",
|
||||||
|
"http://[::ffff:192.168.1.1]/",
|
||||||
|
]) {
|
||||||
|
await expect(assertPublicUrl(raw, publicResolve)).rejects.toThrow(BlockedUrlError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildCleanHtml, safePdfFilename } from "@/lib/clean-pdf/template";
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
title: "Заголовок статьи",
|
||||||
|
url: "https://example.com/article",
|
||||||
|
theme: "light" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("buildCleanHtml", () => {
|
||||||
|
it("экранирует HTML в метаполях", () => {
|
||||||
|
const html = buildCleanHtml({ ...base, title: `<script>alert(1)</script>`, contentHtml: "<p>ок</p>" });
|
||||||
|
expect(html).not.toContain("<script>alert(1)</script>");
|
||||||
|
expect(html).toContain("<script>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("вставляет контент и шапку", () => {
|
||||||
|
const html = buildCleanHtml({ ...base, author: "Автор", site: "Example", contentHtml: "<p>Текст статьи</p>" });
|
||||||
|
expect(html).toContain("<p>Текст статьи</p>");
|
||||||
|
expect(html).toContain("Заголовок статьи");
|
||||||
|
expect(html).toContain("Автор");
|
||||||
|
expect(html).toContain("https://example.com/article");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("строит оглавление при 3+ заголовках и проставляет якоря", () => {
|
||||||
|
const content = "<h2>Один</h2><p>a</p><h2>Два</h2><p>b</p><h3>Три</h3><p>c</p>";
|
||||||
|
const html = buildCleanHtml({ ...base, contentHtml: content });
|
||||||
|
expect(html).toContain("Содержание");
|
||||||
|
expect(html).toMatch(/<h2 id="[^"]+">Один<\/h2>/);
|
||||||
|
expect((html.match(/class="toc-item/g) ?? []).length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("не строит оглавление при <3 заголовках", () => {
|
||||||
|
const html = buildCleanHtml({ ...base, contentHtml: "<h2>Один</h2><p>a</p>" });
|
||||||
|
expect(html).not.toContain("Содержание");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("уникализирует одинаковые якоря", () => {
|
||||||
|
const content = "<h2>Раздел</h2><h2>Раздел</h2><h2>Раздел</h2>";
|
||||||
|
const html = buildCleanHtml({ ...base, contentHtml: content });
|
||||||
|
const ids = [...html.matchAll(/<h2 id="([^"]+)"/g)].map((m) => m[1]);
|
||||||
|
expect(new Set(ids).size).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("переключает тёмную тему", () => {
|
||||||
|
const light = buildCleanHtml({ ...base, contentHtml: "<p>x</p>" });
|
||||||
|
const dark = buildCleanHtml({ ...base, theme: "dark", contentHtml: "<p>x</p>" });
|
||||||
|
expect(light).toContain('data-theme="light"');
|
||||||
|
expect(dark).toContain('data-theme="dark"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("safePdfFilename", () => {
|
||||||
|
it("убирает опасные символы и ограничивает длину", () => {
|
||||||
|
expect(safePdfFilename('Статья: как/не\\надо "делать"?')).toBe("Статья_ как_не_надо _делать_");
|
||||||
|
expect(safePdfFilename("")).toBe("document");
|
||||||
|
expect(safePdfFilename("a".repeat(300)).length).toBeLessThanOrEqual(140);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildZoteroScript } from "@/lib/clean-pdf/zotero-script";
|
||||||
|
|
||||||
|
describe("buildZoteroScript", () => {
|
||||||
|
it("подставляет ключ и адрес школы", () => {
|
||||||
|
const s = buildZoteroScript({ apiKey: "sbpdf_test123", baseUrl: "https://school.second-brain.ru" });
|
||||||
|
expect(s).toContain("'sbpdf_test123'");
|
||||||
|
expect(s).toContain("'https://school.second-brain.ru'");
|
||||||
|
expect(s).toContain("/api/pdf?url=");
|
||||||
|
expect(s).not.toContain("YOUR_KEY");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// НЕ "clean-pdf": так CopyButton логирует копирования — они не должны тратить лимит
|
||||||
|
export const PDF_TOOL_ID = "clean-pdf-generate";
|
||||||
|
export const BURST_LIMIT = 5; // успешных генераций в минуту
|
||||||
|
const BURST_WINDOW_MS = 60_000;
|
||||||
|
|
||||||
|
export function decidePaidAccess(input: {
|
||||||
|
role: string;
|
||||||
|
banned: boolean;
|
||||||
|
enrollments: { courseSlug: string; expiresAt: Date | null }[];
|
||||||
|
freeSlug: string;
|
||||||
|
now?: Date;
|
||||||
|
}): boolean {
|
||||||
|
const now = input.now ?? new Date();
|
||||||
|
if (input.banned) return false;
|
||||||
|
if (input.role === "admin" || input.role === "curator") return true;
|
||||||
|
return input.enrollments.some(
|
||||||
|
(e) => e.courseSlug !== input.freeSlug && (e.expiresAt === null || e.expiresAt > now),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasPaidAccess(userId: string): Promise<boolean> {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
role: true,
|
||||||
|
banned: true,
|
||||||
|
enrollments: { select: { expiresAt: true, course: { select: { slug: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!user) return false;
|
||||||
|
return decidePaidAccess({
|
||||||
|
role: user.role,
|
||||||
|
banned: user.banned ?? false,
|
||||||
|
freeSlug: process.env.FREE_COURSE_SLUG ?? "obsidian-start",
|
||||||
|
enrollments: user.enrollments.map((e) => ({ courseSlug: e.course.slug, expiresAt: e.expiresAt })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function monthStartUtc(now: Date): Date {
|
||||||
|
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMonthlyLimit(): number {
|
||||||
|
const n = Number(process.env.PDF_MONTHLY_LIMIT);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMonthlyUsage(userId: string): Promise<number> {
|
||||||
|
return prisma.toolUsage.count({
|
||||||
|
where: { userId, tool: PDF_TOOL_ID, createdAt: { gte: monthStartUtc(new Date()) } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBurstUsage(userId: string): Promise<number> {
|
||||||
|
return prisma.toolUsage.count({
|
||||||
|
where: { userId, tool: PDF_TOOL_ID, createdAt: { gte: new Date(Date.now() - BURST_WINDOW_MS) } },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
export const PDF_KEY_PREFIX = "sbpdf_";
|
||||||
|
|
||||||
|
export function generatePdfKey(): string {
|
||||||
|
return PDF_KEY_PREFIX + randomBytes(24).toString("base64url");
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { isIP } from "node:net";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
import { Defuddle } from "defuddle/node";
|
||||||
|
import { assertPublicUrl, isPrivateAddress } from "@/lib/clean-pdf/ssrf";
|
||||||
|
import { buildCleanHtml } from "@/lib/clean-pdf/template";
|
||||||
|
|
||||||
|
export class EmptyContentError extends Error {}
|
||||||
|
export class RenderError extends Error {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Оборачивает промис в app-level таймаут: если `p` не завершится за `ms`,
|
||||||
|
* гонка завершится отказом с RenderError, и внешний `finally` (browser.close())
|
||||||
|
* освободит слот browserless вместо того, чтобы держать его до зависшего вызова.
|
||||||
|
*/
|
||||||
|
function withTimeout<T>(p: Promise<T>, ms: number, message: string): Promise<T> {
|
||||||
|
let timer: ReturnType<typeof setTimeout>;
|
||||||
|
const guard = new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new RenderError(message)), ms);
|
||||||
|
timer.unref?.();
|
||||||
|
});
|
||||||
|
return Promise.race([p, guard]).finally(() => clearTimeout(timer)) as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GOTO_TIMEOUT_MS = 30_000;
|
||||||
|
const SETTLE_TIMEOUT_MS = 10_000;
|
||||||
|
const PDF_TIMEOUT_MS = 30_000;
|
||||||
|
const UA =
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
|
||||||
|
|
||||||
|
function browserWsUrl(): string {
|
||||||
|
const base = process.env.BROWSER_WS_URL;
|
||||||
|
if (!base) throw new RenderError("BROWSER_WS_URL не задан");
|
||||||
|
const token = process.env.BROWSERLESS_TOKEN;
|
||||||
|
return token ? `${base}${base.includes("?") ? "&" : "?"}token=${token}` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateCleanPdf(opts: {
|
||||||
|
url: string;
|
||||||
|
format: "A4" | "Letter";
|
||||||
|
theme: "light" | "dark";
|
||||||
|
}): Promise<{ pdf: Buffer; title: string }> {
|
||||||
|
const target = await assertPublicUrl(opts.url);
|
||||||
|
|
||||||
|
const browser = await chromium.connectOverCDP(browserWsUrl()).catch((e) => {
|
||||||
|
throw new RenderError(`Браузер недоступен: ${e.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext({ userAgent: UA, viewport: { width: 1280, height: 800 } });
|
||||||
|
// Вторая линия SSRF-обороны: страница не может дёргать приватные адреса
|
||||||
|
// (литеральные IP и localhost; hostnames уже проверены до goto).
|
||||||
|
await context.route("**/*", (route) => {
|
||||||
|
try {
|
||||||
|
const u = new URL(route.request().url());
|
||||||
|
const host = u.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
||||||
|
if (
|
||||||
|
(u.protocol !== "http:" && u.protocol !== "https:") ||
|
||||||
|
host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") ||
|
||||||
|
(isIP(host) !== 0 && isPrivateAddress(host))
|
||||||
|
) {
|
||||||
|
return route.abort();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return route.abort();
|
||||||
|
}
|
||||||
|
return route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
// context.route НЕ перехватывает WebSocket — отдельная защита от SSRF через ws://
|
||||||
|
await context.routeWebSocket("**/*", (ws) => {
|
||||||
|
try {
|
||||||
|
const u = new URL(ws.url());
|
||||||
|
const host = u.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
||||||
|
if (
|
||||||
|
(u.protocol !== "ws:" && u.protocol !== "wss:") ||
|
||||||
|
host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") ||
|
||||||
|
(isIP(host) !== 0 && isPrivateAddress(host))
|
||||||
|
) {
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ws.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ws.connectToServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(target.href, { waitUntil: "domcontentloaded", timeout: GOTO_TIMEOUT_MS }).catch((e) => {
|
||||||
|
throw new RenderError(`Страница не открылась: ${e.message}`);
|
||||||
|
});
|
||||||
|
await page.waitForLoadState("networkidle", { timeout: SETTLE_TIMEOUT_MS }).catch(() => {});
|
||||||
|
|
||||||
|
const rawHtml = await page.content();
|
||||||
|
const pageTitle = await page.title().catch(() => "");
|
||||||
|
|
||||||
|
const dom = new JSDOM(rawHtml, { url: target.href });
|
||||||
|
const article = await Defuddle(dom.window.document, target.href);
|
||||||
|
// DefuddleResponse (node_modules/defuddle/dist/types.d.ts) типизирует
|
||||||
|
// content/title/author/site/domain/wordCount как обязательные (не optional) поля —
|
||||||
|
// сам объект тоже не nullable (Defuddle() не возвращает null/undefined).
|
||||||
|
// Отсутствие контента выражается пустой строкой/нулевым wordCount, не отсутствием поля.
|
||||||
|
if (!article.content || article.wordCount < 10) {
|
||||||
|
throw new EmptyContentError("Не удалось выделить содержимое страницы");
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = article.title || pageTitle || target.hostname;
|
||||||
|
const cleanHtml = buildCleanHtml({
|
||||||
|
title,
|
||||||
|
author: article.author || undefined,
|
||||||
|
site: article.site || article.domain || undefined,
|
||||||
|
url: target.href,
|
||||||
|
contentHtml: article.content,
|
||||||
|
theme: opts.theme,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfPage = await context.newPage();
|
||||||
|
await pdfPage.setContent(cleanHtml, { waitUntil: "networkidle", timeout: SETTLE_TIMEOUT_MS }).catch(() => {});
|
||||||
|
// page.pdf() в playwright-core не принимает опцию timeout (проверено по
|
||||||
|
// node_modules/playwright-core/types/types.d.ts) и не имеет implicit-бага —
|
||||||
|
// оборачиваем в app-level withTimeout, чтобы зависание всё же дошло до
|
||||||
|
// finally (browser.close()) и не держало CONCURRENT-слот browserless.
|
||||||
|
const pdf = await withTimeout(
|
||||||
|
pdfPage.pdf({
|
||||||
|
format: opts.format,
|
||||||
|
printBackground: true,
|
||||||
|
margin: { top: "15mm", bottom: "18mm", left: "15mm", right: "15mm" },
|
||||||
|
}),
|
||||||
|
PDF_TIMEOUT_MS,
|
||||||
|
"Печать PDF не завершилась вовремя",
|
||||||
|
);
|
||||||
|
|
||||||
|
return { pdf, title };
|
||||||
|
} finally {
|
||||||
|
await browser.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { generatePdfKey } from "@/lib/clean-pdf/api-key";
|
||||||
|
|
||||||
|
export async function getOrCreatePdfKey(userId: string): Promise<string> {
|
||||||
|
const existing = await prisma.pdfApiKey.findUnique({ where: { userId } });
|
||||||
|
if (existing) return existing.key;
|
||||||
|
const created = await prisma.pdfApiKey.upsert({
|
||||||
|
where: { userId },
|
||||||
|
create: { userId, key: generatePdfKey() },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
return created.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function regenerateKey(userId: string): Promise<string> {
|
||||||
|
const key = generatePdfKey();
|
||||||
|
await prisma.pdfApiKey.upsert({
|
||||||
|
where: { userId },
|
||||||
|
create: { userId, key },
|
||||||
|
update: { key },
|
||||||
|
});
|
||||||
|
return key;
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { lookup } from "node:dns/promises";
|
||||||
|
import { isIP } from "node:net";
|
||||||
|
|
||||||
|
export class BlockedUrlError extends Error {}
|
||||||
|
|
||||||
|
export type LookupFn = (
|
||||||
|
hostname: string,
|
||||||
|
opts: { all: true },
|
||||||
|
) => Promise<{ address: string; family: number }[]>;
|
||||||
|
|
||||||
|
function v4ToInt(ip: string): number {
|
||||||
|
return ip.split(".").reduce((acc, o) => acc * 256 + Number(o), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [начало включительно, конец включительно] в виде 32-битных чисел
|
||||||
|
const V4_PRIVATE: Array<[number, number]> = [
|
||||||
|
["0.0.0.0", "0.255.255.255"], // "this network"
|
||||||
|
["10.0.0.0", "10.255.255.255"], // RFC1918
|
||||||
|
["100.64.0.0", "100.127.255.255"], // CGNAT
|
||||||
|
["127.0.0.0", "127.255.255.255"], // loopback
|
||||||
|
["169.254.0.0", "169.254.255.255"], // link-local / cloud metadata
|
||||||
|
["172.16.0.0", "172.31.255.255"], // RFC1918 (docker-сети попадают сюда)
|
||||||
|
["192.0.0.0", "192.0.0.255"], // IETF protocol assignments
|
||||||
|
["192.168.0.0", "192.168.255.255"], // RFC1918
|
||||||
|
["198.18.0.0", "198.19.255.255"], // benchmarking
|
||||||
|
["224.0.0.0", "255.255.255.255"], // multicast + reserved + broadcast
|
||||||
|
].map(([a, b]) => [v4ToInt(a), v4ToInt(b)] as [number, number]);
|
||||||
|
|
||||||
|
function isPrivateV4(ip: string): boolean {
|
||||||
|
const n = v4ToInt(ip);
|
||||||
|
return V4_PRIVATE.some(([lo, hi]) => n >= lo && n <= hi);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Разворачивает IPv6 (в т.ч. сжатый `::` и v4-mapped/embedded dotted) в 8 групп по 16 бит. */
|
||||||
|
function expandV6(ip: string): number[] | null {
|
||||||
|
let s = ip.toLowerCase();
|
||||||
|
|
||||||
|
// Встроенный dotted-хвост (::ffff:127.0.0.1, ::127.0.0.1) → в hex-группы
|
||||||
|
const dotted = s.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
||||||
|
if (dotted) {
|
||||||
|
if (isIP(dotted[1]) !== 4) return null;
|
||||||
|
const n = v4ToInt(dotted[1]);
|
||||||
|
s = s.slice(0, dotted.index) + ((n >>> 16) & 0xffff).toString(16) + ":" + (n & 0xffff).toString(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
const halves = s.split("::");
|
||||||
|
if (halves.length > 2) return null;
|
||||||
|
const head = halves[0] ? halves[0].split(":") : [];
|
||||||
|
const tail = halves.length === 2 ? (halves[1] ? halves[1].split(":") : []) : [];
|
||||||
|
|
||||||
|
let groups: string[];
|
||||||
|
if (halves.length === 2) {
|
||||||
|
const missing = 8 - head.length - tail.length;
|
||||||
|
if (missing < 0) return null;
|
||||||
|
groups = [...head, ...Array(missing).fill("0"), ...tail];
|
||||||
|
} else {
|
||||||
|
groups = head;
|
||||||
|
}
|
||||||
|
if (groups.length !== 8) return null;
|
||||||
|
|
||||||
|
const nums = groups.map((g) => (g === "" ? 0 : parseInt(g, 16)));
|
||||||
|
if (nums.some((x) => Number.isNaN(x) || x < 0 || x > 0xffff)) return null;
|
||||||
|
return nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPrivateAddress(ip: string): boolean {
|
||||||
|
if (isIP(ip) === 4) return isPrivateV4(ip);
|
||||||
|
if (isIP(ip) !== 6) return true; // не IP — не пропускаем
|
||||||
|
|
||||||
|
const groups = expandV6(ip);
|
||||||
|
if (!groups) return true; // не смогли разобрать v6 — не пропускаем
|
||||||
|
|
||||||
|
// ::/96 (v4-compatible, вкл. :: и ::1) и ::ffff:0:0/96 (v4-mapped):
|
||||||
|
// младшие 32 бита содержат IPv4 — проверяем его напрямую.
|
||||||
|
const firstFiveZero = groups.slice(0, 5).every((g) => g === 0);
|
||||||
|
if (firstFiveZero && (groups[5] === 0 || groups[5] === 0xffff)) {
|
||||||
|
const v4num = groups[6] * 0x10000 + groups[7];
|
||||||
|
const dottedV4 = `${(v4num >>> 24) & 255}.${(v4num >>> 16) & 255}.${(v4num >>> 8) & 255}.${v4num & 255}`;
|
||||||
|
return isPrivateV4(dottedV4);
|
||||||
|
}
|
||||||
|
|
||||||
|
const first = groups[0];
|
||||||
|
if (first >= 0xfc00 && first <= 0xfdff) return true; // fc00::/7 (ULA)
|
||||||
|
if (first >= 0xfe80 && first <= 0xfebf) return true; // fe80::/10 (link-local)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertPublicUrl(raw: string, resolve: LookupFn = lookup): Promise<URL> {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
throw new BlockedUrlError("Некорректный URL");
|
||||||
|
}
|
||||||
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||||
|
throw new BlockedUrlError("Поддерживаются только http и https");
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = url.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, ""); // [::1] → ::1
|
||||||
|
if (hostname === "localhost" || hostname.endsWith(".local") || hostname.endsWith(".internal")) {
|
||||||
|
throw new BlockedUrlError("Адрес недоступен");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isIP(hostname)) {
|
||||||
|
if (isPrivateAddress(hostname)) throw new BlockedUrlError("Адрес недоступен");
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
let addresses: { address: string; family: number }[];
|
||||||
|
try {
|
||||||
|
addresses = await resolve(hostname, { all: true });
|
||||||
|
} catch {
|
||||||
|
throw new BlockedUrlError("Не удалось определить адрес сайта");
|
||||||
|
}
|
||||||
|
if (addresses.length === 0 || addresses.some((a) => isPrivateAddress(a.address))) {
|
||||||
|
throw new BlockedUrlError("Адрес недоступен");
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
|
||||||
|
export interface CleanArticle {
|
||||||
|
title: string;
|
||||||
|
author?: string;
|
||||||
|
site?: string;
|
||||||
|
url: string;
|
||||||
|
contentHtml: string;
|
||||||
|
theme: "light" | "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safePdfFilename(title: string): string {
|
||||||
|
const cleaned = title.replace(/[\\/:"*?<>|\n\r]+/g, "_").trim().slice(0, 140);
|
||||||
|
return cleaned || "document";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TocEntry { id: string; text: string; level: 2 | 3 }
|
||||||
|
|
||||||
|
/** Проставляет id заголовкам h2/h3 и возвращает контент + записи оглавления. */
|
||||||
|
function prepareContent(contentHtml: string): { html: string; toc: TocEntry[] } {
|
||||||
|
const dom = new JSDOM(`<body>${contentHtml}</body>`);
|
||||||
|
const doc = dom.window.document;
|
||||||
|
const used = new Set<string>();
|
||||||
|
const toc: TocEntry[] = [];
|
||||||
|
|
||||||
|
doc.querySelectorAll("h2, h3").forEach((h) => {
|
||||||
|
const text = (h.textContent ?? "").trim();
|
||||||
|
if (!text) return;
|
||||||
|
let id = text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "") || "section";
|
||||||
|
let i = 2;
|
||||||
|
while (used.has(id)) id = `${id}-${i++}`;
|
||||||
|
used.add(id);
|
||||||
|
h.id = id;
|
||||||
|
toc.push({ id, text, level: h.tagName === "H2" ? 2 : 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
return { html: doc.body.innerHTML, toc };
|
||||||
|
}
|
||||||
|
|
||||||
|
const CSS = `
|
||||||
|
:root[data-theme="light"] { --bg: #faf7f0; --fg: #1f1d1a; --muted: #6b6459; --line: #d8d2c4; --accent: #7a2e2e; }
|
||||||
|
:root[data-theme="dark"] { --bg: #201e1b; --fg: #e8e4dc; --muted: #a39b8d; --line: #3d3a34; --accent: #d4a0a0; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { background: var(--bg); color: var(--fg); font-family: Georgia, "Times New Roman", serif;
|
||||||
|
font-size: 12.5pt; line-height: 1.65; margin: 0; }
|
||||||
|
main { max-width: 100%; }
|
||||||
|
h1 { font-size: 22pt; line-height: 1.25; margin: 0 0 6pt; }
|
||||||
|
h2 { font-size: 16pt; margin: 20pt 0 8pt; }
|
||||||
|
h3 { font-size: 13.5pt; margin: 16pt 0 6pt; }
|
||||||
|
p { margin: 0 0 9pt; }
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
img, video, iframe { max-width: 100%; height: auto; }
|
||||||
|
pre { background: rgba(127,127,127,.08); border: 1px solid var(--line); padding: 8pt;
|
||||||
|
overflow-x: hidden; white-space: pre-wrap; word-wrap: break-word;
|
||||||
|
font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 9.5pt; }
|
||||||
|
code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.9em; }
|
||||||
|
blockquote { border-left: 3px solid var(--line); margin: 0 0 9pt; padding: 2pt 0 2pt 12pt; color: var(--muted); }
|
||||||
|
table { border-collapse: collapse; width: 100%; font-size: 10.5pt; }
|
||||||
|
th, td { border: 1px solid var(--line); padding: 4pt 6pt; text-align: left; }
|
||||||
|
figure { margin: 0 0 9pt; } figcaption { color: var(--muted); font-size: 9.5pt; }
|
||||||
|
.meta { color: var(--muted); font-size: 10pt; margin-bottom: 4pt; }
|
||||||
|
.meta a { color: var(--muted); }
|
||||||
|
.head-rule { border: 0; border-top: 1px solid var(--line); margin: 12pt 0 16pt; }
|
||||||
|
.toc { border: 1px solid var(--line); padding: 10pt 14pt; margin: 0 0 16pt; }
|
||||||
|
.toc-title { font-weight: bold; margin-bottom: 6pt; }
|
||||||
|
.toc-item { display: block; margin: 2pt 0; }
|
||||||
|
.toc-item.lvl3 { padding-left: 14pt; font-size: 0.92em; }
|
||||||
|
h2, h3 { break-after: avoid; } pre, blockquote, figure, table { break-inside: avoid; }
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function buildCleanHtml(article: CleanArticle): string {
|
||||||
|
const { html, toc } = prepareContent(article.contentHtml);
|
||||||
|
const metaParts = [article.site, article.author].filter(Boolean).map((s) => escapeHtml(s!));
|
||||||
|
|
||||||
|
const tocHtml = toc.length >= 3
|
||||||
|
? `<nav class="toc"><div class="toc-title">Содержание</div>${toc
|
||||||
|
.map((t) => `<a class="toc-item lvl${t.level}" href="#${t.id}">${escapeHtml(t.text)}</a>`)
|
||||||
|
.join("")}</nav>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="ru" data-theme="${article.theme}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>${escapeHtml(article.title)}</title>
|
||||||
|
<style>${CSS}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>${escapeHtml(article.title)}</h1>
|
||||||
|
${metaParts.length ? `<div class="meta">${metaParts.join(" · ")}</div>` : ""}
|
||||||
|
<div class="meta"><a href="${escapeHtml(article.url)}">${escapeHtml(article.url)}</a></div>
|
||||||
|
<hr class="head-rule">
|
||||||
|
${tocHtml}
|
||||||
|
${html}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// Адаптация скрипта Clean PDF (pdf.brainysnipe.ru/zotero-script.js) под школу.
|
||||||
|
export function buildZoteroScript({ apiKey, baseUrl }: { apiKey: string; baseUrl: string }): string {
|
||||||
|
return `// ── Настройки ──────────────────────────────────────────────────────────────
|
||||||
|
const API_KEY = '${apiKey}'; // персональный ключ из school.second-brain.ru/tools/clean-pdf
|
||||||
|
const SERVICE_URL = '${baseUrl}';
|
||||||
|
const FORMAT = 'A4'; // 'A4' или 'Letter'
|
||||||
|
const THEME = 'light'; // 'light' или 'dark'
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (typeof item === "undefined" || !item) return;
|
||||||
|
(async function () {
|
||||||
|
let tempFilePath = null;
|
||||||
|
try {
|
||||||
|
function joinPath(dir, filename) {
|
||||||
|
if (typeof OS !== "undefined" && OS.Path && OS.Path.join) return OS.Path.join(dir, filename);
|
||||||
|
const separator = dir.includes("\\\\") ? "\\\\" : "/";
|
||||||
|
return dir.replace(/[\\\\/]+$/, "") + separator + filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetItem = item;
|
||||||
|
if (!targetItem.isRegularItem()) {
|
||||||
|
if (targetItem.isAttachment() || targetItem.isNote())
|
||||||
|
targetItem = Zotero.Items.get(targetItem.parentID);
|
||||||
|
}
|
||||||
|
if (!targetItem?.isRegularItem()) {
|
||||||
|
Zotero.alert(null, "Чистый PDF", "Выберите обычную запись Zotero.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = targetItem.getField("url");
|
||||||
|
if (!url) { Zotero.alert(null, "Чистый PDF", "У записи нет URL."); return; }
|
||||||
|
|
||||||
|
const pw = new Zotero.ProgressWindow();
|
||||||
|
pw.changeHeadline("Чистый PDF");
|
||||||
|
const progress = new pw.ItemProgress(targetItem.getImageSrc(), targetItem.getField("title"));
|
||||||
|
pw.show();
|
||||||
|
pw.addDescription("Генерируем PDF…");
|
||||||
|
|
||||||
|
const response = await Zotero.getMainWindow().fetch(
|
||||||
|
SERVICE_URL + "/api/pdf?url=" + encodeURIComponent(url) + "&format=" + FORMAT + "&theme=" + THEME,
|
||||||
|
{ headers: { 'Authorization': 'Bearer ' + API_KEY } }
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
progress.setError();
|
||||||
|
pw.addDescription("Ошибка: " + (body.error || ("HTTP " + response.status)));
|
||||||
|
pw.startCloseTimer(8000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uint8Array = new Uint8Array(await (await response.blob()).arrayBuffer());
|
||||||
|
if (uint8Array.length < 1000) {
|
||||||
|
progress.setError(); pw.addDescription("Пришёл пустой PDF."); pw.startCloseTimer(8000); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeTitle = (targetItem.getField("title") || "Document").replace(/[\\\\/:"*?<>|]+/g, "_").slice(0, 140);
|
||||||
|
tempFilePath = joinPath(Zotero.getTempDirectory().path, safeTitle + ".pdf");
|
||||||
|
await Zotero.getMainWindow().IOUtils.write(tempFilePath, uint8Array);
|
||||||
|
await Zotero.Attachments.importFromFile({ file: tempFilePath, parentItemID: targetItem.id, contentType: "application/pdf" });
|
||||||
|
|
||||||
|
for (const attId of targetItem.getAttachments()) {
|
||||||
|
const att = Zotero.Items.get(attId);
|
||||||
|
const ct = att?.attachmentContentType || "";
|
||||||
|
if (ct === "text/html" || ct === "application/zip") await att.eraseTx();
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.setProgress(100);
|
||||||
|
const usesCount = response.headers.get('X-Uses-Count');
|
||||||
|
const maxUses = response.headers.get('X-Max-Uses');
|
||||||
|
const usageInfo = (usesCount && maxUses) ? (" (" + usesCount + "/" + maxUses + " за месяц)") : "";
|
||||||
|
pw.addDescription("PDF прикреплён." + usageInfo);
|
||||||
|
pw.startCloseTimer(4000);
|
||||||
|
} catch (e) {
|
||||||
|
Zotero.alert(null, "Ошибка", e.toString());
|
||||||
|
} finally {
|
||||||
|
if (tempFilePath) {
|
||||||
|
await Zotero.getMainWindow().IOUtils.remove(tempFilePath, { ignoreAbsent: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();`;
|
||||||
|
}
|
||||||
+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