feat: add Word (.docx) export, enhance print density, and fix auth security guards
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
CI / typecheck + tests (push) Canceled after 0s
CI / build + deploy (Nix) — api (push) Canceled after 0s
CI / build + deploy (Nix) — mcp (push) Canceled after 0s
CI / build + deploy (Nix) — worker (push) Canceled after 0s
CI / build + deploy (web) (push) Canceled after 0s
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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=<revision uuid>
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user