fix: support Memos API identity migration

This commit is contained in:
2026-07-19 05:27:46 +08:00
parent 97d4c15407
commit 2ea6a653bf
7 changed files with 15 additions and 7 deletions
+2
View File
@@ -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
+1 -1
View File
@@ -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") {
+3 -1
View File
@@ -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);
+2
View File
@@ -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;
+4 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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);
+2 -2
View File
@@ -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);
}