From 5ccba958d22e2c681983eef3209b98d986c84e21 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sat, 22 Aug 2026 17:59:32 +0700 Subject: [PATCH] feat: add Word (.docx) export, enhance print density, and fix auth security guards --- apps/web/app/api/auth/login/route.ts | 21 +- apps/web/app/api/docs/[...slug]/route.ts | 8 +- apps/web/app/api/docs/route.ts | 8 +- apps/web/app/api/export/route.ts | 31 +- apps/web/app/api/revisions/restore/route.ts | 20 + apps/web/app/components/DocActions.tsx | 11 + apps/web/app/components/Header.tsx | 2 +- apps/web/app/components/Markdown.tsx | 46 +- apps/web/app/components/PdfExportView.tsx | 78 +- apps/web/app/globals.css | 199 ++++- apps/web/app/layout.tsx | 4 +- bun.lock | 51 +- packages/core/package.json | 1 + packages/core/src/export.docx.test.ts | 103 +++ packages/core/src/export.docx.ts | 875 ++++++++++++++++++++ packages/core/src/export.service.ts | 1 + 16 files changed, 1374 insertions(+), 85 deletions(-) create mode 100644 packages/core/src/export.docx.test.ts create mode 100644 packages/core/src/export.docx.ts diff --git a/apps/web/app/api/auth/login/route.ts b/apps/web/app/api/auth/login/route.ts index 9b0f121..9823f37 100644 --- a/apps/web/app/api/auth/login/route.ts +++ b/apps/web/app/api/auth/login/route.ts @@ -13,15 +13,23 @@ function signCookie(value: string): string { return `${value}.${sig}`; } +function safeTimingEqual(a: string, b: string): boolean { + try { + const bufA = Buffer.from(a); + const bufB = Buffer.from(b); + if (bufA.length !== bufB.length) return false; + return timingSafeEqual(bufA, bufB); + } catch { + return false; + } +} + function verifyCookie(cookieValue: string | undefined): boolean { if (!cookieValue) return false; const [value, sig] = cookieValue.split("."); if (!value || !sig) return false; const expected = signCookie(value); - return timingSafeEqual( - Buffer.from(cookieValue), - Buffer.from(expected), - ); + return safeTimingEqual(cookieValue, expected); } export async function POST(req: NextRequest) { @@ -32,10 +40,7 @@ export async function POST(req: NextRequest) { return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); } - const ok = timingSafeEqual( - Buffer.from(password), - Buffer.from(ADMIN_PASSWORD), - ); + const ok = safeTimingEqual(password, ADMIN_PASSWORD); if (!ok) { return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); diff --git a/apps/web/app/api/docs/[...slug]/route.ts b/apps/web/app/api/docs/[...slug]/route.ts index 057fabd..da22279 100644 --- a/apps/web/app/api/docs/[...slug]/route.ts +++ b/apps/web/app/api/docs/[...slug]/route.ts @@ -6,8 +6,12 @@ import { timingSafeEqual } from "node:crypto"; function isAuthorized(req: NextRequest): boolean { if (!WEBHOOK_SECRET) return false; const headerSecret = req.headers.get("x-webhook-secret") ?? ""; - if (headerSecret && timingSafeEqual(Buffer.from(headerSecret), Buffer.from(WEBHOOK_SECRET))) { - return true; + if (headerSecret) { + const bufA = Buffer.from(headerSecret); + const bufB = Buffer.from(WEBHOOK_SECRET); + if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) { + return true; + } } const cookie = req.cookies.get("mcpedia_admin")?.value ?? ""; return cookie.startsWith("admin."); diff --git a/apps/web/app/api/docs/route.ts b/apps/web/app/api/docs/route.ts index f968088..d403141 100644 --- a/apps/web/app/api/docs/route.ts +++ b/apps/web/app/api/docs/route.ts @@ -13,8 +13,12 @@ import { timingSafeEqual } from "node:crypto"; function isAuthorized(req: NextRequest): boolean { if (!WEBHOOK_SECRET) return false; const headerSecret = req.headers.get("x-webhook-secret") ?? ""; - if (headerSecret && timingSafeEqual(Buffer.from(headerSecret), Buffer.from(WEBHOOK_SECRET))) { - return true; + if (headerSecret) { + const bufA = Buffer.from(headerSecret); + const bufB = Buffer.from(WEBHOOK_SECRET); + if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) { + return true; + } } const cookie = req.cookies.get("mcpedia_admin")?.value ?? ""; return cookie.startsWith("admin."); diff --git a/apps/web/app/api/export/route.ts b/apps/web/app/api/export/route.ts index ff5412b..59d4483 100644 --- a/apps/web/app/api/export/route.ts +++ b/apps/web/app/api/export/route.ts @@ -1,5 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import { getExportDocuments, compileExportMarkdown, type ExportSortOption } from "@mcpedia/core"; +import { + getExportDocuments, + compileExportMarkdown, + generateExportDocx, + type ExportSortOption, +} from "@mcpedia/core"; export const dynamic = "force-dynamic"; @@ -11,6 +16,8 @@ export async function GET(req: NextRequest) { const sortBy = (searchParams.get("sort") as ExportSortOption) || "category_points"; const slugsParam = searchParams.get("slugs"); const slugs = slugsParam ? slugsParam.split(",").map((s) => s.trim()) : undefined; + const pageBreaksParam = searchParams.get("pageBreaks"); + const pageBreaks = pageBreaksParam !== "false" && pageBreaksParam !== "0"; try { const data = await getExportDocuments({ @@ -20,13 +27,25 @@ export async function GET(req: NextRequest) { sortBy, }); + const safeFilename = data.summary.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") || "mcpedia-export"; + + if (format === "docx" || format === "word") { + const buffer = await generateExportDocx(data, { pageBreaks }); + const uint8 = new Uint8Array(buffer); + return new NextResponse(uint8, { + headers: { + "Content-Type": + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "Content-Disposition": `attachment; filename="${safeFilename}.docx"`, + }, + }); + } + if (format === "markdown" || format === "md") { const md = compileExportMarkdown(data); - const safeFilename = data.summary.title - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); - return new NextResponse(md, { headers: { "Content-Type": "text/markdown; charset=utf-8", diff --git a/apps/web/app/api/revisions/restore/route.ts b/apps/web/app/api/revisions/restore/route.ts index 581ded2..917755b 100644 --- a/apps/web/app/api/revisions/restore/route.ts +++ b/apps/web/app/api/revisions/restore/route.ts @@ -1,12 +1,32 @@ import { NextRequest, NextResponse } from "next/server"; import { restoreRevision, getRevision } from "@mcpedia/core"; +import { WEBHOOK_SECRET } from "@mcpedia/config"; +import { timingSafeEqual } from "node:crypto"; import { revalidatePath } from "next/cache"; +function isAuthorized(req: NextRequest): boolean { + if (!WEBHOOK_SECRET) return false; + const headerSecret = req.headers.get("x-webhook-secret") ?? ""; + if (headerSecret) { + const bufA = Buffer.from(headerSecret); + const bufB = Buffer.from(WEBHOOK_SECRET); + if (bufA.length === bufB.length && timingSafeEqual(bufA, bufB)) { + return true; + } + } + const cookie = req.cookies.get("mcpedia_admin")?.value ?? ""; + return cookie.startsWith("admin."); +} + // POST /api/revisions/restore — restore a document to a past revision. // Body (form-urlencoded): id= // After restoring, we rebuild semantic chunks (handled inside restoreRevision) // and revalidate the doc page so the Web UI reflects the restored body. export async function POST(req: NextRequest) { + if (!isAuthorized(req)) { + return NextResponse.json({ ok: false, error: "Unauthorized" }, { status: 401 }); + } + const form = await req.formData().catch(() => null); const id = form?.get("id"); if (typeof id !== "string" || id.length === 0) { diff --git a/apps/web/app/components/DocActions.tsx b/apps/web/app/components/DocActions.tsx index f2c9097..a19a7e4 100644 --- a/apps/web/app/components/DocActions.tsx +++ b/apps/web/app/components/DocActions.tsx @@ -7,6 +7,7 @@ import { Check, Share2, FileDown, + FileText, Edit3, } from "lucide-react"; @@ -84,6 +85,16 @@ export default function DocActions({ slug, body, canEdit }: DocActionsProps) { PDF + {/* Export Word (.docx) */} + + + Word (.docx) + + {/* Edit Link */} +
{/* Brand */}
diff --git a/apps/web/app/components/Markdown.tsx b/apps/web/app/components/Markdown.tsx index 1e4a201..7ca2aaa 100644 --- a/apps/web/app/components/Markdown.tsx +++ b/apps/web/app/components/Markdown.tsx @@ -51,16 +51,16 @@ function PreBlock({ children, ...props }: React.HTMLAttributes) } return ( -
+
{/* Code block header bar */} -
+
- + {language}
@@ -85,7 +85,7 @@ function PreBlock({ children, ...props }: React.HTMLAttributes)
-
+      
         {children}
       
@@ -97,15 +97,15 @@ export default function Markdown({ source }: { source: string }) {
{children} @@ -130,7 +130,7 @@ export default function Markdown({ source }: { source: string }) { ); }, table: ({ node, ...props }) => ( -
+
), @@ -140,35 +140,35 @@ export default function Markdown({ source }: { source: string }) { if (alertMatch) { const alertType = alertMatch[1].toUpperCase(); - let borderClass = "border-indigo-500 bg-indigo-500/10 text-indigo-900 dark:text-indigo-200"; + let borderClass = "border-indigo-500 bg-indigo-500/10 text-indigo-900 dark:text-indigo-200 print:bg-indigo-50 print:text-indigo-950"; let Icon = Info; let title = "Note"; if (alertType === "TIP") { - borderClass = "border-emerald-500 bg-emerald-500/10 text-emerald-900 dark:text-emerald-200"; + borderClass = "border-emerald-500 bg-emerald-500/10 text-emerald-900 dark:text-emerald-200 print:bg-emerald-50 print:text-emerald-950"; Icon = Lightbulb; title = "Tip"; } else if (alertType === "IMPORTANT") { - borderClass = "border-violet-500 bg-violet-500/10 text-violet-900 dark:text-violet-200"; + borderClass = "border-violet-500 bg-violet-500/10 text-violet-900 dark:text-violet-200 print:bg-violet-50 print:text-violet-950"; Icon = Flame; title = "Important"; } else if (alertType === "WARNING") { - borderClass = "border-amber-500 bg-amber-500/10 text-amber-900 dark:text-amber-200"; + borderClass = "border-amber-500 bg-amber-500/10 text-amber-900 dark:text-amber-200 print:bg-amber-50 print:text-amber-950"; Icon = AlertTriangle; title = "Warning"; } else if (alertType === "CAUTION") { - borderClass = "border-rose-500 bg-rose-500/10 text-rose-900 dark:text-rose-200"; + borderClass = "border-rose-500 bg-rose-500/10 text-rose-900 dark:text-rose-200 print:bg-rose-50 print:text-rose-950"; Icon = ShieldAlert; title = "Caution"; } return ( -
-
- +
+
+ {title}
-
+
{children}
@@ -176,7 +176,7 @@ export default function Markdown({ source }: { source: string }) { } return ( -
+
{children}
); diff --git a/apps/web/app/components/PdfExportView.tsx b/apps/web/app/components/PdfExportView.tsx index e271804..40e9ad4 100644 --- a/apps/web/app/components/PdfExportView.tsx +++ b/apps/web/app/components/PdfExportView.tsx @@ -17,6 +17,7 @@ import { getSectionMeta } from "@mcpedia/config/sections"; import { Printer, FileDown, + FileText, Copy, Check, ArrowLeft, @@ -119,6 +120,37 @@ export default function PdfExportView({ URL.revokeObjectURL(url); } + const [downloadingDocx, setDownloadingDocx] = useState(false); + + async function handleDownloadDocx() { + setDownloadingDocx(true); + try { + const slugs = filteredAndSortedDocs.map((d) => d.slug).join(","); + const safeName = + summary.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") || "export"; + const url = `/api/export?format=docx&slugs=${encodeURIComponent(slugs)}&sort=${sortBy}&pageBreaks=${pageBreaks}`; + const res = await fetch(url); + if (!res.ok) throw new Error("Failed to generate Word document"); + const blob = await res.blob(); + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = blobUrl; + a.download = `${safeName}.docx`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(blobUrl); + } catch (err) { + console.error("DOCX download error:", err); + alert("Failed to generate .docx file. Please try again."); + } finally { + setDownloadingDocx(false); + } + } + return (
{/* Export Control Toolbar (Screen Only — Hidden in Print) */} @@ -183,6 +215,22 @@ export default function PdfExportView({ Download .md + {/* Download Word (.docx) */} + + {/* Print / Save as PDF Button */}