44 Commits
Author SHA1 Message Date
tangsongdayo 08059e2a9d fix: correct Memos links and author identities 2026-07-19 18:51:16 +08:00
tangsongdayo d512443d00 feat: show source-qualified author names 2026-07-19 13:35:20 +08:00
tangsongdayo 3a20defcc3 fix: constrain remote source avatars 2026-07-19 13:28:37 +08:00
tangsongdayo 6ba0b5f6d2 docs: plan system documentation 2026-07-19 13:06:54 +08:00
tangsongdayo 9cbc7de340 test: add post-deploy smoke checks 2026-07-19 13:05:49 +08:00
tangsongdayo ec48836dc9 test: cover atomic source invites 2026-07-19 13:04:49 +08:00
tangsongdayo 71b6402bd8 chore: add release supply-chain checks 2026-07-19 13:03:28 +08:00
tangsongdayo 21395c19e9 feat: secure offsite backups 2026-07-19 13:01:20 +08:00
tangsongdayo 1e01f3295e feat: retry failed alert deliveries 2026-07-19 12:58:53 +08:00
tangsongdayo 00928b72e4 docs: benchmark search and document scaling 2026-07-19 12:55:40 +08:00
tangsongdayo 82879a36cc feat: index public search with FTS5 2026-07-19 12:52:44 +08:00
tangsongdayo 428ee50cd6 feat: expose health probes and metrics 2026-07-19 12:49:39 +08:00
tangsongdayo 6a10ddb6e5 feat: validate production runtime config 2026-07-19 12:47:42 +08:00
tangsongdayo bc58981de2 test: cover webhook signature validation 2026-07-19 12:46:19 +08:00
tangsongdayo df8f62c75c chore: start v0.8.0 development 2026-07-19 12:39:08 +08:00
tangsongdayo 3fc274e9f1 feat: harden RSS source fetching 2026-07-19 12:38:43 +08:00
tangsongdayo 782f7988f1 release: v0.7.0 2026-07-19 12:36:51 +08:00
tangsongdayo 5997a54f2c feat: preview and retry sync jobs 2026-07-19 06:01:15 +08:00
tangsongdayo 9caf598fcb feat: archive aged attachment cache 2026-07-19 05:59:41 +08:00
tangsongdayo a23bc72dd3 feat: add sync alert webhooks 2026-07-19 05:57:57 +08:00
tangsongdayo e9190da834 feat: rotate signed Memos webhooks 2026-07-19 05:55:43 +08:00
tangsongdayo 2af99a4b43 feat: add privacy retention controls 2026-07-19 05:52:48 +08:00
tangsongdayo c6079bbb47 feat: audit source operations 2026-07-19 05:51:08 +08:00
tangsongdayo 808deaba97 feat: verify scheduled backups 2026-07-19 05:48:12 +08:00
tangsongdayo 44cd7a48ee feat: add source invite roles 2026-07-19 05:45:42 +08:00
tangsongdayo 9e159910ca feat: batch large source imports 2026-07-19 05:43:03 +08:00
tangsongdayo 591981b7c8 feat: export posts and sources 2026-07-19 05:36:52 +08:00
tangsongdayo 05ddafb0d9 feat: add read-only RSS sources 2026-07-19 05:32:55 +08:00
tangsongdayo 2ea6a653bf fix: support Memos API identity migration 2026-07-19 05:27:46 +08:00
tangsongdayo 97d4c15407 feat: configure signed Memos webhooks when supported 2026-07-19 05:14:01 +08:00
tangsongdayo 3d6d1c03f3 docs: record Memos filter compatibility 2026-07-19 05:11:41 +08:00
tangsongdayo a6073bef26 fix: fall back when Memos filters are unsupported 2026-07-19 05:09:49 +08:00
tangsongdayo a2b3ca8924 perf: filter Memos syncs at the API 2026-07-19 05:08:55 +08:00
tangsongdayo c6f8e9fff9 fix: report attachment cache failures and retries 2026-07-19 05:05:25 +08:00
tangsongdayo fadbe39b66 docs: record post-v0.6 fixes 2026-07-19 05:02:08 +08:00
tangsongdayo 21b0c4d4e0 fix: backfill legacy source identities during sync 2026-07-19 04:55:25 +08:00
tangsongdayo d92f474587 fix: hide legacy source rename control 2026-07-19 04:52:17 +08:00
tangsongdayo 15afc34a9c feat: derive source names from Memos accounts 2026-07-19 04:51:35 +08:00
tangsongdayo 5425f93d1f fix: sync only API key owner public memos 2026-07-19 04:48:21 +08:00
tangsongdayo 1ca9491a07 fix: display original Memos publish times 2026-07-19 04:34:15 +08:00
tangsongdayo ed1a798587 feat: complete v0.6 content and attachment experience 2026-07-19 04:15:28 +08:00
tangsongdayo d285f3e275 feat: complete v0.5 operations foundation 2026-07-19 03:58:03 +08:00
tangsongdayo ca3f706cc1 feat: complete v0.4 Memos integration 2026-07-19 03:30:19 +08:00
tangsongdayo 9ab09159e3 feat: complete v0.3 reading and discovery experience 2026-07-19 03:16:18 +08:00
93 changed files with 3131 additions and 143 deletions
+12
View File
@@ -5,7 +5,19 @@ ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-before-first-start
NEXT_PUBLIC_APP_URL=http://localhost:3000
UPLOAD_MAX_BYTES=10485760
UPLOAD_ALLOWED_TYPES=image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain,text/markdown
# Optional HTTP scanner: POSTs a file and expects {"clean": true}. Set required to reject if unavailable.
VIRUS_SCAN_URL=
VIRUS_SCAN_REQUIRED=0
SYNC_INTERVAL_MINUTES=60
# Optional retention windows in days; 0 keeps data indefinitely. Expired unused invites are removed after 30 days.
NOTIFICATION_RETENTION_DAYS=0
READING_HISTORY_RETENTION_DAYS=0
AUDIT_RETENTION_DAYS=365
# Optional Discord webhook URL or ntfy topic URL (for example https://ntfy.sh/my-private-topic). Failed sends retry up to 5 times.
ALERT_WEBHOOK_URL=
# Optional Bearer token for GET /api/metrics. Leave empty only when metrics are restricted by your network/proxy.
METRICS_TOKEN=
# Optional: create the first Memos source for the bootstrap admin.
SEED_MEMOS_NAME=
SEED_MEMOS_URL=
+28
View File
@@ -0,0 +1,28 @@
name: Verify and build
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npm audit --omit=dev --audit-level=high
- run: npx tsc --noEmit
- run: npm test
- run: npm run sbom -- artifacts/mebbling.spdx.json
- uses: actions/upload-artifact@v4
with:
name: mebbling-sbom-${{ gitea.sha }}
path: artifacts/mebbling.spdx.json
- run: docker build --build-arg APP_VERSION=${{ gitea.ref_name }} -t mebbling:${{ gitea.sha }} .
# Optional: configure DEPLOY_WEBHOOK_URL as a Gitea Actions secret to notify your host on a v* tag.
- if: startsWith(gitea.ref, 'refs/tags/v') && secrets.DEPLOY_WEBHOOK_URL != ''
run: curl --fail --silent --show-error -X POST "$DEPLOY_WEBHOOK_URL" -H "Content-Type: application/json" -d '{"version":"${{ gitea.ref_name }}","commit":"${{ gitea.sha }}"}'
+101 -1
View File
@@ -2,7 +2,107 @@
本專案遵循 [Semantic Versioning](https://semver.org/lang/zh-TW/);版本 `0.x` 表示功能仍可能調整。
## [0.2.0] - 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
### Fixed
- Prevented manual URL regeneration from invalidating automatically configured, signed Memos webhooks.
- 貼文頁、首頁卡片、RSS 與 Atom 優先顯示 Memos 原始發布時間,不再顯示同一次匯入的 Hub 寫入時間。
- 多使用者 Memos 來源只同步 API Key 所屬帳號建立的公開貼文;舊來源會在下一次同步自動補回遠端帳號身分並重新篩選鏡像。
- 遠端附件快取失敗會在來源控制台顯示可重試提示,並保留原始連結作為回退。
### Changed
- 來源名稱改由 API Key 對應的 Memos 帳號自動產生與更新,控制台不再接受手動命名。
- 同步會優先使用新版 Memos 的伺服器端 filter;不支援該 API 的舊版 Memos 會安全退回本機篩選。
- 新版 Memos 來源建立時會自動建立帶 HMAC 簽名的 webhook;不支援 User Webhook API 的舊版來源維持手動模式。
- 相容新版 Memos 的 `/auth/me` 與 username 資源名,既有來源會在同步時更新遠端使用者身分。
- 重新連接同一個 Memos 網址時會更新原有來源的 PAT 與遠端身分,不建立重複來源。
### Added
- Added per-source sync difference previews, retry for individual failed jobs, and owner-only batch retry of failed jobs.
- Added optional per-source attachment archive age: cached files are replaced with their original remote links after the configured period.
- Added opt-in Discord/ntfy alerts for exhausted sync retries and stale signed webhooks, with per-event rate limiting.
- Added safe signed Memos webhook rotation: create the replacement first, then remove the previous remote webhook before committing the new credentials.
- Added configurable retention cleanup and a password-confirmed self-service account deletion flow.
- Added append-only audit records for source management, invitations, posting, and administrator moderation.
- Added verified SQLite/upload backups with retention, optional offsite copy, restore script, and installable daily WSL cron schedule.
- Added expiring, one-time source invitation links with viewer and editor roles; only owners and editors can publish to a shared Memos source.
- Added resumable, page-token-based Memos imports with configurable batch size and first-import limit; posts are only hidden after a complete scan.
- Added owner/member-only JSON and Markdown exports for individual posts and complete sources.
- Added read-only RSS sources: validate a feed URL, queue recurring imports, and show imported posts under their RSS source rather than a Hub account.
- 文章顯示時隱藏已辨識的內文 hashtag,保留原始 Markdown 與文章底部標籤。
- 具時間範圍篩選的公開標籤雲與標籤導覽入口。
- 瀏覽器端自動儲存的發文草稿與 Markdown 預覽。
- 來源附件保存資料結構:遠端連結、僅圖片快取或完整快取,並提供來源配額與失效快取清理。
- 標籤別名/合併的資料結構,供後續管理介面使用。
## [0.5.0] - Unreleased
### Added
- 同源 POST 保護,以及以 SQLite 保存、可由多個 Web 容器共用的登入/Webhook 限流。
- Hub 原生附件的 MIME 白名單、檔案數量限制,與可選 HTTP 掃毒服務介面。
- 貼文檢舉、管理員隱藏貼文、停權/解除停權帳號和管理員協助密碼重設。
- `/api/health` 健康檢查、JSON 結構化事件、持久化同步錯誤紀錄與管理員檢視頁。
- Docker image 版本參數,以及 Gitea Actions 的驗證、建置與選用部署 webhook 工作流程。
## [0.4.0] - Unreleased
### Added
- 每個來源可依標籤、日期及附件類型設定同步範圍。
- Memos 遠端貼文連結、遠端 profile、Token 連線測試與連線狀態。
- Webhook 健康狀態與長時間未收到 webhook 警示。
### Changed
- Pull 同步會以來源規則決定鏡像內容;遠端更新會更新 Hub 鏡像,遠端刪除或移出規則範圍會隱藏鏡像貼文。
## [0.3.0] - Unreleased
### Added
- 分頁與可依內容、標籤、來源、作者、日期及附件篩選的公開搜尋。
- 標籤頁、來源頁、RSS 與 Atom feed,以及公開貼文 Open Graph metadata。
- 安全 Markdown 渲染、GitHub Flavored Markdown 與程式碼高亮。
- 收藏、稍後閱讀、閱讀紀錄、互動通知與通知已讀管理。
## [0.2.0] - 2026-07-19
### Added
+3
View File
@@ -1,4 +1,5 @@
FROM node:22-bookworm-slim AS build
ARG APP_VERSION=development
WORKDIR /app
COPY package*.json ./
RUN npm ci
@@ -6,8 +7,10 @@ COPY . .
RUN npm run build
FROM node:22-bookworm-slim
ARG APP_VERSION=development
WORKDIR /app
ENV NODE_ENV=production
ENV APP_VERSION=$APP_VERSION
COPY --from=build /app/package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/.next ./.next
+34 -1
View File
@@ -2,7 +2,7 @@
自架的 Memos 公開貼文 Hub。將朋友各自 Memos 中的公開貼文集中展示,同時保留 Hub 內的留言、表情回應與發文功能。
目前開發版本:`v0.2.0`(尚未發布)。版本變更請見 [CHANGELOG.md](CHANGELOG.md)。
目前開發版本:`v0.8.0`(尚未發布)。版本變更請見 [CHANGELOG.md](CHANGELOG.md)。
## 功能
@@ -16,6 +16,14 @@
- 來源建立者可重新命名、停用、刪除或轉移所有權;共享成員可自行離開來源。
- 同步工作具去重、重試、觸發來源與歷史紀錄;管理員可集中檢視異常。
- 內建 SQLite 與附件備份腳本,以及可追蹤的 schema migration。
- 可依內容、標籤、來源、作者、日期與附件篩選公開貼文,並支援分頁、標籤/來源頁、RSS 與 Atom。
- 提供安全 Markdown、程式碼高亮、收藏、稍後閱讀、閱讀紀錄與互動通知。
- 每個來源可設定標籤、日期與附件類型同步規則,並在貼文頁保留可回到原始 Memos 貼文的連結。
- 控制台可測試 Token/Memos 連線、顯示遠端名稱與頭像,並提示 webhook 長時間未收到事件的狀態。
- 同源請求保護、SQLite 共用登入/webhook 限流、附件白名單與可選掃毒服務。
- 管理員可審核檢舉、隱藏貼文、停權帳號與協助重設密碼;提供健康檢查與 JSON 結構化日誌。
- 文章以底部標籤為主,避免內文 hashtag 重複;提供時間範圍標籤雲、草稿自動儲存與預覽。
- 遠端附件預設直連;來源可選擇只快取圖片或完整快取,並受每來源配額限制。
## 快速啟動(WSLDocker
@@ -38,6 +46,12 @@ docker compose ps
docker compose logs -f web worker
```
部署後可執行公開端點冒煙測試(若 metrics 有設 token,先匯出同一個 `METRICS_TOKEN`):
```bash
./scripts/smoke-test.sh https://你的網域
```
## 環境變數
`.env.example` 為範本。正式環境請更換所有 secret,且不要將 `.env` 加入 Git。
@@ -49,7 +63,11 @@ docker compose logs -f web worker
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 首次啟動時建立的管理員帳號。 |
| `NEXT_PUBLIC_APP_URL` | Hub 的對外 HTTPS 網址,例如 `https://mebbling.example.com`。Webhook URL 以此組成。 |
| `UPLOAD_MAX_BYTES` | Hub 發文上傳附件的單檔上限,預設 10 MiB。 |
| `UPLOAD_ALLOWED_TYPES` | 逗號分隔的 Hub 附件 MIME 白名單。 |
| `VIRUS_SCAN_URL` / `VIRUS_SCAN_REQUIRED` | 選用的 HTTP 掃毒服務;服務需回傳 `{ "clean": true }`。若 required 為 `1`,掃毒不可用時拒絕上傳。 |
| `SYNC_INTERVAL_MINUTES` | 背景校正同步的間隔,預設 60 分鐘。 |
| `ALERT_WEBHOOK_URL` | 選填的 HTTPS Discord webhook 或 ntfy topic;同步及 webhook 異常會保存後投遞,失敗最多重試 5 次。 |
| `METRICS_TOKEN` | 選填的 `/api/metrics` Bearer Token;未設定時務必由網路/反向代理限制存取。 |
| `SEED_MEMOS_*` | 選填;首次啟動時自動建立管理員的第一個 Memos 來源。 |
## 系統架構
@@ -73,6 +91,17 @@ Web 接收使用者操作和 webhook,將同步需求寫入 SQLite 的 `sync_jo
- **Push**:把 Hub 建立的貼文與本機附件上傳/回寫到選定的 Memos 來源。
- **排程校正**:依 `SYNC_INTERVAL_MINUTES` 定期建立 Pull 工作,避免 webhook 遺漏造成資料不同步。
在來源管理中設定的同步規則會套用到 Pull:多個標籤採「同時符合」篩選,日期以 Memos 貼文建立日為準;附件可選擇全部保留、只保留圖片,或不同步附件。貼文後續在遠端被修改、刪除、改為非公開或不再符合規則時,下一次 Pull 會更新或隱藏 Hub 鏡像。
## 正式營運與監控
- `GET /api/health`:供反向代理或監控工具檢查服務與 SQLite 狀態,也會回傳失敗同步工作數與版本。
- Web、Worker 的事件輸出為 JSON;同步錯誤同時保存於管理頁的「最近系統錯誤」。告警 webhook 會以 SQLite 佇列投遞、指數退避重試五次,並以 `mebbling_alert_deliveries` metrics 暴露狀態。
- 登入在 15 分鐘內最多嘗試 8 次;webhook 與登入限流資料存於 SQLite,同一份資料庫的多個 Web 容器會共用計數。
- 所有會改變帳號或內容的瀏覽器 POST 都檢查 `Origin`Webhook 則使用密鑰 URL 驗證,不適用此規則。
- Gitea Actions 工作流程會在推送/標籤時執行型別檢查、測試與 Docker 建置;若設定 `DEPLOY_WEBHOOK_URL` secret,建立 `v*` tag 時會通知部署端。
- 工作流程也會對 production dependencies 執行高風險漏洞檢查,並產生 SPDX SBOM artifact;發布、升級與日後映像簽章的流程見 [發布文件](docs/RELEASING.md)。
## Webhook 設定與驗證
1. 來源建立者登入「控制台」。
@@ -81,6 +110,8 @@ Web 接收使用者操作和 webhook,將同步需求寫入 SQLite 的 `sync_jo
4. 在 Memos 發布或更新一篇公開貼文。
5. 回到 Hub:顯示「最近收到」代表 Hub 確實收到 webhook;「上次同步」更新則代表同步已完成。
若 webhook 已設定但超過 7 天未收到事件,控制台會顯示提醒;這不會中斷定期校正同步。來源建立者也可按「測試 Memos 連線」檢查 Token 是否有效,同時更新遠端顯示名稱與頭像。
網址格式如下;`來源 ID``隨機密鑰` 都由系統產生,請勿自行修改:
```text
@@ -113,6 +144,8 @@ https://你的網域/api/sync/webhook/來源ID/隨機密鑰
├── data/ # SQLite 資料庫持久化資料
├── Dockerfile # WebWorker 共用映像檔
├── docker-compose.yml # web + worker 服務與 volume 掛載
├── scripts/ # 備份、驗證、還原與效能基準工具
├── docs/ # 維運與擴展文件
└── .env.example # 環境變數範本
```
+3 -1
View File
@@ -1,7 +1,9 @@
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { db } from "@/lib/db";
export default async function Account({ searchParams }: { searchParams: Promise<{ error?: string; updated?: string }> }) {
const user = await getSession(); if (!user) redirect("/login"); const query = await searchParams;
return <><h1></h1>{query.error && <p className="error">{query.error}</p>}{query.updated && <p></p>}<section className="card"><p className="meta">{user.username}</p><h2></h2><form action="/api/auth/password" method="post"><label><input name="currentPassword" type="password" autoComplete="current-password" required /></label><label><input name="newPassword" type="password" autoComplete="new-password" minLength={10} required /></label><label><input name="confirmPassword" type="password" autoComplete="new-password" minLength={10} required /></label><button></button></form></section></>;
const ownedSources = db.prepare("SELECT count(*) AS count FROM sources WHERE user_id=?").get(user.id) as { count: number };
return <><h1></h1>{query.error && <p className="error">{query.error}</p>}{query.updated && <p></p>}<section className="card"><p className="meta">{user.username}</p><h2></h2><form action="/api/auth/password" method="post"><label><input name="currentPassword" type="password" autoComplete="current-password" required /></label><label><input name="newPassword" type="password" autoComplete="new-password" minLength={10} required /></label><label><input name="confirmPassword" type="password" autoComplete="new-password" minLength={10} required /></label><button></button></form></section><section className="card"><h2></h2>{ownedSources.count ? <p className="error"> {ownedSources.count} </p> : <form action="/api/auth/delete" method="post"><p className="meta"> Hub </p><label><input name="currentPassword" type="password" autoComplete="current-password" required /></label><label> DELETE <input name="confirmation" required /></label><button className="danger"></button></form>}</section></>;
}
+19 -5
View File
@@ -1,12 +1,26 @@
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { checkRuntimeConfig } from "@/lib/config";
export const dynamic = "force-dynamic";
export default async function AdminPage() {
const user = await getSession(); if (!user || user.role !== "admin") redirect("/");
const failures = db.prepare("SELECT j.id,j.kind,j.trigger,j.status,j.attempts,j.last_error,j.created_at,j.finished_at,s.id AS source_id,s.name,s.base_url FROM sync_jobs j JOIN sources s ON s.id=j.source_id WHERE j.status='failed' OR s.sync_status='error' ORDER BY COALESCE(j.finished_at,j.created_at) DESC LIMIT 100").all() as any[];
const sources = db.prepare("SELECT s.id,s.name,s.base_url,s.sync_status,s.last_synced_at,s.last_error,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[];
return <><h1></h1><section className="card"><h2></h2>{failures.length ? <ul className="job-list">{failures.map((item) => <li key={`${item.id}-${item.source_id}`}><strong>{item.name}</strong> #{item.source_id} · {item.kind || "source"} · <span className="tag">{item.status || "error"}</span><br /><span className="error">{item.last_error || "來源處於錯誤狀態"}</span><br /><span className="meta"> {item.attempts || 0} {new Date((item.finished_at || 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>{source.last_error && <><br /><span className="error">{source.last_error}</span></>}</li>)}</ul></section></>;
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ updated?: string; error?: string }> }) {
const user = await getSession(); if (!user || user.role !== "admin") redirect("/"); const query = await searchParams;
const failures = db.prepare("SELECT j.id,j.kind,j.trigger,j.status,j.attempts,j.last_error,j.created_at,j.finished_at,s.id AS source_id,s.name FROM sync_jobs j JOIN sources s ON s.id=j.source_id WHERE j.status='failed' OR s.sync_status='error' ORDER BY COALESCE(j.finished_at,j.created_at) DESC LIMIT 100").all() as any[];
const reports = db.prepare("SELECT r.id,r.reason,r.created_at,p.id AS post_id,p.content,u.username FROM reports r JOIN posts p ON p.id=r.post_id LEFT JOIN users u ON u.id=r.reporter_id WHERE r.resolved=0 ORDER BY r.created_at LIMIT 100").all() as any[];
const users = db.prepare("SELECT id,username,role,disabled,created_at FROM users ORDER BY created_at DESC LIMIT 100").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 sources = db.prepare("SELECT s.id,s.name,s.base_url,s.sync_status,s.last_synced_at,s.is_enabled,count(sm.user_id) AS member_count FROM sources s LEFT JOIN source_members sm ON sm.source_id=s.id GROUP BY s.id ORDER BY s.id DESC").all() as any[];
const config = checkRuntimeConfig();
return <><h1></h1>{query.updated && <p></p>}{query.error && <p className="error"></p>}
<section className="card"><h2></h2>{reports.length ? <ul className="job-list">{reports.map((report) => <li key={report.id}><strong> #{report.post_id}</strong> · @{report.username || "已刪除使用者"}<br />{report.reason}<br /><span className="meta">{report.content.slice(0, 180)} · {new Date(report.created_at + "Z").toLocaleString("zh-TW")}</span><div className="row"><form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value="hide-post" /><input type="hidden" name="id" value={report.post_id} /><button className="danger"></button></form><form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value="resolve-report" /><input type="hidden" name="id" value={report.id} /><button></button></form></div></li>)}</ul> : <p className="muted"></p>}</section>
<section className="card"><h2>使</h2><ul className="job-list">{users.map((account) => <li key={account.id}><strong>@{account.username}</strong> · <span className="tag">{account.role}</span> · {account.disabled ? "已停權" : "正常"}<div className="row">{account.id !== user.id && <form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value={account.disabled ? "enable-user" : "disable-user"} /><input type="hidden" name="id" value={account.id} /><button className={account.disabled ? "" : "danger"}>{account.disabled ? "解除停權" : "停權"}</button></form>}<form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value="reset-password" /><input type="hidden" name="id" value={account.id} /><input name="password" type="password" minLength={10} required placeholder="管理員重設密碼" /><button></button></form></div></li>)}</ul></section>
<section className="card"><h2></h2>{failures.length ? <ul className="job-list">{failures.map((item) => <li key={`${item.id}-${item.source_id}`}><strong>{item.name}</strong> #{item.source_id} · {item.kind || "source"} · <span className="tag">{item.status || "error"}</span><br /><span className="error">{item.last_error || "來源處於錯誤狀態"}</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><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 }); } }
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { audit } from "@/lib/audit";
import { externalUrl } from "@/lib/http";
import { requireSameOrigin } from "@/lib/security";
import bcrypt from "bcryptjs";
export async function POST(request: Request) {
try {
requireSameOrigin(request); const admin = await requireUser(); if (admin.role !== "admin") throw new Error("Forbidden");
const form = await request.formData(); const action = String(form.get("action")); const id = Number(form.get("id"));
if (action === "hide-post") { db.prepare("UPDATE posts SET hidden=1,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(id); db.prepare("UPDATE reports SET resolved=1 WHERE post_id=?").run(id); }
else if (action === "restore-post") db.prepare("UPDATE posts SET hidden=0,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(id);
else if (action === "disable-user") { if (id === admin.id) throw new Error("Cannot disable yourself"); db.prepare("UPDATE users SET disabled=1 WHERE id=?").run(id); }
else if (action === "enable-user") db.prepare("UPDATE users SET disabled=0 WHERE id=?").run(id);
else if (action === "reset-password") { const password = String(form.get("password") || ""); if (password.length < 10) throw new Error("Invalid password"); db.prepare("UPDATE users SET password_hash=? WHERE id=?").run(await bcrypt.hash(password, 12), id); }
else if (action === "resolve-report") db.prepare("UPDATE reports SET resolved=1 WHERE id=?").run(id);
else throw new Error("Invalid action");
audit(admin.id, `admin.${action}`, "admin-target", id);
return NextResponse.redirect(externalUrl(request, "/admin?updated=1"));
} catch { return NextResponse.redirect(externalUrl(request, "/admin?error=moderation")); }
}
+8
View File
@@ -0,0 +1,8 @@
import bcrypt from "bcryptjs";
import { NextResponse } from "next/server";
import { clearSession, requireUser } from "@/lib/auth";
import { audit } from "@/lib/audit";
import { db } from "@/lib/db";
import { externalUrl } from "@/lib/http";
import { requireSameOrigin } from "@/lib/security";
export async function POST(request: Request) { try { requireSameOrigin(request); const user = await requireUser(); if (user.role === "admin") throw new Error("管理員帳號不可自行刪除"); const form = await request.formData(); if (String(form.get("confirmation")) !== "DELETE") throw new Error("請輸入 DELETE 確認"); const account = db.prepare("SELECT password_hash FROM users WHERE id=?").get(user.id) as { password_hash: string } | undefined; if (!account || !(await bcrypt.compare(String(form.get("currentPassword") || ""), account.password_hash))) throw new Error("目前密碼不正確"); const owned = db.prepare("SELECT count(*) AS count FROM sources WHERE user_id=?").get(user.id) as { count: number }; if (owned.count) throw new Error("請先處理你建立的來源"); audit(user.id, "account.delete", "user", user.id); db.prepare("DELETE FROM users WHERE id=?").run(user.id); await clearSession(); return NextResponse.redirect(externalUrl(request, "/?account=deleted")); } catch (error) { return NextResponse.redirect(externalUrl(request, "/account?error=" + encodeURIComponent(error instanceof Error ? error.message : "delete"))); } }
+3 -3
View File
@@ -1,3 +1,3 @@
import { NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import { db } from "@/lib/db"; import { createSession } from "@/lib/auth"; import { externalUrl } from "@/lib/http";
export async function POST(req: Request) { const form = await req.formData(); const username=String(form.get("username")||""); const password=String(form.get("password")||""); const user=db.prepare("SELECT id,username,password_hash,role,disabled FROM users WHERE username=?").get(username) as any;
if (!user || user.disabled || !(await bcrypt.compare(password,user.password_hash))) return NextResponse.redirect(externalUrl(req,"/login?error=invalid")); await createSession({id:user.id,username:user.username,role:user.role}); return NextResponse.redirect(externalUrl(req,"/dashboard")); }
import { NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import { db } from "@/lib/db"; import { createSession } from "@/lib/auth"; import { externalUrl } from "@/lib/http"; import { withinRateLimit } from "@/lib/rate-limit"; import { clientIp, requireSameOrigin } from "@/lib/security";
export async function POST(req: Request) { try { requireSameOrigin(req); if (!withinRateLimit(`login:${clientIp(req)}`, 8, 15 * 60_000)) return NextResponse.redirect(externalUrl(req,"/login?error=rate-limited")); const form = await req.formData(); const username=String(form.get("username")||""); const password=String(form.get("password")||""); const user=db.prepare("SELECT id,username,password_hash,role,disabled FROM users WHERE username=?").get(username) as any;
if (!user || user.disabled || !(await bcrypt.compare(password,user.password_hash))) return NextResponse.redirect(externalUrl(req,"/login?error=invalid")); await createSession({id:user.id,username:user.username,role:user.role}); return NextResponse.redirect(externalUrl(req,"/dashboard")); } catch { return NextResponse.redirect(externalUrl(req,"/login?error=invalid")); } }
+1 -1
View File
@@ -1 +1 @@
import { NextResponse } from "next/server"; import { clearSession } from "@/lib/auth"; import { externalUrl } from "@/lib/http"; export async function POST(req:Request){await clearSession();return NextResponse.redirect(externalUrl(req,"/"));}
import { NextResponse } from "next/server"; import { clearSession } from "@/lib/auth"; import { externalUrl } from "@/lib/http"; import { requireSameOrigin } from "@/lib/security"; export async function POST(req:Request){try { requireSameOrigin(req); await clearSession(); } catch {} return NextResponse.redirect(externalUrl(req,"/"));}
+2 -1
View File
@@ -3,10 +3,11 @@ import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { externalUrl } from "@/lib/http";
import { requireSameOrigin } from "@/lib/security";
export async function POST(req: Request) {
try {
const user = await requireUser(); const form = await req.formData();
requireSameOrigin(req); const user = await requireUser(); const form = await req.formData();
const currentPassword = String(form.get("currentPassword") || ""); const newPassword = String(form.get("newPassword") || ""); const confirmPassword = String(form.get("confirmPassword") || "");
if (newPassword.length < 10) throw new Error("新密碼至少需要 10 個字元");
if (newPassword !== confirmPassword) throw new Error("兩次新密碼不一致");
+2 -1
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import { db } from "@/lib/db"; import { createSession } from "@/lib/auth"; import { externalUrl } from "@/lib/http";
import { NextResponse } from "next/server"; import bcrypt from "bcryptjs"; import { db } from "@/lib/db"; import { createSession } from "@/lib/auth"; import { externalUrl } from "@/lib/http"; import { clientIp, requireSameOrigin } from "@/lib/security"; import { withinRateLimit } from "@/lib/rate-limit";
export async function POST(req: Request) { const form = await req.formData(); const username = String(form.get("username") || "").trim(); const password = String(form.get("password") || "");
try { requireSameOrigin(req); } catch { return NextResponse.redirect(externalUrl(req,"/register?error=invalid")); } if (!withinRateLimit(`register:${clientIp(req)}`, 5, 60 * 60_000)) return NextResponse.redirect(externalUrl(req,"/register?error=rate-limited"));
if (!/^[A-Za-z0-9_-]{3,32}$/.test(username) || password.length < 10) return NextResponse.redirect(externalUrl(req,"/register?error=invalid"));
try { const out = db.prepare("INSERT INTO users(username,password_hash) VALUES (?,?)").run(username, await bcrypt.hash(password, 12)); await createSession({ id: Number(out.lastInsertRowid), username, role: "user" }); return NextResponse.redirect(externalUrl(req,"/dashboard")); } catch { return NextResponse.redirect(externalUrl(req,"/register?error=taken")); }
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { externalUrl } from "@/lib/http";
import { requireSameOrigin } from "@/lib/security";
export async function POST(req: Request) {
try {
requireSameOrigin(req); const user = await requireUser(); const form = await req.formData(); const postId = Number(form.get("postId")); const kind = String(form.get("kind"));
if (!postId || !["saved", "later"].includes(kind)) throw new Error("Invalid bookmark");
const post = db.prepare("SELECT id FROM posts WHERE id=? AND visibility='PUBLIC' AND hidden=0").get(postId); if (!post) throw new Error("Post not found");
const existing = db.prepare("SELECT kind FROM bookmarks WHERE user_id=? AND post_id=?").get(user.id, postId) as { kind: string } | undefined;
if (existing?.kind === kind) db.prepare("DELETE FROM bookmarks WHERE user_id=? AND post_id=?").run(user.id, postId);
else db.prepare("INSERT INTO bookmarks(user_id,post_id,kind) VALUES(?,?,?) ON CONFLICT(user_id,post_id) DO UPDATE SET kind=excluded.kind,created_at=CURRENT_TIMESTAMP").run(user.id, postId, kind);
return NextResponse.redirect(externalUrl(req, `/posts/${postId}`));
} catch { return NextResponse.redirect(externalUrl(req, "/")); }
}
+18 -2
View File
@@ -1,2 +1,18 @@
import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; import { externalUrl } from "@/lib/http";
export async function POST(req:Request){try{const user=await requireUser();const f=await req.formData();const postId=Number(f.get('postId'));const content=String(f.get('content')||'').trim();if(!postId||!content||content.length>5000)throw new Error('Invalid comment');db.prepare('INSERT INTO comments(post_id,author_id,content) VALUES(?,?,?)').run(postId,user.id,content);return NextResponse.redirect(externalUrl(req,`/posts/${postId}`));}catch{return NextResponse.redirect(externalUrl(req,'/'));}}
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { externalUrl } from "@/lib/http";
import { notify } from "@/lib/notifications";
import { requireSameOrigin } from "@/lib/security";
export async function POST(req: Request) {
try {
requireSameOrigin(req); const user = await requireUser(); const form = await req.formData(); const postId = Number(form.get("postId")); const content = String(form.get("content") || "").trim();
if (!postId || !content || content.length > 5000) throw new Error("Invalid comment");
const post = db.prepare("SELECT author_id FROM posts WHERE id=? AND hidden=0").get(postId) as { author_id: number } | undefined;
if (!post) throw new Error("Post not found");
db.prepare("INSERT INTO comments(post_id,author_id,content) VALUES(?,?,?)").run(postId, user.id, content);
notify(post.author_id, user.id, postId, "comment", `@${user.username} 留言了你的貼文`);
return NextResponse.redirect(externalUrl(req, `/posts/${postId}`));
} catch { return NextResponse.redirect(externalUrl(req, "/")); }
}
+21
View File
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
function markdown(post: any) {
const tags = (() => { try { return JSON.parse(post.tags_json || "[]"); } catch { return []; } })();
const attachments = (() => { try { return JSON.parse(post.attachments_json || "[]"); } catch { return []; } })();
const quote = (value: unknown) => JSON.stringify(value ?? "");
const attachmentList = attachments.length ? `\n\n## Attachments\n${attachments.map((item: any) => `- [${item.filename || item.name || "attachment"}](${item.url || item.externalLink || ""})`).join("\n")}` : "";
return `---\nid: ${post.id}\norigin: ${quote(post.origin)}\nvisibility: ${quote(post.visibility)}\npublished_at: ${quote(post.remote_created_at || post.created_at)}\ntags: ${JSON.stringify(tags)}\nremote_url: ${quote(post.remote_url)}\n---\n\n${post.content}${attachmentList}\n`;
}
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await requireUser(); const { id: rawId } = await params; const post = db.prepare("SELECT p.*,s.name AS source_name FROM posts p LEFT JOIN sources s ON s.id=p.source_id WHERE p.id=?").get(Number(rawId)) as any;
if (!post) return NextResponse.json({ error: "Not found" }, { status: 404 });
const permitted = 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));
if (!permitted) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const format = new URL(request.url).searchParams.get("format") === "markdown" ? "markdown" : "json";
const body = format === "markdown" ? markdown(post) : JSON.stringify({ version: 1, exportedAt: new Date().toISOString(), post: { ...post, tags: JSON.parse(post.tags_json || "[]"), attachments: JSON.parse(post.attachments_json || "[]") } }, null, 2);
return new NextResponse(body, { headers: { "Content-Type": format === "markdown" ? "text/markdown; charset=utf-8" : "application/json; charset=utf-8", "Content-Disposition": `attachment; filename="mebbling-post-${post.id}.${format === "markdown" ? "md" : "json"}"` } });
}
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
function postMarkdown(post: any) { return `## ${post.remote_created_at || post.created_at}\n\n${post.content}\n`; }
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId); const source = db.prepare("SELECT id,name,base_url,integration_type,rss_feed_url,created_at FROM sources WHERE id=?").get(id) as any;
if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!db.prepare("SELECT 1 FROM source_members WHERE source_id=? AND user_id=?").get(id, user.id)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const posts = db.prepare("SELECT id,content,visibility,tags_json,attachments_json,origin,remote_created_at,remote_updated_at,remote_url,created_at,updated_at FROM posts WHERE source_id=? ORDER BY COALESCE(remote_created_at,created_at)").all(id) as any[];
const format = new URL(request.url).searchParams.get("format") === "markdown" ? "markdown" : "json";
const body = format === "markdown" ? `# ${source.name}\n\n${posts.map(postMarkdown).join("\n---\n\n")}` : JSON.stringify({ version: 1, exportedAt: new Date().toISOString(), source, posts: posts.map((post) => ({ ...post, tags: JSON.parse(post.tags_json || "[]"), attachments: JSON.parse(post.attachments_json || "[]") })) }, null, 2);
return new NextResponse(body, { headers: { "Content-Type": format === "markdown" ? "text/markdown; charset=utf-8" : "application/json; charset=utf-8", "Content-Disposition": `attachment; filename="mebbling-source-${id}.${format === "markdown" ? "md" : "json"}"` } });
}
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const probe = new URL(request.url).searchParams.get("probe");
if (probe === "live") return NextResponse.json({ ok: true, status: "live", version: process.env.APP_VERSION || "development", timestamp: new Date().toISOString() });
try {
db.prepare("SELECT 1").get();
const failedJobs = Number((db.prepare("SELECT count(*) AS count FROM sync_jobs WHERE status='failed'").get() as { count: number }).count);
return NextResponse.json({ ok: true, 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 }); }
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { audit } from "@/lib/audit";
import { acceptSourceInvite } from "@/lib/invites";
import { externalUrl } from "@/lib/http";
import { requireSameOrigin } from "@/lib/security";
export async function POST(request: Request) { try { requireSameOrigin(request); const user = await requireUser(); const form = await request.formData(); const 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" } }); }
+14
View File
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { externalUrl } from "@/lib/http";
import { requireSameOrigin } from "@/lib/security";
export async function POST(req: Request) {
try {
requireSameOrigin(req); const user = await requireUser(); const form = await req.formData(); const id = Number(form.get("id"));
if (id) db.prepare("UPDATE notifications SET read_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=?").run(id, user.id);
else db.prepare("UPDATE notifications SET read_at=CURRENT_TIMESTAMP WHERE user_id=? AND read_at IS NULL").run(user.id);
return NextResponse.redirect(externalUrl(req, "/notifications"));
} catch { return NextResponse.redirect(externalUrl(req, "/")); }
}
+2 -2
View File
@@ -1,2 +1,2 @@
import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; import { externalUrl } from "@/lib/http"; import { mkdir, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { extname, join } from "node:path";
export async function POST(req:Request){const json=req.headers.get("accept")?.includes("application/json");try{const user=await requireUser();const f=await req.formData();const content=String(f.get("content")||"").trim();const visibility=String(f.get("visibility")||"PUBLIC");const sourceId=Number(f.get("sourceId"));const tags=String(f.get("tags")||"").split(/\s*,\s*/).filter(Boolean).map(t=>t.replace(/^#/,""));if(!content||!['PRIVATE','PROTECTED','PUBLIC'].includes(visibility)||!sourceId)throw new Error("Invalid post");const source=db.prepare("SELECT s.id FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE s.id=? AND sm.user_id=? AND s.is_enabled=1").get(sourceId,user.id);if(!source)throw new Error("Source not available");const max=Number(process.env.UPLOAD_MAX_BYTES||10485760);const files=f.getAll('attachments').filter((x):x is File=>x instanceof File&&x.size>0);const attachments:any[]=[];await mkdir(join(process.cwd(),'public','uploads'),{recursive:true});for(const file of files){if(file.size>max)throw new Error(`${file.name} exceeds upload limit`);const id=randomUUID()+extname(file.name);await writeFile(join(process.cwd(),'public','uploads',id),Buffer.from(await file.arrayBuffer()));attachments.push({name:file.name,url:`/uploads/${id}`,type:file.type,size:file.size});}const out=db.prepare("INSERT INTO posts(source_id,author_id,content,visibility,tags_json,attachments_json,origin,sync_status) VALUES(?,?,?,?,?,?,'hub','queued')").run(sourceId,user.id,content,visibility,JSON.stringify(tags),JSON.stringify(attachments));db.prepare("INSERT INTO sync_jobs(source_id,kind,payload_json,trigger) VALUES(?, 'push', ?, 'manual')").run(sourceId,JSON.stringify({postId:out.lastInsertRowid}));if(json)return NextResponse.json({id:Number(out.lastInsertRowid)},{status:201});return NextResponse.redirect(externalUrl(req,`/posts/${out.lastInsertRowid}`));}catch(e){const message=e instanceof Error?e.message:'post';if(json)return NextResponse.json({error:message},{status:400});return NextResponse.redirect(externalUrl(req,'/dashboard?error='+encodeURIComponent(message)));}}
import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; import { audit } from "@/lib/audit"; import { externalUrl } from "@/lib/http"; import { mkdir, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { extname, join } from "node:path"; import { requireSameOrigin } from "@/lib/security"; import { validateUpload } from "@/lib/uploads";
export async function POST(req:Request){const json=req.headers.get("accept")?.includes("application/json");try{requireSameOrigin(req);const user=await requireUser();const f=await req.formData();const content=String(f.get("content")||"").trim();const visibility=String(f.get("visibility")||"PUBLIC");const sourceId=Number(f.get("sourceId"));const tags=String(f.get("tags")||"").split(/\s*,\s*/).filter(Boolean).map(t=>t.replace(/^#/,""));if(!content||!['PRIVATE','PROTECTED','PUBLIC'].includes(visibility)||!sourceId)throw new Error("Invalid post");const source=db.prepare("SELECT s.id FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE s.id=? AND sm.user_id=? AND sm.role IN ('owner','editor') AND s.is_enabled=1").get(sourceId,user.id);if(!source)throw new Error("Source not available");const files=f.getAll('attachments').filter((x):x is File=>x instanceof File&&x.size>0);if(files.length>10)throw new Error("最多可上傳 10 個附件");const attachments:any[]=[];await mkdir(join(process.cwd(),'public','uploads'),{recursive:true});for(const file of files){await validateUpload(file);const id=randomUUID()+extname(file.name);await writeFile(join(process.cwd(),'public','uploads',id),Buffer.from(await file.arrayBuffer()));attachments.push({name:file.name,url:`/uploads/${id}`,type:file.type,size:file.size});}const out=db.prepare("INSERT INTO posts(source_id,author_id,content,visibility,tags_json,attachments_json,origin,sync_status) VALUES(?,?,?,?,?,?,'hub','queued')").run(sourceId,user.id,content,visibility,JSON.stringify(tags),JSON.stringify(attachments));audit(user.id,"post.create","post",Number(out.lastInsertRowid),{sourceId,visibility});db.prepare("INSERT INTO sync_jobs(source_id,kind,payload_json,trigger) VALUES(?, 'push', ?, 'manual')").run(sourceId,JSON.stringify({postId:out.lastInsertRowid}));if(json)return NextResponse.json({id:Number(out.lastInsertRowid)},{status:201});return NextResponse.redirect(externalUrl(req,`/posts/${out.lastInsertRowid}`));}catch(e){const message=e instanceof Error?e.message:'post';if(json)return NextResponse.json({error:message},{status:400});return NextResponse.redirect(externalUrl(req,'/dashboard?error='+encodeURIComponent(message)));}}
+20 -2
View File
@@ -1,2 +1,20 @@
import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; import { externalUrl } from "@/lib/http";
const allowed=new Set(['👍','❤️','🎉','🤔']);export async function POST(req:Request){try{const user=await requireUser();const f=await req.formData();const postId=Number(f.get('postId'));const emoji=String(f.get('emoji'));if(!postId||!allowed.has(emoji))throw 0;const found=db.prepare('SELECT 1 FROM reactions WHERE post_id=? AND user_id=? AND emoji=?').get(postId,user.id,emoji);if(found)db.prepare('DELETE FROM reactions WHERE post_id=? AND user_id=? AND emoji=?').run(postId,user.id,emoji);else db.prepare('INSERT INTO reactions(post_id,user_id,emoji) VALUES(?,?,?)').run(postId,user.id,emoji);return NextResponse.redirect(externalUrl(req,`/posts/${postId}`));}catch{return NextResponse.redirect(externalUrl(req,'/'));}}
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { externalUrl } from "@/lib/http";
import { notify } from "@/lib/notifications";
import { requireSameOrigin } from "@/lib/security";
const allowed = new Set(["👍", "❤️", "🎉", "🤔"]);
export async function POST(req: Request) {
try {
requireSameOrigin(req); const user = await requireUser(); const form = await req.formData(); const postId = Number(form.get("postId")); const emoji = String(form.get("emoji"));
if (!postId || !allowed.has(emoji)) throw new Error("Invalid reaction");
const post = db.prepare("SELECT author_id FROM posts WHERE id=? AND hidden=0").get(postId) as { author_id: number } | undefined;
if (!post) throw new Error("Post not found");
const found = db.prepare("SELECT 1 FROM reactions WHERE post_id=? AND user_id=? AND emoji=?").get(postId, user.id, emoji);
if (found) db.prepare("DELETE FROM reactions WHERE post_id=? AND user_id=? AND emoji=?").run(postId, user.id, emoji);
else { db.prepare("INSERT INTO reactions(post_id,user_id,emoji) VALUES(?,?,?)").run(postId, user.id, emoji); notify(post.author_id, user.id, postId, "reaction", `@${user.username} 對你的貼文給了 ${emoji}`); }
return NextResponse.redirect(externalUrl(req, `/posts/${postId}`));
} catch { return NextResponse.redirect(externalUrl(req, "/")); }
}
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { externalUrl } from "@/lib/http";
import { requireSameOrigin } from "@/lib/security";
export async function POST(request: Request) {
try {
requireSameOrigin(request); const user = await requireUser(); const form = await request.formData();
const postId = Number(form.get("postId")); const reason = String(form.get("reason") || "").trim();
if (!postId || reason.length < 3 || reason.length > 500) throw new Error("Invalid report");
const post = db.prepare("SELECT id FROM posts WHERE id=? AND hidden=0").get(postId);
if (!post) throw new Error("Post not found");
db.prepare("INSERT INTO reports(post_id,reporter_id,reason) SELECT ?,?,? WHERE NOT EXISTS (SELECT 1 FROM reports WHERE post_id=? AND reporter_id=? AND resolved=0)").run(postId, user.id, reason, postId, user.id);
return NextResponse.redirect(externalUrl(request, `/posts/${postId}?reported=1`));
} catch { return NextResponse.redirect(externalUrl(request, "/")); }
}
+9
View File
@@ -0,0 +1,9 @@
import { createHash, randomBytes } from "node:crypto";
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { audit } from "@/lib/audit";
import { requireSameOrigin } from "@/lib/security";
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
try { requireSameOrigin(request); const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId); const source = db.prepare("SELECT id FROM sources WHERE id=? AND user_id=?").get(id, user.id); if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 }); const { role } = await request.json() as { role?: string }; if (!['viewer','editor'].includes(role || '')) return NextResponse.json({ error: "Invalid role" }, { status: 400 }); const token = randomBytes(24).toString("base64url"); const hash = createHash("sha256").update(token).digest("hex"); db.prepare("INSERT INTO source_invites(source_id,token_hash,role,expires_at,created_by) VALUES(?,?,?,datetime('now','+7 days'),?)").run(id, hash, role, user.id); audit(user.id, "source.invite.create", "source", id, { role }); const origin = (process.env.NEXT_PUBLIC_APP_URL || new URL(request.url).origin).replace(/\/$/, ""); return NextResponse.json({ url: `${origin}/invite/${token}` }); } catch { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); }
}
+29 -7
View File
@@ -1,23 +1,45 @@
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { decrypt } from "@/lib/crypto";
import { db } from "@/lib/db";
import { audit } from "@/lib/audit";
import { externalUrl } from "@/lib/http";
import { getMemosIdentity, verifyMemos } from "@/lib/memos";
import { queuePull } from "@/lib/sync";
import { requireSameOrigin } from "@/lib/security";
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId); const form = await req.formData(); const action = String(form.get("action") || "");
const source = db.prepare("SELECT id,user_id FROM sources WHERE id=?").get(id) as { id: number; user_id: number } | undefined;
requireSameOrigin(req); const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId); const form = await req.formData(); const action = String(form.get("action") || "");
const source = db.prepare("SELECT id,user_id,base_url,token_encrypted FROM sources WHERE id=?").get(id) as { id: number; user_id: number; base_url: string; token_encrypted: string } | undefined;
const member = db.prepare("SELECT role FROM source_members WHERE source_id=? AND user_id=?").get(id, user.id);
if (!source || !member) throw new Error("Source not found");
const owner = source.user_id === user.id;
if (action === "rename") {
const name = String(form.get("name") || "").trim(); if (!owner || !name || name.length > 80) throw new Error("Only the owner can rename a source");
db.prepare("UPDATE sources SET name=? WHERE id=?").run(name, id);
} else if (action === "set-enabled") {
if (action === "set-enabled") {
if (!owner) throw new Error("Only the owner can change source status"); const enabled = String(form.get("enabled")) === "1";
db.prepare("UPDATE sources SET is_enabled=?,disabled_at=CASE WHEN ? THEN NULL ELSE CURRENT_TIMESTAMP END,sync_status=CASE WHEN ? THEN 'pending' ELSE 'disabled' END WHERE id=?").run(enabled ? 1 : 0, enabled ? 1 : 0, enabled ? 1 : 0, id);
if (enabled) queuePull(id, "manual");
} else if (action === "set-sync-rules") {
if (!owner) throw new Error("Only the owner can change sync rules");
const tags = String(form.get("tags") || "").split(",").map((tag) => tag.trim().replace(/^#/, "")).filter(Boolean).slice(0, 20);
const from = String(form.get("from") || ""); const to = String(form.get("to") || ""); const attachmentMode = String(form.get("attachmentMode") || "all"); const batchSize = Number(form.get("batchSize") || 100); const maxPosts = Number(form.get("maxPosts") || 0);
if ((from && !/^\d{4}-\d{2}-\d{2}$/.test(from)) || (to && !/^\d{4}-\d{2}-\d{2}$/.test(to)) || (from && to && from > to) || !["all", "images", "none"].includes(attachmentMode) || !Number.isInteger(batchSize) || batchSize < 10 || batchSize > 100 || !Number.isInteger(maxPosts) || maxPosts < 0 || maxPosts > 100000) throw new Error("Invalid sync rules");
db.prepare("UPDATE sources SET sync_tags_json=?,sync_from=?,sync_to=?,sync_attachment_mode=?,sync_batch_size=?,sync_max_posts=?,sync_cursor=NULL,sync_imported_count=0,sync_run_id=NULL WHERE id=?").run(JSON.stringify(tags), from || null, to || null, attachmentMode, batchSize, maxPosts || null, id);
queuePull(id, "manual");
} else if (action === "set-attachment-storage") {
if (!owner) throw new Error("Only the owner can change attachment storage"); const mode = String(form.get("mode") || "remote"); const quotaMiB = Number(form.get("quotaMiB") || 100); const archiveAfterDays = Number(form.get("archiveAfterDays") || 0);
if (!["remote", "images", "all"].includes(mode) || !Number.isFinite(quotaMiB) || quotaMiB < 10 || quotaMiB > 10_240 || !Number.isInteger(archiveAfterDays) || archiveAfterDays < 0 || archiveAfterDays > 3650) throw new Error("Invalid attachment storage settings");
db.prepare("UPDATE sources SET attachment_storage_mode=?,attachment_cache_limit_bytes=?,attachment_archive_after_days=?,attachment_cache_error=NULL WHERE id=?").run(mode, Math.round(quotaMiB * 1024 * 1024), archiveAfterDays || null, id); queuePull(id, "manual");
} else if (action === "test-connection") {
if (!owner) throw new Error("Only the owner can test the connection");
try {
const token = decrypt(source.token_encrypted); await verifyMemos(source.base_url, token); const identity = await getMemosIdentity(source.base_url, token);
const avatar = identity.avatarUrl || identity.avatar || null; const avatarUrl = avatar?.startsWith("/") ? `${source.base_url.replace(/\/$/, "")}${avatar}` : avatar;
const name = identity.displayName || identity.nickname || identity.username || identity.name;
db.prepare("UPDATE sources SET name=?,last_connection_at=CURRENT_TIMESTAMP,last_connection_error=NULL,remote_display_name=?,remote_avatar_url=? WHERE id=?").run(name, name, avatarUrl, id);
} catch (connectionError) { const message = connectionError instanceof Error ? connectionError.message : "Connection failed"; db.prepare("UPDATE sources SET last_connection_error=? WHERE id=?").run(message, id); throw connectionError; }
} else if (action === "retry-failed-jobs") {
if (!owner) throw new Error("Only the owner can retry all failed jobs"); db.prepare("UPDATE sync_jobs SET status='queued',attempts=0,last_error=NULL,started_at=NULL,finished_at=NULL,run_after=CURRENT_TIMESTAMP WHERE source_id=? AND status='failed'").run(id);
} else if (action === "leave") {
if (owner) throw new Error("Transfer ownership or delete the source before leaving");
db.prepare("DELETE FROM source_members WHERE source_id=? AND user_id=?").run(id, user.id);
@@ -32,6 +54,6 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
const remove = db.transaction(() => { db.prepare("DELETE FROM posts WHERE source_id=? AND origin='memos'").run(id); db.prepare("UPDATE posts SET source_id=NULL WHERE source_id=? AND origin='hub'").run(id); db.prepare("DELETE FROM sources WHERE id=?").run(id); });
remove();
} else throw new Error("Unknown source action");
return NextResponse.redirect(externalUrl(req, "/dashboard?source=updated"));
audit(user.id, `source.${action}`, "source", id); return NextResponse.redirect(externalUrl(req, "/dashboard?source=updated"));
} catch (error) { return NextResponse.redirect(externalUrl(req, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "source"))); }
}
+6
View File
@@ -0,0 +1,6 @@
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { decrypt } from "@/lib/crypto";
import { db } from "@/lib/db";
import { getMemosIdentity, listMemos } from "@/lib/memos";
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { try { const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId); const source = db.prepare("SELECT s.* FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE s.id=? AND sm.user_id=? AND s.integration_type='memos'").get(id, user.id) as any; if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 }); const token = decrypt(source.token_encrypted); const identity = await getMemosIdentity(source.base_url, token); const rules = { creator: identity.name, tags: JSON.parse(source.sync_tags_json || "[]"), from: source.sync_from, to: source.sync_to, attachmentMode: source.sync_attachment_mode }; const remote = await listMemos(source.base_url, token, rules, { pageSize: 100 }); const local = new Set((db.prepare("SELECT remote_memo_name FROM posts WHERE source_id=? AND origin='memos'").all(id) as { remote_memo_name: string }[]).map((row) => row.remote_memo_name)); const added = remote.memos.filter((memo) => !local.has(memo.name)).length; return NextResponse.json({ inspected: remote.memos.length, added, existing: remote.memos.length - added, hasMore: Boolean(remote.nextPageToken) }); } catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "preview" }, { status: 400 }); } }
+13 -2
View File
@@ -1,13 +1,24 @@
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { decrypt, encrypt } from "@/lib/crypto";
import { createUserWebhook, deleteUserWebhook, getMemosIdentity } from "@/lib/memos";
import { createWebhookSecret, webhookSecretHash } from "@/lib/webhook";
import { requireSameOrigin } from "@/lib/security";
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId);
const source = db.prepare("SELECT id FROM sources WHERE id=? AND user_id=?").get(id, user.id);
requireSameOrigin(request); const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId);
const source = db.prepare("SELECT id,base_url,token_encrypted,remote_user,webhook_mode,webhook_remote_name FROM sources WHERE id=? AND user_id=?").get(id, user.id) as { id: number; base_url: string; token_encrypted: string; remote_user: string | null; webhook_mode: string; webhook_remote_name: string | null } | undefined;
if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (source.webhook_mode === "signed") {
const token = decrypt(source.token_encrypted); const identity = source.remote_user ? { name: source.remote_user } : await getMemosIdentity(source.base_url, token); const pathSecret = createWebhookSecret(); const signingSecret = createWebhookSecret(); const publicOrigin = (process.env.NEXT_PUBLIC_APP_URL || new URL(request.url).origin).replace(/\/$/, "");
const remote = await createUserWebhook(source.base_url, token, identity.name, { url: `${publicOrigin}/api/sync/webhook/${id}/${pathSecret}`, displayName: "Mebbling", signingSecret });
try { if (source.webhook_remote_name) await deleteUserWebhook(source.base_url, token, identity.name, source.webhook_remote_name); }
catch (error) { try { await deleteUserWebhook(source.base_url, token, identity.name, remote.name); } catch {} throw error; }
db.prepare("UPDATE sources SET webhook_secret_hash=?,webhook_remote_name=?,webhook_signing_secret_encrypted=? WHERE id=?").run(webhookSecretHash(pathSecret), remote.name, encrypt(signingSecret), id);
return NextResponse.json({ rotated: true });
}
const secret = createWebhookSecret();
db.prepare("UPDATE sources SET webhook_secret_hash=? WHERE id=?").run(webhookSecretHash(secret), id);
const publicOrigin = (process.env.NEXT_PUBLIC_APP_URL || new URL(request.url).origin).replace(/\/$/, "");
+12 -4
View File
@@ -3,16 +3,18 @@ import { requireUser } from "@/lib/auth";
import { decrypt, encrypt } from "@/lib/crypto";
import { db } from "@/lib/db";
import { externalUrl } from "@/lib/http";
import { getMemosIdentity, verifyMemos } from "@/lib/memos";
import { createUserWebhook, getMemosIdentity, verifyMemos } from "@/lib/memos";
import { createWebhookSecret, webhookSecretHash } from "@/lib/webhook";
import { queuePull } from "@/lib/sync";
import { requireSameOrigin } from "@/lib/security";
export async function POST(req: Request) {
try {
const user = await requireUser(); const form = await req.formData();
const name = String(form.get("name") || "").trim(); const rawBaseUrl = String(form.get("baseUrl") || "").trim(); const token = String(form.get("token") || "").trim();
requireSameOrigin(req); const user = await requireUser(); const form = await req.formData();
const rawBaseUrl = String(form.get("baseUrl") || "").trim(); const token = String(form.get("token") || "").trim();
let baseUrl = "";
try { const url = new URL(rawBaseUrl); if (!['http:', 'https:'].includes(url.protocol)) throw new Error(); baseUrl = `${url.origin}${url.pathname.replace(/\/+$/, "")}`; } catch { throw new Error("Invalid source URL"); }
if (!name || token.length < 20) throw new Error("Invalid source");
if (token.length < 20) throw new Error("Invalid source");
await verifyMemos(baseUrl, token); const identity = await getMemosIdentity(baseUrl, token);
const legacySources = db.prepare("SELECT id,token_encrypted FROM sources WHERE base_url=? AND remote_user IS NULL").all(baseUrl) as { id: number; token_encrypted: string }[];
for (const legacy of legacySources) {
@@ -20,9 +22,15 @@ export async function POST(req: Request) {
}
const shared = db.prepare("SELECT id FROM sources WHERE base_url=? AND remote_user=?").get(baseUrl, identity.name) as { id: number } | undefined;
if (shared) { db.prepare("INSERT OR IGNORE INTO source_members(source_id,user_id) VALUES(?,?)").run(shared.id, user.id); return NextResponse.redirect(externalUrl(req, "/dashboard?source=shared")); }
const name = identity.displayName || identity.nickname || identity.username || identity.name;
const existing = db.prepare("SELECT id FROM sources WHERE user_id=? AND base_url=?").get(user.id, baseUrl) as { id: number } | undefined;
if (existing) { db.prepare("UPDATE sources SET name=?,token_encrypted=?,remote_user=?,sync_status='queued',last_connection_error=NULL WHERE id=?").run(name, encrypt(token), identity.name, existing.id); queuePull(existing.id, "source-created"); return NextResponse.redirect(externalUrl(req, "/dashboard?source=reconnected")); }
const out = db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,remote_user,sync_status) VALUES(?,?,?,?,?, 'queued')").run(user.id, name, baseUrl, encrypt(token), identity.name);
const sourceId = Number(out.lastInsertRowid);
db.prepare("INSERT INTO source_members(source_id,user_id,role) VALUES(?,?,'owner')").run(sourceId, user.id);
const pathSecret = createWebhookSecret(); const signingSecret = createWebhookSecret(); const publicOrigin = (process.env.NEXT_PUBLIC_APP_URL || new URL(req.url).origin).replace(/\/$/, "");
try { const remote = await createUserWebhook(baseUrl, token, identity.name, { url: `${publicOrigin}/api/sync/webhook/${sourceId}/${pathSecret}`, displayName: "Mebbling", signingSecret }); db.prepare("UPDATE sources SET webhook_secret_hash=?,webhook_mode='signed',webhook_remote_name=?,webhook_signing_secret_encrypted=? WHERE id=?").run(webhookSecretHash(pathSecret), remote.name, encrypt(signingSecret), sourceId); }
catch (webhookError) { const message = webhookError instanceof Error ? webhookError.message : "Webhook unsupported"; db.prepare("UPDATE sources SET webhook_mode=? WHERE id=?").run(message.includes("Memos API 404") ? "manual" : "unavailable", sourceId); }
queuePull(sourceId, "source-created");
return NextResponse.redirect(externalUrl(req, "/dashboard?source=connected"));
} catch (error) { return NextResponse.redirect(externalUrl(req, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "source"))); }
+17
View File
@@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { encrypt } from "@/lib/crypto";
import { db } from "@/lib/db";
import { externalUrl } from "@/lib/http";
import { fetchRss } from "@/lib/rss";
import { requireSameOrigin } from "@/lib/security";
import { queuePull } from "@/lib/sync";
export async function POST(request: Request) {
try { requireSameOrigin(request); const user = await requireUser(); const form = await request.formData(); const raw = String(form.get("feedUrl") || "").trim(); const url = new URL(raw); if (url.protocol !== "https:") throw new Error("RSS feed must use HTTPS"); const items = await fetchRss(url.toString()); if (!items.length) throw new Error("RSS feed has no items");
const existing = db.prepare("SELECT id FROM sources WHERE user_id=? AND rss_feed_url=?").get(user.id, url.toString()) as { id: number } | undefined;
if (existing) { queuePull(existing.id, "manual"); return NextResponse.redirect(externalUrl(request, "/dashboard?source=rss-refreshed")); }
const name = `RSS · ${url.hostname}`; const out = db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,integration_type,rss_feed_url,sync_status) VALUES(?,?,?,?, 'rss',?, 'queued')").run(user.id, name, url.origin, encrypt("rss-read-only"), url.toString()); const id = Number(out.lastInsertRowid);
db.prepare("INSERT INTO source_members(source_id,user_id,role) VALUES(?,?,'owner')").run(id, user.id); queuePull(id, "source-created"); return NextResponse.redirect(externalUrl(request, "/dashboard?source=rss-connected"));
} catch (error) { return NextResponse.redirect(externalUrl(request, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "rss"))); }
}
+6
View File
@@ -0,0 +1,6 @@
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { externalUrl } from "@/lib/http";
import { requireSameOrigin } from "@/lib/security";
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { try { requireSameOrigin(request); const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId); const job = db.prepare("SELECT j.id FROM sync_jobs j JOIN source_members sm ON sm.source_id=j.source_id WHERE j.id=? AND sm.user_id=? AND j.status='failed'").get(id, user.id); if (!job) throw new Error("Failed job not found"); db.prepare("UPDATE sync_jobs SET status='queued',attempts=0,last_error=NULL,started_at=NULL,finished_at=NULL,run_after=CURRENT_TIMESTAMP WHERE id=?").run(id); return NextResponse.redirect(externalUrl(request, "/dashboard?sync=queued")); } catch (error) { return NextResponse.redirect(externalUrl(request, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "retry"))); } }
+2 -1
View File
@@ -3,10 +3,11 @@ import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { externalUrl } from "@/lib/http";
import { queuePull } from "@/lib/sync";
import { requireSameOrigin } from "@/lib/security";
export async function POST(req: Request) {
try {
const user = await requireUser(); const form = await req.formData(); const sourceId = Number(form.get("sourceId"));
requireSameOrigin(req); const user = await requireUser(); const form = await req.formData(); const sourceId = Number(form.get("sourceId"));
const source = db.prepare("SELECT s.id FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE s.id=? AND sm.user_id=? AND s.is_enabled=1").get(sourceId, user.id);
if (!source) throw new Error("Source not available");
const created = queuePull(sourceId, "manual");
@@ -1,19 +1,24 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { withinRateLimit } from "@/lib/rate-limit";
import { clientIp } from "@/lib/security";
import { logEvent } from "@/lib/observability";
import { webhookSecretMatches } from "@/lib/webhook";
import { standardWebhookMatches } from "@/lib/webhook";
import { decrypt } from "@/lib/crypto";
import { queuePull } from "@/lib/sync";
export async function POST(request: Request, { params }: { params: Promise<{ sourceId: string; secret: string }> }) {
const { sourceId, secret } = await params;
const id = Number(sourceId);
const source = db.prepare("SELECT id, webhook_secret_hash FROM sources WHERE id=? AND is_enabled=1").get(id) as { id: number; webhook_secret_hash: string | null } | undefined;
const source = db.prepare("SELECT id, webhook_secret_hash,webhook_mode,webhook_signing_secret_encrypted FROM sources WHERE id=? AND is_enabled=1").get(id) as { id: number; webhook_secret_hash: string | null; webhook_mode: string; webhook_signing_secret_encrypted: string | null } | undefined;
if (!source || !webhookSecretMatches(secret, source.webhook_secret_hash)) return NextResponse.json({ error: "Not found" }, { status: 404 });
const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0].trim() || "unknown";
if (!withinRateLimit(`webhook:${id}:${forwarded}`)) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
let payload: unknown = {};
try { payload = await request.json(); } catch { /* Memos payload is optional; a pull reconciles source state. */ }
if (!withinRateLimit(`webhook:${id}:${clientIp(request)}`, 30, 60_000)) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
const raw = await request.text();
if (source.webhook_mode === "signed") { const signingSecret = source.webhook_signing_secret_encrypted ? decrypt(source.webhook_signing_secret_encrypted) : ""; if (!standardWebhookMatches(signingSecret, request.headers.get("webhook-id"), request.headers.get("webhook-timestamp"), request.headers.get("webhook-signature"), raw)) return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); }
let payload: unknown = {}; try { payload = raw ? JSON.parse(raw) : {}; } catch { /* Memos payload is optional; a pull reconciles source state. */ }
db.prepare("UPDATE sources SET last_webhook_at=CURRENT_TIMESTAMP WHERE id=?").run(id);
const queued = queuePull(id, "webhook", payload);
logEvent("info", "webhook_received", { sourceId: id, queued });
return NextResponse.json({ ok: true, queued });
}
+8
View File
@@ -0,0 +1,8 @@
import { db } from "@/lib/db";
const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, (char) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", '"': "&quot;" }[char] || char));
export async function GET() {
const origin = (process.env.NEXT_PUBLIC_APP_URL || "http://localhost:8088").replace(/\/$/, ""); const posts = db.prepare("SELECT p.id,p.content,COALESCE(p.remote_created_at,p.created_at) AS published_at,u.username FROM posts p JOIN users u ON u.id=p.author_id WHERE p.visibility='PUBLIC' AND p.hidden=0 ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 50").all() as { id: number; content: string; published_at: string; username: string }[]; const updated = posts[0] ? new Date(posts[0].published_at).toISOString() : new Date().toISOString();
const entries = posts.map((post) => `<entry><id>${origin}/posts/${post.id}</id><title>${escapeXml(`@${post.username} 的貼文`)}</title><link href="${origin}/posts/${post.id}"/><updated>${new Date(post.published_at).toISOString()}</updated><content type="text">${escapeXml(post.content)}</content></entry>`).join("");
return new Response(`<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title>Mebbling</title><id>${origin}</id><link href="${origin}/atom.xml" rel="self"/><updated>${updated}</updated>${entries}</feed>`, { headers: { "Content-Type": "application/atom+xml; charset=utf-8", "Cache-Control": "public, max-age=300" } });
}
+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;
}
+9
View File
@@ -0,0 +1,9 @@
import ReactMarkdown from "react-markdown";
import rehypeHighlight from "rehype-highlight";
import remarkGfm from "remark-gfm";
import { withoutInlineTags } from "@/lib/tags";
/** Raw HTML is intentionally not enabled, so Memos content cannot inject script or markup. */
export function Markdown({ content, tags = [], compact = false }: { content: string; tags?: string[]; compact?: boolean }) {
return <div className={`markdown${compact ? " markdown-compact" : ""}`}><ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>{withoutInlineTags(content, tags)}</ReactMarkdown></div>;
}
+12
View File
@@ -0,0 +1,12 @@
import Link from "next/link";
import { Attachments } from "./attachments";
import { Markdown } from "./markdown";
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_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 }) {
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; 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>;
}
+7
View File
@@ -0,0 +1,7 @@
"use client";
import { useState } from "react";
export function InviteControl({ sourceId }: { sourceId: number }) {
const [role, setRole] = useState("viewer"); const [url, setUrl] = useState(""); const [error, setError] = useState("");
async function create() { setError(""); const response = await fetch(`/api/sources/${sourceId}/invites`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ role }) }); const body = await response.json(); if (!response.ok) setError(body.error || "無法建立邀請"); else setUrl(body.url); }
return <div><label><select value={role} onChange={(event) => setRole(event.target.value)}><option value="viewer"></option><option value="editor"></option></select></label><button type="button" onClick={create}> 7 </button>{url && <><input readOnly value={url} onFocus={(event) => event.currentTarget.select()} /><button type="button" onClick={() => navigator.clipboard.writeText(url)}></button></>}{error && <p className="error">{error}</p>}</div>;
}
+17 -10
View File
@@ -3,33 +3,40 @@ import { getSession } from "@/lib/auth";
import { db } from "@/lib/db";
import { PublishForm } from "./publish-form";
import { WebhookControl } from "./webhook-control";
import { InviteControl } from "./invite-control";
import { SyncPreview } from "./sync-preview";
type Source = { id: number; name: string; base_url: string; sync_status: string; last_synced_at: string | null; last_error: string | null; webhook_secret_hash: string | null; last_webhook_at: string | null; owner_id: number; is_enabled: number; disabled_at: string | null };
type Source = { id: number; name: string; base_url: string; integration_type: "memos" | "rss"; rss_feed_url: string | null; sync_status: string; last_synced_at: string | null; last_error: string | null; webhook_secret_hash: string | null; webhook_mode: string; last_webhook_at: string | null; owner_id: number; membership_role: "owner" | "editor" | "viewer"; is_enabled: number; disabled_at: string | null; sync_tags_json: string; sync_from: string | null; sync_to: string | null; sync_attachment_mode: "all" | "images" | "none"; sync_batch_size: number; sync_max_posts: number | null; sync_cursor: string | null; sync_imported_count: number; attachment_storage_mode: "remote" | "images" | "all"; attachment_cache_limit_bytes: number; attachment_archive_after_days: number | null; attachment_cache_error: string | null; remote_display_name: string | null; remote_avatar_url: string | null; last_connection_at: string | null; last_connection_error: string | null };
type Job = { id: number; kind: string; trigger: string | null; status: string; attempts: number; last_error: string | null; created_at: string; finished_at: string | null };
export const dynamic = "force-dynamic";
export default async function Dashboard({ searchParams }: { searchParams: Promise<{ error?: string; source?: string; sync?: string }> }) {
const query = await searchParams; const user = await getSession(); if (!user) redirect("/login");
const sourceRows = db.prepare("SELECT s.id,s.name,s.base_url,s.sync_status,s.last_synced_at,s.last_error,s.webhook_secret_hash,s.last_webhook_at,s.user_id AS owner_id,s.is_enabled,s.disabled_at FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE sm.user_id=? ORDER BY s.id DESC").all(user.id) as Source[];
const sources = sourceRows.map((source) => ({ ...source, members: db.prepare("SELECT u.username,u.id,sm.role FROM source_members sm JOIN users u ON u.id=sm.user_id WHERE sm.source_id=? ORDER BY sm.role DESC,u.username").all(source.id) as { username: string; id: number; role: string }[], jobs: db.prepare("SELECT id,kind,trigger,status,attempts,last_error,created_at,finished_at FROM sync_jobs WHERE source_id=? ORDER BY id DESC LIMIT 5").all(source.id) as Job[] }));
const publishSources = sources.filter((source) => source.is_enabled);
return <>
const sourceRows = db.prepare("SELECT s.id,s.name,s.base_url,s.integration_type,s.rss_feed_url,s.sync_status,s.last_synced_at,s.last_error,s.webhook_secret_hash,s.webhook_mode,s.last_webhook_at,s.user_id AS owner_id,sm.role AS membership_role,s.is_enabled,s.disabled_at,s.sync_tags_json,s.sync_from,s.sync_to,s.sync_attachment_mode,s.sync_batch_size,s.sync_max_posts,s.sync_cursor,s.sync_imported_count,s.attachment_storage_mode,s.attachment_cache_limit_bytes,s.attachment_archive_after_days,s.attachment_cache_error,s.remote_display_name,s.remote_avatar_url,s.last_connection_at,s.last_connection_error FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE sm.user_id=? ORDER BY s.id DESC").all(user.id) as Source[];
const sources = sourceRows.map((source) => ({ ...source, syncTags: (() => { try { return JSON.parse(source.sync_tags_json) as string[]; } catch { return []; } })(), members: db.prepare("SELECT u.username,u.id,sm.role FROM source_members sm JOIN users u ON u.id=sm.user_id WHERE sm.source_id=? ORDER BY sm.role DESC,u.username").all(source.id) as { username: string; id: number; role: string }[], jobs: db.prepare("SELECT id,kind,trigger,status,attempts,last_error,created_at,finished_at FROM sync_jobs WHERE source_id=? ORDER BY id DESC LIMIT 5").all(source.id) as Job[] }));
const publishSources = sources.filter((source) => source.is_enabled && source.integration_type === "memos" && ["owner", "editor"].includes(source.membership_role));
return <><style>{`form:has(input[name="action"][value="rename"]){display:none}`}</style>
<h1></h1>
{query.error && <p className="error">{query.error}</p>}
{query.source === "shared" ? <p> Memos </p> : query.source === "updated" ? <p></p> : query.source && <p></p>}
{query.sync === "queued" && <p></p>}{query.sync === "already-queued" && <p className="muted"></p>}
<section className="card"><h2> Memos</h2>{publishSources.length ? <PublishForm sources={publishSources} /> : <p className="muted"> Memos </p>}</section>
<section className="card"><h2> Memos</h2><form action="/api/sources" method="post"><label><input name="name" required placeholder="我的 Memos" /></label><label>Memos <input name="baseUrl" type="url" required placeholder="https://memos.example.com" /></label><label>Personal Access Token<input name="token" type="password" required /></label><button></button></form><p className="muted">Token 使 Memos </p></section>
<section className="card"><h2> Memos</h2><form action="/api/sources" method="post"><label>Memos <input name="baseUrl" type="url" required placeholder="https://memos.example.com" /></label><label>Personal Access Token<input name="token" type="password" required /></label><button></button></form><p className="muted">使 API Key Memos Token 使 Memos </p></section>
<section className="card"><h2> RSS</h2><form action="/api/sources/rss" method="post"><label>RSS Feed <input name="feedUrl" type="url" required placeholder="https://example.com/feed.xml" /></label><button></button></form><p className="muted">RSS API Key Feed </p></section>
<section><h2></h2>{sources.map((source) => <article className="card" key={source.id}>
<div className="space"><strong>{source.name}</strong><span className="tag">{source.is_enabled ? source.sync_status : "disabled"}</span></div>
<p className="meta"> ID{source.id}<br />{source.base_url}<br />{source.members.map((member) => `${member.username}${member.role === "owner" ? "(建立者)" : ""}`).join("、")}<br />{source.last_synced_at || "尚未完成"}<br />Webhook{source.webhook_secret_hash ? (source.last_webhook_at ? `最近收到:${new Date(source.last_webhook_at + "Z").toLocaleString("zh-TW")}` : "已建立 URL,尚未收到呼叫") : "尚未建立 URL"}{!source.is_enabled && <><br />{source.disabled_at ? new Date(source.disabled_at + "Z").toLocaleString("zh-TW") : "是"}</>}{source.last_error && <><br /><span className="error">{source.last_error}</span></>}</p>
<p className="meta"> ID{source.id}<br />{source.integration_type === "rss" ? source.rss_feed_url : source.base_url}{source.remote_display_name && <><br />Memos {source.remote_avatar_url && <img className="avatar" src={source.remote_avatar_url} alt="" />} {source.remote_display_name}</>}<br />{source.members.map((member) => `${member.username}${member.role === "owner" ? "(建立者)" : ""}`).join("、")}<br />{source.last_synced_at || "尚未完成"}{source.sync_cursor && <>{source.sync_imported_count} </>}<br />{source.integration_type === "memos" && <>{source.attachment_storage_mode === "remote" ? "遠端連結" : source.attachment_storage_mode === "images" ? "只快取圖片" : "完整備份"} {Math.round(source.attachment_cache_limit_bytes / 1024 / 1024)} MiB<br />{source.last_connection_at ? `最近成功:${new Date(source.last_connection_at + "Z").toLocaleString("zh-TW")}` : "尚未測試"}<br />Webhook{source.webhook_secret_hash ? (source.last_webhook_at ? (Date.now() - new Date(source.last_webhook_at + "Z").getTime() > 7 * 24 * 60 * 60 * 1000 ? `警示:超過 7 天未收到(最近:${new Date(source.last_webhook_at + "Z").toLocaleString("zh-TW")}` : `健康(最近收到:${new Date(source.last_webhook_at + "Z").toLocaleString("zh-TW")}`) : "已建立 URL,尚未收到呼叫") : "尚未建立 URL"}</>}{!source.is_enabled && <><br />{source.disabled_at ? new Date(source.disabled_at + "Z").toLocaleString("zh-TW") : "是"}</>}{source.last_error && <><br /><span className="error">{source.last_error}</span></>}{source.last_connection_error && <><br /><span className="error">{source.last_connection_error}</span></>}{source.attachment_cache_error && <><br /><span className="error">{source.attachment_cache_error}</span></>}</p>
{source.owner_id === user.id ? <>
<WebhookControl sourceId={source.id} configured={Boolean(source.webhook_secret_hash)} />
<details><summary></summary><form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="rename" /><label><input name="name" defaultValue={source.name} required maxLength={80} /></label><button></button></form><form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="set-enabled" /><input type="hidden" name="enabled" value={source.is_enabled ? "0" : "1"} /><button className={source.is_enabled ? "danger" : ""}>{source.is_enabled ? "停用來源" : "啟用來源"}</button></form>{source.members.length > 1 && <form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="transfer" /><label><select name="username" required defaultValue=""> <option value="" disabled></option>{source.members.filter((member) => member.id !== user.id).map((member) => <option key={member.id} value={member.username}>{member.username}</option>)}</select></label><button></button></form>}<form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="delete" /><button className="danger"></button></form></details>
{source.integration_type === "memos" && <WebhookControl sourceId={source.id} configured={Boolean(source.webhook_secret_hash)} automatic={source.webhook_mode === "signed"} />}
{source.integration_type === "memos" && <SyncPreview sourceId={source.id} />}
<InviteControl sourceId={source.id} />
<form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="retry-failed-jobs" /><button></button></form>
<form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="set-attachment-storage" /><label><select name="mode" defaultValue={source.attachment_storage_mode}><option value="remote"> Hub </option><option value="images"></option><option value="all"></option></select></label><label>MiB<input name="quotaMiB" type="number" min="10" max="10240" defaultValue={Math.round(source.attachment_cache_limit_bytes / 1024 / 1024)} /></label><label>0 <input name="archiveAfterDays" type="number" min="0" max="3650" defaultValue={source.attachment_archive_after_days || 0} /></label><button></button></form>
<details><summary></summary><form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="rename" /><label><input name="name" defaultValue={source.name} required maxLength={80} /></label><button></button></form><form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="set-sync-rules" /><label><input name="tags" defaultValue={source.syncTags.join(", ")} placeholder="旅行, 技術" /></label><div className="row"><label><input name="from" type="date" defaultValue={source.sync_from || ""} /></label><label><input name="to" type="date" defaultValue={source.sync_to || ""} /></label></div><label><select name="attachmentMode" defaultValue={source.sync_attachment_mode}><option value="all"></option><option value="images"></option><option value="none"></option></select></label><div className="row"><label><input name="batchSize" type="number" min="10" max="100" defaultValue={source.sync_batch_size} /></label><label>0 <input name="maxPosts" type="number" min="0" max="100000" defaultValue={source.sync_max_posts || 0} /></label></div><button></button></form><form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="test-connection" /><button> Memos </button></form><form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="set-enabled" /><input type="hidden" name="enabled" value={source.is_enabled ? "0" : "1"} /><button className={source.is_enabled ? "danger" : ""}>{source.is_enabled ? "停用來源" : "啟用來源"}</button></form>{source.members.length > 1 && <form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="transfer" /><label><select name="username" required defaultValue=""> <option value="" disabled></option>{source.members.filter((member) => member.id !== user.id).map((member) => <option key={member.id} value={member.username}>{member.username}</option>)}</select></label><button></button></form>}<form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="delete" /><button className="danger"></button></form></details>
</> : <form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="leave" /><button className="danger"></button></form>}
<form action="/api/sync" method="post"><input type="hidden" name="sourceId" value={source.id} /><button disabled={!source.is_enabled}>{source.is_enabled ? "立即同步" : "來源已停用"}</button></form>
<details><summary></summary>{source.jobs.length ? <ul className="job-list">{source.jobs.map((job) => <li key={job.id}><strong>{job.kind}</strong> · {job.trigger || "legacy"} · <span className="tag">{job.status}</span> · {job.attempts} <br /><span className="meta">{new Date(job.created_at + "Z").toLocaleString("zh-TW")}{job.finished_at && `;完成:${new Date(job.finished_at + "Z").toLocaleString("zh-TW")}`}</span>{job.last_error && <><br /><span className="error">{job.last_error}</span></>}</li>)}</ul> : <p className="muted"></p>}</details>
<details><summary></summary>{source.jobs.length ? <ul className="job-list">{source.jobs.map((job) => <li key={job.id}><strong>{job.kind}</strong> · {job.trigger || "legacy"} · <span className="tag">{job.status}</span> · {job.attempts} <br /><span className="meta">{new Date(job.created_at + "Z").toLocaleString("zh-TW")}{job.finished_at && `;完成:${new Date(job.finished_at + "Z").toLocaleString("zh-TW")}`}</span>{job.last_error && <><br /><span className="error">{job.last_error}</span></>}{job.status === "failed" && <form action={`/api/sync/jobs/${job.id}/retry`} method="post"><button></button></form>}</li>)}</ul> : <p className="muted"></p>}</details>
</article>)}</section>
</>;
}
+8 -27
View File
@@ -1,34 +1,15 @@
"use client";
import { FormEvent, useState } from "react";
import { FormEvent, useEffect, useMemo, useState } from "react";
type Source = { id: number; name: string };
const draftKey = "mebbling:publish-draft";
export function PublishForm({ sources }: { sources: Source[] }) {
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true); setError("");
try {
const response = await fetch("/api/posts", { method: "POST", body: new FormData(event.currentTarget), headers: { Accept: "application/json" } });
const result = await response.json();
if (!response.ok) throw new Error(result.error || "發佈失敗");
window.location.assign(`/posts/${result.id}`);
} catch (reason) {
setError(reason instanceof Error ? reason.message : "發佈失敗");
setSubmitting(false);
}
}
return <form onSubmit={submit} encType="multipart/form-data">
<label>Markdown<textarea name="content" required /></label>
<label><input name="tags" placeholder="旅行, 想法" /></label>
<label><select name="visibility" defaultValue="PUBLIC"><option value="PUBLIC"></option><option value="PROTECTED"></option><option value="PRIVATE"></option></select></label>
<label><select name="sourceId" required>{sources.map((source) => <option key={source.id} value={source.id}>{source.name}</option>)}</select></label>
<label> 10 MB<input name="attachments" type="file" multiple /></label>
{error && <p className="error">{error}</p>}
<button disabled={submitting}>{submitting ? "發佈中…" : "發佈並同步"}</button>
</form>;
const [error, setError] = useState(""); const [submitting, setSubmitting] = useState(false); const [content, setContent] = useState(""); const [tags, setTags] = useState(""); const [sourceId, setSourceId] = useState(String(sources[0]?.id || "")); const [preview, setPreview] = useState(false);
useEffect(() => { try { const saved = JSON.parse(localStorage.getItem(draftKey) || "{}"); setContent(saved.content || ""); setTags(saved.tags || ""); if (saved.sourceId && sources.some((source) => String(source.id) === saved.sourceId)) setSourceId(saved.sourceId); } catch {} }, [sources]);
useEffect(() => { localStorage.setItem(draftKey, JSON.stringify({ content, tags, sourceId })); }, [content, tags, sourceId]);
const draftState = useMemo(() => content ? "草稿已自動儲存於此瀏覽器" : "", [content]);
async function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); setSubmitting(true); setError(""); try { const response = await fetch("/api/posts", { method: "POST", body: new FormData(event.currentTarget), headers: { Accept: "application/json" } }); const result = await response.json(); if (!response.ok) throw new Error(result.error || "發佈失敗"); localStorage.removeItem(draftKey); window.location.assign(`/posts/${result.id}`); } catch (reason) { setError(reason instanceof Error ? reason.message : "發佈失敗"); setSubmitting(false); } }
return <form onSubmit={submit} encType="multipart/form-data"><label>Markdown<textarea name="content" value={content} onChange={(event) => setContent(event.target.value)} required /></label><div className="row"><button type="button" onClick={() => setPreview(!preview)}>{preview ? "繼續編輯" : "預覽"}</button><span className="meta">{draftState}</span></div>{preview && <section className="card"><pre className="markdown">{content || "(尚無內容)"}</pre></section>}<label><input name="tags" value={tags} onChange={(event) => setTags(event.target.value)} placeholder="旅行, 想法" /></label><label><select name="visibility" defaultValue="PUBLIC"><option value="PUBLIC"></option><option value="PROTECTED"></option><option value="PRIVATE"></option></select></label><label><select name="sourceId" required value={sourceId} onChange={(event) => setSourceId(event.target.value)}>{sources.map((source) => <option key={source.id} value={source.id}>{source.name}</option>)}</select></label><label> 10 MB<input name="attachments" type="file" multiple /></label>{error && <p className="error">{error}</p>}<button disabled={submitting}>{submitting ? "發佈中…" : "發佈並同步"}</button></form>;
}
+3
View File
@@ -0,0 +1,3 @@
"use client";
import { useState } from "react";
export function SyncPreview({ sourceId }: { sourceId: number }) { const [text, setText] = useState(""); async function preview() { setText("讀取中…"); const response = await fetch(`/api/sources/${sourceId}/preview`); const body = await response.json(); setText(response.ok ? `本頁預覽:新增 ${body.added}、已存在 ${body.existing},共檢查 ${body.inspected}${body.hasMore ? "(來源尚有更多頁)" : ""}` : body.error || "無法預覽"); } return <div><button type="button" onClick={preview}></button>{text && <p className="meta">{text}</p>}</div>; }
+5 -4
View File
@@ -2,17 +2,18 @@
import { useState } from "react";
export function WebhookControl({ sourceId, configured }: { sourceId: number; configured: boolean }) {
const [url, setUrl] = useState(""); const [error, setError] = useState(""); const [busy, setBusy] = useState(false);
export function WebhookControl({ sourceId, configured, automatic = false }: { sourceId: number; configured: boolean; automatic?: boolean }) {
const [url, setUrl] = useState(""); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); const [busy, setBusy] = useState(false);
async function generate() {
setBusy(true); setError("");
setBusy(true); setError(""); setNotice("");
try {
const response = await fetch(`/api/sources/${sourceId}/webhook`, { method: "POST", headers: { Accept: "application/json" } });
const body = await response.json(); if (!response.ok) throw new Error(body.error || "無法產生 webhook URL"); setUrl(body.url);
const body = await response.json(); if (!response.ok) throw new Error(body.error || "無法產生 webhook URL"); if (body.rotated) setNotice("Webhook 已安全輪替。"); else setUrl(body.url);
} catch (reason) { setError(reason instanceof Error ? reason.message : "無法產生 webhook URL"); }
finally { setBusy(false); }
}
async function copy() { if (url) await navigator.clipboard.writeText(url); }
if (automatic) return <div className="webhook-control"><p className="meta">Webhook Memos </p><button type="button" onClick={generate} disabled={busy}>{busy ? "輪替中…" : "安全輪替 Webhook"}</button>{notice && <p>{notice}</p>}{error && <p className="error">{error}</p>}</div>;
return <div className="webhook-control"><p className="meta">Webhook{configured ? "已設定" : "尚未設定"}</p>
{url ? <><label className="sr-only" htmlFor={`webhook-${sourceId}`}>Webhook URL</label><input id={`webhook-${sourceId}`} readOnly value={url} onFocus={(event) => event.currentTarget.select()} /><div className="row"><button type="button" onClick={copy}> URL</button><button type="button" className="danger" onClick={generate} disabled={busy}></button></div><p className="meta"> Memos</p></> : <button type="button" onClick={generate} disabled={busy}>{busy ? "產生中…" : configured ? "重新產生 webhook URL" : "產生 webhook URL"}</button>}
{error && <p className="error">{error}</p>}
+3
View File
@@ -0,0 +1,3 @@
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
export default async function InvitePage({ params }: { params: Promise<{ token: string }> }) { const { token } = await params; const user = await getSession(); if (!user) redirect("/login"); return <section className="card"><h1></h1><p> @{user.username} </p><form action="/api/invites/accept" method="post"><input type="hidden" name="token" value={token} /><button></button></form></section>; }
+5 -2
View File
@@ -1,8 +1,11 @@
import "./styles.css";
import "./avatar.css";
import Link from "next/link";
import { getSession } from "@/lib/auth";
export const metadata = { title: "Mebbling", description: "Your Memos hub" };
import { db } from "@/lib/db";
export const metadata = { title: "Mebbling", description: "聚合朋友公開筆記的 Memos Hub", alternates: { types: { "application/rss+xml": [{ url: "/rss.xml", title: "Mebbling RSS" }], "application/atom+xml": [{ url: "/atom.xml", title: "Mebbling Atom" }] } }, openGraph: { title: "Mebbling", description: "聚合朋友公開筆記的 Memos Hub", type: "website" } };
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const user = await getSession();
return <html lang="zh-Hant"><body><header><Link href="/" className="brand">Mebbling</Link><nav><Link href="/"></Link>{user ? <><Link href="/dashboard"></Link><Link href="/account"></Link>{user.role === "admin" && <Link href="/admin"></Link>}<form action="/api/auth/logout" method="post"><button></button></form></> : <><Link href="/login"></Link><Link href="/register"></Link></>}</nav></header><main>{children}</main></body></html>;
const unread = user ? Number((db.prepare("SELECT count(*) count FROM notifications WHERE user_id=? AND read_at IS NULL").get(user.id) as { count: number }).count) : 0;
return <html lang="zh-Hant"><body><header><Link href="/" className="brand">Mebbling</Link><nav><Link href="/"></Link><Link href="/tags"></Link>{user ? <><Link href="/reading"></Link><Link href="/notifications">{unread ? ` (${unread})` : ""}</Link><Link href="/dashboard"></Link><Link href="/account"></Link>{user.role === "admin" && <Link href="/admin"></Link>}<form action="/api/auth/logout" method="post"><button></button></form></> : <><Link href="/login"></Link><Link href="/register"></Link></>}</nav></header><main>{children}</main></body></html>;
}
+10
View File
@@ -0,0 +1,10 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { db } from "@/lib/db";
export default async function NotificationsPage() {
const user = await getSession(); if (!user) redirect("/login");
const notifications = db.prepare("SELECT n.*,u.username AS actor_username FROM notifications n LEFT JOIN users u ON u.id=n.actor_id WHERE n.user_id=? ORDER BY n.created_at DESC LIMIT 100").all(user.id) as any[];
return <><div className="space"><h1></h1><form action="/api/notifications/read" method="post"><button></button></form></div>{notifications.length ? <ul className="reading-list">{notifications.map((item) => <li className={item.read_at ? "" : "unread"} key={item.id}><Link href={`/posts/${item.post_id}`}>{item.message}</Link><br /><span className="meta">{new Date(item.created_at + "Z").toLocaleString("zh-TW")}</span>{!item.read_at && <form action="/api/notifications/read" method="post"><input type="hidden" name="id" value={item.id} /><button></button></form>}</li>)}</ul> : <p className="muted"></p>}</>;
}
+18 -9
View File
@@ -1,11 +1,20 @@
import Link from "next/link"; import { db } from "@/lib/db"; import { Attachments } from "./components/attachments";
import Link from "next/link";
import { db } from "@/lib/db";
import { PostCard, type PublicPost } from "./components/post-card";
export const dynamic = "force-dynamic";
type Post = { id:number; content:string; tags_json:string; attachments_json:string; created_at:string; username:string; name:string|null; source_base_url:string|null; comment_count:number; reaction_count:number };
export default async function Home({ searchParams }: { searchParams: Promise<{ q?: string; tag?: string }> }) {
const query = await searchParams;
const q = query.q?.trim() || ""; const tag = query.tag?.trim() || "";
const where = ["p.visibility = 'PUBLIC'", "p.hidden = 0"]; const args: string[] = [];
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)}%`); }
const posts = db.prepare(`SELECT p.*, u.username, s.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 ${where.join(" AND ")} ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 100`).all(...args) as Post[];
return <><section className="space"><div><h1> Memos Hub</h1><p className="muted"></p></div><Link className="button" href="/dashboard"></Link></section><form className="row" method="get"><input name="q" defaultValue={q} placeholder="搜尋公開貼文"/><input name="tag" defaultValue={tag} placeholder="標籤"/><button></button></form>{posts.length ? posts.map(p=><article className="card" key={p.id}><div className="space"><Link className="meta post-name-link" href={`/posts/${p.id}`}>@{p.username}{p.name ? ` · ${p.name}` : ""}</Link><span className="meta">{new Date(p.created_at).toLocaleString("zh-TW")}</span></div><pre>{p.content}</pre><Attachments json={p.attachments_json} sourceBaseUrl={p.source_base_url} compact/><div className="row">{JSON.parse(p.tags_json).map((t:string)=><span className="tag" key={t}>#{t}</span>)}<Link href={`/posts/${p.id}`}>💬 {p.comment_count} 🙂 {p.reaction_count}</Link></div></article>) : <p className="muted"></p>}</>;
const pageSize = 20;
type Query = { q?: string; tag?: string; source?: string; author?: string; from?: string; to?: string; attachments?: string; page?: string };
export default async function Home({ searchParams }: { searchParams: Promise<Query> }) {
const query = await searchParams; const q = query.q?.trim() || ""; const tag = query.tag?.trim() || ""; const author = query.author?.trim() || ""; const sourceId = Number(query.source) || 0; const from = query.from || ""; const to = query.to || ""; const attachments = query.attachments === "1"; const page = Math.max(1, Number(query.page) || 1);
const where = ["p.visibility='PUBLIC'", "p.hidden=0"]; const args: (string | number)[] = [];
const ftsQuery = q.split(/\s+/).filter(Boolean).map((term) => `"${term.replaceAll('"', '""')}"`).join(" AND ");
if (q) { where.push("posts_fts MATCH ?"); args.push(ftsQuery); } if (tag) { where.push("p.tags_json LIKE ?"); args.push(`%${JSON.stringify(tag).slice(1, -1)}%`); } if (author) { where.push("(u.username LIKE ? 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 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 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>}</>;
}
+31 -3
View File
@@ -1,3 +1,31 @@
import { notFound, redirect } from "next/navigation"; import { db } from "@/lib/db"; import { getSession } from "@/lib/auth"; import { Attachments } from "@/app/components/attachments";
export const dynamic="force-dynamic";
export default async function PostPage({params}:{params:Promise<{id:string}>}){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 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;if(!post||post.hidden)notFound();const user=await getSession();if(post.visibility!=='PUBLIC'&&post.author_id!==user?.id)redirect('/');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[];return <article><p className="meta">@{post.username} · {post.name||'Hub'} · {new Date(post.created_at).toLocaleString('zh-TW')}</p><pre className="card">{post.content}</pre><Attachments json={post.attachments_json} sourceBaseUrl={post.source_base_url}/><section className="row">{reactions.map((r:any)=><span className="tag" key={r.emoji}>{r.emoji} {r.count}</span>)}{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>:<p></p>}{comments.map(c=><div className="card" key={c.id}><strong>@{c.username}</strong><p>{c.content}</p><span className="meta">{new Date(c.created_at).toLocaleString('zh-TW')}</span></div>)}</section></article>}
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { db } from "@/lib/db";
import { getSession } from "@/lib/auth";
import { Attachments } from "@/app/components/attachments";
import { Markdown } from "@/app/components/markdown";
import { canonicalTags } from "@/lib/tags";
import { postAuthorLabel } from "@/lib/display";
export const dynamic = "force-dynamic";
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,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: "找不到貼文" };
const description = post.content.replace(/\s+/g, " ").slice(0, 160); const author = postAuthorLabel(post);
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 }> }) {
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_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 (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 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[];
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><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>;
}
+13
View File
@@ -0,0 +1,13 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { db } from "@/lib/db";
type Item = { id: number; content: string; username: string; kind?: string; at: string };
function PostList({ items, empty }: { items: Item[]; empty: string }) { return items.length ? <ul className="reading-list">{items.map((item) => <li key={`${item.kind}-${item.id}`}><Link href={`/posts/${item.id}`}>{item.content.slice(0, 120) || "(空白貼文)"}</Link><br /><span className="meta">@{item.username} · {item.kind === "later" ? "稍後閱讀" : item.kind === "saved" ? "收藏" : "最近閱讀"} · {new Date(item.at + "Z").toLocaleString("zh-TW")}</span></li>)}</ul> : <p className="muted">{empty}</p>; }
export default async function ReadingPage() {
const user = await getSession(); if (!user) redirect("/login");
const saved = db.prepare("SELECT p.id,p.content,u.username,b.kind,b.created_at AS at FROM bookmarks b JOIN posts p ON p.id=b.post_id JOIN users u ON u.id=p.author_id WHERE b.user_id=? AND p.hidden=0 ORDER BY b.created_at DESC").all(user.id) as Item[];
const history = db.prepare("SELECT p.id,p.content,u.username,h.last_read_at AS at FROM reading_history h JOIN posts p ON p.id=h.post_id JOIN users u ON u.id=p.author_id WHERE h.user_id=? AND p.hidden=0 ORDER BY h.last_read_at DESC LIMIT 50").all(user.id) as Item[];
return <><h1></h1><section className="card"><h2></h2><PostList items={saved} empty="尚未收藏任何貼文。" /></section><section className="card"><h2></h2><PostList items={history} empty="尚無閱讀紀錄。" /></section></>;
}
+8
View File
@@ -0,0 +1,8 @@
import { db } from "@/lib/db";
const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, (char) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", '"': "&quot;" }[char] || char));
export async function GET() {
const origin = (process.env.NEXT_PUBLIC_APP_URL || "http://localhost:8088").replace(/\/$/, ""); const posts = db.prepare("SELECT p.id,p.content,COALESCE(p.remote_created_at,p.created_at) AS published_at,u.username FROM posts p JOIN users u ON u.id=p.author_id WHERE p.visibility='PUBLIC' AND p.hidden=0 ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 50").all() as { id: number; content: string; published_at: string; username: string }[];
const items = posts.map((post) => `<item><title>${escapeXml(`@${post.username} 的貼文`)}</title><link>${origin}/posts/${post.id}</link><guid>${origin}/posts/${post.id}</guid><description>${escapeXml(post.content.slice(0, 500))}</description><pubDate>${new Date(post.published_at).toUTCString()}</pubDate></item>`).join("");
return new Response(`<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>Mebbling</title><link>${origin}</link><description>公開 Memos Hub</description>${items}</channel></rss>`, { headers: { "Content-Type": "application/rss+xml; charset=utf-8", "Cache-Control": "public, max-age=300" } });
}
+13
View File
@@ -0,0 +1,13 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { db } from "@/lib/db";
import { PostCard, type PublicPost } from "@/app/components/post-card";
import { getSession } from "@/lib/auth";
export const dynamic = "force-dynamic";
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 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));
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
@@ -1 +1 @@
:root{color-scheme:dark;font-family:ui-sans-serif,system-ui;background:#10131a;color:#edf1f8}*{box-sizing:border-box}body{margin:0}header{display:flex;justify-content:space-between;align-items:center;padding:1rem max(1.5rem,calc((100% - 1000px)/2));border-bottom:1px solid #293243;background:#151a23;position:sticky;top:0}main{width:min(900px,calc(100% - 2rem));margin:2rem auto}.brand{font-size:1.35rem;font-weight:700;color:#8ab4ff}nav{display:flex;gap:1rem;align-items:center}a{color:#bcd3ff;text-decoration:none}button,.button{background:#3778e5;color:#fff;border:0;border-radius:.5rem;padding:.55rem .8rem;cursor:pointer;font:inherit}button:hover,.button:hover{filter:brightness(1.1)}form{display:grid;gap:.8rem;max-width:580px}input,textarea,select{width:100%;padding:.65rem;border:1px solid #3a455a;border-radius:.45rem;background:#171d28;color:inherit}textarea{min-height:140px}.card{background:#171d28;border:1px solid #293243;border-radius:.75rem;padding:1rem;margin:.8rem 0}.muted{color:#aab4c5}.row{display:flex;gap:.7rem;align-items:center;flex-wrap:wrap}.space{display:flex;justify-content:space-between;gap:1rem}.error{color:#ff9d9d}.tag{background:#25314a;padding:.15rem .45rem;border-radius:.4rem;font-size:.85rem}pre{white-space:pre-wrap;font-family:inherit}.meta{font-size:.86rem;color:#aab4c5}.danger{background:#aa3746}.attachments{display:flex;flex-wrap:wrap;gap:.65rem;margin:.9rem 0}.attachment-image{display:block;max-width:min(100%,520px);padding:0;background:none;border:0;border-radius:.5rem;overflow:hidden}.attachment-image img{display:block;max-width:100%;max-height:520px;border-radius:.5rem;border:1px solid #3a455a}.attachment-image:hover img{border-color:#8ab4ff}.attachments-compact .attachment-image{max-width:220px}.attachments-compact .attachment-image img{max-height:220px;object-fit:cover}.attachment-file{padding:.45rem .65rem;border:1px solid #3a455a;border-radius:.45rem;background:#202838}.image-lightbox{position:fixed;z-index:100;inset:0;display:grid;place-items:center;padding:2rem;background:rgb(0 0 0 / .88);cursor:zoom-out}.image-lightbox img{display:block;max-width:100%;max-height:100%;object-fit:contain;cursor:default}.image-lightbox-close{position:absolute;top:1rem;right:1rem;width:2.5rem;height:2.5rem;padding:0;border-radius:50%;font-size:2rem;line-height:1;background:#25314a}
:root{color-scheme:dark;font-family:ui-sans-serif,system-ui;background:#10131a;color:#edf1f8}*{box-sizing:border-box}body{margin:0}header{display:flex;justify-content:space-between;align-items:center;padding:1rem max(1.5rem,calc((100% - 1000px)/2));border-bottom:1px solid #293243;background:#151a23;position:sticky;top:0;z-index:10}main{width:min(900px,calc(100% - 2rem));margin:2rem auto}.brand{font-size:1.35rem;font-weight:700;color:#8ab4ff}nav{display:flex;gap:1rem;align-items:center;flex-wrap:wrap}a{color:#bcd3ff;text-decoration:none}button,.button{background:#3778e5;color:#fff;border:0;border-radius:.5rem;padding:.55rem .8rem;cursor:pointer;font:inherit}button:hover,.button:hover{filter:brightness(1.1)}form{display:grid;gap:.8rem;max-width:580px}input,textarea,select{width:100%;padding:.65rem;border:1px solid #3a455a;border-radius:.45rem;background:#171d28;color:inherit}textarea{min-height:140px}.card{background:#171d28;border:1px solid #293243;border-radius:.75rem;padding:1rem;margin:.8rem 0}.muted{color:#aab4c5}.row{display:flex;gap:.7rem;align-items:center;flex-wrap:wrap}.space{display:flex;justify-content:space-between;gap:1rem}.error{color:#ff9d9d}.tag{background:#25314a;padding:.15rem .45rem;border-radius:.4rem;font-size:.85rem}.meta{font-size:.86rem;color:#aab4c5}.danger{background:#aa3746}.attachments{display:flex;flex-wrap:wrap;gap:.65rem;margin:.9rem 0}.attachment-image{display:block;max-width:min(100%,520px);padding:0;background:none;border:0;border-radius:.5rem;overflow:hidden}.attachment-image img{display:block;max-width:100%;max-height:520px;border-radius:.5rem;border:1px solid #3a455a}.attachment-image:hover img{border-color:#8ab4ff}.attachments-compact .attachment-image{max-width:220px}.attachments-compact .attachment-image img{max-height:220px;object-fit:cover}.attachment-file{padding:.45rem .65rem;border:1px solid #3a455a;border-radius:.45rem;background:#202838}.image-lightbox{position:fixed;z-index:100;inset:0;display:grid;place-items:center;padding:2rem;background:rgb(0 0 0 / .88);cursor:zoom-out}.image-lightbox img{display:block;max-width:100%;max-height:100%;object-fit:contain;cursor:default}.image-lightbox-close{position:absolute;top:1rem;right:1rem;width:2.5rem;height:2.5rem;padding:0;border-radius:50%;font-size:2rem;line-height:1;background:#25314a}.markdown{line-height:1.7;overflow-wrap:anywhere}.markdown>*:first-child{margin-top:0}.markdown>*:last-child{margin-bottom:0}.markdown pre{overflow:auto;padding:1rem;border-radius:.5rem;background:#0c1017}.markdown code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.markdown :not(pre)>code{padding:.1rem .3rem;border-radius:.25rem;background:#25314a}.markdown blockquote{margin-left:0;padding-left:1rem;border-left:3px solid #5278ba;color:#c1cad8}.markdown table{border-collapse:collapse;display:block;overflow:auto}.markdown th,.markdown td{padding:.4rem .6rem;border:1px solid #3a455a}.markdown-compact{max-height:18rem;overflow:hidden;mask-image:linear-gradient(#000 85%,transparent)}.search-form{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));max-width:none;margin:1rem 0}.search-form label{display:grid;gap:.3rem}.search-form .check{display:flex;align-items:center;gap:.4rem}.search-form .check input{width:auto}.pagination{display:flex;justify-content:center;gap:1rem;align-items:center;margin:2rem 0}.reading-list,.job-list{list-style:none;padding:0;display:grid;gap:.7rem}.reading-list li,.job-list li{padding:.8rem;border:1px solid #293243;border-radius:.5rem}.unread{border-left:3px solid #8ab4ff!important}@media (max-width:700px){header{align-items:flex-start;flex-direction:column}.search-form{grid-template-columns:1fr 1fr}.search-form button{grid-column:span 2}}
+11
View File
@@ -0,0 +1,11 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { db } from "@/lib/db";
import { PostCard, type PublicPost } from "@/app/components/post-card";
export const dynamic = "force-dynamic";
export default async function TagPage({ params }: { params: Promise<{ tag: string }> }) {
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_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} />)}</>;
}
+12
View File
@@ -0,0 +1,12 @@
import Link from "next/link";
import { db } from "@/lib/db";
import { canonicalTag } from "@/lib/tags";
export const dynamic = "force-dynamic";
export default async function TagsPage({ searchParams }: { searchParams: Promise<{ days?: string }> }) {
const query = await searchParams; const days = [30, 90, 365, 0].includes(Number(query.days)) ? Number(query.days) : 0;
const rows = db.prepare(`SELECT tags_json FROM posts WHERE visibility='PUBLIC' AND hidden=0 ${days ? "AND created_at >= datetime('now', ?)" : ""}`).all(...(days ? [`-${days} days`] : [])) as { tags_json: string }[];
const counts = new Map<string, number>(); for (const row of rows) { try { for (const tag of new Set(JSON.parse(row.tags_json) as string[])) { const canonical = canonicalTag(tag); counts.set(canonical, (counts.get(canonical) || 0) + 1); } } catch {} }
const tags = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0], "zh-Hant")); const max = Math.max(...tags.map(([, count]) => count), 1);
return <><h1></h1><nav className="row"><Link className={!days ? "tag" : ""} href="/tags"></Link>{[30, 90, 365].map((value) => <Link className={days === value ? "tag" : ""} href={`/tags?days=${value}`} key={value}> {value} </Link>)}</nav><p className="muted">使 {tags.length} </p><section className="tag-cloud">{tags.map(([tag, count]) => <Link href={`/tags/${encodeURIComponent(tag)}`} key={tag} style={{ fontSize: `${0.9 + (count / max) * 1.5}rem` }} title={`${count} 篇貼文`}>#{tag}<small>{count}</small></Link>)}</section></>;
}
+10 -2
View File
@@ -1,6 +1,10 @@
services:
web:
build: .
image: mebbling:${MEBBLING_VERSION:-0.8.0}
build:
context: .
args:
APP_VERSION: "${MEBBLING_VERSION:-0.8.0}"
ports: ["8088:3000"]
env_file: .env
environment: { DATABASE_PATH: /app/data/hub.db }
@@ -9,7 +13,11 @@ services:
- ./public/uploads:/app/public/uploads
restart: unless-stopped
worker:
build: .
image: mebbling:${MEBBLING_VERSION:-0.8.0}
build:
context: .
args:
APP_VERSION: "${MEBBLING_VERSION:-0.8.0}"
command: npm run worker
env_file: .env
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` 驗證文件中的服務狀態檢查。
+56 -6
View File
@@ -13,20 +13,70 @@
- `hub.db`:由正在執行的 SQLite 資料庫建立的一致性備份。
- `uploads.tar.gz`Hub 本機上傳的附件。
`data/backups/` 已由 Git 排除。請將備份複製到另一台主機或加密的雲端儲存;只留在同一台機器不算完整備份。
每次備份都會執行 SQLite `integrity_check`、驗證附件壓縮檔,並在 `SHA256SUMS` 記錄雜湊。`data/backups/` 已由 Git 排除。也可隨時執行不改動正式資料的驗證:
```bash
./scripts/verify-backup.sh data/backups/<時間>
```
可設定保留與異地複製(例如掛載的 NAS、加密磁碟或 rclone 掛載點):
```bash
BACKUP_RETENTION_DAYS=30 BACKUP_OFFSITE_DIR=/mnt/nas/mebbling ./scripts/backup.sh
```
若異地目的地可能由他人讀取,使用 `age` 加密。設定加密後,異地只會收到 `backup.tar.gz.age`,本機仍保留可供快速還原的已驗證備份:
```bash
BACKUP_AGE_RECIPIENT=age1你的收件人公鑰 \
BACKUP_RCLONE_REMOTE='remote:bucket/mebbling' \
BACKUP_RETENTION_DAYS=30 ./scripts/backup.sh
```
`BACKUP_RCLONE_REMOTE` 可使用已在主機設定好的 rclone S3、B2、SFTP 等 remote;腳本會在缺少 `age``rclone` 時安全失敗,不會假裝已完成異地備份。加密檔要還原時,先以持有的 age identity 解密並解壓為原本的備份目錄,再使用下列還原命令。請把 `verify-backup.sh` 的成功輸出保留在 cron log 中,作為每日可用性驗證報告;完整還原演練仍應定期在隔離環境執行。
在 WSL 主機安裝每日 03:15 排程:
```bash
./scripts/install-backup-cron.sh
```
## 還原
1. 停止服務:`docker compose down`
2. 備份目前的 `data/hub.db``public/uploads/`,以免操作失誤。
3. 將選定備份中的 `hub.db` 覆蓋為 `data/hub.db`
4. 解開附件:`tar -xzf data/backups/<時間>/uploads.tar.gz -C public`
5. 重新啟動:`docker compose up -d`
先在非正式環境執行還原演練;腳本會驗證雜湊、停止服務、保留原本資料庫副本、還原資料庫與附件後重新啟動:
```bash
CONFIRM_RESTORE=YES ./scripts/restore.sh data/backups/<時間>
```
請始終一起還原資料庫與附件,否則貼文中的附件連結可能失效。
## 健康檢查與日誌
反向代理或監控服務可呼叫 `GET /api/health`。收到 `200` 且 JSON 的 `ok: true` 表示 Web 與 SQLite 可用;`failedJobs` 可用於設定同步異常告警。
容器日誌為 JSON 事件。管理員頁會保留最近的同步錯誤;請定期備份 SQLite,因為錯誤事件和限流狀態同樣位於資料庫。
## 附件掃毒
Hub 原生附件預設只接受圖片、PDF、純文字與 Markdown。若要串接掃毒服務,設定 `VIRUS_SCAN_URL`;服務應接受檔案內容的 HTTP POST,並回覆 JSON `{ "clean": true }`。設為 `VIRUS_SCAN_REQUIRED=1` 後,掃毒服務逾時或不可用時會拒絕上傳。
## Schema migration
資料庫 schema 由 `lib/db.ts` 管理。每個欄位 migration 在 `schema_migrations` 表中記錄版本與套用時間,啟動 Web 或 Worker 時會自動執行尚未套用的安全 migration。
升級 Mebbling 前請先執行備份。若新版本在測試環境正常運作,再升級正式資料;不支援直接以舊程式碼讀取已升級 schema 的保證。
## 資料保存與刪除
`NOTIFICATION_RETENTION_DAYS``READING_HISTORY_RETENTION_DAYS``AUDIT_RETENTION_DAYS` 可設定保存天數;`0` 代表無限期保存。Worker 每日清理一次。到期或已使用的邀請連結會自動移除。
使用者可在「帳號設定」以目前密碼與 `DELETE` 文字確認刪除帳號及個人資料。若帳號仍擁有來源,必須先轉移或刪除來源,避免誤刪共享內容。
## 外部告警
設定 `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 的雙寫不列為預設策略;除非有完整一致性驗證,避免長期雙寫。
+38
View File
@@ -0,0 +1,38 @@
import { db } from "@/lib/db";
function allowed(bucket: string, seconds = 3600) {
const now = Math.floor(Date.now() / 1000); const row = db.prepare("SELECT reset_at FROM rate_limits WHERE bucket=?").get(bucket) as { reset_at: number } | undefined;
if (row && row.reset_at > now) return false;
db.prepare("INSERT INTO rate_limits(bucket,count,reset_at) VALUES(?,1,?) ON CONFLICT(bucket) DO UPDATE SET count=count+1,reset_at=excluded.reset_at").run(bucket, now + seconds); return true;
}
export function sendAlert(bucket: string, title: string, message: string) {
if (!process.env.ALERT_WEBHOOK_URL?.trim() || !allowed(`alert:${bucket}`)) return false;
db.prepare("INSERT INTO alert_deliveries(bucket,title,message) VALUES(?,?,?)").run(bucket, title, message);
return true;
}
type Alert = { id: number; title: string; message: string; attempts: number };
export async function deliverNextAlert() {
const candidate = db.prepare("SELECT id FROM alert_deliveries WHERE status='queued' AND run_after<=CURRENT_TIMESTAMP ORDER BY id LIMIT 1").get() as { id: number } | undefined;
if (!candidate) return false;
const claimed = db.prepare("UPDATE alert_deliveries SET status='running',attempts=attempts+1,started_at=CURRENT_TIMESTAMP WHERE id=? AND status='queued'").run(candidate.id);
if (!claimed.changes) return false;
const alert = db.prepare("SELECT id,title,message,attempts FROM alert_deliveries WHERE id=?").get(candidate.id) as Alert;
const url = process.env.ALERT_WEBHOOK_URL?.trim();
try {
if (!url) throw new Error("ALERT_WEBHOOK_URL is not configured");
const target = new URL(url);
if (target.protocol !== "https:") throw new Error("Alert webhook must use HTTPS");
const isNtfy = /(^|\.)ntfy\.sh$/.test(target.hostname);
const response = await fetch(target, isNtfy ? { method: "POST", headers: { Title: alert.title, Priority: "high" }, body: alert.message, signal: AbortSignal.timeout(10_000) } : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: `**${alert.title}**\n${alert.message}` }), signal: AbortSignal.timeout(10_000) });
if (!response.ok) throw new Error(`Alert endpoint returned ${response.status}`);
db.prepare("UPDATE alert_deliveries SET status='done',finished_at=CURRENT_TIMESTAMP,last_error=NULL WHERE id=?").run(alert.id);
} catch (error) {
const message = error instanceof Error ? error.message : "Alert delivery failed";
const exhausted = alert.attempts >= 5;
const delayMinutes = Math.min(60, 2 ** Math.max(0, alert.attempts - 1));
db.prepare("UPDATE alert_deliveries SET status=?,last_error=?,finished_at=CASE WHEN ? THEN CURRENT_TIMESTAMP ELSE NULL END,run_after=CASE WHEN ? THEN run_after ELSE datetime('now', ?) END WHERE id=?").run(exhausted ? "failed" : "queued", message, exhausted ? 1 : 0, exhausted ? 1 : 0, `+${delayMinutes} minutes`, alert.id);
}
return true;
}
+4
View File
@@ -0,0 +1,4 @@
import { db } from "@/lib/db";
export function audit(actorUserId: number | null, action: string, targetType: string, targetId?: string | number, metadata: unknown = {}) {
db.prepare("INSERT INTO audit_events(actor_user_id,action,target_type,target_id,metadata_json) VALUES(?,?,?,?,?)").run(actorUserId, action, targetType, targetId == null ? null : String(targetId), JSON.stringify(metadata));
}
+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("; ")}`); }
+108
View File
@@ -1,7 +1,9 @@
import Database from "better-sqlite3";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { requireRuntimeConfig } from "@/lib/config";
requireRuntimeConfig();
const path = process.env.HUB_BUILD === "1" ? ":memory:" : (process.env.DATABASE_PATH || "./data/hub.db");
if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true });
export const db = new Database(path);
@@ -19,6 +21,8 @@ CREATE TABLE IF NOT EXISTS sources (
name TEXT NOT NULL, base_url TEXT NOT NULL, token_encrypted TEXT NOT NULL, remote_user TEXT,
webhook_supported INTEGER NOT NULL DEFAULT 0, sync_status TEXT NOT NULL DEFAULT 'pending', last_synced_at TEXT, last_error TEXT,
is_enabled INTEGER NOT NULL DEFAULT 1, disabled_at TEXT,
sync_tags_json TEXT NOT NULL DEFAULT '[]', sync_from TEXT, sync_to TEXT, sync_attachment_mode TEXT NOT NULL DEFAULT 'all',
remote_display_name TEXT, remote_avatar_url TEXT, last_connection_at TEXT, last_connection_error TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(user_id, base_url)
);
CREATE TABLE IF NOT EXISTS posts (
@@ -26,6 +30,7 @@ CREATE TABLE IF NOT EXISTS posts (
author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, remote_memo_name TEXT, content TEXT NOT NULL,
visibility TEXT NOT NULL DEFAULT 'PUBLIC', tags_json TEXT NOT NULL DEFAULT '[]', attachments_json TEXT NOT NULL DEFAULT '[]',
origin TEXT NOT NULL DEFAULT 'memos', remote_created_at TEXT, remote_updated_at TEXT, sync_status TEXT NOT NULL DEFAULT 'synced',
remote_url TEXT,
hidden INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(source_id, remote_memo_name)
);
@@ -54,12 +59,58 @@ CREATE TABLE IF NOT EXISTS source_members (
role TEXT NOT NULL DEFAULT 'member', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(source_id, user_id)
);
CREATE TABLE IF NOT EXISTS bookmarks (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
kind TEXT NOT NULL DEFAULT 'saved', created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(user_id, post_id)
);
CREATE TABLE IF NOT EXISTS reading_history (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
last_read_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(user_id, post_id)
);
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
actor_id INTEGER REFERENCES users(id) ON DELETE SET NULL, post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,
type TEXT NOT NULL, message TEXT NOT NULL, read_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS posts_public_idx ON posts(visibility, hidden, created_at DESC);
CREATE INDEX IF NOT EXISTS sync_jobs_idx ON sync_jobs(status, run_after);
CREATE INDEX IF NOT EXISTS notifications_user_idx ON notifications(user_id, read_at, created_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS source_remote_identity_unique ON sources(base_url, remote_user) WHERE remote_user IS NOT NULL;
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS rate_limits (
bucket TEXT PRIMARY KEY, count INTEGER NOT NULL, reset_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS error_events (
id INTEGER PRIMARY KEY, scope TEXT NOT NULL, message TEXT NOT NULL, context_json TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS alert_deliveries (
id INTEGER PRIMARY KEY, bucket TEXT NOT NULL, title TEXT NOT NULL, message TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued', attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, started_at TEXT, finished_at TEXT,
run_after TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS source_invites (
id INTEGER PRIMARY KEY, source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
token_hash TEXT UNIQUE NOT NULL, role TEXT NOT NULL DEFAULT 'viewer', expires_at TEXT NOT NULL,
used_at TEXT, created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS audit_events (
id INTEGER PRIMARY KEY, actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL, target_type TEXT NOT NULL, target_id TEXT, metadata_json TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tag_aliases (
alias TEXT PRIMARY KEY COLLATE NOCASE, canonical TEXT NOT NULL COLLATE NOCASE,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
db.exec("INSERT OR IGNORE INTO source_members(source_id,user_id,role) SELECT id,user_id,'owner' FROM sources");
@@ -77,6 +128,63 @@ applyColumnMigration(4, "sources", "disabled_at", "ALTER TABLE sources ADD COLUM
applyColumnMigration(5, "sync_jobs", "trigger", "ALTER TABLE sync_jobs ADD COLUMN trigger TEXT NOT NULL DEFAULT 'manual'");
applyColumnMigration(6, "sync_jobs", "started_at", "ALTER TABLE sync_jobs ADD COLUMN started_at TEXT");
applyColumnMigration(7, "sync_jobs", "finished_at", "ALTER TABLE sync_jobs ADD COLUMN finished_at TEXT");
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(8)").run();
applyColumnMigration(9, "sources", "sync_tags_json", "ALTER TABLE sources ADD COLUMN sync_tags_json TEXT NOT NULL DEFAULT '[]'");
applyColumnMigration(10, "sources", "sync_from", "ALTER TABLE sources ADD COLUMN sync_from TEXT");
applyColumnMigration(11, "sources", "sync_to", "ALTER TABLE sources ADD COLUMN sync_to TEXT");
applyColumnMigration(12, "sources", "sync_attachment_mode", "ALTER TABLE sources ADD COLUMN sync_attachment_mode TEXT NOT NULL DEFAULT 'all'");
applyColumnMigration(13, "sources", "remote_display_name", "ALTER TABLE sources ADD COLUMN remote_display_name TEXT");
applyColumnMigration(14, "sources", "remote_avatar_url", "ALTER TABLE sources ADD COLUMN remote_avatar_url TEXT");
applyColumnMigration(15, "sources", "last_connection_at", "ALTER TABLE sources ADD COLUMN last_connection_at TEXT");
applyColumnMigration(16, "sources", "last_connection_error", "ALTER TABLE sources ADD COLUMN last_connection_error TEXT");
applyColumnMigration(17, "posts", "remote_url", "ALTER TABLE posts ADD COLUMN remote_url TEXT");
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(18)").run();
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(19)").run();
applyColumnMigration(20, "sources", "attachment_storage_mode", "ALTER TABLE sources ADD COLUMN attachment_storage_mode TEXT NOT NULL DEFAULT 'remote'");
applyColumnMigration(21, "sources", "attachment_cache_limit_bytes", "ALTER TABLE sources ADD COLUMN attachment_cache_limit_bytes INTEGER NOT NULL DEFAULT 104857600");
applyColumnMigration(22, "sources", "attachment_cache_error", "ALTER TABLE sources ADD COLUMN attachment_cache_error TEXT");
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(23)").run();
applyColumnMigration(24, "sources", "webhook_mode", "ALTER TABLE sources ADD COLUMN webhook_mode TEXT NOT NULL DEFAULT 'manual'");
applyColumnMigration(25, "sources", "webhook_remote_name", "ALTER TABLE sources ADD COLUMN webhook_remote_name TEXT");
applyColumnMigration(26, "sources", "webhook_signing_secret_encrypted", "ALTER TABLE sources ADD COLUMN webhook_signing_secret_encrypted TEXT");
applyColumnMigration(27, "sources", "integration_type", "ALTER TABLE sources ADD COLUMN integration_type TEXT NOT NULL DEFAULT 'memos'");
applyColumnMigration(28, "sources", "rss_feed_url", "ALTER TABLE sources ADD COLUMN rss_feed_url TEXT");
applyColumnMigration(29, "sources", "sync_batch_size", "ALTER TABLE sources ADD COLUMN sync_batch_size INTEGER NOT NULL DEFAULT 100");
applyColumnMigration(30, "sources", "sync_max_posts", "ALTER TABLE sources ADD COLUMN sync_max_posts INTEGER");
applyColumnMigration(31, "sources", "sync_cursor", "ALTER TABLE sources ADD COLUMN sync_cursor TEXT");
applyColumnMigration(32, "sources", "sync_imported_count", "ALTER TABLE sources ADD COLUMN sync_imported_count INTEGER NOT NULL DEFAULT 0");
applyColumnMigration(33, "sources", "sync_run_id", "ALTER TABLE sources ADD COLUMN sync_run_id TEXT");
applyColumnMigration(34, "posts", "last_seen_sync_run", "ALTER TABLE posts ADD COLUMN last_seen_sync_run TEXT");
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(35)").run();
db.prepare("UPDATE source_members SET role='editor' WHERE role='member'").run();
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(36)").run();
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(37)").run();
applyColumnMigration(38, "sources", "attachment_archive_after_days", "ALTER TABLE sources ADD COLUMN attachment_archive_after_days INTEGER");
const ftsSchema = `
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(content, tags);
CREATE TRIGGER IF NOT EXISTS posts_fts_insert AFTER INSERT ON posts BEGIN
INSERT INTO posts_fts(rowid,content,tags) VALUES(new.id,new.content,new.tags_json);
END;
CREATE TRIGGER IF NOT EXISTS posts_fts_delete AFTER DELETE ON posts BEGIN
DELETE FROM posts_fts WHERE rowid=old.id;
END;
CREATE TRIGGER IF NOT EXISTS posts_fts_update AFTER UPDATE OF content,tags_json ON posts BEGIN
DELETE FROM posts_fts WHERE rowid=old.id;
INSERT INTO posts_fts(rowid,content,tags) VALUES(new.id,new.content,new.tags_json);
END;
`;
db.exec(ftsSchema);
const hasFtsMigration = db.prepare("SELECT 1 FROM schema_migrations WHERE version=39").get();
if (!hasFtsMigration) { db.prepare("INSERT INTO posts_fts(rowid,content,tags) SELECT id,content,tags_json FROM posts").run(); db.prepare("INSERT INTO schema_migrations(version) VALUES(39)").run(); }
db.exec("CREATE INDEX IF NOT EXISTS posts_source_visibility_idx ON posts(source_id,visibility,hidden); CREATE INDEX IF NOT EXISTS posts_author_visibility_idx ON posts(author_id,visibility,hidden);");
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(40)").run();
db.exec("CREATE INDEX IF NOT EXISTS alert_deliveries_status_idx ON alert_deliveries(status,run_after)");
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(41)").run();
const 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 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;
})();
}
+42 -6
View File
@@ -1,19 +1,55 @@
export type MemosMemo = { name: string; content: string; visibility: string; createTime?: string; updateTime?: string; tags?: string[]; attachments?: unknown[]; resources?: unknown[] };
export type MemosMemo = { name: string; content: string; visibility: string; creator?: string; createTime?: string; updateTime?: string; tags?: string[]; attachments?: { type?: string }[]; resources?: { type?: string }[] };
export type MemosIdentity = { name: string; username?: string; nickname?: string; displayName?: string; avatarUrl?: string; avatar?: string };
export type MemosSyncRules = { creator?: string | null; tags?: string[]; from?: string | null; to?: string | null; attachmentMode?: "all" | "images" | "none" };
const base = (url: string) => url.replace(/\/+$/, "") + "/api/v1";
async function request(url: string, token: string, init?: RequestInit) {
const res = await fetch(url, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) }, cache: "no-store" });
if (!res.ok) throw new Error(`Memos API ${res.status}: ${await res.text()}`); return res;
}
export async function createUserWebhook(baseUrl: string, token: string, userName: string, webhook: { url: string; displayName: string; signingSecret: string }) {
const user = userName.split("/").at(-1); if (!user) throw new Error("Invalid Memos user");
return (await request(`${base(baseUrl)}/users/${encodeURIComponent(user)}/webhooks`, token, { method: "POST", body: JSON.stringify(webhook) })).json() as Promise<{ name: string }>;
}
export async function deleteUserWebhook(baseUrl: string, token: string, userName: string, webhookName: string) {
const user = userName.split("/").at(-1), webhook = webhookName.split("/").at(-1); if (!user || !webhook) throw new Error("Invalid Memos webhook");
await request(`${base(baseUrl)}/users/${encodeURIComponent(user)}/webhooks/${encodeURIComponent(webhook)}`, token, { method: "DELETE" });
}
export async function verifyMemos(baseUrl: string, token: string) { await request(`${base(baseUrl)}/memos?pageSize=1`, token); }
export async function getMemosIdentity(baseUrl: string, token: string) {
const user = await (await request(`${base(baseUrl)}/auth/status`, token, { method: "POST", body: "{}" })).json() as { name: string; username?: string };
let user: MemosIdentity;
try { user = await (await request(`${base(baseUrl)}/auth/me`, token)).json() as MemosIdentity; if ((user as any).user) user = (user as any).user as MemosIdentity; }
catch (error) { if (!(error instanceof Error) || !error.message.startsWith("Memos API 404")) throw error; user = await (await request(`${base(baseUrl)}/auth/status`, token, { method: "POST", body: "{}" })).json() as MemosIdentity; }
if (!user.name) throw new Error("Memos did not return an account identity");
return user;
}
export async function listMemos(baseUrl: string, token: string) {
const all: MemosMemo[] = []; let pageToken = "";
do { const res = await request(`${base(baseUrl)}/memos?pageSize=100${pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ""}`, token); const data = await res.json(); all.push(...(data.memos || [])); pageToken = data.nextPageToken || ""; } while (pageToken);
return all.filter((memo) => memo.visibility === "PUBLIC");
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 async function listMemos(baseUrl: string, token: string, rules: MemosSyncRules = {}, page: { pageToken?: string; pageSize?: number } = {}): Promise<MemosPage> {
let pageToken = page.pageToken || "";
const tags = rules.tags?.filter(Boolean) || []; const mode = rules.attachmentMode || "all";
const quote = (value: string) => JSON.stringify(value); const filters = [rules.creator && `creator == ${quote(rules.creator)}`, `visibility == "PUBLIC"`, ...tags.map((tag) => `tags.exists(t, t == ${quote(tag)})`)].filter(Boolean).join(" && ");
let useServerFilter = Boolean(filters);
do { const params = new URLSearchParams({ pageSize: String(Math.max(10, Math.min(100, page.pageSize || 100))) }); if (pageToken) params.set("pageToken", pageToken); if (useServerFilter && filters) params.set("filter", filters); let data: any;
try { data = await (await request(`${base(baseUrl)}/memos?${params}`, token)).json(); }
catch (error) { if (!pageToken && useServerFilter && error instanceof Error && error.message.startsWith("Memos API 400")) { useServerFilter = false; continue; } throw error; }
const memos = (data.memos || []).filter((memo: MemosMemo) => {
if (memo.visibility !== "PUBLIC") return false;
if (rules.creator && memo.creator !== rules.creator) return false;
if (tags.length && !tags.every((tag) => memo.tags?.includes(tag))) return false;
const created = memo.createTime?.slice(0, 10); if (rules.from && (!created || created < rules.from)) return false; if (rules.to && (!created || created > rules.to)) return false;
return true;
}).map((memo: MemosMemo) => {
if (mode === "all") return memo;
const onlyImages = <T extends { type?: string }>(items: T[] | undefined) => mode === "none" ? [] : (items || []).filter((item) => item.type?.startsWith("image/"));
return { ...memo, attachments: onlyImages(memo.attachments), resources: onlyImages(memo.resources) };
});
return { memos, nextPageToken: data.nextPageToken || "" };
} while (true);
}
export async function createMemo(baseUrl: string, token: string, memo: Pick<MemosMemo, "content" | "visibility"> & { attachments?: unknown[]; resources?: unknown[] }) {
return (await request(`${base(baseUrl)}/memos`, token, { method: "POST", body: JSON.stringify({ state: "NORMAL", ...memo }) })).json() as Promise<MemosMemo>;
+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";
}
+6
View File
@@ -0,0 +1,6 @@
import { db } from "@/lib/db";
export function notify(userId: number, actorId: number, postId: number, type: "comment" | "reaction", message: string) {
if (userId === actorId) return;
db.prepare("INSERT INTO notifications(user_id,actor_id,post_id,type,message) VALUES(?,?,?,?,?)").run(userId, actorId, postId, type, message);
}
+11
View File
@@ -0,0 +1,11 @@
import { db } from "@/lib/db";
export function logEvent(level: "info" | "warn" | "error", event: string, fields: Record<string, unknown> = {}) {
console[level](JSON.stringify({ timestamp: new Date().toISOString(), level, event, ...fields }));
}
export function recordError(scope: string, error: unknown, context: Record<string, unknown> = {}) {
const message = error instanceof Error ? error.message : "Unknown error";
db.prepare("INSERT INTO error_events(scope,message,context_json) VALUES(?,?,?)").run(scope, message.slice(0, 1000), JSON.stringify(context));
logEvent("error", "application_error", { scope, message, ...context });
}
+17 -6
View File
@@ -1,8 +1,19 @@
const visits = new Map<string, { count: number; resetAt: number }>();
import { db } from "@/lib/db";
export function withinRateLimit(key: string, limit = 30, windowMs = 60_000) {
const now = Date.now(); const record = visits.get(key);
if (!record || record.resetAt <= now) { visits.set(key, { count: 1, resetAt: now + windowMs }); return true; }
if (record.count >= limit) return false;
record.count += 1; return true;
/** SQLite-backed fixed-window limiter shared by every Web container using this database. */
export function withinRateLimit(bucket: string, limit = 30, windowMs = 60_000) {
const now = Date.now();
const transaction = db.transaction(() => {
const found = db.prepare("SELECT count,reset_at FROM rate_limits WHERE bucket=?").get(bucket) as { count: number; reset_at: number } | undefined;
if (!found || found.reset_at <= now) {
db.prepare("INSERT INTO rate_limits(bucket,count,reset_at) VALUES(?,?,?) ON CONFLICT(bucket) DO UPDATE SET count=excluded.count,reset_at=excluded.reset_at").run(bucket, 1, now + windowMs);
return true;
}
if (found.count >= limit) return false;
db.prepare("UPDATE rate_limits SET count=count+1 WHERE bucket=?").run(bucket);
return true;
});
const allowed = transaction();
if (Math.random() < 0.01) db.prepare("DELETE FROM rate_limits WHERE reset_at<?").run(now);
return allowed;
}
+35
View File
@@ -0,0 +1,35 @@
import { resolve4, resolve6 } from "node:dns/promises";
import { isIP } from "node:net";
export type RssItem = { id: string; content: string; link: string; publishedAt: string | null };
const 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 atomLink = (xml: string) => decode(xml.match(/<link\b[^>]*\bhref=["']([^"']+)["'][^>]*>/i)?.[1] || "");
const date = (value: string) => { const parsed = new Date(value); return value && !Number.isNaN(parsed.getTime()) ? parsed.toISOString() : null; };
function privateIp(address: string) {
if (isIP(address) === 4) { const [a, b] = address.split(".").map(Number); return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); }
const normalized = address.toLowerCase(); return normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:") || normalized.startsWith("::ffff:127.") || normalized.startsWith("::ffff:10.") || normalized.startsWith("::ffff:192.168.");
}
async function assertPublicHttps(raw: string) {
const url = new URL(raw); if (url.protocol !== "https:" || url.username || url.password) throw new Error("RSS feed must use a public HTTPS URL"); const host = url.hostname.toLowerCase(); if (host === "localhost" || host.endsWith(".local")) throw new Error("RSS feed host is not public");
const direct = isIP(host); const addresses = direct ? [host] : [...await resolve4(host).catch(() => [] as string[]), ...await resolve6(host).catch(() => [] as string[])];
if (!addresses.length || addresses.some(privateIp)) throw new Error("RSS feed host is not public"); return url;
}
async function responseText(response: Response) {
const headerSize = Number(response.headers.get("content-length") || 0); if (headerSize > MAX_FEED_BYTES) throw new Error("RSS feed is too large"); const reader = response.body?.getReader(); if (!reader) return ""; const chunks: Uint8Array[] = []; let total = 0;
while (true) { const { done, value } = await reader.read(); if (done) break; total += value.byteLength; if (total > MAX_FEED_BYTES) { await reader.cancel(); throw new Error("RSS feed is too large"); } chunks.push(value); }
return Buffer.concat(chunks.map((item) => Buffer.from(item))).toString("utf8");
}
export function parseFeed(xml: string): RssItem[] {
if (/<!DOCTYPE|<!ENTITY/i.test(xml)) throw new Error("RSS feed contains unsupported XML declarations");
const rss = xml.match(/<item\b[\s\S]*?<\/item>/gi) || []; const atom = xml.match(/<entry\b[\s\S]*?<\/entry>/gi) || []; const entries = rss.length ? rss.map((body) => ({ body, atom: false })) : atom.map((body) => ({ body, atom: true }));
return entries.map(({ body, atom }) => { const link = atom ? atomLink(body) : field(body, "link"); const id = field(body, atom ? "id" : "guid") || link; const content = field(body, atom ? "content" : "description") || field(body, atom ? "summary" : "title") || field(body, "title"); return { id, link, content, publishedAt: date(field(body, atom ? "published" : "pubDate") || field(body, atom ? "updated" : "")) }; }).filter((item) => item.id && item.content) as RssItem[];
}
export async function fetchRss(raw: string) {
let url = await assertPublicHttps(raw);
for (let redirects = 0; redirects <= 3; redirects++) { const response = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(15_000), headers: { Accept: "application/rss+xml, application/atom+xml, application/xml, text/xml" } }); if ([301, 302, 303, 307, 308].includes(response.status)) { const location = response.headers.get("location"); if (!location || redirects === 3) throw new Error("RSS feed redirect is invalid"); url = await assertPublicHttps(new URL(location, url).toString()); continue; } if (!response.ok) throw new Error(`RSS ${response.status}`); return parseFeed(await responseText(response)); }
throw new Error("RSS feed redirect is invalid");
}
+15
View File
@@ -0,0 +1,15 @@
function requestOrigin(request: Request) {
const proto = request.headers.get("x-forwarded-proto") || new URL(request.url).protocol.replace(":", "");
const host = request.headers.get("x-forwarded-host") || request.headers.get("host") || new URL(request.url).host;
return `${proto}://${host}`;
}
/** Browser form POSTs and fetch requests must originate from this Hub. */
export function requireSameOrigin(request: Request) {
const origin = request.headers.get("origin");
if (!origin || origin !== requestOrigin(request)) throw new Error("Invalid request origin");
}
export function clientIp(request: Request) {
return request.headers.get("x-forwarded-for")?.split(",")[0].trim() || request.headers.get("x-real-ip") || "unknown";
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { db } from "@/lib/db";
export type SyncTrigger = "manual" | "webhook" | "scheduled" | "source-created";
export type SyncTrigger = "manual" | "webhook" | "scheduled" | "source-created" | "batch";
/** Queue one pull per source at a time. Returns true only when a new job was created. */
export function queuePull(sourceId: number, trigger: SyncTrigger, payload: unknown = {}) {
+19
View File
@@ -0,0 +1,19 @@
import { db } from "@/lib/db";
export function canonicalTag(tag: string) {
const clean = tag.trim().replace(/^#/, "");
const alias = db.prepare("SELECT canonical FROM tag_aliases WHERE alias=? COLLATE NOCASE").get(clean) as { canonical: string } | undefined;
return alias?.canonical || clean;
}
export function canonicalTags(tags: string[]) { return [...new Set(tags.map(canonicalTag).filter(Boolean))]; }
/** Removes only known tags from normal Markdown lines; fenced code is always untouched. */
export function withoutInlineTags(content: string, tags: string[]) {
let fenced = false;
return content.split("\n").map((line) => {
if (/^\s*```/.test(line)) { fenced = !fenced; return line; }
if (fenced) return line;
return tags.reduce((text, tag) => text.replace(new RegExp(`(^|\\s)#${tag.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}(?=\\s|$|[,。!?、,.!?])`, "gu"), "$1").replace(/ {2,}/g, " "), line);
}).join("\n");
}
+22
View File
@@ -0,0 +1,22 @@
import { extname } from "node:path";
import { logEvent } from "@/lib/observability";
const defaults = new Set(["image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf", "text/plain", "text/markdown"]);
export async function validateUpload(file: File) {
const allowed = new Set((process.env.UPLOAD_ALLOWED_TYPES || "").split(",").map((item) => item.trim()).filter(Boolean));
const types = allowed.size ? allowed : defaults;
const max = Number(process.env.UPLOAD_MAX_BYTES || 10 * 1024 * 1024);
if (!types.has(file.type)) throw new Error(`不允許的附件類型:${file.type || extname(file.name) || "未知"}`);
if (file.size > max) throw new Error(`${file.name} exceeds upload limit`);
const scanner = process.env.VIRUS_SCAN_URL;
if (!scanner) return;
try {
const response = await fetch(scanner, { method: "POST", headers: { "content-type": file.type || "application/octet-stream", "x-filename": encodeURIComponent(file.name) }, body: await file.arrayBuffer(), signal: AbortSignal.timeout(15_000) });
const result = await response.json().catch(() => ({})) as { clean?: boolean };
if (!response.ok || result.clean !== true) throw new Error("附件未通過掃描");
} catch (error) {
logEvent("warn", "upload_scan_unavailable", { name: file.name });
if (process.env.VIRUS_SCAN_REQUIRED === "1") throw error;
}
}
+6 -1
View File
@@ -1,4 +1,4 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
export function createWebhookSecret() { return randomBytes(32).toString("base64url"); }
export function webhookSecretHash(secret: string) { return createHash("sha256").update(secret).digest("hex"); }
@@ -8,3 +8,8 @@ export function webhookSecretMatches(secret: string, expectedHash: string | null
const expected = Buffer.from(expectedHash, "hex");
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
export function standardWebhookMatches(secret: string, id: string | null, timestamp: string | null, signature: string | null, body: string) {
const seconds = Number(timestamp); if (!id || !timestamp || !signature || !Number.isFinite(seconds) || !Number.isInteger(seconds) || Math.abs(Date.now() / 1000 - seconds) > 300) return false;
const expected = createHmac("sha256", secret).update(`${id}.${timestamp}.${body}`).digest("base64");
return signature.split(" ").some((item) => { const [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); });
}
+1586 -4
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -1,6 +1,6 @@
{
"name": "mebbling",
"version": "0.2.0",
"version": "0.8.0",
"description": "",
"private": true,
"scripts": {
@@ -8,7 +8,8 @@
"build": "HUB_BUILD=1 next build",
"start": "next start",
"worker": "tsx worker/index.ts",
"test": "TMPDIR=/tmp tsx --test tests/**/*.test.ts"
"test": "TMPDIR=/tmp tsx --test tests/**/*.test.ts",
"sbom": "node scripts/generate-sbom.mjs"
},
"keywords": [],
"author": "",
@@ -16,10 +17,14 @@
"dependencies": {
"bcryptjs": "^3.0.3",
"better-sqlite3": "^12.11.1",
"highlight.js": "^11.11.1",
"jose": "^6.2.3",
"next": "^15.5.20",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"tsx": "^4.23.1",
"zod": "^4.4.3"
},
+35 -1
View File
@@ -2,6 +2,8 @@
set -euo pipefail
# Creates a consistent SQLite backup through the running web container, then archives Hub uploads.
# Optional: BACKUP_RETENTION_DAYS=30 BACKUP_OFFSITE_DIR=/mnt/backup/mebbling ./scripts/backup.sh
# For encrypted remote copies: BACKUP_AGE_RECIPIENT=age1... BACKUP_RCLONE_REMOTE='remote:bucket/mebbling' ./scripts/backup.sh
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$root_dir"
stamp="$(date +%Y%m%d-%H%M%S)"
@@ -15,4 +17,36 @@ docker compose exec -T -e BACKUP_PATH="/app/data/backups/$stamp/hub.db" web node
'
tar -czf "$backup_dir/uploads.tar.gz" -C public uploads
printf 'Created backup: %s\n' "$backup_dir"
docker compose exec -T -e BACKUP_PATH="/app/data/backups/$stamp/hub.db" 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); }
'
tar -tzf "$backup_dir/uploads.tar.gz" >/dev/null
(cd "$backup_dir" && sha256sum hub.db uploads.tar.gz > SHA256SUMS)
"$root_dir/scripts/verify-backup.sh" "$backup_dir"
archive="$backup_dir/backup.tar.gz"
tar -czf "$archive" -C "$backup_dir" hub.db uploads.tar.gz SHA256SUMS
offsite_payload="$backup_dir"
if [[ -n "${BACKUP_AGE_RECIPIENT:-}" ]]; then
command -v age >/dev/null || { echo "age is required when BACKUP_AGE_RECIPIENT is set" >&2; exit 2; }
age -r "$BACKUP_AGE_RECIPIENT" -o "$archive.age" "$archive"
offsite_payload="$archive.age"
fi
if [[ -n "${BACKUP_OFFSITE_DIR:-}" ]]; then
destination="$BACKUP_OFFSITE_DIR/$stamp"; mkdir -p "$destination"
if [[ "$offsite_payload" == "$backup_dir" ]]; then cp -a "$backup_dir/." "$destination/"; else cp -a "$offsite_payload" "$destination/"; fi
printf 'Copied verified backup to: %s\n' "$destination"
fi
if [[ -n "${BACKUP_RCLONE_REMOTE:-}" ]]; then
command -v rclone >/dev/null || { echo "rclone is required when BACKUP_RCLONE_REMOTE is set" >&2; exit 2; }
if [[ "$offsite_payload" == "$backup_dir" ]]; then rclone copy "$backup_dir" "$BACKUP_RCLONE_REMOTE/$stamp"; else rclone copy "$offsite_payload" "$BACKUP_RCLONE_REMOTE/$stamp"; fi
printf 'Copied verified backup using rclone to: %s/%s\n' "$BACKUP_RCLONE_REMOTE" "$stamp"
fi
if [[ -n "${BACKUP_RETENTION_DAYS:-}" ]]; then
[[ "$BACKUP_RETENTION_DAYS" =~ ^[0-9]+$ ]] || { echo "BACKUP_RETENTION_DAYS must be a non-negative integer" >&2; exit 2; }
find data/backups -mindepth 1 -maxdepth 1 -type d -mtime "+$BACKUP_RETENTION_DAYS" -exec rm -rf {} +
fi
printf 'Created and verified backup: %s\n' "$backup_dir"
+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}`);
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
# Installs a daily 03:15 host cron job. Run from WSL after setting an offsite mount if desired.
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
line="15 3 * * * cd $root_dir && BACKUP_RETENTION_DAYS=30 ./scripts/backup.sh >> data/backup-cron.log 2>&1"
(crontab -l 2>/dev/null | grep -Fv "scripts/backup.sh"; echo "$line") | crontab -
printf 'Installed daily backup cron job. Inspect with: crontab -l\n'
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 ]]; then echo "Usage: CONFIRM_RESTORE=YES ./scripts/restore.sh data/backups/YYYYMMDD-HHMMSS" >&2; exit 2; fi
if [[ "${CONFIRM_RESTORE:-}" != "YES" ]]; then echo "Refusing restore. Set CONFIRM_RESTORE=YES after verifying the backup path." >&2; exit 2; fi
backup_dir="$1"; [[ -f "$backup_dir/hub.db" && -f "$backup_dir/uploads.tar.gz" && -f "$backup_dir/SHA256SUMS" ]] || { echo "Backup is incomplete" >&2; exit 2; }
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; cd "$root_dir"
"$root_dir/scripts/verify-backup.sh" "$backup_dir"
docker compose down
mkdir -p data public
mv data/hub.db "data/hub.db.before-restore.$(date +%Y%m%d-%H%M%S)" 2>/dev/null || true
rm -rf public/uploads
cp "$backup_dir/hub.db" data/hub.db
tar -xzf "$backup_dir/uploads.tar.gz" -C public
docker compose up -d
printf 'Restore started from: %s\n' "$backup_dir"
+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/);
});
+7 -1
View File
@@ -14,12 +14,18 @@ after(() => { database?.close(); rmSync(databasePath, { force: true }); rmSync(`
test("applies tracked migrations and deduplicates active pull jobs", async () => {
const { db } = await import("../lib/db"); database = db;
const { queuePull } = await import("../lib/sync");
const { notify } = await import("../lib/notifications");
const migrations = db.prepare("SELECT version FROM schema_migrations ORDER BY version").all() as { version: number }[];
assert.deepEqual(migrations.map((item) => item.version), [1, 2, 3, 4, 5, 6, 7]);
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 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, "webhook", { event: "memo.updated" }), false);
const jobs = db.prepare("SELECT kind,trigger,status FROM sync_jobs WHERE source_id=?").all(sourceId) as { kind: string; trigger: string; status: string }[];
assert.deepEqual(jobs, [{ kind: "pull", trigger: "manual", status: "queued" }]);
const actorId = Number(db.prepare("INSERT INTO users(username,password_hash) VALUES('actor-test','hash')").run().lastInsertRowid);
const postId = Number(db.prepare("INSERT INTO posts(author_id,content) VALUES(?,?)").run(userId, "Notification test").lastInsertRowid);
assert.equal(Number((db.prepare("SELECT count(*) AS count FROM posts_fts WHERE posts_fts MATCH 'Notification'").get() as { count: number }).count), 1);
notify(userId, actorId, postId, "comment", "commented"); notify(userId, userId, postId, "reaction", "ignored");
assert.deepEqual(db.prepare("SELECT type,message FROM notifications WHERE user_id=?").all(userId), [{ type: "comment", message: "commented" }]);
});
+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);
});
+82 -18
View File
@@ -1,24 +1,74 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { readFile, mkdir, readdir, unlink, writeFile } from "node:fs/promises";
import { extname, join } from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { db } from "../lib/db";
import { decrypt } from "../lib/crypto";
import { createMemo, createRemoteFile, listMemos, setMemoAttachments } from "../lib/memos";
import { createMemo, createRemoteFile, getMemosIdentity, listMemos, memoUrl, setMemoAttachments } from "../lib/memos";
import { recordError } from "../lib/observability";
import { fetchRss } from "../lib/rss";
import { deliverNextAlert, sendAlert } from "../lib/alerts";
type Source = { id: number; user_id: number; base_url: string; token_encrypted: string; is_enabled: number };
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 };
function upsertRemote(source: Source, memo: any) {
const tags = JSON.stringify(memo.tags || []), attachments = JSON.stringify(memo.attachments || memo.resources || []);
db.prepare(`INSERT INTO posts(source_id,author_id,remote_memo_name,content,visibility,tags_json,attachments_json,origin,remote_created_at,remote_updated_at,sync_status,hidden) VALUES(?,?,?,?,?,?,?,?,?,?, 'synced',0) ON CONFLICT(source_id,remote_memo_name) DO UPDATE SET content=excluded.content,visibility=excluded.visibility,tags_json=excluded.tags_json,attachments_json=excluded.attachments_json,remote_updated_at=excluded.remote_updated_at,hidden=0,updated_at=CURRENT_TIMESTAMP`).run(source.id, source.user_id, memo.name, memo.content, memo.visibility, tags, attachments, "memos", memo.createTime || null, memo.updateTime || null);
function remoteAttachmentUrl(attachment: any, baseUrl: string) {
if (attachment.url || attachment.externalLink) return attachment.url || attachment.externalLink;
if (!attachment.name || !attachment.filename) return null;
return `${baseUrl.replace(/\/$/, "")}/file/${attachment.name.split("/").map(encodeURIComponent).join("/")}/${encodeURIComponent(attachment.filename)}`;
}
async function cacheAttachments(source: Source, attachments: any[]) {
if (source.attachment_storage_mode === "remote") return { items: attachments, errors: [] as string[] };
const directory = join(process.cwd(), "public", "uploads", "cache", `source-${source.id}`); await mkdir(directory, { recursive: true });
let used = 0;
for (const row of db.prepare("SELECT attachments_json FROM posts WHERE source_id=?").all(source.id) as { attachments_json: string }[]) { try { used += (JSON.parse(row.attachments_json) as any[]).filter((item) => String(item.url || "").startsWith(`/uploads/cache/source-${source.id}/`)).reduce((sum, item) => sum + Number(item.size || 0), 0); } catch {} }
const result: any[] = [], errors: string[] = [];
for (const attachment of attachments) {
const url = remoteAttachmentUrl(attachment, source.base_url); const type = attachment.type || "";
if (!url || (source.attachment_storage_mode === "images" && !type.startsWith("image/"))) { result.push(attachment); continue; }
try {
const target = new URL(url); if (target.origin !== new URL(source.base_url).origin) throw new Error("Attachment host is not the source host");
const response = await fetch(url, { signal: AbortSignal.timeout(15_000) }); if (!response.ok) throw new Error(`Attachment download ${response.status}`);
const body = Buffer.from(await response.arrayBuffer()); if (used + body.length > source.attachment_cache_limit_bytes) throw new Error("Attachment cache quota exceeded");
const filename = attachment.filename || attachment.name || "attachment"; const key = createHash("sha256").update(url).digest("hex").slice(0, 24) + extname(filename);
await writeFile(join(directory, key), body); used += body.length;
result.push({ ...attachment, originalUrl: url, url: `/uploads/cache/source-${source.id}/${key}`, type: type || response.headers.get("content-type") || "application/octet-stream", size: body.length });
} catch (error) { const message = error instanceof Error ? error.message : "快取附件失敗"; errors.push(message); recordError("attachment-cache", error, { sourceId: source.id, url }); result.push(attachment); }
}
return { items: result, errors };
}
async function cleanupCache(source: Source) {
if (source.attachment_archive_after_days) {
const oldPosts = db.prepare("SELECT id,attachments_json FROM posts WHERE source_id=? AND COALESCE(remote_created_at,created_at)<datetime('now', ?) AND attachments_json LIKE ?").all(source.id, `-${source.attachment_archive_after_days} days`, `%/uploads/cache/source-${source.id}/%`) as { id: number; attachments_json: string }[];
for (const post of oldPosts) { try { const attachments = (JSON.parse(post.attachments_json) as any[]).map((item) => String(item.url || "").startsWith(`/uploads/cache/source-${source.id}/`) && item.originalUrl ? { ...item, url: item.originalUrl } : item); db.prepare("UPDATE posts SET attachments_json=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(JSON.stringify(attachments), post.id); } catch {} }
}
const directory = join(process.cwd(), "public", "uploads", "cache", `source-${source.id}`); let names: string[]; try { names = await readdir(directory); } catch { return; }
const used = new Set<string>(); for (const row of db.prepare("SELECT attachments_json FROM posts WHERE source_id=?").all(source.id) as { attachments_json: string }[]) { try { for (const attachment of JSON.parse(row.attachments_json) as any[]) { const url = String(attachment.url || ""); if (url.startsWith(`/uploads/cache/source-${source.id}/`)) used.add(url.split("/").at(-1)!); } } catch {} }
await Promise.all(names.filter((name) => !used.has(name)).map((name) => unlink(join(directory, name)).catch(() => undefined)));
}
async function upsertRemote(source: Source, memo: any, syncRun: string) {
const cached = await cacheAttachments(source, memo.attachments || memo.resources || []); const tags = JSON.stringify(memo.tags || []), attachments = JSON.stringify(cached.items);
db.prepare(`INSERT INTO posts(source_id,author_id,remote_memo_name,content,visibility,tags_json,attachments_json,origin,remote_created_at,remote_updated_at,sync_status,hidden,remote_url,last_seen_sync_run) VALUES(?,?,?,?,?,?,?,?,?,?, 'synced',0,?,?) ON CONFLICT(source_id,remote_memo_name) DO UPDATE SET content=excluded.content,visibility=excluded.visibility,tags_json=excluded.tags_json,attachments_json=excluded.attachments_json,remote_updated_at=excluded.remote_updated_at,remote_url=excluded.remote_url,last_seen_sync_run=excluded.last_seen_sync_run,hidden=0,updated_at=CURRENT_TIMESTAMP`).run(source.id, source.user_id, memo.name, memo.content, memo.visibility, tags, attachments, "memos", memo.createTime || null, memo.updateTime || null, memoUrl(source.base_url, memo.name), syncRun);
return cached.errors;
}
async function pull(source: Source) {
const memos = await listMemos(source.base_url, decrypt(source.token_encrypted));
for (const memo of memos) upsertRemote(source, memo);
const names = memos.map((memo) => memo.name);
if (names.length) { const placeholders = names.map(() => "?").join(","); db.prepare(`UPDATE posts SET hidden=1,updated_at=CURRENT_TIMESTAMP WHERE source_id=? AND remote_memo_name IS NOT NULL AND remote_memo_name NOT IN (${placeholders})`).run(source.id, ...names); }
else db.prepare("UPDATE posts SET hidden=1,updated_at=CURRENT_TIMESTAMP WHERE source_id=? AND remote_memo_name IS NOT NULL").run(source.id);
db.prepare("UPDATE sources SET sync_status='synced',last_synced_at=CURRENT_TIMESTAMP,last_error=NULL WHERE id=?").run(source.id);
if (source.integration_type === "rss") { const items = await fetchRss(source.rss_feed_url || ""); for (const item of items) db.prepare("INSERT INTO posts(source_id,author_id,remote_memo_name,content,visibility,tags_json,attachments_json,origin,remote_created_at,remote_updated_at,sync_status,hidden,remote_url) VALUES(?,?,?,?,?,'[]','[]','rss',?,?, 'synced',0,?) ON CONFLICT(source_id,remote_memo_name) DO UPDATE SET content=excluded.content,remote_created_at=excluded.remote_created_at,remote_url=excluded.remote_url,hidden=0,updated_at=CURRENT_TIMESTAMP").run(source.id,source.user_id,`rss:${item.id}`,item.content,"PUBLIC",item.publishedAt,item.publishedAt,item.link); const names=items.map((item)=>`rss:${item.id}`); if(names.length) db.prepare(`UPDATE posts SET hidden=1 WHERE source_id=? AND remote_memo_name NOT IN (${names.map(()=>"?").join(",")})`).run(source.id,...names); db.prepare("UPDATE sources SET sync_status='synced',last_synced_at=CURRENT_TIMESTAMP,last_error=NULL WHERE id=?").run(source.id); return false; }
const token = decrypt(source.token_encrypted); const identity = await getMemosIdentity(source.base_url, token); const creator = identity.name;
const rules = { creator, tags: JSON.parse(source.sync_tags_json || "[]") as string[], from: source.sync_from, to: source.sync_to, attachmentMode: source.sync_attachment_mode };
const remaining = source.sync_max_posts ? Math.max(0, source.sync_max_posts - source.sync_imported_count) : source.sync_batch_size;
if (remaining === 0) { db.prepare("UPDATE sources SET sync_cursor=NULL,sync_status='synced',last_synced_at=CURRENT_TIMESTAMP WHERE id=?").run(source.id); return false; }
const page = await listMemos(source.base_url, token, rules, { pageToken: source.sync_cursor || "", pageSize: Math.min(source.sync_batch_size, remaining) }); const memos = page.memos; const syncRun = source.sync_run_id || randomUUID();
const cacheErrors: string[] = []; for (const memo of memos) cacheErrors.push(...await upsertRemote(source, memo, syncRun));
const imported = source.sync_imported_count + memos.length; const capped = Boolean(source.sync_max_posts && imported >= source.sync_max_posts); const complete = !page.nextPageToken && !capped; const more = Boolean(page.nextPageToken) && !capped;
if (complete) db.prepare("UPDATE posts SET hidden=1,updated_at=CURRENT_TIMESTAMP WHERE source_id=? AND origin='memos' AND COALESCE(last_seen_sync_run,'')<>?").run(source.id, syncRun);
await cleanupCache(source);
const avatar = identity.avatarUrl || identity.avatar || null; const avatarUrl = avatar?.startsWith("/") ? `${source.base_url.replace(/\/$/, "")}${avatar}` : avatar;
const name = identity.displayName || identity.nickname || identity.username || identity.name;
db.prepare("UPDATE sources SET name=?,remote_user=?,sync_status=?,last_synced_at=CASE WHEN ? THEN NULL ELSE CURRENT_TIMESTAMP END,last_error=NULL,last_connection_at=CURRENT_TIMESTAMP,last_connection_error=NULL,attachment_cache_error=?,remote_display_name=?,remote_avatar_url=?,sync_cursor=?,sync_imported_count=?,sync_run_id=? WHERE id=?").run(name, creator, more ? 'importing' : 'synced', more ? 1 : 0, cacheErrors.length ? `${cacheErrors.length} 個附件快取失敗;可按「立即同步」重試。` : null, name, avatarUrl, more ? page.nextPageToken : null, more ? imported : 0, more ? syncRun : null, source.id);
return more;
}
async function push(source: Source, payload: any) {
@@ -33,7 +83,7 @@ async function push(source: Source, payload: any) {
}
const memo = await createMemo(source.base_url, token, { content: post.content, visibility: post.visibility, resources });
if (attachments.length) await setMemoAttachments(source.base_url, token, memo.name, attachments);
db.prepare("UPDATE posts SET remote_memo_name=?,remote_created_at=?,remote_updated_at=?,sync_status='synced',updated_at=CURRENT_TIMESTAMP WHERE id=?").run(memo.name, memo.createTime || null, memo.updateTime || null, post.id);
db.prepare("UPDATE posts SET remote_memo_name=?,remote_created_at=?,remote_updated_at=?,remote_url=?,sync_status='synced',updated_at=CURRENT_TIMESTAMP WHERE id=?").run(memo.name, memo.createTime || null, memo.updateTime || null, memoUrl(source.base_url, memo.name), post.id);
}
async function run() {
@@ -43,13 +93,15 @@ async function run() {
const source = db.prepare("SELECT * FROM sources WHERE id=?").get(job.source_id) as Source | undefined;
if (!source || !source.is_enabled) { db.prepare("UPDATE sync_jobs SET status='cancelled',last_error='Source is disabled or deleted',finished_at=CURRENT_TIMESTAMP WHERE id=?").run(job.id); return; }
try {
if (job.kind === "pull") await pull(source); else if (job.kind === "push") await push(source, JSON.parse(job.payload_json || "{}"));
const more = job.kind === "pull" ? await pull(source) : false; if (job.kind === "push") await push(source, JSON.parse(job.payload_json || "{}"));
db.prepare("UPDATE sync_jobs SET status='done',finished_at=CURRENT_TIMESTAMP WHERE id=?").run(job.id);
db.prepare("UPDATE sources SET sync_status='synced',last_error=NULL,last_synced_at=CURRENT_TIMESTAMP WHERE id=?").run(source.id);
if (more) db.prepare("INSERT INTO sync_jobs(source_id,kind,trigger) VALUES(?,'pull','batch')").run(source.id); else db.prepare("UPDATE sources SET sync_status='synced',last_error=NULL,last_synced_at=CURRENT_TIMESTAMP WHERE id=?").run(source.id);
} catch (error) {
const message = error instanceof Error ? error.message : "Sync failure"; const exhausted = job.attempts + 1 >= 5;
recordError("sync", error, { sourceId: source.id, jobId: job.id, kind: job.kind, attempts: job.attempts });
db.prepare("UPDATE sync_jobs SET status=?,last_error=?,finished_at=CASE WHEN ? THEN CURRENT_TIMESTAMP ELSE NULL END,run_after=CASE WHEN ? THEN run_after ELSE datetime('now','+5 minutes') END WHERE id=?").run(exhausted ? "failed" : "queued", message, exhausted ? 1 : 0, exhausted ? 1 : 0, job.id);
db.prepare("UPDATE sources SET sync_status='error',last_error=? WHERE id=?").run(message, source.id);
db.prepare("UPDATE sources SET sync_status='error',last_error=?,last_connection_error=? WHERE id=?").run(message, message, source.id);
if (exhausted) void sendAlert(`sync:${source.id}`, "Mebbling 同步失敗", `來源 #${source.id} 已重試 5 次仍失敗:${message}`);
}
}
@@ -58,4 +110,16 @@ function schedule() {
db.prepare(`INSERT INTO sync_jobs(source_id,kind,trigger) SELECT id,'pull','scheduled' FROM sources WHERE is_enabled=1 AND COALESCE(last_synced_at,'1970-01-01') < datetime('now', ?) AND NOT EXISTS (SELECT 1 FROM sync_jobs j WHERE j.source_id=sources.id AND j.kind='pull' AND j.status IN ('queued','running'))`).run(`-${interval} minutes`);
}
setInterval(() => { schedule(); void run(); }, 5000); schedule(); void run();
function retention() {
const days = (name: string) => Math.max(0, Number(process.env[name] || 0) || 0);
db.prepare("DELETE FROM source_invites WHERE expires_at<CURRENT_TIMESTAMP AND (used_at IS NOT NULL OR expires_at<datetime('now','-30 days'))").run();
const cleanup = (table: string, column: string, value: number) => { if (value) db.prepare(`DELETE FROM ${table} WHERE ${column}<datetime('now', ?)`).run(`-${value} days`); };
cleanup("notifications", "created_at", days("NOTIFICATION_RETENTION_DAYS")); cleanup("reading_history", "last_read_at", days("READING_HISTORY_RETENTION_DAYS")); cleanup("audit_events", "created_at", days("AUDIT_RETENTION_DAYS"));
}
function webhookHealth() {
const stale = db.prepare("SELECT id,name,last_webhook_at FROM sources WHERE is_enabled=1 AND webhook_mode='signed' AND (last_webhook_at IS NULL OR last_webhook_at<datetime('now','-7 days'))").all() as { id: number; name: string; last_webhook_at: string | null }[];
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(); void deliverNextAlert(); }, 5000); schedule(); retention(); webhookHealth(); void run(); void deliverNextAlert();