Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d285f3e275 |
@@ -5,6 +5,10 @@ ADMIN_USERNAME=admin
|
|||||||
ADMIN_PASSWORD=change-me-before-first-start
|
ADMIN_PASSWORD=change-me-before-first-start
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
UPLOAD_MAX_BYTES=10485760
|
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
|
SYNC_INTERVAL_MINUTES=60
|
||||||
# Optional: create the first Memos source for the bootstrap admin.
|
# Optional: create the first Memos source for the bootstrap admin.
|
||||||
SEED_MEMOS_NAME=
|
SEED_MEMOS_NAME=
|
||||||
|
|||||||
@@ -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 }}"}'
|
||||||
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
本專案遵循 [Semantic Versioning](https://semver.org/lang/zh-TW/);版本 `0.x` 表示功能仍可能調整。
|
本專案遵循 [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
|
## [0.4.0] - Unreleased
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
FROM node:22-bookworm-slim AS build
|
FROM node:22-bookworm-slim AS build
|
||||||
|
ARG APP_VERSION=development
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
@@ -6,8 +7,10 @@ COPY . .
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM node:22-bookworm-slim
|
FROM node:22-bookworm-slim
|
||||||
|
ARG APP_VERSION=development
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
ENV APP_VERSION=$APP_VERSION
|
||||||
COPY --from=build /app/package*.json ./
|
COPY --from=build /app/package*.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
COPY --from=build /app/.next ./.next
|
COPY --from=build /app/.next ./.next
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
自架的 Memos 公開貼文 Hub。將朋友各自 Memos 中的公開貼文集中展示,同時保留 Hub 內的留言、表情回應與發文功能。
|
自架的 Memos 公開貼文 Hub。將朋友各自 Memos 中的公開貼文集中展示,同時保留 Hub 內的留言、表情回應與發文功能。
|
||||||
|
|
||||||
目前開發版本:`v0.4.0`(尚未發布)。版本變更請見 [CHANGELOG.md](CHANGELOG.md)。
|
目前開發版本:`v0.5.0`(尚未發布)。版本變更請見 [CHANGELOG.md](CHANGELOG.md)。
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
- 提供安全 Markdown、程式碼高亮、收藏、稍後閱讀、閱讀紀錄與互動通知。
|
- 提供安全 Markdown、程式碼高亮、收藏、稍後閱讀、閱讀紀錄與互動通知。
|
||||||
- 每個來源可設定標籤、日期與附件類型同步規則,並在貼文頁保留可回到原始 Memos 貼文的連結。
|
- 每個來源可設定標籤、日期與附件類型同步規則,並在貼文頁保留可回到原始 Memos 貼文的連結。
|
||||||
- 控制台可測試 Token/Memos 連線、顯示遠端名稱與頭像,並提示 webhook 長時間未收到事件的狀態。
|
- 控制台可測試 Token/Memos 連線、顯示遠端名稱與頭像,並提示 webhook 長時間未收到事件的狀態。
|
||||||
|
- 同源請求保護、SQLite 共用登入/webhook 限流、附件白名單與可選掃毒服務。
|
||||||
|
- 管理員可審核檢舉、隱藏貼文、停權帳號與協助重設密碼;提供健康檢查與 JSON 結構化日誌。
|
||||||
|
|
||||||
## 快速啟動(WSL/Docker)
|
## 快速啟動(WSL/Docker)
|
||||||
|
|
||||||
@@ -53,6 +55,8 @@ docker compose logs -f web worker
|
|||||||
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 首次啟動時建立的管理員帳號。 |
|
| `ADMIN_USERNAME` / `ADMIN_PASSWORD` | 首次啟動時建立的管理員帳號。 |
|
||||||
| `NEXT_PUBLIC_APP_URL` | Hub 的對外 HTTPS 網址,例如 `https://mebbling.example.com`。Webhook URL 以此組成。 |
|
| `NEXT_PUBLIC_APP_URL` | Hub 的對外 HTTPS 網址,例如 `https://mebbling.example.com`。Webhook URL 以此組成。 |
|
||||||
| `UPLOAD_MAX_BYTES` | Hub 發文上傳附件的單檔上限,預設 10 MiB。 |
|
| `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 分鐘。 |
|
| `SYNC_INTERVAL_MINUTES` | 背景校正同步的間隔,預設 60 分鐘。 |
|
||||||
| `SEED_MEMOS_*` | 選填;首次啟動時自動建立管理員的第一個 Memos 來源。 |
|
| `SEED_MEMOS_*` | 選填;首次啟動時自動建立管理員的第一個 Memos 來源。 |
|
||||||
|
|
||||||
@@ -79,6 +83,14 @@ Web 接收使用者操作和 webhook,將同步需求寫入 SQLite 的 `sync_jo
|
|||||||
|
|
||||||
在來源管理中設定的同步規則會套用到 Pull:多個標籤採「同時符合」篩選,日期以 Memos 貼文建立日為準;附件可選擇全部保留、只保留圖片,或不同步附件。貼文後續在遠端被修改、刪除、改為非公開或不再符合規則時,下一次 Pull 會更新或隱藏 Hub 鏡像。
|
在來源管理中設定的同步規則會套用到 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 設定與驗證
|
## Webhook 設定與驗證
|
||||||
|
|
||||||
1. 來源建立者登入「控制台」。
|
1. 來源建立者登入「控制台」。
|
||||||
|
|||||||
+14
-5
@@ -4,9 +4,18 @@ import { db } from "@/lib/db";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ updated?: string; error?: string }> }) {
|
||||||
const user = await getSession(); if (!user || user.role !== "admin") redirect("/");
|
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,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 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 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[];
|
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[];
|
||||||
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></>;
|
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>
|
||||||
|
</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")); }
|
||||||
|
}
|
||||||
@@ -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";
|
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) { 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;
|
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")); }
|
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 @@
|
|||||||
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,"/"));}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { NextResponse } from "next/server";
|
|||||||
import { requireUser } from "@/lib/auth";
|
import { requireUser } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { externalUrl } from "@/lib/http";
|
import { externalUrl } from "@/lib/http";
|
||||||
|
import { requireSameOrigin } from "@/lib/security";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
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") || "");
|
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.length < 10) throw new Error("新密碼至少需要 10 個字元");
|
||||||
if (newPassword !== confirmPassword) throw new Error("兩次新密碼不一致");
|
if (newPassword !== confirmPassword) throw new Error("兩次新密碼不一致");
|
||||||
|
|||||||
@@ -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") || "");
|
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"));
|
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")); }
|
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,10 +2,11 @@ import { NextResponse } from "next/server";
|
|||||||
import { requireUser } from "@/lib/auth";
|
import { requireUser } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { externalUrl } from "@/lib/http";
|
import { externalUrl } from "@/lib/http";
|
||||||
|
import { requireSameOrigin } from "@/lib/security";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
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");
|
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 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;
|
const existing = db.prepare("SELECT kind FROM bookmarks WHERE user_id=? AND post_id=?").get(user.id, postId) as { kind: string } | undefined;
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { requireUser } from "@/lib/auth";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { externalUrl } from "@/lib/http";
|
import { externalUrl } from "@/lib/http";
|
||||||
import { notify } from "@/lib/notifications";
|
import { notify } from "@/lib/notifications";
|
||||||
|
import { requireSameOrigin } from "@/lib/security";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
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");
|
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;
|
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");
|
if (!post) throw new Error("Post not found");
|
||||||
|
|||||||
@@ -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,10 +2,11 @@ import { NextResponse } from "next/server";
|
|||||||
import { requireUser } from "@/lib/auth";
|
import { requireUser } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { externalUrl } from "@/lib/http";
|
import { externalUrl } from "@/lib/http";
|
||||||
|
import { requireSameOrigin } from "@/lib/security";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
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);
|
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);
|
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"));
|
return NextResponse.redirect(externalUrl(req, "/notifications"));
|
||||||
|
|||||||
@@ -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";
|
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{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)));}}
|
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)));}}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { requireUser } from "@/lib/auth";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { externalUrl } from "@/lib/http";
|
import { externalUrl } from "@/lib/http";
|
||||||
import { notify } from "@/lib/notifications";
|
import { notify } from "@/lib/notifications";
|
||||||
|
import { requireSameOrigin } from "@/lib/security";
|
||||||
|
|
||||||
const allowed = new Set(["👍", "❤️", "🎉", "🤔"]);
|
const allowed = new Set(["👍", "❤️", "🎉", "🤔"]);
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
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");
|
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;
|
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");
|
if (!post) throw new Error("Post not found");
|
||||||
|
|||||||
@@ -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, "/")); }
|
||||||
|
}
|
||||||
@@ -5,10 +5,11 @@ import { db } from "@/lib/db";
|
|||||||
import { externalUrl } from "@/lib/http";
|
import { externalUrl } from "@/lib/http";
|
||||||
import { getMemosIdentity, verifyMemos } from "@/lib/memos";
|
import { getMemosIdentity, verifyMemos } from "@/lib/memos";
|
||||||
import { queuePull } from "@/lib/sync";
|
import { queuePull } from "@/lib/sync";
|
||||||
|
import { requireSameOrigin } from "@/lib/security";
|
||||||
|
|
||||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
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 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);
|
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");
|
if (!source || !member) throw new Error("Source not found");
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { NextResponse } from "next/server";
|
|||||||
import { requireUser } from "@/lib/auth";
|
import { requireUser } from "@/lib/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { createWebhookSecret, webhookSecretHash } from "@/lib/webhook";
|
import { createWebhookSecret, webhookSecretHash } from "@/lib/webhook";
|
||||||
|
import { requireSameOrigin } from "@/lib/security";
|
||||||
|
|
||||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
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);
|
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 });
|
if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
const secret = createWebhookSecret();
|
const secret = createWebhookSecret();
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { db } from "@/lib/db";
|
|||||||
import { externalUrl } from "@/lib/http";
|
import { externalUrl } from "@/lib/http";
|
||||||
import { getMemosIdentity, verifyMemos } from "@/lib/memos";
|
import { getMemosIdentity, verifyMemos } from "@/lib/memos";
|
||||||
import { queuePull } from "@/lib/sync";
|
import { queuePull } from "@/lib/sync";
|
||||||
|
import { requireSameOrigin } from "@/lib/security";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
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();
|
const name = String(form.get("name") || "").trim(); const rawBaseUrl = String(form.get("baseUrl") || "").trim(); const token = String(form.get("token") || "").trim();
|
||||||
let baseUrl = "";
|
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"); }
|
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"); }
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { requireUser } from "@/lib/auth";
|
|||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { externalUrl } from "@/lib/http";
|
import { externalUrl } from "@/lib/http";
|
||||||
import { queuePull } from "@/lib/sync";
|
import { queuePull } from "@/lib/sync";
|
||||||
|
import { requireSameOrigin } from "@/lib/security";
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
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);
|
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");
|
if (!source) throw new Error("Source not available");
|
||||||
const created = queuePull(sourceId, "manual");
|
const created = queuePull(sourceId, "manual");
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { withinRateLimit } from "@/lib/rate-limit";
|
import { withinRateLimit } from "@/lib/rate-limit";
|
||||||
|
import { clientIp } from "@/lib/security";
|
||||||
|
import { logEvent } from "@/lib/observability";
|
||||||
import { webhookSecretMatches } from "@/lib/webhook";
|
import { webhookSecretMatches } from "@/lib/webhook";
|
||||||
import { queuePull } from "@/lib/sync";
|
import { queuePull } from "@/lib/sync";
|
||||||
|
|
||||||
@@ -9,11 +11,11 @@ export async function POST(request: Request, { params }: { params: Promise<{ sou
|
|||||||
const id = Number(sourceId);
|
const id = Number(sourceId);
|
||||||
const source = db.prepare("SELECT id, webhook_secret_hash FROM sources WHERE id=? AND is_enabled=1").get(id) as { id: number; webhook_secret_hash: string | null } | undefined;
|
const source = db.prepare("SELECT id, webhook_secret_hash 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 });
|
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}:${clientIp(request)}`, 30, 60_000)) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||||
if (!withinRateLimit(`webhook:${id}:${forwarded}`)) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
|
||||||
let payload: unknown = {};
|
let payload: unknown = {};
|
||||||
try { payload = await request.json(); } catch { /* Memos payload is optional; a pull reconciles source state. */ }
|
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("UPDATE sources SET last_webhook_at=CURRENT_TIMESTAMP WHERE id=?").run(id);
|
||||||
const queued = queuePull(id, "webhook", payload);
|
const queued = queuePull(id, "webhook", payload);
|
||||||
|
logEvent("info", "webhook_received", { sourceId: id, queued });
|
||||||
return NextResponse.json({ ok: true, queued });
|
return NextResponse.json({ ok: true, queued });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" } };
|
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 { 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 (!post || post.hidden) notFound(); const user = await getSession(); if (post.visibility !== "PUBLIC" && post.author_id !== user?.id) redirect("/");
|
||||||
if (user && post.visibility === "PUBLIC") db.prepare("INSERT INTO reading_history(user_id,post_id) VALUES(?,?) ON CONFLICT(user_id,post_id) DO UPDATE SET last_read_at=CURRENT_TIMESTAMP").run(user.id, id);
|
if (user && post.visibility === "PUBLIC") db.prepare("INSERT INTO reading_history(user_id,post_id) VALUES(?,?) ON CONFLICT(user_id,post_id) DO UPDATE SET last_read_at=CURRENT_TIMESTAMP").run(user.id, id);
|
||||||
const bookmark = user ? db.prepare("SELECT kind FROM bookmarks WHERE user_id=? AND post_id=?").get(user.id, id) as { kind: string } | undefined : undefined;
|
const bookmark = user ? db.prepare("SELECT kind FROM bookmarks WHERE user_id=? AND post_id=?").get(user.id, id) as { kind: string } | undefined : undefined;
|
||||||
@@ -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[];
|
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.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} /></section><Attachments json={post.attachments_json} sourceBaseUrl={post.source_base_url} />
|
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} /></section><Attachments json={post.attachments_json} sourceBaseUrl={post.source_base_url} />
|
||||||
<section className="row">{reactions.map((reaction: any) => <span className="tag" key={reaction.emoji}>{reaction.emoji} {reaction.count}</span>)}{user && <><form action="/api/bookmarks" method="post"><input type="hidden" name="postId" value={id} /><input type="hidden" name="kind" value="saved" /><button>{bookmark?.kind === "saved" ? "取消收藏" : "收藏"}</button></form><form action="/api/bookmarks" method="post"><input type="hidden" name="postId" value={id} /><input type="hidden" name="kind" value="later" /><button>{bookmark?.kind === "later" ? "取消稍後閱讀" : "稍後閱讀"}</button></form></>}{user && ["👍", "❤️", "🎉", "🤔"].map((emoji) => <form action="/api/reactions" method="post" key={emoji}><input type="hidden" name="postId" value={id} /><input type="hidden" name="emoji" value={emoji} /><button>{emoji}</button></form>)}</section>
|
<section className="row">{reactions.map((reaction: any) => <span className="tag" key={reaction.emoji}>{reaction.emoji} {reaction.count}</span>)}{user && <><form action="/api/bookmarks" method="post"><input type="hidden" name="postId" value={id} /><input type="hidden" name="kind" value="saved" /><button>{bookmark?.kind === "saved" ? "取消收藏" : "收藏"}</button></form><form action="/api/bookmarks" method="post"><input type="hidden" name="postId" value={id} /><input type="hidden" name="kind" value="later" /><button>{bookmark?.kind === "later" ? "取消稍後閱讀" : "稍後閱讀"}</button></form></>}{user && ["👍", "❤️", "🎉", "🤔"].map((emoji) => <form action="/api/reactions" method="post" key={emoji}><input type="hidden" name="postId" value={id} /><input type="hidden" name="emoji" value={emoji} /><button>{emoji}</button></form>)}</section>
|
||||||
<section><h2>留言</h2>{user ? <form action="/api/comments" method="post"><input type="hidden" name="postId" value={id} /><textarea name="content" required placeholder="在 Hub 留下留言" /><button>送出留言</button></form> : <p>請先登入以留言或表情回應。</p>}{comments.map((comment) => <div className="card" key={comment.id}><strong>@{comment.username}</strong><p>{comment.content}</p><span className="meta">{new Date(comment.created_at).toLocaleString("zh-TW")}</span></div>)}</section>
|
<section><h2>留言</h2>{user ? <><form action="/api/comments" method="post"><input type="hidden" name="postId" value={id} /><textarea name="content" required placeholder="在 Hub 留下留言" /><button>送出留言</button></form><details><summary>檢舉這篇貼文</summary>{query.reported && <p>已收到檢舉,管理員會審核。</p>}<form action="/api/reports" method="post"><input type="hidden" name="postId" value={id} /><label>原因<input name="reason" required minLength={3} maxLength={500} /></label><button className="danger">送出檢舉</button></form></details></> : <p>請先登入以留言、互動或檢舉。</p>}{comments.map((comment) => <div className="card" key={comment.id}><strong>@{comment.username}</strong><p>{comment.content}</p><span className="meta">{new Date(comment.created_at).toLocaleString("zh-TW")}</span></div>)}</section>
|
||||||
</article>;
|
</article>;
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -1,6 +1,10 @@
|
|||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build: .
|
image: mebbling:${MEBBLING_VERSION:-0.5.0}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
APP_VERSION: "${MEBBLING_VERSION:-0.5.0}"
|
||||||
ports: ["8088:3000"]
|
ports: ["8088:3000"]
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment: { DATABASE_PATH: /app/data/hub.db }
|
environment: { DATABASE_PATH: /app/data/hub.db }
|
||||||
@@ -9,7 +13,11 @@ services:
|
|||||||
- ./public/uploads:/app/public/uploads
|
- ./public/uploads:/app/public/uploads
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
worker:
|
worker:
|
||||||
build: .
|
image: mebbling:${MEBBLING_VERSION:-0.5.0}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
APP_VERSION: "${MEBBLING_VERSION:-0.5.0}"
|
||||||
command: npm run worker
|
command: npm run worker
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment: { DATABASE_PATH: /app/data/hub.db }
|
environment: { DATABASE_PATH: /app/data/hub.db }
|
||||||
|
|||||||
@@ -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 migration
|
||||||
|
|
||||||
資料庫 schema 由 `lib/db.ts` 管理。每個欄位 migration 在 `schema_migrations` 表中記錄版本與套用時間,啟動 Web 或 Worker 時會自動執行尚未套用的安全 migration。
|
資料庫 schema 由 `lib/db.ts` 管理。每個欄位 migration 在 `schema_migrations` 表中記錄版本與套用時間,啟動 Web 或 Worker 時會自動執行尚未套用的安全 migration。
|
||||||
|
|||||||
@@ -81,6 +81,13 @@ CREATE UNIQUE INDEX IF NOT EXISTS source_remote_identity_unique ON sources(base_
|
|||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
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
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
db.exec("INSERT OR IGNORE INTO source_members(source_id,user_id,role) SELECT id,user_id,'owner' FROM sources");
|
db.exec("INSERT OR IGNORE INTO source_members(source_id,user_id,role) SELECT id,user_id,'owner' FROM sources");
|
||||||
@@ -108,6 +115,8 @@ applyColumnMigration(14, "sources", "remote_avatar_url", "ALTER TABLE sources AD
|
|||||||
applyColumnMigration(15, "sources", "last_connection_at", "ALTER TABLE sources ADD COLUMN last_connection_at 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(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");
|
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();
|
||||||
|
|
||||||
const admin = process.env.ADMIN_USERNAME;
|
const admin = process.env.ADMIN_USERNAME;
|
||||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
/** SQLite-backed fixed-window limiter shared by every Web container using this database. */
|
||||||
const now = Date.now(); const record = visits.get(key);
|
export function withinRateLimit(bucket: string, limit = 30, windowMs = 60_000) {
|
||||||
if (!record || record.resetAt <= now) { visits.set(key, { count: 1, resetAt: now + windowMs }); return true; }
|
const now = Date.now();
|
||||||
if (record.count >= limit) return false;
|
const transaction = db.transaction(() => {
|
||||||
record.count += 1; return true;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mebbling",
|
"name": "mebbling",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mebbling",
|
"name": "mebbling",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"license": "PolyForm-Noncommercial-1.0.0",
|
"license": "PolyForm-Noncommercial-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mebbling",
|
"name": "mebbling",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ test("applies tracked migrations and deduplicates active pull jobs", async () =>
|
|||||||
const { queuePull } = await import("../lib/sync");
|
const { queuePull } = await import("../lib/sync");
|
||||||
const { notify } = await import("../lib/notifications");
|
const { notify } = await import("../lib/notifications");
|
||||||
const migrations = db.prepare("SELECT version FROM schema_migrations ORDER BY version").all() as { version: number }[];
|
const migrations = db.prepare("SELECT version FROM schema_migrations ORDER BY version").all() as { version: number }[];
|
||||||
assert.deepEqual(migrations.map((item) => item.version), Array.from({ length: 17 }, (_, index) => index + 1));
|
assert.deepEqual(migrations.map((item) => item.version), Array.from({ length: 19 }, (_, index) => index + 1));
|
||||||
const userId = Number(db.prepare("INSERT INTO users(username,password_hash) VALUES('sync-test','hash')").run().lastInsertRowid);
|
const userId = Number(db.prepare("INSERT INTO users(username,password_hash) VALUES('sync-test','hash')").run().lastInsertRowid);
|
||||||
const sourceId = Number(db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,is_enabled) VALUES(?,?,?,?,1)").run(userId, "Test", "https://example.test", "encrypted").lastInsertRowid);
|
const sourceId = Number(db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,is_enabled) VALUES(?,?,?,?,1)").run(userId, "Test", "https://example.test", "encrypted").lastInsertRowid);
|
||||||
assert.equal(queuePull(sourceId, "manual"), true);
|
assert.equal(queuePull(sourceId, "manual"), true);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|||||||
import { db } from "../lib/db";
|
import { db } from "../lib/db";
|
||||||
import { decrypt } from "../lib/crypto";
|
import { decrypt } from "../lib/crypto";
|
||||||
import { createMemo, createRemoteFile, getMemosIdentity, listMemos, memoUrl, setMemoAttachments } from "../lib/memos";
|
import { createMemo, createRemoteFile, getMemosIdentity, listMemos, memoUrl, setMemoAttachments } from "../lib/memos";
|
||||||
|
import { recordError } from "../lib/observability";
|
||||||
|
|
||||||
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" };
|
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" };
|
||||||
type Job = { id: number; source_id: number; kind: "pull" | "push"; payload_json: string | null; attempts: number };
|
type Job = { id: number; source_id: number; kind: "pull" | "push"; payload_json: string | null; attempts: number };
|
||||||
@@ -50,6 +51,7 @@ async function run() {
|
|||||||
db.prepare("UPDATE sources SET sync_status='synced',last_error=NULL,last_synced_at=CURRENT_TIMESTAMP WHERE id=?").run(source.id);
|
db.prepare("UPDATE sources SET sync_status='synced',last_error=NULL,last_synced_at=CURRENT_TIMESTAMP WHERE id=?").run(source.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Sync failure"; const exhausted = job.attempts + 1 >= 5;
|
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 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=?,last_connection_error=? WHERE id=?").run(message, message, source.id);
|
db.prepare("UPDATE sources SET sync_status='error',last_error=?,last_connection_error=? WHERE id=?").run(message, message, source.id);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user