iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
佛心分享-SideProject30

為你自己蓋一座會複利的知識庫——WikiBrain系列 第 26 篇

Day 26 - 開源選 AGPL,加上唯讀分享連結與 Obsidian 匯出

  • 分享至 

  • xImage
  •  

前言

這個產品開源,而且有一鍵匯出。你隨時可以自己跑,也隨時可以走。

公開到哪裡

公開的 repo 裡有三層知識庫、MCP 六工具、匯入、書目、引用、健檢、對話、模版、OAuth,也有帳務、寄信,以及 ops/backup/ 的備份腳本。不在這份樹裡的是營運後台。

閉源的話,「你會不會偷看我的資料」「你倒了我的東西怎麼辦」這兩個問題我沒有好答案。程式看得到,匯出又是 Obsidian 讀得了的 Markdown,這兩件事我答得了。沒有預算去投廣告,開發者會逛 GitHub。

自己架要租伺服器、設資料庫、弄憑證、顧備份,還要處理 OAuth 才能讓 Claude.ai 連進來。程式裡的 Pro 方案是一個月六美元,PRO_MONTH_CENTS 是 600。賣的是不用自己管這些。

AGPL 是授權上的限制:任何人可以拿去改。拿改過的版本做成線上服務、讓別人透過網路使用,就必須向那些使用者提供對應的源碼。收不收錢都一樣。

公開之前先清歷史

我把程式碼整理好準備公開,然後做了一次自我審查。找到的東西包括服務商的登入信箱、託管平台給的來源主機名、備份的儲存桶名稱、內部的測試矩陣。信箱和主機名是攻擊面,儲存桶名稱沒有公開的理由。

從最新版拿掉不夠,它們還在 git 歷史裡。要清得重寫歷史再強制推送。如果已經有人 fork,就永遠清不掉。所以要先決定什麼可以公開,再開始寫。我的順序反了,結果是把歷史重寫了一次。

程式碼公開之後,這個系列還是會貼片段。寫文章的 repo 有一支 check.yml,每次 push、每個 pull request 都跑 scripts/check.mjs,掃金鑰形狀、來源主機名、真實信箱、資料庫連線字串。它擋下來過一次:某一篇裡我順手貼了一段設定。

營運後台不在這份樹裡

後台整組不進開源版。伺服器這邊是一層動態載入:

const SPECIFIER = './ops-console.js';
let installed: OpsConsole | null = null;
try {
  installed = ((await import(SPECIFIER)) as { opsConsole: OpsConsole }).opsConsole ?? null;
} catch {
  installed = null; // open-source build: no operator console
}

/** The console, or null when this build does not include one. */
export const ops = (): OpsConsole | null => installed;

出處:src/ops.ts:27-36

字串放在變數裡,是為了不要讓編譯器在建置時就把那個模組解析掉。開源這份樹沒有 ops-console.ts,字面量的 import 會讓兩邊都編不起來。

/api/admin/status、/api/admin/capacity、/api/admin/registry 三條路由還是掛著。ops() 是 null 時 isAdmin 過不了,回應 404,訊息是 not found。另外兩條用同一個判斷。

api.get('/admin/status', async (_req, res) => {
  if (res.locals.viaToken || !ops()?.isAdmin((res.locals.session as Session).user.email)) { res.status(404).json({ error: 'NOT_FOUND', message: 'not found' }); return; }
  res.setHeader('Cache-Control', 'no-store');
  res.json(await ops()!.status());
});

出處:src/api.ts:358-362

前面講監控時的 ops()?.degraded(),問號是因為 ops() 可能是 null。開源這份樹裡沒有後台,/healthz 就不會多出後台那些原因。

前端用 glob 看 Admin.tsx 在不在。沒有這個檔案,/admin 那條路由就不註冊。

const adminPage = Object.values(import.meta.glob<{ default: ComponentType<{ me: Me }> }>('./pages/Admin.tsx', { eager: true }))[0]?.default ?? null;

出處:web/src/App.tsx:22

路由那一行是 App.tsx:60:adminPage 有值才掛 /admin。

分享連結的 token

分享連結是 /s/<token>,唯讀、不進目錄、一次一頁。

const newToken = () => 'wbs_' + randomBytes(16).toString('base64url');

出處:src/shares.ts:13

十六個隨機位元組,base64url 不補等號,是二十二個字元。沒有另外的帳號或權限,拿到網址就讀得到,拿不到就猜不中。想分享的人通常沒有這個產品的帳號,逼他們註冊才能看,這個功能就沒人用。

wbs_ 讓這串憑證掃得出來。掃日誌、掃截圖、掃文章草稿,沒有前綴的話它跟任何雜湊長得一樣。

讀取時先驗形狀

export async function readShared(token: string): Promise<(SharedNote & { workspace_id: string }) | null> {
  if (!/^wbs_[A-Za-z0-9_-]{16,32}$/.test(token)) return null;
  const { rows } = await pool.query<{ workspace_id: string; path: string; title: string; content_md: string; updated_at: Date }>(
    `SELECT n.workspace_id, n.path, n.title, n.content_md, n.updated_at FROM shares s JOIN notes n ON n.id = s.note_id
     WHERE s.token = $1 AND s.revoked_at IS NULL AND n.deleted_at IS NULL`, [token]);
  const r = rows[0]; if (!r) return null;
  return { workspace_id: r.workspace_id, path: r.path, title: r.title, content: r.content_md, updated_at: r.updated_at, layer: r.path.split('/')[0] as SharedNote['layer'] };
}

出處:src/shares.ts:37-44

/s/ 是公開路徑,形狀不對就回 null,不查資料庫。{16,32} 比剛好二十二寬,之後換長度不用先改這一行。

revoked_at IS NULL 和 deleted_at IS NULL 是兩種已經不該讀得到的狀態:分享撤銷了,或頁面軟刪了。頁面那條 API 帶 Cache-Control: no-store。撤銷之後,下一次讀頁面會重新查這一列,對不上就 404。shares 沒有到期欄位,也沒有另外的簽章。

回傳時把 content_md 放進 content。下一節讀的就是這個欄位。

圖片只放行這一頁引用到的

export async function readSharedAsset(token: string, assetId: string) {
  const s = await readShared(token); if (!s) return null;
  if (!s.content.includes(`/api/assets/${assetId}`)) return null; // only images the shared page actually embeds
  return getAsset(s.workspace_id, assetId);
}

出處:src/shares.ts:46-50

附件 id 是十二個隨機位元組寫成十六進位,猜不完。若只檢查「這張圖屬於這個工作區」,id 一旦從別處漏出去,分享 token 就能把那張圖讀走,包括沒有被分享的頁面裡的圖。所以要這一頁的 Markdown 裡真的有 /api/assets/<id>。上一節把 content_md 放進 content,就是為了讓這裡讀到的是同一份正文。

每次讀圖都會先把那一頁再查一次。圖片回應本身是 Cache-Control: private, max-age=3600,頁面那條才是 no-store。

匯出成一個 zip

export async function buildExportZip(workspaceId: string): Promise<Buffer> {
  const { rows } = await pool.query<{ path: string; content_md: string }>(
    `SELECT path, content_md FROM notes WHERE workspace_id = $1 AND deleted_at IS NULL ORDER BY path`, [workspaceId]);
  const assets = await assetsForExport(workspaceId);
  const map = new Map(assets.map(a => [a.id, a.path]));
  const files: Record<string, Uint8Array> = {};
  for (const r of rows) files[r.path] = strToU8(rewriteAssetLinks(r.content_md, map));
  for (const a of assets) files[a.path] = new Uint8Array(a.data);
  files['README.md'] = strToU8(`# WikiBrain 匯出\n\n匯出時間:${new Date().toISOString()}\n共 ${rows.length} 頁${assets.length ? `、${assets.length} 個圖片附件(raw/assets/)` : ''}。三層:raw/(原始來源)、wiki/(知識頁)、schema/(編纂規則)。[[wiki-link]] 與 Obsidian 相容。\n`);
  return Buffer.from(zipSync(files, { level: 6 }));
}

出處:src/export.ts:6-16

path 就是 zip 裡的路徑,解開來是 raw/、wiki/、schema/。要改的只有圖片連結:資料庫裡是 /api/assets/<id>,rewriteAssetLinks 換成 raw/assets/ 底下的檔名,否則解開全是破圖。

zip 裡的 README.md 寫匯出時間、頁數、有沒有圖片,以及三層各放什麼。[[wiki-link]] 和頁首的 front matter 本來就是這份 Markdown 的形狀,Obsidian 讀得了。

GET /api/export 把這包 zip 當附件下載,檔名是 wikibrain- 加當天日期。沒有增量,也沒有排程。要定期自己備份的人,打這一個端點就夠。

分享不設密碼,也不設到期

shares 只有 revoked_at,沒有密碼,沒有到期時間。密碼解決的是「不是拿到連結的人都能看」。到期解決的是「不要永遠公開」。現在兩件都不做。不想繼續公開,就撤銷。撤銷之後,下一次讀頁面會重新查這一列。

小結

帳務、寄信、備份腳本都在公開的 repo 裡。不在的是營運後台:載不到 ./ops-console.js 時,/api/admin/* 回 404;沒有 Admin.tsx 就不註冊 /admin。

分享 token 是 wbs_ 接十六個隨機位元組。形狀不對不查資料庫,撤銷或頁面已刪,下一次讀頁面就 404。圖片還要這一頁的 Markdown 裡有那個 /api/assets/<id>,附件 id 猜不完,但漏出去的 id 不能靠分享 token 讀走。匯出把未刪除的頁和圖片打成一個 zip,圖片路徑改成 raw/assets/,README 寫時間、頁數和三層。


上一篇
# Day 25 - 用本機 HTTP 伺服器測模型來回
下一篇
Day 27 - 設一個保留期清掉舊快照,刪帳號之後實際去數筆數
系列文
為你自己蓋一座會複利的知識庫——WikiBrain 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言