3 Commits
44 changed files with 441 additions and 96 deletions
+4
View File
@@ -5,6 +5,10 @@ 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: create the first Memos source for the bootstrap admin.
SEED_MEMOS_NAME=
+22
View File
@@ -0,0 +1,22 @@
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: npx tsc --noEmit
- run: npm test
- 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 }}"}'
+32
View File
@@ -2,6 +2,38 @@
本專案遵循 [Semantic Versioning](https://semver.org/lang/zh-TW/);版本 `0.x` 表示功能仍可能調整。
## [0.6.0] - Unreleased
### Added
- 文章顯示時隱藏已辨識的內文 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
+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
+21 -1
View File
@@ -2,7 +2,7 @@
自架的 Memos 公開貼文 Hub。將朋友各自 Memos 中的公開貼文集中展示,同時保留 Hub 內的留言、表情回應與發文功能。
目前開發版本:`v0.2.0`(尚未發布)。版本變更請見 [CHANGELOG.md](CHANGELOG.md)。
目前開發版本:`v0.6.0`(尚未發布)。版本變更請見 [CHANGELOG.md](CHANGELOG.md)。
## 功能
@@ -18,6 +18,12 @@
- 內建 SQLite 與附件備份腳本,以及可追蹤的 schema migration。
- 可依內容、標籤、來源、作者、日期與附件篩選公開貼文,並支援分頁、標籤/來源頁、RSS 與 Atom。
- 提供安全 Markdown、程式碼高亮、收藏、稍後閱讀、閱讀紀錄與互動通知。
- 每個來源可設定標籤、日期與附件類型同步規則,並在貼文頁保留可回到原始 Memos 貼文的連結。
- 控制台可測試 Token/Memos 連線、顯示遠端名稱與頭像,並提示 webhook 長時間未收到事件的狀態。
- 同源請求保護、SQLite 共用登入/webhook 限流、附件白名單與可選掃毒服務。
- 管理員可審核檢舉、隱藏貼文、停權帳號與協助重設密碼;提供健康檢查與 JSON 結構化日誌。
- 文章以底部標籤為主,避免內文 hashtag 重複;提供時間範圍標籤雲、草稿自動儲存與預覽。
- 遠端附件預設直連;來源可選擇只快取圖片或完整快取,並受每來源配額限制。
## 快速啟動(WSLDocker
@@ -51,6 +57,8 @@ 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 分鐘。 |
| `SEED_MEMOS_*` | 選填;首次啟動時自動建立管理員的第一個 Memos 來源。 |
@@ -75,6 +83,16 @@ Web 接收使用者操作和 webhook,將同步需求寫入 SQLite 的 `sync_jo
- **Push**:把 Hub 建立的貼文與本機附件上傳/回寫到選定的 Memos 來源。
- **排程校正**:依 `SYNC_INTERVAL_MINUTES` 定期建立 Pull 工作,避免 webhook 遺漏造成資料不同步。
在來源管理中設定的同步規則會套用到 Pull:多個標籤採「同時符合」篩選,日期以 Memos 貼文建立日為準;附件可選擇全部保留、只保留圖片,或不同步附件。貼文後續在遠端被修改、刪除、改為非公開或不再符合規則時,下一次 Pull 會更新或隱藏 Hub 鏡像。
## 正式營運與監控
- `GET /api/health`:供反向代理或監控工具檢查服務與 SQLite 狀態,也會回傳失敗同步工作數與版本。
- Web、Worker 的事件輸出為 JSON;同步錯誤同時保存於管理頁的「最近系統錯誤」。
- 登入在 15 分鐘內最多嘗試 8 次;webhook 與登入限流資料存於 SQLite,同一份資料庫的多個 Web 容器會共用計數。
- 所有會改變帳號或內容的瀏覽器 POST 都檢查 `Origin`Webhook 則使用密鑰 URL 驗證,不適用此規則。
- Gitea Actions 工作流程會在推送/標籤時執行型別檢查、測試與 Docker 建置;若設定 `DEPLOY_WEBHOOK_URL` secret,建立 `v*` tag 時會通知部署端。
## Webhook 設定與驗證
1. 來源建立者登入「控制台」。
@@ -83,6 +101,8 @@ Web 接收使用者操作和 webhook,將同步需求寫入 SQLite 的 `sync_jo
4. 在 Memos 發布或更新一篇公開貼文。
5. 回到 Hub:顯示「最近收到」代表 Hub 確實收到 webhook;「上次同步」更新則代表同步已完成。
若 webhook 已設定但超過 7 天未收到事件,控制台會顯示提醒;這不會中斷定期校正同步。來源建立者也可按「測試 Memos 連線」檢查 Token 是否有效,同時更新遠端顯示名稱與頭像。
網址格式如下;`來源 ID``隨機密鑰` 都由系統產生,請勿自行修改:
```text
+14 -5
View File
@@ -4,9 +4,18 @@ import { db } from "@/lib/db";
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 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[];
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><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>
</>;
}
+21
View File
@@ -0,0 +1,21 @@
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";
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");
return NextResponse.redirect(externalUrl(request, "/admin?updated=1"));
} catch { return NextResponse.redirect(externalUrl(request, "/admin?error=moderation")); }
}
+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")); }
}
+2 -1
View File
@@ -2,10 +2,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(); const postId = Number(form.get("postId")); const kind = String(form.get("kind"));
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;
+2 -1
View File
@@ -3,10 +3,11 @@ 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 {
const user = await requireUser(); const form = await req.formData(); const postId = Number(form.get("postId")); const content = String(form.get("content") || "").trim();
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");
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
export const dynamic = "force-dynamic";
export async function GET() {
try {
db.prepare("SELECT 1").get();
const failedJobs = Number((db.prepare("SELECT count(*) AS count FROM sync_jobs WHERE status='failed'").get() as { count: number }).count);
return NextResponse.json({ ok: true, version: process.env.APP_VERSION || "development", database: "ok", failedJobs, timestamp: new Date().toISOString() });
} catch { return NextResponse.json({ ok: false, database: "error" }, { status: 503 }); }
}
+2 -1
View File
@@ -2,10 +2,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(); const id = Number(form.get("id"));
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"));
+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 { 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 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));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)));}}
+2 -1
View File
@@ -3,11 +3,12 @@ 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 {
const user = await requireUser(); const form = await req.formData(); const postId = Number(form.get("postId")); const emoji = String(form.get("emoji"));
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");
+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, "/")); }
}
+23 -2
View File
@@ -1,13 +1,16 @@
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { decrypt } from "@/lib/crypto";
import { db } from "@/lib/db";
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;
@@ -18,6 +21,24 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
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");
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)) throw new Error("Invalid sync rules");
db.prepare("UPDATE sources SET sync_tags_json=?,sync_from=?,sync_to=?,sync_attachment_mode=? WHERE id=?").run(JSON.stringify(tags), from || null, to || null, attachmentMode, 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);
if (!["remote", "images", "all"].includes(mode) || !Number.isFinite(quotaMiB) || quotaMiB < 10 || quotaMiB > 10_240) throw new Error("Invalid attachment storage settings");
db.prepare("UPDATE sources SET attachment_storage_mode=?,attachment_cache_limit_bytes=?,attachment_cache_error=NULL WHERE id=?").run(mode, Math.round(quotaMiB * 1024 * 1024), 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;
db.prepare("UPDATE sources SET last_connection_at=CURRENT_TIMESTAMP,last_connection_error=NULL,remote_display_name=?,remote_avatar_url=? WHERE id=?").run(identity.nickname || identity.username || identity.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 === "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);
+2 -1
View File
@@ -2,10 +2,11 @@ import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
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);
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 secret = createWebhookSecret();
+2 -1
View File
@@ -5,10 +5,11 @@ import { db } from "@/lib/db";
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) {
try {
const user = await requireUser(); const form = await req.formData();
requireSameOrigin(req); 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();
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"); }
+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,6 +1,8 @@
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 { queuePull } from "@/lib/sync";
@@ -9,11 +11,11 @@ export async function POST(request: Request, { params }: { params: Promise<{ sou
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;
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 });
if (!withinRateLimit(`webhook:${id}:${clientIp(request)}`, 30, 60_000)) 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. */ }
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 });
}
+3 -2
View File
@@ -1,8 +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, compact = false }: { content: string; compact?: boolean }) {
return <div className={`markdown${compact ? " markdown-compact" : ""}`}><ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeHighlight]}>{content}</ReactMarkdown></div>;
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>;
}
+3 -2
View File
@@ -1,10 +1,11 @@
import Link from "next/link";
import { Attachments } from "./attachments";
import { Markdown } from "./markdown";
import { canonicalTags } from "@/lib/tags";
export type PublicPost = { id: number; source_id: number | null; 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 function PostCard({ post }: { post: PublicPost }) {
let tags: string[] = []; try { tags = JSON.parse(post.tags_json); } catch { /* Ignore malformed legacy tags. */ }
return <article className="card"><div className="space"><Link className="meta post-name-link" href={`/posts/${post.id}`}>@{post.username}{post.name ? ` · ${post.name}` : ""}</Link><span className="meta">{new Date(post.created_at).toLocaleString("zh-TW")}</span></div><Markdown content={post.content} 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>;
let tags: string[] = []; try { tags = canonicalTags(JSON.parse(post.tags_json)); } catch { /* Ignore malformed legacy tags. */ }
return <article className="card"><div className="space"><Link className="meta post-name-link" href={`/posts/${post.id}`}>@{post.username}{post.name ? ` · ${post.name}` : ""}</Link><span className="meta">{new Date(post.created_at).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>;
}
+6 -5
View File
@@ -4,15 +4,15 @@ import { db } from "@/lib/db";
import { PublishForm } from "./publish-form";
import { WebhookControl } from "./webhook-control";
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; 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; 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_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 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,s.sync_tags_json,s.sync_from,s.sync_to,s.sync_attachment_mode,s.attachment_storage_mode,s.attachment_cache_limit_bytes,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);
return <>
<h1></h1>
@@ -23,10 +23,11 @@ export default async function Dashboard({ searchParams }: { searchParams: Promis
<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><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.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 || "尚未完成"}<br />{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>
<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><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><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>
+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>;
}
+1 -1
View File
@@ -6,5 +6,5 @@ export const metadata = { title: "Mebbling", description: "聚合朋友公開筆
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const user = await getSession();
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>{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>;
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>;
}
+5 -4
View File
@@ -4,6 +4,7 @@ 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";
export const dynamic = "force-dynamic";
@@ -14,16 +15,16 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
return { title: `@${post.username} 的貼文|Mebbling`, description, openGraph: { title: `@${post.username}${post.name ? ` · ${post.name}` : ""}Mebbling`, description, type: "article" } };
}
export default async function PostPage({ params }: { params: Promise<{ id: string }> }) {
export default async function PostPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ reported?: string }> }) {
const { id: rawId } = await params; const id = Number(rawId);
const 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;
const post = db.prepare("SELECT p.*,u.username,s.name,s.base_url AS source_base_url,s.remote_display_name FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.id=?").get(id) as any; const query = await searchParams;
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[];
return <article><p className="meta">@{post.username} · {post.name || "Hub"} · {new Date(post.created_at).toLocaleString("zh-TW")}</p><section className="card"><Markdown content={post.content} /></section><Attachments json={post.attachments_json} sourceBaseUrl={post.source_base_url} />
let tags: string[] = []; try { tags = canonicalTags(JSON.parse(post.tags_json)); } catch {} return <article><p className="meta">@{post.username} · {post.remote_display_name || post.name || "Hub"} · {new Date(post.created_at).toLocaleString("zh-TW")}{post.remote_url && <> · <a href={post.remote_url} target="_blank" rel="noreferrer"> Memos </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> : <p></p>}{comments.map((comment) => <div className="card" key={comment.id}><strong>@{comment.username}</strong><p>{comment.content}</p><span className="meta">{new Date(comment.created_at).toLocaleString("zh-TW")}</span></div>)}</section>
<section><h2></h2>{user ? <><form action="/api/comments" method="post"><input type="hidden" name="postId" value={id} /><textarea name="content" required placeholder="在 Hub 留下留言" /><button></button></form><details><summary></summary>{query.reported && <p></p>}<form action="/api/reports" method="post"><input type="hidden" name="postId" value={id} /><label><input name="reason" required minLength={3} maxLength={500} /></label><button className="danger"></button></form></details></> : <p></p>}{comments.map((comment) => <div className="card" key={comment.id}><strong>@{comment.username}</strong><p>{comment.content}</p><span className="meta">{new Date(comment.created_at).toLocaleString("zh-TW")}</span></div>)}</section>
</article>;
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { PostCard, type PublicPost } from "@/app/components/post-card";
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 FROM sources WHERE id=?").get(id) as { id: number; name: string; base_url: string } | undefined; if (!source) notFound();
const { id: rawId } = await params; const id = Number(rawId); const source = db.prepare("SELECT id,name,base_url,remote_display_name,remote_avatar_url FROM sources WHERE id=?").get(id) as { id: number; name: string; base_url: string; remote_display_name: string | null; remote_avatar_url: string | null } | undefined; if (!source) notFound();
const posts = db.prepare("SELECT p.*,u.username,s.name,s.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[];
return <><p><Link href="/"> </Link></p><h1>{source.name}</h1><p className="meta">{source.base_url} · {posts.length} </p>{posts.map((post) => <PostCard key={post.id} post={post} />)}</>;
return <><p><Link href="/"> </Link></p><h1>{source.name}</h1><p className="meta">{source.remote_avatar_url && <img className="avatar" src={source.remote_avatar_url} alt="" />} {source.remote_display_name || "Memos"}<br />{source.base_url} · {posts.length} </p>{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.6.0}
build:
context: .
args:
APP_VERSION: "${MEBBLING_VERSION:-0.6.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.6.0}
build:
context: .
args:
APP_VERSION: "${MEBBLING_VERSION:-0.6.0}"
command: npm run worker
env_file: .env
environment: { DATABASE_PATH: /app/data/hub.db }
+10
View File
@@ -25,6 +25,16 @@
請始終一起還原資料庫與附件,否則貼文中的附件連結可能失效。
## 健康檢查與日誌
反向代理或監控服務可呼叫 `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。
+29
View File
@@ -19,6 +19,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 +28,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)
);
@@ -78,6 +81,17 @@ CREATE UNIQUE INDEX IF NOT EXISTS source_remote_identity_unique ON sources(base_
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 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");
@@ -96,6 +110,21 @@ applyColumnMigration(5, "sync_jobs", "trigger", "ALTER TABLE sync_jobs ADD COLUM
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();
const admin = process.env.ADMIN_USERNAME;
const adminPassword = process.env.ADMIN_PASSWORD;
+17 -4
View File
@@ -1,4 +1,6 @@
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; createTime?: string; updateTime?: string; tags?: string[]; attachments?: { type?: string }[]; resources?: { type?: string }[] };
export type MemosIdentity = { name: string; username?: string; nickname?: string; avatarUrl?: string; avatar?: string };
export type MemosSyncRules = { 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" });
@@ -6,14 +8,25 @@ async function request(url: string, token: string, init?: RequestInit) {
}
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 };
const 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) {
export function memoUrl(baseUrl: string, memoName: string) { const id = memoName.split("/").at(-1); return id ? `${baseUrl.replace(/\/$/, "")}/m/${encodeURIComponent(id)}` : null; }
export async function listMemos(baseUrl: string, token: string, rules: MemosSyncRules = {}) {
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");
const tags = rules.tags?.filter(Boolean) || []; const mode = rules.attachmentMode || "all";
return all.filter((memo) => {
if (memo.visibility !== "PUBLIC") 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) => {
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) };
});
}
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>;
+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;
}
+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";
}
+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;
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mebbling",
"version": "0.3.0",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mebbling",
"version": "0.3.0",
"version": "0.6.0",
"license": "PolyForm-Noncommercial-1.0.0",
"dependencies": {
"bcryptjs": "^3.0.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mebbling",
"version": "0.3.0",
"version": "0.6.0",
"description": "",
"private": true,
"scripts": {
+1 -1
View File
@@ -16,7 +16,7 @@ test("applies tracked migrations and deduplicates active pull jobs", async () =>
const { queuePull } = await import("../lib/sync");
const { notify } = await import("../lib/notifications");
const migrations = db.prepare("SELECT version FROM schema_migrations ORDER BY version").all() as { version: number }[];
assert.deepEqual(migrations.map((item) => item.version), [1, 2, 3, 4, 5, 6, 7, 8]);
assert.deepEqual(migrations.map((item) => item.version), Array.from({ length: 23 }, (_, 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);
+51 -12
View File
@@ -1,24 +1,62 @@
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 } 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";
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; 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 };
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 attachments;
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[] = [];
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) { recordError("attachment-cache", error, { sourceId: source.id, url }); result.push(attachment); }
}
return result;
}
async function cleanupCache(source: Source) {
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) {
const tags = JSON.stringify(memo.tags || []), attachments = JSON.stringify(await cacheAttachments(source, 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,remote_url) 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,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));
}
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 token = decrypt(source.token_encrypted); const rules = { tags: JSON.parse(source.sync_tags_json || "[]") as string[], from: source.sync_from, to: source.sync_to, attachmentMode: source.sync_attachment_mode };
const [memos, identity] = await Promise.all([listMemos(source.base_url, token, rules), getMemosIdentity(source.base_url, token)]);
for (const memo of memos) await 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);
await cleanupCache(source);
const avatar = identity.avatarUrl || identity.avatar || null; const avatarUrl = avatar?.startsWith("/") ? `${source.base_url.replace(/\/$/, "")}${avatar}` : avatar;
db.prepare("UPDATE sources SET sync_status='synced',last_synced_at=CURRENT_TIMESTAMP,last_error=NULL,last_connection_at=CURRENT_TIMESTAMP,last_connection_error=NULL,remote_display_name=?,remote_avatar_url=? WHERE id=?").run(identity.nickname || identity.username || identity.name, avatarUrl, source.id);
}
async function push(source: Source, payload: any) {
@@ -33,7 +71,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() {
@@ -48,8 +86,9 @@ async function run() {
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);
}
}