From 318857177756fc50ab53b08c2a9c3a246536f54f Mon Sep 17 00:00:00 2001 From: hitalin Date: Sat, 12 Sep 2026 12:50:47 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(identity):=20=E3=83=8E=E3=83=BC?= =?UTF-8?q?=E3=83=88=E3=81=AE=E5=90=8C=E4=B8=80=E6=80=A7=E3=82=AD=E3=83=BC?= =?UTF-8?q?=E3=81=A8=20origin=20=E5=88=A4=E5=AE=9A=E3=82=92=E5=B0=8E?= =?UTF-8?q?=E5=85=A5=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 複数サーバーで観測した同じノートを束ねるための identity (正規化 AP object id) を追加する (notedeck#1058)。 - identity モジュール: uri が無いローカルノートは host + ID から組み立て、 純粋 Renote の Announce id (末尾 /activity) を origin 側の行と一致させる。 host は UTS#46 で ASCII 小文字化し、比較はすべて Rust 側で済ませる - NormalizedNote に _identity / _isOrigin / _identityTrusted / contentHidden を追加。旧 JSON は serde default で読み、DB 読み出しの共通関数で補完する - notes_cache に identity 列と索引 (V7、列追加のみ)。既存行の backfill は 起動をブロックしないチャンク API で行い、完了前は uri 列でフォールバック - find_notes_by_identity (account 横断) と search_cached_notes_across - CLI ログインの host を NoteDeck 経路と同じ ASCII 小文字で保存 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01FkkqpTo56FUVjoksbhLj1n --- Cargo.lock | 1 + Cargo.toml | 1 + migrations/V7__add_identity_column.sql | 16 ++ src/commands/auth.rs | 4 + src/db.rs | 325 +++++++++++++++++++++++-- src/identity.rs | 197 +++++++++++++++ src/lib.rs | 1 + src/models.rs | 161 ++++++++++++ 8 files changed, 691 insertions(+), 15 deletions(-) create mode 100644 migrations/V7__add_identity_column.sql create mode 100644 src/identity.rs diff --git a/Cargo.lock b/Cargo.lock index 9d6c254..5e8e43b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1440,6 +1440,7 @@ dependencies = [ "tower-http", "tracing", "tracing-subscriber", + "url", "utoipa", "utoipa-axum", "uuid", diff --git a/Cargo.toml b/Cargo.toml index c6f1fc2..4274d34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ rusqlite = { version = "0.35", features = ["bundled"] } refinery = { version = "0.9", features = ["rusqlite"] } serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" +url = "2" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tokio-stream = { version = "0.1", features = ["sync"] } tokio-tungstenite = { version = "0.26", features = ["rustls-tls-native-roots", "rustls-tls-webpki-roots"] } diff --git a/migrations/V7__add_identity_column.sql b/migrations/V7__add_identity_column.sql new file mode 100644 index 0000000..815aa80 --- /dev/null +++ b/migrations/V7__add_identity_column.sql @@ -0,0 +1,16 @@ +-- V7: notes_cache に同一性キー (identity) 列を追加する (notedeck#1058)。 +-- identity は正規化した ActivityPub object id。複数サーバーで観測した同じノートを +-- 束ねるためのキーで、導出規則は src/identity.rs が正本。 +-- +-- この migration は列追加と索引だけ。既存行の backfill は起動をブロックしないよう +-- `Database::backfill_identity_chunk` で起動後にバックグラウンドで行う +-- (V6 級の全行リライトは起動を分単位で止めるため)。未 backfill 行は '' で、 +-- `find_notes_by_identity` は完了まで uri 列でフォールバックする。 +-- +-- 索引は部分索引にしない: `identity = ?` の束縛パラメータでは部分索引の条件 +-- (identity != '') を planner が証明できず索引が使われない。'' 行は索引上で +-- 連続するので backfill のチャンク選択にも同じ索引が効く。 + +ALTER TABLE notes_cache ADD COLUMN identity TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_notes_cache_identity ON notes_cache(identity); diff --git a/src/commands/auth.rs b/src/commands/auth.rs index d5de2fc..1f0ed02 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -53,6 +53,10 @@ pub fn run_accounts(db: &Database, fmt: OutputFormat) -> Result<(), NoteDeckErro } pub async fn run_login(db: &Database, host: &str, fmt: OutputFormat) -> Result<(), NoteDeckError> { + // NoteDeck 経路と同じく ASCII 小文字で保存する (accounts の (host, user_id) 一意制約と + // ノートの取得元 host を揃える。notedeck#1058) + let host = host.trim().to_ascii_lowercase(); + let host = host.as_str(); let client = MisskeyClient::new()?; let session_id = uuid::Uuid::new_v4().to_string(); diff --git a/src/db.rs b/src/db.rs index 8ccd4db..be36737 100644 --- a/src/db.rs +++ b/src/db.rs @@ -597,13 +597,14 @@ impl Database { let tx = conn.unchecked_transaction()?; { let mut entity_stmt = tx.prepare_cached( - "INSERT INTO notes_cache (note_id, account_id, server_host, created_at, text, note_json, cached_at, uri) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + "INSERT INTO notes_cache (note_id, account_id, server_host, created_at, text, note_json, cached_at, uri, identity) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ON CONFLICT(note_id, account_id) DO UPDATE SET text = excluded.text, note_json = excluded.note_json, cached_at = excluded.cached_at, - uri = excluded.uri", + uri = excluded.uri, + identity = excluded.identity", )?; let mut membership_stmt = tx.prepare_cached( "INSERT INTO note_timelines (account_id, timeline_key, note_id, sort_key, added_at) @@ -612,6 +613,13 @@ impl Database { )?; for note in notes { let json = serde_json::to_string(note).unwrap_or_default(); + // identity は normalize 済みなら埋まっている。旧 JSON 由来の + // 空値でも列だけは必ず埋める (backfill 待ちにしない) + let identity = if note.identity.is_empty() { + crate::identity::identity_of(note.uri.as_deref(), ¬e.server_host, ¬e.id) + } else { + note.identity.clone() + }; entity_stmt.execute(params![ note.id, note.account_id, @@ -621,6 +629,7 @@ impl Database { json, now, note.uri, + identity, ])?; membership_stmt.execute(params![ note.account_id, @@ -719,6 +728,16 @@ impl Database { Ok(deleted as u64) } + /// notes_cache の note_json を NormalizedNote に戻す。スキーマ世代差・破損行は None。 + /// identity 系フィールド (`_identity` / `_isOrigin` / `_identityTrusted`) は + /// 旧 JSON に無いので常に再計算して補う (決定的なので既存値と同値になる)。 + /// 5 つの読み出し経路すべてがここを通る (述語評価より前に補うため)。 + fn parse_cached_note(json: &str) -> Option { + let mut note = serde_json::from_str::(json).ok()?; + note.fill_identity(); + Some(note) + } + /// Find cached notes by ActivityPub URI across all accounts. /// Uses the partial index on `uri` for fast lookups. pub fn find_notes_by_uri(&self, uri: &str) -> Result, NoteDeckError> { @@ -731,13 +750,65 @@ impl Database { let mut notes = Vec::new(); for row in rows { let json = row?; - if let Ok(note) = serde_json::from_str::(&json) { + if let Some(note) = Self::parse_cached_note(&json) { + notes.push(note); + } + } + Ok(notes) + } + + /// identity (正規化 AP object id) でキャッシュを account 横断で引く (notedeck#1058)。 + /// 引数は生の URI でもよい (同じ規則で正規化する)。backfill 完了前の行 + /// (identity = '') は uri 列でフォールバックする。ローカル行と Renote の + /// `/activity` 行は backfill 完了まで拾えない (短い過渡)。 + pub fn find_notes_by_identity( + &self, + uri_or_identity: &str, + ) -> Result, NoteDeckError> { + let identity = crate::identity::identity_of(Some(uri_or_identity), "", ""); + let conn = self.lock_read()?; + let mut stmt = conn.prepare_cached( + "SELECT note_json FROM notes_cache WHERE identity = ?1 OR (identity = '' AND uri = ?1)", + )?; + let rows = stmt.query_map(params![identity], |row| row.get::<_, String>(0))?; + let mut notes = Vec::new(); + for row in rows { + if let Some(note) = Self::parse_cached_note(&row?) { notes.push(note); } } Ok(notes) } + /// V7 で追加した identity 列の backfill を 1 チャンク進める。戻り値は更新行数で、 + /// 0 なら完了。identity は列 (uri / server_host / note_id) だけから導出できるので + /// JSON parse は要らない。1 チャンク = 1 tx で writer ロックを短く持つ + /// (WS 取り込みを止めない)。upsert とは同じロックで直列化され値は決定的なので + /// 競合しない。 + pub fn backfill_identity_chunk(&self, chunk: usize) -> Result { + let conn = self.lock_write()?; + let tx = conn.unchecked_transaction()?; + let rows: Vec<(i64, String, String, Option)> = { + let mut stmt = tx.prepare_cached( + "SELECT rowid, note_id, server_host, uri FROM notes_cache WHERE identity = '' LIMIT ?1", + )?; + let mapped = stmt.query_map(params![chunk as i64], |r| { + Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)) + })?; + mapped.collect::>()? + }; + { + let mut upd = + tx.prepare_cached("UPDATE notes_cache SET identity = ?1 WHERE rowid = ?2")?; + for (rowid, note_id, server_host, uri) in &rows { + let identity = crate::identity::identity_of(uri.as_deref(), server_host, note_id); + upd.execute(params![identity, rowid])?; + } + } + tx.commit()?; + Ok(rows.len() as u64) + } + pub fn search_cached_notes( &self, account_id: &str, @@ -756,12 +827,41 @@ impl Database { until_date: Option<&str>, ascending: bool, ) -> Result, NoteDeckError> { + self.search_cached_notes_across( + &[account_id], + query, + limit, + since_date, + until_date, + ascending, + ) + } + + /// 複数アカウントを横断して検索する (notedeck#945 / #1058)。結果は variant + /// (アカウントごとの行) のまま返し、同一ノートの束ねは呼び出し側で行う。 + /// `account_ids` が空なら空を返す。 + pub fn search_cached_notes_across( + &self, + account_ids: &[&str], + query: &str, + limit: i64, + since_date: Option<&str>, + until_date: Option<&str>, + ascending: bool, + ) -> Result, NoteDeckError> { + if account_ids.is_empty() { + return Ok(Vec::new()); + } let conn = self.lock_read()?; let order = if ascending { "ASC" } else { "DESC" }; let has_query = !query.is_empty(); - let mut conditions = vec!["nc.account_id = ?1".to_string()]; - let mut param_idx = 2u32; + let account_placeholders = (1..=account_ids.len()) + .map(|i| format!("?{i}")) + .collect::>() + .join(", "); + let mut conditions = vec![format!("nc.account_id IN ({account_placeholders})")]; + let mut param_idx = account_ids.len() as u32 + 1; let fts_query; let like_pattern; @@ -803,7 +903,9 @@ impl Database { let mut stmt = conn.prepare(&sql)?; let mut dynamic_params: Vec> = Vec::new(); - dynamic_params.push(Box::new(account_id.to_string())); + for id in account_ids { + dynamic_params.push(Box::new(id.to_string())); + } if use_fts { dynamic_params.push(Box::new(fts_query)); } @@ -831,7 +933,7 @@ impl Database { Ok(rows .into_iter() - .filter_map(|json_str| serde_json::from_str::(&json_str).ok()) + .filter_map(|json_str| Self::parse_cached_note(&json_str)) .collect()) } @@ -907,8 +1009,8 @@ impl Database { created_at, note_id, }); - match serde_json::from_str::(&json) { - Ok(note) => match pred(¬e) { + match Self::parse_cached_note(&json) { + Some(note) => match pred(¬e) { Some(true) => { out.notes.push(note); if out.notes.len() >= limit { @@ -921,7 +1023,7 @@ impl Database { None => out.errors += 1, }, // スキーマ世代差・破損行も per-note エラーとして扱う - Err(_) => out.errors += 1, + None => out.errors += 1, } } // limit で止めた場合はこのチャンクを読み切っていないので、 @@ -1046,7 +1148,7 @@ impl Database { let mut notes = Vec::new(); for row in rows { let json_str = row?; - if let Ok(note) = serde_json::from_str::(&json_str) { + if let Some(note) = Self::parse_cached_note(&json_str) { notes.push(note); } } @@ -1335,7 +1437,7 @@ impl Database { }; Ok(jsons .iter() - .filter_map(|json| serde_json::from_str::(json).ok()) + .filter_map(|json| Self::parse_cached_note(json)) .collect()) } @@ -2157,10 +2259,14 @@ mod tests { // --- Notes cache tests --- fn sample_note(id: &str, text: &str) -> NormalizedNote { - NormalizedNote { + let mut note = NormalizedNote { id: id.to_string(), account_id: "acc-1".to_string(), server_host: "misskey.io".to_string(), + identity: String::new(), + is_origin: false, + identity_trusted: false, + content_hidden: false, created_at: "2025-01-01T00:00:00Z".to_string(), text: Some(text.to_string()), cw: None, @@ -2199,7 +2305,9 @@ mod tests { mode_flags: HashMap::new(), reply: None, renote: None, - } + }; + note.fill_identity(); + note } #[test] @@ -3761,4 +3869,191 @@ mod tests { let hits = db.search_cached_notes("acc-1", "migration", 10).unwrap(); assert_eq!(hits.len(), 2); } + + // ---- identity (notedeck#1058) ---- + + fn variant(id: &str, account_id: &str, host: &str, uri: Option<&str>) -> NormalizedNote { + let mut n = sample_note(id, "hello identity"); + n.account_id = account_id.to_string(); + n.server_host = host.to_string(); + n.uri = uri.map(str::to_string); + n.fill_identity(); + n + } + + #[test] + fn find_notes_by_identity_bundles_local_row_and_activity_row() { + let (_dir, db) = temp_db(); + // origin 側の純粋 Renote 行 (uri なし) と、連合先が Announce の id を uri に持つ行 + let origin = variant("r1", "acc-1", "origin.example", None); + let remote = variant( + "localB", + "acc-2", + "b.example", + Some("https://origin.example/notes/r1/activity"), + ); + db.ingest_notes(&[origin, remote], &tk("home")).unwrap(); + + let hits = db + .find_notes_by_identity("https://origin.example/notes/r1") + .unwrap(); + assert_eq!(hits.len(), 2); + for n in &hits { + assert_eq!(n.identity, "https://origin.example/notes/r1"); + } + let by_account: std::collections::HashSet<_> = + hits.iter().map(|n| n.account_id.as_str()).collect(); + assert!(by_account.contains("acc-1") && by_account.contains("acc-2")); + // 生の URI の大文字 host でも同じ規則で正規化して引ける + let hits = db + .find_notes_by_identity("HTTPS://Origin.Example/notes/r1") + .unwrap(); + assert_eq!(hits.len(), 2); + } + + #[test] + fn find_notes_by_identity_sets_origin_and_trusted_flags() { + let (_dir, db) = temp_db(); + let mut origin = variant("x1", "acc-1", "origin.example", None); + origin.user.host = None; + let mut remote = variant( + "localB", + "acc-2", + "b.example", + Some("https://origin.example/notes/x1"), + ); + remote.user.host = Some("origin.example".to_string()); + let mut spoofed = variant( + "localC", + "acc-3", + "c.example", + Some("https://origin.example/notes/x1"), + ); + spoofed.user.host = Some("evil.example".to_string()); + spoofed.fill_identity(); + db.ingest_notes(&[origin, remote, spoofed], &tk("home")) + .unwrap(); + + let hits = db + .find_notes_by_identity("https://origin.example/notes/x1") + .unwrap(); + let get = |acc: &str| hits.iter().find(|n| n.account_id == acc).unwrap().clone(); + assert!(get("acc-1").is_origin && get("acc-1").identity_trusted); + assert!(!get("acc-2").is_origin && get("acc-2").identity_trusted); + assert!(!get("acc-3").is_origin && !get("acc-3").identity_trusted); + } + + #[test] + fn backfill_identity_chunk_fills_legacy_rows_and_uri_fallback_bridges_the_gap() { + let (_dir, db) = temp_db(); + let local = variant("r1", "acc-1", "origin.example", None); + let remote = variant( + "localB", + "acc-2", + "b.example", + Some("https://origin.example/notes/r1"), + ); + db.ingest_notes(&[local, remote], &tk("home")).unwrap(); + // V7 直後の状態を再現: identity 列が空 + { + let conn = db.lock().unwrap(); + conn.execute("UPDATE notes_cache SET identity = ''", []) + .unwrap(); + } + // backfill 前: uri 列のフォールバックで remote だけ拾える (local 行は uri NULL) + let hits = db + .find_notes_by_identity("https://origin.example/notes/r1") + .unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].account_id, "acc-2"); + + // 1 行ずつ 2 回で完了、3 回目は 0 + assert_eq!(db.backfill_identity_chunk(1).unwrap(), 1); + assert_eq!(db.backfill_identity_chunk(1).unwrap(), 1); + assert_eq!(db.backfill_identity_chunk(1).unwrap(), 0); + + let hits = db + .find_notes_by_identity("https://origin.example/notes/r1") + .unwrap(); + assert_eq!(hits.len(), 2); + let conn = db.lock().unwrap(); + let empty: i64 = conn + .query_row( + "SELECT COUNT(*) FROM notes_cache WHERE identity = ''", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(empty, 0); + } + + #[test] + fn parse_cached_note_fills_identity_for_legacy_json() { + let (_dir, db) = temp_db(); + let note = variant("n1", "acc-1", "misskey.io", None); + db.ingest_notes(&[note], &tk("home")).unwrap(); + // 旧世代の JSON (identity 系フィールド無し) を再現 + { + let conn = db.lock().unwrap(); + conn.execute( + "UPDATE notes_cache SET note_json = json_remove(note_json, '$._identity', '$._isOrigin', '$._identityTrusted')", + [], + ) + .unwrap(); + let json: String = conn + .query_row("SELECT note_json FROM notes_cache", [], |r| r.get(0)) + .unwrap(); + assert!(!json.contains("_identity")); + } + let notes = db.get_cached_timeline("acc-1", &tk("home"), 10).unwrap(); + assert_eq!(notes.len(), 1); + assert_eq!(notes[0].identity, "https://misskey.io/notes/n1"); + assert!(notes[0].is_origin); + assert!(notes[0].identity_trusted); + // カラムクエリの走査経路も同じ補完を通る + let out = db + .scan_cached_notes("acc-1", None, &[], 10, 1000, None, |n: &NormalizedNote| { + Some(!n.identity.is_empty()) + }) + .unwrap(); + assert_eq!(out.notes.len(), 1); + } + + #[test] + fn search_cached_notes_across_returns_variants_of_all_accounts() { + let (_dir, db) = temp_db(); + let a = variant( + "n1", + "acc-1", + "a.example", + Some("https://o.example/notes/z"), + ); + let b = variant( + "n2", + "acc-2", + "b.example", + Some("https://o.example/notes/z"), + ); + let other = variant("n3", "acc-3", "c.example", None); + db.ingest_notes(&[a, b, other], &tk("home")).unwrap(); + + let hits = db + .search_cached_notes_across(&["acc-1", "acc-2"], "identity", 10, None, None, false) + .unwrap(); + assert_eq!(hits.len(), 2); + assert!(hits + .iter() + .all(|n| n.identity == "https://o.example/notes/z")); + assert!(db + .search_cached_notes_across(&[], "identity", 10, None, None, false) + .unwrap() + .is_empty()); + // 単一アカウント版は横断版の薄いラッパ + assert_eq!( + db.search_cached_notes("acc-3", "identity", 10) + .unwrap() + .len(), + 1 + ); + } } diff --git a/src/identity.rs b/src/identity.rs new file mode 100644 index 0000000..2721ece --- /dev/null +++ b/src/identity.rs @@ -0,0 +1,197 @@ +//! ノートの同一性キー (identity) の導出。 +//! +//! 複数サーバー (複数アカウント) で観測した同じノートを束ねるための正規化 +//! ActivityPub object id。設計の正本は notedeck#1058。 +//! +//! 規則: +//! - `uri` が無い (ローカルノート) → `https://{host}/notes/{id}` を組み立てる +//! - `uri` がある → そのまま。scheme と host だけ正規化し、末尾が完全一致で +//! `/activity` なら除去する (純粋 Renote を連合先で受けると Announce activity の +//! id が uri になり、origin 側の Renote 行と一致しないため) +//! - フラグメント・クエリ・末尾スラッシュには触れない (本家は uri を完全一致で引く) +//! - host は UTS#46 の ASCII 化 + 小文字、ポート保持。既定ポートは落ちる +//! +//! host の比較 (origin 判定・整合検査) もここで済ませ、フロントには真偽値だけを渡す。 + +use url::Url; + +/// host を正規形 (ASCII 小文字、ポート保持) にする。解釈できなければ小文字化のみ。 +pub fn normalize_host(host: &str) -> String { + let h = host.trim(); + if let Ok(u) = Url::parse(&format!("https://{h}/")) { + if let Some(hs) = u.host_str() { + return match u.port() { + Some(p) => format!("{hs}:{p}"), + None => hs.to_string(), + }; + } + } + h.to_ascii_lowercase() +} + +/// `raw_uri` / 取得元サーバー / サーバー内 ID から identity を導出する。 +pub fn identity_of(raw_uri: Option<&str>, server_host: &str, note_id: &str) -> String { + match raw_uri.map(str::trim) { + None | Some("") => format!("https://{}/notes/{}", normalize_host(server_host), note_id), + Some(raw) => normalize_uri(raw), + } +} + +/// identity の host (正規形)。解釈できなければ None。 +pub fn identity_host(identity: &str) -> Option { + let (_, authority, _) = split_uri(identity)?; + Some(normalize_host(authority)) +} + +/// `scheme://authority rest` に分解する。`://` が無ければ None。 +fn split_uri(uri: &str) -> Option<(&str, &str, &str)> { + let idx = uri.find("://")?; + let scheme = &uri[..idx]; + let after = &uri[idx + 3..]; + let end = after.find(['/', '?', '#']).unwrap_or(after.len()); + Some((scheme, &after[..end], &after[end..])) +} + +fn normalize_uri(raw: &str) -> String { + let Some((scheme, authority, rest)) = split_uri(raw) else { + return raw.to_string(); + }; + if scheme.is_empty() || authority.is_empty() { + return raw.to_string(); + } + let mut out = format!( + "{}://{}{}", + scheme.to_ascii_lowercase(), + normalize_host(authority), + rest + ); + if let Some(stripped) = out.strip_suffix("/activity") { + out = stripped.to_string(); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_note_builds_canonical_uri() { + assert_eq!( + identity_of(None, "misskey.io", "abc123"), + "https://misskey.io/notes/abc123" + ); + assert_eq!( + identity_of(Some(""), "misskey.io", "abc123"), + "https://misskey.io/notes/abc123" + ); + } + + #[test] + fn remote_note_keeps_uri() { + assert_eq!( + identity_of( + Some("https://origin.example/notes/x1"), + "b.example", + "localB" + ), + "https://origin.example/notes/x1" + ); + } + + #[test] + fn pure_renote_announce_id_matches_origin_row() { + // 連合先の Renote 行 (Announce の id) と origin 側の Renote 行 (uri なし) が一致する + let remote = identity_of( + Some("https://origin.example/notes/r1/activity"), + "b.example", + "localB", + ); + let origin = identity_of(None, "origin.example", "r1"); + assert_eq!(remote, origin); + } + + #[test] + fn activity_suffix_is_exact_match_only() { + assert_eq!( + identity_of(Some("https://o.example/notes/x/activity/"), "b", "id"), + "https://o.example/notes/x/activity/" + ); + assert_eq!( + identity_of(Some("https://o.example/notes/x/activity#frag"), "b", "id"), + "https://o.example/notes/x/activity#frag" + ); + } + + #[test] + fn mastodon_uri_kept_and_boost_normalized() { + assert_eq!( + identity_of( + Some("https://mastodon.example/users/alice/statuses/123"), + "b", + "id" + ), + "https://mastodon.example/users/alice/statuses/123" + ); + assert_eq!( + identity_of( + Some("https://mastodon.example/users/alice/statuses/123/activity"), + "b", + "id" + ), + "https://mastodon.example/users/alice/statuses/123" + ); + } + + #[test] + fn scheme_and_host_are_lowercased_but_path_untouched() { + assert_eq!( + identity_of(Some("HTTPS://Origin.Example/notes/AbC"), "b", "id"), + "https://origin.example/notes/AbC" + ); + } + + #[test] + fn unicode_host_is_punycoded_on_both_paths() { + let remote = identity_of(Some("https://日本語.example/notes/x"), "b", "id"); + let local = identity_of(None, "日本語.example", "x"); + assert_eq!(remote, "https://xn--wgv71a119e.example/notes/x"); + assert_eq!(remote, local); + } + + #[test] + fn port_is_preserved_and_default_port_dropped() { + assert_eq!( + identity_of(Some("https://o.example:8443/notes/x"), "b", "id"), + "https://o.example:8443/notes/x" + ); + assert_eq!(normalize_host("O.Example:8443"), "o.example:8443"); + assert_eq!(normalize_host("o.example:443"), "o.example"); + } + + #[test] + fn fragment_query_and_trailing_slash_are_untouched() { + for u in [ + "https://o.example/notes/x#frag", + "https://o.example/notes/x?p=1", + "https://o.example/notes/x/", + ] { + assert_eq!(identity_of(Some(u), "b", "id"), u); + } + } + + #[test] + fn unparseable_uri_is_kept_verbatim_not_emptied() { + assert_eq!(identity_of(Some("not a uri"), "b", "id"), "not a uri"); + assert_eq!(identity_of(Some("urn:x"), "b", "id"), "urn:x"); + } + + #[test] + fn identity_host_extracts_normalized_host() { + assert_eq!( + identity_host("https://Origin.Example:8443/notes/x").as_deref(), + Some("origin.example:8443") + ); + assert_eq!(identity_host("not a uri"), None); + } +} diff --git a/src/lib.rs b/src/lib.rs index 54873e3..703b77f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod error; pub mod event_bus; pub mod format; pub mod http_server; +pub mod identity; pub(crate) mod insecure; pub mod keychain; pub mod models; diff --git a/src/models.rs b/src/models.rs index 18babf7..9576b4e 100644 --- a/src/models.rs +++ b/src/models.rs @@ -136,6 +136,20 @@ pub struct NormalizedNote { pub account_id: String, #[serde(rename = "_serverHost")] pub server_host: String, + /// 同一性キー (正規化 AP object id)。導出は `identity::identity_of` (notedeck#1058)。 + /// 旧 JSON には無いので default で読み、`fill_identity` で補う。 + #[serde(rename = "_identity", default)] + pub identity: String, + /// identity の host == 取得元サーバー (このビューが origin か) + #[serde(rename = "_isOrigin", default)] + pub is_origin: bool, + /// 整合検査: リモート投稿者の host と identity の host が一致するか。 + /// ローカル投稿者 (user.host = None) は identity を自分で組むので常に true。 + #[serde(rename = "_identityTrusted", default)] + pub identity_trusted: bool, + /// サーバーが本文を隠した状態 (packed の isHidden)。ミュート由来の非表示とは別概念。 + #[serde(default)] + pub content_hidden: bool, pub created_at: String, pub text: Option, pub cw: Option, @@ -1450,6 +1464,9 @@ pub struct RawNote { pub visible_user_ids: Vec, #[serde(default)] pub is_favorited: bool, + /// packed の isHidden (followers/specified の非可視、投稿者の隠す設定、未ログイン制限) + #[serde(default)] + pub is_hidden: bool, pub reply: Option>, pub renote: Option>, /// Catch-all for fork-specific fields (e.g., isNoteInYamiMode) @@ -1718,12 +1735,63 @@ impl From for ServerEmoji { // --- Conversion: Raw -> Normalized --- +/// identity / is_origin / identity_trusted を (uri, server_host, id, user.host) から計算する。 +fn identity_fields( + uri: Option<&str>, + server_host: &str, + note_id: &str, + user_host: Option<&str>, +) -> (String, bool, bool) { + let identity = crate::identity::identity_of(uri, server_host, note_id); + let ident_host = crate::identity::identity_host(&identity); + let is_origin = + ident_host.as_deref() == Some(crate::identity::normalize_host(server_host).as_str()); + let identity_trusted = match user_host { + Some(h) => ident_host.as_deref() == Some(crate::identity::normalize_host(h).as_str()), + None => true, + }; + (identity, is_origin, identity_trusted) +} + +impl NormalizedNote { + /// identity 系フィールドを再計算する (reply / renote も再帰)。 + /// DB から読み出した旧 JSON (`_identity` 無し) の補完に使う。決定的なので + /// 既に値がある行に適用しても同値になる。 + pub fn fill_identity(&mut self) { + let (identity, is_origin, identity_trusted) = identity_fields( + self.uri.as_deref(), + &self.server_host, + &self.id, + self.user.host.as_deref(), + ); + self.identity = identity; + self.is_origin = is_origin; + self.identity_trusted = identity_trusted; + if let Some(r) = self.reply.as_mut() { + r.fill_identity(); + } + if let Some(r) = self.renote.as_mut() { + r.fill_identity(); + } + } +} + impl RawNote { pub fn normalize(self, account_id: &str, server_host: &str) -> NormalizedNote { + let (identity, is_origin, identity_trusted) = identity_fields( + self.uri.as_deref(), + server_host, + &self.id, + self.user.host.as_deref(), + ); NormalizedNote { id: self.id, account_id: account_id.to_string(), server_host: server_host.to_string(), + identity, + is_origin, + identity_trusted, + content_hidden: self.is_hidden, created_at: self.created_at, text: self.text, cw: self.cw, @@ -2837,4 +2905,97 @@ mod tests { assert_eq!(back.host, "misskey.io"); assert_eq!(back.software_version, "2024.1.0"); } + + // ---- identity (notedeck#1058) ---- + + #[test] + fn normalize_derives_identity_for_local_note() { + let raw: RawNote = serde_json::from_value(raw_note_json()).unwrap(); + let note = raw.normalize("acc1", "misskey.io"); + assert_eq!(note.identity, "https://misskey.io/notes/n1"); + assert!(note.is_origin); + assert!(note.identity_trusted); + assert!(!note.content_hidden); + } + + #[test] + fn normalize_derives_identity_for_remote_note_and_checks_author_host() { + let mut v = raw_note_json(); + v["uri"] = serde_json::json!("https://origin.example/notes/x1"); + v["user"]["host"] = serde_json::json!("origin.example"); + let note: NormalizedNote = serde_json::from_value::(v.clone()) + .unwrap() + .normalize("acc1", "misskey.io"); + assert_eq!(note.identity, "https://origin.example/notes/x1"); + assert!(!note.is_origin); + assert!(note.identity_trusted); + + // 投稿者 host と uri の host が食い違えば不整合 + v["user"]["host"] = serde_json::json!("evil.example"); + let spoofed: NormalizedNote = serde_json::from_value::(v) + .unwrap() + .normalize("acc1", "misskey.io"); + assert!(!spoofed.identity_trusted); + } + + #[test] + fn normalize_passes_is_hidden_through_as_content_hidden() { + let mut v = raw_note_json(); + v["isHidden"] = serde_json::json!(true); + let note = serde_json::from_value::(v) + .unwrap() + .normalize("acc1", "misskey.io"); + assert!(note.content_hidden); + // mode_flags には混ざらない + assert!(!note.mode_flags.contains_key("isHidden")); + } + + #[test] + fn normalize_recurses_identity_into_renote_and_reply() { + let mut v = raw_note_json(); + let mut inner = raw_note_json(); + inner["id"] = serde_json::json!("inner1"); + inner["uri"] = serde_json::json!("https://origin.example/notes/inner1"); + inner["user"]["host"] = serde_json::json!("origin.example"); + v["renote"] = inner.clone(); + v["reply"] = inner; + let note = serde_json::from_value::(v) + .unwrap() + .normalize("acc1", "misskey.io"); + let renote = note.renote.as_ref().unwrap(); + assert_eq!(renote.identity, "https://origin.example/notes/inner1"); + assert!(!renote.is_origin); + assert_eq!( + note.reply.as_ref().unwrap().identity, + "https://origin.example/notes/inner1" + ); + } + + #[test] + fn fill_identity_recomputes_from_legacy_json() { + let raw: RawNote = serde_json::from_value(raw_note_json()).unwrap(); + let note = raw.normalize("acc1", "misskey.io"); + let mut json: Value = serde_json::to_value(¬e).unwrap(); + let obj = json.as_object_mut().unwrap(); + obj.remove("_identity"); + obj.remove("_isOrigin"); + obj.remove("_identityTrusted"); + let mut back: NormalizedNote = serde_json::from_value(json).unwrap(); + assert_eq!(back.identity, ""); + back.fill_identity(); + assert_eq!(back.identity, note.identity); + assert_eq!(back.is_origin, note.is_origin); + assert_eq!(back.identity_trusted, note.identity_trusted); + } + + #[test] + fn identity_fields_serialize_with_frontend_names() { + let raw: RawNote = serde_json::from_value(raw_note_json()).unwrap(); + let note = raw.normalize("acc1", "misskey.io"); + let json = serde_json::to_value(¬e).unwrap(); + assert_eq!(json["_identity"], "https://misskey.io/notes/n1"); + assert_eq!(json["_isOrigin"], true); + assert_eq!(json["_identityTrusted"], true); + assert_eq!(json["contentHidden"], false); + } } From e2fd037b6e96d9206db80bd2916d601e9ad6a44f Mon Sep 17 00:00:00 2001 From: hitalin Date: Sat, 12 Sep 2026 14:19:42 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore(clippy):=20=E6=96=B0=E3=81=97?= =?UTF-8?q?=E3=81=84=20stable=20=E3=81=AE=20result=5Flarge=5Ferr=20?= =?UTF-8?q?=E3=82=92=20axum=20middleware=20=E3=81=A7=E8=A8=B1=E5=8F=AF?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI の stable toolchain が進み、Result を返す認証 middleware が result_large_err で落ちるようになった。axum の from_fn の 定型なので Err を Box にはできず、この関数に限って許可する。 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01FkkqpTo56FUVjoksbhLj1n --- src/http_server.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/http_server.rs b/src/http_server.rs index d3c349a..5cd0fd3 100644 --- a/src/http_server.rs +++ b/src/http_server.rs @@ -322,6 +322,11 @@ pub fn endpoints_from_spec(openapi: &utoipa::openapi::OpenApi) -> Vec { // --- Auth middleware --- +// axum の middleware は「通す (Next の応答) / 弾く (エラー応答)」を +// Result で短絡させるのが定型で、Err 側の Response を +// Box にすると from_fn の型に乗らない。新しい clippy (result_large_err) が +// Err の大きさだけを見て警告するので、この関数に限って許可する +#[allow(clippy::result_large_err)] async fn auth_middleware( State(state): State, req: Request,