diff --git a/CHANGELOG.md b/CHANGELOG.md index f844cca..e2af3e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ - 來源名稱改由 API Key 對應的 Memos 帳號自動產生與更新,控制台不再接受手動命名。 - 同步會優先使用新版 Memos 的伺服器端 filter;不支援該 API 的舊版 Memos 會安全退回本機篩選。 - 新版 Memos 來源建立時會自動建立帶 HMAC 簽名的 webhook;不支援 User Webhook API 的舊版來源維持手動模式。 +- 相容新版 Memos 的 `/auth/me` 與 username 資源名,既有來源會在同步時更新遠端使用者身分。 +- 重新連接同一個 Memos 網址時會更新原有來源的 PAT 與遠端身分,不建立重複來源。 ## [0.6.0] - Unreleased diff --git a/app/api/sources/[id]/manage/route.ts b/app/api/sources/[id]/manage/route.ts index 037dec9..612e638 100644 --- a/app/api/sources/[id]/manage/route.ts +++ b/app/api/sources/[id]/manage/route.ts @@ -34,7 +34,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str 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; - const name = identity.nickname || identity.username || identity.name; + 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 === "leave") { diff --git a/app/api/sources/route.ts b/app/api/sources/route.ts index 38981e7..7d20287 100644 --- a/app/api/sources/route.ts +++ b/app/api/sources/route.ts @@ -22,7 +22,9 @@ 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.nickname || identity.username || identity.name; + 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); diff --git a/lib/db.ts b/lib/db.ts index 1e58220..e0f925c 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -128,6 +128,8 @@ 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"); const admin = process.env.ADMIN_USERNAME; const adminPassword = process.env.ADMIN_PASSWORD; diff --git a/lib/memos.ts b/lib/memos.ts index 05f52b9..8308a5c 100644 --- a/lib/memos.ts +++ b/lib/memos.ts @@ -1,5 +1,5 @@ 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; avatarUrl?: string; avatar?: 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) { @@ -12,7 +12,9 @@ export async function createUserWebhook(baseUrl: string, token: string, userName } 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; } diff --git a/tests/sync.test.ts b/tests/sync.test.ts index 69cf32f..41a47f6 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: 26 }, (_, index) => index + 1)); + assert.deepEqual(migrations.map((item) => item.version), Array.from({ length: 28 }, (_, 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); diff --git a/worker/index.ts b/worker/index.ts index ed8c285..db17644 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -49,7 +49,7 @@ async function upsertRemote(source: Source, memo: any) { } async function pull(source: Source) { - const token = decrypt(source.token_encrypted); const identity = await getMemosIdentity(source.base_url, token); const creator = source.remote_user || identity.name; + 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 memos = await listMemos(source.base_url, token, rules); const cacheErrors: string[] = []; for (const memo of memos) cacheErrors.push(...await upsertRemote(source, memo)); @@ -58,7 +58,7 @@ async function pull(source: Source) { 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); await cleanupCache(source); const avatar = identity.avatarUrl || identity.avatar || null; const avatarUrl = avatar?.startsWith("/") ? `${source.base_url.replace(/\/$/, "")}${avatar}` : avatar; - const name = identity.nickname || identity.username || identity.name; + const name = identity.displayName || identity.nickname || identity.username || identity.name; db.prepare("UPDATE sources SET name=?,remote_user=?,sync_status='synced',last_synced_at=CURRENT_TIMESTAMP,last_error=NULL,last_connection_at=CURRENT_TIMESTAMP,last_connection_error=NULL,attachment_cache_error=?,remote_display_name=?,remote_avatar_url=? WHERE id=?").run(name, creator, cacheErrors.length ? `${cacheErrors.length} 個附件快取失敗;可按「立即同步」重試。` : null, name, avatarUrl, source.id); }