16 Commits
46 changed files with 549 additions and 46 deletions
+3 -1
View File
@@ -14,8 +14,10 @@ SYNC_INTERVAL_MINUTES=60
NOTIFICATION_RETENTION_DAYS=0 NOTIFICATION_RETENTION_DAYS=0
READING_HISTORY_RETENTION_DAYS=0 READING_HISTORY_RETENTION_DAYS=0
AUDIT_RETENTION_DAYS=365 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= 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. # Optional: create the first Memos source for the bootstrap admin.
SEED_MEMOS_NAME= SEED_MEMOS_NAME=
SEED_MEMOS_URL= SEED_MEMOS_URL=
+6
View File
@@ -14,8 +14,14 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: { node-version: 22, cache: npm } with: { node-version: 22, cache: npm }
- run: npm ci - run: npm ci
- run: npm audit --omit=dev --audit-level=high
- run: npx tsc --noEmit - run: npx tsc --noEmit
- run: npm test - 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 }} . - 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. # 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 != '' - if: startsWith(gitea.ref, 'refs/tags/v') && secrets.DEPLOY_WEBHOOK_URL != ''
+29
View File
@@ -4,6 +4,35 @@
## [Unreleased] ## [Unreleased]
### Fixed
- Constrained remote Memos avatars to a fixed, cropped size so oversized source images cannot break dashboard or source-page layouts.
- Updated Memos article links for UID-based releases while retaining legacy numeric-ID links.
- Standardized article-page and share-metadata author labels with the source account identity.
### Changed
- Changed author labels for Memos and Hub-to-Memos posts from duplicated source names to `@remote-user@source-hostname` identities.
## [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 ## [0.7.0] - 2026-07-19
### Fixed ### Fixed
+13 -2
View File
@@ -2,7 +2,7 @@
自架的 Memos 公開貼文 Hub。將朋友各自 Memos 中的公開貼文集中展示,同時保留 Hub 內的留言、表情回應與發文功能。 自架的 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 docker compose logs -f web worker
``` ```
部署後可執行公開端點冒煙測試(若 metrics 有設 token,先匯出同一個 `METRICS_TOKEN`):
```bash
./scripts/smoke-test.sh https://你的網域
```
## 環境變數 ## 環境變數
`.env.example` 為範本。正式環境請更換所有 secret,且不要將 `.env` 加入 Git。 `.env.example` 為範本。正式環境請更換所有 secret,且不要將 `.env` 加入 Git。
@@ -60,6 +66,8 @@ docker compose logs -f web worker
| `UPLOAD_ALLOWED_TYPES` | 逗號分隔的 Hub 附件 MIME 白名單。 | | `UPLOAD_ALLOWED_TYPES` | 逗號分隔的 Hub 附件 MIME 白名單。 |
| `VIRUS_SCAN_URL` / `VIRUS_SCAN_REQUIRED` | 選用的 HTTP 掃毒服務;服務需回傳 `{ "clean": true }`。若 required 為 `1`,掃毒不可用時拒絕上傳。 | | `VIRUS_SCAN_URL` / `VIRUS_SCAN_REQUIRED` | 選用的 HTTP 掃毒服務;服務需回傳 `{ "clean": true }`。若 required 為 `1`,掃毒不可用時拒絕上傳。 |
| `SYNC_INTERVAL_MINUTES` | 背景校正同步的間隔,預設 60 分鐘。 | | `SYNC_INTERVAL_MINUTES` | 背景校正同步的間隔,預設 60 分鐘。 |
| `ALERT_WEBHOOK_URL` | 選填的 HTTPS Discord webhook 或 ntfy topic;同步及 webhook 異常會保存後投遞,失敗最多重試 5 次。 |
| `METRICS_TOKEN` | 選填的 `/api/metrics` Bearer Token;未設定時務必由網路/反向代理限制存取。 |
| `SEED_MEMOS_*` | 選填;首次啟動時自動建立管理員的第一個 Memos 來源。 | | `SEED_MEMOS_*` | 選填;首次啟動時自動建立管理員的第一個 Memos 來源。 |
## 系統架構 ## 系統架構
@@ -88,10 +96,11 @@ Web 接收使用者操作和 webhook,將同步需求寫入 SQLite 的 `sync_jo
## 正式營運與監控 ## 正式營運與監控
- `GET /api/health`:供反向代理或監控工具檢查服務與 SQLite 狀態,也會回傳失敗同步工作數與版本。 - `GET /api/health`:供反向代理或監控工具檢查服務與 SQLite 狀態,也會回傳失敗同步工作數與版本。
- Web、Worker 的事件輸出為 JSON;同步錯誤同時保存於管理頁的「最近系統錯誤」。 - Web、Worker 的事件輸出為 JSON;同步錯誤同時保存於管理頁的「最近系統錯誤」。告警 webhook 會以 SQLite 佇列投遞、指數退避重試五次,並以 `mebbling_alert_deliveries` metrics 暴露狀態。
- 登入在 15 分鐘內最多嘗試 8 次;webhook 與登入限流資料存於 SQLite,同一份資料庫的多個 Web 容器會共用計數。 - 登入在 15 分鐘內最多嘗試 8 次;webhook 與登入限流資料存於 SQLite,同一份資料庫的多個 Web 容器會共用計數。
- 所有會改變帳號或內容的瀏覽器 POST 都檢查 `Origin`Webhook 則使用密鑰 URL 驗證,不適用此規則。 - 所有會改變帳號或內容的瀏覽器 POST 都檢查 `Origin`Webhook 則使用密鑰 URL 驗證,不適用此規則。
- Gitea Actions 工作流程會在推送/標籤時執行型別檢查、測試與 Docker 建置;若設定 `DEPLOY_WEBHOOK_URL` secret,建立 `v*` tag 時會通知部署端。 - Gitea Actions 工作流程會在推送/標籤時執行型別檢查、測試與 Docker 建置;若設定 `DEPLOY_WEBHOOK_URL` secret,建立 `v*` tag 時會通知部署端。
- 工作流程也會對 production dependencies 執行高風險漏洞檢查,並產生 SPDX SBOM artifact;發布、升級與日後映像簽章的流程見 [發布文件](docs/RELEASING.md)。
## Webhook 設定與驗證 ## Webhook 設定與驗證
@@ -135,6 +144,8 @@ https://你的網域/api/sync/webhook/來源ID/隨機密鑰
├── data/ # SQLite 資料庫持久化資料 ├── data/ # SQLite 資料庫持久化資料
├── Dockerfile # WebWorker 共用映像檔 ├── Dockerfile # WebWorker 共用映像檔
├── docker-compose.yml # web + worker 服務與 volume 掛載 ├── docker-compose.yml # web + worker 服務與 volume 掛載
├── scripts/ # 備份、驗證、還原與效能基準工具
├── docs/ # 維運與擴展文件
└── .env.example # 環境變數範本 └── .env.example # 環境變數範本
``` ```
+3
View File
@@ -1,6 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth"; import { getSession } from "@/lib/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { checkRuntimeConfig } from "@/lib/config";
export const dynamic = "force-dynamic"; 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 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 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 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>} 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>{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> <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>{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>{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><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>
</>; </>;
} }
+4
View File
@@ -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 }); } }
+4 -2
View File
@@ -2,10 +2,12 @@ import { NextResponse } from "next/server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
export const dynamic = "force-dynamic"; 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 { try {
db.prepare("SELECT 1").get(); 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); 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 }); } } catch { return NextResponse.json({ ok: false, database: "error" }, { status: 503 }); }
} }
+2 -3
View File
@@ -1,8 +1,7 @@
import { createHash } from "node:crypto";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth"; import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { audit } from "@/lib/audit"; import { audit } from "@/lib/audit";
import { acceptSourceInvite } from "@/lib/invites";
import { externalUrl } from "@/lib/http"; import { externalUrl } from "@/lib/http";
import { requireSameOrigin } from "@/lib/security"; 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"))); } }
+5
View File
@@ -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" } }); }
+12
View File
@@ -0,0 +1,12 @@
.avatar {
display: inline-block;
width: 2rem;
height: 2rem;
max-width: 2rem;
max-height: 2rem;
object-fit: cover;
object-position: center;
border-radius: 50%;
vertical-align: middle;
flex: 0 0 2rem;
}
+3 -2
View File
@@ -2,10 +2,11 @@ import Link from "next/link";
import { Attachments } from "./attachments"; import { Attachments } from "./attachments";
import { Markdown } from "./markdown"; import { Markdown } from "./markdown";
import { canonicalTags } from "@/lib/tags"; import { canonicalTags } from "@/lib/tags";
import { postAuthorLabel } from "@/lib/display";
export type PublicPost = { id: number; source_id: number | null; origin: string; content: string; tags_json: string; attachments_json: string; created_at: string; remote_created_at?: string | null; username: string; name: string | null; remote_display_name?: string | null; source_base_url: string | null; comment_count: number; reaction_count: number }; export type PublicPost = { id: number; source_id: number | null; origin: string; content: string; tags_json: string; attachments_json: string; created_at: string; remote_created_at?: string | null; username: string; name: string | null; remote_user?: string | null; remote_display_name?: string | null; source_base_url: string | null; comment_count: number; reaction_count: number };
export function PostCard({ post }: { post: PublicPost }) { export function PostCard({ post }: { post: PublicPost }) {
let tags: string[] = []; try { tags = canonicalTags(JSON.parse(post.tags_json)); } catch { /* Ignore malformed legacy tags. */ } let tags: string[] = []; try { tags = canonicalTags(JSON.parse(post.tags_json)); } catch { /* Ignore malformed legacy tags. */ }
const publishedAt = post.remote_created_at || post.created_at; const author = post.origin === "memos" ? (post.remote_display_name || post.name || post.username) : (post.name || post.username); return <article className="card"><div className="space"><Link className="meta post-name-link" href={`/posts/${post.id}`}>@{author}{post.name ? ` · ${post.name}` : ""}</Link><span className="meta">{new Date(publishedAt).toLocaleString("zh-TW")}</span></div><Markdown content={post.content} tags={tags} compact /><Attachments json={post.attachments_json} sourceBaseUrl={post.source_base_url} compact /><div className="row">{tags.map((tag) => <Link className="tag" href={`/tags/${encodeURIComponent(tag)}`} key={tag}>#{tag}</Link>)}{post.source_id && <Link className="tag" href={`/sources/${post.source_id}`}></Link>}<Link href={`/posts/${post.id}`}> · 💬 {post.comment_count} 🙂 {post.reaction_count}</Link></div></article>; const publishedAt = post.remote_created_at || post.created_at; return <article className="card"><div className="space"><Link className="meta post-name-link" href={`/posts/${post.id}`}>{postAuthorLabel(post)}</Link><span className="meta">{new Date(publishedAt).toLocaleString("zh-TW")}</span></div><Markdown content={post.content} tags={tags} compact /><Attachments json={post.attachments_json} sourceBaseUrl={post.source_base_url} compact /><div className="row">{tags.map((tag) => <Link className="tag" href={`/tags/${encodeURIComponent(tag)}`} key={tag}>#{tag}</Link>)}{post.source_id && <Link className="tag" href={`/sources/${post.source_id}`}></Link>}<Link href={`/posts/${post.id}`}> · 💬 {post.comment_count} 🙂 {post.reaction_count}</Link></div></article>;
} }
+1
View File
@@ -1,4 +1,5 @@
import "./styles.css"; import "./styles.css";
import "./avatar.css";
import Link from "next/link"; import Link from "next/link";
import { getSession } from "@/lib/auth"; import { getSession } from "@/lib/auth";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
+4 -3
View File
@@ -9,10 +9,11 @@ type Query = { q?: string; tag?: string; source?: string; author?: string; from?
export default async function Home({ searchParams }: { searchParams: Promise<Query> }) { 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 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)[] = []; 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 ftsQuery = q.split(/\s+/).filter(Boolean).map((term) => `"${term.replaceAll('"', '""')}"`).join(" AND ");
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 ")}`; 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 ? OR s.remote_user LIKE ?)"); args.push(`%${author}%`, `%${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 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 posts = db.prepare(`SELECT p.*,u.username,s.name,s.remote_user,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 }[]; const sources = db.prepare("SELECT id,name FROM sources WHERE is_enabled=1 ORDER BY name").all() as { id: number; name: string }[];
const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) if (value && key !== "page") params.set(key, value); const pageHref = (target: number) => { const next = new URLSearchParams(params); next.set("page", String(target)); return `/?${next}`; }; const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) if (value && key !== "page") params.set(key, value); const pageHref = (target: number) => { const next = new URLSearchParams(params); next.set("page", String(target)); return `/?${next}`; };
return <><section className="space"><div><h1> Memos Hub</h1><p className="muted"></p></div><Link className="button" href="/dashboard"></Link></section><form className="search-form" method="get"><input name="q" defaultValue={q} placeholder="搜尋公開貼文" /><input name="tag" defaultValue={tag} placeholder="標籤" /><input name="author" defaultValue={author} placeholder="作者" /><select name="source" defaultValue={sourceId || ""}><option value=""></option>{sources.map((source) => <option key={source.id} value={source.id}>{source.name}</option>)}</select><label><input name="from" type="date" defaultValue={from} /></label><label><input name="to" type="date" defaultValue={to} /></label><label className="check"><input name="attachments" type="checkbox" value="1" defaultChecked={attachments} /></label><button></button></form><p className="meta"> {total} </p>{posts.length ? posts.map((post) => <PostCard post={post} key={post.id} />) : <p className="muted"></p>}{pages > 1 && <nav className="pagination" aria-label="貼文分頁">{safePage > 1 && <Link href={pageHref(safePage - 1)}> </Link>}<span> {safePage}{pages} </span>{safePage < pages && <Link href={pageHref(safePage + 1)}> </Link>}</nav>}</>; return <><section className="space"><div><h1> Memos Hub</h1><p className="muted"></p></div><Link className="button" href="/dashboard"></Link></section><form className="search-form" method="get"><input name="q" defaultValue={q} placeholder="搜尋公開貼文" /><input name="tag" defaultValue={tag} placeholder="標籤" /><input name="author" defaultValue={author} placeholder="作者" /><select name="source" defaultValue={sourceId || ""}><option value=""></option>{sources.map((source) => <option key={source.id} value={source.id}>{source.name}</option>)}</select><label><input name="from" type="date" defaultValue={from} /></label><label><input name="to" type="date" defaultValue={to} /></label><label className="check"><input name="attachments" type="checkbox" value="1" defaultChecked={attachments} /></label><button></button></form><p className="meta"> {total} </p>{posts.length ? posts.map((post) => <PostCard post={post} key={post.id} />) : <p className="muted"></p>}{pages > 1 && <nav className="pagination" aria-label="貼文分頁">{safePage > 1 && <Link href={pageHref(safePage - 1)}> </Link>}<span> {safePage}{pages} </span>{safePage < pages && <Link href={pageHref(safePage + 1)}> </Link>}</nav>}</>;
+6 -5
View File
@@ -5,25 +5,26 @@ import { getSession } from "@/lib/auth";
import { Attachments } from "@/app/components/attachments"; import { Attachments } from "@/app/components/attachments";
import { Markdown } from "@/app/components/markdown"; import { Markdown } from "@/app/components/markdown";
import { canonicalTags } from "@/lib/tags"; import { canonicalTags } from "@/lib/tags";
import { postAuthorLabel } from "@/lib/display";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> { export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id: rawId } = await params; const post = db.prepare("SELECT p.content,p.hidden,p.visibility,u.username,s.name FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.id=?").get(Number(rawId)) as { content: string; hidden: number; visibility: string; username: string; name: string | null } | undefined; const { id: rawId } = await params; const post = db.prepare("SELECT p.content,p.hidden,p.visibility,p.origin,u.username,s.name,s.remote_user,s.base_url AS source_base_url FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.id=?").get(Number(rawId)) as { content: string; hidden: number; visibility: string; origin: string; username: string; name: string | null; remote_user: string | null; source_base_url: string | null } | undefined;
if (!post || post.hidden || post.visibility !== "PUBLIC") return { title: "找不到貼文" }; if (!post || post.hidden || post.visibility !== "PUBLIC") return { title: "找不到貼文" };
const description = post.content.replace(/\s+/g, " ").slice(0, 160); const description = post.content.replace(/\s+/g, " ").slice(0, 160); const author = postAuthorLabel(post);
return { title: `@${post.username} 的貼文|Mebbling`, description, openGraph: { title: `@${post.username}${post.name ? ` · ${post.name}` : ""}Mebbling`, description, type: "article" } }; return { title: `${author} 的貼文|Mebbling`, description, openGraph: { title: `${author}Mebbling`, description, type: "article" } };
} }
export default async function PostPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ reported?: string }> }) { export default async function PostPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ reported?: string }> }) {
const { id: rawId } = await params; const id = Number(rawId); const { id: rawId } = await params; const id = Number(rawId);
const post = db.prepare("SELECT p.*,u.username,s.name,s.base_url AS source_base_url,s.remote_display_name FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.id=?").get(id) as any; const query = await searchParams; const post = db.prepare("SELECT p.*,u.username,s.name,s.base_url AS source_base_url,s.remote_user,s.remote_display_name FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.id=?").get(id) as any; const query = await searchParams;
if (!post || post.hidden) notFound(); const user = await getSession(); if (post.visibility !== "PUBLIC" && post.author_id !== user?.id) redirect("/"); if (!post || post.hidden) notFound(); const user = await getSession(); if (post.visibility !== "PUBLIC" && post.author_id !== user?.id) redirect("/");
if (user && post.visibility === "PUBLIC") db.prepare("INSERT INTO reading_history(user_id,post_id) VALUES(?,?) ON CONFLICT(user_id,post_id) DO UPDATE SET last_read_at=CURRENT_TIMESTAMP").run(user.id, id); if (user && post.visibility === "PUBLIC") db.prepare("INSERT INTO reading_history(user_id,post_id) VALUES(?,?) ON CONFLICT(user_id,post_id) DO UPDATE SET last_read_at=CURRENT_TIMESTAMP").run(user.id, id);
const bookmark = user ? db.prepare("SELECT kind FROM bookmarks WHERE user_id=? AND post_id=?").get(user.id, id) as { kind: string } | undefined : undefined; const bookmark = user ? db.prepare("SELECT kind FROM bookmarks WHERE user_id=? AND post_id=?").get(user.id, id) as { kind: string } | undefined : undefined;
const comments = db.prepare("SELECT c.*,u.username FROM comments c JOIN users u ON u.id=c.author_id WHERE c.post_id=? AND c.hidden=0 ORDER BY c.created_at").all(id) as any[]; const comments = db.prepare("SELECT c.*,u.username FROM comments c JOIN users u ON u.id=c.author_id WHERE c.post_id=? AND c.hidden=0 ORDER BY c.created_at").all(id) as any[];
const reactions = db.prepare("SELECT emoji,count(*) count FROM reactions WHERE post_id=? GROUP BY emoji").all(id) as any[]; const reactions = db.prepare("SELECT emoji,count(*) count FROM reactions WHERE post_id=? GROUP BY emoji").all(id) as any[];
let tags: string[] = []; try { tags = canonicalTags(JSON.parse(post.tags_json)); } catch {} const publishedAt = post.remote_created_at || post.created_at; const canExport = Boolean(user && (post.author_id === user.id || (post.source_id && db.prepare("SELECT 1 FROM source_members WHERE source_id=? AND user_id=?").get(post.source_id, user.id)))); return <article><p className="meta">@{post.username} · {post.remote_display_name || post.name || "Hub"} · {new Date(publishedAt).toLocaleString("zh-TW")}{post.remote_url && <> · <a href={post.remote_url} target="_blank" rel="noreferrer"> Memos </a></>}</p>{canExport && <p className="row"><a href={`/api/export/posts/${id}?format=json`}> JSON</a><a href={`/api/export/posts/${id}?format=markdown`}> Markdown</a></p>}<section className="card"><Markdown content={post.content} tags={tags} /></section><Attachments json={post.attachments_json} sourceBaseUrl={post.source_base_url} /> let tags: string[] = []; try { tags = canonicalTags(JSON.parse(post.tags_json)); } catch {} const publishedAt = post.remote_created_at || post.created_at; const canExport = Boolean(user && (post.author_id === user.id || (post.source_id && db.prepare("SELECT 1 FROM source_members WHERE source_id=? AND user_id=?").get(post.source_id, user.id)))); return <article><p className="meta">{postAuthorLabel(post)} · {new Date(publishedAt).toLocaleString("zh-TW")}{post.remote_url && <> · <a href={post.remote_url} target="_blank" rel="noreferrer"> Memos </a></>}</p>{canExport && <p className="row"><a href={`/api/export/posts/${id}?format=json`}> JSON</a><a href={`/api/export/posts/${id}?format=markdown`}> Markdown</a></p>}<section className="card"><Markdown content={post.content} tags={tags} /></section><Attachments json={post.attachments_json} sourceBaseUrl={post.source_base_url} />
<section className="row">{reactions.map((reaction: any) => <span className="tag" key={reaction.emoji}>{reaction.emoji} {reaction.count}</span>)}{user && <><form action="/api/bookmarks" method="post"><input type="hidden" name="postId" value={id} /><input type="hidden" name="kind" value="saved" /><button>{bookmark?.kind === "saved" ? "取消收藏" : "收藏"}</button></form><form action="/api/bookmarks" method="post"><input type="hidden" name="postId" value={id} /><input type="hidden" name="kind" value="later" /><button>{bookmark?.kind === "later" ? "取消稍後閱讀" : "稍後閱讀"}</button></form></>}{user && ["👍", "❤️", "🎉", "🤔"].map((emoji) => <form action="/api/reactions" method="post" key={emoji}><input type="hidden" name="postId" value={id} /><input type="hidden" name="emoji" value={emoji} /><button>{emoji}</button></form>)}</section> <section className="row">{reactions.map((reaction: any) => <span className="tag" key={reaction.emoji}>{reaction.emoji} {reaction.count}</span>)}{user && <><form action="/api/bookmarks" method="post"><input type="hidden" name="postId" value={id} /><input type="hidden" name="kind" value="saved" /><button>{bookmark?.kind === "saved" ? "取消收藏" : "收藏"}</button></form><form action="/api/bookmarks" method="post"><input type="hidden" name="postId" value={id} /><input type="hidden" name="kind" value="later" /><button>{bookmark?.kind === "later" ? "取消稍後閱讀" : "稍後閱讀"}</button></form></>}{user && ["👍", "❤️", "🎉", "🤔"].map((emoji) => <form action="/api/reactions" method="post" key={emoji}><input type="hidden" name="postId" value={id} /><input type="hidden" name="emoji" value={emoji} /><button>{emoji}</button></form>)}</section>
<section><h2></h2>{user ? <><form action="/api/comments" method="post"><input type="hidden" name="postId" value={id} /><textarea name="content" required placeholder="在 Hub 留下留言" /><button></button></form><details><summary></summary>{query.reported && <p></p>}<form action="/api/reports" method="post"><input type="hidden" name="postId" value={id} /><label><input name="reason" required minLength={3} maxLength={500} /></label><button className="danger"></button></form></details></> : <p></p>}{comments.map((comment) => <div className="card" key={comment.id}><strong>@{comment.username}</strong><p>{comment.content}</p><span className="meta">{new Date(comment.created_at).toLocaleString("zh-TW")}</span></div>)}</section> <section><h2></h2>{user ? <><form action="/api/comments" method="post"><input type="hidden" name="postId" value={id} /><textarea name="content" required placeholder="在 Hub 留下留言" /><button></button></form><details><summary></summary>{query.reported && <p></p>}<form action="/api/reports" method="post"><input type="hidden" name="postId" value={id} /><label><input name="reason" required minLength={3} maxLength={500} /></label><button className="danger"></button></form></details></> : <p></p>}{comments.map((comment) => <div className="card" key={comment.id}><strong>@{comment.username}</strong><p>{comment.content}</p><span className="meta">{new Date(comment.created_at).toLocaleString("zh-TW")}</span></div>)}</section>
</article>; </article>;
+1 -1
View File
@@ -7,7 +7,7 @@ import { getSession } from "@/lib/auth";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function SourcePage({ params }: { params: Promise<{ id: string }> }) { export default async function SourcePage({ params }: { params: Promise<{ id: string }> }) {
const { id: rawId } = await params; const id = Number(rawId); const source = db.prepare("SELECT id,name,base_url,remote_display_name,remote_avatar_url FROM sources WHERE id=?").get(id) as { id: number; name: string; base_url: string; remote_display_name: string | null; remote_avatar_url: string | null } | undefined; if (!source) notFound(); const { id: rawId } = await params; const id = Number(rawId); const source = db.prepare("SELECT id,name,base_url,remote_display_name,remote_avatar_url FROM sources WHERE id=?").get(id) as { id: number; name: string; base_url: string; remote_display_name: string | null; remote_avatar_url: string | null } | undefined; if (!source) notFound();
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 FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.source_id=? AND p.visibility='PUBLIC' AND p.hidden=0 ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 100").all(id) as PublicPost[]; const posts = db.prepare("SELECT p.*,u.username,s.name,s.remote_user,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 FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.source_id=? AND p.visibility='PUBLIC' AND p.hidden=0 ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 100").all(id) as PublicPost[];
const user = await getSession(); const member = Boolean(user && db.prepare("SELECT 1 FROM source_members WHERE source_id=? AND user_id=?").get(id, user.id)); const user = await getSession(); const member = Boolean(user && db.prepare("SELECT 1 FROM source_members WHERE source_id=? AND user_id=?").get(id, user.id));
return <><p><Link href="/"> </Link></p><h1>{source.name}</h1><p className="meta">{source.remote_avatar_url && <img className="avatar" src={source.remote_avatar_url} alt="" />} {source.remote_display_name || "Memos"}<br />{source.base_url} · {posts.length} </p>{member && <p className="row"><a href={`/api/export/sources/${id}?format=json`}> JSON</a><a href={`/api/export/sources/${id}?format=markdown`}> Markdown</a></p>}{posts.map((post) => <PostCard key={post.id} post={post} />)}</>; return <><p><Link href="/"> </Link></p><h1>{source.name}</h1><p className="meta">{source.remote_avatar_url && <img className="avatar" src={source.remote_avatar_url} alt="" />} {source.remote_display_name || "Memos"}<br />{source.base_url} · {posts.length} </p>{member && <p className="row"><a href={`/api/export/sources/${id}?format=json`}> JSON</a><a href={`/api/export/sources/${id}?format=markdown`}> Markdown</a></p>}{posts.map((post) => <PostCard key={post.id} post={post} />)}</>;
} }
+1 -1
View File
@@ -6,6 +6,6 @@ import { PostCard, type PublicPost } from "@/app/components/post-card";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function TagPage({ params }: { params: Promise<{ tag: string }> }) { export default async function TagPage({ params }: { params: Promise<{ tag: string }> }) {
const { tag: encoded } = await params; const tag = decodeURIComponent(encoded).trim(); if (!tag) notFound(); const { tag: encoded } = await params; const tag = decodeURIComponent(encoded).trim(); if (!tag) notFound();
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 FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.visibility='PUBLIC' AND p.hidden=0 AND p.tags_json LIKE ? ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 100").all(`%${JSON.stringify(tag).slice(1, -1)}%`) as PublicPost[]; const posts = db.prepare("SELECT p.*,u.username,s.name,s.remote_user,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 FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.visibility='PUBLIC' AND p.hidden=0 AND p.tags_json LIKE ? ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 100").all(`%${JSON.stringify(tag).slice(1, -1)}%`) as PublicPost[];
return <><p><Link href="/"> </Link></p><h1>#{tag}</h1><p className="muted">{posts.length} </p>{posts.map((post) => <PostCard key={post.id} post={post} />)}</>; return <><p><Link href="/"> </Link></p><h1>#{tag}</h1><p className="muted">{posts.length} </p>{posts.map((post) => <PostCard key={post.id} post={post} />)}</>;
} }
+4 -4
View File
@@ -1,10 +1,10 @@
services: services:
web: web:
image: mebbling:${MEBBLING_VERSION:-0.7.0} image: mebbling:${MEBBLING_VERSION:-0.8.0}
build: build:
context: . context: .
args: args:
APP_VERSION: "${MEBBLING_VERSION:-0.7.0}" APP_VERSION: "${MEBBLING_VERSION:-0.8.0}"
ports: ["8088:3000"] ports: ["8088:3000"]
env_file: .env env_file: .env
environment: { DATABASE_PATH: /app/data/hub.db } environment: { DATABASE_PATH: /app/data/hub.db }
@@ -13,11 +13,11 @@ services:
- ./public/uploads:/app/public/uploads - ./public/uploads:/app/public/uploads
restart: unless-stopped restart: unless-stopped
worker: worker:
image: mebbling:${MEBBLING_VERSION:-0.7.0} image: mebbling:${MEBBLING_VERSION:-0.8.0}
build: build:
context: . context: .
args: args:
APP_VERSION: "${MEBBLING_VERSION:-0.7.0}" APP_VERSION: "${MEBBLING_VERSION:-0.8.0}"
command: npm run worker command: npm run worker
env_file: .env env_file: .env
environment: { DATABASE_PATH: /app/data/hub.db } environment: { DATABASE_PATH: /app/data/hub.db }
+33
View File
@@ -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
View File
@@ -13,7 +13,11 @@
- `hub.db`:由正在執行的 SQLite 資料庫建立的一致性備份。 - `hub.db`:由正在執行的 SQLite 資料庫建立的一致性備份。
- `uploads.tar.gz`Hub 本機上傳的附件。 - `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 掛載點): 可設定保留與異地複製(例如掛載的 NAS、加密磁碟或 rclone 掛載點):
@@ -21,6 +25,16 @@
BACKUP_RETENTION_DAYS=30 BACKUP_OFFSITE_DIR=/mnt/nas/mebbling ./scripts/backup.sh 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 排程: 在 WSL 主機安裝每日 03:15 排程:
```bash ```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>` 抓取。
+17
View File
@@ -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 啟動時自動執行,請勿在升級後直接回退到舊映像。
+21
View File
@@ -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
View File
@@ -5,7 +5,34 @@ function allowed(bucket: string, seconds = 3600) {
if (row && row.reset_at > now) return false; 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; 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) { export function sendAlert(bucket: string, title: string, message: string) {
const url = process.env.ALERT_WEBHOOK_URL?.trim(); if (!url || !allowed(`alert:${bucket}`)) return false; if (!process.env.ALERT_WEBHOOK_URL?.trim() || !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; } 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;
} }
+10
View File
@@ -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("; ")}`); }
+33
View File
@@ -1,7 +1,9 @@
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import { mkdirSync } from "node:fs"; import { mkdirSync } from "node:fs";
import { dirname } from "node:path"; 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"); const path = process.env.HUB_BUILD === "1" ? ":memory:" : (process.env.DATABASE_PATH || "./data/hub.db");
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true }); if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
export const db = new Database(path); 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, id INTEGER PRIMARY KEY, scope TEXT NOT NULL, message TEXT NOT NULL, context_json TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP 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 ( CREATE TABLE IF NOT EXISTS source_invites (
id INTEGER PRIMARY KEY, source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE, 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, token_hash TEXT UNIQUE NOT NULL, role TEXT NOT NULL DEFAULT 'viewer', expires_at TEXT NOT NULL,
@@ -152,6 +160,31 @@ 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(36)").run();
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(37)").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"); 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 hasMemoUrlMigration = db.prepare("SELECT 1 FROM schema_migrations WHERE version=42").get();
if (!hasMemoUrlMigration) {
db.exec(`UPDATE posts SET remote_url=(SELECT rtrim(s.base_url,'/') FROM sources s WHERE s.id=posts.source_id) || CASE WHEN substr(remote_memo_name,7)<>'' AND substr(remote_memo_name,7) NOT GLOB '*[^0-9]*' THEN '/m/' || substr(remote_memo_name,7) ELSE '/' || remote_memo_name END WHERE origin='memos' AND remote_memo_name LIKE 'memos/%' AND source_id IS NOT NULL`);
db.prepare("INSERT INTO schema_migrations(version) VALUES(42)").run();
}
const admin = process.env.ADMIN_USERNAME; const admin = process.env.ADMIN_USERNAME;
const adminPassword = process.env.ADMIN_PASSWORD; const adminPassword = process.env.ADMIN_PASSWORD;
+17
View File
@@ -0,0 +1,17 @@
export type AuthorDisplay = {
origin: string;
username: string;
name: string | null;
remote_user?: string | null;
remote_display_name?: string | null;
source_base_url: string | null;
};
export function postAuthorLabel(post: AuthorDisplay) {
const remoteUser = post.remote_user?.split("/").filter(Boolean).at(-1);
const author = remoteUser || (post.origin === "rss" ? post.name : post.username) || post.name || "unknown";
if (post.source_base_url) {
try { return `@${author}@${new URL(post.source_base_url).hostname}`; } catch { /* Keep a readable fallback for legacy malformed URLs. */ }
}
return `@${author}`;
}
+14
View File
@@ -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;
})();
}
+6 -1
View File
@@ -22,7 +22,12 @@ export async function getMemosIdentity(baseUrl: string, token: string) {
if (!user.name) throw new Error("Memos did not return an account identity"); if (!user.name) throw new Error("Memos did not return an account identity");
return user; return user;
} }
export function memoUrl(baseUrl: string, memoName: string) { const id = memoName.split("/").at(-1); return id ? `${baseUrl.replace(/\/$/, "")}/m/${encodeURIComponent(id)}` : null; } export function memoUrl(baseUrl: string, memoName: string) {
const id = memoName.split("/").at(-1);
if (!id) return null;
const path = /^\d+$/.test(id) ? `m/${encodeURIComponent(id)}` : `memos/${encodeURIComponent(id)}`;
return `${baseUrl.replace(/\/$/, "")}/${path}`;
}
export type MemosPage = { memos: MemosMemo[]; nextPageToken: string }; export type MemosPage = { memos: MemosMemo[]; nextPageToken: string };
export async function listMemos(baseUrl: string, token: string, rules: MemosSyncRules = {}, page: { pageToken?: string; pageSize?: number } = {}): Promise<MemosPage> { export async function listMemos(baseUrl: string, token: string, rules: MemosSyncRules = {}, page: { pageToken?: string; pageSize?: number } = {}): Promise<MemosPage> {
let pageToken = page.pageToken || ""; let pageToken = page.pageToken || "";
+12
View File
@@ -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
View File
@@ -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 }; export type RssItem = { id: string; content: string; link: string; publishedAt: string | null };
const decode = (value: string) => value.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&"); const MAX_FEED_BYTES = 2 * 1024 * 1024;
const decode = (value: string) => value.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&amp;/g, "&");
const field = (xml: string, name: string) => decode(xml.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`, "i"))?.[1]?.trim() || ""); 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 atomLink = (xml: string) => decode(xml.match(/<link\b[^>]*\bhref=["']([^"']+)["'][^>]*>/i)?.[1] || "");
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 date = (value: string) => { const parsed = new Date(value); return value && !Number.isNaN(parsed.getTime()) ? parsed.toISOString() : null; };
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[]; 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
View File
@@ -9,7 +9,7 @@ export function webhookSecretMatches(secret: string, expectedHash: string | null
return actual.length === expected.length && timingSafeEqual(actual, expected); return actual.length === expected.length && timingSafeEqual(actual, expected);
} }
export function standardWebhookMatches(secret: string, id: string | null, timestamp: string | null, signature: string | null, body: string) { 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"); 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); });
} }
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mebbling", "name": "mebbling",
"version": "0.7.0", "version": "0.8.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mebbling", "name": "mebbling",
"version": "0.7.0", "version": "0.8.0",
"license": "PolyForm-Noncommercial-1.0.0", "license": "PolyForm-Noncommercial-1.0.0",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "mebbling", "name": "mebbling",
"version": "0.7.0", "version": "0.8.0",
"description": "", "description": "",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -8,7 +8,8 @@
"build": "HUB_BUILD=1 next build", "build": "HUB_BUILD=1 next build",
"start": "next start", "start": "next start",
"worker": "tsx worker/index.ts", "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": [], "keywords": [],
"author": "", "author": "",
+17 -1
View File
@@ -3,6 +3,7 @@ set -euo pipefail
# Creates a consistent SQLite backup through the running web container, then archives Hub uploads. # 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 # 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)" root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root_dir" cd "$root_dir"
stamp="$(date +%Y%m%d-%H%M%S)" 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 tar -tzf "$backup_dir/uploads.tar.gz" >/dev/null
(cd "$backup_dir" && sha256sum hub.db uploads.tar.gz > SHA256SUMS) (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 if [[ -n "${BACKUP_OFFSITE_DIR:-}" ]]; then
destination="$BACKUP_OFFSITE_DIR/$stamp"; mkdir -p "$destination" 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" printf 'Copied verified backup to: %s\n' "$destination"
fi 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 if [[ -n "${BACKUP_RETENTION_DAYS:-}" ]]; then
[[ "$BACKUP_RETENTION_DAYS" =~ ^[0-9]+$ ]] || { echo "BACKUP_RETENTION_DAYS must be a non-negative integer" >&2; exit 2; } [[ "$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 {} + find data/backups -mindepth 1 -maxdepth 1 -type d -mtime "+$BACKUP_RETENTION_DAYS" -exec rm -rf {} +
+14
View File
@@ -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
+16
View File
@@ -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
View File
@@ -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 [[ $# -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 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; } 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="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; cd "$root_dir"
"$root_dir/scripts/verify-backup.sh" "$backup_dir"
docker compose down docker compose down
mkdir -p data public mkdir -p data public
mv data/hub.db "data/hub.db.before-restore.$(date +%Y%m%d-%H%M%S)" 2>/dev/null || true mv data/hub.db "data/hub.db.before-restore.$(date +%Y%m%d-%H%M%S)" 2>/dev/null || true
+19
View File
@@ -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"
+16
View File
@@ -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"
+26
View File
@@ -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/);
});
+10
View File
@@ -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);
});
+21
View File
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { postAuthorLabel } from "../lib/display";
import { memoUrl } from "../lib/memos";
test("formats Memos authors as remote user at source hostname", () => {
assert.equal(postAuthorLabel({ origin: "memos", username: "hub-user", name: "唐宋打油", remote_user: "users/tangsong", remote_display_name: "唐宋打油", source_base_url: "https://memos.fishking.studio/path" }), "@tangsong@memos.fishking.studio");
});
test("formats Hub posts linked to Memos with the same source identity", () => {
assert.equal(postAuthorLabel({ origin: "hub", username: "codex", name: "唐宋打油", remote_user: "users/tangsong", remote_display_name: "唐宋打油", source_base_url: "https://memos.fishking.studio" }), "@tangsong@memos.fishking.studio");
});
test("keeps the existing label for non-Memos posts", () => {
assert.equal(postAuthorLabel({ origin: "hub", username: "codex", name: "Hub", source_base_url: null }), "@codex");
});
test("uses current Memos UID links while preserving legacy numeric links", () => {
assert.equal(memoUrl("https://memos.example.com/", "memos/VA54SvJG7yBparf3SiH3Ey"), "https://memos.example.com/memos/VA54SvJG7yBparf3SiH3Ey");
assert.equal(memoUrl("https://memos.example.com", "memos/15"), "https://memos.example.com/m/15");
});
+23
View File
@@ -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), /已使用/);
});
+11
View File
@@ -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
View File
@@ -16,7 +16,7 @@ test("applies tracked migrations and deduplicates active pull jobs", async () =>
const { queuePull } = await import("../lib/sync"); const { queuePull } = await import("../lib/sync");
const { notify } = await import("../lib/notifications"); const { notify } = await import("../lib/notifications");
const migrations = db.prepare("SELECT version FROM schema_migrations ORDER BY version").all() as { version: number }[]; 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: 42 }, (_, index) => index + 1));
const userId = Number(db.prepare("INSERT INTO users(username,password_hash) VALUES('sync-test','hash')").run().lastInsertRowid); 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); 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); 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" }]); 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 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); 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"); 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" }]); assert.deepEqual(db.prepare("SELECT type,message FROM notifications WHERE user_id=?").all(userId), [{ type: "comment", message: "commented" }]);
}); });
+18
View File
@@ -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
View File
@@ -6,7 +6,7 @@ import { decrypt } from "../lib/crypto";
import { createMemo, createRemoteFile, getMemosIdentity, listMemos, memoUrl, setMemoAttachments } from "../lib/memos"; import { createMemo, createRemoteFile, getMemosIdentity, listMemos, memoUrl, setMemoAttachments } from "../lib/memos";
import { recordError } from "../lib/observability"; import { recordError } from "../lib/observability";
import { fetchRss } from "../lib/rss"; 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 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 }; 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 同步校正。`); 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();