diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d39d0e..e200b1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ ### 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. diff --git a/app/api/sources/[id]/manage/route.ts b/app/api/sources/[id]/manage/route.ts index d0cefb5..dc6fac1 100644 --- a/app/api/sources/[id]/manage/route.ts +++ b/app/api/sources/[id]/manage/route.ts @@ -38,6 +38,8 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str 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); diff --git a/app/api/sources/[id]/preview/route.ts b/app/api/sources/[id]/preview/route.ts new file mode 100644 index 0000000..a7fd88b --- /dev/null +++ b/app/api/sources/[id]/preview/route.ts @@ -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 }); } } diff --git a/app/api/sync/jobs/[id]/retry/route.ts b/app/api/sync/jobs/[id]/retry/route.ts new file mode 100644 index 0000000..bcf1a02 --- /dev/null +++ b/app/api/sync/jobs/[id]/retry/route.ts @@ -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"))); } } diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 8af3e12..c1d7e9d 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -4,6 +4,7 @@ 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; 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 }; @@ -28,12 +29,14 @@ export default async function Dashboard({ searchParams }: { searchParams: Promis
來源 ID:{source.id}
{source.integration_type === "rss" ? source.rss_feed_url : source.base_url}{source.remote_display_name && <>
Memos 帳號:{source.remote_avatar_url && } {source.remote_display_name}>}
成員:{source.members.map((member) => `${member.username}${member.role === "owner" ? "(建立者)" : ""}`).join("、")}
上次同步:{source.last_synced_at || "尚未完成"}{source.sync_cursor && <>(批次匯入中:{source.sync_imported_count} 篇)>}
{source.integration_type === "memos" && <>附件保存:{source.attachment_storage_mode === "remote" ? "遠端連結" : source.attachment_storage_mode === "images" ? "只快取圖片" : "完整備份"}(配額 {Math.round(source.attachment_cache_limit_bytes / 1024 / 1024)} MiB)
連線:{source.last_connection_at ? `最近成功:${new Date(source.last_connection_at + "Z").toLocaleString("zh-TW")}` : "尚未測試"}
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 && <>
已停用:{source.disabled_at ? new Date(source.disabled_at + "Z").toLocaleString("zh-TW") : "是"}>}{source.last_error && <>
同步:{source.last_error}>}{source.last_connection_error && <>
連線:{source.last_connection_error}>}{source.attachment_cache_error && <>
附件快取:{source.attachment_cache_error}>}
尚無同步工作。
}尚無同步工作。
}{text}
}