feat: add scoped document API and resumable batch uploader

This commit is contained in:
2026-10-30 18:14:33 +08:00
parent 0c2a909521
commit 6bfeea4e7e
16 changed files with 826 additions and 27 deletions
+105 -15
View File
@@ -9,6 +9,9 @@ export type DocLink = {
export type Doc = DocLink & { content: string };
export type DocInput = Omit<Doc, "revision" | "searchText">;
export type DocumentExport = { format: "tabularium-documents-v1"; documents: Doc[] };
export class DocumentValidationError extends Error {}
export class DocumentConflictError extends Error {}
export type DocumentReceipt = Pick<Doc, "slug" | "sourceSlug" | "revision">;
export const validSlug = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
function database() {
@@ -25,6 +28,10 @@ function database() {
document_id TEXT NOT NULL REFERENCES documents(id), revision INTEGER NOT NULL,
payload TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY(document_id, revision)
);
CREATE TABLE IF NOT EXISTS document_write_requests (
namespace TEXT NOT NULL, request_id TEXT NOT NULL, digest TEXT NOT NULL,
response TEXT NOT NULL, PRIMARY KEY(namespace, request_id)
);
CREATE TABLE IF NOT EXISTS document_imports (
digest TEXT PRIMARY KEY, document_count INTEGER NOT NULL, created_at TEXT NOT NULL
);
@@ -33,50 +40,51 @@ function database() {
}
export function validateDocument(input: DocInput): void {
if (!input || typeof input !== "object") throw Error("Invalid document");
if (!input || typeof input !== "object") throw new DocumentValidationError("Invalid document");
for (const key of ["slug", "sourceSlug"] as const)
if (typeof input[key] !== "string" || !validSlug.test(input[key])) throw Error(`Invalid ${key}`);
if (input.slug.split("/").some((part) => /^\d+-/.test(part))) throw Error("Use a semantic public slug");
if (typeof input[key] !== "string" || !validSlug.test(input[key])) throw new DocumentValidationError(`Invalid ${key}`);
if (input.slug.split("/").some((part) => /^\d+-/.test(part))) throw new DocumentValidationError("Use a semantic public slug");
for (const key of ["title", "category", "collection", "group"] as const)
if (typeof input[key] !== "string" || !input[key].trim()) throw Error(`Missing ${key}`);
if (typeof input[key] !== "string" || !input[key].trim()) throw new DocumentValidationError(`Missing ${key}`);
if (typeof input.content !== "string" || typeof input.description !== "string" ||
typeof input.draft !== "boolean" || typeof input.featured !== "boolean") throw Error("Invalid document fields");
if (input.content.length > 2_000_000) throw Error("Document exceeds content limit");
typeof input.draft !== "boolean" || typeof input.featured !== "boolean") throw new DocumentValidationError("Invalid document fields");
if (input.content.length > 2_000_000) throw new DocumentValidationError("Document exceeds content limit");
const images = [
...input.content.matchAll(/!\[[^\]]*\]\(<?([^\s)>]+)/g),
...input.content.matchAll(/<img\b[^>]*\bsrc=["']([^"']+)/gi),
];
if (images.some(([, source]) => /\.(?:png|jpe?g|tiff?|bmp)(?:[?#]|$)/i.test(source)))
throw Error("Use compressed WebP images instead of original rasters");
throw new DocumentValidationError("Use compressed WebP images instead of original rasters");
}
function write(db: ReturnType<typeof database>, input: DocInput, expectedRevision: number): Doc {
validateDocument(input);
const existing = db.prepare("SELECT id, revision FROM documents WHERE source_slug=?")
.get(input.sourceSlug) as { id: string; revision: number } | undefined;
if ((existing?.revision ?? 0) !== expectedRevision) throw Error("Document revision conflict");
if ((existing?.revision ?? 0) !== expectedRevision) throw new DocumentConflictError("Document revision conflict");
const id = existing?.id ?? randomUUID();
const doc: Doc = { slug: input.slug, sourceSlug: input.sourceSlug, category: input.category,
title: input.title, group: input.group, collection: input.collection, description: input.description,
draft: input.draft, featured: input.featured, content: input.content, revision: expectedRevision + 1 };
const payload = JSON.stringify(doc);
// Aliases remain reserved after renaming; collision rolls back the whole write.
db.prepare(`INSERT INTO documents VALUES (?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET
slug=excluded.slug, revision=excluded.revision, payload=excluded.payload`)
.run(id, doc.slug, doc.sourceSlug, doc.revision, payload);
// Reserve both canonical and historical names before updating any rows.
for (const alias of new Set([doc.slug, doc.sourceSlug])) {
const prior = db.prepare("SELECT document_id FROM document_aliases WHERE alias=?")
.get(alias) as { document_id: string } | undefined;
if (prior && prior.document_id !== id) throw Error(`Document alias collision: ${alias}`);
db.prepare("INSERT OR IGNORE INTO document_aliases VALUES (?,?)").run(alias, id);
if (prior && prior.document_id !== id) throw new DocumentConflictError(`Document alias collision: ${alias}`);
}
db.prepare(`INSERT INTO documents VALUES (?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET
slug=excluded.slug, revision=excluded.revision, payload=excluded.payload`)
.run(id, doc.slug, doc.sourceSlug, doc.revision, payload);
for (const alias of new Set([doc.slug, doc.sourceSlug]))
db.prepare("INSERT OR IGNORE INTO document_aliases VALUES (?,?)").run(alias, id);
db.prepare("INSERT INTO document_revisions VALUES (?,?,?,?)")
.run(id, doc.revision, payload, new Date().toISOString());
return doc;
}
export function saveDocument(input: DocInput, expectedRevision: number): Doc {
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw Error("Provide expected revision");
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) throw new DocumentValidationError("Provide expected revision");
const db = database();
try {
db.exec("BEGIN IMMEDIATE");
@@ -145,3 +153,85 @@ export function documentHistory(sourceSlug: string): Doc[] {
.map((row) => JSON.parse(row.payload) as Doc);
} finally { db.close(); }
}
// API batches commit entirely or not at all; receipts make lost-response retries safe.
export function saveDocumentBatch(documents: Doc[], request?: { namespace: string; id: string }) {
if (!Array.isArray(documents) || !documents.length || documents.length > 100)
throw new DocumentValidationError("Provide 1 to 100 documents");
const seen = new Set<string>();
for (const doc of documents) {
validateDocument(doc);
if (!Number.isSafeInteger(doc.revision) || doc.revision < 0)
throw new DocumentValidationError("Provide expected revision");
if (seen.has(doc.sourceSlug)) throw new DocumentValidationError("Duplicate sourceSlug in batch");
seen.add(doc.sourceSlug);
}
if (request && (!request.namespace || !/^[A-Za-z0-9._:-]{1,128}$/.test(request.id)))
throw new DocumentValidationError("Invalid Idempotency-Key");
const digest = createHash("sha256").update(JSON.stringify(documents)).digest("hex");
const db = database();
try {
db.exec("BEGIN IMMEDIATE");
try {
if (request) {
const prior = db.prepare("SELECT digest,response FROM document_write_requests WHERE namespace=? AND request_id=?")
.get(request.namespace, request.id) as { digest: string; response: string } | undefined;
if (prior) {
if (prior.digest !== digest) throw new DocumentConflictError("Idempotency-Key was used with different content");
db.exec("COMMIT");
return { documents: JSON.parse(prior.response) as DocumentReceipt[], replayed: true };
}
}
const receipts = documents.map((input) => {
const { slug, sourceSlug, revision } = write(db, input, input.revision);
return { slug, sourceSlug, revision };
});
if (request) db.prepare("INSERT INTO document_write_requests VALUES (?,?,?,?)")
.run(request.namespace, request.id, digest, JSON.stringify(receipts));
db.exec("COMMIT");
return { documents: receipts, replayed: false };
} catch (error) { db.exec("ROLLBACK"); throw error; }
} finally { db.close(); }
}
export function documentPage(limit = 50, offset = 0) {
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0)
throw new DocumentValidationError("Invalid pagination");
const db = database();
try {
db.exec("BEGIN");
const total = (db.prepare("SELECT COUNT(*) AS n FROM documents").get() as { n: number }).n;
const documents = (db.prepare("SELECT json_remove(payload,'$.content') AS payload FROM documents ORDER BY source_slug LIMIT ? OFFSET ?")
.all(limit, offset) as { payload: string }[]).map((row) => JSON.parse(row.payload) as DocLink);
db.exec("COMMIT");
return { documents, total, limit, offset, nextOffset: offset + documents.length < total ? offset + documents.length : null };
} finally { db.close(); }
}
export function documentHistoryPage(sourceSlug: string, limit = 50, offset = 0) {
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0)
throw new DocumentValidationError("Invalid pagination");
const db = database();
try {
db.exec("BEGIN");
const total = (db.prepare(`SELECT COUNT(*) AS n FROM document_revisions r JOIN documents d
ON d.id=r.document_id WHERE d.source_slug=?`).get(sourceSlug) as { n: number }).n;
const revisions = (db.prepare(`SELECT json_remove(r.payload,'$.content') AS payload, r.created_at AS createdAt
FROM document_revisions r JOIN documents d ON d.id=r.document_id
WHERE d.source_slug=? ORDER BY r.revision DESC LIMIT ? OFFSET ?`)
.all(sourceSlug, limit, offset) as { payload: string; createdAt: string }[])
.map((row) => ({ ...JSON.parse(row.payload) as DocLink, createdAt: row.createdAt }));
db.exec("COMMIT");
return { revisions, total, limit, offset, nextOffset: offset + revisions.length < total ? offset + revisions.length : null };
} finally { db.close(); }
}
export function documentRevision(sourceSlug: string, revision: number): Doc | null {
if (!Number.isSafeInteger(revision) || revision < 1) throw new DocumentValidationError("Invalid revision");
const db = database();
try {
const row = db.prepare(`SELECT r.payload FROM document_revisions r JOIN documents d
ON d.id=r.document_id WHERE d.source_slug=? AND r.revision=?`).get(sourceSlug, revision) as { payload: string } | undefined;
return row ? JSON.parse(row.payload) as Doc : null;
} finally { db.close(); }
}
+56
View File
@@ -0,0 +1,56 @@
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { openSiteDatabase } from "./site-database";
export type DocumentKeyScope = "read" | "read-write";
export type DocumentKey = { id: string; name: string; scope: DocumentKeyScope;
createdAt: number; expiresAt: number; revokedAt: number | null; lastUsedAt: number | null };
function database() {
const db = openSiteDatabase();
db.exec(`CREATE TABLE IF NOT EXISTS document_api_keys (
id TEXT PRIMARY KEY, name TEXT NOT NULL, digest TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL CHECK(scope IN ('read','read-write')), created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL, revoked_at INTEGER, last_used_at INTEGER
);`);
return db;
}
export function createDocumentKey(name: string, scope: DocumentKeyScope = "read-write", days = 90) {
if (!name.trim() || name.length > 100 || !["read", "read-write"].includes(scope) ||
!Number.isSafeInteger(days) || days < 1 || days > 365) throw Error("Invalid key name, scope or lifetime");
const id = randomUUID();
const token = "tdoc_" + randomBytes(32).toString("base64url");
const createdAt = Date.now();
const expiresAt = createdAt + days * 86400000;
const db = database();
try {
db.prepare("INSERT INTO document_api_keys(id,name,digest,scope,created_at,expires_at) VALUES (?,?,?,?,?,?)")
.run(id, name.trim(), createHash("sha256").update(token).digest("hex"), scope, createdAt, expiresAt);
return { id, token, name: name.trim(), scope, createdAt, expiresAt };
} finally { db.close(); }
}
export function authenticateDocumentKey(token: string | undefined, now = Date.now()) {
if (!token || !/^tdoc_[A-Za-z0-9_-]{43}$/.test(token)) return null;
const db = database();
try {
const row = db.prepare("SELECT id,scope,last_used_at AS lastUsedAt FROM document_api_keys WHERE digest=? AND revoked_at IS NULL AND expires_at>?")
.get(createHash("sha256").update(token).digest("hex"), now) as { id: string; scope: DocumentKeyScope; lastUsedAt: number | null } | undefined;
if (!row) return null;
if (row.lastUsedAt === null || row.lastUsedAt < now - 60000)
db.prepare("UPDATE document_api_keys SET last_used_at=? WHERE id=? AND (last_used_at IS NULL OR last_used_at<?)")
.run(now, row.id, now - 60000);
return { id: row.id, scope: row.scope };
} finally { db.close(); }
}
export function listDocumentKeys(): DocumentKey[] {
const db = database();
try {
return db.prepare(`SELECT id,name,scope,created_at AS createdAt,expires_at AS expiresAt,
revoked_at AS revokedAt,last_used_at AS lastUsedAt FROM document_api_keys ORDER BY created_at,id`).all() as DocumentKey[];
} finally { db.close(); }
}
export function revokeDocumentKey(id: string) {
const db = database();
try {
const result = db.prepare("UPDATE document_api_keys SET revoked_at=COALESCE(revoked_at,?) WHERE id=?").run(Date.now(), id);
if (!result.changes) throw Error("Document key not found");
} finally { db.close(); }
}
+51
View File
@@ -0,0 +1,51 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { authenticateDocumentKey } from "./document-keys";
import { DocumentConflictError, DocumentValidationError, validateDocument, type Doc } from "./docs";
export function authorizeDocuments(request: NextApiRequest, response: NextApiResponse, write = false) {
response.setHeader("Cache-Control", "private, no-store");
response.setHeader("X-Content-Type-Options", "nosniff");
const header = request.headers.authorization;
const token = typeof header === "string" && header.startsWith("Bearer ") ? header.slice(7) : undefined;
const key = authenticateDocumentKey(token);
if (!key) {
response.status(401).json({ error: "需要有效的文档 API Key", code: "unauthorized" });
return null;
}
if (write && key.scope !== "read-write") {
response.status(403).json({ error: "该 API Key 仅允许读取文档", code: "read_only" });
return null;
}
return key;
}
export function documentApiError(response: NextApiResponse, error: unknown) {
if (error instanceof DocumentConflictError)
return response.status(409).json({ error: error.message, code: "conflict" });
if (error instanceof DocumentValidationError)
return response.status(400).json({ error: error.message, code: "invalid_document" });
return response.status(503).json({ error: "文档数据库暂时不可用", code: "unavailable" });
}
export function parseDocument(value: unknown): Doc {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new DocumentValidationError("Expected a document object");
const row = value as Record<string, unknown>;
const fields = ["slug", "sourceSlug", "title", "category", "collection", "group", "description", "draft", "featured", "content", "revision"];
if (Object.keys(row).some((key) => !fields.includes(key))) throw new DocumentValidationError("Unknown document field");
const doc = Object.fromEntries(fields.map((key) => [key, row[key]])) as Doc;
validateDocument(doc);
if (!Number.isSafeInteger(doc.revision) || doc.revision < 0) throw new DocumentValidationError("Provide expected revision");
return doc;
}
export function pagination(request: NextApiRequest) {
const number = (value: string | string[] | undefined, fallback: number) => {
if (value === undefined) return fallback;
if (typeof value !== "string" || !/^\d+$/.test(value)) throw new DocumentValidationError("Invalid pagination");
return Number(value);
};
return { limit: number(request.query.limit, 50), offset: number(request.query.offset, 0) };
}
export function idempotency(request: NextApiRequest, namespace: string) {
const id = request.headers["idempotency-key"];
if (id === undefined) return undefined;
if (typeof id !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(id)) throw new DocumentValidationError("Invalid Idempotency-Key");
return { namespace, id };
}
@@ -0,0 +1,22 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { DocumentValidationError, saveDocumentBatch } from "@/lib/docs";
import { authorizeDocuments, documentApiError, idempotency, parseDocument } from "@/lib/documents-api";
export const config = { api: { bodyParser: { sizeLimit: "8mb" } } };
export default function handler(request: NextApiRequest, response: NextApiResponse) {
try {
const key = authorizeDocuments(request, response, true);
if (!key) return;
if (request.method !== "POST") {
response.setHeader("Allow", "POST");
return response.status(405).end();
}
const body = request.body;
if (!body || typeof body !== "object" || Array.isArray(body) ||
Object.keys(body).length !== 1 || !Array.isArray(body.documents) ||
!body.documents.length || body.documents.length > 100)
throw new DocumentValidationError("Expected { documents: [...] } with 1 to 100 documents");
const documents = body.documents.map((row: unknown) => parseDocument(row));
return response.status(200).json(saveDocumentBatch(documents, idempotency(request, key.id)));
} catch (error) { return documentApiError(response, error); }
}
@@ -0,0 +1,21 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { documentPage, saveDocumentBatch } from "@/lib/docs";
import { authorizeDocuments, documentApiError, idempotency, pagination, parseDocument } from "@/lib/documents-api";
export const config = { api: { bodyParser: { sizeLimit: "8mb" } } };
export default function handler(request: NextApiRequest, response: NextApiResponse) {
try {
const key = authorizeDocuments(request, response, request.method === "POST");
if (!key) return;
if (request.method === "GET") {
const { limit, offset } = pagination(request);
return response.status(200).json(documentPage(limit, offset));
}
if (request.method === "POST") {
const result = saveDocumentBatch([parseDocument(request.body)], idempotency(request, key.id));
return response.status(200).json({ document: result.documents[0], replayed: result.replayed });
}
response.setHeader("Allow", "GET, POST");
return response.status(405).end();
} catch (error) { return documentApiError(response, error); }
}
@@ -0,0 +1,30 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { DocumentValidationError, documentHistoryPage, documentRevision, readDocument } from "@/lib/docs";
import { authorizeDocuments, documentApiError, pagination } from "@/lib/documents-api";
export default function handler(request: NextApiRequest, response: NextApiResponse) {
try {
if (!authorizeDocuments(request, response)) return;
if (request.method !== "GET") {
response.setHeader("Allow", "GET");
return response.status(405).end();
}
const slug = request.query.slug;
if (!Array.isArray(slug)) throw new DocumentValidationError("Invalid document slug");
const doc = readDocument(slug.join("/"));
if (!doc) return response.status(404).json({ error: "文档不存在", code: "not_found" });
if (request.query.revision !== undefined) {
if (request.query.history !== undefined || typeof request.query.revision !== "string" || !/^\d+$/.test(request.query.revision))
throw new DocumentValidationError("Invalid revision query");
const prior = documentRevision(doc.sourceSlug, Number(request.query.revision));
if (!prior) return response.status(404).json({ error: "修订不存在", code: "not_found" });
return response.status(200).json(prior);
}
if (request.query.history !== undefined) {
if (request.query.history !== "1") throw new DocumentValidationError("Use history=1");
const { limit, offset } = pagination(request);
return response.status(200).json(documentHistoryPage(doc.sourceSlug, limit, offset));
}
return response.status(200).json(doc);
} catch (error) { return documentApiError(response, error); }
}
+9
View File
@@ -1,5 +1,6 @@
import { NextResponse, type NextRequest } from "next/server";
import { authConfig, SESSION_COOKIE, validSession } from "./lib/auth";
import { authenticateDocumentKey } from "./lib/document-keys";
export function proxy(request: NextRequest) {
const path = request.nextUrl.pathname;
const headers = {
@@ -31,6 +32,14 @@ export function proxy(request: NextRequest) {
? authorization.slice(7)
: undefined;
const token = bearer ?? request.cookies.get(SESSION_COOKIE)?.value;
// Document keys are scoped to the document API; they never authenticate other pages or APIs.
if (bearer && (path === "/api/documents" || path.startsWith("/api/documents/"))) {
try {
if (authenticateDocumentKey(bearer)) return NextResponse.next({ headers });
} catch {
return NextResponse.json({ error: "文档认证暂时不可用" }, { status: 503, headers });
}
}
if (validSession(token, config)) {
if (!["GET", "HEAD", "OPTIONS"].includes(request.method) && !bearer) {
try {