Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ba0b5f6d2 | ||
|
|
9cbc7de340 | ||
|
|
ec48836dc9 | ||
|
|
71b6402bd8 | ||
|
|
21395c19e9 | ||
|
|
1e01f3295e | ||
|
|
00928b72e4 | ||
|
|
82879a36cc | ||
|
|
428ee50cd6 | ||
|
|
6a10ddb6e5 | ||
|
|
bc58981de2 | ||
|
|
df8f62c75c | ||
|
|
3fc274e9f1 |
+3
-1
@@ -14,8 +14,10 @@ SYNC_INTERVAL_MINUTES=60
|
||||
NOTIFICATION_RETENTION_DAYS=0
|
||||
READING_HISTORY_RETENTION_DAYS=0
|
||||
AUDIT_RETENTION_DAYS=365
|
||||
# Optional Discord webhook URL or ntfy topic URL (for example https://ntfy.sh/my-private-topic).
|
||||
# Optional Discord webhook URL or ntfy topic URL (for example https://ntfy.sh/my-private-topic). Failed sends retry up to 5 times.
|
||||
ALERT_WEBHOOK_URL=
|
||||
# Optional Bearer token for GET /api/metrics. Leave empty only when metrics are restricted by your network/proxy.
|
||||
METRICS_TOKEN=
|
||||
# Optional: create the first Memos source for the bootstrap admin.
|
||||
SEED_MEMOS_NAME=
|
||||
SEED_MEMOS_URL=
|
||||
|
||||
@@ -14,8 +14,14 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: npm }
|
||||
- run: npm ci
|
||||
- run: npm audit --omit=dev --audit-level=high
|
||||
- run: npx tsc --noEmit
|
||||
- run: npm test
|
||||
- run: npm run sbom -- artifacts/mebbling.spdx.json
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mebbling-sbom-${{ gitea.sha }}
|
||||
path: artifacts/mebbling.spdx.json
|
||||
- run: docker build --build-arg APP_VERSION=${{ gitea.ref_name }} -t mebbling:${{ gitea.sha }} .
|
||||
# Optional: configure DEPLOY_WEBHOOK_URL as a Gitea Actions secret to notify your host on a v* tag.
|
||||
- if: startsWith(gitea.ref, 'refs/tags/v') && secrets.DEPLOY_WEBHOOK_URL != ''
|
||||
|
||||
@@ -4,6 +4,25 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.8.0] - 2026-07-19
|
||||
|
||||
### Added
|
||||
|
||||
- Hardened incoming RSS/Atom sources with public-HTTPS validation, redirect checks, response-size limits, and safe XML declaration rejection.
|
||||
- Added production configuration validation for secrets, encryption keys, and the public HTTPS URL, plus an administrator-facing status check.
|
||||
- Added Prometheus-compatible metrics and distinct liveness/readiness health probes.
|
||||
- Added SQLite FTS5 indexing for public-content search, maintained automatically as posts change.
|
||||
- Added a repeatable FTS search benchmark and documented PostgreSQL migration decision criteria.
|
||||
- Added persistent alert delivery with HTTPS validation, exponential-backoff retries, and Prometheus delivery-state metrics.
|
||||
- Added verified backup reports plus optional age-encrypted rclone offsite copies.
|
||||
- Added production dependency auditing, reproducible SPDX SBOM generation, and a documented release/upgrade/signing path.
|
||||
- Added a repeatable post-deploy HTTP smoke test for health, homepage, RSS, and metrics endpoints.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Tightened standard webhook signature validation to reject invalid timestamps and non-v1 signature schemes.
|
||||
- Made source invitation consumption atomic so a token cannot be accepted twice during concurrent requests.
|
||||
|
||||
## [0.7.0] - 2026-07-19
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
自架的 Memos 公開貼文 Hub。將朋友各自 Memos 中的公開貼文集中展示,同時保留 Hub 內的留言、表情回應與發文功能。
|
||||
|
||||
目前版本:`v0.7.0`。版本變更請見 [CHANGELOG.md](CHANGELOG.md)。
|
||||
目前開發版本:`v0.8.0`(尚未發布)。版本變更請見 [CHANGELOG.md](CHANGELOG.md)。
|
||||
|
||||
## 功能
|
||||
|
||||
@@ -46,6 +46,12 @@ docker compose ps
|
||||
docker compose logs -f web worker
|
||||
```
|
||||
|
||||
部署後可執行公開端點冒煙測試(若 metrics 有設 token,先匯出同一個 `METRICS_TOKEN`):
|
||||
|
||||
```bash
|
||||
./scripts/smoke-test.sh https://你的網域
|
||||
```
|
||||
|
||||
## 環境變數
|
||||
|
||||
以 `.env.example` 為範本。正式環境請更換所有 secret,且不要將 `.env` 加入 Git。
|
||||
@@ -60,6 +66,8 @@ docker compose logs -f web worker
|
||||
| `UPLOAD_ALLOWED_TYPES` | 逗號分隔的 Hub 附件 MIME 白名單。 |
|
||||
| `VIRUS_SCAN_URL` / `VIRUS_SCAN_REQUIRED` | 選用的 HTTP 掃毒服務;服務需回傳 `{ "clean": true }`。若 required 為 `1`,掃毒不可用時拒絕上傳。 |
|
||||
| `SYNC_INTERVAL_MINUTES` | 背景校正同步的間隔,預設 60 分鐘。 |
|
||||
| `ALERT_WEBHOOK_URL` | 選填的 HTTPS Discord webhook 或 ntfy topic;同步及 webhook 異常會保存後投遞,失敗最多重試 5 次。 |
|
||||
| `METRICS_TOKEN` | 選填的 `/api/metrics` Bearer Token;未設定時務必由網路/反向代理限制存取。 |
|
||||
| `SEED_MEMOS_*` | 選填;首次啟動時自動建立管理員的第一個 Memos 來源。 |
|
||||
|
||||
## 系統架構
|
||||
@@ -88,10 +96,11 @@ Web 接收使用者操作和 webhook,將同步需求寫入 SQLite 的 `sync_jo
|
||||
## 正式營運與監控
|
||||
|
||||
- `GET /api/health`:供反向代理或監控工具檢查服務與 SQLite 狀態,也會回傳失敗同步工作數與版本。
|
||||
- Web、Worker 的事件輸出為 JSON;同步錯誤同時保存於管理頁的「最近系統錯誤」。
|
||||
- Web、Worker 的事件輸出為 JSON;同步錯誤同時保存於管理頁的「最近系統錯誤」。告警 webhook 會以 SQLite 佇列投遞、指數退避重試五次,並以 `mebbling_alert_deliveries` metrics 暴露狀態。
|
||||
- 登入在 15 分鐘內最多嘗試 8 次;webhook 與登入限流資料存於 SQLite,同一份資料庫的多個 Web 容器會共用計數。
|
||||
- 所有會改變帳號或內容的瀏覽器 POST 都檢查 `Origin`,Webhook 則使用密鑰 URL 驗證,不適用此規則。
|
||||
- Gitea Actions 工作流程會在推送/標籤時執行型別檢查、測試與 Docker 建置;若設定 `DEPLOY_WEBHOOK_URL` secret,建立 `v*` tag 時會通知部署端。
|
||||
- 工作流程也會對 production dependencies 執行高風險漏洞檢查,並產生 SPDX SBOM artifact;發布、升級與日後映像簽章的流程見 [發布文件](docs/RELEASING.md)。
|
||||
|
||||
## Webhook 設定與驗證
|
||||
|
||||
@@ -135,6 +144,8 @@ https://你的網域/api/sync/webhook/來源ID/隨機密鑰
|
||||
├── data/ # SQLite 資料庫持久化資料
|
||||
├── Dockerfile # Web/Worker 共用映像檔
|
||||
├── docker-compose.yml # web + worker 服務與 volume 掛載
|
||||
├── scripts/ # 備份、驗證、還原與效能基準工具
|
||||
├── docs/ # 維運與擴展文件
|
||||
└── .env.example # 環境變數範本
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { checkRuntimeConfig } from "@/lib/config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -12,6 +13,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
||||
const errors = db.prepare("SELECT scope,message,created_at FROM error_events ORDER BY id DESC LIMIT 30").all() as any[];
|
||||
const audits = db.prepare("SELECT a.action,a.target_type,a.target_id,a.metadata_json,a.created_at,u.username FROM audit_events a LEFT JOIN users u ON u.id=a.actor_user_id ORDER BY a.id DESC LIMIT 50").all() as any[];
|
||||
const sources = db.prepare("SELECT s.id,s.name,s.base_url,s.sync_status,s.last_synced_at,s.is_enabled,count(sm.user_id) AS member_count FROM sources s LEFT JOIN source_members sm ON sm.source_id=s.id GROUP BY s.id ORDER BY s.id DESC").all() as any[];
|
||||
const config = checkRuntimeConfig();
|
||||
return <><h1>管理員</h1>{query.updated && <p>管理操作已完成。</p>}{query.error && <p className="error">管理操作未完成。</p>}
|
||||
<section className="card"><h2>待審核檢舉</h2>{reports.length ? <ul className="job-list">{reports.map((report) => <li key={report.id}><strong>貼文 #{report.post_id}</strong> · 檢舉者 @{report.username || "已刪除使用者"}<br />{report.reason}<br /><span className="meta">{report.content.slice(0, 180)} · {new Date(report.created_at + "Z").toLocaleString("zh-TW")}</span><div className="row"><form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value="hide-post" /><input type="hidden" name="id" value={report.post_id} /><button className="danger">隱藏貼文並結案</button></form><form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value="resolve-report" /><input type="hidden" name="id" value={report.id} /><button>保留貼文並結案</button></form></div></li>)}</ul> : <p className="muted">沒有待審核檢舉。</p>}</section>
|
||||
<section className="card"><h2>使用者</h2><ul className="job-list">{users.map((account) => <li key={account.id}><strong>@{account.username}</strong> · <span className="tag">{account.role}</span> · {account.disabled ? "已停權" : "正常"}<div className="row">{account.id !== user.id && <form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value={account.disabled ? "enable-user" : "disable-user"} /><input type="hidden" name="id" value={account.id} /><button className={account.disabled ? "" : "danger"}>{account.disabled ? "解除停權" : "停權"}</button></form>}<form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value="reset-password" /><input type="hidden" name="id" value={account.id} /><input name="password" type="password" minLength={10} required placeholder="管理員重設密碼" /><button>重設密碼</button></form></div></li>)}</ul></section>
|
||||
@@ -19,5 +21,6 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
||||
<section className="card"><h2>最近系統錯誤</h2>{errors.length ? <ul className="job-list">{errors.map((error, index) => <li key={index}><strong>{error.scope}</strong> · <span className="error">{error.message}</span><br /><span className="meta">{new Date(error.created_at + "Z").toLocaleString("zh-TW")}</span></li>)}</ul> : <p className="muted">尚無記錄。</p>}</section>
|
||||
<section className="card"><h2>稽核紀錄</h2>{audits.length ? <ul className="job-list">{audits.map((item, index) => <li key={index}><strong>{item.action}</strong> · @{item.username || "system"} · {item.target_type} #{item.target_id || "—"}<br /><span className="meta">{new Date(item.created_at + "Z").toLocaleString("zh-TW")}</span></li>)}</ul> : <p className="muted">尚無稽核紀錄。</p>}</section>
|
||||
<section className="card"><h2>所有來源</h2><ul className="job-list">{sources.map((source) => <li key={source.id}><strong>{source.name}</strong> · <span className="tag">{source.is_enabled ? source.sync_status : "disabled"}</span> · 成員 {source.member_count}<br /><span className="meta">#{source.id} · {source.base_url} · 上次同步:{source.last_synced_at || "尚未完成"}</span></li>)}</ul></section>
|
||||
<section className="card"><h2>正式環境設定</h2>{config.ok ? <p>設定檢查通過。</p> : <ul>{config.errors.map((error) => <li className="error" key={error}>{error}</li>)}</ul>}</section>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { checkRuntimeConfig } from "@/lib/config";
|
||||
export async function GET() { try { const user = await requireUser(); if (user.role !== "admin") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json(checkRuntimeConfig()); } catch { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } }
|
||||
@@ -2,10 +2,12 @@ import { NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export async function GET() {
|
||||
export async function GET(request: Request) {
|
||||
const probe = new URL(request.url).searchParams.get("probe");
|
||||
if (probe === "live") return NextResponse.json({ ok: true, status: "live", version: process.env.APP_VERSION || "development", timestamp: new Date().toISOString() });
|
||||
try {
|
||||
db.prepare("SELECT 1").get();
|
||||
const failedJobs = Number((db.prepare("SELECT count(*) AS count FROM sync_jobs WHERE status='failed'").get() as { count: number }).count);
|
||||
return NextResponse.json({ ok: true, version: process.env.APP_VERSION || "development", database: "ok", failedJobs, timestamp: new Date().toISOString() });
|
||||
return NextResponse.json({ ok: true, status: "ready", version: process.env.APP_VERSION || "development", database: "ok", failedJobs, timestamp: new Date().toISOString() });
|
||||
} catch { return NextResponse.json({ ok: false, database: "error" }, { status: 503 }); }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { acceptSourceInvite } from "@/lib/invites";
|
||||
import { externalUrl } from "@/lib/http";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
export async function POST(request: Request) { try { requireSameOrigin(request); const user = await requireUser(); const form = await request.formData(); const token = String(form.get("token") || ""); const hash = createHash("sha256").update(token).digest("hex"); const invite = db.prepare("SELECT id,source_id,role FROM source_invites WHERE token_hash=? AND used_at IS NULL AND expires_at>CURRENT_TIMESTAMP").get(hash) as { id: number; source_id: number; role: string } | undefined; if (!invite) throw new Error("邀請不存在、已使用或已過期"); db.transaction(() => { db.prepare("INSERT INTO source_members(source_id,user_id,role) VALUES(?,?,?) ON CONFLICT(source_id,user_id) DO UPDATE SET role=excluded.role").run(invite.source_id, user.id, invite.role); db.prepare("UPDATE source_invites SET used_at=CURRENT_TIMESTAMP WHERE id=?").run(invite.id); })(); audit(user.id, "source.invite.accept", "source", invite.source_id, { role: invite.role }); return NextResponse.redirect(externalUrl(request, "/dashboard?source=joined")); } catch (error) { return NextResponse.redirect(externalUrl(request, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "invite"))); } }
|
||||
export async function POST(request: Request) { try { requireSameOrigin(request); const user = await requireUser(); const form = await request.formData(); const invite = acceptSourceInvite(user.id, String(form.get("token") || "")); audit(user.id, "source.invite.accept", "source", invite.source_id, { role: invite.role }); return NextResponse.redirect(externalUrl(request, "/dashboard?source=joined")); } catch (error) { return NextResponse.redirect(externalUrl(request, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "invite"))); } }
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { prometheusMetrics } from "@/lib/metrics";
|
||||
export const dynamic = "force-dynamic";
|
||||
export async function GET(request: Request) { const expected = process.env.METRICS_TOKEN; const supplied = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") || ""; if (expected) { const actual = Buffer.from(supplied), target = Buffer.from(expected); if (actual.length !== target.length || !timingSafeEqual(actual, target)) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } return new NextResponse(prometheusMetrics(), { headers: { "Content-Type": "text/plain; version=0.0.4; charset=utf-8", "Cache-Control": "no-store" } }); }
|
||||
+3
-2
@@ -9,8 +9,9 @@ type Query = { q?: string; tag?: string; source?: string; author?: string; from?
|
||||
export default async function Home({ searchParams }: { searchParams: Promise<Query> }) {
|
||||
const query = await searchParams; const q = query.q?.trim() || ""; const tag = query.tag?.trim() || ""; const author = query.author?.trim() || ""; const sourceId = Number(query.source) || 0; const from = query.from || ""; const to = query.to || ""; const attachments = query.attachments === "1"; const page = Math.max(1, Number(query.page) || 1);
|
||||
const where = ["p.visibility='PUBLIC'", "p.hidden=0"]; const args: (string | number)[] = [];
|
||||
if (q) { where.push("p.content LIKE ?"); args.push(`%${q}%`); } if (tag) { where.push("p.tags_json LIKE ?"); args.push(`%${JSON.stringify(tag).slice(1, -1)}%`); } if (author) { where.push("u.username LIKE ?"); args.push(`%${author}%`); } if (sourceId) { where.push("s.id=?"); args.push(sourceId); } if (from) { where.push("date(COALESCE(p.remote_created_at,p.created_at)) >= date(?)"); args.push(from); } if (to) { where.push("date(COALESCE(p.remote_created_at,p.created_at)) <= date(?)"); args.push(to); } if (attachments) where.push("p.attachments_json <> '[]'");
|
||||
const joins = " FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id "; const predicate = ` WHERE ${where.join(" AND ")}`;
|
||||
const ftsQuery = q.split(/\s+/).filter(Boolean).map((term) => `"${term.replaceAll('"', '""')}"`).join(" AND ");
|
||||
if (q) { where.push("posts_fts MATCH ?"); args.push(ftsQuery); } if (tag) { where.push("p.tags_json LIKE ?"); args.push(`%${JSON.stringify(tag).slice(1, -1)}%`); } if (author) { where.push("u.username LIKE ?"); args.push(`%${author}%`); } if (sourceId) { where.push("s.id=?"); args.push(sourceId); } if (from) { where.push("date(COALESCE(p.remote_created_at,p.created_at)) >= date(?)"); args.push(from); } if (to) { where.push("date(COALESCE(p.remote_created_at,p.created_at)) <= date(?)"); args.push(to); } if (attachments) where.push("p.attachments_json <> '[]'");
|
||||
const joins = ` FROM posts p ${q ? "JOIN posts_fts ON posts_fts.rowid=p.id" : ""} JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id `; const predicate = ` WHERE ${where.join(" AND ")}`;
|
||||
const total = Number((db.prepare(`SELECT count(*) count${joins}${predicate}`).get(...args) as { count: number }).count); const pages = Math.max(1, Math.ceil(total / pageSize)); const safePage = Math.min(page, pages);
|
||||
const posts = db.prepare(`SELECT p.*,u.username,s.name,s.remote_display_name,s.base_url AS source_base_url,(SELECT count(*) FROM comments c WHERE c.post_id=p.id AND c.hidden=0) comment_count,(SELECT count(*) FROM reactions r WHERE r.post_id=p.id) reaction_count${joins}${predicate} ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT ? OFFSET ?`).all(...args, pageSize, (safePage - 1) * pageSize) as PublicPost[];
|
||||
const sources = db.prepare("SELECT id,name FROM sources WHERE is_enabled=1 ORDER BY name").all() as { id: number; name: string }[];
|
||||
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
services:
|
||||
web:
|
||||
image: mebbling:${MEBBLING_VERSION:-0.7.0}
|
||||
image: mebbling:${MEBBLING_VERSION:-0.8.0}
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
APP_VERSION: "${MEBBLING_VERSION:-0.7.0}"
|
||||
APP_VERSION: "${MEBBLING_VERSION:-0.8.0}"
|
||||
ports: ["8088:3000"]
|
||||
env_file: .env
|
||||
environment: { DATABASE_PATH: /app/data/hub.db }
|
||||
@@ -13,11 +13,11 @@ services:
|
||||
- ./public/uploads:/app/public/uploads
|
||||
restart: unless-stopped
|
||||
worker:
|
||||
image: mebbling:${MEBBLING_VERSION:-0.7.0}
|
||||
image: mebbling:${MEBBLING_VERSION:-0.8.0}
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
APP_VERSION: "${MEBBLING_VERSION:-0.7.0}"
|
||||
APP_VERSION: "${MEBBLING_VERSION:-0.8.0}"
|
||||
command: npm run worker
|
||||
env_file: .env
|
||||
environment: { DATABASE_PATH: /app/data/hub.db }
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# 系統文件 Progress
|
||||
|
||||
此文件管理 Mebbling 對外與維運文件的章節規劃;它不取代版本變更紀錄,功能變更仍以 `CHANGELOG.md` 為準。
|
||||
|
||||
## 文件地圖
|
||||
|
||||
| 優先 | 章節 | 讀者 | 預計內容 | 狀態 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| P0 | 總覽與架構 | 所有使用者 | Mebbling 解決的問題、元件圖、資料流、權限邊界 | 規劃中 |
|
||||
| P0 | 快速開始 | 自架管理者 | Docker/WSL 啟動、必要環境變數、首次管理員與第一個來源 | 現有 README,待拆分 |
|
||||
| P0 | Memos 來源與同步 | Hub 使用者 | PAT、公開貼文規則、多使用者來源、附件策略、RSS 限制 | 規劃中 |
|
||||
| P0 | Webhook 指南 | 來源建立者 | 自動簽章 webhook、手動模式、驗證、輪替與故障排查 | 規劃中 |
|
||||
| P0 | 營運手冊 | 維運者 | health/metrics、告警、備份/還原、升級、事件處理 | 部分完成(OPERATIONS) |
|
||||
| P1 | 安全與隱私 | 管理者/貢獻者 | Token 加密、存取控制、保存期限、帳號刪除、威脅模型與限制 | 規劃中 |
|
||||
| P1 | 設定參考 | 維運者 | 所有環境變數、預設值、正式環境檢查與範例 | 規劃中 |
|
||||
| P1 | API/資料參考 | 整合者 | 公開 RSS/Atom、health、metrics、資料表與 migration 策略 | 規劃中 |
|
||||
| P1 | 發布與貢獻 | 維護者 | 測試、SBOM、版本、release、升級與容器簽章前置條件 | 部分完成(RELEASING) |
|
||||
| P2 | 使用者操作手冊 | 一般使用者 | 搜尋、標籤、收藏、留言、邀請、匯出與發文 | 規劃中 |
|
||||
| P2 | 疑難排解 | 所有使用者 | Token/同步/Webhook/附件/備份常見問題與診斷流程 | 規劃中 |
|
||||
|
||||
## 撰寫順序
|
||||
|
||||
1. 將 README 的「快速啟動、架構、環境變數」拆成可獨立連結的入門文件。
|
||||
2. 寫出 Memos 同步、Webhook 與 RSS 三份整合指南,統一說明適用情境與限制。
|
||||
3. 擴充維運手冊的監控、告警、備份、升級與事故處理章節。
|
||||
4. 補上安全/隱私、設定與 API 參考;內容直接對應實際程式與 `.env.example`。
|
||||
5. 最後以操作手冊和疑難排解收斂成對一般使用者友善的文件。
|
||||
|
||||
## 完成標準
|
||||
|
||||
- 每個章節至少包含適用對象、前置條件、操作步驟、驗證方式與失敗處理。
|
||||
- 涉及安全或資料風險的章節要明確列出不可逆操作及備份需求。
|
||||
- 指令須可在 WSL/Docker 的實際環境重現;發布前以 `scripts/smoke-test.sh` 驗證文件中的服務狀態檢查。
|
||||
+20
-2
@@ -13,7 +13,11 @@
|
||||
- `hub.db`:由正在執行的 SQLite 資料庫建立的一致性備份。
|
||||
- `uploads.tar.gz`:Hub 本機上傳的附件。
|
||||
|
||||
每次備份都會執行 SQLite `integrity_check`、驗證附件壓縮檔,並在 `SHA256SUMS` 記錄雜湊。`data/backups/` 已由 Git 排除。
|
||||
每次備份都會執行 SQLite `integrity_check`、驗證附件壓縮檔,並在 `SHA256SUMS` 記錄雜湊。`data/backups/` 已由 Git 排除。也可隨時執行不改動正式資料的驗證:
|
||||
|
||||
```bash
|
||||
./scripts/verify-backup.sh data/backups/<時間>
|
||||
```
|
||||
|
||||
可設定保留與異地複製(例如掛載的 NAS、加密磁碟或 rclone 掛載點):
|
||||
|
||||
@@ -21,6 +25,16 @@
|
||||
BACKUP_RETENTION_DAYS=30 BACKUP_OFFSITE_DIR=/mnt/nas/mebbling ./scripts/backup.sh
|
||||
```
|
||||
|
||||
若異地目的地可能由他人讀取,使用 `age` 加密。設定加密後,異地只會收到 `backup.tar.gz.age`,本機仍保留可供快速還原的已驗證備份:
|
||||
|
||||
```bash
|
||||
BACKUP_AGE_RECIPIENT=age1你的收件人公鑰 \
|
||||
BACKUP_RCLONE_REMOTE='remote:bucket/mebbling' \
|
||||
BACKUP_RETENTION_DAYS=30 ./scripts/backup.sh
|
||||
```
|
||||
|
||||
`BACKUP_RCLONE_REMOTE` 可使用已在主機設定好的 rclone S3、B2、SFTP 等 remote;腳本會在缺少 `age` 或 `rclone` 時安全失敗,不會假裝已完成異地備份。加密檔要還原時,先以持有的 age identity 解密並解壓為原本的備份目錄,再使用下列還原命令。請把 `verify-backup.sh` 的成功輸出保留在 cron log 中,作為每日可用性驗證報告;完整還原演練仍應定期在隔離環境執行。
|
||||
|
||||
在 WSL 主機安裝每日 03:15 排程:
|
||||
|
||||
```bash
|
||||
@@ -61,4 +75,8 @@ Hub 原生附件預設只接受圖片、PDF、純文字與 Markdown。若要串
|
||||
|
||||
## 外部告警
|
||||
|
||||
設定 `ALERT_WEBHOOK_URL` 後,Worker 會在同步重試耗盡、或簽章 Webhook 超過 7 天未收到事件時發送告警。支援 Discord incoming webhook 或 ntfy topic URL;同一事件每小時最多通知一次。
|
||||
設定 `ALERT_WEBHOOK_URL` 後,Worker 會在同步重試耗盡、或簽章 Webhook 超過 7 天未收到事件時發送告警。支援 HTTPS Discord incoming webhook 或 ntfy topic URL;同一事件每小時最多通知一次。告警會保存於 SQLite,失敗時以指數退避重試、最多五次;`/api/metrics` 的 `mebbling_alert_deliveries` 可監看最終失敗。
|
||||
|
||||
## 監控指標與健康檢查
|
||||
|
||||
`GET /api/health?probe=live` 只確認程序存活;預設的 `GET /api/health` 是 readiness 檢查,會確認 SQLite 可讀取。Prometheus 格式的 `GET /api/metrics` 提供來源、公開貼文、同步佇列、Webhook 與附件快取的聚合指標。若設定 `METRICS_TOKEN`,請以 `Authorization: Bearer <token>` 抓取。
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# 發布流程
|
||||
|
||||
## 每次發布前
|
||||
|
||||
1. 確認 `CHANGELOG.md` 的 Unreleased 內容與目標版本一致。
|
||||
2. 在本機執行 `npm test`、`npx tsc --noEmit`、`npm run build` 與 `./scripts/backup.sh`;部署後執行 `./scripts/smoke-test.sh https://你的網域`。
|
||||
3. 提交並推送 `main`,確認 Gitea Actions 的 verify workflow 成功;它會執行 production dependency audit、測試、Docker build,並產生 SPDX SBOM artifact。
|
||||
4. 建立 annotated tag,例如:`git tag -a v0.8.0 -m "Mebbling v0.8.0"`,再執行 `git push origin v0.8.0`。
|
||||
5. 在 Gitea 的 Releases 以同一個 tag 建立 release;若版本尚供測試,勾選 Pre-release。
|
||||
|
||||
## 映像簽章
|
||||
|
||||
目前 workflow 只建置本機 Docker image,尚未指定容器 registry,因此不會產生無法驗證的假簽章。要啟用 cosign,先決定可推送的 OCI registry 與 image 名稱,並在 Gitea Actions 設定 `COSIGN_PRIVATE_KEY`、`COSIGN_PASSWORD` 和 registry 登入 secret;之後將 push、`cosign sign` 與 `cosign verify` 加入 tag 工作。部署端應只接受已驗證的 tag digest。
|
||||
|
||||
## 升級
|
||||
|
||||
升級前先備份。將 `.env` 的 `MEBBLING_VERSION` 改為新 tag,執行 `docker compose pull`(若使用 registry)或重新建置後 `docker compose up -d --no-build`。確認 `/api/health?probe=ready` 與 `/api/metrics`,並觀察 worker log。資料庫 migration 會在 Web/Worker 啟動時自動執行,請勿在升級後直接回退到舊映像。
|
||||
@@ -0,0 +1,21 @@
|
||||
# 擴展與資料庫評估
|
||||
|
||||
## 搜尋基準
|
||||
|
||||
FTS5 索引由 `posts_fts` 與 SQLite trigger 維護。可在部署前後以相同關鍵字比較查詢計畫與平均時間:
|
||||
|
||||
```bash
|
||||
./scripts/benchmark-search.sh "關鍵字" 100
|
||||
```
|
||||
|
||||
輸出中的 `queryPlan` 應包含 FTS 虛擬表掃描,而不是 `posts` 的全表 `LIKE` 掃描。請記錄貼文數量、硬體、SQLite 版本與平均查詢時間,作為升級決策依據。
|
||||
|
||||
## 何時由 SQLite 遷移到 PostgreSQL
|
||||
|
||||
SQLite 仍適合單一主機、單一磁碟與低至中等寫入量。當出現以下任一情況時,先在 staging 驗證 PostgreSQL:
|
||||
|
||||
- 需要跨多台主機同時執行 Web/Worker,或需要跨區高可用。
|
||||
- 寫入鎖定持續造成同步佇列延遲,或 WAL 檔案/備份窗口已難以控制。
|
||||
- FTS、稽核或貼文資料量使查詢基準無法滿足服務目標。
|
||||
|
||||
遷移步驟:停止寫入、使用 v0.7 以後的 JSON 匯出建立內容快照、以 migration 建立 PostgreSQL schema、匯入 users/sources/posts/互動與同步工作、在 staging 驗證計數與抽樣內容、切換唯讀短暫維護窗口、最後更新 `DATABASE_URL` 及備份/監控設定。SQLite 與 PostgreSQL 的雙寫不列為預設策略;除非有完整一致性驗證,避免長期雙寫。
|
||||
+30
-3
@@ -5,7 +5,34 @@ function allowed(bucket: string, seconds = 3600) {
|
||||
if (row && row.reset_at > now) return false;
|
||||
db.prepare("INSERT INTO rate_limits(bucket,count,reset_at) VALUES(?,1,?) ON CONFLICT(bucket) DO UPDATE SET count=count+1,reset_at=excluded.reset_at").run(bucket, now + seconds); return true;
|
||||
}
|
||||
export async function sendAlert(bucket: string, title: string, message: string) {
|
||||
const url = process.env.ALERT_WEBHOOK_URL?.trim(); if (!url || !allowed(`alert:${bucket}`)) return false;
|
||||
try { const isNtfy = /(^|\.)ntfy\.sh\//.test(new URL(url).hostname + new URL(url).pathname); const response = await fetch(url, isNtfy ? { method: "POST", headers: { Title: title, Priority: "high" }, body: message, signal: AbortSignal.timeout(10_000) } : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: `**${title}**\n${message}` }), signal: AbortSignal.timeout(10_000) }); if (!response.ok) throw new Error(`Alert ${response.status}`); return true; } catch { return false; }
|
||||
export function sendAlert(bucket: string, title: string, message: string) {
|
||||
if (!process.env.ALERT_WEBHOOK_URL?.trim() || !allowed(`alert:${bucket}`)) return false;
|
||||
db.prepare("INSERT INTO alert_deliveries(bucket,title,message) VALUES(?,?,?)").run(bucket, title, message);
|
||||
return true;
|
||||
}
|
||||
|
||||
type Alert = { id: number; title: string; message: string; attempts: number };
|
||||
|
||||
export async function deliverNextAlert() {
|
||||
const candidate = db.prepare("SELECT id FROM alert_deliveries WHERE status='queued' AND run_after<=CURRENT_TIMESTAMP ORDER BY id LIMIT 1").get() as { id: number } | undefined;
|
||||
if (!candidate) return false;
|
||||
const claimed = db.prepare("UPDATE alert_deliveries SET status='running',attempts=attempts+1,started_at=CURRENT_TIMESTAMP WHERE id=? AND status='queued'").run(candidate.id);
|
||||
if (!claimed.changes) return false;
|
||||
const alert = db.prepare("SELECT id,title,message,attempts FROM alert_deliveries WHERE id=?").get(candidate.id) as Alert;
|
||||
const url = process.env.ALERT_WEBHOOK_URL?.trim();
|
||||
try {
|
||||
if (!url) throw new Error("ALERT_WEBHOOK_URL is not configured");
|
||||
const target = new URL(url);
|
||||
if (target.protocol !== "https:") throw new Error("Alert webhook must use HTTPS");
|
||||
const isNtfy = /(^|\.)ntfy\.sh$/.test(target.hostname);
|
||||
const response = await fetch(target, isNtfy ? { method: "POST", headers: { Title: alert.title, Priority: "high" }, body: alert.message, signal: AbortSignal.timeout(10_000) } : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: `**${alert.title}**\n${alert.message}` }), signal: AbortSignal.timeout(10_000) });
|
||||
if (!response.ok) throw new Error(`Alert endpoint returned ${response.status}`);
|
||||
db.prepare("UPDATE alert_deliveries SET status='done',finished_at=CURRENT_TIMESTAMP,last_error=NULL WHERE id=?").run(alert.id);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Alert delivery failed";
|
||||
const exhausted = alert.attempts >= 5;
|
||||
const delayMinutes = Math.min(60, 2 ** Math.max(0, alert.attempts - 1));
|
||||
db.prepare("UPDATE alert_deliveries SET status=?,last_error=?,finished_at=CASE WHEN ? THEN CURRENT_TIMESTAMP ELSE NULL END,run_after=CASE WHEN ? THEN run_after ELSE datetime('now', ?) END WHERE id=?").run(exhausted ? "failed" : "queued", message, exhausted ? 1 : 0, exhausted ? 1 : 0, `+${delayMinutes} minutes`, alert.id);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export type ConfigStatus = { ok: boolean; errors: string[] };
|
||||
export function checkRuntimeConfig(env: NodeJS.ProcessEnv = process.env): ConfigStatus {
|
||||
if (env.HUB_BUILD === "1" || env.NODE_ENV !== "production") return { ok: true, errors: [] };
|
||||
const errors: string[] = []; const session = env.SESSION_SECRET || ""; const encryption = env.TOKEN_ENCRYPTION_KEY || ""; const publicUrl = env.NEXT_PUBLIC_APP_URL || "";
|
||||
if (session.length < 32 || session === "development-only-change-me" || session.includes("replace-with")) errors.push("SESSION_SECRET must be a non-default value of at least 32 characters");
|
||||
if (!/^[0-9a-f]{64}$/i.test(encryption)) errors.push("TOKEN_ENCRYPTION_KEY must be 64 hexadecimal characters");
|
||||
try { if (new URL(publicUrl).protocol !== "https:") throw new Error(); } catch { errors.push("NEXT_PUBLIC_APP_URL must be an HTTPS URL in production"); }
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
export function requireRuntimeConfig() { const status = checkRuntimeConfig(); if (!status.ok) throw new Error(`Invalid production configuration: ${status.errors.join("; ")}`); }
|
||||
@@ -1,7 +1,9 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { requireRuntimeConfig } from "@/lib/config";
|
||||
|
||||
requireRuntimeConfig();
|
||||
const path = process.env.HUB_BUILD === "1" ? ":memory:" : (process.env.DATABASE_PATH || "./data/hub.db");
|
||||
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
|
||||
export const db = new Database(path);
|
||||
@@ -88,6 +90,12 @@ CREATE TABLE IF NOT EXISTS error_events (
|
||||
id INTEGER PRIMARY KEY, scope TEXT NOT NULL, message TEXT NOT NULL, context_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS alert_deliveries (
|
||||
id INTEGER PRIMARY KEY, bucket TEXT NOT NULL, title TEXT NOT NULL, message TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued', attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, started_at TEXT, finished_at TEXT,
|
||||
run_after TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS source_invites (
|
||||
id INTEGER PRIMARY KEY, source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
||||
token_hash TEXT UNIQUE NOT NULL, role TEXT NOT NULL DEFAULT 'viewer', expires_at TEXT NOT NULL,
|
||||
@@ -152,6 +160,26 @@ db.prepare("UPDATE source_members SET role='editor' WHERE role='member'").run();
|
||||
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(36)").run();
|
||||
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(37)").run();
|
||||
applyColumnMigration(38, "sources", "attachment_archive_after_days", "ALTER TABLE sources ADD COLUMN attachment_archive_after_days INTEGER");
|
||||
const ftsSchema = `
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(content, tags);
|
||||
CREATE TRIGGER IF NOT EXISTS posts_fts_insert AFTER INSERT ON posts BEGIN
|
||||
INSERT INTO posts_fts(rowid,content,tags) VALUES(new.id,new.content,new.tags_json);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS posts_fts_delete AFTER DELETE ON posts BEGIN
|
||||
DELETE FROM posts_fts WHERE rowid=old.id;
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS posts_fts_update AFTER UPDATE OF content,tags_json ON posts BEGIN
|
||||
DELETE FROM posts_fts WHERE rowid=old.id;
|
||||
INSERT INTO posts_fts(rowid,content,tags) VALUES(new.id,new.content,new.tags_json);
|
||||
END;
|
||||
`;
|
||||
db.exec(ftsSchema);
|
||||
const hasFtsMigration = db.prepare("SELECT 1 FROM schema_migrations WHERE version=39").get();
|
||||
if (!hasFtsMigration) { db.prepare("INSERT INTO posts_fts(rowid,content,tags) SELECT id,content,tags_json FROM posts").run(); db.prepare("INSERT INTO schema_migrations(version) VALUES(39)").run(); }
|
||||
db.exec("CREATE INDEX IF NOT EXISTS posts_source_visibility_idx ON posts(source_id,visibility,hidden); CREATE INDEX IF NOT EXISTS posts_author_visibility_idx ON posts(author_id,visibility,hidden);");
|
||||
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(40)").run();
|
||||
db.exec("CREATE INDEX IF NOT EXISTS alert_deliveries_status_idx ON alert_deliveries(status,run_after)");
|
||||
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(41)").run();
|
||||
|
||||
const admin = process.env.ADMIN_USERNAME;
|
||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export function acceptSourceInvite(userId: number, token: string) {
|
||||
const hash = createHash("sha256").update(token).digest("hex");
|
||||
return db.transaction(() => {
|
||||
const invite = db.prepare("SELECT id,source_id,role FROM source_invites WHERE token_hash=? AND used_at IS NULL AND expires_at>CURRENT_TIMESTAMP").get(hash) as { id: number; source_id: number; role: string } | undefined;
|
||||
if (!invite) throw new Error("邀請不存在、已使用或已過期");
|
||||
const consumed = db.prepare("UPDATE source_invites SET used_at=CURRENT_TIMESTAMP WHERE id=? AND used_at IS NULL").run(invite.id);
|
||||
if (!consumed.changes) throw new Error("邀請不存在、已使用或已過期");
|
||||
db.prepare("INSERT INTO source_members(source_id,user_id,role) VALUES(?,?,?) ON CONFLICT(source_id,user_id) DO UPDATE SET role=excluded.role").run(invite.source_id, userId, invite.role);
|
||||
return invite;
|
||||
})();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
const line = (name: string, value: number, labels = "") => `${name}${labels ? `{${labels}}` : ""} ${Number.isFinite(value) ? value : 0}`;
|
||||
export function prometheusMetrics() {
|
||||
const count = (sql: string) => Number((db.prepare(sql).get() as { count: number }).count);
|
||||
const jobs = db.prepare("SELECT status,count(*) AS count FROM sync_jobs GROUP BY status").all() as { status: string; count: number }[];
|
||||
const alerts = db.prepare("SELECT status,count(*) AS count FROM alert_deliveries GROUP BY status").all() as { status: string; count: number }[];
|
||||
const queuedAge = Number((db.prepare("SELECT COALESCE(MAX(strftime('%s','now')-strftime('%s',created_at)),0) AS age FROM sync_jobs WHERE status='queued'").get() as { age: number }).age);
|
||||
const signed = count("SELECT count(*) AS count FROM sources WHERE is_enabled=1 AND webhook_mode='signed'"); const recentWebhook = count("SELECT count(*) AS count FROM sources WHERE is_enabled=1 AND webhook_mode='signed' AND last_webhook_at>=datetime('now','-24 hours')");
|
||||
const cacheRows = db.prepare("SELECT attachments_json FROM posts").all() as { attachments_json: string }[]; let cacheBytes = 0; for (const row of cacheRows) { try { cacheBytes += (JSON.parse(row.attachments_json) as { url?: string; size?: number }[]).filter((item) => item.url?.startsWith("/uploads/cache/")).reduce((sum, item) => sum + Number(item.size || 0), 0); } catch {} }
|
||||
return ["# HELP mebbling_sources_enabled Number of enabled sources", "# TYPE mebbling_sources_enabled gauge", line("mebbling_sources_enabled", count("SELECT count(*) AS count FROM sources WHERE is_enabled=1")), "# HELP mebbling_posts_public Number of visible public posts", "# TYPE mebbling_posts_public gauge", line("mebbling_posts_public", count("SELECT count(*) AS count FROM posts WHERE visibility='PUBLIC' AND hidden=0")), "# HELP mebbling_sync_jobs Number of sync jobs by status", "# TYPE mebbling_sync_jobs gauge", ...jobs.map((job) => line("mebbling_sync_jobs", job.count, `status=\"${job.status.replaceAll('"', '')}\"`)), "# HELP mebbling_alert_deliveries Alert deliveries by status", "# TYPE mebbling_alert_deliveries gauge", ...alerts.map((alert) => line("mebbling_alert_deliveries", alert.count, `status=\"${alert.status.replaceAll('"', '')}\"`)), line("mebbling_sync_queue_oldest_seconds", queuedAge), line("mebbling_signed_webhooks", signed), line("mebbling_signed_webhooks_recent_24h", recentWebhook), line("mebbling_attachment_cache_bytes", cacheBytes)].join("\n") + "\n";
|
||||
}
|
||||
+32
-5
@@ -1,8 +1,35 @@
|
||||
import { resolve4, resolve6 } from "node:dns/promises";
|
||||
import { isIP } from "node:net";
|
||||
|
||||
export type RssItem = { id: string; content: string; link: string; publishedAt: string | null };
|
||||
const decode = (value: string) => value.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
||||
const MAX_FEED_BYTES = 2 * 1024 * 1024;
|
||||
const decode = (value: string) => value.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/&/g, "&");
|
||||
const field = (xml: string, name: string) => decode(xml.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`, "i"))?.[1]?.trim() || "");
|
||||
export async function fetchRss(url: string) {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(15_000), headers: { Accept: "application/rss+xml, application/xml, text/xml" } }); if (!response.ok) throw new Error(`RSS ${response.status}`);
|
||||
const xml = await response.text(); const entries = xml.match(/<item\b[\s\S]*?<\/item>/gi) || [];
|
||||
return entries.map((item) => { const link = field(item, "link"); const id = field(item, "guid") || link; return { id, link, content: field(item, "description") || field(item, "title"), publishedAt: field(item, "pubDate") ? new Date(field(item, "pubDate")).toISOString() : null }; }).filter((item) => item.id && item.content) as RssItem[];
|
||||
const atomLink = (xml: string) => decode(xml.match(/<link\b[^>]*\bhref=["']([^"']+)["'][^>]*>/i)?.[1] || "");
|
||||
const date = (value: string) => { const parsed = new Date(value); return value && !Number.isNaN(parsed.getTime()) ? parsed.toISOString() : null; };
|
||||
|
||||
function privateIp(address: string) {
|
||||
if (isIP(address) === 4) { const [a, b] = address.split(".").map(Number); return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); }
|
||||
const normalized = address.toLowerCase(); return normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:") || normalized.startsWith("::ffff:127.") || normalized.startsWith("::ffff:10.") || normalized.startsWith("::ffff:192.168.");
|
||||
}
|
||||
async function assertPublicHttps(raw: string) {
|
||||
const url = new URL(raw); if (url.protocol !== "https:" || url.username || url.password) throw new Error("RSS feed must use a public HTTPS URL"); const host = url.hostname.toLowerCase(); if (host === "localhost" || host.endsWith(".local")) throw new Error("RSS feed host is not public");
|
||||
const direct = isIP(host); const addresses = direct ? [host] : [...await resolve4(host).catch(() => [] as string[]), ...await resolve6(host).catch(() => [] as string[])];
|
||||
if (!addresses.length || addresses.some(privateIp)) throw new Error("RSS feed host is not public"); return url;
|
||||
}
|
||||
async function responseText(response: Response) {
|
||||
const headerSize = Number(response.headers.get("content-length") || 0); if (headerSize > MAX_FEED_BYTES) throw new Error("RSS feed is too large"); const reader = response.body?.getReader(); if (!reader) return ""; const chunks: Uint8Array[] = []; let total = 0;
|
||||
while (true) { const { done, value } = await reader.read(); if (done) break; total += value.byteLength; if (total > MAX_FEED_BYTES) { await reader.cancel(); throw new Error("RSS feed is too large"); } chunks.push(value); }
|
||||
return Buffer.concat(chunks.map((item) => Buffer.from(item))).toString("utf8");
|
||||
}
|
||||
|
||||
export function parseFeed(xml: string): RssItem[] {
|
||||
if (/<!DOCTYPE|<!ENTITY/i.test(xml)) throw new Error("RSS feed contains unsupported XML declarations");
|
||||
const rss = xml.match(/<item\b[\s\S]*?<\/item>/gi) || []; const atom = xml.match(/<entry\b[\s\S]*?<\/entry>/gi) || []; const entries = rss.length ? rss.map((body) => ({ body, atom: false })) : atom.map((body) => ({ body, atom: true }));
|
||||
return entries.map(({ body, atom }) => { const link = atom ? atomLink(body) : field(body, "link"); const id = field(body, atom ? "id" : "guid") || link; const content = field(body, atom ? "content" : "description") || field(body, atom ? "summary" : "title") || field(body, "title"); return { id, link, content, publishedAt: date(field(body, atom ? "published" : "pubDate") || field(body, atom ? "updated" : "")) }; }).filter((item) => item.id && item.content) as RssItem[];
|
||||
}
|
||||
export async function fetchRss(raw: string) {
|
||||
let url = await assertPublicHttps(raw);
|
||||
for (let redirects = 0; redirects <= 3; redirects++) { const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(15_000), headers: { Accept: "application/rss+xml, application/atom+xml, application/xml, text/xml" } }); if ([301, 302, 303, 307, 308].includes(response.status)) { const location = response.headers.get("location"); if (!location || redirects === 3) throw new Error("RSS feed redirect is invalid"); url = await assertPublicHttps(new URL(location, url).toString()); continue; } if (!response.ok) throw new Error(`RSS ${response.status}`); return parseFeed(await responseText(response)); }
|
||||
throw new Error("RSS feed redirect is invalid");
|
||||
}
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@ export function webhookSecretMatches(secret: string, expectedHash: string | null
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||
}
|
||||
export function standardWebhookMatches(secret: string, id: string | null, timestamp: string | null, signature: string | null, body: string) {
|
||||
if (!id || !timestamp || !signature || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
|
||||
const seconds = Number(timestamp); if (!id || !timestamp || !signature || !Number.isFinite(seconds) || !Number.isInteger(seconds) || Math.abs(Date.now() / 1000 - seconds) > 300) return false;
|
||||
const expected = createHmac("sha256", secret).update(`${id}.${timestamp}.${body}`).digest("base64");
|
||||
return signature.split(" ").some((item) => { const value = item.split(",")[1]; if (!value) return false; const actual = Buffer.from(value); const target = Buffer.from(expected); return actual.length === target.length && timingSafeEqual(actual, target); });
|
||||
return signature.split(" ").some((item) => { const [version, value] = item.split(","); if (version !== "v1" || !value) return false; const actual = Buffer.from(value); const target = Buffer.from(expected); return actual.length === target.length && timingSafeEqual(actual, target); });
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mebbling",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mebbling",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mebbling",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"description": "",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -8,7 +8,8 @@
|
||||
"build": "HUB_BUILD=1 next build",
|
||||
"start": "next start",
|
||||
"worker": "tsx worker/index.ts",
|
||||
"test": "TMPDIR=/tmp tsx --test tests/**/*.test.ts"
|
||||
"test": "TMPDIR=/tmp tsx --test tests/**/*.test.ts",
|
||||
"sbom": "node scripts/generate-sbom.mjs"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
+17
-1
@@ -3,6 +3,7 @@ set -euo pipefail
|
||||
|
||||
# Creates a consistent SQLite backup through the running web container, then archives Hub uploads.
|
||||
# Optional: BACKUP_RETENTION_DAYS=30 BACKUP_OFFSITE_DIR=/mnt/backup/mebbling ./scripts/backup.sh
|
||||
# For encrypted remote copies: BACKUP_AGE_RECIPIENT=age1... BACKUP_RCLONE_REMOTE='remote:bucket/mebbling' ./scripts/backup.sh
|
||||
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$root_dir"
|
||||
stamp="$(date +%Y%m%d-%H%M%S)"
|
||||
@@ -23,12 +24,27 @@ docker compose exec -T -e BACKUP_PATH="/app/data/backups/$stamp/hub.db" web node
|
||||
'
|
||||
tar -tzf "$backup_dir/uploads.tar.gz" >/dev/null
|
||||
(cd "$backup_dir" && sha256sum hub.db uploads.tar.gz > SHA256SUMS)
|
||||
"$root_dir/scripts/verify-backup.sh" "$backup_dir"
|
||||
|
||||
archive="$backup_dir/backup.tar.gz"
|
||||
tar -czf "$archive" -C "$backup_dir" hub.db uploads.tar.gz SHA256SUMS
|
||||
offsite_payload="$backup_dir"
|
||||
if [[ -n "${BACKUP_AGE_RECIPIENT:-}" ]]; then
|
||||
command -v age >/dev/null || { echo "age is required when BACKUP_AGE_RECIPIENT is set" >&2; exit 2; }
|
||||
age -r "$BACKUP_AGE_RECIPIENT" -o "$archive.age" "$archive"
|
||||
offsite_payload="$archive.age"
|
||||
fi
|
||||
|
||||
if [[ -n "${BACKUP_OFFSITE_DIR:-}" ]]; then
|
||||
destination="$BACKUP_OFFSITE_DIR/$stamp"; mkdir -p "$destination"
|
||||
cp -a "$backup_dir/." "$destination/"
|
||||
if [[ "$offsite_payload" == "$backup_dir" ]]; then cp -a "$backup_dir/." "$destination/"; else cp -a "$offsite_payload" "$destination/"; fi
|
||||
printf 'Copied verified backup to: %s\n' "$destination"
|
||||
fi
|
||||
if [[ -n "${BACKUP_RCLONE_REMOTE:-}" ]]; then
|
||||
command -v rclone >/dev/null || { echo "rclone is required when BACKUP_RCLONE_REMOTE is set" >&2; exit 2; }
|
||||
if [[ "$offsite_payload" == "$backup_dir" ]]; then rclone copy "$backup_dir" "$BACKUP_RCLONE_REMOTE/$stamp"; else rclone copy "$offsite_payload" "$BACKUP_RCLONE_REMOTE/$stamp"; fi
|
||||
printf 'Copied verified backup using rclone to: %s/%s\n' "$BACKUP_RCLONE_REMOTE" "$stamp"
|
||||
fi
|
||||
if [[ -n "${BACKUP_RETENTION_DAYS:-}" ]]; then
|
||||
[[ "$BACKUP_RETENTION_DAYS" =~ ^[0-9]+$ ]] || { echo "BACKUP_RETENTION_DAYS must be a non-negative integer" >&2; exit 2; }
|
||||
find data/backups -mindepth 1 -maxdepth 1 -type d -mtime "+$BACKUP_RETENTION_DAYS" -exec rm -rf {} +
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Usage: ./scripts/benchmark-search.sh "搜尋詞" [iterations]
|
||||
query="${1:-test}"
|
||||
iterations="${2:-100}"
|
||||
[[ "$iterations" =~ ^[1-9][0-9]*$ ]] || { echo "iterations must be a positive integer" >&2; exit 2; }
|
||||
docker compose exec -T -e SEARCH_QUERY="$query" -e SEARCH_ITERATIONS="$iterations" web node - <<'NODE'
|
||||
const { performance } = require("node:perf_hooks"); const Database = require("better-sqlite3");
|
||||
const db = new Database(process.env.DATABASE_PATH, { readonly: true }); const query = process.env.SEARCH_QUERY.split(/\s+/).filter(Boolean).map((term) => `"${term.replaceAll('"','""')}"`).join(" AND "); const iterations = Number(process.env.SEARCH_ITERATIONS);
|
||||
const plan = db.prepare("EXPLAIN QUERY PLAN SELECT p.id FROM posts p JOIN posts_fts ON posts_fts.rowid=p.id WHERE p.visibility='PUBLIC' AND p.hidden=0 AND posts_fts MATCH ? ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 20").all(query);
|
||||
const statement = db.prepare("SELECT p.id FROM posts p JOIN posts_fts ON posts_fts.rowid=p.id WHERE p.visibility='PUBLIC' AND p.hidden=0 AND posts_fts MATCH ? ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 20"); const start = performance.now(); let resultCount = 0; for (let index = 0; index < iterations; index++) resultCount = statement.all(query).length; const elapsed = performance.now() - start;
|
||||
console.log(JSON.stringify({ query: process.env.SEARCH_QUERY, iterations, resultCount, totalMs: Number(elapsed.toFixed(2)), averageMs: Number((elapsed / iterations).toFixed(3)), queryPlan: plan.map((row) => row.detail) }, null, 2)); db.close();
|
||||
NODE
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const output = resolve(process.argv[2] || "artifacts/mebbling.spdx.json");
|
||||
const lock = JSON.parse(await readFile("package-lock.json", "utf8"));
|
||||
const packages = Object.entries(lock.packages)
|
||||
.filter(([path, value]) => path.startsWith("node_modules/") && value.version)
|
||||
.map(([path, value]) => {
|
||||
const name = path.slice("node_modules/".length);
|
||||
return { SPDXID: `SPDXRef-Package-${name.replace(/[^A-Za-z0-9.-]/g, "-")}`, name, versionInfo: value.version, downloadLocation: value.resolved || "NOASSERTION", licenseConcluded: "NOASSERTION", licenseDeclared: value.license || "NOASSERTION", checksums: value.integrity ? [{ algorithm: "SHA512", checksumValue: value.integrity.replace(/^sha512-/, "") }] : [], externalRefs: [{ referenceCategory: "PACKAGE-MANAGER", referenceType: "purl", referenceLocator: `pkg:npm/${encodeURIComponent(name).replace("%40", "@")}@${value.version}` }] };
|
||||
});
|
||||
const sbom = { spdxVersion: "SPDX-2.3", dataLicense: "CC0-1.0", SPDXID: "SPDXRef-DOCUMENT", name: "mebbling", documentNamespace: `https://gitea.fishking.studio/tangsongdayo/Mebbling/sbom/${lock.version}`, creationInfo: { created: new Date().toISOString(), creators: ["Tool: Mebbling package-lock SBOM generator"] }, packages: [{ SPDXID: "SPDXRef-Mebbling", name: lock.name, versionInfo: lock.version, downloadLocation: "NOASSERTION", licenseConcluded: "NOASSERTION", licenseDeclared: lock.packages[""].license || "NOASSERTION" }, ...packages], relationships: packages.map((item) => ({ spdxElementId: "SPDXRef-Mebbling", relationshipType: "DEPENDS_ON", relatedSpdxElement: item.SPDXID })) };
|
||||
await mkdir(dirname(output), { recursive: true });
|
||||
await writeFile(output, `${JSON.stringify(sbom, null, 2)}\n`);
|
||||
console.log(`Generated SPDX SBOM with ${packages.length} packages: ${output}`);
|
||||
+1
-1
@@ -4,8 +4,8 @@ set -euo pipefail
|
||||
if [[ $# -ne 1 ]]; then echo "Usage: CONFIRM_RESTORE=YES ./scripts/restore.sh data/backups/YYYYMMDD-HHMMSS" >&2; exit 2; fi
|
||||
if [[ "${CONFIRM_RESTORE:-}" != "YES" ]]; then echo "Refusing restore. Set CONFIRM_RESTORE=YES after verifying the backup path." >&2; exit 2; fi
|
||||
backup_dir="$1"; [[ -f "$backup_dir/hub.db" && -f "$backup_dir/uploads.tar.gz" && -f "$backup_dir/SHA256SUMS" ]] || { echo "Backup is incomplete" >&2; exit 2; }
|
||||
(cd "$backup_dir" && sha256sum -c SHA256SUMS)
|
||||
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; cd "$root_dir"
|
||||
"$root_dir/scripts/verify-backup.sh" "$backup_dir"
|
||||
docker compose down
|
||||
mkdir -p data public
|
||||
mv data/hub.db "data/hub.db.before-restore.$(date +%Y%m%d-%H%M%S)" 2>/dev/null || true
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
base_url="${1:-http://localhost:8088}"
|
||||
base_url="${base_url%/}"
|
||||
get() { curl --fail --silent --show-error "$@"; }
|
||||
|
||||
live="$(get "$base_url/api/health?probe=live")"
|
||||
ready="$(get "$base_url/api/health?probe=ready")"
|
||||
home="$(get "$base_url/")"
|
||||
rss_headers="$(get -I "$base_url/rss.xml")"
|
||||
if [[ -n "${METRICS_TOKEN:-}" ]]; then metrics="$(get -H "Authorization: Bearer $METRICS_TOKEN" "$base_url/api/metrics")"; else metrics="$(get "$base_url/api/metrics")"; fi
|
||||
|
||||
[[ "$live" == *'"status":"live"'* ]] || { echo "Liveness probe did not return live" >&2; exit 1; }
|
||||
[[ "$ready" == *'"status":"ready"'* && "$ready" == *'"database":"ok"'* ]] || { echo "Readiness probe did not confirm database" >&2; exit 1; }
|
||||
[[ "$home" == *'<title>Mebbling</title>'* ]] || { echo "Home page title missing" >&2; exit 1; }
|
||||
[[ "${rss_headers,,}" == *'content-type: application/rss+xml'* ]] || { echo "RSS content type missing" >&2; exit 1; }
|
||||
[[ "$metrics" == *'mebbling_sources_enabled'* ]] || { echo "Metrics response is incomplete" >&2; exit 1; }
|
||||
printf 'Smoke test passed: %s\n' "$base_url"
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then echo "Usage: ./scripts/verify-backup.sh data/backups/YYYYMMDD-HHMMSS" >&2; exit 2; fi
|
||||
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
backup_dir="$(cd "$1" && pwd)"
|
||||
[[ "$backup_dir" == "$root_dir"/* ]] || { echo "Backup must be inside the project directory" >&2; exit 2; }
|
||||
[[ -f "$backup_dir/hub.db" && -f "$backup_dir/uploads.tar.gz" && -f "$backup_dir/SHA256SUMS" ]] || { echo "Backup is incomplete" >&2; exit 2; }
|
||||
(cd "$backup_dir" && sha256sum -c SHA256SUMS)
|
||||
tar -tzf "$backup_dir/uploads.tar.gz" >/dev/null
|
||||
container_path="/app/${backup_dir#"$root_dir"/}/hub.db"
|
||||
docker compose -f "$root_dir/docker-compose.yml" exec -T -e BACKUP_PATH="$container_path" web node -e '
|
||||
const Database = require("better-sqlite3"); const db = new Database(process.env.BACKUP_PATH, { readonly: true });
|
||||
const row = db.prepare("PRAGMA integrity_check").get(); db.close(); if (row.integrity_check !== "ok") { console.error("SQLite integrity check failed"); process.exit(1); }
|
||||
'
|
||||
printf 'Verified backup: %s\n' "$backup_dir"
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, test } from "node:test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { rmSync } from "node:fs";
|
||||
|
||||
const databasePath = `/tmp/mebbling-alerts-${randomUUID()}.db`;
|
||||
process.env.DATABASE_PATH = databasePath;
|
||||
process.env.ALERT_WEBHOOK_URL = "https://alerts.example.test/hook";
|
||||
delete process.env.HUB_BUILD;
|
||||
|
||||
let database: typeof import("../lib/db").db | undefined;
|
||||
|
||||
after(() => { database?.close(); rmSync(databasePath, { force: true }); rmSync(`${databasePath}-wal`, { force: true }); rmSync(`${databasePath}-shm`, { force: true }); delete process.env.ALERT_WEBHOOK_URL; });
|
||||
|
||||
test("persists failed alerts for exponential-backoff retry", async () => {
|
||||
const { db } = await import("../lib/db"); database = db;
|
||||
const { deliverNextAlert, sendAlert } = await import("../lib/alerts");
|
||||
assert.equal(sendAlert("test", "Test alert", "delivery should retry"), true);
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response("unavailable", { status: 503 });
|
||||
try { assert.equal(await deliverNextAlert(), true); } finally { globalThis.fetch = originalFetch; }
|
||||
const delivery = db.prepare("SELECT status,attempts,last_error FROM alert_deliveries").get() as { status: string; attempts: number; last_error: string };
|
||||
assert.equal(delivery.status, "queued");
|
||||
assert.equal(delivery.attempts, 1);
|
||||
assert.match(delivery.last_error, /503/);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { checkRuntimeConfig } from "../lib/config";
|
||||
|
||||
test("requires secure production secrets and a public HTTPS origin", () => {
|
||||
assert.equal(checkRuntimeConfig({ NODE_ENV: "production", SESSION_SECRET: "x".repeat(32), TOKEN_ENCRYPTION_KEY: "a".repeat(64), NEXT_PUBLIC_APP_URL: "https://hub.example.test" }).ok, true);
|
||||
const invalid = checkRuntimeConfig({ NODE_ENV: "production", SESSION_SECRET: "development-only-change-me", TOKEN_ENCRYPTION_KEY: "wrong", NEXT_PUBLIC_APP_URL: "http://localhost:8088" });
|
||||
assert.equal(invalid.ok, false); assert.equal(invalid.errors.length, 3);
|
||||
assert.equal(checkRuntimeConfig({ NODE_ENV: "test" }).ok, true);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { after, test } from "node:test";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { rmSync } from "node:fs";
|
||||
|
||||
const databasePath = `/tmp/mebbling-invites-${randomUUID()}.db`;
|
||||
process.env.DATABASE_PATH = databasePath;
|
||||
delete process.env.HUB_BUILD;
|
||||
let database: typeof import("../lib/db").db | undefined;
|
||||
after(() => { database?.close(); rmSync(databasePath, { force: true }); rmSync(`${databasePath}-wal`, { force: true }); rmSync(`${databasePath}-shm`, { force: true }); });
|
||||
|
||||
test("consumes a source invitation exactly once and grants its role", async () => {
|
||||
const { db } = await import("../lib/db"); database = db;
|
||||
const { acceptSourceInvite } = await import("../lib/invites");
|
||||
const owner = Number(db.prepare("INSERT INTO users(username,password_hash) VALUES('owner','hash')").run().lastInsertRowid);
|
||||
const member = Number(db.prepare("INSERT INTO users(username,password_hash) VALUES('member','hash')").run().lastInsertRowid);
|
||||
const source = Number(db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted) VALUES(?,?,?,?)").run(owner, "Source", "https://example.test", "encrypted").lastInsertRowid);
|
||||
const token = "single-use-token";
|
||||
db.prepare("INSERT INTO source_invites(source_id,token_hash,role,expires_at,created_by) VALUES(?,?,?,datetime('now','+1 day'),?)").run(source, createHash("sha256").update(token).digest("hex"), "editor", owner);
|
||||
assert.deepEqual(acceptSourceInvite(member, token), { id: 1, source_id: source, role: "editor" });
|
||||
assert.equal((db.prepare("SELECT role FROM source_members WHERE source_id=? AND user_id=?").get(source, member) as { role: string }).role, "editor");
|
||||
assert.throws(() => acceptSourceInvite(member, token), /已使用/);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { parseFeed } from "../lib/rss";
|
||||
|
||||
test("parses RSS and Atom items without evaluating XML declarations", () => {
|
||||
const rss = parseFeed("<rss><channel><item><guid>one</guid><link>https://example.test/one</link><description><![CDATA[Hello]]></description><pubDate>2026-07-19T00:00:00Z</pubDate></item></channel></rss>");
|
||||
assert.deepEqual(rss[0], { id: "one", link: "https://example.test/one", content: "Hello", publishedAt: "2026-07-19T00:00:00.000Z" });
|
||||
const atom = parseFeed("<feed><entry><id>two</id><link href=\"https://example.test/two\"/><summary>World</summary><updated>2026-07-19T01:00:00Z</updated></entry></feed>");
|
||||
assert.deepEqual(atom[0], { id: "two", link: "https://example.test/two", content: "World", publishedAt: "2026-07-19T01:00:00.000Z" });
|
||||
assert.throws(() => parseFeed("<!DOCTYPE feed><feed />"), /unsupported XML/);
|
||||
});
|
||||
+2
-1
@@ -16,7 +16,7 @@ test("applies tracked migrations and deduplicates active pull jobs", async () =>
|
||||
const { queuePull } = await import("../lib/sync");
|
||||
const { notify } = await import("../lib/notifications");
|
||||
const migrations = db.prepare("SELECT version FROM schema_migrations ORDER BY version").all() as { version: number }[];
|
||||
assert.deepEqual(migrations.map((item) => item.version), Array.from({ length: 38 }, (_, index) => index + 1));
|
||||
assert.deepEqual(migrations.map((item) => item.version), Array.from({ length: 41 }, (_, index) => index + 1));
|
||||
const userId = Number(db.prepare("INSERT INTO users(username,password_hash) VALUES('sync-test','hash')").run().lastInsertRowid);
|
||||
const sourceId = Number(db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,is_enabled) VALUES(?,?,?,?,1)").run(userId, "Test", "https://example.test", "encrypted").lastInsertRowid);
|
||||
assert.equal(queuePull(sourceId, "manual"), true);
|
||||
@@ -25,6 +25,7 @@ test("applies tracked migrations and deduplicates active pull jobs", async () =>
|
||||
assert.deepEqual(jobs, [{ kind: "pull", trigger: "manual", status: "queued" }]);
|
||||
const actorId = Number(db.prepare("INSERT INTO users(username,password_hash) VALUES('actor-test','hash')").run().lastInsertRowid);
|
||||
const postId = Number(db.prepare("INSERT INTO posts(author_id,content) VALUES(?,?)").run(userId, "Notification test").lastInsertRowid);
|
||||
assert.equal(Number((db.prepare("SELECT count(*) AS count FROM posts_fts WHERE posts_fts MATCH 'Notification'").get() as { count: number }).count), 1);
|
||||
notify(userId, actorId, postId, "comment", "commented"); notify(userId, userId, postId, "reaction", "ignored");
|
||||
assert.deepEqual(db.prepare("SELECT type,message FROM notifications WHERE user_id=?").all(userId), [{ type: "comment", message: "commented" }]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHmac } from "node:crypto";
|
||||
import { test } from "node:test";
|
||||
import { standardWebhookMatches, webhookSecretHash, webhookSecretMatches } from "../lib/webhook";
|
||||
|
||||
test("verifies path secrets using a constant-time hash comparison", () => {
|
||||
const secret = "path-secret"; assert.equal(webhookSecretMatches(secret, webhookSecretHash(secret)), true); assert.equal(webhookSecretMatches("wrong", webhookSecretHash(secret)), false); assert.equal(webhookSecretMatches(secret, null), false);
|
||||
});
|
||||
|
||||
test("accepts only fresh v1 standard webhooks with an exact body signature", () => {
|
||||
const secret = "signing-secret", id = "event-1", timestamp = String(Math.floor(Date.now() / 1000)), body = '{"type":"memo.updated"}';
|
||||
const signature = createHmac("sha256", secret).update(`${id}.${timestamp}.${body}`).digest("base64");
|
||||
assert.equal(standardWebhookMatches(secret, id, timestamp, `v1,${signature}`, body), true);
|
||||
assert.equal(standardWebhookMatches(secret, id, timestamp, `v1,${signature}`, "{}"), false);
|
||||
assert.equal(standardWebhookMatches(secret, id, "not-a-time", `v1,${signature}`, body), false);
|
||||
assert.equal(standardWebhookMatches(secret, id, timestamp, `v2,${signature}`, body), false);
|
||||
assert.equal(standardWebhookMatches(secret, id, String(Math.floor(Date.now() / 1000) - 301), `v1,${signature}`, body), false);
|
||||
});
|
||||
+2
-2
@@ -6,7 +6,7 @@ import { decrypt } from "../lib/crypto";
|
||||
import { createMemo, createRemoteFile, getMemosIdentity, listMemos, memoUrl, setMemoAttachments } from "../lib/memos";
|
||||
import { recordError } from "../lib/observability";
|
||||
import { fetchRss } from "../lib/rss";
|
||||
import { sendAlert } from "../lib/alerts";
|
||||
import { deliverNextAlert, sendAlert } from "../lib/alerts";
|
||||
|
||||
type Source = { id: number; user_id: number; base_url: string; token_encrypted: string; remote_user: string | null; integration_type: "memos" | "rss"; rss_feed_url: string | null; is_enabled: number; sync_tags_json: string; sync_from: string | null; sync_to: string | null; sync_attachment_mode: "all" | "images" | "none"; attachment_storage_mode: "remote" | "images" | "all"; attachment_cache_limit_bytes: number; attachment_archive_after_days: number | null; sync_batch_size: number; sync_max_posts: number | null; sync_cursor: string | null; sync_imported_count: number; sync_run_id: string | null };
|
||||
type Job = { id: number; source_id: number; kind: "pull" | "push"; payload_json: string | null; attempts: number };
|
||||
@@ -122,4 +122,4 @@ function webhookHealth() {
|
||||
for (const source of stale) void sendAlert(`webhook:${source.id}`, "Mebbling Webhook 未收到事件", `來源「${source.name}」超過 7 天未收到 Webhook;目前仍會以定期 API 同步校正。`);
|
||||
}
|
||||
|
||||
let lastRetention = 0, lastWebhookHealth = 0; setInterval(() => { schedule(); if (Date.now() - lastRetention > 86_400_000) { retention(); lastRetention = Date.now(); } if (Date.now() - lastWebhookHealth > 3_600_000) { webhookHealth(); lastWebhookHealth = Date.now(); } void run(); }, 5000); schedule(); retention(); webhookHealth(); void run();
|
||||
let lastRetention = 0, lastWebhookHealth = 0; setInterval(() => { schedule(); if (Date.now() - lastRetention > 86_400_000) { retention(); lastRetention = Date.now(); } if (Date.now() - lastWebhookHealth > 3_600_000) { webhookHealth(); lastWebhookHealth = Date.now(); } void run(); void deliverNextAlert(); }, 5000); schedule(); retention(); webhookHealth(); void run(); void deliverNextAlert();
|
||||
|
||||
Reference in New Issue
Block a user