wiki-viewer 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/README.md +328 -99
- package/.next/standalone/agents/bootstrap-prompt.md +1 -0
- package/.next/standalone/agents/wiki-viewer-skill/SKILL.md +236 -0
- package/.next/standalone/bin/wiki-viewer.js +57 -4
- package/.next/standalone/docs/agent-collab-plan.md +1615 -0
- package/.next/standalone/docs/agent-collab-v2-plan.md +771 -0
- package/.next/standalone/package.json +19 -2
- package/.next/standalone/pnpm-lock.yaml +1368 -325
- package/.next/standalone/pnpm-workspace.yaml +7 -1
- package/.next/standalone/src/app/api/agent/activity/route.ts +58 -0
- package/.next/standalone/src/app/api/agent/admin/agents/[agentId]/revoke/route.ts +40 -0
- package/.next/standalone/src/app/api/agent/admin/agents/route.ts +33 -0
- package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/approve/route.ts +83 -0
- package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/deny/route.ts +36 -0
- package/.next/standalone/src/app/api/agent/admin/registrations/route.ts +27 -0
- package/.next/standalone/src/app/api/agent/events/[...path]/route.ts +136 -0
- package/.next/standalone/src/app/api/agent/files/[...path]/route.ts +202 -0
- package/.next/standalone/src/app/api/agent/internal/span/route.ts +117 -0
- package/.next/standalone/src/app/api/agent/register/[regId]/route.ts +50 -0
- package/.next/standalone/src/app/api/agent/register/route.ts +110 -0
- package/.next/standalone/src/app/api/agent/settings/route.ts +30 -0
- package/.next/standalone/src/app/api/agent/settings/token/regenerate/route.ts +20 -0
- package/.next/standalone/src/app/api/agent/sidecar/[...path]/route.ts +49 -0
- package/.next/standalone/src/app/api/agents/install/route.ts +83 -0
- package/.next/standalone/src/app/api/agents/skill/route.ts +20 -0
- package/.next/standalone/src/app/api/agents/skill.tar.gz/route.ts +87 -0
- package/.next/standalone/src/app/api/auth/[...all]/route.ts +7 -0
- package/.next/standalone/src/app/api/owner/init/route.ts +14 -0
- package/.next/standalone/src/app/api/system/auth-settings/route.ts +85 -0
- package/.next/standalone/src/app/api/system/browse/route.ts +4 -0
- package/.next/standalone/src/app/api/system/clear-root/route.ts +8 -1
- package/.next/standalone/src/app/api/system/config/route.ts +5 -1
- package/.next/standalone/src/app/api/system/pins/route.ts +7 -0
- package/.next/standalone/src/app/api/system/reveal/route.ts +7 -0
- package/.next/standalone/src/app/api/system/root-status/route.ts +5 -1
- package/.next/standalone/src/app/api/system/set-root/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/app/route.ts +15 -0
- package/.next/standalone/src/app/api/wiki/content/route.ts +110 -11
- package/.next/standalone/src/app/api/wiki/folder/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/move/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/new-file/route.ts +55 -0
- package/.next/standalone/src/app/api/wiki/page/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/route.ts +10 -0
- package/.next/standalone/src/app/api/wiki/slugs/route.ts +5 -1
- package/.next/standalone/src/app/api/wiki/upload/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/watch/route.ts +6 -1
- package/.next/standalone/src/app/globals.css +70 -0
- package/.next/standalone/src/app/page.tsx +461 -217
- package/.next/standalone/src/app/signin/page.tsx +217 -0
- package/.next/standalone/src/components/ai-panel/activity-row.tsx +64 -0
- package/.next/standalone/src/components/ai-panel/ai-panel.tsx +322 -0
- package/.next/standalone/src/components/ai-panel/token-section.tsx +312 -0
- package/.next/standalone/src/components/auth-settings-sheet.tsx +209 -0
- package/.next/standalone/src/components/editor/bubble-menu.tsx +41 -2
- package/.next/standalone/src/components/editor/comment-pip.tsx +56 -0
- package/.next/standalone/src/components/editor/comment-thread.tsx +252 -0
- package/.next/standalone/src/components/editor/editor.tsx +513 -10
- package/.next/standalone/src/components/editor/extensions/proof-span.ts +60 -0
- package/.next/standalone/src/components/editor/extensions.ts +2 -0
- package/.next/standalone/src/components/editor/proof-span-popover.tsx +161 -0
- package/.next/standalone/src/components/editor/suggest-edit-popover.tsx +228 -0
- package/.next/standalone/src/components/editor/suggestion-card.tsx +201 -0
- package/.next/standalone/src/components/view-width-toggle.tsx +47 -0
- package/.next/standalone/src/components/wiki/markdown-preview.tsx +120 -0
- package/.next/standalone/src/lib/auth/allowlist.ts +50 -0
- package/.next/standalone/src/lib/auth/client.ts +4 -0
- package/.next/standalone/src/lib/auth/csrf.ts +68 -0
- package/.next/standalone/src/lib/auth/server.ts +164 -0
- package/.next/standalone/src/lib/config.ts +4 -0
- package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +20 -0
- package/.next/standalone/src/lib/markdown/sanitize-schema.ts +106 -0
- package/.next/standalone/src/lib/markdown/to-html.ts +46 -8
- package/.next/standalone/src/lib/markdown/to-markdown.ts +14 -0
- package/.next/standalone/src/lib/proof/activity-shared.ts +31 -0
- package/.next/standalone/src/lib/proof/activity.ts +74 -0
- package/.next/standalone/src/lib/proof/auth.ts +132 -0
- package/.next/standalone/src/lib/proof/block-refs.ts +133 -0
- package/.next/standalone/src/lib/proof/blocks.ts +73 -0
- package/.next/standalone/src/lib/proof/client-auth.ts +23 -0
- package/.next/standalone/src/lib/proof/event-bus.ts +47 -0
- package/.next/standalone/src/lib/proof/file-lock.ts +38 -0
- package/.next/standalone/src/lib/proof/glob.ts +51 -0
- package/.next/standalone/src/lib/proof/idempotency.ts +32 -0
- package/.next/standalone/src/lib/proof/mutex.ts +32 -0
- package/.next/standalone/src/lib/proof/ops-applier.ts +678 -0
- package/.next/standalone/src/lib/proof/owner-auth.ts +2 -0
- package/.next/standalone/src/lib/proof/pending.ts +97 -0
- package/.next/standalone/src/lib/proof/proof-span.ts +141 -0
- package/.next/standalone/src/lib/proof/rate-limit.ts +53 -0
- package/.next/standalone/src/lib/proof/register-rate-limit.ts +53 -0
- package/.next/standalone/src/lib/proof/registry.ts +190 -0
- package/.next/standalone/src/lib/proof/sidecar.ts +66 -0
- package/.next/standalone/src/lib/proof/suggest-capture.ts +69 -0
- package/.next/standalone/src/lib/proof/types.ts +170 -0
- package/.next/standalone/src/lib/proof-config.ts +14 -0
- package/.next/standalone/src/middleware.ts +38 -0
- package/.next/standalone/src/stores/ai-panel-store.ts +58 -3
- package/.next/standalone/src/stores/editor-store.ts +115 -9
- package/.next/standalone/src/stores/proof-store.ts +123 -0
- package/.next/standalone/src/stores/view-width-store.ts +62 -0
- package/.next/standalone/src/tests/proof/activity-aggregator.test.ts +146 -0
- package/.next/standalone/src/tests/proof/agent-ownership.test.ts +140 -0
- package/.next/standalone/src/tests/proof/agents-install.test.ts +57 -0
- package/.next/standalone/src/tests/proof/better-auth.test.ts +68 -0
- package/.next/standalone/src/tests/proof/block-refs.test.ts +108 -0
- package/.next/standalone/src/tests/proof/blocks.test.ts +92 -0
- package/.next/standalone/src/tests/proof/comments-ops.test.ts +177 -0
- package/.next/standalone/src/tests/proof/editor-roundtrip.test.ts +85 -0
- package/.next/standalone/src/tests/proof/external-edit.test.ts +58 -0
- package/.next/standalone/src/tests/proof/file-lock.test.ts +80 -0
- package/.next/standalone/src/tests/proof/glob.test.ts +82 -0
- package/.next/standalone/src/tests/proof/helpers/auth-session.ts +27 -0
- package/.next/standalone/src/tests/proof/markdown-to-html.test.ts +46 -0
- package/.next/standalone/src/tests/proof/ops-applier.test.ts +368 -0
- package/.next/standalone/src/tests/proof/owner-init.test.ts +2 -0
- package/.next/standalone/src/tests/proof/parse-frontmatter.test.ts +60 -0
- package/.next/standalone/src/tests/proof/proof-span.test.ts +98 -0
- package/.next/standalone/src/tests/proof/rate-limit.test.ts +54 -0
- package/.next/standalone/src/tests/proof/registration-flow.test.ts +330 -0
- package/.next/standalone/src/tests/proof/registry.test.ts +169 -0
- package/.next/standalone/src/tests/proof/routes.test.ts +516 -0
- package/.next/standalone/src/tests/proof/sidecar-trim.test.ts +58 -0
- package/.next/standalone/src/tests/proof/suggestion-ops.test.ts +280 -0
- package/.next/standalone/src/tests/proof/wiki-content-put.test.ts +140 -0
- package/.next/standalone/src/tests/proof/wiki-routes-auth.test.ts +208 -0
- package/README.md +328 -99
- package/bin/wiki-viewer.js +57 -4
- package/package.json +19 -2
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { markdownToHtml } from "@/lib/markdown/to-html";
|
|
5
|
+
import { useWikiSlugsStore } from "@/stores/wiki-slugs-store";
|
|
6
|
+
|
|
7
|
+
interface MarkdownPreviewProps {
|
|
8
|
+
markdown: string;
|
|
9
|
+
pagePath: string;
|
|
10
|
+
onNavigate: (targetPath: string, anchor: string | null) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function MarkdownPreview({
|
|
14
|
+
markdown,
|
|
15
|
+
pagePath,
|
|
16
|
+
onNavigate,
|
|
17
|
+
}: MarkdownPreviewProps) {
|
|
18
|
+
const [html, setHtml] = useState<string>("");
|
|
19
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
20
|
+
|
|
21
|
+
// Recompute HTML whenever markdown or pagePath changes.
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
let cancelled = false;
|
|
24
|
+
markdownToHtml(markdown, { pagePath, sanitize: true }).then((result) => {
|
|
25
|
+
if (!cancelled) setHtml(result);
|
|
26
|
+
});
|
|
27
|
+
return () => {
|
|
28
|
+
cancelled = true;
|
|
29
|
+
};
|
|
30
|
+
}, [markdown, pagePath]);
|
|
31
|
+
|
|
32
|
+
// Delegated click handler: wiki-links navigate; external links get _blank.
|
|
33
|
+
const handleClick = useCallback(
|
|
34
|
+
(e: MouseEvent) => {
|
|
35
|
+
const link = (e.target as HTMLElement).closest(
|
|
36
|
+
"a",
|
|
37
|
+
) as HTMLAnchorElement | null;
|
|
38
|
+
if (!link) return;
|
|
39
|
+
|
|
40
|
+
if (link.dataset.wikiLink === "true") {
|
|
41
|
+
e.preventDefault();
|
|
42
|
+
const slug = link.dataset.slug ?? "";
|
|
43
|
+
if (!slug) return;
|
|
44
|
+
const dir = useWikiSlugsStore.getState().getDir(slug);
|
|
45
|
+
if (!dir) return; // broken link - no nav
|
|
46
|
+
const targetPath =
|
|
47
|
+
dir === "root" ? `${slug}.md` : `${dir}/${slug}.md`;
|
|
48
|
+
const anchor = link.dataset.anchor ?? null;
|
|
49
|
+
onNavigate(targetPath, anchor);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// External links: ensure _blank + noreferrer.
|
|
54
|
+
const href = link.getAttribute("href") ?? "";
|
|
55
|
+
if (/^https?:\/\//.test(href)) {
|
|
56
|
+
if (!link.target) {
|
|
57
|
+
link.setAttribute("target", "_blank");
|
|
58
|
+
link.setAttribute("rel", "noreferrer");
|
|
59
|
+
}
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Anchor-only hrefs: let browser scroll natively.
|
|
64
|
+
},
|
|
65
|
+
[onNavigate],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
// Attach and detach click handler whenever html or handler changes.
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
const el = containerRef.current;
|
|
71
|
+
if (!el) return;
|
|
72
|
+
// Set _blank on external links at bind time so they work without JS.
|
|
73
|
+
el.querySelectorAll<HTMLAnchorElement>("a[href^='http']").forEach((a) => {
|
|
74
|
+
if (!a.target) {
|
|
75
|
+
a.setAttribute("target", "_blank");
|
|
76
|
+
a.setAttribute("rel", "noreferrer");
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
el.addEventListener("click", handleClick);
|
|
80
|
+
return () => el.removeEventListener("click", handleClick);
|
|
81
|
+
}, [html, handleClick]);
|
|
82
|
+
|
|
83
|
+
// Apply broken-link styling after render and whenever slug store updates.
|
|
84
|
+
const slugsLoadedAt = useWikiSlugsStore((s) => s.loadedAt);
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
const el = containerRef.current;
|
|
87
|
+
if (!el) return;
|
|
88
|
+
const store = useWikiSlugsStore.getState();
|
|
89
|
+
el.querySelectorAll<HTMLAnchorElement>(
|
|
90
|
+
'a[data-wiki-link="true"]',
|
|
91
|
+
).forEach((link) => {
|
|
92
|
+
const slug = link.dataset.slug ?? "";
|
|
93
|
+
if (slug && !store.has(slug)) {
|
|
94
|
+
link.dataset.broken = "true";
|
|
95
|
+
} else {
|
|
96
|
+
delete link.dataset.broken;
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}, [html, slugsLoadedAt]);
|
|
100
|
+
|
|
101
|
+
// Disable task-list checkboxes in read-only viewer.
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const el = containerRef.current;
|
|
104
|
+
if (!el) return;
|
|
105
|
+
el.querySelectorAll<HTMLInputElement>(
|
|
106
|
+
'input[type="checkbox"]',
|
|
107
|
+
).forEach((input) => {
|
|
108
|
+
input.disabled = true;
|
|
109
|
+
});
|
|
110
|
+
}, [html]);
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div
|
|
114
|
+
ref={containerRef}
|
|
115
|
+
className="tiptap md-preview"
|
|
116
|
+
// biome-ignore lint/security/noDangerouslySetInnerHtml: HTML is sanitized by rehype-sanitize
|
|
117
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
118
|
+
/>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email allowlist check.
|
|
3
|
+
*
|
|
4
|
+
* Source of truth is ~/.wiki-viewer/config.json (allowedEmails / allowedDomains),
|
|
5
|
+
* editable at runtime via the UI. Env vars AUTH_ALLOWED_EMAILS and
|
|
6
|
+
* AUTH_ALLOWED_DOMAIN act as a fallback seed when config is empty, so existing
|
|
7
|
+
* deployments keep working without a config file.
|
|
8
|
+
*
|
|
9
|
+
* If neither config nor env sets any restriction, all emails are allowed.
|
|
10
|
+
*/
|
|
11
|
+
import { readConfig } from "@/lib/config";
|
|
12
|
+
|
|
13
|
+
function splitList(raw: string | undefined): string[] {
|
|
14
|
+
return (raw ?? "")
|
|
15
|
+
.split(",")
|
|
16
|
+
.map((s) => s.trim().toLowerCase())
|
|
17
|
+
.filter(Boolean);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Resolve the effective allowlist: config wins, env is the fallback. */
|
|
21
|
+
export async function getAllowlist(): Promise<{
|
|
22
|
+
emails: string[];
|
|
23
|
+
domains: string[];
|
|
24
|
+
}> {
|
|
25
|
+
const config = await readConfig();
|
|
26
|
+
const emails =
|
|
27
|
+
config.allowedEmails && config.allowedEmails.length > 0
|
|
28
|
+
? config.allowedEmails.map((s) => s.trim().toLowerCase()).filter(Boolean)
|
|
29
|
+
: splitList(process.env.AUTH_ALLOWED_EMAILS);
|
|
30
|
+
const domains =
|
|
31
|
+
config.allowedDomains && config.allowedDomains.length > 0
|
|
32
|
+
? config.allowedDomains.map((s) => s.trim().toLowerCase()).filter(Boolean)
|
|
33
|
+
: splitList(process.env.AUTH_ALLOWED_DOMAIN);
|
|
34
|
+
return { emails, domains };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function isEmailAllowed(email: string): Promise<boolean> {
|
|
38
|
+
const { emails, domains } = await getAllowlist();
|
|
39
|
+
|
|
40
|
+
// No restrictions set — allow all
|
|
41
|
+
if (emails.length === 0 && domains.length === 0) return true;
|
|
42
|
+
|
|
43
|
+
const e = email.toLowerCase();
|
|
44
|
+
if (emails.includes(e)) return true;
|
|
45
|
+
|
|
46
|
+
const at = e.lastIndexOf("@");
|
|
47
|
+
if (at >= 0 && domains.includes(e.slice(at + 1))) return true;
|
|
48
|
+
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reject cross-origin state-changing requests that arrive with a session cookie.
|
|
5
|
+
* Bearer-only requests (no Origin/Referer) pass through — they are authenticated
|
|
6
|
+
* separately and are not vulnerable to CSRF.
|
|
7
|
+
*
|
|
8
|
+
* Returns null if the request may proceed, or a NextResponse(403) to return.
|
|
9
|
+
*
|
|
10
|
+
* Call at the top of every POST/PUT/DELETE/PATCH handler in /api/wiki/* and
|
|
11
|
+
* /api/system/* before requireUser.
|
|
12
|
+
*/
|
|
13
|
+
export function checkOrigin(req: Request): NextResponse | null {
|
|
14
|
+
const method = req.method.toUpperCase();
|
|
15
|
+
if (method === "GET" || method === "HEAD" || method === "OPTIONS") return null;
|
|
16
|
+
|
|
17
|
+
// Bearer-auth agents typically omit Origin/Referer — let checkAuth handle them.
|
|
18
|
+
const origin = req.headers.get("origin");
|
|
19
|
+
const referer = req.headers.get("referer");
|
|
20
|
+
if (!origin && !referer) return null;
|
|
21
|
+
|
|
22
|
+
const allowed = buildAllowedOrigins(req.headers.get("host") ?? "");
|
|
23
|
+
|
|
24
|
+
if (origin) {
|
|
25
|
+
if (allowed.has(origin)) return null;
|
|
26
|
+
return NextResponse.json(
|
|
27
|
+
{ error: "FORBIDDEN", message: "Bad origin" },
|
|
28
|
+
{ status: 403 },
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (referer) {
|
|
33
|
+
try {
|
|
34
|
+
const { protocol, host } = new URL(referer);
|
|
35
|
+
if (allowed.has(`${protocol}//${host}`)) return null;
|
|
36
|
+
} catch {
|
|
37
|
+
// malformed Referer — reject
|
|
38
|
+
}
|
|
39
|
+
return NextResponse.json(
|
|
40
|
+
{ error: "FORBIDDEN", message: "Bad referer" },
|
|
41
|
+
{ status: 403 },
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function buildAllowedOrigins(hostHeader: string): Set<string> {
|
|
49
|
+
const hostname = hostHeader.split(":")[0];
|
|
50
|
+
const extra = (process.env.WIKI_OWNER_HOSTS ?? "")
|
|
51
|
+
.split(",")
|
|
52
|
+
.map((h) => h.trim())
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
|
|
55
|
+
const hosts = Array.from(new Set(["localhost", "127.0.0.1", hostname, ...extra]));
|
|
56
|
+
const ports = ["", ":3000", ":3003"];
|
|
57
|
+
if (hostHeader.includes(":")) ports.push(":" + hostHeader.split(":")[1]);
|
|
58
|
+
|
|
59
|
+
const allowed = new Set<string>();
|
|
60
|
+
for (const h of hosts) {
|
|
61
|
+
for (const proto of ["http", "https"]) {
|
|
62
|
+
for (const port of ports) {
|
|
63
|
+
allowed.add(`${proto}://${h}${port}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return allowed;
|
|
68
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Better Auth server instance.
|
|
3
|
+
* Supports: email+password, Google OAuth (optional).
|
|
4
|
+
* DB: SQLite at ~/.wiki-viewer/auth.db (WAL mode).
|
|
5
|
+
*/
|
|
6
|
+
import { betterAuth } from "better-auth";
|
|
7
|
+
import { nextCookies } from "better-auth/next-js";
|
|
8
|
+
import Database from "better-sqlite3";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { isEmailAllowed } from "./allowlist";
|
|
14
|
+
|
|
15
|
+
// Guard runs at server startup, not during next build (NEXT_PHASE=phase-production-build).
|
|
16
|
+
// Set WIKI_ALLOW_INSECURE=1 to bypass the https requirement (development / CI / smoke tests).
|
|
17
|
+
if (
|
|
18
|
+
process.env.NODE_ENV === "production" &&
|
|
19
|
+
process.env.NEXT_PHASE !== "phase-production-build" &&
|
|
20
|
+
process.env.WIKI_ALLOW_INSECURE !== "1"
|
|
21
|
+
) {
|
|
22
|
+
const url = process.env.BETTER_AUTH_URL ?? "";
|
|
23
|
+
if (!url) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
"BETTER_AUTH_URL is required in production. Set it to https://your-domain so cookies and OAuth callbacks resolve correctly. Set WIKI_ALLOW_INSECURE=1 to bypass (development only).",
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (!url.startsWith("https://")) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`BETTER_AUTH_URL must be https:// in production (got: ${url}). Set WIKI_ALLOW_INSECURE=1 to bypass (development only).`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DATA_DIR = path.join(process.env.HOME ?? os.homedir(), ".wiki-viewer");
|
|
36
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
37
|
+
const DB_PATH = path.join(DATA_DIR, "auth.db");
|
|
38
|
+
const SECRET_PATH = path.join(DATA_DIR, "auth.secret");
|
|
39
|
+
|
|
40
|
+
function resolveSecret(): string {
|
|
41
|
+
if (process.env.BETTER_AUTH_SECRET) return process.env.BETTER_AUTH_SECRET;
|
|
42
|
+
if (existsSync(SECRET_PATH)) {
|
|
43
|
+
return readFileSync(SECRET_PATH, "utf-8").trim();
|
|
44
|
+
}
|
|
45
|
+
const fresh = randomBytes(32).toString("base64");
|
|
46
|
+
writeFileSync(SECRET_PATH, fresh, { mode: 0o600 });
|
|
47
|
+
try {
|
|
48
|
+
chmodSync(SECRET_PATH, 0o600);
|
|
49
|
+
} catch {
|
|
50
|
+
// chmod best-effort on platforms that support it
|
|
51
|
+
}
|
|
52
|
+
return fresh;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const db = new Database(DB_PATH);
|
|
56
|
+
db.pragma("journal_mode = WAL");
|
|
57
|
+
|
|
58
|
+
function getTrustedOrigins(): string[] {
|
|
59
|
+
const extra = (process.env.WIKI_OWNER_HOSTS ?? "")
|
|
60
|
+
.split(",")
|
|
61
|
+
.map((h) => h.trim())
|
|
62
|
+
.filter(Boolean);
|
|
63
|
+
const ports = ["", ":3000", ":3003"];
|
|
64
|
+
const base = ["http://localhost:3000", "http://localhost:3003"];
|
|
65
|
+
const extras = extra.flatMap((h) =>
|
|
66
|
+
ports.flatMap((p) => [`http://${h}${p}`, `https://${h}${p}`]),
|
|
67
|
+
);
|
|
68
|
+
return Array.from(new Set([...base, ...extras]));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const socialProviders: Record<string, unknown> = {};
|
|
72
|
+
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
|
73
|
+
socialProviders.google = {
|
|
74
|
+
clientId: process.env.GOOGLE_CLIENT_ID,
|
|
75
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const auth = betterAuth({
|
|
80
|
+
database: db,
|
|
81
|
+
secret: resolveSecret(),
|
|
82
|
+
baseURL: process.env.BETTER_AUTH_URL,
|
|
83
|
+
emailAndPassword: {
|
|
84
|
+
enabled: true,
|
|
85
|
+
requireEmailVerification: false,
|
|
86
|
+
autoSignIn: true,
|
|
87
|
+
},
|
|
88
|
+
// Loosen Better Auth's default 3-per-10-seconds limit on /sign-in/email
|
|
89
|
+
// so a single bad password doesn't lock the user out for the window.
|
|
90
|
+
// Per-user lockout is not implemented (Better Auth rate-limits by IP).
|
|
91
|
+
rateLimit: {
|
|
92
|
+
enabled: process.env.NODE_ENV === "production",
|
|
93
|
+
window: 60,
|
|
94
|
+
max: 100,
|
|
95
|
+
customRules: {
|
|
96
|
+
"/sign-in/email": { window: 60, max: 20 },
|
|
97
|
+
"/sign-up/email": { window: 60, max: 10 },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
socialProviders,
|
|
101
|
+
databaseHooks: {
|
|
102
|
+
user: {
|
|
103
|
+
create: {
|
|
104
|
+
before: async (user: { email: string }) => {
|
|
105
|
+
if (!(await isEmailAllowed(user.email))) {
|
|
106
|
+
throw new Error("SIGNUP_NOT_ALLOWED");
|
|
107
|
+
}
|
|
108
|
+
return { data: user };
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
plugins: [nextCookies()],
|
|
114
|
+
trustedOrigins: getTrustedOrigins(),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// Auto-migrate tables on first import. Idempotent: re-runs are no-ops once schema matches.
|
|
118
|
+
// Exposed as a Promise so consumers can await readiness before issuing auth calls.
|
|
119
|
+
const MIGRATED_PROMISE_KEY = Symbol.for("wiki-viewer.better-auth.migration-promise");
|
|
120
|
+
interface GlobalWithPromise {
|
|
121
|
+
[k: symbol]: Promise<void> | undefined;
|
|
122
|
+
}
|
|
123
|
+
const g = globalThis as unknown as GlobalWithPromise;
|
|
124
|
+
if (!g[MIGRATED_PROMISE_KEY]) {
|
|
125
|
+
g[MIGRATED_PROMISE_KEY] = (async () => {
|
|
126
|
+
const mod = (await import("better-auth/db/migration")) as {
|
|
127
|
+
getMigrations: (opts: unknown) => Promise<{ runMigrations: () => Promise<void> }>;
|
|
128
|
+
};
|
|
129
|
+
const { runMigrations } = await mod.getMigrations(
|
|
130
|
+
(auth as unknown as { options: unknown }).options,
|
|
131
|
+
);
|
|
132
|
+
await runMigrations();
|
|
133
|
+
})();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Await this in tests / one-shot scripts before calling auth.api.*. */
|
|
137
|
+
export function authReady(): Promise<void> {
|
|
138
|
+
return g[MIGRATED_PROMISE_KEY] ?? Promise.resolve();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface SessionUser {
|
|
142
|
+
id: string;
|
|
143
|
+
email: string;
|
|
144
|
+
name: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function getSessionFromRequest(req: Request) {
|
|
148
|
+
return auth.api.getSession({ headers: req.headers });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function requireUser(
|
|
152
|
+
req: Request,
|
|
153
|
+
): Promise<{ ok: true; user: SessionUser } | { ok: false }> {
|
|
154
|
+
const session = await getSessionFromRequest(req);
|
|
155
|
+
if (!session?.user) return { ok: false };
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
user: {
|
|
159
|
+
id: session.user.id,
|
|
160
|
+
email: session.user.email,
|
|
161
|
+
name: session.user.name,
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -5,6 +5,10 @@ import path from "node:path";
|
|
|
5
5
|
interface WikiViewerConfig {
|
|
6
6
|
pinnedPaths?: string[];
|
|
7
7
|
lastOpenedPath?: string;
|
|
8
|
+
/** Email allowlist for signup. Empty/undefined = no email restriction. */
|
|
9
|
+
allowedEmails?: string[];
|
|
10
|
+
/** Domain allowlist for signup. Empty/undefined = no domain restriction. */
|
|
11
|
+
allowedDomains?: string[];
|
|
8
12
|
}
|
|
9
13
|
|
|
10
14
|
function configPath() {
|
|
@@ -41,6 +41,19 @@ export function parseFrontmatter(text: string): ParsedFrontmatter {
|
|
|
41
41
|
|
|
42
42
|
const block = match[1];
|
|
43
43
|
const body = text.slice(match[0].length);
|
|
44
|
+
|
|
45
|
+
// Guard against runaway capture. BLOCK_RE matches from the opening `---`
|
|
46
|
+
// to the FIRST `\n---\n`, which may be a markdown horizontal rule far down
|
|
47
|
+
// the document rather than a real closing fence. If the block does not look
|
|
48
|
+
// like YAML (a markdown heading or HTML on the first non-empty line, or a
|
|
49
|
+
// blank line gap typical of body content), bail out and treat the whole
|
|
50
|
+
// text as body so the document still renders.
|
|
51
|
+
const blockLines = block.split(/\r?\n/);
|
|
52
|
+
const firstContent = blockLines.find((l) => l.trim() !== "") ?? "";
|
|
53
|
+
if (/^\s*(#{1,6}\s|<|>|-\s|\*\s|\d+\.\s)/.test(firstContent)) {
|
|
54
|
+
return { data: {}, body: text };
|
|
55
|
+
}
|
|
56
|
+
|
|
44
57
|
const data: Record<string, unknown> = {};
|
|
45
58
|
|
|
46
59
|
const lines = block.split(/\r?\n/);
|
|
@@ -80,5 +93,12 @@ export function parseFrontmatter(text: string): ParsedFrontmatter {
|
|
|
80
93
|
i += 1;
|
|
81
94
|
}
|
|
82
95
|
|
|
96
|
+
// A valid frontmatter block always yields at least one key. Zero keys means
|
|
97
|
+
// BLOCK_RE consumed body content up to a stray `---` (e.g. a horizontal
|
|
98
|
+
// rule). Reject so the body is not swallowed.
|
|
99
|
+
if (Object.keys(data).length === 0) {
|
|
100
|
+
return { data: {}, body: text };
|
|
101
|
+
}
|
|
102
|
+
|
|
83
103
|
return { data, body };
|
|
84
104
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { defaultSchema } from "rehype-sanitize";
|
|
2
|
+
import type { Options as SanitizeOptions } from "rehype-sanitize";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Sanitize schema for the read-only markdown preview.
|
|
6
|
+
*
|
|
7
|
+
* The base `defaultSchema` from rehype-sanitize strips most attributes and
|
|
8
|
+
* many elements. Wiki content legitimately embeds raw HTML: tables, task-list
|
|
9
|
+
* checkboxes, and video/embed iframes produced by the shared pipeline.
|
|
10
|
+
* Extend the schema so that markup renders instead of being escaped to literal
|
|
11
|
+
* text, while still removing scripts, event handlers, and other dangerous
|
|
12
|
+
* vectors that defaultSchema blocks.
|
|
13
|
+
*/
|
|
14
|
+
const tableTags = ["table", "thead", "tbody", "tfoot", "tr", "th", "td", "colgroup", "col"];
|
|
15
|
+
|
|
16
|
+
export const previewSanitizeSchema: SanitizeOptions = {
|
|
17
|
+
...defaultSchema,
|
|
18
|
+
tagNames: Array.from(
|
|
19
|
+
new Set([
|
|
20
|
+
...(defaultSchema.tagNames ?? []),
|
|
21
|
+
...tableTags,
|
|
22
|
+
// Task-list structure emitted by fixTaskListHtml.
|
|
23
|
+
"label",
|
|
24
|
+
// Embed wrapper divs and iframes from upgradeProviderVideos.
|
|
25
|
+
"iframe",
|
|
26
|
+
"video",
|
|
27
|
+
]),
|
|
28
|
+
),
|
|
29
|
+
attributes: {
|
|
30
|
+
...defaultSchema.attributes,
|
|
31
|
+
// Allow layout attributes on table elements.
|
|
32
|
+
table: [...(defaultSchema.attributes?.table ?? []), "className", "style"],
|
|
33
|
+
th: [
|
|
34
|
+
...(defaultSchema.attributes?.th ?? []),
|
|
35
|
+
"colSpan",
|
|
36
|
+
"rowSpan",
|
|
37
|
+
"style",
|
|
38
|
+
"className",
|
|
39
|
+
],
|
|
40
|
+
td: [
|
|
41
|
+
...(defaultSchema.attributes?.td ?? []),
|
|
42
|
+
"colSpan",
|
|
43
|
+
"rowSpan",
|
|
44
|
+
"style",
|
|
45
|
+
"className",
|
|
46
|
+
],
|
|
47
|
+
col: ["span", "style", "className"],
|
|
48
|
+
colgroup: ["span", "style", "className"],
|
|
49
|
+
tr: ["style", "className"],
|
|
50
|
+
// Preserve wiki-link data attributes used by the delegated click handler.
|
|
51
|
+
// rehype-sanitize uses hast property names (camelCase) in the attributes map.
|
|
52
|
+
// Plain string = allow any value; tuple [name, val1, val2] = allow only those values.
|
|
53
|
+
a: [
|
|
54
|
+
...(defaultSchema.attributes?.a ?? []),
|
|
55
|
+
"className",
|
|
56
|
+
["dataWikiLink", "true"],
|
|
57
|
+
"dataSlug",
|
|
58
|
+
"dataAlias",
|
|
59
|
+
"dataAnchor",
|
|
60
|
+
["dataBroken", "true"],
|
|
61
|
+
["dataPdfLink", "true"],
|
|
62
|
+
],
|
|
63
|
+
// Task-list input/label.
|
|
64
|
+
input: ["type", "checked", "disabled"],
|
|
65
|
+
label: ["className"],
|
|
66
|
+
// Embed iframes - allow all attrs used by upgradeProviderVideos.
|
|
67
|
+
iframe: [
|
|
68
|
+
"src",
|
|
69
|
+
"allow",
|
|
70
|
+
"allowFullScreen",
|
|
71
|
+
"frameBorder",
|
|
72
|
+
"loading",
|
|
73
|
+
"referrerPolicy",
|
|
74
|
+
"dataEmbedProvider",
|
|
75
|
+
],
|
|
76
|
+
// Embed wrapper divs.
|
|
77
|
+
div: [
|
|
78
|
+
...(defaultSchema.attributes?.div ?? []),
|
|
79
|
+
"className",
|
|
80
|
+
["dataEmbed", "true"],
|
|
81
|
+
"dataProvider",
|
|
82
|
+
"dataSrc",
|
|
83
|
+
"dataOriginalUrl",
|
|
84
|
+
"dataAspectRatio",
|
|
85
|
+
],
|
|
86
|
+
// Task-list list items.
|
|
87
|
+
li: [
|
|
88
|
+
...(defaultSchema.attributes?.li ?? []),
|
|
89
|
+
"className",
|
|
90
|
+
"dataType",
|
|
91
|
+
"dataChecked",
|
|
92
|
+
],
|
|
93
|
+
ul: [
|
|
94
|
+
...(defaultSchema.attributes?.ul ?? []),
|
|
95
|
+
"className",
|
|
96
|
+
"dataType",
|
|
97
|
+
],
|
|
98
|
+
// Allow class names + id on all elements for styling.
|
|
99
|
+
"*": [...(defaultSchema.attributes?.["*"] ?? []), "className", "id"],
|
|
100
|
+
},
|
|
101
|
+
// Allow https iframe src (for embed providers). http excluded intentionally.
|
|
102
|
+
protocols: {
|
|
103
|
+
...defaultSchema.protocols,
|
|
104
|
+
src: [...(defaultSchema.protocols?.src ?? []), "https"],
|
|
105
|
+
},
|
|
106
|
+
};
|
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import rehypeParse from "rehype-parse";
|
|
2
|
+
import rehypeRaw from "rehype-raw";
|
|
3
|
+
import rehypeSanitize from "rehype-sanitize";
|
|
1
4
|
import rehypeStringify from "rehype-stringify";
|
|
2
5
|
import remarkGfm from "remark-gfm";
|
|
3
6
|
import remarkParse from "remark-parse";
|
|
4
7
|
import remarkRehype from "remark-rehype";
|
|
5
8
|
import { unified } from "unified";
|
|
6
9
|
import { detectEmbed } from "@/lib/embeds/detect";
|
|
10
|
+
import { previewSanitizeSchema } from "@/lib/markdown/sanitize-schema";
|
|
7
11
|
|
|
8
12
|
// Canonical wiki-link regex (llm-wiki-pm ground truth).
|
|
9
13
|
// Groups: 1=slug 2=alias 3=anchor
|
|
@@ -133,8 +137,11 @@ function resolveRelativeUrls(html: string, pagePath: string): string {
|
|
|
133
137
|
}
|
|
134
138
|
|
|
135
139
|
// Unified's plugin resolution + processor freeze runs on every `unified()`
|
|
136
|
-
// call. Reuse
|
|
140
|
+
// call. Reuse single frozen pipelines across every page render so
|
|
137
141
|
// navigation doesn't pay that cost on the hot path.
|
|
142
|
+
|
|
143
|
+
// Base pipeline: produces HTML with raw nodes passed through as-is.
|
|
144
|
+
// Used for both editor and (pre-sanitize) viewer passes.
|
|
138
145
|
const processor = unified()
|
|
139
146
|
.use(remarkParse)
|
|
140
147
|
.use(remarkGfm)
|
|
@@ -142,26 +149,57 @@ const processor = unified()
|
|
|
142
149
|
.use(rehypeStringify, { allowDangerousHtml: true })
|
|
143
150
|
.freeze();
|
|
144
151
|
|
|
152
|
+
// Sanitize-only pipeline: takes a fully assembled HTML string, parses it back
|
|
153
|
+
// into hast (rehype-parse as a fragment), expands any raw nodes (rehype-raw),
|
|
154
|
+
// then strips unsafe nodes (rehype-sanitize). Runs LAST so all string
|
|
155
|
+
// post-processing is covered by sanitize.
|
|
156
|
+
const sanitizerOnly = unified()
|
|
157
|
+
.use(rehypeParse, { fragment: true })
|
|
158
|
+
.use(rehypeRaw)
|
|
159
|
+
.use(rehypeSanitize, previewSanitizeSchema)
|
|
160
|
+
.use(rehypeStringify)
|
|
161
|
+
.freeze();
|
|
162
|
+
|
|
163
|
+
export interface MarkdownToHtmlOptions {
|
|
164
|
+
/** File path used to resolve relative URLs (./image.png etc.). */
|
|
165
|
+
pagePath?: string;
|
|
166
|
+
/** Run rehype-sanitize on output. Use true for read-only viewer. Default false. */
|
|
167
|
+
sanitize?: boolean;
|
|
168
|
+
}
|
|
169
|
+
|
|
145
170
|
export async function markdownToHtml(
|
|
146
171
|
markdown: string,
|
|
147
|
-
|
|
172
|
+
optsOrPagePath?: string | MarkdownToHtmlOptions,
|
|
148
173
|
): Promise<string> {
|
|
174
|
+
const opts: MarkdownToHtmlOptions =
|
|
175
|
+
typeof optsOrPagePath === "string"
|
|
176
|
+
? { pagePath: optsOrPagePath }
|
|
177
|
+
: (optsOrPagePath ?? {});
|
|
178
|
+
|
|
149
179
|
// Pre-process wiki-links before remark (which would treat [[ as text)
|
|
150
180
|
const preprocessed = convertWikiLinks(markdown);
|
|
151
181
|
|
|
182
|
+
// Always use the base pipeline first.
|
|
152
183
|
const result = await processor.process(preprocessed);
|
|
153
|
-
|
|
154
184
|
let html = String(result);
|
|
155
185
|
|
|
156
|
-
// Post-process task lists for Tiptap compatibility
|
|
186
|
+
// Post-process task lists for Tiptap compatibility.
|
|
157
187
|
html = fixTaskListHtml(html);
|
|
158
188
|
|
|
159
|
-
// Heal <video src="youtube-url"> into real iframe embeds
|
|
189
|
+
// Heal <video src="youtube-url"> into real iframe embeds.
|
|
190
|
+
// Must run before sanitize so <video src> attr is still present.
|
|
160
191
|
html = upgradeProviderVideos(html);
|
|
161
192
|
|
|
162
|
-
// Resolve relative URLs if page path is provided
|
|
163
|
-
|
|
164
|
-
|
|
193
|
+
// Resolve relative URLs if page path is provided.
|
|
194
|
+
// Must run before sanitize so interpolated paths are covered.
|
|
195
|
+
if (opts.pagePath) {
|
|
196
|
+
html = resolveRelativeUrls(html, opts.pagePath);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Sanitize last, after all string post-processing, so no injected
|
|
200
|
+
// content escapes the sanitizer.
|
|
201
|
+
if (opts.sanitize) {
|
|
202
|
+
html = String(await sanitizerOnly.process(html));
|
|
165
203
|
}
|
|
166
204
|
|
|
167
205
|
return html;
|
|
@@ -49,6 +49,20 @@ turndown.addRule("wikiLink", {
|
|
|
49
49
|
},
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
// Preserve <proof-span> provenance marks — must run before styledSpan so the
|
|
53
|
+
// custom tag is matched first. DOM uppercases custom tag names, so check PROOF-SPAN.
|
|
54
|
+
turndown.addRule("proofSpan", {
|
|
55
|
+
filter: (node) => node.nodeName === "PROOF-SPAN",
|
|
56
|
+
replacement: (content, node) => {
|
|
57
|
+
const el = node as HTMLElement;
|
|
58
|
+
const attrs: string[] = [];
|
|
59
|
+
for (const a of Array.from(el.attributes)) {
|
|
60
|
+
attrs.push(`${a.name}="${a.value.replace(/"/g, """)}"`);
|
|
61
|
+
}
|
|
62
|
+
return `<proof-span${attrs.length ? " " + attrs.join(" ") : ""}>${content}</proof-span>`;
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
52
66
|
// Preserve inline styled spans (text color, background color, font weight, etc.)
|
|
53
67
|
// so colors and highlights survive markdown roundtrip.
|
|
54
68
|
turndown.addRule("styledSpan", {
|