From 9ae88e3381a0000a3ef431766cbd9ba5c8213ee3 Mon Sep 17 00:00:00 2001 From: LuoQianhong Date: Wed, 21 Oct 2026 23:20:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E7=AC=94=E8=AE=B0=E6=9C=AC=E7=AB=99=E7=82=B9=E4=BD=93=E9=AA=8C?= =?UTF-8?q?=E4=B8=8E=E5=8F=91=E5=B8=83=E6=8E=A7=E5=88=B6=20(#634)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 公开笔记本站点新增长文导航、正文检索、逐站点排除和站点配置,保留实时分享以及原有 Token、密码和解锁 Cookie。收录只对无密码的别名页生效,Token 页保持完整 noindex。 迁移 0072 为 notebook_shares 增加站点列,并新增排除关系表。先执行迁移再部署。 Closes #591 --- apps/api/src/api-context.ts | 1 + apps/api/src/index.ts | 5 + apps/api/src/notebook-share-routes.test.mjs | 242 +++++ apps/api/src/notebook-share-routes.ts | 239 ++++- apps/api/src/notebook-site-page.ts | 74 ++ apps/api/src/notebook-site-service.ts | 39 + apps/web/src/app/App.tsx | 9 +- apps/web/src/components/MemoListPane.tsx | 2 + apps/web/src/components/MemoNotebookSites.tsx | 71 ++ .../components/NotebookSiteArticleTools.tsx | 128 +++ .../src/components/NotebookSiteCodeBlock.tsx | 27 + .../components/NotebookSiteManagementPage.tsx | 42 + .../components/NotebookSiteMemoBrowser.tsx | 58 ++ .../web/src/components/NotebookSiteSearch.tsx | 60 ++ .../src/components/NotebookSiteSettings.tsx | 90 ++ apps/web/src/components/NotebookTreeItem.tsx | 11 +- .../components/PublicNotebookSharePage.tsx | 32 +- .../web/src/components/PublicNotebookSite.tsx | 485 ++------- apps/web/src/components/PublicSharePage.tsx | 50 +- apps/web/src/components/ReadOnlyX6Diagram.tsx | 30 +- .../components/dialogs/ShareMemoDialog.tsx | 33 +- .../dialogs/ShareNotebookDialog.tsx | 31 +- apps/web/src/components/notebook-site.css | 7 + .../settings/NotebookShareManagement.tsx | 2 +- apps/web/vite.config.ts | 1 + .../2026-10-21-public-notebook-site.md | 76 ++ .../2026-10-21-public-notebook-site.zh-CN.md | 76 ++ docs/public-notebook-site-plan-ablation.json | 507 ++++++++++ docs/public-notebook-site-plan.md | 179 ++++ docs/public-notebook-site-plan.zh-CN.md | 179 ++++ .../public-notebook-site-schema-ablation.json | 947 ++++++++++++++++++ migrations/0072_public_notebook_sites.sql | 12 + packages/client/src/index.ts | 27 + packages/shared/src/i18n/en-US.ts | 72 +- packages/shared/src/i18n/ja.ts | 72 +- packages/shared/src/i18n/pl.ts | 72 +- packages/shared/src/i18n/zh-CN.ts | 72 +- packages/shared/src/index.ts | 1 + packages/shared/src/notebook-site.ts | 56 ++ packages/shared/src/sharing.ts | 5 + .../public-notebook-site-plan-ablation.mjs | 251 +++++ .../public-notebook-site-schema-ablation.mjs | 307 ++++++ scripts/self-hosted-server.mjs | 5 + scripts/wrangler-runner.mjs | 2 +- tests/e2e/public-notebook-indexing.spec.ts | 120 +++ wrangler.toml | 2 +- 46 files changed, 4297 insertions(+), 512 deletions(-) create mode 100644 apps/api/src/notebook-site-page.ts create mode 100644 apps/api/src/notebook-site-service.ts create mode 100644 apps/web/src/components/MemoNotebookSites.tsx create mode 100644 apps/web/src/components/NotebookSiteArticleTools.tsx create mode 100644 apps/web/src/components/NotebookSiteCodeBlock.tsx create mode 100644 apps/web/src/components/NotebookSiteManagementPage.tsx create mode 100644 apps/web/src/components/NotebookSiteMemoBrowser.tsx create mode 100644 apps/web/src/components/NotebookSiteSearch.tsx create mode 100644 apps/web/src/components/NotebookSiteSettings.tsx create mode 100644 apps/web/src/components/notebook-site.css create mode 100644 docs/changes/2026-10-21-public-notebook-site.md create mode 100644 docs/changes/2026-10-21-public-notebook-site.zh-CN.md create mode 100644 docs/public-notebook-site-plan-ablation.json create mode 100644 docs/public-notebook-site-plan.md create mode 100644 docs/public-notebook-site-plan.zh-CN.md create mode 100644 docs/public-notebook-site-schema-ablation.json create mode 100644 migrations/0072_public_notebook_sites.sql create mode 100644 packages/shared/src/notebook-site.ts create mode 100644 scripts/public-notebook-site-plan-ablation.mjs create mode 100644 scripts/public-notebook-site-schema-ablation.mjs create mode 100644 tests/e2e/public-notebook-indexing.spec.ts diff --git a/apps/api/src/api-context.ts b/apps/api/src/api-context.ts index 7b47e15b..f10dccac 100644 --- a/apps/api/src/api-context.ts +++ b/apps/api/src/api-context.ts @@ -39,6 +39,7 @@ export type Bindings = { }; export type WorkerBindings = Omit & CloudflareStorageBindings & { + ASSETS: Fetcher; CF_VERSION_METADATA?: { timestamp?: string }; }; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 41072f69..74c1c62f 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -120,6 +120,7 @@ import { } from "./user-routes"; import { registerNotebookRoutes } from "./notebook-routes"; import { registerNotebookShareRoutes, registerPublicNotebookShareRoutes } from "./notebook-share-routes"; +import { isNotebookSitePagePath, renderNotebookSitePage } from "./notebook-site-page"; import { registerMemoShareRoutes, registerPublicShareRoutes } from "./share-routes"; import { registerPublicTableFormRoutes, registerTableFormRoutes } from "./table-form-routes"; import { @@ -454,6 +455,10 @@ export const fetchEdgeEverApp = async ( const worker = { async fetch(request: Request, env: WorkerBindings, ctx: ExecutionContext) { + if (isNotebookSitePagePath(new URL(request.url).pathname)) { + return (await renderNotebookSitePage(request, createCloudflareStorageAdapter(env).db, + () => env.ASSETS.fetch(new Request(new URL("/", request.url), { method: "GET" }))))!; + } return fetchEdgeEverApp(request, { ...env, storage: createCloudflareStorageAdapter(env), diff --git a/apps/api/src/notebook-share-routes.test.mjs b/apps/api/src/notebook-share-routes.test.mjs index a3efbbef..66b880e1 100644 --- a/apps/api/src/notebook-share-routes.test.mjs +++ b/apps/api/src/notebook-share-routes.test.mjs @@ -9,6 +9,8 @@ import { } from "./notebook-share-routes"; import { hashPassword } from "./auth-crypto"; import { registerPublicShareRoutes } from "./share-routes"; +import { renderNotebookSitePage } from "./notebook-site-page"; +import { PUBLIC_PAGE_ROBOTS } from "@edgeever/shared/public-page-headers"; const databases = []; afterEach(() => databases.splice(0).forEach((db) => db.close())); @@ -78,6 +80,8 @@ const fixture = (legacyHash) => { db.exec(readFileSync("migrations/0068_notebook_share_passwords.sql", "utf8")); if (legacyHash) db.run("UPDATE notebook_shares SET password_hash = ?", [legacyHash]); db.exec(readFileSync("migrations/0069_share_password_encryption.sql", "utf8")); + for (const file of migrations.filter((file) => file > "migrations/0069_share_password_encryption.sql")) + db.exec(readFileSync(file, "utf8")); const environment = { EDGE_EVER_CREDENTIALS_ENCRYPTION_KEY: "test-share-master-key", storage: createSelfHostedStorageAdapter( @@ -120,6 +124,244 @@ const fixture = (legacyHash) => { return { db, app, environment, request, addMemo, blobReads: () => blobReads }; }; +const manage = (f, path, body, init = {}) => f.app.request(`/api/v1/notebooks/root/share${path}`, { + method: body === undefined ? "GET" : "PATCH", + ...(body === undefined ? {} : { body: JSON.stringify(body), headers: { "content-type": "application/json" } }), + ...init, +}, f.environment); + +describe("public notebook site controls", () => { + test("old share upgrade preserves scope, token, and password while settings patch is independent", async () => { + const f = fixture(await hashPassword("old-password")); + const before = f.db.query("SELECT token, password_hash FROM notebook_shares").get(); + const saved = await manage(f, "/site", { siteSlug: "My-Docs", settings: { footerText: "Example", sortMode: "title" } }); + expect(saved.status).toBe(200); + expect((await saved.json()).share.siteSlug).toBe("my-docs"); + await manage(f, "/site", { settings: { logoUrl: "https://example.org/logo.png" } }); + const { share } = await (await manage(f, "")).json(); + expect(share.siteSettings.footerText).toBe("Example"); + expect(share.siteSettings.allowIndexing).toBe(false); + expect(f.db.query("SELECT token, password_hash FROM notebook_shares").get()).toEqual(before); + expect((await f.request()).status).toBe(403); + const unlocked = await f.request("/unlock", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ password: "old-password" }) }); + const cookie = unlocked.headers.get("set-cookie").split(";")[0]; + const directory = await (await f.request("", { headers: { cookie } })).json(); + expect(directory.share.memos).toHaveLength(3); + }); + + test("exclusion gates every read path, keeps independent shares, and restores idempotently", async () => { + const f = fixture(); + const child = await f.app.request("/api/v1/notebooks/child/share", { method: "POST" }, f.environment); + const childToken = (await child.json()).share.token; + expect((await manage(f, "/memos/memo_child", { excluded: true })).status).toBe(200); + expect((await manage(f, "/memos/memo_child", { excluded: true })).status).toBe(200); + const directory = await (await f.request()).json(); + expect(directory.share.memos.map((m) => m.id)).not.toContain("memo_child"); + for (const path of ["/memos/memo_child", "/memos/memo_child/preview", "/resources/res_memo_child/blob"]) + expect((await f.request(path)).status).toBe(404); + expect(f.blobReads()).toBe(0); + const search = await (await f.request("/search?q=memo&limit=1")).json(); + expect(search.memos).toHaveLength(1); + expect(search.memos[0].id).not.toBe("memo_child"); + expect((await f.app.request(`/api/public/notebook-shares/${childToken}/memos/memo_child`, {}, f.environment)).status).toBe(200); + const excluded = await (await manage(f, "/excluded-memos")).json(); + expect(excluded.memos.map((m) => m.id)).toEqual(["memo_child"]); + f.db.run("UPDATE memos SET tags_json = '[\"draft\"]' WHERE id = 'memo_child'"); + f.db.run("UPDATE memos SET notebook_id = 'private' WHERE id = 'memo_child'"); + f.db.run("UPDATE memos SET notebook_id = 'child' WHERE id = 'memo_child'"); + expect((await f.request("/memos/memo_child")).status).toBe(404); + await manage(f, "/memos/memo_child", { excluded: false }); + await manage(f, "/memos/memo_child", { excluded: false }); + expect((await f.request("/memos/memo_child")).status).toBe(200); + expect((await f.request("/memos/memo_child/preview")).status).toBe(200); + }); + + test("original-note controls list live ancestor sites with independent exclusions and no new shares", async () => { + const f = fixture(); + await f.app.request("/api/v1/notebooks/child/share", { method: "POST" }, f.environment); + const sites = (id = "memo_child", init = {}) => f.app.request(`/api/v1/memos/${id}/notebook-sites`, init, f.environment); + const response = await sites(); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + expect((await response.json()).sites).toEqual([ + { notebookId: "child", notebookName: "child", excluded: false }, + { notebookId: "root", notebookName: "root", excluded: false }, + ]); + await manage(f, "/memos/memo_child", { excluded: true }); + expect((await (await sites()).json()).sites).toEqual([ + { notebookId: "child", notebookName: "child", excluded: false }, + { notebookId: "root", notebookName: "root", excluded: true }, + ]); + expect((await f.request("/memos/memo_child")).status).toBe(404); + const childToken = f.db.query("SELECT token FROM notebook_shares WHERE notebook_id = 'child'").get().token; + expect((await f.app.request(`/api/public/notebook-shares/${childToken}/memos/memo_child`, {}, f.environment)).status).toBe(200); + expect((await (await sites("memo_grand")).json()).sites).toHaveLength(2); + expect(f.db.query("SELECT COUNT(*) AS count FROM memo_shares").get().count).toBe(1); + expect((await (await sites("memo_private")).json()).sites).toEqual([]); + f.db.run("UPDATE memos SET notebook_id = 'private' WHERE id = 'memo_child'"); + f.db.run("UPDATE notebooks SET parent_id = 'foreign' WHERE id = 'private'"); + expect((await (await sites()).json()).sites).toEqual([]); + f.db.run("UPDATE notebooks SET is_deleted = 1 WHERE id = 'child'"); + expect((await (await sites("memo_grand")).json()).sites).toEqual([]); + for (const kind of ["none", "agent"]) + expect((await sites("memo_grand", { headers: { "test-auth": kind } })).status).toBe(403); + for (const id of ["missing", "memo_other"]) + expect((await sites(id)).status).toBe(404); + f.db.run("UPDATE memos SET is_deleted = 1 WHERE id = 'memo_grand'"); + expect((await sites("memo_grand")).status).toBe(404); + }); + + test("owner content management filters before paging and reports exclusions beyond the first page", async () => { + const f = fixture(); + for (let i = 0; i < 105; i++) f.addMemo(`bulk_${String(i).padStart(3, "0")}`, "child"); + await manage(f, "/memos/bulk_075", { excluded: true }); + await manage(f, "/memos/memo_child", { excluded: true }); + const firstResponse = await manage(f, "/memos?limit=20"); + expect(firstResponse.status).toBe(200); + const first = await firstResponse.json(); + expect(first.memos).toHaveLength(20); + expect(first.nextCursor).toBe("bulk_019"); + expect(first.memos.every((memo) => typeof memo.excluded === "boolean")).toBe(true); + expect(first.memos[0].contentJson).toBeUndefined(); + const later = await (await manage(f, "/memos?limit=20&cursor=bulk_074")).json(); + expect(later.memos[0]).toMatchObject({ id: "bulk_075", notebookId: "child", notebookName: "child", excluded: true }); + const excluded = await (await manage(f, "/memos?visibility=excluded&limit=1")).json(); + expect(excluded.memos.map((memo) => memo.id)).toEqual(["bulk_075"]); + const nextExcluded = await (await manage(f, `/memos?visibility=excluded&limit=1&cursor=${excluded.nextCursor}`)).json(); + expect(nextExcluded.memos.map((memo) => memo.id)).toEqual(["memo_child"]); + expect(nextExcluded.nextCursor).toBeNull(); + f.db.run("UPDATE memo_contents SET content_text = 'OwnerBodyNeedle 中文正文检索' WHERE memo_id = 'bulk_075'"); + for (const q of ["OwnerBodyNeedle", "正文检索"]) { + const found = await (await manage(f, `/memos?q=${encodeURIComponent(q)}`)).json(); + expect(found.memos.map((memo) => memo.id)).toEqual(["bulk_075"]); + const picker = await (await manage(f, `/memos?visibility=public&q=${encodeURIComponent(q)}`)).json(); + expect(picker.memos).toHaveLength(0); + } + expect((await manage(f, "/memos?visibility=invalid")).status).toBe(400); + for (const kind of ["none", "agent"]) + expect((await manage(f, "/memos", undefined, { headers: { "test-auth": kind } })).status).toBe(403); + expect((await f.app.request("/api/v1/notebooks/foreign/share/memos", {}, f.environment)).status).toBe(404); + f.db.run("UPDATE notebooks SET is_deleted = 1 WHERE id = 'root'"); + expect((await manage(f, "/memos")).status).toBe(404); + }); + + test("body and Chinese search filter before paging and combine with exact tags", async () => { + const f = fixture(); + for (const id of ["memo_root", "memo_child", "memo_grand"]) { + f.db.run("UPDATE memo_contents SET content_text = 'OnlyBodyWord 中文检索内容' WHERE memo_id = ?", [id]); + f.db.run("INSERT OR REPLACE INTO memo_search_documents (memo_id, title, content_text, tags) VALUES (?, ?, 'OnlyBodyWord 中文检索内容', '')", [id, id]); + } + f.db.run(`UPDATE memos SET tags_json = '["guide"]' WHERE id = 'memo_root'`); + await manage(f, "/memos/memo_child", { excluded: true }); + for (const q of ["OnlyBodyWord", "检索"]) { + const result = await (await f.request(`/search?q=${encodeURIComponent(q)}&limit=1`)).json(); + expect(result.memos).toHaveLength(1); + expect(result.memos[0].excerpt).toContain(q); + expect(result.nextCursor).not.toBeNull(); + const page2 = await (await f.request(`/search?q=${encodeURIComponent(q)}&limit=1&cursor=${result.nextCursor}`)).json(); + expect(page2.memos).toHaveLength(1); + expect(page2.memos[0].id).not.toBe(result.memos[0].id); + expect(page2.nextCursor).toBeNull(); + } + const tagged = await (await f.request("/search?q=OnlyBodyWord&tag=guide")).json(); + expect(tagged.memos.map((m) => m.id)).toEqual(["memo_root"]); + const longBody = "prefix ".repeat(90) + "Alpha different words Omega"; + f.db.run("UPDATE memo_contents SET content_text = ? WHERE memo_id = 'memo_root'", [longBody]); + f.db.run("UPDATE memo_search_documents SET content_text = ? WHERE memo_id = 'memo_root'", [longBody]); + const multiword = await (await f.request("/search?q=Alpha%20Omega")).json(); + expect(multiword.memos[0].excerpt).toContain("Alpha"); + expect(multiword.memos[0].excerpt).toContain("Omega"); + }); + + test("rejects invalid targets, unknown settings, non-HTTPS URLs and oversized merged settings", async () => { + const f = fixture(); + for (const id of ["memo_private", "memo_other", "missing"]) + expect((await manage(f, `/memos/${id}`, { excluded: true })).status).toBe(404); + for (const settings of [{ homeMemoId: "memo_private" }, { excludedTags: ["draft"] }, { logoUrl: "javascript:alert(1)" }, { logoUrl: "broken" }, { headerLinks: [{ label: "x", url: "broken" }] }, { headerLinks: [{ label: "x", url: "http://example.org" }] }, { footerText: "x".repeat(17_000) }]) + expect((await manage(f, "/site", { settings })).status).toBe(400); + expect((await manage(f, "/site", { settings: { footerText: "ok" } }, { headers: { "content-type": "application/json", "test-auth": "agent" } })).status).toBe(403); + expect((await manage(f, "/site", { settings: { footerText: "ok" } }, { headers: { "content-type": "application/json", "test-auth": "none" } })).status).toBe(403); + }); + + test("homepages fall back on exclusion and aliases disclose identity only", async () => { + const f = fixture(); + await manage(f, "/site", { siteSlug: "My-Docs", settings: { homeMemoId: "memo_child" } }); + expect((await (await f.request()).json()).share.settings.homeMemoId).toBe("memo_child"); + await manage(f, "/memos/memo_child", { excluded: true }); + expect((await (await f.request()).json()).share.settings.homeMemoId).toBeNull(); + expect((await manage(f, "/site", { settings: { homeMemoId: "memo_child" } })).status).toBe(400); + const alias = await f.app.request("/api/public/notebook-sites/my-docs/resolve", {}, f.environment); + expect(await alias.json()).toEqual({ token }); + const child = await f.app.request("/api/v1/notebooks/child/share", { method: "POST" }, f.environment); + expect(child.status).toBe(200); + const collision = await f.app.request("/api/v1/notebooks/child/share/site", { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ siteSlug: "MY-DOCS" }) }, f.environment); + expect(collision.status).toBe(409); + await f.app.request("/api/v1/notebooks/root/share", { method: "DELETE" }, f.environment); + expect((await f.app.request("/api/public/notebook-sites/my-docs/resolve", {}, f.environment)).status).toBe(404); + expect(f.db.query("SELECT count(*) AS count FROM notebook_share_excluded_memos").get().count).toBe(0); + }); + + test("enabling or rotating passwords disables indexing and clearing does not re-enable it", async () => { + const f = fixture(); + await manage(f, "/site", { settings: { allowIndexing: true } }); + expect((await (await f.request()).json()).share.settings.allowIndexing).toBe(true); + await manage(f, "", { passwordProtected: true }); + expect((await manage(f, "/site", { settings: { allowIndexing: true } })).status).toBe(400); + await manage(f, "", { passwordProtected: false }); + expect((await (await f.request()).json()).share.settings.allowIndexing).toBe(false); + }); + + test("server HTML escapes metadata, preserves protected privacy, and denies excluded aliases", async () => { + const f = fixture(); + f.db.run("UPDATE memos SET title = ?, excerpt = ? WHERE id = 'memo_child'", [" $& $' $$", 'A & B "quote"']); + await manage(f, "/site", { siteSlug: "docs", settings: { allowIndexing: true, homeMemoId: "memo_child" } }); + const render = (path) => renderNotebookSitePage(new Request(`https://example.org${path}`), f.environment.storage.db, + async () => new Response('Old
', { headers: { "content-type": "text/html" } })); + const visible = await render("/s/docs/memo_child"); + expect(visible.status).toBe(200); + expect(visible.headers.get("X-Robots-Tag")).toBe("index, follow"); + const html = await visible.text(); + expect(html).toContain("<script>secret()</script>"); + expect(html).toContain("<script>secret()</script> $& $' $$"); + expect(html).not.toContain("