From e6ebdb05763c55b88dbc8dbbbba344f44c68184d Mon Sep 17 00:00:00 2001 From: tangsongdayo Date: Sun, 19 Jul 2026 00:29:30 +0800 Subject: [PATCH] Initial Mebbling hub implementation --- .dockerignore | 6 + .env.example | 12 + .gitignore | 8 + Dockerfile | 19 + README.md | 118 + app/api/auth/login/route.ts | 3 + app/api/auth/logout/route.ts | 1 + app/api/auth/register/route.ts | 5 + app/api/comments/route.ts | 2 + app/api/posts/route.ts | 2 + app/api/reactions/route.ts | 2 + app/api/sources/[id]/webhook/route.ts | 16 + app/api/sources/route.ts | 8 + app/api/sync/route.ts | 2 + .../sync/webhook/[sourceId]/[secret]/route.ts | 18 + app/components/attachments.tsx | 40 + app/dashboard/page.tsx | 3 + app/dashboard/publish-form.tsx | 34 + app/dashboard/webhook-control.tsx | 20 + app/layout.tsx | 8 + app/login/page.tsx | 1 + app/page.tsx | 11 + app/posts/[id]/page.tsx | 3 + app/register/page.tsx | 1 + app/styles.css | 1 + docker-compose.yml | 19 + global.d.ts | 1 + lib/auth.ts | 15 + lib/crypto.ts | 16 + lib/db.ts | 89 + lib/http.ts | 6 + lib/memos.ts | 38 + lib/rate-limit.ts | 8 + lib/webhook.ts | 10 + next-env.d.ts | 6 + next.config.mjs | 12 + package-lock.json | 1925 +++++++++++++++++ package.json | 33 + public/uploads/.gitkeep | 1 + tsconfig.json | 40 + worker/index.ts | 8 + 41 files changed, 2571 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/api/auth/login/route.ts create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/auth/register/route.ts create mode 100644 app/api/comments/route.ts create mode 100644 app/api/posts/route.ts create mode 100644 app/api/reactions/route.ts create mode 100644 app/api/sources/[id]/webhook/route.ts create mode 100644 app/api/sources/route.ts create mode 100644 app/api/sync/route.ts create mode 100644 app/api/sync/webhook/[sourceId]/[secret]/route.ts create mode 100644 app/components/attachments.tsx create mode 100644 app/dashboard/page.tsx create mode 100644 app/dashboard/publish-form.tsx create mode 100644 app/dashboard/webhook-control.tsx create mode 100644 app/layout.tsx create mode 100644 app/login/page.tsx create mode 100644 app/page.tsx create mode 100644 app/posts/[id]/page.tsx create mode 100644 app/register/page.tsx create mode 100644 app/styles.css create mode 100644 docker-compose.yml create mode 100644 global.d.ts create mode 100644 lib/auth.ts create mode 100644 lib/crypto.ts create mode 100644 lib/db.ts create mode 100644 lib/http.ts create mode 100644 lib/memos.ts create mode 100644 lib/rate-limit.ts create mode 100644 lib/webhook.ts create mode 100644 next-env.d.ts create mode 100644 next.config.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/uploads/.gitkeep create mode 100644 tsconfig.json create mode 100644 worker/index.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b4732f8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.next +.git +.env +data +public/uploads diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..53b0dc5 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +DATABASE_PATH=/app/data/hub.db +SESSION_SECRET=replace-with-a-long-random-secret +TOKEN_ENCRYPTION_KEY=replace-with-64-hex-characters +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me-before-first-start +NEXT_PUBLIC_APP_URL=http://localhost:3000 +UPLOAD_MAX_BYTES=10485760 +SYNC_INTERVAL_MINUTES=60 +# Optional: create the first Memos source for the bootstrap admin. +SEED_MEMOS_NAME= +SEED_MEMOS_URL= +SEED_MEMOS_TOKEN= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f1d2a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.next/ +*.tsbuildinfo +.env +data/*.db +data/*.db-* +public/uploads/* +!public/uploads/.gitkeep diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c82e94e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM node:22-bookworm-slim AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:22-bookworm-slim +WORKDIR /app +ENV NODE_ENV=production +COPY --from=build /app/package*.json ./ +RUN npm ci --omit=dev +COPY --from=build /app/.next ./.next +COPY --from=build /app/public ./public +COPY --from=build /app/lib ./lib +COPY --from=build /app/worker ./worker +COPY --from=build /app/tsconfig.json ./tsconfig.json +EXPOSE 3000 +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d38cdef --- /dev/null +++ b/README.md @@ -0,0 +1,118 @@ +# Mebbling + +自架的 Memos 公開貼文 Hub。將朋友各自 Memos 中的公開貼文集中展示,同時保留 Hub 內的留言、表情回應與發文功能。 + +## 功能 + +- 匯入多個 Memos 來源的 `PUBLIC` 貼文、標籤與附件。 +- 圖片在首頁與文章頁直接顯示;點擊後以站內全螢幕燈箱檢視。 +- Hub 使用者可留言、表情回應,並可將新貼文推送到已連接的 Memos。 +- 同一個「Memos 網址 + Memos 帳號」只建立一個共享來源,避免重複同步及重複貼文。 +- 使用者可加入共享來源並手動同步或發文;僅來源建立者能管理 webhook URL。 +- Webhook URL 採不可猜測的隨機密鑰路徑、雜湊保存與簡易速率限制。 +- 控制台會顯示最近一次收到 webhook 的時間及最後同步時間。 + +## 快速啟動(WSL/Docker) + +```bash +cp .env.example .env +# 編輯 .env,至少設定 SESSION_SECRET、TOKEN_ENCRYPTION_KEY、ADMIN_USERNAME、ADMIN_PASSWORD +docker compose up --build -d +``` + +預設網址為 [http://localhost:8088](http://localhost:8088)。停止服務: + +```bash +docker compose down +``` + +查看服務狀態與日誌: + +```bash +docker compose ps +docker compose logs -f web worker +``` + +## 環境變數 + +以 `.env.example` 為範本。正式環境請更換所有 secret,且不要將 `.env` 加入 Git。 + +| 變數 | 用途 | +| --- | --- | +| `SESSION_SECRET` | 登入 session 的簽章密鑰。請使用長隨機字串。 | +| `TOKEN_ENCRYPTION_KEY` | Memos Token 的 AES-256-GCM 加密金鑰,必須為 64 個十六進位字元。 | +| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 首次啟動時建立的管理員帳號。 | +| `NEXT_PUBLIC_APP_URL` | Hub 的對外 HTTPS 網址,例如 `https://mebbling.example.com`。Webhook URL 以此組成。 | +| `UPLOAD_MAX_BYTES` | Hub 發文上傳附件的單檔上限,預設 10 MiB。 | +| `SYNC_INTERVAL_MINUTES` | 背景校正同步的間隔,預設 60 分鐘。 | +| `SEED_MEMOS_*` | 選填;首次啟動時自動建立管理員的第一個 Memos 來源。 | + +## 系統架構 + +```mermaid +flowchart LR + Visitor[訪客/Hub 使用者] --> Web[Next.js Web\nport 8088] + Web --> DB[(SQLite\ndata/hub.db)] + Web --> Uploads[附件\npublic/uploads] + Memos[Memos 來源] -->|Webhook| Web + Web -->|建立同步工作| Jobs[同步佇列\nsync_jobs] + Worker[背景 Worker] --> Jobs + Worker --> DB + Worker <-->|Memos API| Memos + Worker --> Uploads +``` + +Web 接收使用者操作和 webhook,將同步需求寫入 SQLite 的 `sync_jobs`。背景 Worker 每 5 秒處理一項工作,負責: + +- **Pull**:從 Memos 取得公開貼文,更新 Hub 鏡像;已刪除或非公開的遠端貼文會在 Hub 隱藏。 +- **Push**:把 Hub 建立的貼文與本機附件上傳/回寫到選定的 Memos 來源。 +- **排程校正**:依 `SYNC_INTERVAL_MINUTES` 定期建立 Pull 工作,避免 webhook 遺漏造成資料不同步。 + +## Webhook 設定與驗證 + +1. 來源建立者登入「控制台」。 +2. 在來源卡片按「產生 webhook URL」,立即複製完整網址。 +3. 在該 Memos 帳號的 webhook 設定中貼上網址。 +4. 在 Memos 發布或更新一篇公開貼文。 +5. 回到 Hub:顯示「最近收到」代表 Hub 確實收到 webhook;「上次同步」更新則代表同步已完成。 + +網址格式如下;`來源 ID` 與 `隨機密鑰` 都由系統產生,請勿自行修改: + +```text +https://你的網域/api/sync/webhook/來源ID/隨機密鑰 +``` + +重新產生 webhook URL 會立即使舊 URL 失效。共享來源的其他成員可查看接收狀態,但沒有產生或輪替密鑰的權限。 + +## 專案結構 + +```text +. +├── app/ # Next.js App Router:頁面、元件與 API +│ ├── api/ # 登入、來源、發文、同步、webhook 等路由 +│ ├── components/ # 可重用 UI,例如附件圖片燈箱 +│ ├── dashboard/ # 來源管理、發文與 webhook 控制台 +│ ├── posts/[id]/ # 單篇貼文頁 +│ ├── page.tsx # 公開貼文首頁 +│ ├── layout.tsx # 全站版型與導覽 +│ └── styles.css # 全站樣式 +├── lib/ # 共用伺服器邏輯 +│ ├── auth.ts # Session 與權限 +│ ├── crypto.ts # Token 加解密 +│ ├── db.ts # SQLite schema 與輕量遷移 +│ ├── memos.ts # Memos API 封裝 +│ ├── webhook.ts # Webhook 密鑰產生、雜湊與驗證 +│ └── rate-limit.ts # Webhook 簡易速率限制 +├── worker/ # 背景同步 worker +├── public/uploads/ # Hub 上傳附件的持久化資料 +├── data/ # SQLite 資料庫持久化資料 +├── Dockerfile # Web/Worker 共用映像檔 +├── docker-compose.yml # web + worker 服務與 volume 掛載 +└── .env.example # 環境變數範本 +``` + +`data/` 與 `public/uploads/` 是正式資料,備份時請一併備份。`.next/` 與 `node_modules/` 是可重新產生的建置/依賴資料,不需備份。 + +## 正式部署 + +將 `NEXT_PUBLIC_APP_URL` 設成實際 HTTPS 網域,並以反向代理將該網域導向 Web 容器的 3000 連接埠(或主機的 8088 對應埠)。務必確保外部可連到 webhook URL,否則仍會由定期校正同步補回資料,但不會即時更新。 diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..1a69e56 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +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")); } diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..9918143 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +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,"/"));} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..8f24565 --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,5 @@ +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") || "").trim(); const password = String(form.get("password") || ""); + 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")); } +} diff --git a/app/api/comments/route.ts b/app/api/comments/route.ts new file mode 100644 index 0000000..702bf45 --- /dev/null +++ b/app/api/comments/route.ts @@ -0,0 +1,2 @@ +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,'/'));}} diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts new file mode 100644 index 0000000..a71f2fd --- /dev/null +++ b/app/api/posts/route.ts @@ -0,0 +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=?").get(sourceId,user.id);if(!source)throw new Error("Source not found");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) VALUES(?, 'push', ?)").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)));}} diff --git a/app/api/reactions/route.ts b/app/api/reactions/route.ts new file mode 100644 index 0000000..7bee143 --- /dev/null +++ b/app/api/reactions/route.ts @@ -0,0 +1,2 @@ +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,'/'));}} diff --git a/app/api/sources/[id]/webhook/route.ts b/app/api/sources/[id]/webhook/route.ts new file mode 100644 index 0000000..48a9cfc --- /dev/null +++ b/app/api/sources/[id]/webhook/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { requireUser } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { createWebhookSecret, webhookSecretHash } from "@/lib/webhook"; + +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); + if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 }); + 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(/\/$/, ""); + return NextResponse.json({ url: `${publicOrigin}/api/sync/webhook/${id}/${secret}` }); + } catch { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } +} diff --git a/app/api/sources/route.ts b/app/api/sources/route.ts new file mode 100644 index 0000000..af5f473 --- /dev/null +++ b/app/api/sources/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { decrypt, encrypt } from "@/lib/crypto"; import { getMemosIdentity, verifyMemos } from "@/lib/memos"; import { db } from "@/lib/db"; import { externalUrl } from "@/lib/http"; +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(); 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"); 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){try{const legacyIdentity=await getMemosIdentity(baseUrl,decrypt(legacy.token_encrypted));db.prepare("UPDATE sources SET remote_user=? WHERE id=? AND remote_user IS NULL").run(legacyIdentity.name,legacy.id);}catch{ /* Keep unavailable legacy sources unchanged. */ }} + 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 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);db.prepare("INSERT INTO sync_jobs(source_id,kind) VALUES(?, 'pull')").run(sourceId);return NextResponse.redirect(externalUrl(req,"/dashboard?source=connected")); + } catch(e){ return NextResponse.redirect(externalUrl(req,"/dashboard?error="+encodeURIComponent(e instanceof Error?e.message:"source"))); } } diff --git a/app/api/sync/route.ts b/app/api/sync/route.ts new file mode 100644 index 0000000..61fe2a7 --- /dev/null +++ b/app/api/sync/route.ts @@ -0,0 +1,2 @@ +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 sourceId=Number(f.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=?').get(sourceId,user.id);if(!source)throw 0;db.prepare("INSERT INTO sync_jobs(source_id,kind) VALUES(?, 'pull')").run(sourceId);return NextResponse.redirect(externalUrl(req,'/dashboard'));}catch{return NextResponse.redirect(externalUrl(req,'/'));}} diff --git a/app/api/sync/webhook/[sourceId]/[secret]/route.ts b/app/api/sync/webhook/[sourceId]/[secret]/route.ts new file mode 100644 index 0000000..17dfe89 --- /dev/null +++ b/app/api/sync/webhook/[sourceId]/[secret]/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { withinRateLimit } from "@/lib/rate-limit"; +import { webhookSecretMatches } from "@/lib/webhook"; + +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=?").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 }); + 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); + db.prepare("INSERT INTO sync_jobs(source_id,kind,payload_json) VALUES(?, 'pull', ?)").run(id, JSON.stringify(payload)); + return NextResponse.json({ ok: true }); +} diff --git a/app/components/attachments.tsx b/app/components/attachments.tsx new file mode 100644 index 0000000..fdc1bb2 --- /dev/null +++ b/app/components/attachments.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect, useState } from "react"; + +type Attachment = { name?: string; filename?: string; url?: string; externalLink?: string; type?: string; size?: string | number }; + +function resourceUrl(attachment: Attachment, sourceBaseUrl?: string) { + if (attachment.url) return attachment.url; + if (attachment.externalLink) return attachment.externalLink; + if (!sourceBaseUrl || !attachment.name || !attachment.filename) return null; + const resourceName = attachment.name.split("/").map(encodeURIComponent).join("/"); + return `${sourceBaseUrl.replace(/\/$/, "")}/file/${resourceName}/${encodeURIComponent(attachment.filename)}`; +} + +export function Attachments({ json, sourceBaseUrl, compact = false }: { json: string; sourceBaseUrl?: string | null; compact?: boolean }) { + const [activeImage, setActiveImage] = useState<{ href: string; label: string } | null>(null); + useEffect(() => { + const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") setActiveImage(null); }; + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, []); + + let attachments: Attachment[] = []; + try { attachments = JSON.parse(json); } catch { return null; } + const displayable = attachments.map((attachment) => ({ attachment, href: resourceUrl(attachment, sourceBaseUrl || undefined) })).filter((item): item is { attachment: Attachment; href: string } => Boolean(item.href)); + if (!displayable.length) return null; + return <> +
+ {displayable.map(({ attachment, href }) => { + const label = attachment.filename || attachment.name || "附件"; + if (attachment.type?.startsWith("image/")) return ; + return 📎 {label}; + })} +
+ {activeImage &&
setActiveImage(null)}> + + {activeImage.label} event.stopPropagation()} /> +
} + ; +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..2a556d0 --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,3 @@ +import { redirect } from "next/navigation"; import { getSession } from "@/lib/auth"; import { db } from "@/lib/db"; import { PublishForm } from "./publish-form"; import { WebhookControl } from "./webhook-control"; +export const dynamic="force-dynamic"; +export default async function Dashboard({searchParams}:{searchParams:Promise<{error?:string;source?:string}>}){const query=await searchParams;const user=await getSession();if(!user)redirect('/login');const sources=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 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 any[];return <>

控制台

{query.error&&

{query.error}

}{query.source==='shared'?

你已加入既有的共享 Memos 來源,不會重複同步貼文。

:query.source&&

來源已連接,首次同步已排入佇列。

}

發佈到自己的 Memos

{sources.length?:

請先連接一個 Memos 來源。

}

連接 Memos

Token 會使用伺服器金鑰加密保存。同一個 Memos 帳號與網址會自動共用來源,不會建立重複貼文。

已連接來源

{sources.map(s=>
{s.name}{s.sync_status}

來源 ID:{s.id}
{s.base_url}
上次同步:{s.last_synced_at||'尚未完成'}
Webhook:{s.webhook_secret_hash?(s.last_webhook_at?`最近收到:${new Date(s.last_webhook_at+'Z').toLocaleString('zh-TW')}`:'已建立 URL,尚未收到呼叫'):'尚未建立 URL'}{s.last_error&&<>
{s.last_error}}

{s.owner_id===user.id?:

這是共享來源;只有建立者可以管理 webhook。

}
)}
} diff --git a/app/dashboard/publish-form.tsx b/app/dashboard/publish-form.tsx new file mode 100644 index 0000000..f0b013f --- /dev/null +++ b/app/dashboard/publish-form.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { FormEvent, useState } from "react"; + +type Source = { id: number; name: string }; + +export function PublishForm({ sources }: { sources: Source[] }) { + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + + async function submit(event: FormEvent) { + 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
+