From 44cd7a48ee80b6c0a6547a4723b7c5704a2be6e7 Mon Sep 17 00:00:00 2001 From: tangsongdayo Date: Sun, 19 Jul 2026 05:45:42 +0800 Subject: [PATCH] feat: add source invite roles --- CHANGELOG.md | 1 + app/api/invites/accept/route.ts | 7 +++++++ app/api/sources/[id]/invites/route.ts | 8 ++++++++ app/dashboard/invite-control.tsx | 7 +++++++ app/dashboard/page.tsx | 8 +++++--- app/invite/[token]/page.tsx | 3 +++ lib/db.ts | 9 +++++++++ tests/sync.test.ts | 2 +- 8 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 app/api/invites/accept/route.ts create mode 100644 app/api/sources/[id]/invites/route.ts create mode 100644 app/dashboard/invite-control.tsx create mode 100644 app/invite/[token]/page.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index ea20ba9..1c561a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ ### Added +- 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. diff --git a/app/api/invites/accept/route.ts b/app/api/invites/accept/route.ts new file mode 100644 index 0000000..db0a91a --- /dev/null +++ b/app/api/invites/accept/route.ts @@ -0,0 +1,7 @@ +import { createHash } from "node:crypto"; +import { NextResponse } from "next/server"; +import { requireUser } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { externalUrl } from "@/lib/http"; +import { requireSameOrigin } from "@/lib/security"; +export async function POST(request: Request) { try { requireSameOrigin(request); const user = await requireUser(); const form = await request.formData(); const 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); })(); return NextResponse.redirect(externalUrl(request, "/dashboard?source=joined")); } catch (error) { return NextResponse.redirect(externalUrl(request, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "invite"))); } } diff --git a/app/api/sources/[id]/invites/route.ts b/app/api/sources/[id]/invites/route.ts new file mode 100644 index 0000000..22702e5 --- /dev/null +++ b/app/api/sources/[id]/invites/route.ts @@ -0,0 +1,8 @@ +import { createHash, randomBytes } from "node:crypto"; +import { NextResponse } from "next/server"; +import { requireUser } from "@/lib/auth"; +import { db } from "@/lib/db"; +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); 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 }); } +} diff --git a/app/dashboard/invite-control.tsx b/app/dashboard/invite-control.tsx new file mode 100644 index 0000000..5fbf3a5 --- /dev/null +++ b/app/dashboard/invite-control.tsx @@ -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
{url && <> event.currentTarget.select()} />}{error &&

{error}

}
; +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index e211f19..94e0084 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -3,17 +3,18 @@ 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"; -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; 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_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_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.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,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_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_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 && source.integration_type === "memos"); + const publishSources = sources.filter((source) => source.is_enabled && source.integration_type === "memos" && ["owner", "editor"].includes(source.membership_role)); return <>

控制台

{query.error &&

{query.error}

} @@ -27,6 +28,7 @@ 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}}

{source.owner_id === user.id ? <> {source.integration_type === "memos" && } +
來源管理
{source.members.length > 1 &&
}
:
} diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx new file mode 100644 index 0000000..46be829 --- /dev/null +++ b/app/invite/[token]/page.tsx @@ -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

加入來源

以目前登入帳號 @{user.username} 接受此一次性邀請。

; } diff --git a/lib/db.ts b/lib/db.ts index 25e05d1..0b31a30 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -88,6 +88,12 @@ 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 tag_aliases ( alias TEXT PRIMARY KEY COLLATE NOCASE, canonical TEXT NOT NULL COLLATE NOCASE, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP @@ -136,6 +142,9 @@ applyColumnMigration(31, "sources", "sync_cursor", "ALTER TABLE sources ADD COLU 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(); const admin = process.env.ADMIN_USERNAME; const adminPassword = process.env.ADMIN_PASSWORD; diff --git a/tests/sync.test.ts b/tests/sync.test.ts index fe7cb55..144ff0a 100644 --- a/tests/sync.test.ts +++ b/tests/sync.test.ts @@ -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: 34 }, (_, index) => index + 1)); + assert.deepEqual(migrations.map((item) => item.version), Array.from({ length: 36 }, (_, 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);