diff --git a/.env.example b/.env.example index 53b0dc5..d22af39 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml new file mode 100644 index 0000000..ce3509e --- /dev/null +++ b/.gitea/workflows/verify.yml @@ -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 }}"}' diff --git a/CHANGELOG.md b/CHANGELOG.md index 62c0e04..1c90d57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ 本專案遵循 [Semantic Versioning](https://semver.org/lang/zh-TW/);版本 `0.x` 表示功能仍可能調整。 +## [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 diff --git a/Dockerfile b/Dockerfile index c82e94e..ad7c30a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 32c9d68..4c6fa34 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 自架的 Memos 公開貼文 Hub。將朋友各自 Memos 中的公開貼文集中展示,同時保留 Hub 內的留言、表情回應與發文功能。 -目前開發版本:`v0.4.0`(尚未發布)。版本變更請見 [CHANGELOG.md](CHANGELOG.md)。 +目前開發版本:`v0.5.0`(尚未發布)。版本變更請見 [CHANGELOG.md](CHANGELOG.md)。 ## 功能 @@ -20,6 +20,8 @@ - 提供安全 Markdown、程式碼高亮、收藏、稍後閱讀、閱讀紀錄與互動通知。 - 每個來源可設定標籤、日期與附件類型同步規則,並在貼文頁保留可回到原始 Memos 貼文的連結。 - 控制台可測試 Token/Memos 連線、顯示遠端名稱與頭像,並提示 webhook 長時間未收到事件的狀態。 +- 同源請求保護、SQLite 共用登入/webhook 限流、附件白名單與可選掃毒服務。 +- 管理員可審核檢舉、隱藏貼文、停權帳號與協助重設密碼;提供健康檢查與 JSON 結構化日誌。 ## 快速啟動(WSL/Docker) @@ -53,6 +55,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 來源。 | @@ -79,6 +83,14 @@ Web 接收使用者操作和 webhook,將同步需求寫入 SQLite 的 `sync_jo 在來源管理中設定的同步規則會套用到 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. 來源建立者登入「控制台」。 diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 51b0557..6105cec 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -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 <>

管理員:同步狀態

失敗或異常工作

{failures.length ? :

沒有同步異常。

}

所有來源

; +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 <>

管理員

{query.updated &&

管理操作已完成。

}{query.error &&

管理操作未完成。

} +

待審核檢舉

{reports.length ? :

沒有待審核檢舉。

}
+

使用者

+

失敗或異常工作

{failures.length ? :

沒有同步異常。

}
+

最近系統錯誤

{errors.length ? :

尚無記錄。

}
+

所有來源

+ ; } diff --git a/app/api/admin/moderate/route.ts b/app/api/admin/moderate/route.ts new file mode 100644 index 0000000..bd2837f --- /dev/null +++ b/app/api/admin/moderate/route.ts @@ -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")); } +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 1a69e56..cd634ae 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -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")); } } diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts index 9918143..81b228c 100644 --- a/app/api/auth/logout/route.ts +++ b/app/api/auth/logout/route.ts @@ -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,"/"));} diff --git a/app/api/auth/password/route.ts b/app/api/auth/password/route.ts index a34f7f2..e1ba248 100644 --- a/app/api/auth/password/route.ts +++ b/app/api/auth/password/route.ts @@ -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("兩次新密碼不一致"); diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 8f24565..d4ffe36 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -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")); } } diff --git a/app/api/bookmarks/route.ts b/app/api/bookmarks/route.ts index ea6b08d..1702092 100644 --- a/app/api/bookmarks/route.ts +++ b/app/api/bookmarks/route.ts @@ -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; diff --git a/app/api/comments/route.ts b/app/api/comments/route.ts index ee2c45d..fd314cc 100644 --- a/app/api/comments/route.ts +++ b/app/api/comments/route.ts @@ -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"); diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..5487561 --- /dev/null +++ b/app/api/health/route.ts @@ -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 }); } +} diff --git a/app/api/notifications/read/route.ts b/app/api/notifications/read/route.ts index 5f8bac5..c9eff12 100644 --- a/app/api/notifications/read/route.ts +++ b/app/api/notifications/read/route.ts @@ -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")); diff --git a/app/api/posts/route.ts b/app/api/posts/route.ts index 57cbe56..cc2ccf5 100644 --- a/app/api/posts/route.ts +++ b/app/api/posts/route.ts @@ -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)));}} diff --git a/app/api/reactions/route.ts b/app/api/reactions/route.ts index 6e98d13..91e9d5f 100644 --- a/app/api/reactions/route.ts +++ b/app/api/reactions/route.ts @@ -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"); diff --git a/app/api/reports/route.ts b/app/api/reports/route.ts new file mode 100644 index 0000000..03f82f5 --- /dev/null +++ b/app/api/reports/route.ts @@ -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, "/")); } +} diff --git a/app/api/sources/[id]/manage/route.ts b/app/api/sources/[id]/manage/route.ts index aeab9bc..97faa23 100644 --- a/app/api/sources/[id]/manage/route.ts +++ b/app/api/sources/[id]/manage/route.ts @@ -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, { 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") || ""); + 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"); diff --git a/app/api/sources/[id]/webhook/route.ts b/app/api/sources/[id]/webhook/route.ts index 48a9cfc..ab60a95 100644 --- a/app/api/sources/[id]/webhook/route.ts +++ b/app/api/sources/[id]/webhook/route.ts @@ -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(); diff --git a/app/api/sources/route.ts b/app/api/sources/route.ts index 89e4fef..e47f4cb 100644 --- a/app/api/sources/route.ts +++ b/app/api/sources/route.ts @@ -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"); } diff --git a/app/api/sync/route.ts b/app/api/sync/route.ts index 5bbe61a..4308008 100644 --- a/app/api/sync/route.ts +++ b/app/api/sync/route.ts @@ -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"); diff --git a/app/api/sync/webhook/[sourceId]/[secret]/route.ts b/app/api/sync/webhook/[sourceId]/[secret]/route.ts index b816eef..acecd3e 100644 --- a/app/api/sync/webhook/[sourceId]/[secret]/route.ts +++ b/app/api/sync/webhook/[sourceId]/[secret]/route.ts @@ -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 }); } diff --git a/app/posts/[id]/page.tsx b/app/posts/[id]/page.tsx index fd5698d..7fa3b3a 100644 --- a/app/posts/[id]/page.tsx +++ b/app/posts/[id]/page.tsx @@ -14,9 +14,9 @@ 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,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 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; @@ -24,6 +24,6 @@ export default async function PostPage({ params }: { params: Promise<{ id: strin const reactions = db.prepare("SELECT emoji,count(*) count FROM reactions WHERE post_id=? GROUP BY emoji").all(id) as any[]; return

@{post.username} · {post.remote_display_name || post.name || "Hub"} · {new Date(post.created_at).toLocaleString("zh-TW")}{post.remote_url && <> · 在 Memos 開啟}

{reactions.map((reaction: any) => {reaction.emoji} {reaction.count})}{user && <>
}{user && ["👍", "❤️", "🎉", "🤔"].map((emoji) =>
)}
-

留言

{user ?