Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
782f7988f1 | ||
|
|
5997a54f2c | ||
|
|
9caf598fcb | ||
|
|
a23bc72dd3 | ||
|
|
e9190da834 | ||
|
|
2af99a4b43 | ||
|
|
c6079bbb47 | ||
|
|
808deaba97 | ||
|
|
44cd7a48ee | ||
|
|
9e159910ca | ||
|
|
591981b7c8 | ||
|
|
05ddafb0d9 | ||
|
|
2ea6a653bf | ||
|
|
97d4c15407 | ||
|
|
3d6d1c03f3 | ||
|
|
a6073bef26 | ||
|
|
a2b3ca8924 | ||
|
|
c6f8e9fff9 | ||
|
|
fadbe39b66 | ||
|
|
21b0c4d4e0 | ||
|
|
d92f474587 | ||
|
|
15afc34a9c | ||
|
|
5425f93d1f | ||
|
|
1ca9491a07 |
@@ -10,6 +10,12 @@ UPLOAD_ALLOWED_TYPES=image/jpeg,image/png,image/gif,image/webp,application/pdf,t
|
||||
VIRUS_SCAN_URL=
|
||||
VIRUS_SCAN_REQUIRED=0
|
||||
SYNC_INTERVAL_MINUTES=60
|
||||
# Optional retention windows in days; 0 keeps data indefinitely. Expired unused invites are removed after 30 days.
|
||||
NOTIFICATION_RETENTION_DAYS=0
|
||||
READING_HISTORY_RETENTION_DAYS=0
|
||||
AUDIT_RETENTION_DAYS=365
|
||||
# Optional Discord webhook URL or ntfy topic URL (for example https://ntfy.sh/my-private-topic).
|
||||
ALERT_WEBHOOK_URL=
|
||||
# Optional: create the first Memos source for the bootstrap admin.
|
||||
SEED_MEMOS_NAME=
|
||||
SEED_MEMOS_URL=
|
||||
|
||||
+31
-1
@@ -2,10 +2,40 @@
|
||||
|
||||
本專案遵循 [Semantic Versioning](https://semver.org/lang/zh-TW/);版本 `0.x` 表示功能仍可能調整。
|
||||
|
||||
## [0.6.0] - Unreleased
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.0] - 2026-07-19
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevented manual URL regeneration from invalidating automatically configured, signed Memos webhooks.
|
||||
|
||||
- 貼文頁、首頁卡片、RSS 與 Atom 優先顯示 Memos 原始發布時間,不再顯示同一次匯入的 Hub 寫入時間。
|
||||
- 多使用者 Memos 來源只同步 API Key 所屬帳號建立的公開貼文;舊來源會在下一次同步自動補回遠端帳號身分並重新篩選鏡像。
|
||||
- 遠端附件快取失敗會在來源控制台顯示可重試提示,並保留原始連結作為回退。
|
||||
|
||||
### Changed
|
||||
|
||||
- 來源名稱改由 API Key 對應的 Memos 帳號自動產生與更新,控制台不再接受手動命名。
|
||||
- 同步會優先使用新版 Memos 的伺服器端 filter;不支援該 API 的舊版 Memos 會安全退回本機篩選。
|
||||
- 新版 Memos 來源建立時會自動建立帶 HMAC 簽名的 webhook;不支援 User Webhook API 的舊版來源維持手動模式。
|
||||
- 相容新版 Memos 的 `/auth/me` 與 username 資源名,既有來源會在同步時更新遠端使用者身分。
|
||||
- 重新連接同一個 Memos 網址時會更新原有來源的 PAT 與遠端身分,不建立重複來源。
|
||||
|
||||
### Added
|
||||
|
||||
- Added per-source sync difference previews, retry for individual failed jobs, and owner-only batch retry of failed jobs.
|
||||
- Added optional per-source attachment archive age: cached files are replaced with their original remote links after the configured period.
|
||||
- Added opt-in Discord/ntfy alerts for exhausted sync retries and stale signed webhooks, with per-event rate limiting.
|
||||
- Added safe signed Memos webhook rotation: create the replacement first, then remove the previous remote webhook before committing the new credentials.
|
||||
- Added configurable retention cleanup and a password-confirmed self-service account deletion flow.
|
||||
- Added append-only audit records for source management, invitations, posting, and administrator moderation.
|
||||
- Added verified SQLite/upload backups with retention, optional offsite copy, restore script, and installable daily WSL cron schedule.
|
||||
- Added expiring, one-time source invitation links with viewer and editor roles; only owners and editors can publish to a shared Memos source.
|
||||
- Added resumable, page-token-based Memos imports with configurable batch size and first-import limit; posts are only hidden after a complete scan.
|
||||
- Added owner/member-only JSON and Markdown exports for individual posts and complete sources.
|
||||
- Added read-only RSS sources: validate a feed URL, queue recurring imports, and show imported posts under their RSS source rather than a Hub account.
|
||||
|
||||
- 文章顯示時隱藏已辨識的內文 hashtag,保留原始 Markdown 與文章底部標籤。
|
||||
- 具時間範圍篩選的公開標籤雲與標籤導覽入口。
|
||||
- 瀏覽器端自動儲存的發文草稿與 Markdown 預覽。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
自架的 Memos 公開貼文 Hub。將朋友各自 Memos 中的公開貼文集中展示,同時保留 Hub 內的留言、表情回應與發文功能。
|
||||
|
||||
目前開發版本:`v0.6.0`(尚未發布)。版本變更請見 [CHANGELOG.md](CHANGELOG.md)。
|
||||
目前版本:`v0.7.0`。版本變更請見 [CHANGELOG.md](CHANGELOG.md)。
|
||||
|
||||
## 功能
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export default async function Account({ searchParams }: { searchParams: Promise<{ error?: string; updated?: string }> }) {
|
||||
const user = await getSession(); if (!user) redirect("/login"); const query = await searchParams;
|
||||
return <><h1>帳號設定</h1>{query.error && <p className="error">{query.error}</p>}{query.updated && <p>密碼已更新。</p>}<section className="card"><p className="meta">帳號:{user.username}</p><h2>變更密碼</h2><form action="/api/auth/password" method="post"><label>目前密碼<input name="currentPassword" type="password" autoComplete="current-password" required /></label><label>新密碼<input name="newPassword" type="password" autoComplete="new-password" minLength={10} required /></label><label>確認新密碼<input name="confirmPassword" type="password" autoComplete="new-password" minLength={10} required /></label><button>更新密碼</button></form></section></>;
|
||||
const ownedSources = db.prepare("SELECT count(*) AS count FROM sources WHERE user_id=?").get(user.id) as { count: number };
|
||||
return <><h1>帳號設定</h1>{query.error && <p className="error">{query.error}</p>}{query.updated && <p>密碼已更新。</p>}<section className="card"><p className="meta">帳號:{user.username}</p><h2>變更密碼</h2><form action="/api/auth/password" method="post"><label>目前密碼<input name="currentPassword" type="password" autoComplete="current-password" required /></label><label>新密碼<input name="newPassword" type="password" autoComplete="new-password" minLength={10} required /></label><label>確認新密碼<input name="confirmPassword" type="password" autoComplete="new-password" minLength={10} required /></label><button>更新密碼</button></form></section><section className="card"><h2>刪除帳號與個人資料</h2>{ownedSources.count ? <p className="error">你仍是 {ownedSources.count} 個來源的建立者。請先轉移建立者或刪除來源,才能刪除帳號。</p> : <form action="/api/auth/delete" method="post"><p className="meta">此操作會移除你的 Hub 帳號、你建立的貼文與個人資料,且無法復原。</p><label>目前密碼<input name="currentPassword" type="password" autoComplete="current-password" required /></label><label>輸入 DELETE 確認<input name="confirmation" required /></label><button className="danger">永久刪除帳號</button></form>}</section></>;
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
|
||||
const reports = db.prepare("SELECT r.id,r.reason,r.created_at,p.id AS post_id,p.content,u.username FROM reports r JOIN posts p ON p.id=r.post_id LEFT JOIN users u ON u.id=r.reporter_id WHERE r.resolved=0 ORDER BY r.created_at LIMIT 100").all() as any[];
|
||||
const users = db.prepare("SELECT id,username,role,disabled,created_at FROM users ORDER BY created_at DESC LIMIT 100").all() as any[];
|
||||
const errors = db.prepare("SELECT scope,message,created_at FROM error_events ORDER BY id DESC LIMIT 30").all() as any[];
|
||||
const audits = db.prepare("SELECT a.action,a.target_type,a.target_id,a.metadata_json,a.created_at,u.username FROM audit_events a LEFT JOIN users u ON u.id=a.actor_user_id ORDER BY a.id DESC LIMIT 50").all() as any[];
|
||||
const sources = db.prepare("SELECT s.id,s.name,s.base_url,s.sync_status,s.last_synced_at,s.is_enabled,count(sm.user_id) AS member_count FROM sources s LEFT JOIN source_members sm ON sm.source_id=s.id GROUP BY s.id ORDER BY s.id DESC").all() as any[];
|
||||
return <><h1>管理員</h1>{query.updated && <p>管理操作已完成。</p>}{query.error && <p className="error">管理操作未完成。</p>}
|
||||
<section className="card"><h2>待審核檢舉</h2>{reports.length ? <ul className="job-list">{reports.map((report) => <li key={report.id}><strong>貼文 #{report.post_id}</strong> · 檢舉者 @{report.username || "已刪除使用者"}<br />{report.reason}<br /><span className="meta">{report.content.slice(0, 180)} · {new Date(report.created_at + "Z").toLocaleString("zh-TW")}</span><div className="row"><form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value="hide-post" /><input type="hidden" name="id" value={report.post_id} /><button className="danger">隱藏貼文並結案</button></form><form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value="resolve-report" /><input type="hidden" name="id" value={report.id} /><button>保留貼文並結案</button></form></div></li>)}</ul> : <p className="muted">沒有待審核檢舉。</p>}</section>
|
||||
<section className="card"><h2>使用者</h2><ul className="job-list">{users.map((account) => <li key={account.id}><strong>@{account.username}</strong> · <span className="tag">{account.role}</span> · {account.disabled ? "已停權" : "正常"}<div className="row">{account.id !== user.id && <form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value={account.disabled ? "enable-user" : "disable-user"} /><input type="hidden" name="id" value={account.id} /><button className={account.disabled ? "" : "danger"}>{account.disabled ? "解除停權" : "停權"}</button></form>}<form action="/api/admin/moderate" method="post"><input type="hidden" name="action" value="reset-password" /><input type="hidden" name="id" value={account.id} /><input name="password" type="password" minLength={10} required placeholder="管理員重設密碼" /><button>重設密碼</button></form></div></li>)}</ul></section>
|
||||
<section className="card"><h2>失敗或異常工作</h2>{failures.length ? <ul className="job-list">{failures.map((item) => <li key={`${item.id}-${item.source_id}`}><strong>{item.name}</strong>(來源 #{item.source_id}) · {item.kind || "source"} · <span className="tag">{item.status || "error"}</span><br /><span className="error">{item.last_error || "來源處於錯誤狀態"}</span></li>)}</ul> : <p className="muted">沒有同步異常。</p>}</section>
|
||||
<section className="card"><h2>最近系統錯誤</h2>{errors.length ? <ul className="job-list">{errors.map((error, index) => <li key={index}><strong>{error.scope}</strong> · <span className="error">{error.message}</span><br /><span className="meta">{new Date(error.created_at + "Z").toLocaleString("zh-TW")}</span></li>)}</ul> : <p className="muted">尚無記錄。</p>}</section>
|
||||
<section className="card"><h2>稽核紀錄</h2>{audits.length ? <ul className="job-list">{audits.map((item, index) => <li key={index}><strong>{item.action}</strong> · @{item.username || "system"} · {item.target_type} #{item.target_id || "—"}<br /><span className="meta">{new Date(item.created_at + "Z").toLocaleString("zh-TW")}</span></li>)}</ul> : <p className="muted">尚無稽核紀錄。</p>}</section>
|
||||
<section className="card"><h2>所有來源</h2><ul className="job-list">{sources.map((source) => <li key={source.id}><strong>{source.name}</strong> · <span className="tag">{source.is_enabled ? source.sync_status : "disabled"}</span> · 成員 {source.member_count}<br /><span className="meta">#{source.id} · {source.base_url} · 上次同步:{source.last_synced_at || "尚未完成"}</span></li>)}</ul></section>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { externalUrl } from "@/lib/http";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
import bcrypt from "bcryptjs";
|
||||
@@ -16,6 +17,7 @@ export async function POST(request: Request) {
|
||||
else if (action === "reset-password") { const password = String(form.get("password") || ""); if (password.length < 10) throw new Error("Invalid password"); db.prepare("UPDATE users SET password_hash=? WHERE id=?").run(await bcrypt.hash(password, 12), id); }
|
||||
else if (action === "resolve-report") db.prepare("UPDATE reports SET resolved=1 WHERE id=?").run(id);
|
||||
else throw new Error("Invalid action");
|
||||
audit(admin.id, `admin.${action}`, "admin-target", id);
|
||||
return NextResponse.redirect(externalUrl(request, "/admin?updated=1"));
|
||||
} catch { return NextResponse.redirect(externalUrl(request, "/admin?error=moderation")); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { NextResponse } from "next/server";
|
||||
import { clearSession, requireUser } from "@/lib/auth";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { db } from "@/lib/db";
|
||||
import { externalUrl } from "@/lib/http";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
export async function POST(request: Request) { try { requireSameOrigin(request); const user = await requireUser(); if (user.role === "admin") throw new Error("管理員帳號不可自行刪除"); const form = await request.formData(); if (String(form.get("confirmation")) !== "DELETE") throw new Error("請輸入 DELETE 確認"); const account = db.prepare("SELECT password_hash FROM users WHERE id=?").get(user.id) as { password_hash: string } | undefined; if (!account || !(await bcrypt.compare(String(form.get("currentPassword") || ""), account.password_hash))) throw new Error("目前密碼不正確"); const owned = db.prepare("SELECT count(*) AS count FROM sources WHERE user_id=?").get(user.id) as { count: number }; if (owned.count) throw new Error("請先處理你建立的來源"); audit(user.id, "account.delete", "user", user.id); db.prepare("DELETE FROM users WHERE id=?").run(user.id); await clearSession(); return NextResponse.redirect(externalUrl(request, "/?account=deleted")); } catch (error) { return NextResponse.redirect(externalUrl(request, "/account?error=" + encodeURIComponent(error instanceof Error ? error.message : "delete"))); } }
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
function markdown(post: any) {
|
||||
const tags = (() => { try { return JSON.parse(post.tags_json || "[]"); } catch { return []; } })();
|
||||
const attachments = (() => { try { return JSON.parse(post.attachments_json || "[]"); } catch { return []; } })();
|
||||
const quote = (value: unknown) => JSON.stringify(value ?? "");
|
||||
const attachmentList = attachments.length ? `\n\n## Attachments\n${attachments.map((item: any) => `- [${item.filename || item.name || "attachment"}](${item.url || item.externalLink || ""})`).join("\n")}` : "";
|
||||
return `---\nid: ${post.id}\norigin: ${quote(post.origin)}\nvisibility: ${quote(post.visibility)}\npublished_at: ${quote(post.remote_created_at || post.created_at)}\ntags: ${JSON.stringify(tags)}\nremote_url: ${quote(post.remote_url)}\n---\n\n${post.content}${attachmentList}\n`;
|
||||
}
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireUser(); const { id: rawId } = await params; const post = db.prepare("SELECT p.*,s.name AS source_name FROM posts p LEFT JOIN sources s ON s.id=p.source_id WHERE p.id=?").get(Number(rawId)) as any;
|
||||
if (!post) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
const permitted = post.author_id === user.id || (post.source_id && db.prepare("SELECT 1 FROM source_members WHERE source_id=? AND user_id=?").get(post.source_id, user.id));
|
||||
if (!permitted) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
const format = new URL(request.url).searchParams.get("format") === "markdown" ? "markdown" : "json";
|
||||
const body = format === "markdown" ? markdown(post) : JSON.stringify({ version: 1, exportedAt: new Date().toISOString(), post: { ...post, tags: JSON.parse(post.tags_json || "[]"), attachments: JSON.parse(post.attachments_json || "[]") } }, null, 2);
|
||||
return new NextResponse(body, { headers: { "Content-Type": format === "markdown" ? "text/markdown; charset=utf-8" : "application/json; charset=utf-8", "Content-Disposition": `attachment; filename="mebbling-post-${post.id}.${format === "markdown" ? "md" : "json"}"` } });
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
function postMarkdown(post: any) { return `## ${post.remote_created_at || post.created_at}\n\n${post.content}\n`; }
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId); const source = db.prepare("SELECT id,name,base_url,integration_type,rss_feed_url,created_at FROM sources WHERE id=?").get(id) as any;
|
||||
if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (!db.prepare("SELECT 1 FROM source_members WHERE source_id=? AND user_id=?").get(id, user.id)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
const posts = db.prepare("SELECT id,content,visibility,tags_json,attachments_json,origin,remote_created_at,remote_updated_at,remote_url,created_at,updated_at FROM posts WHERE source_id=? ORDER BY COALESCE(remote_created_at,created_at)").all(id) as any[];
|
||||
const format = new URL(request.url).searchParams.get("format") === "markdown" ? "markdown" : "json";
|
||||
const body = format === "markdown" ? `# ${source.name}\n\n${posts.map(postMarkdown).join("\n---\n\n")}` : JSON.stringify({ version: 1, exportedAt: new Date().toISOString(), source, posts: posts.map((post) => ({ ...post, tags: JSON.parse(post.tags_json || "[]"), attachments: JSON.parse(post.attachments_json || "[]") })) }, null, 2);
|
||||
return new NextResponse(body, { headers: { "Content-Type": format === "markdown" ? "text/markdown; charset=utf-8" : "application/json; charset=utf-8", "Content-Disposition": `attachment; filename="mebbling-source-${id}.${format === "markdown" ? "md" : "json"}"` } });
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { externalUrl } from "@/lib/http";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
export async function POST(request: Request) { try { requireSameOrigin(request); const user = await requireUser(); const form = await request.formData(); const token = String(form.get("token") || ""); const hash = createHash("sha256").update(token).digest("hex"); const invite = db.prepare("SELECT id,source_id,role FROM source_invites WHERE token_hash=? AND used_at IS NULL AND expires_at>CURRENT_TIMESTAMP").get(hash) as { id: number; source_id: number; role: string } | undefined; if (!invite) throw new Error("邀請不存在、已使用或已過期"); db.transaction(() => { db.prepare("INSERT INTO source_members(source_id,user_id,role) VALUES(?,?,?) ON CONFLICT(source_id,user_id) DO UPDATE SET role=excluded.role").run(invite.source_id, user.id, invite.role); db.prepare("UPDATE source_invites SET used_at=CURRENT_TIMESTAMP WHERE id=?").run(invite.id); })(); audit(user.id, "source.invite.accept", "source", invite.source_id, { role: invite.role }); return NextResponse.redirect(externalUrl(request, "/dashboard?source=joined")); } catch (error) { return NextResponse.redirect(externalUrl(request, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "invite"))); } }
|
||||
@@ -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 { 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)));}}
|
||||
import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; import { audit } from "@/lib/audit"; import { externalUrl } from "@/lib/http"; import { mkdir, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { extname, join } from "node:path"; import { requireSameOrigin } from "@/lib/security"; import { validateUpload } from "@/lib/uploads";
|
||||
export async function POST(req:Request){const json=req.headers.get("accept")?.includes("application/json");try{requireSameOrigin(req);const user=await requireUser();const f=await req.formData();const content=String(f.get("content")||"").trim();const visibility=String(f.get("visibility")||"PUBLIC");const sourceId=Number(f.get("sourceId"));const tags=String(f.get("tags")||"").split(/\s*,\s*/).filter(Boolean).map(t=>t.replace(/^#/,""));if(!content||!['PRIVATE','PROTECTED','PUBLIC'].includes(visibility)||!sourceId)throw new Error("Invalid post");const source=db.prepare("SELECT s.id FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE s.id=? AND sm.user_id=? AND sm.role IN ('owner','editor') AND s.is_enabled=1").get(sourceId,user.id);if(!source)throw new Error("Source not available");const files=f.getAll('attachments').filter((x):x is File=>x instanceof File&&x.size>0);if(files.length>10)throw new Error("最多可上傳 10 個附件");const attachments:any[]=[];await mkdir(join(process.cwd(),'public','uploads'),{recursive:true});for(const file of files){await validateUpload(file);const id=randomUUID()+extname(file.name);await writeFile(join(process.cwd(),'public','uploads',id),Buffer.from(await file.arrayBuffer()));attachments.push({name:file.name,url:`/uploads/${id}`,type:file.type,size:file.size});}const out=db.prepare("INSERT INTO posts(source_id,author_id,content,visibility,tags_json,attachments_json,origin,sync_status) VALUES(?,?,?,?,?,?,'hub','queued')").run(sourceId,user.id,content,visibility,JSON.stringify(tags),JSON.stringify(attachments));audit(user.id,"post.create","post",Number(out.lastInsertRowid),{sourceId,visibility});db.prepare("INSERT INTO sync_jobs(source_id,kind,payload_json,trigger) VALUES(?, 'push', ?, 'manual')").run(sourceId,JSON.stringify({postId:out.lastInsertRowid}));if(json)return NextResponse.json({id:Number(out.lastInsertRowid)},{status:201});return NextResponse.redirect(externalUrl(req,`/posts/${out.lastInsertRowid}`));}catch(e){const message=e instanceof Error?e.message:'post';if(json)return NextResponse.json({error:message},{status:400});return NextResponse.redirect(externalUrl(req,'/dashboard?error='+encodeURIComponent(message)));}}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try { requireSameOrigin(request); const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId); const source = db.prepare("SELECT id FROM sources WHERE id=? AND user_id=?").get(id, user.id); if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 }); const { role } = await request.json() as { role?: string }; if (!['viewer','editor'].includes(role || '')) return NextResponse.json({ error: "Invalid role" }, { status: 400 }); const token = randomBytes(24).toString("base64url"); const hash = createHash("sha256").update(token).digest("hex"); db.prepare("INSERT INTO source_invites(source_id,token_hash,role,expires_at,created_by) VALUES(?,?,?,datetime('now','+7 days'),?)").run(id, hash, role, user.id); audit(user.id, "source.invite.create", "source", id, { role }); const origin = (process.env.NEXT_PUBLIC_APP_URL || new URL(request.url).origin).replace(/\/$/, ""); return NextResponse.json({ url: `${origin}/invite/${token}` }); } catch { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); }
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { db } from "@/lib/db";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { externalUrl } from "@/lib/http";
|
||||
import { getMemosIdentity, verifyMemos } from "@/lib/memos";
|
||||
import { queuePull } from "@/lib/sync";
|
||||
@@ -14,31 +15,31 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
||||
const member = db.prepare("SELECT role FROM source_members WHERE source_id=? AND user_id=?").get(id, user.id);
|
||||
if (!source || !member) throw new Error("Source not found");
|
||||
const owner = source.user_id === user.id;
|
||||
if (action === "rename") {
|
||||
const name = String(form.get("name") || "").trim(); if (!owner || !name || name.length > 80) throw new Error("Only the owner can rename a source");
|
||||
db.prepare("UPDATE sources SET name=? WHERE id=?").run(name, id);
|
||||
} else if (action === "set-enabled") {
|
||||
if (action === "set-enabled") {
|
||||
if (!owner) throw new Error("Only the owner can change source status"); const enabled = String(form.get("enabled")) === "1";
|
||||
db.prepare("UPDATE sources SET is_enabled=?,disabled_at=CASE WHEN ? THEN NULL ELSE CURRENT_TIMESTAMP END,sync_status=CASE WHEN ? THEN 'pending' ELSE 'disabled' END WHERE id=?").run(enabled ? 1 : 0, enabled ? 1 : 0, enabled ? 1 : 0, id);
|
||||
if (enabled) queuePull(id, "manual");
|
||||
} else if (action === "set-sync-rules") {
|
||||
if (!owner) throw new Error("Only the owner can change sync rules");
|
||||
const tags = String(form.get("tags") || "").split(",").map((tag) => tag.trim().replace(/^#/, "")).filter(Boolean).slice(0, 20);
|
||||
const from = String(form.get("from") || ""); const to = String(form.get("to") || ""); const attachmentMode = String(form.get("attachmentMode") || "all");
|
||||
if ((from && !/^\d{4}-\d{2}-\d{2}$/.test(from)) || (to && !/^\d{4}-\d{2}-\d{2}$/.test(to)) || (from && to && from > to) || !["all", "images", "none"].includes(attachmentMode)) throw new Error("Invalid sync rules");
|
||||
db.prepare("UPDATE sources SET sync_tags_json=?,sync_from=?,sync_to=?,sync_attachment_mode=? WHERE id=?").run(JSON.stringify(tags), from || null, to || null, attachmentMode, id);
|
||||
const from = String(form.get("from") || ""); const to = String(form.get("to") || ""); const attachmentMode = String(form.get("attachmentMode") || "all"); const batchSize = Number(form.get("batchSize") || 100); const maxPosts = Number(form.get("maxPosts") || 0);
|
||||
if ((from && !/^\d{4}-\d{2}-\d{2}$/.test(from)) || (to && !/^\d{4}-\d{2}-\d{2}$/.test(to)) || (from && to && from > to) || !["all", "images", "none"].includes(attachmentMode) || !Number.isInteger(batchSize) || batchSize < 10 || batchSize > 100 || !Number.isInteger(maxPosts) || maxPosts < 0 || maxPosts > 100000) throw new Error("Invalid sync rules");
|
||||
db.prepare("UPDATE sources SET sync_tags_json=?,sync_from=?,sync_to=?,sync_attachment_mode=?,sync_batch_size=?,sync_max_posts=?,sync_cursor=NULL,sync_imported_count=0,sync_run_id=NULL WHERE id=?").run(JSON.stringify(tags), from || null, to || null, attachmentMode, batchSize, maxPosts || null, id);
|
||||
queuePull(id, "manual");
|
||||
} else if (action === "set-attachment-storage") {
|
||||
if (!owner) throw new Error("Only the owner can change attachment storage"); const mode = String(form.get("mode") || "remote"); const quotaMiB = Number(form.get("quotaMiB") || 100);
|
||||
if (!["remote", "images", "all"].includes(mode) || !Number.isFinite(quotaMiB) || quotaMiB < 10 || quotaMiB > 10_240) throw new Error("Invalid attachment storage settings");
|
||||
db.prepare("UPDATE sources SET attachment_storage_mode=?,attachment_cache_limit_bytes=?,attachment_cache_error=NULL WHERE id=?").run(mode, Math.round(quotaMiB * 1024 * 1024), id); queuePull(id, "manual");
|
||||
if (!owner) throw new Error("Only the owner can change attachment storage"); const mode = String(form.get("mode") || "remote"); const quotaMiB = Number(form.get("quotaMiB") || 100); const archiveAfterDays = Number(form.get("archiveAfterDays") || 0);
|
||||
if (!["remote", "images", "all"].includes(mode) || !Number.isFinite(quotaMiB) || quotaMiB < 10 || quotaMiB > 10_240 || !Number.isInteger(archiveAfterDays) || archiveAfterDays < 0 || archiveAfterDays > 3650) throw new Error("Invalid attachment storage settings");
|
||||
db.prepare("UPDATE sources SET attachment_storage_mode=?,attachment_cache_limit_bytes=?,attachment_archive_after_days=?,attachment_cache_error=NULL WHERE id=?").run(mode, Math.round(quotaMiB * 1024 * 1024), archiveAfterDays || null, id); queuePull(id, "manual");
|
||||
} else if (action === "test-connection") {
|
||||
if (!owner) throw new Error("Only the owner can test the connection");
|
||||
try {
|
||||
const token = decrypt(source.token_encrypted); await verifyMemos(source.base_url, token); const identity = await getMemosIdentity(source.base_url, token);
|
||||
const avatar = identity.avatarUrl || identity.avatar || null; const avatarUrl = avatar?.startsWith("/") ? `${source.base_url.replace(/\/$/, "")}${avatar}` : avatar;
|
||||
db.prepare("UPDATE sources SET last_connection_at=CURRENT_TIMESTAMP,last_connection_error=NULL,remote_display_name=?,remote_avatar_url=? WHERE id=?").run(identity.nickname || identity.username || identity.name, avatarUrl, id);
|
||||
const name = identity.displayName || identity.nickname || identity.username || identity.name;
|
||||
db.prepare("UPDATE sources SET name=?,last_connection_at=CURRENT_TIMESTAMP,last_connection_error=NULL,remote_display_name=?,remote_avatar_url=? WHERE id=?").run(name, name, avatarUrl, id);
|
||||
} catch (connectionError) { const message = connectionError instanceof Error ? connectionError.message : "Connection failed"; db.prepare("UPDATE sources SET last_connection_error=? WHERE id=?").run(message, id); throw connectionError; }
|
||||
} else if (action === "retry-failed-jobs") {
|
||||
if (!owner) throw new Error("Only the owner can retry all failed jobs"); db.prepare("UPDATE sync_jobs SET status='queued',attempts=0,last_error=NULL,started_at=NULL,finished_at=NULL,run_after=CURRENT_TIMESTAMP WHERE source_id=? AND status='failed'").run(id);
|
||||
} else if (action === "leave") {
|
||||
if (owner) throw new Error("Transfer ownership or delete the source before leaving");
|
||||
db.prepare("DELETE FROM source_members WHERE source_id=? AND user_id=?").run(id, user.id);
|
||||
@@ -53,6 +54,6 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
|
||||
const remove = db.transaction(() => { db.prepare("DELETE FROM posts WHERE source_id=? AND origin='memos'").run(id); db.prepare("UPDATE posts SET source_id=NULL WHERE source_id=? AND origin='hub'").run(id); db.prepare("DELETE FROM sources WHERE id=?").run(id); });
|
||||
remove();
|
||||
} else throw new Error("Unknown source action");
|
||||
return NextResponse.redirect(externalUrl(req, "/dashboard?source=updated"));
|
||||
audit(user.id, `source.${action}`, "source", id); return NextResponse.redirect(externalUrl(req, "/dashboard?source=updated"));
|
||||
} catch (error) { return NextResponse.redirect(externalUrl(req, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "source"))); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { db } from "@/lib/db";
|
||||
import { getMemosIdentity, listMemos } from "@/lib/memos";
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { try { const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId); const source = db.prepare("SELECT s.* FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE s.id=? AND sm.user_id=? AND s.integration_type='memos'").get(id, user.id) as any; if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 }); const token = decrypt(source.token_encrypted); const identity = await getMemosIdentity(source.base_url, token); const rules = { creator: identity.name, tags: JSON.parse(source.sync_tags_json || "[]"), from: source.sync_from, to: source.sync_to, attachmentMode: source.sync_attachment_mode }; const remote = await listMemos(source.base_url, token, rules, { pageSize: 100 }); const local = new Set((db.prepare("SELECT remote_memo_name FROM posts WHERE source_id=? AND origin='memos'").all(id) as { remote_memo_name: string }[]).map((row) => row.remote_memo_name)); const added = remote.memos.filter((memo) => !local.has(memo.name)).length; return NextResponse.json({ inspected: remote.memos.length, added, existing: remote.memos.length - added, hasMore: Boolean(remote.nextPageToken) }); } catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : "preview" }, { status: 400 }); } }
|
||||
@@ -1,14 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { createUserWebhook, deleteUserWebhook, getMemosIdentity } from "@/lib/memos";
|
||||
import { createWebhookSecret, webhookSecretHash } from "@/lib/webhook";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
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,base_url,token_encrypted,remote_user,webhook_mode,webhook_remote_name FROM sources WHERE id=? AND user_id=?").get(id, user.id) as { id: number; base_url: string; token_encrypted: string; remote_user: string | null; webhook_mode: string; webhook_remote_name: string | null } | undefined;
|
||||
if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (source.webhook_mode === "signed") {
|
||||
const token = decrypt(source.token_encrypted); const identity = source.remote_user ? { name: source.remote_user } : await getMemosIdentity(source.base_url, token); const pathSecret = createWebhookSecret(); const signingSecret = createWebhookSecret(); const publicOrigin = (process.env.NEXT_PUBLIC_APP_URL || new URL(request.url).origin).replace(/\/$/, "");
|
||||
const remote = await createUserWebhook(source.base_url, token, identity.name, { url: `${publicOrigin}/api/sync/webhook/${id}/${pathSecret}`, displayName: "Mebbling", signingSecret });
|
||||
try { if (source.webhook_remote_name) await deleteUserWebhook(source.base_url, token, identity.name, source.webhook_remote_name); }
|
||||
catch (error) { try { await deleteUserWebhook(source.base_url, token, identity.name, remote.name); } catch {} throw error; }
|
||||
db.prepare("UPDATE sources SET webhook_secret_hash=?,webhook_remote_name=?,webhook_signing_secret_encrypted=? WHERE id=?").run(webhookSecretHash(pathSecret), remote.name, encrypt(signingSecret), id);
|
||||
return NextResponse.json({ rotated: true });
|
||||
}
|
||||
const secret = createWebhookSecret();
|
||||
db.prepare("UPDATE sources SET webhook_secret_hash=? WHERE id=?").run(webhookSecretHash(secret), id);
|
||||
const publicOrigin = (process.env.NEXT_PUBLIC_APP_URL || new URL(request.url).origin).replace(/\/$/, "");
|
||||
|
||||
@@ -3,17 +3,18 @@ import { requireUser } from "@/lib/auth";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { db } from "@/lib/db";
|
||||
import { externalUrl } from "@/lib/http";
|
||||
import { getMemosIdentity, verifyMemos } from "@/lib/memos";
|
||||
import { createUserWebhook, getMemosIdentity, verifyMemos } from "@/lib/memos";
|
||||
import { createWebhookSecret, webhookSecretHash } from "@/lib/webhook";
|
||||
import { queuePull } from "@/lib/sync";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
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 rawBaseUrl = String(form.get("baseUrl") || "").trim(); const token = String(form.get("token") || "").trim();
|
||||
let baseUrl = "";
|
||||
try { const url = new URL(rawBaseUrl); if (!['http:', 'https:'].includes(url.protocol)) throw new Error(); baseUrl = `${url.origin}${url.pathname.replace(/\/+$/, "")}`; } catch { throw new Error("Invalid source URL"); }
|
||||
if (!name || token.length < 20) throw new Error("Invalid source");
|
||||
if (token.length < 20) throw new Error("Invalid source");
|
||||
await verifyMemos(baseUrl, token); const identity = await getMemosIdentity(baseUrl, token);
|
||||
const legacySources = db.prepare("SELECT id,token_encrypted FROM sources WHERE base_url=? AND remote_user IS NULL").all(baseUrl) as { id: number; token_encrypted: string }[];
|
||||
for (const legacy of legacySources) {
|
||||
@@ -21,9 +22,15 @@ export async function POST(req: Request) {
|
||||
}
|
||||
const shared = db.prepare("SELECT id FROM sources WHERE base_url=? AND remote_user=?").get(baseUrl, identity.name) as { id: number } | undefined;
|
||||
if (shared) { db.prepare("INSERT OR IGNORE INTO source_members(source_id,user_id) VALUES(?,?)").run(shared.id, user.id); return NextResponse.redirect(externalUrl(req, "/dashboard?source=shared")); }
|
||||
const name = identity.displayName || identity.nickname || identity.username || identity.name;
|
||||
const existing = db.prepare("SELECT id FROM sources WHERE user_id=? AND base_url=?").get(user.id, baseUrl) as { id: number } | undefined;
|
||||
if (existing) { db.prepare("UPDATE sources SET name=?,token_encrypted=?,remote_user=?,sync_status='queued',last_connection_error=NULL WHERE id=?").run(name, encrypt(token), identity.name, existing.id); queuePull(existing.id, "source-created"); return NextResponse.redirect(externalUrl(req, "/dashboard?source=reconnected")); }
|
||||
const out = db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,remote_user,sync_status) VALUES(?,?,?,?,?, 'queued')").run(user.id, name, baseUrl, encrypt(token), identity.name);
|
||||
const sourceId = Number(out.lastInsertRowid);
|
||||
db.prepare("INSERT INTO source_members(source_id,user_id,role) VALUES(?,?,'owner')").run(sourceId, user.id);
|
||||
const pathSecret = createWebhookSecret(); const signingSecret = createWebhookSecret(); const publicOrigin = (process.env.NEXT_PUBLIC_APP_URL || new URL(req.url).origin).replace(/\/$/, "");
|
||||
try { const remote = await createUserWebhook(baseUrl, token, identity.name, { url: `${publicOrigin}/api/sync/webhook/${sourceId}/${pathSecret}`, displayName: "Mebbling", signingSecret }); db.prepare("UPDATE sources SET webhook_secret_hash=?,webhook_mode='signed',webhook_remote_name=?,webhook_signing_secret_encrypted=? WHERE id=?").run(webhookSecretHash(pathSecret), remote.name, encrypt(signingSecret), sourceId); }
|
||||
catch (webhookError) { const message = webhookError instanceof Error ? webhookError.message : "Webhook unsupported"; db.prepare("UPDATE sources SET webhook_mode=? WHERE id=?").run(message.includes("Memos API 404") ? "manual" : "unavailable", sourceId); }
|
||||
queuePull(sourceId, "source-created");
|
||||
return NextResponse.redirect(externalUrl(req, "/dashboard?source=connected"));
|
||||
} catch (error) { return NextResponse.redirect(externalUrl(req, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "source"))); }
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { db } from "@/lib/db";
|
||||
import { externalUrl } from "@/lib/http";
|
||||
import { fetchRss } from "@/lib/rss";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
import { queuePull } from "@/lib/sync";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try { requireSameOrigin(request); const user = await requireUser(); const form = await request.formData(); const raw = String(form.get("feedUrl") || "").trim(); const url = new URL(raw); if (url.protocol !== "https:") throw new Error("RSS feed must use HTTPS"); const items = await fetchRss(url.toString()); if (!items.length) throw new Error("RSS feed has no items");
|
||||
const existing = db.prepare("SELECT id FROM sources WHERE user_id=? AND rss_feed_url=?").get(user.id, url.toString()) as { id: number } | undefined;
|
||||
if (existing) { queuePull(existing.id, "manual"); return NextResponse.redirect(externalUrl(request, "/dashboard?source=rss-refreshed")); }
|
||||
const name = `RSS · ${url.hostname}`; const out = db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,integration_type,rss_feed_url,sync_status) VALUES(?,?,?,?, 'rss',?, 'queued')").run(user.id, name, url.origin, encrypt("rss-read-only"), url.toString()); const id = Number(out.lastInsertRowid);
|
||||
db.prepare("INSERT INTO source_members(source_id,user_id,role) VALUES(?,?,'owner')").run(id, user.id); queuePull(id, "source-created"); return NextResponse.redirect(externalUrl(request, "/dashboard?source=rss-connected"));
|
||||
} catch (error) { return NextResponse.redirect(externalUrl(request, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "rss"))); }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { externalUrl } from "@/lib/http";
|
||||
import { requireSameOrigin } from "@/lib/security";
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { try { requireSameOrigin(request); const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId); const job = db.prepare("SELECT j.id FROM sync_jobs j JOIN source_members sm ON sm.source_id=j.source_id WHERE j.id=? AND sm.user_id=? AND j.status='failed'").get(id, user.id); if (!job) throw new Error("Failed job not found"); db.prepare("UPDATE sync_jobs SET status='queued',attempts=0,last_error=NULL,started_at=NULL,finished_at=NULL,run_after=CURRENT_TIMESTAMP WHERE id=?").run(id); return NextResponse.redirect(externalUrl(request, "/dashboard?sync=queued")); } catch (error) { return NextResponse.redirect(externalUrl(request, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "retry"))); } }
|
||||
@@ -4,16 +4,19 @@ import { withinRateLimit } from "@/lib/rate-limit";
|
||||
import { clientIp } from "@/lib/security";
|
||||
import { logEvent } from "@/lib/observability";
|
||||
import { webhookSecretMatches } from "@/lib/webhook";
|
||||
import { standardWebhookMatches } from "@/lib/webhook";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { queuePull } from "@/lib/sync";
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ sourceId: string; secret: string }> }) {
|
||||
const { sourceId, secret } = await params;
|
||||
const id = Number(sourceId);
|
||||
const source = db.prepare("SELECT id, webhook_secret_hash FROM sources WHERE id=? AND is_enabled=1").get(id) as { id: number; webhook_secret_hash: string | null } | undefined;
|
||||
const source = db.prepare("SELECT id, webhook_secret_hash,webhook_mode,webhook_signing_secret_encrypted FROM sources WHERE id=? AND is_enabled=1").get(id) as { id: number; webhook_secret_hash: string | null; webhook_mode: string; webhook_signing_secret_encrypted: string | null } | undefined;
|
||||
if (!source || !webhookSecretMatches(secret, source.webhook_secret_hash)) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
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. */ }
|
||||
const raw = await request.text();
|
||||
if (source.webhook_mode === "signed") { const signingSecret = source.webhook_signing_secret_encrypted ? decrypt(source.webhook_signing_secret_encrypted) : ""; if (!standardWebhookMatches(signingSecret, request.headers.get("webhook-id"), request.headers.get("webhook-timestamp"), request.headers.get("webhook-signature"), raw)) return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); }
|
||||
let payload: unknown = {}; try { payload = raw ? JSON.parse(raw) : {}; } catch { /* Memos payload is optional; a pull reconciles source state. */ }
|
||||
db.prepare("UPDATE sources SET last_webhook_at=CURRENT_TIMESTAMP WHERE id=?").run(id);
|
||||
const queued = queuePull(id, "webhook", payload);
|
||||
logEvent("info", "webhook_received", { sourceId: id, queued });
|
||||
|
||||
@@ -2,7 +2,7 @@ import { db } from "@/lib/db";
|
||||
|
||||
const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, (char) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[char] || char));
|
||||
export async function GET() {
|
||||
const origin = (process.env.NEXT_PUBLIC_APP_URL || "http://localhost:8088").replace(/\/$/, ""); const posts = db.prepare("SELECT p.id,p.content,p.created_at,u.username FROM posts p JOIN users u ON u.id=p.author_id WHERE p.visibility='PUBLIC' AND p.hidden=0 ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 50").all() as { id: number; content: string; created_at: string; username: string }[]; const updated = posts[0] ? new Date(posts[0].created_at + "Z").toISOString() : new Date().toISOString();
|
||||
const entries = posts.map((post) => `<entry><id>${origin}/posts/${post.id}</id><title>${escapeXml(`@${post.username} 的貼文`)}</title><link href="${origin}/posts/${post.id}"/><updated>${new Date(post.created_at + "Z").toISOString()}</updated><content type="text">${escapeXml(post.content)}</content></entry>`).join("");
|
||||
const origin = (process.env.NEXT_PUBLIC_APP_URL || "http://localhost:8088").replace(/\/$/, ""); const posts = db.prepare("SELECT p.id,p.content,COALESCE(p.remote_created_at,p.created_at) AS published_at,u.username FROM posts p JOIN users u ON u.id=p.author_id WHERE p.visibility='PUBLIC' AND p.hidden=0 ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 50").all() as { id: number; content: string; published_at: string; username: string }[]; const updated = posts[0] ? new Date(posts[0].published_at).toISOString() : new Date().toISOString();
|
||||
const entries = posts.map((post) => `<entry><id>${origin}/posts/${post.id}</id><title>${escapeXml(`@${post.username} 的貼文`)}</title><link href="${origin}/posts/${post.id}"/><updated>${new Date(post.published_at).toISOString()}</updated><content type="text">${escapeXml(post.content)}</content></entry>`).join("");
|
||||
return new Response(`<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title>Mebbling</title><id>${origin}</id><link href="${origin}/atom.xml" rel="self"/><updated>${updated}</updated>${entries}</feed>`, { headers: { "Content-Type": "application/atom+xml; charset=utf-8", "Cache-Control": "public, max-age=300" } });
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import { Attachments } from "./attachments";
|
||||
import { Markdown } from "./markdown";
|
||||
import { canonicalTags } from "@/lib/tags";
|
||||
|
||||
export type PublicPost = { id: number; source_id: number | null; content: string; tags_json: string; attachments_json: string; created_at: string; username: string; name: string | null; source_base_url: string | null; comment_count: number; reaction_count: number };
|
||||
export type PublicPost = { id: number; source_id: number | null; origin: string; content: string; tags_json: string; attachments_json: string; created_at: string; remote_created_at?: string | null; username: string; name: string | null; remote_display_name?: string | null; source_base_url: string | null; comment_count: number; reaction_count: number };
|
||||
|
||||
export function PostCard({ post }: { post: PublicPost }) {
|
||||
let tags: string[] = []; try { tags = canonicalTags(JSON.parse(post.tags_json)); } catch { /* Ignore malformed legacy tags. */ }
|
||||
return <article className="card"><div className="space"><Link className="meta post-name-link" href={`/posts/${post.id}`}>@{post.username}{post.name ? ` · ${post.name}` : ""}</Link><span className="meta">{new Date(post.created_at).toLocaleString("zh-TW")}</span></div><Markdown content={post.content} tags={tags} compact /><Attachments json={post.attachments_json} sourceBaseUrl={post.source_base_url} compact /><div className="row">{tags.map((tag) => <Link className="tag" href={`/tags/${encodeURIComponent(tag)}`} key={tag}>#{tag}</Link>)}{post.source_id && <Link className="tag" href={`/sources/${post.source_id}`}>來源</Link>}<Link href={`/posts/${post.id}`}>閱讀全文 · 💬 {post.comment_count} 🙂 {post.reaction_count}</Link></div></article>;
|
||||
const publishedAt = post.remote_created_at || post.created_at; const author = post.origin === "memos" ? (post.remote_display_name || post.name || post.username) : (post.name || post.username); return <article className="card"><div className="space"><Link className="meta post-name-link" href={`/posts/${post.id}`}>@{author}{post.name ? ` · ${post.name}` : ""}</Link><span className="meta">{new Date(publishedAt).toLocaleString("zh-TW")}</span></div><Markdown content={post.content} tags={tags} compact /><Attachments json={post.attachments_json} sourceBaseUrl={post.source_base_url} compact /><div className="row">{tags.map((tag) => <Link className="tag" href={`/tags/${encodeURIComponent(tag)}`} key={tag}>#{tag}</Link>)}{post.source_id && <Link className="tag" href={`/sources/${post.source_id}`}>來源</Link>}<Link href={`/posts/${post.id}`}>閱讀全文 · 💬 {post.comment_count} 🙂 {post.reaction_count}</Link></div></article>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
export function InviteControl({ sourceId }: { sourceId: number }) {
|
||||
const [role, setRole] = useState("viewer"); const [url, setUrl] = useState(""); const [error, setError] = useState("");
|
||||
async function create() { setError(""); const response = await fetch(`/api/sources/${sourceId}/invites`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ role }) }); const body = await response.json(); if (!response.ok) setError(body.error || "無法建立邀請"); else setUrl(body.url); }
|
||||
return <div><label>邀請角色<select value={role} onChange={(event) => setRole(event.target.value)}><option value="viewer">檢視者</option><option value="editor">編輯者</option></select></label><button type="button" onClick={create}>建立 7 天一次性邀請連結</button>{url && <><input readOnly value={url} onFocus={(event) => event.currentTarget.select()} /><button type="button" onClick={() => navigator.clipboard.writeText(url)}>複製連結</button></>}{error && <p className="error">{error}</p>}</div>;
|
||||
}
|
||||
+16
-10
@@ -3,34 +3,40 @@ import { getSession } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { PublishForm } from "./publish-form";
|
||||
import { WebhookControl } from "./webhook-control";
|
||||
import { InviteControl } from "./invite-control";
|
||||
import { SyncPreview } from "./sync-preview";
|
||||
|
||||
type Source = { id: number; name: string; base_url: string; sync_status: string; last_synced_at: string | null; last_error: string | null; webhook_secret_hash: string | null; last_webhook_at: string | null; owner_id: number; is_enabled: number; disabled_at: string | null; sync_tags_json: string; sync_from: string | null; sync_to: string | null; sync_attachment_mode: "all" | "images" | "none"; attachment_storage_mode: "remote" | "images" | "all"; attachment_cache_limit_bytes: number; attachment_cache_error: string | null; remote_display_name: string | null; remote_avatar_url: string | null; last_connection_at: string | null; last_connection_error: string | null };
|
||||
type Source = { id: number; name: string; base_url: string; integration_type: "memos" | "rss"; rss_feed_url: string | null; sync_status: string; last_synced_at: string | null; last_error: string | null; webhook_secret_hash: string | null; webhook_mode: string; last_webhook_at: string | null; owner_id: number; membership_role: "owner" | "editor" | "viewer"; is_enabled: number; disabled_at: string | null; sync_tags_json: string; sync_from: string | null; sync_to: string | null; sync_attachment_mode: "all" | "images" | "none"; sync_batch_size: number; sync_max_posts: number | null; sync_cursor: string | null; sync_imported_count: number; attachment_storage_mode: "remote" | "images" | "all"; attachment_cache_limit_bytes: number; attachment_archive_after_days: number | null; attachment_cache_error: string | null; remote_display_name: string | null; remote_avatar_url: string | null; last_connection_at: string | null; last_connection_error: string | null };
|
||||
type Job = { id: number; kind: string; trigger: string | null; status: string; attempts: number; last_error: string | null; created_at: string; finished_at: string | null };
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Dashboard({ searchParams }: { searchParams: Promise<{ error?: string; source?: string; sync?: string }> }) {
|
||||
const query = await searchParams; const user = await getSession(); if (!user) redirect("/login");
|
||||
const sourceRows = db.prepare("SELECT s.id,s.name,s.base_url,s.sync_status,s.last_synced_at,s.last_error,s.webhook_secret_hash,s.last_webhook_at,s.user_id AS owner_id,s.is_enabled,s.disabled_at,s.sync_tags_json,s.sync_from,s.sync_to,s.sync_attachment_mode,s.attachment_storage_mode,s.attachment_cache_limit_bytes,s.attachment_cache_error,s.remote_display_name,s.remote_avatar_url,s.last_connection_at,s.last_connection_error FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE sm.user_id=? ORDER BY s.id DESC").all(user.id) as Source[];
|
||||
const sourceRows = db.prepare("SELECT s.id,s.name,s.base_url,s.integration_type,s.rss_feed_url,s.sync_status,s.last_synced_at,s.last_error,s.webhook_secret_hash,s.webhook_mode,s.last_webhook_at,s.user_id AS owner_id,sm.role AS membership_role,s.is_enabled,s.disabled_at,s.sync_tags_json,s.sync_from,s.sync_to,s.sync_attachment_mode,s.sync_batch_size,s.sync_max_posts,s.sync_cursor,s.sync_imported_count,s.attachment_storage_mode,s.attachment_cache_limit_bytes,s.attachment_archive_after_days,s.attachment_cache_error,s.remote_display_name,s.remote_avatar_url,s.last_connection_at,s.last_connection_error FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE sm.user_id=? ORDER BY s.id DESC").all(user.id) as Source[];
|
||||
const sources = sourceRows.map((source) => ({ ...source, syncTags: (() => { try { return JSON.parse(source.sync_tags_json) as string[]; } catch { return []; } })(), members: db.prepare("SELECT u.username,u.id,sm.role FROM source_members sm JOIN users u ON u.id=sm.user_id WHERE sm.source_id=? ORDER BY sm.role DESC,u.username").all(source.id) as { username: string; id: number; role: string }[], jobs: db.prepare("SELECT id,kind,trigger,status,attempts,last_error,created_at,finished_at FROM sync_jobs WHERE source_id=? ORDER BY id DESC LIMIT 5").all(source.id) as Job[] }));
|
||||
const publishSources = sources.filter((source) => source.is_enabled);
|
||||
return <>
|
||||
const publishSources = sources.filter((source) => source.is_enabled && source.integration_type === "memos" && ["owner", "editor"].includes(source.membership_role));
|
||||
return <><style>{`form:has(input[name="action"][value="rename"]){display:none}`}</style>
|
||||
<h1>控制台</h1>
|
||||
{query.error && <p className="error">{query.error}</p>}
|
||||
{query.source === "shared" ? <p>你已加入既有的共享 Memos 來源,不會重複同步貼文。</p> : query.source === "updated" ? <p>來源設定已更新。</p> : query.source && <p>來源已連接,首次同步已排入佇列。</p>}
|
||||
{query.sync === "queued" && <p>同步已排入佇列。</p>}{query.sync === "already-queued" && <p className="muted">此來源已有同步工作處理中,不重複排入。</p>}
|
||||
<section className="card"><h2>發佈到自己的 Memos</h2>{publishSources.length ? <PublishForm sources={publishSources} /> : <p className="muted">請先連接並啟用一個 Memos 來源。</p>}</section>
|
||||
<section className="card"><h2>連接 Memos</h2><form action="/api/sources" method="post"><label>顯示名稱<input name="name" required placeholder="我的 Memos" /></label><label>Memos 網址<input name="baseUrl" type="url" required placeholder="https://memos.example.com" /></label><label>Personal Access Token<input name="token" type="password" required /></label><button>驗證並連接</button></form><p className="muted">Token 會使用伺服器金鑰加密保存。同一個 Memos 帳號與網址會自動共用來源,不會建立重複貼文。</p></section>
|
||||
<section className="card"><h2>連接 Memos</h2><form action="/api/sources" method="post"><label>Memos 網址<input name="baseUrl" type="url" required placeholder="https://memos.example.com" /></label><label>Personal Access Token<input name="token" type="password" required /></label><button>驗證並連接</button></form><p className="muted">來源名稱會自動使用 API Key 對應的 Memos 帳號。Token 會使用伺服器金鑰加密保存;同一個 Memos 帳號與網址會自動共用來源,不會建立重複貼文。</p></section>
|
||||
<section className="card"><h2>連接 RSS</h2><form action="/api/sources/rss" method="post"><label>RSS Feed 網址<input name="feedUrl" type="url" required placeholder="https://example.com/feed.xml" /></label><button>驗證並訂閱</button></form><p className="muted">RSS 為唯讀來源,不需要 API Key,也不會回寫原網站。同步時會更新 Feed 內公開的項目。</p></section>
|
||||
<section><h2>已連接來源</h2>{sources.map((source) => <article className="card" key={source.id}>
|
||||
<div className="space"><strong>{source.name}</strong><span className="tag">{source.is_enabled ? source.sync_status : "disabled"}</span></div>
|
||||
<p className="meta">來源 ID:{source.id}<br />{source.base_url}{source.remote_display_name && <><br />Memos 帳號:{source.remote_avatar_url && <img className="avatar" src={source.remote_avatar_url} alt="" />} {source.remote_display_name}</>}<br />成員:{source.members.map((member) => `${member.username}${member.role === "owner" ? "(建立者)" : ""}`).join("、")}<br />上次同步:{source.last_synced_at || "尚未完成"}<br />附件保存:{source.attachment_storage_mode === "remote" ? "遠端連結" : source.attachment_storage_mode === "images" ? "只快取圖片" : "完整備份"}(配額 {Math.round(source.attachment_cache_limit_bytes / 1024 / 1024)} MiB)<br />連線:{source.last_connection_at ? `最近成功:${new Date(source.last_connection_at + "Z").toLocaleString("zh-TW")}` : "尚未測試"}<br />Webhook:{source.webhook_secret_hash ? (source.last_webhook_at ? (Date.now() - new Date(source.last_webhook_at + "Z").getTime() > 7 * 24 * 60 * 60 * 1000 ? `警示:超過 7 天未收到(最近:${new Date(source.last_webhook_at + "Z").toLocaleString("zh-TW")})` : `健康(最近收到:${new Date(source.last_webhook_at + "Z").toLocaleString("zh-TW")})`) : "已建立 URL,尚未收到呼叫") : "尚未建立 URL"}{!source.is_enabled && <><br />已停用:{source.disabled_at ? new Date(source.disabled_at + "Z").toLocaleString("zh-TW") : "是"}</>}{source.last_error && <><br /><span className="error">同步:{source.last_error}</span></>}{source.last_connection_error && <><br /><span className="error">連線:{source.last_connection_error}</span></>}{source.attachment_cache_error && <><br /><span className="error">附件快取:{source.attachment_cache_error}</span></>}</p>
|
||||
<p className="meta">來源 ID:{source.id}<br />{source.integration_type === "rss" ? source.rss_feed_url : source.base_url}{source.remote_display_name && <><br />Memos 帳號:{source.remote_avatar_url && <img className="avatar" src={source.remote_avatar_url} alt="" />} {source.remote_display_name}</>}<br />成員:{source.members.map((member) => `${member.username}${member.role === "owner" ? "(建立者)" : ""}`).join("、")}<br />上次同步:{source.last_synced_at || "尚未完成"}{source.sync_cursor && <>(批次匯入中:{source.sync_imported_count} 篇)</>}<br />{source.integration_type === "memos" && <>附件保存:{source.attachment_storage_mode === "remote" ? "遠端連結" : source.attachment_storage_mode === "images" ? "只快取圖片" : "完整備份"}(配額 {Math.round(source.attachment_cache_limit_bytes / 1024 / 1024)} MiB)<br />連線:{source.last_connection_at ? `最近成功:${new Date(source.last_connection_at + "Z").toLocaleString("zh-TW")}` : "尚未測試"}<br />Webhook:{source.webhook_secret_hash ? (source.last_webhook_at ? (Date.now() - new Date(source.last_webhook_at + "Z").getTime() > 7 * 24 * 60 * 60 * 1000 ? `警示:超過 7 天未收到(最近:${new Date(source.last_webhook_at + "Z").toLocaleString("zh-TW")})` : `健康(最近收到:${new Date(source.last_webhook_at + "Z").toLocaleString("zh-TW")})`) : "已建立 URL,尚未收到呼叫") : "尚未建立 URL"}</>}{!source.is_enabled && <><br />已停用:{source.disabled_at ? new Date(source.disabled_at + "Z").toLocaleString("zh-TW") : "是"}</>}{source.last_error && <><br /><span className="error">同步:{source.last_error}</span></>}{source.last_connection_error && <><br /><span className="error">連線:{source.last_connection_error}</span></>}{source.attachment_cache_error && <><br /><span className="error">附件快取:{source.attachment_cache_error}</span></>}</p>
|
||||
{source.owner_id === user.id ? <>
|
||||
<WebhookControl sourceId={source.id} configured={Boolean(source.webhook_secret_hash)} />
|
||||
<form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="set-attachment-storage" /><label>附件保存策略<select name="mode" defaultValue={source.attachment_storage_mode}><option value="remote">遠端連結(不佔 Hub 空間)</option><option value="images">只快取圖片</option><option value="all">完整備份附件</option></select></label><label>快取配額(MiB)<input name="quotaMiB" type="number" min="10" max="10240" defaultValue={Math.round(source.attachment_cache_limit_bytes / 1024 / 1024)} /></label><button>儲存附件策略並同步</button></form>
|
||||
<details><summary>來源管理</summary><form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="rename" /><label>顯示名稱<input name="name" defaultValue={source.name} required maxLength={80} /></label><button>儲存名稱</button></form><form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="set-sync-rules" /><label>只同步標籤(逗號分隔,留白為全部)<input name="tags" defaultValue={source.syncTags.join(", ")} placeholder="旅行, 技術" /></label><div className="row"><label>開始日期<input name="from" type="date" defaultValue={source.sync_from || ""} /></label><label>結束日期<input name="to" type="date" defaultValue={source.sync_to || ""} /></label></div><label>附件<select name="attachmentMode" defaultValue={source.sync_attachment_mode}><option value="all">同步全部附件</option><option value="images">僅同步圖片</option><option value="none">不同步附件</option></select></label><button>儲存同步規則並同步</button></form><form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="test-connection" /><button>測試 Memos 連線</button></form><form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="set-enabled" /><input type="hidden" name="enabled" value={source.is_enabled ? "0" : "1"} /><button className={source.is_enabled ? "danger" : ""}>{source.is_enabled ? "停用來源" : "啟用來源"}</button></form>{source.members.length > 1 && <form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="transfer" /><label>轉移建立者<select name="username" required defaultValue=""> <option value="" disabled>選擇成員</option>{source.members.filter((member) => member.id !== user.id).map((member) => <option key={member.id} value={member.username}>{member.username}</option>)}</select></label><button>轉移所有權</button></form>}<form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="delete" /><button className="danger">刪除來源與遠端鏡像貼文</button></form></details>
|
||||
{source.integration_type === "memos" && <WebhookControl sourceId={source.id} configured={Boolean(source.webhook_secret_hash)} automatic={source.webhook_mode === "signed"} />}
|
||||
{source.integration_type === "memos" && <SyncPreview sourceId={source.id} />}
|
||||
<InviteControl sourceId={source.id} />
|
||||
<form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="retry-failed-jobs" /><button>批次重試失敗同步</button></form>
|
||||
<form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="set-attachment-storage" /><label>附件保存策略<select name="mode" defaultValue={source.attachment_storage_mode}><option value="remote">遠端連結(不佔 Hub 空間)</option><option value="images">只快取圖片</option><option value="all">完整備份附件</option></select></label><label>快取配額(MiB)<input name="quotaMiB" type="number" min="10" max="10240" defaultValue={Math.round(source.attachment_cache_limit_bytes / 1024 / 1024)} /></label><label>快取封存天數(0 為不封存)<input name="archiveAfterDays" type="number" min="0" max="3650" defaultValue={source.attachment_archive_after_days || 0} /></label><button>儲存附件策略並同步</button></form>
|
||||
<details><summary>來源管理</summary><form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="rename" /><label>顯示名稱<input name="name" defaultValue={source.name} required maxLength={80} /></label><button>儲存名稱</button></form><form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="set-sync-rules" /><label>只同步標籤(逗號分隔,留白為全部)<input name="tags" defaultValue={source.syncTags.join(", ")} placeholder="旅行, 技術" /></label><div className="row"><label>開始日期<input name="from" type="date" defaultValue={source.sync_from || ""} /></label><label>結束日期<input name="to" type="date" defaultValue={source.sync_to || ""} /></label></div><label>附件<select name="attachmentMode" defaultValue={source.sync_attachment_mode}><option value="all">同步全部附件</option><option value="images">僅同步圖片</option><option value="none">不同步附件</option></select></label><div className="row"><label>每批貼文數<input name="batchSize" type="number" min="10" max="100" defaultValue={source.sync_batch_size} /></label><label>首次匯入上限(0 為不限)<input name="maxPosts" type="number" min="0" max="100000" defaultValue={source.sync_max_posts || 0} /></label></div><button>儲存同步規則並從頭批次同步</button></form><form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="test-connection" /><button>測試 Memos 連線</button></form><form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="set-enabled" /><input type="hidden" name="enabled" value={source.is_enabled ? "0" : "1"} /><button className={source.is_enabled ? "danger" : ""}>{source.is_enabled ? "停用來源" : "啟用來源"}</button></form>{source.members.length > 1 && <form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="transfer" /><label>轉移建立者<select name="username" required defaultValue=""> <option value="" disabled>選擇成員</option>{source.members.filter((member) => member.id !== user.id).map((member) => <option key={member.id} value={member.username}>{member.username}</option>)}</select></label><button>轉移所有權</button></form>}<form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="delete" /><button className="danger">刪除來源與遠端鏡像貼文</button></form></details>
|
||||
</> : <form action={`/api/sources/${source.id}/manage`} method="post"><input type="hidden" name="action" value="leave" /><button className="danger">離開共享來源</button></form>}
|
||||
<form action="/api/sync" method="post"><input type="hidden" name="sourceId" value={source.id} /><button disabled={!source.is_enabled}>{source.is_enabled ? "立即同步" : "來源已停用"}</button></form>
|
||||
<details><summary>最近同步工作</summary>{source.jobs.length ? <ul className="job-list">{source.jobs.map((job) => <li key={job.id}><strong>{job.kind}</strong> · {job.trigger || "legacy"} · <span className="tag">{job.status}</span> · 嘗試 {job.attempts} 次<br /><span className="meta">建立:{new Date(job.created_at + "Z").toLocaleString("zh-TW")}{job.finished_at && `;完成:${new Date(job.finished_at + "Z").toLocaleString("zh-TW")}`}</span>{job.last_error && <><br /><span className="error">{job.last_error}</span></>}</li>)}</ul> : <p className="muted">尚無同步工作。</p>}</details>
|
||||
<details><summary>最近同步工作</summary>{source.jobs.length ? <ul className="job-list">{source.jobs.map((job) => <li key={job.id}><strong>{job.kind}</strong> · {job.trigger || "legacy"} · <span className="tag">{job.status}</span> · 嘗試 {job.attempts} 次<br /><span className="meta">建立:{new Date(job.created_at + "Z").toLocaleString("zh-TW")}{job.finished_at && `;完成:${new Date(job.finished_at + "Z").toLocaleString("zh-TW")}`}</span>{job.last_error && <><br /><span className="error">{job.last_error}</span></>}{job.status === "failed" && <form action={`/api/sync/jobs/${job.id}/retry`} method="post"><button>重試此工作</button></form>}</li>)}</ul> : <p className="muted">尚無同步工作。</p>}</details>
|
||||
</article>)}</section>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
export function SyncPreview({ sourceId }: { sourceId: number }) { const [text, setText] = useState(""); async function preview() { setText("讀取中…"); const response = await fetch(`/api/sources/${sourceId}/preview`); const body = await response.json(); setText(response.ok ? `本頁預覽:新增 ${body.added}、已存在 ${body.existing},共檢查 ${body.inspected} 篇${body.hasMore ? "(來源尚有更多頁)" : ""}` : body.error || "無法預覽"); } return <div><button type="button" onClick={preview}>預覽同步差異</button>{text && <p className="meta">{text}</p>}</div>; }
|
||||
@@ -2,17 +2,18 @@
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function WebhookControl({ sourceId, configured }: { sourceId: number; configured: boolean }) {
|
||||
const [url, setUrl] = useState(""); const [error, setError] = useState(""); const [busy, setBusy] = useState(false);
|
||||
export function WebhookControl({ sourceId, configured, automatic = false }: { sourceId: number; configured: boolean; automatic?: boolean }) {
|
||||
const [url, setUrl] = useState(""); const [error, setError] = useState(""); const [notice, setNotice] = useState(""); const [busy, setBusy] = useState(false);
|
||||
async function generate() {
|
||||
setBusy(true); setError("");
|
||||
setBusy(true); setError(""); setNotice("");
|
||||
try {
|
||||
const response = await fetch(`/api/sources/${sourceId}/webhook`, { method: "POST", headers: { Accept: "application/json" } });
|
||||
const body = await response.json(); if (!response.ok) throw new Error(body.error || "無法產生 webhook URL"); setUrl(body.url);
|
||||
const body = await response.json(); if (!response.ok) throw new Error(body.error || "無法產生 webhook URL"); if (body.rotated) setNotice("Webhook 已安全輪替。"); else setUrl(body.url);
|
||||
} catch (reason) { setError(reason instanceof Error ? reason.message : "無法產生 webhook URL"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
async function copy() { if (url) await navigator.clipboard.writeText(url); }
|
||||
if (automatic) return <div className="webhook-control"><p className="meta">Webhook:已由 Memos 自動設定並驗證簽章。</p><button type="button" onClick={generate} disabled={busy}>{busy ? "輪替中…" : "安全輪替 Webhook"}</button>{notice && <p>{notice}</p>}{error && <p className="error">{error}</p>}</div>;
|
||||
return <div className="webhook-control"><p className="meta">Webhook:{configured ? "已設定" : "尚未設定"}</p>
|
||||
{url ? <><label className="sr-only" htmlFor={`webhook-${sourceId}`}>Webhook URL</label><input id={`webhook-${sourceId}`} readOnly value={url} onFocus={(event) => event.currentTarget.select()} /><div className="row"><button type="button" onClick={copy}>複製 URL</button><button type="button" className="danger" onClick={generate} disabled={busy}>重新產生</button></div><p className="meta">請立即複製到 Memos;重新整理後完整密鑰不會再顯示。</p></> : <button type="button" onClick={generate} disabled={busy}>{busy ? "產生中…" : configured ? "重新產生 webhook URL" : "產生 webhook URL"}</button>}
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/auth";
|
||||
export default async function InvitePage({ params }: { params: Promise<{ token: string }> }) { const { token } = await params; const user = await getSession(); if (!user) redirect("/login"); return <section className="card"><h1>加入來源</h1><p>以目前登入帳號 @{user.username} 接受此一次性邀請。</p><form action="/api/invites/accept" method="post"><input type="hidden" name="token" value={token} /><button>接受邀請</button></form></section>; }
|
||||
+1
-1
@@ -12,7 +12,7 @@ export default async function Home({ searchParams }: { searchParams: Promise<Que
|
||||
if (q) { where.push("p.content LIKE ?"); args.push(`%${q}%`); } if (tag) { where.push("p.tags_json LIKE ?"); args.push(`%${JSON.stringify(tag).slice(1, -1)}%`); } if (author) { where.push("u.username LIKE ?"); args.push(`%${author}%`); } if (sourceId) { where.push("s.id=?"); args.push(sourceId); } if (from) { where.push("date(COALESCE(p.remote_created_at,p.created_at)) >= date(?)"); args.push(from); } if (to) { where.push("date(COALESCE(p.remote_created_at,p.created_at)) <= date(?)"); args.push(to); } if (attachments) where.push("p.attachments_json <> '[]'");
|
||||
const joins = " FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id "; const predicate = ` WHERE ${where.join(" AND ")}`;
|
||||
const total = Number((db.prepare(`SELECT count(*) count${joins}${predicate}`).get(...args) as { count: number }).count); const pages = Math.max(1, Math.ceil(total / pageSize)); const safePage = Math.min(page, pages);
|
||||
const posts = db.prepare(`SELECT p.*,u.username,s.name,s.base_url AS source_base_url,(SELECT count(*) FROM comments c WHERE c.post_id=p.id AND c.hidden=0) comment_count,(SELECT count(*) FROM reactions r WHERE r.post_id=p.id) reaction_count${joins}${predicate} ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT ? OFFSET ?`).all(...args, pageSize, (safePage - 1) * pageSize) as PublicPost[];
|
||||
const posts = db.prepare(`SELECT p.*,u.username,s.name,s.remote_display_name,s.base_url AS source_base_url,(SELECT count(*) FROM comments c WHERE c.post_id=p.id AND c.hidden=0) comment_count,(SELECT count(*) FROM reactions r WHERE r.post_id=p.id) reaction_count${joins}${predicate} ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT ? OFFSET ?`).all(...args, pageSize, (safePage - 1) * pageSize) as PublicPost[];
|
||||
const sources = db.prepare("SELECT id,name FROM sources WHERE is_enabled=1 ORDER BY name").all() as { id: number; name: string }[];
|
||||
const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) if (value && key !== "page") params.set(key, value); const pageHref = (target: number) => { const next = new URLSearchParams(params); next.set("page", String(target)); return `/?${next}`; };
|
||||
return <><section className="space"><div><h1>公開 Memos Hub</h1><p className="muted">聚合朋友們公開分享的筆記。</p></div><Link className="button" href="/dashboard">發佈/連接來源</Link></section><form className="search-form" method="get"><input name="q" defaultValue={q} placeholder="搜尋公開貼文" /><input name="tag" defaultValue={tag} placeholder="標籤" /><input name="author" defaultValue={author} placeholder="作者" /><select name="source" defaultValue={sourceId || ""}><option value="">所有來源</option>{sources.map((source) => <option key={source.id} value={source.id}>{source.name}</option>)}</select><label>從<input name="from" type="date" defaultValue={from} /></label><label>到<input name="to" type="date" defaultValue={to} /></label><label className="check"><input name="attachments" type="checkbox" value="1" defaultChecked={attachments} />只看附件</label><button>搜尋</button></form><p className="meta">共 {total} 篇公開貼文</p>{posts.length ? posts.map((post) => <PostCard post={post} key={post.id} />) : <p className="muted">尚無符合的公開貼文。</p>}{pages > 1 && <nav className="pagination" aria-label="貼文分頁">{safePage > 1 && <Link href={pageHref(safePage - 1)}>← 上一頁</Link>}<span>第 {safePage}/{pages} 頁</span>{safePage < pages && <Link href={pageHref(safePage + 1)}>下一頁 →</Link>}</nav>}</>;
|
||||
|
||||
@@ -23,7 +23,7 @@ export default async function PostPage({ params, searchParams }: { params: Promi
|
||||
const bookmark = user ? db.prepare("SELECT kind FROM bookmarks WHERE user_id=? AND post_id=?").get(user.id, id) as { kind: string } | undefined : undefined;
|
||||
const comments = db.prepare("SELECT c.*,u.username FROM comments c JOIN users u ON u.id=c.author_id WHERE c.post_id=? AND c.hidden=0 ORDER BY c.created_at").all(id) as any[];
|
||||
const reactions = db.prepare("SELECT emoji,count(*) count FROM reactions WHERE post_id=? GROUP BY emoji").all(id) as any[];
|
||||
let tags: string[] = []; try { tags = canonicalTags(JSON.parse(post.tags_json)); } catch {} return <article><p className="meta">@{post.username} · {post.remote_display_name || post.name || "Hub"} · {new Date(post.created_at).toLocaleString("zh-TW")}{post.remote_url && <> · <a href={post.remote_url} target="_blank" rel="noreferrer">在 Memos 開啟</a></>}</p><section className="card"><Markdown content={post.content} tags={tags} /></section><Attachments json={post.attachments_json} sourceBaseUrl={post.source_base_url} />
|
||||
let tags: string[] = []; try { tags = canonicalTags(JSON.parse(post.tags_json)); } catch {} const publishedAt = post.remote_created_at || post.created_at; const canExport = Boolean(user && (post.author_id === user.id || (post.source_id && db.prepare("SELECT 1 FROM source_members WHERE source_id=? AND user_id=?").get(post.source_id, user.id)))); return <article><p className="meta">@{post.username} · {post.remote_display_name || post.name || "Hub"} · {new Date(publishedAt).toLocaleString("zh-TW")}{post.remote_url && <> · <a href={post.remote_url} target="_blank" rel="noreferrer">在 Memos 開啟</a></>}</p>{canExport && <p className="row"><a href={`/api/export/posts/${id}?format=json`}>匯出 JSON</a><a href={`/api/export/posts/${id}?format=markdown`}>匯出 Markdown</a></p>}<section className="card"><Markdown content={post.content} tags={tags} /></section><Attachments json={post.attachments_json} sourceBaseUrl={post.source_base_url} />
|
||||
<section className="row">{reactions.map((reaction: any) => <span className="tag" key={reaction.emoji}>{reaction.emoji} {reaction.count}</span>)}{user && <><form action="/api/bookmarks" method="post"><input type="hidden" name="postId" value={id} /><input type="hidden" name="kind" value="saved" /><button>{bookmark?.kind === "saved" ? "取消收藏" : "收藏"}</button></form><form action="/api/bookmarks" method="post"><input type="hidden" name="postId" value={id} /><input type="hidden" name="kind" value="later" /><button>{bookmark?.kind === "later" ? "取消稍後閱讀" : "稍後閱讀"}</button></form></>}{user && ["👍", "❤️", "🎉", "🤔"].map((emoji) => <form action="/api/reactions" method="post" key={emoji}><input type="hidden" name="postId" value={id} /><input type="hidden" name="emoji" value={emoji} /><button>{emoji}</button></form>)}</section>
|
||||
<section><h2>留言</h2>{user ? <><form action="/api/comments" method="post"><input type="hidden" name="postId" value={id} /><textarea name="content" required placeholder="在 Hub 留下留言" /><button>送出留言</button></form><details><summary>檢舉這篇貼文</summary>{query.reported && <p>已收到檢舉,管理員會審核。</p>}<form action="/api/reports" method="post"><input type="hidden" name="postId" value={id} /><label>原因<input name="reason" required minLength={3} maxLength={500} /></label><button className="danger">送出檢舉</button></form></details></> : <p>請先登入以留言、互動或檢舉。</p>}{comments.map((comment) => <div className="card" key={comment.id}><strong>@{comment.username}</strong><p>{comment.content}</p><span className="meta">{new Date(comment.created_at).toLocaleString("zh-TW")}</span></div>)}</section>
|
||||
</article>;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { db } from "@/lib/db";
|
||||
|
||||
const escapeXml = (value: string) => value.replace(/[<>&'\"]/g, (char) => ({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[char] || char));
|
||||
export async function GET() {
|
||||
const origin = (process.env.NEXT_PUBLIC_APP_URL || "http://localhost:8088").replace(/\/$/, ""); const posts = db.prepare("SELECT p.id,p.content,p.created_at,u.username FROM posts p JOIN users u ON u.id=p.author_id WHERE p.visibility='PUBLIC' AND p.hidden=0 ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 50").all() as { id: number; content: string; created_at: string; username: string }[];
|
||||
const items = posts.map((post) => `<item><title>${escapeXml(`@${post.username} 的貼文`)}</title><link>${origin}/posts/${post.id}</link><guid>${origin}/posts/${post.id}</guid><description>${escapeXml(post.content.slice(0, 500))}</description><pubDate>${new Date(post.created_at + "Z").toUTCString()}</pubDate></item>`).join("");
|
||||
const origin = (process.env.NEXT_PUBLIC_APP_URL || "http://localhost:8088").replace(/\/$/, ""); const posts = db.prepare("SELECT p.id,p.content,COALESCE(p.remote_created_at,p.created_at) AS published_at,u.username FROM posts p JOIN users u ON u.id=p.author_id WHERE p.visibility='PUBLIC' AND p.hidden=0 ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 50").all() as { id: number; content: string; published_at: string; username: string }[];
|
||||
const items = posts.map((post) => `<item><title>${escapeXml(`@${post.username} 的貼文`)}</title><link>${origin}/posts/${post.id}</link><guid>${origin}/posts/${post.id}</guid><description>${escapeXml(post.content.slice(0, 500))}</description><pubDate>${new Date(post.published_at).toUTCString()}</pubDate></item>`).join("");
|
||||
return new Response(`<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>Mebbling</title><link>${origin}</link><description>公開 Memos Hub</description>${items}</channel></rss>`, { headers: { "Content-Type": "application/rss+xml; charset=utf-8", "Cache-Control": "public, max-age=300" } });
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { db } from "@/lib/db";
|
||||
import { PostCard, type PublicPost } from "@/app/components/post-card";
|
||||
import { getSession } from "@/lib/auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export default async function SourcePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id: rawId } = await params; const id = Number(rawId); const source = db.prepare("SELECT id,name,base_url,remote_display_name,remote_avatar_url FROM sources WHERE id=?").get(id) as { id: number; name: string; base_url: string; remote_display_name: string | null; remote_avatar_url: string | null } | undefined; if (!source) notFound();
|
||||
const posts = db.prepare("SELECT p.*,u.username,s.name,s.base_url AS source_base_url,(SELECT count(*) FROM comments c WHERE c.post_id=p.id AND c.hidden=0) comment_count,(SELECT count(*) FROM reactions r WHERE r.post_id=p.id) reaction_count FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.source_id=? AND p.visibility='PUBLIC' AND p.hidden=0 ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 100").all(id) as PublicPost[];
|
||||
return <><p><Link href="/">← 探索</Link></p><h1>{source.name}</h1><p className="meta">{source.remote_avatar_url && <img className="avatar" src={source.remote_avatar_url} alt="" />} {source.remote_display_name || "Memos"}<br />{source.base_url} · {posts.length} 篇公開貼文</p>{posts.map((post) => <PostCard key={post.id} post={post} />)}</>;
|
||||
const posts = db.prepare("SELECT p.*,u.username,s.name,s.remote_display_name,s.base_url AS source_base_url,(SELECT count(*) FROM comments c WHERE c.post_id=p.id AND c.hidden=0) comment_count,(SELECT count(*) FROM reactions r WHERE r.post_id=p.id) reaction_count FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.source_id=? AND p.visibility='PUBLIC' AND p.hidden=0 ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 100").all(id) as PublicPost[];
|
||||
const user = await getSession(); const member = Boolean(user && db.prepare("SELECT 1 FROM source_members WHERE source_id=? AND user_id=?").get(id, user.id));
|
||||
return <><p><Link href="/">← 探索</Link></p><h1>{source.name}</h1><p className="meta">{source.remote_avatar_url && <img className="avatar" src={source.remote_avatar_url} alt="" />} {source.remote_display_name || "Memos"}<br />{source.base_url} · {posts.length} 篇公開貼文</p>{member && <p className="row"><a href={`/api/export/sources/${id}?format=json`}>匯出來源 JSON</a><a href={`/api/export/sources/${id}?format=markdown`}>匯出來源 Markdown</a></p>}{posts.map((post) => <PostCard key={post.id} post={post} />)}</>;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@ import { PostCard, type PublicPost } from "@/app/components/post-card";
|
||||
export const dynamic = "force-dynamic";
|
||||
export default async function TagPage({ params }: { params: Promise<{ tag: string }> }) {
|
||||
const { tag: encoded } = await params; const tag = decodeURIComponent(encoded).trim(); if (!tag) notFound();
|
||||
const posts = db.prepare("SELECT p.*,u.username,s.name,s.base_url AS source_base_url,(SELECT count(*) FROM comments c WHERE c.post_id=p.id AND c.hidden=0) comment_count,(SELECT count(*) FROM reactions r WHERE r.post_id=p.id) reaction_count FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.visibility='PUBLIC' AND p.hidden=0 AND p.tags_json LIKE ? ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 100").all(`%${JSON.stringify(tag).slice(1, -1)}%`) as PublicPost[];
|
||||
const posts = db.prepare("SELECT p.*,u.username,s.name,s.remote_display_name,s.base_url AS source_base_url,(SELECT count(*) FROM comments c WHERE c.post_id=p.id AND c.hidden=0) comment_count,(SELECT count(*) FROM reactions r WHERE r.post_id=p.id) reaction_count FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.visibility='PUBLIC' AND p.hidden=0 AND p.tags_json LIKE ? ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 100").all(`%${JSON.stringify(tag).slice(1, -1)}%`) as PublicPost[];
|
||||
return <><p><Link href="/">← 探索</Link></p><h1>#{tag}</h1><p className="muted">{posts.length} 篇公開貼文</p>{posts.map((post) => <PostCard key={post.id} post={post} />)}</>;
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
services:
|
||||
web:
|
||||
image: mebbling:${MEBBLING_VERSION:-0.6.0}
|
||||
image: mebbling:${MEBBLING_VERSION:-0.7.0}
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
APP_VERSION: "${MEBBLING_VERSION:-0.6.0}"
|
||||
APP_VERSION: "${MEBBLING_VERSION:-0.7.0}"
|
||||
ports: ["8088:3000"]
|
||||
env_file: .env
|
||||
environment: { DATABASE_PATH: /app/data/hub.db }
|
||||
@@ -13,11 +13,11 @@ services:
|
||||
- ./public/uploads:/app/public/uploads
|
||||
restart: unless-stopped
|
||||
worker:
|
||||
image: mebbling:${MEBBLING_VERSION:-0.6.0}
|
||||
image: mebbling:${MEBBLING_VERSION:-0.7.0}
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
APP_VERSION: "${MEBBLING_VERSION:-0.6.0}"
|
||||
APP_VERSION: "${MEBBLING_VERSION:-0.7.0}"
|
||||
command: npm run worker
|
||||
env_file: .env
|
||||
environment: { DATABASE_PATH: /app/data/hub.db }
|
||||
|
||||
+28
-6
@@ -13,15 +13,27 @@
|
||||
- `hub.db`:由正在執行的 SQLite 資料庫建立的一致性備份。
|
||||
- `uploads.tar.gz`:Hub 本機上傳的附件。
|
||||
|
||||
`data/backups/` 已由 Git 排除。請將備份複製到另一台主機或加密的雲端儲存;只留在同一台機器不算完整備份。
|
||||
每次備份都會執行 SQLite `integrity_check`、驗證附件壓縮檔,並在 `SHA256SUMS` 記錄雜湊。`data/backups/` 已由 Git 排除。
|
||||
|
||||
可設定保留與異地複製(例如掛載的 NAS、加密磁碟或 rclone 掛載點):
|
||||
|
||||
```bash
|
||||
BACKUP_RETENTION_DAYS=30 BACKUP_OFFSITE_DIR=/mnt/nas/mebbling ./scripts/backup.sh
|
||||
```
|
||||
|
||||
在 WSL 主機安裝每日 03:15 排程:
|
||||
|
||||
```bash
|
||||
./scripts/install-backup-cron.sh
|
||||
```
|
||||
|
||||
## 還原
|
||||
|
||||
1. 停止服務:`docker compose down`。
|
||||
2. 備份目前的 `data/hub.db` 與 `public/uploads/`,以免操作失誤。
|
||||
3. 將選定備份中的 `hub.db` 覆蓋為 `data/hub.db`。
|
||||
4. 解開附件:`tar -xzf data/backups/<時間>/uploads.tar.gz -C public`。
|
||||
5. 重新啟動:`docker compose up -d`。
|
||||
先在非正式環境執行還原演練;腳本會驗證雜湊、停止服務、保留原本資料庫副本、還原資料庫與附件後重新啟動:
|
||||
|
||||
```bash
|
||||
CONFIRM_RESTORE=YES ./scripts/restore.sh data/backups/<時間>
|
||||
```
|
||||
|
||||
請始終一起還原資料庫與附件,否則貼文中的附件連結可能失效。
|
||||
|
||||
@@ -40,3 +52,13 @@ Hub 原生附件預設只接受圖片、PDF、純文字與 Markdown。若要串
|
||||
資料庫 schema 由 `lib/db.ts` 管理。每個欄位 migration 在 `schema_migrations` 表中記錄版本與套用時間,啟動 Web 或 Worker 時會自動執行尚未套用的安全 migration。
|
||||
|
||||
升級 Mebbling 前請先執行備份。若新版本在測試環境正常運作,再升級正式資料;不支援直接以舊程式碼讀取已升級 schema 的保證。
|
||||
|
||||
## 資料保存與刪除
|
||||
|
||||
`NOTIFICATION_RETENTION_DAYS`、`READING_HISTORY_RETENTION_DAYS` 與 `AUDIT_RETENTION_DAYS` 可設定保存天數;`0` 代表無限期保存。Worker 每日清理一次。到期或已使用的邀請連結會自動移除。
|
||||
|
||||
使用者可在「帳號設定」以目前密碼與 `DELETE` 文字確認刪除帳號及個人資料。若帳號仍擁有來源,必須先轉移或刪除來源,避免誤刪共享內容。
|
||||
|
||||
## 外部告警
|
||||
|
||||
設定 `ALERT_WEBHOOK_URL` 後,Worker 會在同步重試耗盡、或簽章 Webhook 超過 7 天未收到事件時發送告警。支援 Discord incoming webhook 或 ntfy topic URL;同一事件每小時最多通知一次。
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
function allowed(bucket: string, seconds = 3600) {
|
||||
const now = Math.floor(Date.now() / 1000); const row = db.prepare("SELECT reset_at FROM rate_limits WHERE bucket=?").get(bucket) as { reset_at: number } | undefined;
|
||||
if (row && row.reset_at > now) return false;
|
||||
db.prepare("INSERT INTO rate_limits(bucket,count,reset_at) VALUES(?,1,?) ON CONFLICT(bucket) DO UPDATE SET count=count+1,reset_at=excluded.reset_at").run(bucket, now + seconds); return true;
|
||||
}
|
||||
export async function sendAlert(bucket: string, title: string, message: string) {
|
||||
const url = process.env.ALERT_WEBHOOK_URL?.trim(); if (!url || !allowed(`alert:${bucket}`)) return false;
|
||||
try { const isNtfy = /(^|\.)ntfy\.sh\//.test(new URL(url).hostname + new URL(url).pathname); const response = await fetch(url, isNtfy ? { method: "POST", headers: { Title: title, Priority: "high" }, body: message, signal: AbortSignal.timeout(10_000) } : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: `**${title}**\n${message}` }), signal: AbortSignal.timeout(10_000) }); if (!response.ok) throw new Error(`Alert ${response.status}`); return true; } catch { return false; }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { db } from "@/lib/db";
|
||||
export function audit(actorUserId: number | null, action: string, targetType: string, targetId?: string | number, metadata: unknown = {}) {
|
||||
db.prepare("INSERT INTO audit_events(actor_user_id,action,target_type,target_id,metadata_json) VALUES(?,?,?,?,?)").run(actorUserId, action, targetType, targetId == null ? null : String(targetId), JSON.stringify(metadata));
|
||||
}
|
||||
@@ -88,6 +88,17 @@ CREATE TABLE IF NOT EXISTS error_events (
|
||||
id INTEGER PRIMARY KEY, scope TEXT NOT NULL, message TEXT NOT NULL, context_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS source_invites (
|
||||
id INTEGER PRIMARY KEY, source_id INTEGER NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
|
||||
token_hash TEXT UNIQUE NOT NULL, role TEXT NOT NULL DEFAULT 'viewer', expires_at TEXT NOT NULL,
|
||||
used_at TEXT, created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
id INTEGER PRIMARY KEY, actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL, target_type TEXT NOT NULL, target_id TEXT, metadata_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tag_aliases (
|
||||
alias TEXT PRIMARY KEY COLLATE NOCASE, canonical TEXT NOT NULL COLLATE NOCASE,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
@@ -125,6 +136,22 @@ applyColumnMigration(20, "sources", "attachment_storage_mode", "ALTER TABLE sour
|
||||
applyColumnMigration(21, "sources", "attachment_cache_limit_bytes", "ALTER TABLE sources ADD COLUMN attachment_cache_limit_bytes INTEGER NOT NULL DEFAULT 104857600");
|
||||
applyColumnMigration(22, "sources", "attachment_cache_error", "ALTER TABLE sources ADD COLUMN attachment_cache_error TEXT");
|
||||
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(23)").run();
|
||||
applyColumnMigration(24, "sources", "webhook_mode", "ALTER TABLE sources ADD COLUMN webhook_mode TEXT NOT NULL DEFAULT 'manual'");
|
||||
applyColumnMigration(25, "sources", "webhook_remote_name", "ALTER TABLE sources ADD COLUMN webhook_remote_name TEXT");
|
||||
applyColumnMigration(26, "sources", "webhook_signing_secret_encrypted", "ALTER TABLE sources ADD COLUMN webhook_signing_secret_encrypted TEXT");
|
||||
applyColumnMigration(27, "sources", "integration_type", "ALTER TABLE sources ADD COLUMN integration_type TEXT NOT NULL DEFAULT 'memos'");
|
||||
applyColumnMigration(28, "sources", "rss_feed_url", "ALTER TABLE sources ADD COLUMN rss_feed_url TEXT");
|
||||
applyColumnMigration(29, "sources", "sync_batch_size", "ALTER TABLE sources ADD COLUMN sync_batch_size INTEGER NOT NULL DEFAULT 100");
|
||||
applyColumnMigration(30, "sources", "sync_max_posts", "ALTER TABLE sources ADD COLUMN sync_max_posts INTEGER");
|
||||
applyColumnMigration(31, "sources", "sync_cursor", "ALTER TABLE sources ADD COLUMN sync_cursor TEXT");
|
||||
applyColumnMigration(32, "sources", "sync_imported_count", "ALTER TABLE sources ADD COLUMN sync_imported_count INTEGER NOT NULL DEFAULT 0");
|
||||
applyColumnMigration(33, "sources", "sync_run_id", "ALTER TABLE sources ADD COLUMN sync_run_id TEXT");
|
||||
applyColumnMigration(34, "posts", "last_seen_sync_run", "ALTER TABLE posts ADD COLUMN last_seen_sync_run TEXT");
|
||||
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(35)").run();
|
||||
db.prepare("UPDATE source_members SET role='editor' WHERE role='member'").run();
|
||||
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(36)").run();
|
||||
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(37)").run();
|
||||
applyColumnMigration(38, "sources", "attachment_archive_after_days", "ALTER TABLE sources ADD COLUMN attachment_archive_after_days INTEGER");
|
||||
|
||||
const admin = process.env.ADMIN_USERNAME;
|
||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||
|
||||
+28
-10
@@ -1,32 +1,50 @@
|
||||
export type MemosMemo = { name: string; content: string; visibility: string; createTime?: string; updateTime?: string; tags?: string[]; attachments?: { type?: string }[]; resources?: { type?: string }[] };
|
||||
export type MemosIdentity = { name: string; username?: string; nickname?: string; avatarUrl?: string; avatar?: string };
|
||||
export type MemosSyncRules = { tags?: string[]; from?: string | null; to?: string | null; attachmentMode?: "all" | "images" | "none" };
|
||||
export type MemosMemo = { name: string; content: string; visibility: string; creator?: string; createTime?: string; updateTime?: string; tags?: string[]; attachments?: { type?: string }[]; resources?: { type?: string }[] };
|
||||
export type MemosIdentity = { name: string; username?: string; nickname?: string; displayName?: string; avatarUrl?: string; avatar?: string };
|
||||
export type MemosSyncRules = { creator?: string | null; tags?: string[]; from?: string | null; to?: string | null; attachmentMode?: "all" | "images" | "none" };
|
||||
const base = (url: string) => url.replace(/\/+$/, "") + "/api/v1";
|
||||
async function request(url: string, token: string, init?: RequestInit) {
|
||||
const res = await fetch(url, { ...init, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...(init?.headers || {}) }, cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`Memos API ${res.status}: ${await res.text()}`); return res;
|
||||
}
|
||||
export async function createUserWebhook(baseUrl: string, token: string, userName: string, webhook: { url: string; displayName: string; signingSecret: string }) {
|
||||
const user = userName.split("/").at(-1); if (!user) throw new Error("Invalid Memos user");
|
||||
return (await request(`${base(baseUrl)}/users/${encodeURIComponent(user)}/webhooks`, token, { method: "POST", body: JSON.stringify(webhook) })).json() as Promise<{ name: string }>;
|
||||
}
|
||||
export async function deleteUserWebhook(baseUrl: string, token: string, userName: string, webhookName: string) {
|
||||
const user = userName.split("/").at(-1), webhook = webhookName.split("/").at(-1); if (!user || !webhook) throw new Error("Invalid Memos webhook");
|
||||
await request(`${base(baseUrl)}/users/${encodeURIComponent(user)}/webhooks/${encodeURIComponent(webhook)}`, token, { method: "DELETE" });
|
||||
}
|
||||
export async function verifyMemos(baseUrl: string, token: string) { await request(`${base(baseUrl)}/memos?pageSize=1`, token); }
|
||||
export async function getMemosIdentity(baseUrl: string, token: string) {
|
||||
const user = await (await request(`${base(baseUrl)}/auth/status`, token, { method: "POST", body: "{}" })).json() as MemosIdentity;
|
||||
let user: MemosIdentity;
|
||||
try { user = await (await request(`${base(baseUrl)}/auth/me`, token)).json() as MemosIdentity; if ((user as any).user) user = (user as any).user as MemosIdentity; }
|
||||
catch (error) { if (!(error instanceof Error) || !error.message.startsWith("Memos API 404")) throw error; user = await (await request(`${base(baseUrl)}/auth/status`, token, { method: "POST", body: "{}" })).json() as MemosIdentity; }
|
||||
if (!user.name) throw new Error("Memos did not return an account identity");
|
||||
return user;
|
||||
}
|
||||
export function memoUrl(baseUrl: string, memoName: string) { const id = memoName.split("/").at(-1); return id ? `${baseUrl.replace(/\/$/, "")}/m/${encodeURIComponent(id)}` : null; }
|
||||
export async function listMemos(baseUrl: string, token: string, rules: MemosSyncRules = {}) {
|
||||
const all: MemosMemo[] = []; let pageToken = "";
|
||||
do { const res = await request(`${base(baseUrl)}/memos?pageSize=100${pageToken ? `&pageToken=${encodeURIComponent(pageToken)}` : ""}`, token); const data = await res.json(); all.push(...(data.memos || [])); pageToken = data.nextPageToken || ""; } while (pageToken);
|
||||
export type MemosPage = { memos: MemosMemo[]; nextPageToken: string };
|
||||
export async function listMemos(baseUrl: string, token: string, rules: MemosSyncRules = {}, page: { pageToken?: string; pageSize?: number } = {}): Promise<MemosPage> {
|
||||
let pageToken = page.pageToken || "";
|
||||
const tags = rules.tags?.filter(Boolean) || []; const mode = rules.attachmentMode || "all";
|
||||
return all.filter((memo) => {
|
||||
const quote = (value: string) => JSON.stringify(value); const filters = [rules.creator && `creator == ${quote(rules.creator)}`, `visibility == "PUBLIC"`, ...tags.map((tag) => `tags.exists(t, t == ${quote(tag)})`)].filter(Boolean).join(" && ");
|
||||
let useServerFilter = Boolean(filters);
|
||||
do { const params = new URLSearchParams({ pageSize: String(Math.max(10, Math.min(100, page.pageSize || 100))) }); if (pageToken) params.set("pageToken", pageToken); if (useServerFilter && filters) params.set("filter", filters); let data: any;
|
||||
try { data = await (await request(`${base(baseUrl)}/memos?${params}`, token)).json(); }
|
||||
catch (error) { if (!pageToken && useServerFilter && error instanceof Error && error.message.startsWith("Memos API 400")) { useServerFilter = false; continue; } throw error; }
|
||||
const memos = (data.memos || []).filter((memo: MemosMemo) => {
|
||||
if (memo.visibility !== "PUBLIC") return false;
|
||||
if (rules.creator && memo.creator !== rules.creator) return false;
|
||||
if (tags.length && !tags.every((tag) => memo.tags?.includes(tag))) return false;
|
||||
const created = memo.createTime?.slice(0, 10); if (rules.from && (!created || created < rules.from)) return false; if (rules.to && (!created || created > rules.to)) return false;
|
||||
return true;
|
||||
}).map((memo) => {
|
||||
}).map((memo: MemosMemo) => {
|
||||
if (mode === "all") return memo;
|
||||
const onlyImages = <T extends { type?: string }>(items: T[] | undefined) => mode === "none" ? [] : (items || []).filter((item) => item.type?.startsWith("image/"));
|
||||
return { ...memo, attachments: onlyImages(memo.attachments), resources: onlyImages(memo.resources) };
|
||||
});
|
||||
});
|
||||
return { memos, nextPageToken: data.nextPageToken || "" };
|
||||
} while (true);
|
||||
}
|
||||
export async function createMemo(baseUrl: string, token: string, memo: Pick<MemosMemo, "content" | "visibility"> & { attachments?: unknown[]; resources?: unknown[] }) {
|
||||
return (await request(`${base(baseUrl)}/memos`, token, { method: "POST", body: JSON.stringify({ state: "NORMAL", ...memo }) })).json() as Promise<MemosMemo>;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export type RssItem = { id: string; content: string; link: string; publishedAt: string | null };
|
||||
const decode = (value: string) => value.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
|
||||
const field = (xml: string, name: string) => decode(xml.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)</${name}>`, "i"))?.[1]?.trim() || "");
|
||||
export async function fetchRss(url: string) {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(15_000), headers: { Accept: "application/rss+xml, application/xml, text/xml" } }); if (!response.ok) throw new Error(`RSS ${response.status}`);
|
||||
const xml = await response.text(); const entries = xml.match(/<item\b[\s\S]*?<\/item>/gi) || [];
|
||||
return entries.map((item) => { const link = field(item, "link"); const id = field(item, "guid") || link; return { id, link, content: field(item, "description") || field(item, "title"), publishedAt: field(item, "pubDate") ? new Date(field(item, "pubDate")).toISOString() : null }; }).filter((item) => item.id && item.content) as RssItem[];
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export type SyncTrigger = "manual" | "webhook" | "scheduled" | "source-created";
|
||||
export type SyncTrigger = "manual" | "webhook" | "scheduled" | "source-created" | "batch";
|
||||
|
||||
/** Queue one pull per source at a time. Returns true only when a new job was created. */
|
||||
export function queuePull(sourceId: number, trigger: SyncTrigger, payload: unknown = {}) {
|
||||
|
||||
+6
-1
@@ -1,4 +1,4 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
export function createWebhookSecret() { return randomBytes(32).toString("base64url"); }
|
||||
export function webhookSecretHash(secret: string) { return createHash("sha256").update(secret).digest("hex"); }
|
||||
@@ -8,3 +8,8 @@ export function webhookSecretMatches(secret: string, expectedHash: string | null
|
||||
const expected = Buffer.from(expectedHash, "hex");
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
||||
}
|
||||
export function standardWebhookMatches(secret: string, id: string | null, timestamp: string | null, signature: string | null, body: string) {
|
||||
if (!id || !timestamp || !signature || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
|
||||
const expected = createHmac("sha256", secret).update(`${id}.${timestamp}.${body}`).digest("base64");
|
||||
return signature.split(" ").some((item) => { const value = item.split(",")[1]; if (!value) return false; const actual = Buffer.from(value); const target = Buffer.from(expected); return actual.length === target.length && timingSafeEqual(actual, target); });
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mebbling",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mebbling",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"dependencies": {
|
||||
"bcryptjs": "^3.0.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mebbling",
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"description": "",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
+19
-1
@@ -2,6 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
# Creates a consistent SQLite backup through the running web container, then archives Hub uploads.
|
||||
# Optional: BACKUP_RETENTION_DAYS=30 BACKUP_OFFSITE_DIR=/mnt/backup/mebbling ./scripts/backup.sh
|
||||
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$root_dir"
|
||||
stamp="$(date +%Y%m%d-%H%M%S)"
|
||||
@@ -15,4 +16,21 @@ docker compose exec -T -e BACKUP_PATH="/app/data/backups/$stamp/hub.db" web node
|
||||
'
|
||||
|
||||
tar -czf "$backup_dir/uploads.tar.gz" -C public uploads
|
||||
printf 'Created backup: %s\n' "$backup_dir"
|
||||
|
||||
docker compose exec -T -e BACKUP_PATH="/app/data/backups/$stamp/hub.db" web node -e '
|
||||
const Database = require("better-sqlite3"); const db = new Database(process.env.BACKUP_PATH, { readonly: true });
|
||||
const row = db.prepare("PRAGMA integrity_check").get(); db.close(); if (row.integrity_check !== "ok") { console.error("SQLite integrity check failed"); process.exit(1); }
|
||||
'
|
||||
tar -tzf "$backup_dir/uploads.tar.gz" >/dev/null
|
||||
(cd "$backup_dir" && sha256sum hub.db uploads.tar.gz > SHA256SUMS)
|
||||
|
||||
if [[ -n "${BACKUP_OFFSITE_DIR:-}" ]]; then
|
||||
destination="$BACKUP_OFFSITE_DIR/$stamp"; mkdir -p "$destination"
|
||||
cp -a "$backup_dir/." "$destination/"
|
||||
printf 'Copied verified backup to: %s\n' "$destination"
|
||||
fi
|
||||
if [[ -n "${BACKUP_RETENTION_DAYS:-}" ]]; then
|
||||
[[ "$BACKUP_RETENTION_DAYS" =~ ^[0-9]+$ ]] || { echo "BACKUP_RETENTION_DAYS must be a non-negative integer" >&2; exit 2; }
|
||||
find data/backups -mindepth 1 -maxdepth 1 -type d -mtime "+$BACKUP_RETENTION_DAYS" -exec rm -rf {} +
|
||||
fi
|
||||
printf 'Created and verified backup: %s\n' "$backup_dir"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Installs a daily 03:15 host cron job. Run from WSL after setting an offsite mount if desired.
|
||||
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
line="15 3 * * * cd $root_dir && BACKUP_RETENTION_DAYS=30 ./scripts/backup.sh >> data/backup-cron.log 2>&1"
|
||||
(crontab -l 2>/dev/null | grep -Fv "scripts/backup.sh"; echo "$line") | crontab -
|
||||
printf 'Installed daily backup cron job. Inspect with: crontab -l\n'
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -ne 1 ]]; then echo "Usage: CONFIRM_RESTORE=YES ./scripts/restore.sh data/backups/YYYYMMDD-HHMMSS" >&2; exit 2; fi
|
||||
if [[ "${CONFIRM_RESTORE:-}" != "YES" ]]; then echo "Refusing restore. Set CONFIRM_RESTORE=YES after verifying the backup path." >&2; exit 2; fi
|
||||
backup_dir="$1"; [[ -f "$backup_dir/hub.db" && -f "$backup_dir/uploads.tar.gz" && -f "$backup_dir/SHA256SUMS" ]] || { echo "Backup is incomplete" >&2; exit 2; }
|
||||
(cd "$backup_dir" && sha256sum -c SHA256SUMS)
|
||||
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"; cd "$root_dir"
|
||||
docker compose down
|
||||
mkdir -p data public
|
||||
mv data/hub.db "data/hub.db.before-restore.$(date +%Y%m%d-%H%M%S)" 2>/dev/null || true
|
||||
rm -rf public/uploads
|
||||
cp "$backup_dir/hub.db" data/hub.db
|
||||
tar -xzf "$backup_dir/uploads.tar.gz" -C public
|
||||
docker compose up -d
|
||||
printf 'Restore started from: %s\n' "$backup_dir"
|
||||
+1
-1
@@ -16,7 +16,7 @@ test("applies tracked migrations and deduplicates active pull jobs", async () =>
|
||||
const { queuePull } = await import("../lib/sync");
|
||||
const { notify } = await import("../lib/notifications");
|
||||
const migrations = db.prepare("SELECT version FROM schema_migrations ORDER BY version").all() as { version: number }[];
|
||||
assert.deepEqual(migrations.map((item) => item.version), Array.from({ length: 23 }, (_, index) => index + 1));
|
||||
assert.deepEqual(migrations.map((item) => item.version), Array.from({ length: 38 }, (_, index) => index + 1));
|
||||
const userId = Number(db.prepare("INSERT INTO users(username,password_hash) VALUES('sync-test','hash')").run().lastInsertRowid);
|
||||
const sourceId = Number(db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,is_enabled) VALUES(?,?,?,?,1)").run(userId, "Test", "https://example.test", "encrypted").lastInsertRowid);
|
||||
assert.equal(queuePull(sourceId, "manual"), true);
|
||||
|
||||
+44
-19
@@ -1,12 +1,14 @@
|
||||
import { readFile, mkdir, readdir, unlink, writeFile } from "node:fs/promises";
|
||||
import { extname, join } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { db } from "../lib/db";
|
||||
import { decrypt } from "../lib/crypto";
|
||||
import { createMemo, createRemoteFile, getMemosIdentity, listMemos, memoUrl, setMemoAttachments } from "../lib/memos";
|
||||
import { recordError } from "../lib/observability";
|
||||
import { fetchRss } from "../lib/rss";
|
||||
import { sendAlert } from "../lib/alerts";
|
||||
|
||||
type Source = { id: number; user_id: number; base_url: string; token_encrypted: string; is_enabled: number; sync_tags_json: string; sync_from: string | null; sync_to: string | null; sync_attachment_mode: "all" | "images" | "none"; attachment_storage_mode: "remote" | "images" | "all"; attachment_cache_limit_bytes: number };
|
||||
type Source = { id: number; user_id: number; base_url: string; token_encrypted: string; remote_user: string | null; integration_type: "memos" | "rss"; rss_feed_url: string | null; is_enabled: number; sync_tags_json: string; sync_from: string | null; sync_to: string | null; sync_attachment_mode: "all" | "images" | "none"; attachment_storage_mode: "remote" | "images" | "all"; attachment_cache_limit_bytes: number; attachment_archive_after_days: number | null; sync_batch_size: number; sync_max_posts: number | null; sync_cursor: string | null; sync_imported_count: number; sync_run_id: string | null };
|
||||
type Job = { id: number; source_id: number; kind: "pull" | "push"; payload_json: string | null; attempts: number };
|
||||
|
||||
function remoteAttachmentUrl(attachment: any, baseUrl: string) {
|
||||
@@ -16,11 +18,11 @@ function remoteAttachmentUrl(attachment: any, baseUrl: string) {
|
||||
}
|
||||
|
||||
async function cacheAttachments(source: Source, attachments: any[]) {
|
||||
if (source.attachment_storage_mode === "remote") return attachments;
|
||||
if (source.attachment_storage_mode === "remote") return { items: attachments, errors: [] as string[] };
|
||||
const directory = join(process.cwd(), "public", "uploads", "cache", `source-${source.id}`); await mkdir(directory, { recursive: true });
|
||||
let used = 0;
|
||||
for (const row of db.prepare("SELECT attachments_json FROM posts WHERE source_id=?").all(source.id) as { attachments_json: string }[]) { try { used += (JSON.parse(row.attachments_json) as any[]).filter((item) => String(item.url || "").startsWith(`/uploads/cache/source-${source.id}/`)).reduce((sum, item) => sum + Number(item.size || 0), 0); } catch {} }
|
||||
const result: any[] = [];
|
||||
const result: any[] = [], errors: string[] = [];
|
||||
for (const attachment of attachments) {
|
||||
const url = remoteAttachmentUrl(attachment, source.base_url); const type = attachment.type || "";
|
||||
if (!url || (source.attachment_storage_mode === "images" && !type.startsWith("image/"))) { result.push(attachment); continue; }
|
||||
@@ -31,32 +33,42 @@ async function cacheAttachments(source: Source, attachments: any[]) {
|
||||
const filename = attachment.filename || attachment.name || "attachment"; const key = createHash("sha256").update(url).digest("hex").slice(0, 24) + extname(filename);
|
||||
await writeFile(join(directory, key), body); used += body.length;
|
||||
result.push({ ...attachment, originalUrl: url, url: `/uploads/cache/source-${source.id}/${key}`, type: type || response.headers.get("content-type") || "application/octet-stream", size: body.length });
|
||||
} catch (error) { recordError("attachment-cache", error, { sourceId: source.id, url }); result.push(attachment); }
|
||||
} catch (error) { const message = error instanceof Error ? error.message : "快取附件失敗"; errors.push(message); recordError("attachment-cache", error, { sourceId: source.id, url }); result.push(attachment); }
|
||||
}
|
||||
return result;
|
||||
return { items: result, errors };
|
||||
}
|
||||
|
||||
async function cleanupCache(source: Source) {
|
||||
if (source.attachment_archive_after_days) {
|
||||
const oldPosts = db.prepare("SELECT id,attachments_json FROM posts WHERE source_id=? AND COALESCE(remote_created_at,created_at)<datetime('now', ?) AND attachments_json LIKE ?").all(source.id, `-${source.attachment_archive_after_days} days`, `%/uploads/cache/source-${source.id}/%`) as { id: number; attachments_json: string }[];
|
||||
for (const post of oldPosts) { try { const attachments = (JSON.parse(post.attachments_json) as any[]).map((item) => String(item.url || "").startsWith(`/uploads/cache/source-${source.id}/`) && item.originalUrl ? { ...item, url: item.originalUrl } : item); db.prepare("UPDATE posts SET attachments_json=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(JSON.stringify(attachments), post.id); } catch {} }
|
||||
}
|
||||
const directory = join(process.cwd(), "public", "uploads", "cache", `source-${source.id}`); let names: string[]; try { names = await readdir(directory); } catch { return; }
|
||||
const used = new Set<string>(); for (const row of db.prepare("SELECT attachments_json FROM posts WHERE source_id=?").all(source.id) as { attachments_json: string }[]) { try { for (const attachment of JSON.parse(row.attachments_json) as any[]) { const url = String(attachment.url || ""); if (url.startsWith(`/uploads/cache/source-${source.id}/`)) used.add(url.split("/").at(-1)!); } } catch {} }
|
||||
await Promise.all(names.filter((name) => !used.has(name)).map((name) => unlink(join(directory, name)).catch(() => undefined)));
|
||||
}
|
||||
|
||||
async function upsertRemote(source: Source, memo: any) {
|
||||
const tags = JSON.stringify(memo.tags || []), attachments = JSON.stringify(await cacheAttachments(source, memo.attachments || memo.resources || []));
|
||||
db.prepare(`INSERT INTO posts(source_id,author_id,remote_memo_name,content,visibility,tags_json,attachments_json,origin,remote_created_at,remote_updated_at,sync_status,hidden,remote_url) VALUES(?,?,?,?,?,?,?,?,?,?, 'synced',0,?) ON CONFLICT(source_id,remote_memo_name) DO UPDATE SET content=excluded.content,visibility=excluded.visibility,tags_json=excluded.tags_json,attachments_json=excluded.attachments_json,remote_updated_at=excluded.remote_updated_at,remote_url=excluded.remote_url,hidden=0,updated_at=CURRENT_TIMESTAMP`).run(source.id, source.user_id, memo.name, memo.content, memo.visibility, tags, attachments, "memos", memo.createTime || null, memo.updateTime || null, memoUrl(source.base_url, memo.name));
|
||||
async function upsertRemote(source: Source, memo: any, syncRun: string) {
|
||||
const cached = await cacheAttachments(source, memo.attachments || memo.resources || []); const tags = JSON.stringify(memo.tags || []), attachments = JSON.stringify(cached.items);
|
||||
db.prepare(`INSERT INTO posts(source_id,author_id,remote_memo_name,content,visibility,tags_json,attachments_json,origin,remote_created_at,remote_updated_at,sync_status,hidden,remote_url,last_seen_sync_run) VALUES(?,?,?,?,?,?,?,?,?,?, 'synced',0,?,?) ON CONFLICT(source_id,remote_memo_name) DO UPDATE SET content=excluded.content,visibility=excluded.visibility,tags_json=excluded.tags_json,attachments_json=excluded.attachments_json,remote_updated_at=excluded.remote_updated_at,remote_url=excluded.remote_url,last_seen_sync_run=excluded.last_seen_sync_run,hidden=0,updated_at=CURRENT_TIMESTAMP`).run(source.id, source.user_id, memo.name, memo.content, memo.visibility, tags, attachments, "memos", memo.createTime || null, memo.updateTime || null, memoUrl(source.base_url, memo.name), syncRun);
|
||||
return cached.errors;
|
||||
}
|
||||
|
||||
async function pull(source: Source) {
|
||||
const token = decrypt(source.token_encrypted); const rules = { tags: JSON.parse(source.sync_tags_json || "[]") as string[], from: source.sync_from, to: source.sync_to, attachmentMode: source.sync_attachment_mode };
|
||||
const [memos, identity] = await Promise.all([listMemos(source.base_url, token, rules), getMemosIdentity(source.base_url, token)]);
|
||||
for (const memo of memos) await upsertRemote(source, memo);
|
||||
const names = memos.map((memo) => memo.name);
|
||||
if (names.length) { const placeholders = names.map(() => "?").join(","); db.prepare(`UPDATE posts SET hidden=1,updated_at=CURRENT_TIMESTAMP WHERE source_id=? AND remote_memo_name IS NOT NULL AND remote_memo_name NOT IN (${placeholders})`).run(source.id, ...names); }
|
||||
else db.prepare("UPDATE posts SET hidden=1,updated_at=CURRENT_TIMESTAMP WHERE source_id=? AND remote_memo_name IS NOT NULL").run(source.id);
|
||||
if (source.integration_type === "rss") { const items = await fetchRss(source.rss_feed_url || ""); for (const item of items) db.prepare("INSERT INTO posts(source_id,author_id,remote_memo_name,content,visibility,tags_json,attachments_json,origin,remote_created_at,remote_updated_at,sync_status,hidden,remote_url) VALUES(?,?,?,?,?,'[]','[]','rss',?,?, 'synced',0,?) ON CONFLICT(source_id,remote_memo_name) DO UPDATE SET content=excluded.content,remote_created_at=excluded.remote_created_at,remote_url=excluded.remote_url,hidden=0,updated_at=CURRENT_TIMESTAMP").run(source.id,source.user_id,`rss:${item.id}`,item.content,"PUBLIC",item.publishedAt,item.publishedAt,item.link); const names=items.map((item)=>`rss:${item.id}`); if(names.length) db.prepare(`UPDATE posts SET hidden=1 WHERE source_id=? AND remote_memo_name NOT IN (${names.map(()=>"?").join(",")})`).run(source.id,...names); db.prepare("UPDATE sources SET sync_status='synced',last_synced_at=CURRENT_TIMESTAMP,last_error=NULL WHERE id=?").run(source.id); return false; }
|
||||
const token = decrypt(source.token_encrypted); const identity = await getMemosIdentity(source.base_url, token); const creator = identity.name;
|
||||
const rules = { creator, tags: JSON.parse(source.sync_tags_json || "[]") as string[], from: source.sync_from, to: source.sync_to, attachmentMode: source.sync_attachment_mode };
|
||||
const remaining = source.sync_max_posts ? Math.max(0, source.sync_max_posts - source.sync_imported_count) : source.sync_batch_size;
|
||||
if (remaining === 0) { db.prepare("UPDATE sources SET sync_cursor=NULL,sync_status='synced',last_synced_at=CURRENT_TIMESTAMP WHERE id=?").run(source.id); return false; }
|
||||
const page = await listMemos(source.base_url, token, rules, { pageToken: source.sync_cursor || "", pageSize: Math.min(source.sync_batch_size, remaining) }); const memos = page.memos; const syncRun = source.sync_run_id || randomUUID();
|
||||
const cacheErrors: string[] = []; for (const memo of memos) cacheErrors.push(...await upsertRemote(source, memo, syncRun));
|
||||
const imported = source.sync_imported_count + memos.length; const capped = Boolean(source.sync_max_posts && imported >= source.sync_max_posts); const complete = !page.nextPageToken && !capped; const more = Boolean(page.nextPageToken) && !capped;
|
||||
if (complete) db.prepare("UPDATE posts SET hidden=1,updated_at=CURRENT_TIMESTAMP WHERE source_id=? AND origin='memos' AND COALESCE(last_seen_sync_run,'')<>?").run(source.id, syncRun);
|
||||
await cleanupCache(source);
|
||||
const avatar = identity.avatarUrl || identity.avatar || null; const avatarUrl = avatar?.startsWith("/") ? `${source.base_url.replace(/\/$/, "")}${avatar}` : avatar;
|
||||
db.prepare("UPDATE sources SET sync_status='synced',last_synced_at=CURRENT_TIMESTAMP,last_error=NULL,last_connection_at=CURRENT_TIMESTAMP,last_connection_error=NULL,remote_display_name=?,remote_avatar_url=? WHERE id=?").run(identity.nickname || identity.username || identity.name, avatarUrl, source.id);
|
||||
const name = identity.displayName || identity.nickname || identity.username || identity.name;
|
||||
db.prepare("UPDATE sources SET name=?,remote_user=?,sync_status=?,last_synced_at=CASE WHEN ? THEN NULL ELSE CURRENT_TIMESTAMP END,last_error=NULL,last_connection_at=CURRENT_TIMESTAMP,last_connection_error=NULL,attachment_cache_error=?,remote_display_name=?,remote_avatar_url=?,sync_cursor=?,sync_imported_count=?,sync_run_id=? WHERE id=?").run(name, creator, more ? 'importing' : 'synced', more ? 1 : 0, cacheErrors.length ? `${cacheErrors.length} 個附件快取失敗;可按「立即同步」重試。` : null, name, avatarUrl, more ? page.nextPageToken : null, more ? imported : 0, more ? syncRun : null, source.id);
|
||||
return more;
|
||||
}
|
||||
|
||||
async function push(source: Source, payload: any) {
|
||||
@@ -81,14 +93,15 @@ async function run() {
|
||||
const source = db.prepare("SELECT * FROM sources WHERE id=?").get(job.source_id) as Source | undefined;
|
||||
if (!source || !source.is_enabled) { db.prepare("UPDATE sync_jobs SET status='cancelled',last_error='Source is disabled or deleted',finished_at=CURRENT_TIMESTAMP WHERE id=?").run(job.id); return; }
|
||||
try {
|
||||
if (job.kind === "pull") await pull(source); else if (job.kind === "push") await push(source, JSON.parse(job.payload_json || "{}"));
|
||||
const more = job.kind === "pull" ? await pull(source) : false; if (job.kind === "push") await push(source, JSON.parse(job.payload_json || "{}"));
|
||||
db.prepare("UPDATE sync_jobs SET status='done',finished_at=CURRENT_TIMESTAMP WHERE id=?").run(job.id);
|
||||
db.prepare("UPDATE sources SET sync_status='synced',last_error=NULL,last_synced_at=CURRENT_TIMESTAMP WHERE id=?").run(source.id);
|
||||
if (more) db.prepare("INSERT INTO sync_jobs(source_id,kind,trigger) VALUES(?,'pull','batch')").run(source.id); else db.prepare("UPDATE sources SET sync_status='synced',last_error=NULL,last_synced_at=CURRENT_TIMESTAMP WHERE id=?").run(source.id);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Sync failure"; const exhausted = job.attempts + 1 >= 5;
|
||||
recordError("sync", error, { sourceId: source.id, jobId: job.id, kind: job.kind, attempts: job.attempts });
|
||||
db.prepare("UPDATE sync_jobs SET status=?,last_error=?,finished_at=CASE WHEN ? THEN CURRENT_TIMESTAMP ELSE NULL END,run_after=CASE WHEN ? THEN run_after ELSE datetime('now','+5 minutes') END WHERE id=?").run(exhausted ? "failed" : "queued", message, exhausted ? 1 : 0, exhausted ? 1 : 0, job.id);
|
||||
db.prepare("UPDATE sources SET sync_status='error',last_error=?,last_connection_error=? WHERE id=?").run(message, message, source.id);
|
||||
if (exhausted) void sendAlert(`sync:${source.id}`, "Mebbling 同步失敗", `來源 #${source.id} 已重試 5 次仍失敗:${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,4 +110,16 @@ function schedule() {
|
||||
db.prepare(`INSERT INTO sync_jobs(source_id,kind,trigger) SELECT id,'pull','scheduled' FROM sources WHERE is_enabled=1 AND COALESCE(last_synced_at,'1970-01-01') < datetime('now', ?) AND NOT EXISTS (SELECT 1 FROM sync_jobs j WHERE j.source_id=sources.id AND j.kind='pull' AND j.status IN ('queued','running'))`).run(`-${interval} minutes`);
|
||||
}
|
||||
|
||||
setInterval(() => { schedule(); void run(); }, 5000); schedule(); void run();
|
||||
function retention() {
|
||||
const days = (name: string) => Math.max(0, Number(process.env[name] || 0) || 0);
|
||||
db.prepare("DELETE FROM source_invites WHERE expires_at<CURRENT_TIMESTAMP AND (used_at IS NOT NULL OR expires_at<datetime('now','-30 days'))").run();
|
||||
const cleanup = (table: string, column: string, value: number) => { if (value) db.prepare(`DELETE FROM ${table} WHERE ${column}<datetime('now', ?)`).run(`-${value} days`); };
|
||||
cleanup("notifications", "created_at", days("NOTIFICATION_RETENTION_DAYS")); cleanup("reading_history", "last_read_at", days("READING_HISTORY_RETENTION_DAYS")); cleanup("audit_events", "created_at", days("AUDIT_RETENTION_DAYS"));
|
||||
}
|
||||
|
||||
function webhookHealth() {
|
||||
const stale = db.prepare("SELECT id,name,last_webhook_at FROM sources WHERE is_enabled=1 AND webhook_mode='signed' AND (last_webhook_at IS NULL OR last_webhook_at<datetime('now','-7 days'))").all() as { id: number; name: string; last_webhook_at: string | null }[];
|
||||
for (const source of stale) void sendAlert(`webhook:${source.id}`, "Mebbling Webhook 未收到事件", `來源「${source.name}」超過 7 天未收到 Webhook;目前仍會以定期 API 同步校正。`);
|
||||
}
|
||||
|
||||
let lastRetention = 0, lastWebhookHealth = 0; setInterval(() => { schedule(); if (Date.now() - lastRetention > 86_400_000) { retention(); lastRetention = Date.now(); } if (Date.now() - lastWebhookHealth > 3_600_000) { webhookHealth(); lastWebhookHealth = Date.now(); } void run(); }, 5000); schedule(); retention(); webhookHealth(); void run();
|
||||
|
||||
Reference in New Issue
Block a user