getProfile answers the account's tangled profile record, not only its avatar
Signed-off-by: dawn <90008@klbr.net>
3919 unmodified linesExpand all3920392139223923392439253926392739283060 unmodified linesExpand all3919 unmodified linesExpand all "tokio", "toml", "tracing", "tracing-subscriber", "unicode-segmentation", "url",]
[[package]]3060 unmodified linesExpand all46 unmodified lines47484950515253545540 unmodified lines46 unmodified linestoml = { version = "0.9", default-features = false, features = ["parse", "serde"] }tracing = "0.1"tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }url = { version = "2", features = ["serde"] }unicode-segmentation = "1"futures = "0.3"gix = { version = "0.84", default-features = false }memmap2 = "0.9"gix-pack = { version = "0.71", default-features = false }40 unmodified lines123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384use jacquard_common::types::string::{Did, Handle};use serde_json::{Map, Value, json};use unicode_segmentation::UnicodeSegmentation as _;
const STATS: [&str; 7] = [ "merged-pull-request-count", "closed-pull-request-count", "open-pull-request-count", "open-issue-count", "closed-issue-count", "repository-count", "star-count",];const MAX_URI_BYTES: usize = 8192;const MAX_LABELS: usize = 10;const MAX_LABEL_BYTES: usize = 128;
/// the org.tangled.actor.profile fields within the lexicon's limits. free text is cut to fit,/// anything else out of shape is dropped. the avatar is the knot's own copy, so it isn't keptpub(crate) fn sanitized(value: &Value) -> Map<String, Value> { let fields = [ ("description", text(&value["description"], 2560, 256)), ("location", text(&value["location"], 400, 40)), ("pronouns", text(&value["pronouns"], 200, 30)), ("links", list(&value["links"], 5, is_uri)), ( "stats", list(&value["stats"], 2, |stat| STATS.contains(&stat)), ), ( "pinnedRepositories", list(&value["pinnedRepositories"], 6, |did| Did::new(did).is_ok()), ), ( "isOrganization", value["isOrganization"].as_bool().map(Value::Bool), ), ( "preferredHandle", value["preferredHandle"] .as_str() .filter(|handle| Handle::new(*handle).is_ok()) .map(Value::from), ), ("labels", labels(&value["labels"])), ]; fields .into_iter() .filter_map(|(field, kept)| Some((field.to_string(), kept?))) .collect()}
fn text(value: &Value, max_bytes: usize, max_graphemes: usize) -> Option<Value> { let text = value.as_str()?; let end = text .grapheme_indices(true) .take(max_graphemes) .map(|(start, grapheme)| start + grapheme.len()) .take_while(|&end| end <= max_bytes) .last() .unwrap_or(0); Some(text[..end].into())}
fn list(value: &Value, max: usize, valid: impl Fn(&str) -> bool) -> Option<Value> { let items = value.as_array()?.iter().filter_map(Value::as_str); Some(items.filter(|item| valid(item)).take(max).collect())}
fn is_uri(uri: &str) -> bool { uri.len() <= MAX_URI_BYTES && url::Url::parse(uri).is_ok()}
fn labels(value: &Value) -> Option<Value> { let values: Vec<Value> = value["values"] .as_array()? .iter() .filter_map(|label| label["val"].as_str()) .filter(|val| val.len() <= MAX_LABEL_BYTES) .take(MAX_LABELS) .map(|val| json!({ "val": val })) .collect(); Some(json!({ "$type": "com.atproto.label.defs#selfLabels", "values": values }))}12 unmodified lines1314151617181920213 unmodified lines252627282928303132333413 unmodified lines4849505152535455565758153 unmodified linesExpand all21221321421521621721821922066 unmodified lines2872882892902852862912922932942952902962972982993003012963023033042993003053063073023083093103113123133143151 unmodified line31731831932032132232332432512 unmodified lines3383393403413423433443453463473483323333493503513523533543383393403413423433553563573583593603483613623633643653663543673683693703713723733743753763773603783793803813823833843853863873883893903913923933943953963973983994004014024033 unmodified lines40740840941037241141241341441541641741898 unmodified lines12 unmodified linesuse knot_record::manifest::{MANIFEST_COLLECTION, MANIFEST_RKEY, RepoDescription};use knot_runtime::{HttpLimits, HttpRequest, HttpTransport, NetworkError, ReqwestHttp};use knot_types::{AccountDid, BlobCid, RepoDid, Sha256Digest};use serde::{Deserialize, Serialize};use serde_json::{Map, Value};use url::Url;
use crate::config::ValidatedConfig;use crate::datastore::{Datastore, schema};3 unmodified lines
const REFRESH_TIMEOUT: Duration = Duration::from_secs(3);const AVATAR_TIMEOUT: Duration = Duration::from_secs(10);const AVATAR_MAX_BYTES: u64 = 1_000_000;const TANGLED_PROFILE: &str = "org.tangled.actor.profile";const PROFILE_COLLECTIONS: [&str; 2] = ["org.tangled.actor.profile", "app.bsky.actor.profile"];const PROFILE_COLLECTIONS: [&str; 2] = [TANGLED_PROFILE, "app.bsky.actor.profile"];/// owners are verified on every sync, everyone else the social records name this oftenpub(crate) const AUTHOR_REVERIFY_SECS: u64 = 6 * 60 * 60;const REFRESHES_IN_FLIGHT: usize = 16;
13 unmodified lines #[serde(default, skip_serializing_if = "Option::is_none")] pub avatar_cid: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub avatar_ext: Option<String>, /// the account's org.tangled.actor.profile record, sanitized #[serde(default, skip_serializing_if = "Option::is_none")] pub profile: Option<Map<String, Value>>,}
impl StoredHandle { pub(crate) fn avatar_file(&self) -> Option<String> {153 unmodified linesExpand all handle, verified_at_unix: 0, avatar_cid: None, avatar_ext: None, profile: None, }, ); } None => {66 unmodified lines .collect(); let found = verified(atproto, due).await; let refreshed: Vec<_> = futures::stream::iter(found.into_iter().map( |(raw_did, (handle, endpoint))| async move { let avatar = tokio::time::timeout(AVATAR_TIMEOUT, avatar_of(pds, avatars, raw_did, &endpoint)) let found = tokio::time::timeout(AVATAR_TIMEOUT, profile_of(pds, avatars, raw_did, &endpoint)) .await .ok() .flatten(); (raw_did, handle, avatar) (raw_did, handle, found) }, )) .buffer_unordered(REFRESHES_IN_FLIGHT) .collect() .await; for (raw_did, handle, avatar) in refreshed { for (raw_did, handle, found) in refreshed { stored.unverified.remove(raw_did); let previous = stored.entries.get(raw_did); let (avatar_cid, avatar_ext) = match avatar { Some(found) => found.unzip(), let ((avatar_cid, avatar_ext), profile) = match found { Some(found) => (found.avatar.unzip(), found.profile), None => previous .map(|entry| (entry.avatar_cid.clone(), entry.avatar_ext.clone())) .map(|entry| { let avatar = (entry.avatar_cid.clone(), entry.avatar_ext.clone()); (avatar, entry.profile.clone()) }) .unwrap_or_default(), }; stored.entries.insert( raw_did.to_string(),1 unmodified line handle, verified_at_unix: now_unix, avatar_cid, avatar_ext, profile, }, ); } let is_named =12 unmodified lines let resolved = atproto.resolve_handle_to_did(handle).await.ok()?; (resolved == did).then(|| (handle.as_str().to_string(), identity.pds.url().clone()))}
#[derive(Debug, PartialEq, Eq)]pub(super) struct Fetched { /// the cid and extension of the avatar this knot keeps, `None` when it can't serve one pub avatar: Option<(String, String)>, pub profile: Option<Map<String, Value>>,}
/// `None` means the pds failed, `Some(None)` means no avatar this knot can serve.pub(super) async fn avatar_of<P: HttpTransport>(/// `None` means the pds failed.pub(super) async fn profile_of<P: HttpTransport>( pds: &P, avatars: &Path, did: &str, endpoint: &Url,) -> Option<Option<(String, String)>> { let xrpc = |method: &str, query: &[(&str, &str)]| { let mut url = endpoint.join(&format!("xrpc/{method}")).ok()?; url.query_pairs_mut().extend_pairs(query); Some(HttpRequest::get(url)) };) -> Option<Fetched> { let mut link = None; let mut profile = None; for collection in PROFILE_COLLECTIONS { let query = [("repo", did), ("collection", collection), ("rkey", "self")]; let response = pds .execute(xrpc("com.atproto.repo.getRecord", &query)?) .execute(xrpc(endpoint, "com.atproto.repo.getRecord", &query)?) .await .ok()?; if response.status.is_server_error() { return None; } let record: serde_json::Value = serde_json::from_slice(&response.body).unwrap_or_default(); let record: Value = serde_json::from_slice(&response.body).unwrap_or_default(); if collection == TANGLED_PROFILE && response.status.is_success() { profile = record["value"] .is_object() .then(|| super::actor_profile::sanitized(&record["value"])); } if let Some(found) = record["value"]["avatar"]["ref"]["$link"].as_str() { link = Some(found.to_string()); break; } } let Some(cid) = link.and_then(|link| link.parse().ok().and_then(BlobCid::new)) else { let avatar = match link { Some(link) => avatar_of(pds, avatars, did, endpoint, &link).await?, None => None, }; Some(Fetched { avatar, profile })}
fn xrpc(endpoint: &Url, method: &str, query: &[(&str, &str)]) -> Option<HttpRequest> { let mut url = endpoint.join(&format!("xrpc/{method}")).ok()?; url.query_pairs_mut().extend_pairs(query); Some(HttpRequest::get(url))}
/// `None` means the pds failed, `Some(None)` means no avatar this knot can serve.async fn avatar_of<P: HttpTransport>( pds: &P, avatars: &Path, did: &str, endpoint: &Url, link: &str,) -> Option<Option<(String, String)>> { let Some(cid) = link.parse().ok().and_then(BlobCid::new) else { return Some(None); }; let name = cid.to_string(); let served = crate::xrpc::social::avatar_types();3 unmodified lines { return Some(Some((name, ext.to_string()))); } let query = [("did", did), ("cid", name.as_str())]; let response = match pds.execute(xrpc("com.atproto.sync.getBlob", &query)?).await { let response = match pds .execute(xrpc(endpoint, "com.atproto.sync.getBlob", &query)?) .await { Ok(response) => response, Err(NetworkError::TooLarge { .. }) => return Some(None), Err(_) => return None, };98 unmodified lines21 unmodified lines222324252627282930663 unmodified linesExpand all21 unmodified linespub const THEME_COLOR_DARK: &str = "#1b1519";const UNWIND_THRESHOLD: usize = 12;const POPOVER_CHAR_THRESHOLD: usize = 45;
mod actor_profile;mod handles;pub mod og;#[cfg(test)]mod tests;663 unmodified linesExpand all123234567548 unmodified linesExpand all556557558559560561562563564565566567568569115 unmodified linesExpand all6856866876886896906916926936946956966976986997006867017027037043 unmodified lines708709710711698712713714715716703717718705719720707721722709723724725726727714728729716730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830274 unmodified linesExpand alluse super::handles::{ AUTHOR_REVERIFY_SECS, Named, avatar_of, published_description, refresh_handles_with, verify_unseen_with, AUTHOR_REVERIFY_SECS, Fetched, Named, StoredHandle, profile_of, published_description, refresh_handles_with, verify_unseen_with,};use super::*;use crate::config::IndexConfig;use crate::datastore::{Datastore, schema};548 unmodified linesExpand all assert_eq!( stored.entries["did:plc:verified"].avatar_cid.as_deref(), Some(cid.as_str()) ); assert_eq!( stored.entries["did:plc:verified"].profile, serde_json::json!({ "description": "hi" }) .as_object() .cloned() );
offline.store(false, Ordering::SeqCst); let lookups = author_lookups.load(Ordering::SeqCst); let soon = 1_000 + AUTHOR_REVERIFY_SECS - 1;115 unmodified linesExpand all }) })}
async fn avatar( pds: &impl knot_runtime::HttpTransport, avatars: &Path, endpoint: &url::Url,) -> Option<Option<(String, String)>> { let found = profile_of(pds, avatars, "did:plc:verified", endpoint).await; found.map(|found| found.avatar)}
#[tokio::test]async fn avatars_are_kept_only_as_the_image_their_cid_names() { use http::StatusCode; const DID: &str = "did:plc:verified"; let root = tempfile::tempdir().unwrap(); let avatars = root.path().join("avatars"); let endpoint = url::Url::parse("https://pds.example.test/").unwrap(); let tangled = |cid: &str| BTreeMap::from([("org.tangled.actor.profile", avatar_ref(cid))]);3 unmodified lines let pds = fake_pds( tangled(&blob_cid(svg)), Some((StatusCode::OK, svg.to_vec())), ); assert_eq!(avatar_of(&pds, &avatars, DID, &endpoint).await, Some(None)); assert_eq!(avatar(&pds, &avatars, &endpoint).await, Some(None)); let pds = fake_pds( tangled(&cid), Some((StatusCode::OK, b"\x89PNG other".to_vec())), ); assert_eq!(avatar_of(&pds, &avatars, DID, &endpoint).await, Some(None)); assert_eq!(avatar(&pds, &avatars, &endpoint).await, Some(None)); let pds = fake_pds(tangled("../../etc/passwd"), None); assert_eq!(avatar_of(&pds, &avatars, DID, &endpoint).await, Some(None)); assert_eq!(avatar(&pds, &avatars, &endpoint).await, Some(None)); let pds = fake_pds(BTreeMap::new(), None); assert_eq!(avatar_of(&pds, &avatars, DID, &endpoint).await, Some(None)); assert_eq!(avatar(&pds, &avatars, &endpoint).await, Some(None)); let pds = fake_pds(tangled(&cid), Some((StatusCode::BAD_GATEWAY, Vec::new()))); assert_eq!(avatar_of(&pds, &avatars, DID, &endpoint).await, None); assert_eq!(avatar(&pds, &avatars, &endpoint).await, None); assert!(!avatars.exists(), "nothing rejected is written");
let fetched = Some(Some((cid.clone(), "png".to_string()))); let pds = fake_pds(tangled(&cid), Some((StatusCode::OK, PNG.to_vec()))); assert_eq!(avatar_of(&pds, &avatars, DID, &endpoint).await, fetched); assert_eq!(avatar(&pds, &avatars, &endpoint).await, fetched); let cached = fake_pds(tangled(&cid), None); assert_eq!(avatar_of(&cached, &avatars, DID, &endpoint).await, fetched); assert_eq!(avatar(&cached, &avatars, &endpoint).await, fetched);}
#[tokio::test]async fn the_tangled_profile_is_kept_within_the_lexicon() { use http::StatusCode; use serde_json::json; let root = tempfile::tempdir().unwrap(); let avatars = root.path().join("avatars"); let endpoint = url::Url::parse("https://pds.example.test/").unwrap(); let cid = blob_cid(PNG); let pinned = "did:plc:hqvpokkyso2k22zc76czmcj4"; let links: Vec<String> = (0..7).map(|n| format!("https://{n}.test")).collect(); let offered = [vec!["not a uri".to_string()], links.clone()].concat(); let mut tangled = avatar_ref(&cid); tangled.as_object_mut().unwrap().extend( json!({ "$type": "org.tangled.actor.profile", "description": "e\u{301}".repeat(300), "location": "x".repeat(50), "pronouns": "it,she", "links": offered, "stats": ["star-count", "bogus", "repository-count", "open-issue-count"], "pinnedRepositories": ["nope", pinned], "isOrganization": "yes", "preferredHandle": "ptr.pet", "labels": { "values": [{ "val": "bot" }, { "val": "x".repeat(129) }], "extra": 1 }, "createdAt": "2026-10-13T12:55:23.571710Z", "followers": 9, }) .as_object() .unwrap() .clone(), ); let bsky = json!({ "description": "from bsky", "displayName": "ptr" }); let pds = fake_pds( BTreeMap::from([ ("org.tangled.actor.profile", tangled), ("app.bsky.actor.profile", bsky.clone()), ]), Some((StatusCode::OK, PNG.to_vec())), ); let found = profile_of(&pds, &avatars, "did:plc:verified", &endpoint).await; let expected = json!({ "description": "e\u{301}".repeat(256), "location": "x".repeat(40), "pronouns": "it,she", "links": links[..5], "stats": ["star-count", "repository-count"], "pinnedRepositories": [pinned], "preferredHandle": "ptr.pet", "labels": { "$type": "com.atproto.label.defs#selfLabels", "values": [{ "val": "bot" }] }, }); assert_eq!( found, Some(Fetched { avatar: Some((cid.clone(), "png".to_string())), profile: expected.as_object().cloned(), }) );
let family = "\u{1f469}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}"; let wide = json!({ "description": family.repeat(200) }); let pds = fake_pds(BTreeMap::from([("org.tangled.actor.profile", wide)]), None); let found = profile_of(&pds, &avatars, "did:plc:verified", &endpoint).await; let description = found.unwrap().profile.unwrap()["description"].clone(); assert_eq!( description, family.repeat(102), "25-byte graphemes, cut within 2560 bytes" );
let pds = fake_pds( BTreeMap::from([("app.bsky.actor.profile", avatar_ref(&cid))]), None, ); let found = profile_of(&pds, &avatars, "did:plc:verified", &endpoint).await; assert_eq!( found, Some(Fetched { avatar: Some((cid, "png".to_string())), profile: None, }), "a bsky profile only lends its avatar" );
let old: StoredHandle = serde_json::from_value(json!({ "handle": "ptr.pet", "verified_at_unix": 1, "avatar_cid": "bafkrei", "avatar_ext": "png", })) .unwrap(); assert_eq!(old.profile, None, "an older cache reads as no profile yet");}
#[tokio::test]async fn reads_manifest_description_from_settled_chain() { use knot_record::manifest::{ManifestMeta, ManifestRecord}; use knot_runtime::{K256Signer, SeededEntropy}; use knot_types::{MstKey, OwnerDid, RepoName, UnixSeconds};274 unmodified linesExpand all