feat(editor): show authenticated profile avatars
This commit is contained in:
parent
435f0b4091
commit
f1ae6002ec
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -3849,8 +3849,10 @@ dependencies = [
|
|||
"base64",
|
||||
"dirs 5.0.1",
|
||||
"futures",
|
||||
"getrandom 0.3.4",
|
||||
"github-copilot-sdk",
|
||||
"glam",
|
||||
"hmac",
|
||||
"jian-ops-schema",
|
||||
"memmap2",
|
||||
"op-acp",
|
||||
|
|
@ -3874,6 +3876,7 @@ dependencies = [
|
|||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"skia-safe",
|
||||
"tokio",
|
||||
"zip",
|
||||
|
|
|
|||
|
|
@ -73,13 +73,13 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn decodes_signed_in_payload() {
|
||||
let payload = r#"{"display_name":"Kay","primary_email":"kay@example.com","avatar_url":null,"device_id":"d1"}"#;
|
||||
let payload = r#"{"display_name":"Kay","primary_email":"kay@example.com","avatar_url":"https://cdn.example/kay.png","device_id":"d1"}"#;
|
||||
assert_eq!(
|
||||
AuthStatus::decode(4, Some(payload)),
|
||||
AuthStatus::SignedIn {
|
||||
display_name: "Kay".to_string(),
|
||||
primary_email: Some("kay@example.com".to_string()),
|
||||
avatar_url: None,
|
||||
avatar_url: Some("https://cdn.example/kay.png".to_string()),
|
||||
device_id: "d1".to_string(),
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,13 @@ pub const API_PREFIX: &str = "/api/auth/";
|
|||
/// `GET` — session/status snapshot (seeds `account_ui_available`).
|
||||
pub const STATUS: &str = "/api/auth/status";
|
||||
|
||||
/// `POST` — bounded current-account avatar bytes from the same-origin daemon.
|
||||
///
|
||||
/// JSON POST is intentional: unlike a GET, a cross-site image element cannot
|
||||
/// trigger the daemon's upstream fetch without passing the existing
|
||||
/// same-origin and JSON content-type gates.
|
||||
pub const AVATAR: &str = "/api/auth/avatar";
|
||||
|
||||
/// `POST` — begin a device-login pairing. The daemon holds the request
|
||||
/// until the pairing's verification URI is known.
|
||||
pub const LOGIN_BEGIN: &str = "/api/auth/login/begin";
|
||||
|
|
@ -36,7 +43,14 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn api_routes_share_the_gating_prefix() {
|
||||
for route in [STATUS, LOGIN_BEGIN, LOGIN_STATUS, LOGIN_CANCEL, LOGOUT] {
|
||||
for route in [
|
||||
STATUS,
|
||||
AVATAR,
|
||||
LOGIN_BEGIN,
|
||||
LOGIN_STATUS,
|
||||
LOGIN_CANCEL,
|
||||
LOGOUT,
|
||||
] {
|
||||
assert!(
|
||||
route.starts_with(API_PREFIX),
|
||||
"{route} outside {API_PREFIX}"
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
//! Bounded host/widget handoff for verified collaboration avatars.
|
||||
//! Bounded host/widget handoff for verified profile avatars.
|
||||
//!
|
||||
//! URLs arrive only from the authenticated collaboration roster and stay in
|
||||
//! this ephemeral process-local registry; encoded bytes stay in its LRU.
|
||||
//! Neither enters `EditorState`, document serialization, or debug output.
|
||||
//! Widgets read only by epoch-local participant key; the desktop host owns
|
||||
//! HTTPS and the existing image decode workers own rasterization.
|
||||
//! URLs arrive from authenticated account/collaboration profiles and stay in
|
||||
//! this ephemeral process-local registry; encoded bytes stay in its LRU. The
|
||||
//! desktop host owns SSRF-safe HTTPS and the existing image decode workers own
|
||||
//! rasterization.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::fmt;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const MAX_AVATAR_ENCODED_BYTES: usize = 512 * 1024;
|
||||
pub const MAX_AVATAR_SOURCE_EDGE_PX: u32 = 1_024;
|
||||
|
|
@ -17,9 +17,15 @@ pub const AVATAR_DECODE_EDGE_PX: u32 = 64;
|
|||
|
||||
const MAX_AVATAR_URL_BYTES: usize = 2_048;
|
||||
const MAX_PARTICIPANT_KEY_BYTES: usize = 256;
|
||||
const MAX_ACCOUNT_REVISION_BYTES: usize = 128;
|
||||
const COLLAB_KEY_PREFIX: &str = "collab:";
|
||||
const MAX_REGISTRY_KEY_BYTES: usize = MAX_PARTICIPANT_KEY_BYTES + COLLAB_KEY_PREFIX.len();
|
||||
const ACCOUNT_AVATAR_KEY: &str = "account:current";
|
||||
const AVATAR_CACHE_BYTE_BUDGET: usize = 8 * 1024 * 1024;
|
||||
const AVATAR_CACHE_MAX_ENTRIES: usize = 64;
|
||||
const AVATAR_PENDING_CAP: usize = 32;
|
||||
const ACCOUNT_AVATAR_MAX_RETRIES: u8 = 6;
|
||||
const ACCOUNT_AVATAR_MAX_RETRY_DELAY: Duration = Duration::from_secs(30);
|
||||
const IMAGE_ID_PREFIX: u64 = 0xa710_0000_0000_0000;
|
||||
|
||||
/// Opaque request token. Its custom `Debug` deliberately omits the URL and
|
||||
|
|
@ -37,6 +43,15 @@ impl CollabAvatarFetchRequest {
|
|||
pub fn url(&self) -> &str {
|
||||
&self.url
|
||||
}
|
||||
|
||||
/// Whether this request belongs to the locally authenticated account.
|
||||
///
|
||||
/// Remote collaboration participants always occupy the separate
|
||||
/// `collab:` namespace and therefore cannot opt into account-only host
|
||||
/// fetch policy.
|
||||
pub fn is_current_account(&self) -> bool {
|
||||
self.participant_key == ACCOUNT_AVATAR_KEY
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for CollabAvatarFetchRequest {
|
||||
|
|
@ -81,6 +96,8 @@ struct AvatarSlot {
|
|||
image_id: u64,
|
||||
state: SlotState,
|
||||
last_used: u64,
|
||||
failure_count: u8,
|
||||
retry_at: Option<Instant>,
|
||||
}
|
||||
|
||||
struct AvatarRegistry {
|
||||
|
|
@ -155,6 +172,8 @@ impl AvatarRegistry {
|
|||
image_id,
|
||||
state: SlotState::Waiting,
|
||||
last_used: tick,
|
||||
failure_count: 0,
|
||||
retry_at: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -196,11 +215,29 @@ impl AvatarRegistry {
|
|||
|
||||
fn ready(&mut self, participant_key: &str) -> Option<CollabAvatarImage> {
|
||||
if participant_key.is_empty()
|
||||
|| participant_key.len() > MAX_PARTICIPANT_KEY_BYTES
|
||||
|| participant_key.len() > MAX_REGISTRY_KEY_BYTES
|
||||
|| participant_key.chars().any(char::is_control)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if participant_key == ACCOUNT_AVATAR_KEY {
|
||||
let retry_url = self.slots.get_mut(participant_key).and_then(|slot| {
|
||||
let retry_due = matches!(slot.state, SlotState::Failed)
|
||||
&& slot
|
||||
.retry_at
|
||||
.is_some_and(|deadline| Instant::now() >= deadline);
|
||||
if matches!(slot.state, SlotState::Waiting) || retry_due {
|
||||
slot.state = SlotState::Waiting;
|
||||
slot.retry_at = None;
|
||||
Some(slot.url.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(url) = retry_url {
|
||||
let _ = self.lookup(participant_key, &url);
|
||||
}
|
||||
}
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
let tick = self.tick;
|
||||
let slot = self.slots.get_mut(participant_key)?;
|
||||
|
|
@ -269,6 +306,8 @@ impl AvatarRegistry {
|
|||
slot.last_used = self.tick;
|
||||
match encoded {
|
||||
Some(encoded) => {
|
||||
slot.failure_count = 0;
|
||||
slot.retry_at = None;
|
||||
self.cached_bytes = self.cached_bytes.saturating_add(encoded.len());
|
||||
self.encoded.insert(slot.image_id, encoded);
|
||||
slot.state = SlotState::Ready;
|
||||
|
|
@ -279,6 +318,18 @@ impl AvatarRegistry {
|
|||
}
|
||||
None => {
|
||||
slot.state = SlotState::Failed;
|
||||
if request.participant_key == ACCOUNT_AVATAR_KEY
|
||||
&& slot.failure_count < ACCOUNT_AVATAR_MAX_RETRIES
|
||||
{
|
||||
slot.failure_count += 1;
|
||||
slot.retry_at = Some(
|
||||
Instant::now()
|
||||
+ account_avatar_retry_delay(slot.failure_count)
|
||||
.min(ACCOUNT_AVATAR_MAX_RETRY_DELAY),
|
||||
);
|
||||
} else {
|
||||
slot.retry_at = None;
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
|
@ -288,16 +339,105 @@ impl AvatarRegistry {
|
|||
self.encoded.get(&image_id).map(Arc::clone)
|
||||
}
|
||||
|
||||
fn has_background_work(&self) -> bool {
|
||||
!self.pending.is_empty()
|
||||
|| self.slots.get(ACCOUNT_AVATAR_KEY).is_some_and(|slot| {
|
||||
matches!(
|
||||
slot.state,
|
||||
SlotState::Waiting | SlotState::Queued | SlotState::InFlight
|
||||
) || (matches!(slot.state, SlotState::Failed) && slot.retry_at.is_some())
|
||||
})
|
||||
}
|
||||
|
||||
fn install_ready(
|
||||
&mut self,
|
||||
participant_key: &str,
|
||||
source_identity: &str,
|
||||
bytes: Vec<u8>,
|
||||
) -> bool {
|
||||
let Some(encoded) = validate_encoded_avatar(bytes) else {
|
||||
self.remove_slot(participant_key);
|
||||
return false;
|
||||
};
|
||||
let changed = self
|
||||
.slots
|
||||
.get(participant_key)
|
||||
.is_some_and(|slot| slot.url != source_identity);
|
||||
if changed {
|
||||
self.remove_slot(participant_key);
|
||||
}
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
let tick = self.tick;
|
||||
if !self.slots.contains_key(participant_key) {
|
||||
if self.max_entries == 0 {
|
||||
return false;
|
||||
}
|
||||
self.evict_to_entry_limit(self.max_entries.saturating_sub(1));
|
||||
let image_id = IMAGE_ID_PREFIX | self.next_image_id;
|
||||
self.next_image_id = self.next_image_id.wrapping_add(1).max(1);
|
||||
self.slots.insert(
|
||||
participant_key.to_string(),
|
||||
AvatarSlot {
|
||||
url: source_identity.to_string(),
|
||||
profile_generation: 1,
|
||||
image_id,
|
||||
state: SlotState::Ready,
|
||||
last_used: tick,
|
||||
failure_count: 0,
|
||||
retry_at: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
let slot = self
|
||||
.slots
|
||||
.get_mut(participant_key)
|
||||
.expect("ready slot was inserted above");
|
||||
slot.last_used = tick;
|
||||
slot.state = SlotState::Ready;
|
||||
slot.failure_count = 0;
|
||||
slot.retry_at = None;
|
||||
if let Some(previous) = self.encoded.insert(slot.image_id, encoded.clone()) {
|
||||
self.cached_bytes = self.cached_bytes.saturating_sub(previous.len());
|
||||
}
|
||||
self.cached_bytes = self.cached_bytes.saturating_add(encoded.len());
|
||||
self.evict_over_budget();
|
||||
self.slots
|
||||
.get(participant_key)
|
||||
.is_some_and(|slot| matches!(slot.state, SlotState::Ready))
|
||||
}
|
||||
|
||||
fn begin_session_generation(&mut self, generation: u64) -> bool {
|
||||
let account_request_invalidated = self
|
||||
.slots
|
||||
.get(ACCOUNT_AVATAR_KEY)
|
||||
.is_some_and(|slot| matches!(slot.state, SlotState::Queued | SlotState::InFlight));
|
||||
let changed = self.session_generation != generation
|
||||
|| !self.pending.is_empty()
|
||||
|| !self.slots.is_empty()
|
||||
|| !self.encoded.is_empty();
|
||||
|| self.slots.keys().any(|key| key != ACCOUNT_AVATAR_KEY)
|
||||
|| account_request_invalidated;
|
||||
self.session_generation = generation;
|
||||
|
||||
let stale_keys: Vec<String> = self
|
||||
.slots
|
||||
.keys()
|
||||
.filter(|key| key.as_str() != ACCOUNT_AVATAR_KEY)
|
||||
.cloned()
|
||||
.collect();
|
||||
for key in stale_keys {
|
||||
self.remove_slot(&key);
|
||||
}
|
||||
self.pending.clear();
|
||||
self.slots.clear();
|
||||
self.encoded.clear();
|
||||
self.cached_bytes = 0;
|
||||
let account_retry_url = self.slots.get_mut(ACCOUNT_AVATAR_KEY).and_then(|account| {
|
||||
if matches!(account.state, SlotState::Queued | SlotState::InFlight) {
|
||||
account.state = SlotState::Waiting;
|
||||
Some(account.url.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if let Some(url) = account_retry_url {
|
||||
let _ = self.lookup(ACCOUNT_AVATAR_KEY, &url);
|
||||
}
|
||||
self.tick = self.tick.wrapping_add(1);
|
||||
// Keep `next_image_id` monotonic: a late decode result may still land
|
||||
// in the backend LRU, and a fresh generation must never reuse its id.
|
||||
|
|
@ -319,8 +459,15 @@ impl AvatarRegistry {
|
|||
let Some(oldest) = self
|
||||
.slots
|
||||
.iter()
|
||||
.filter(|(key, _)| key.as_str() != ACCOUNT_AVATAR_KEY)
|
||||
.min_by_key(|(_, slot)| slot.last_used)
|
||||
.map(|(key, _)| key.clone())
|
||||
.or_else(|| {
|
||||
self.slots
|
||||
.iter()
|
||||
.min_by_key(|(_, slot)| slot.last_used)
|
||||
.map(|(key, _)| key.clone())
|
||||
})
|
||||
else {
|
||||
break;
|
||||
};
|
||||
|
|
@ -333,8 +480,15 @@ impl AvatarRegistry {
|
|||
let Some(oldest) = self
|
||||
.slots
|
||||
.iter()
|
||||
.filter(|(key, _)| key.as_str() != ACCOUNT_AVATAR_KEY)
|
||||
.min_by_key(|(_, slot)| slot.last_used)
|
||||
.map(|(key, _)| key.clone())
|
||||
.or_else(|| {
|
||||
self.slots
|
||||
.iter()
|
||||
.min_by_key(|(_, slot)| slot.last_used)
|
||||
.map(|(key, _)| key.clone())
|
||||
})
|
||||
else {
|
||||
break;
|
||||
};
|
||||
|
|
@ -355,31 +509,80 @@ fn avatars() -> &'static Mutex<AvatarRegistry> {
|
|||
/// epoch-local participant key. Invalid/absent profiles remove any stale
|
||||
/// image for that participant.
|
||||
pub fn register_collab_avatar_url(participant_key: &str, url: Option<&str>) -> bool {
|
||||
let Some(participant_key) = collab_registry_key(participant_key) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(mut registry) = avatars().lock() else {
|
||||
return false;
|
||||
};
|
||||
let Some(url) = url else {
|
||||
registry.remove_slot(participant_key);
|
||||
registry.remove_slot(&participant_key);
|
||||
return true;
|
||||
};
|
||||
if !valid_lookup(participant_key, url) {
|
||||
registry.remove_slot(participant_key);
|
||||
if !valid_lookup(&participant_key, url) {
|
||||
registry.remove_slot(&participant_key);
|
||||
return false;
|
||||
}
|
||||
let _ = registry.lookup(participant_key, url);
|
||||
let _ = registry.lookup(&participant_key, url);
|
||||
true
|
||||
}
|
||||
|
||||
/// Resolve a ready avatar by opaque participant key only.
|
||||
pub fn collab_avatar_image(participant_key: &str) -> Option<CollabAvatarImage> {
|
||||
avatars().lock().ok()?.ready(participant_key)
|
||||
let participant_key = collab_registry_key(participant_key)?;
|
||||
avatars().lock().ok()?.ready(&participant_key)
|
||||
}
|
||||
|
||||
/// Register or clear the current authenticated account's profile image.
|
||||
///
|
||||
/// The account lives in a separate namespace from collaboration participant
|
||||
/// keys, so an untrusted roster key cannot replace the signed-in user's image.
|
||||
pub fn register_account_avatar_url(url: Option<&str>) -> bool {
|
||||
let Ok(mut registry) = avatars().lock() else {
|
||||
return false;
|
||||
};
|
||||
let Some(url) = url else {
|
||||
registry.remove_slot(ACCOUNT_AVATAR_KEY);
|
||||
return true;
|
||||
};
|
||||
if !valid_lookup(ACCOUNT_AVATAR_KEY, url) {
|
||||
registry.remove_slot(ACCOUNT_AVATAR_KEY);
|
||||
return false;
|
||||
}
|
||||
let _ = registry.lookup(ACCOUNT_AVATAR_KEY, url);
|
||||
true
|
||||
}
|
||||
|
||||
/// Resolve the current account image after its host fetch has completed.
|
||||
pub fn account_avatar_image() -> Option<CollabAvatarImage> {
|
||||
avatars().lock().ok()?.ready(ACCOUNT_AVATAR_KEY)
|
||||
}
|
||||
|
||||
/// Install bytes returned by the authenticated serve-web avatar proxy.
|
||||
///
|
||||
/// `revision` is an opaque, URL-derived identity emitted by the daemon. The
|
||||
/// browser never receives the underlying profile URL.
|
||||
pub fn install_account_avatar_bytes(revision: &str, bytes: Vec<u8>) -> bool {
|
||||
if revision.is_empty()
|
||||
|| revision.len() > MAX_ACCOUNT_REVISION_BYTES
|
||||
|| !revision
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let source_identity = format!("proxy:{revision}");
|
||||
avatars()
|
||||
.lock()
|
||||
.map(|mut registry| registry.install_ready(ACCOUNT_AVATAR_KEY, &source_identity, bytes))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Begin a process-local avatar epoch for a collaboration runtime.
|
||||
/// Pending, failed, and ready entries are dropped on every call, including
|
||||
/// when a newly constructed runtime reuses the same numeric generation.
|
||||
/// In-flight workers may finish, but their request tokens can no longer
|
||||
/// complete because opaque image ids are never reused.
|
||||
/// Collaboration entries are dropped on every call, including when a newly
|
||||
/// constructed runtime reuses the same numeric generation. The independent
|
||||
/// current-account slot survives; an in-flight account request is safely
|
||||
/// requeued under the new generation.
|
||||
pub fn begin_collab_avatar_generation(generation: u64) -> bool {
|
||||
avatars()
|
||||
.lock()
|
||||
|
|
@ -414,13 +617,18 @@ pub fn cached_collab_avatar_bytes(image_id: u64) -> Option<Arc<[u8]>> {
|
|||
pub fn has_pending_collab_avatar_requests() -> bool {
|
||||
avatars()
|
||||
.lock()
|
||||
.map(|registry| !registry.pending.is_empty())
|
||||
.map(|registry| registry.has_background_work())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn account_avatar_retry_delay(failure_count: u8) -> Duration {
|
||||
let exponent = u32::from(failure_count.saturating_sub(1).min(5));
|
||||
Duration::from_secs(1_u64 << exponent)
|
||||
}
|
||||
|
||||
fn valid_lookup(participant_key: &str, url: &str) -> bool {
|
||||
!participant_key.is_empty()
|
||||
&& participant_key.len() <= MAX_PARTICIPANT_KEY_BYTES
|
||||
&& participant_key.len() <= MAX_REGISTRY_KEY_BYTES
|
||||
&& !participant_key.chars().any(char::is_control)
|
||||
&& !url.is_empty()
|
||||
&& url.len() <= MAX_AVATAR_URL_BYTES
|
||||
|
|
@ -428,6 +636,16 @@ fn valid_lookup(participant_key: &str, url: &str) -> bool {
|
|||
&& !url.chars().any(char::is_whitespace)
|
||||
}
|
||||
|
||||
fn collab_registry_key(participant_key: &str) -> Option<String> {
|
||||
if participant_key.is_empty()
|
||||
|| participant_key.len() > MAX_PARTICIPANT_KEY_BYTES
|
||||
|| participant_key.chars().any(char::is_control)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(format!("{COLLAB_KEY_PREFIX}{participant_key}"))
|
||||
}
|
||||
|
||||
fn validate_encoded_avatar(bytes: Vec<u8>) -> Option<Arc<[u8]>> {
|
||||
if bytes.is_empty() || bytes.len() > MAX_AVATAR_ENCODED_BYTES {
|
||||
return None;
|
||||
|
|
@ -455,146 +673,5 @@ pub(crate) fn lock_collab_avatar_registry_for_tests() -> std::sync::MutexGuard<'
|
|||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn png_header(width: u32, height: u32, payload_bytes: usize) -> Vec<u8> {
|
||||
let mut bytes = vec![0; payload_bytes.max(24)];
|
||||
bytes[..8].copy_from_slice(b"\x89PNG\r\n\x1a\n");
|
||||
bytes[12..16].copy_from_slice(b"IHDR");
|
||||
bytes[16..20].copy_from_slice(&width.to_be_bytes());
|
||||
bytes[20..24].copy_from_slice(&height.to_be_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn fetch(registry: &mut AvatarRegistry, key: &str, url: &str) -> CollabAvatarFetchRequest {
|
||||
assert!(registry.lookup(key, url).is_none());
|
||||
registry.take_requests(1).pop().expect("request queued")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_is_cached_and_failures_keep_the_fallback() {
|
||||
let mut registry = AvatarRegistry::with_limits(1_024, 4, 4);
|
||||
let ok = fetch(&mut registry, "p1", "https://cdn.example/a.png");
|
||||
assert!(registry.complete(&ok, Some(png_header(16, 16, 48))));
|
||||
let image = registry
|
||||
.lookup("p1", "https://cdn.example/a.png")
|
||||
.expect("valid response is ready");
|
||||
assert_eq!(image.image_id, ok.image_id);
|
||||
|
||||
let failed = fetch(&mut registry, "p2", "https://cdn.example/b.png");
|
||||
assert!(registry.complete(&failed, None));
|
||||
assert!(registry.lookup("p2", "https://cdn.example/b.png").is_none());
|
||||
assert!(registry.take_requests(1).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_bytes_dimensions_and_queue_are_bounded() {
|
||||
let mut registry = AvatarRegistry::with_limits(2_048, 64, 1);
|
||||
let large_body = fetch(&mut registry, "p1", "https://cdn.example/large.png");
|
||||
assert!(registry.complete(&large_body, Some(vec![0; MAX_AVATAR_ENCODED_BYTES + 1])));
|
||||
assert!(registry
|
||||
.lookup("p1", "https://cdn.example/large.png")
|
||||
.is_none());
|
||||
|
||||
let large_raster = fetch(&mut registry, "p2", "https://cdn.example/wide.png");
|
||||
assert!(registry.complete(
|
||||
&large_raster,
|
||||
Some(png_header(MAX_AVATAR_SOURCE_EDGE_PX + 1, 1, 24))
|
||||
));
|
||||
assert!(registry
|
||||
.lookup("p2", "https://cdn.example/wide.png")
|
||||
.is_none());
|
||||
|
||||
assert!(registry.lookup("p3", "https://cdn.example/c.png").is_none());
|
||||
assert!(registry.lookup("p4", "https://cdn.example/d.png").is_none());
|
||||
assert_eq!(registry.take_requests(8).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lru_eviction_and_generation_switch_drop_stale_results() {
|
||||
let mut registry = AvatarRegistry::with_limits(80, 2, 4);
|
||||
let first = fetch(&mut registry, "p1", "https://cdn.example/one.png");
|
||||
assert!(registry.complete(&first, Some(png_header(2, 2, 40))));
|
||||
let second = fetch(&mut registry, "p2", "https://cdn.example/two.png");
|
||||
assert!(registry.complete(&second, Some(png_header(2, 2, 40))));
|
||||
assert!(registry
|
||||
.lookup("p1", "https://cdn.example/one.png")
|
||||
.is_some());
|
||||
let third = fetch(&mut registry, "p3", "https://cdn.example/three.png");
|
||||
assert!(registry.complete(&third, Some(png_header(2, 2, 40))));
|
||||
assert!(
|
||||
!registry.slots.contains_key("p2"),
|
||||
"least recently used entry is evicted"
|
||||
);
|
||||
|
||||
let old = fetch(&mut registry, "p4", "https://cdn.example/old.png");
|
||||
assert!(registry
|
||||
.lookup("p4", "https://cdn.example/new.png")
|
||||
.is_none());
|
||||
let new = registry.take_requests(1).pop().expect("new generation");
|
||||
assert!(!registry.complete(&old, Some(png_header(2, 2, 32))));
|
||||
assert!(registry
|
||||
.lookup("p4", "https://cdn.example/new.png")
|
||||
.is_none());
|
||||
assert!(registry.complete(&new, Some(png_header(2, 2, 32))));
|
||||
assert_eq!(
|
||||
registry
|
||||
.lookup("p4", "https://cdn.example/new.png")
|
||||
.expect("new generation wins")
|
||||
.image_id,
|
||||
new.image_id
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_generation_rotation_clears_cache_and_rejects_late_workers() {
|
||||
let mut registry = AvatarRegistry::with_limits(1_024, 4, 4);
|
||||
assert!(registry.begin_session_generation(7));
|
||||
let stale = fetch(
|
||||
&mut registry,
|
||||
"same-participant",
|
||||
"https://cdn.example/same.png",
|
||||
);
|
||||
assert!(registry.begin_session_generation(8));
|
||||
assert!(registry.slots.is_empty());
|
||||
assert!(registry.pending.is_empty());
|
||||
assert!(!registry.complete(&stale, Some(png_header(16, 16, 32))));
|
||||
|
||||
let current = fetch(
|
||||
&mut registry,
|
||||
"same-participant",
|
||||
"https://cdn.example/same.png",
|
||||
);
|
||||
assert_ne!(stale.image_id, current.image_id);
|
||||
assert!(
|
||||
registry.begin_session_generation(8),
|
||||
"a new runtime may reuse the numeric generation and must still reset"
|
||||
);
|
||||
assert!(!registry.complete(¤t, Some(png_header(16, 16, 32))));
|
||||
let refreshed = fetch(
|
||||
&mut registry,
|
||||
"same-participant",
|
||||
"https://cdn.example/same.png",
|
||||
);
|
||||
assert_ne!(current.image_id, refreshed.image_id);
|
||||
assert!(registry.complete(&refreshed, Some(png_header(16, 16, 32))));
|
||||
assert!(registry
|
||||
.lookup("same-participant", "https://cdn.example/same.png")
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_debug_redacts_url_and_identity() {
|
||||
let mut registry = AvatarRegistry::with_limits(100, 1, 1);
|
||||
let request = fetch(
|
||||
&mut registry,
|
||||
"private-participant",
|
||||
"https://cdn.example/secret.png",
|
||||
);
|
||||
let debug = format!("{request:?}");
|
||||
assert!(!debug.contains("private-participant"));
|
||||
assert!(!debug.contains("cdn.example"));
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
}
|
||||
}
|
||||
#[path = "collab_avatar_runtime_tests.rs"]
|
||||
mod tests;
|
||||
|
|
|
|||
255
crates/op-editor-ui/src/collab_avatar_runtime_tests.rs
Normal file
255
crates/op-editor-ui/src/collab_avatar_runtime_tests.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
use super::*;
|
||||
|
||||
fn png_header(width: u32, height: u32, payload_bytes: usize) -> Vec<u8> {
|
||||
let mut bytes = vec![0; payload_bytes.max(24)];
|
||||
bytes[..8].copy_from_slice(b"\x89PNG\r\n\x1a\n");
|
||||
bytes[12..16].copy_from_slice(b"IHDR");
|
||||
bytes[16..20].copy_from_slice(&width.to_be_bytes());
|
||||
bytes[20..24].copy_from_slice(&height.to_be_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn fetch(registry: &mut AvatarRegistry, key: &str, url: &str) -> CollabAvatarFetchRequest {
|
||||
assert!(registry.lookup(key, url).is_none());
|
||||
registry.take_requests(1).pop().expect("request queued")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn success_is_cached_and_failures_keep_the_fallback() {
|
||||
let mut registry = AvatarRegistry::with_limits(1_024, 4, 4);
|
||||
let ok = fetch(&mut registry, "p1", "https://cdn.example/a.png");
|
||||
assert!(registry.complete(&ok, Some(png_header(16, 16, 48))));
|
||||
let image = registry
|
||||
.lookup("p1", "https://cdn.example/a.png")
|
||||
.expect("valid response is ready");
|
||||
assert_eq!(image.image_id, ok.image_id);
|
||||
|
||||
let failed = fetch(&mut registry, "p2", "https://cdn.example/b.png");
|
||||
assert!(registry.complete(&failed, None));
|
||||
assert!(registry.lookup("p2", "https://cdn.example/b.png").is_none());
|
||||
assert!(registry.take_requests(1).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_bytes_dimensions_and_queue_are_bounded() {
|
||||
let mut registry = AvatarRegistry::with_limits(2_048, 64, 1);
|
||||
let large_body = fetch(&mut registry, "p1", "https://cdn.example/large.png");
|
||||
assert!(registry.complete(&large_body, Some(vec![0; MAX_AVATAR_ENCODED_BYTES + 1])));
|
||||
assert!(registry
|
||||
.lookup("p1", "https://cdn.example/large.png")
|
||||
.is_none());
|
||||
|
||||
let large_raster = fetch(&mut registry, "p2", "https://cdn.example/wide.png");
|
||||
assert!(registry.complete(
|
||||
&large_raster,
|
||||
Some(png_header(MAX_AVATAR_SOURCE_EDGE_PX + 1, 1, 24))
|
||||
));
|
||||
assert!(registry
|
||||
.lookup("p2", "https://cdn.example/wide.png")
|
||||
.is_none());
|
||||
|
||||
assert!(registry.lookup("p3", "https://cdn.example/c.png").is_none());
|
||||
assert!(registry.lookup("p4", "https://cdn.example/d.png").is_none());
|
||||
assert_eq!(registry.take_requests(8).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lru_eviction_and_generation_switch_drop_stale_results() {
|
||||
let mut registry = AvatarRegistry::with_limits(80, 2, 4);
|
||||
let first = fetch(&mut registry, "p1", "https://cdn.example/one.png");
|
||||
assert!(registry.complete(&first, Some(png_header(2, 2, 40))));
|
||||
let second = fetch(&mut registry, "p2", "https://cdn.example/two.png");
|
||||
assert!(registry.complete(&second, Some(png_header(2, 2, 40))));
|
||||
assert!(registry
|
||||
.lookup("p1", "https://cdn.example/one.png")
|
||||
.is_some());
|
||||
let third = fetch(&mut registry, "p3", "https://cdn.example/three.png");
|
||||
assert!(registry.complete(&third, Some(png_header(2, 2, 40))));
|
||||
assert!(
|
||||
!registry.slots.contains_key("p2"),
|
||||
"least recently used entry is evicted"
|
||||
);
|
||||
|
||||
let old = fetch(&mut registry, "p4", "https://cdn.example/old.png");
|
||||
assert!(registry
|
||||
.lookup("p4", "https://cdn.example/new.png")
|
||||
.is_none());
|
||||
let new = registry.take_requests(1).pop().expect("new generation");
|
||||
assert!(!registry.complete(&old, Some(png_header(2, 2, 32))));
|
||||
assert!(registry
|
||||
.lookup("p4", "https://cdn.example/new.png")
|
||||
.is_none());
|
||||
assert!(registry.complete(&new, Some(png_header(2, 2, 32))));
|
||||
assert_eq!(
|
||||
registry
|
||||
.lookup("p4", "https://cdn.example/new.png")
|
||||
.expect("new generation wins")
|
||||
.image_id,
|
||||
new.image_id
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_generation_rotation_clears_cache_and_rejects_late_workers() {
|
||||
let mut registry = AvatarRegistry::with_limits(1_024, 4, 4);
|
||||
assert!(registry.begin_session_generation(7));
|
||||
let stale = fetch(
|
||||
&mut registry,
|
||||
"same-participant",
|
||||
"https://cdn.example/same.png",
|
||||
);
|
||||
assert!(registry.begin_session_generation(8));
|
||||
assert!(registry.slots.is_empty());
|
||||
assert!(registry.pending.is_empty());
|
||||
assert!(!registry.complete(&stale, Some(png_header(16, 16, 32))));
|
||||
|
||||
let current = fetch(
|
||||
&mut registry,
|
||||
"same-participant",
|
||||
"https://cdn.example/same.png",
|
||||
);
|
||||
assert_ne!(stale.image_id, current.image_id);
|
||||
assert!(
|
||||
registry.begin_session_generation(8),
|
||||
"a new runtime may reuse the numeric generation and must still reset"
|
||||
);
|
||||
assert!(!registry.complete(¤t, Some(png_header(16, 16, 32))));
|
||||
let refreshed = fetch(
|
||||
&mut registry,
|
||||
"same-participant",
|
||||
"https://cdn.example/same.png",
|
||||
);
|
||||
assert_ne!(current.image_id, refreshed.image_id);
|
||||
assert!(registry.complete(&refreshed, Some(png_header(16, 16, 32))));
|
||||
assert!(registry
|
||||
.lookup("same-participant", "https://cdn.example/same.png")
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collaboration_generation_preserves_and_requeues_the_account_slot() {
|
||||
let mut registry = AvatarRegistry::with_limits(1_024, 4, 4);
|
||||
let account = fetch(
|
||||
&mut registry,
|
||||
ACCOUNT_AVATAR_KEY,
|
||||
"https://cdn.example/account.png",
|
||||
);
|
||||
assert!(registry.begin_session_generation(7));
|
||||
assert!(!registry.complete(&account, Some(png_header(16, 16, 32))));
|
||||
let retried = registry
|
||||
.take_requests(1)
|
||||
.pop()
|
||||
.expect("account request requeued");
|
||||
assert_eq!(retried.image_id, account.image_id);
|
||||
assert!(registry.complete(&retried, Some(png_header(16, 16, 32))));
|
||||
|
||||
let collab = fetch(
|
||||
&mut registry,
|
||||
"collab:participant",
|
||||
"https://cdn.example/participant.png",
|
||||
);
|
||||
assert!(registry.complete(&collab, Some(png_header(16, 16, 32))));
|
||||
assert!(registry.begin_session_generation(8));
|
||||
assert!(registry.ready(ACCOUNT_AVATAR_KEY).is_some());
|
||||
assert!(!registry.slots.contains_key("collab:participant"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_account_fetch_retries_with_backoff_and_keeps_the_fallback() {
|
||||
let mut registry = AvatarRegistry::with_limits(1_024, 4, 4);
|
||||
let failed = fetch(
|
||||
&mut registry,
|
||||
ACCOUNT_AVATAR_KEY,
|
||||
"https://cdn.example/account.png",
|
||||
);
|
||||
assert!(registry.complete(&failed, None));
|
||||
assert!(registry.ready(ACCOUNT_AVATAR_KEY).is_none());
|
||||
assert!(registry.has_background_work());
|
||||
assert!(registry.pending.is_empty());
|
||||
|
||||
registry
|
||||
.slots
|
||||
.get_mut(ACCOUNT_AVATAR_KEY)
|
||||
.expect("account slot")
|
||||
.retry_at = Some(Instant::now());
|
||||
assert!(registry.ready(ACCOUNT_AVATAR_KEY).is_none());
|
||||
let retried = registry
|
||||
.take_requests(1)
|
||||
.pop()
|
||||
.expect("due account retry queued");
|
||||
assert_eq!(retried.image_id, failed.image_id);
|
||||
assert!(registry.complete(&retried, Some(png_header(16, 16, 32))));
|
||||
assert!(registry.ready(ACCOUNT_AVATAR_KEY).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_and_adversarial_participant_keys_cannot_collide() {
|
||||
let _guard = lock_collab_avatar_registry_for_tests();
|
||||
assert!(register_account_avatar_url(Some(
|
||||
"https://cdn.example/account.png"
|
||||
)));
|
||||
assert!(register_collab_avatar_url(
|
||||
ACCOUNT_AVATAR_KEY,
|
||||
Some("https://cdn.example/participant.png")
|
||||
));
|
||||
let requests = take_collab_avatar_requests(2);
|
||||
assert_eq!(requests.len(), 2);
|
||||
assert_eq!(
|
||||
requests
|
||||
.iter()
|
||||
.filter(|request| request.is_current_account())
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
for request in &requests {
|
||||
assert!(complete_collab_avatar_request(
|
||||
request,
|
||||
Some(png_header(16, 16, 32))
|
||||
));
|
||||
}
|
||||
let account = account_avatar_image().expect("account image");
|
||||
let participant = collab_avatar_image(ACCOUNT_AVATAR_KEY).expect("participant image");
|
||||
assert_ne!(account.image_id, participant.image_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_install_replaces_only_on_a_valid_new_revision() {
|
||||
let _guard = lock_collab_avatar_registry_for_tests();
|
||||
assert!(install_account_avatar_bytes(
|
||||
"revision-one",
|
||||
png_header(16, 16, 32)
|
||||
));
|
||||
let first = account_avatar_image().expect("first proxy image");
|
||||
assert!(!install_account_avatar_bytes(
|
||||
"../unsafe",
|
||||
png_header(16, 16, 32)
|
||||
));
|
||||
assert_eq!(
|
||||
account_avatar_image()
|
||||
.expect("invalid revision keeps current")
|
||||
.image_id,
|
||||
first.image_id
|
||||
);
|
||||
assert!(install_account_avatar_bytes(
|
||||
"revision-two",
|
||||
png_header(16, 16, 32)
|
||||
));
|
||||
assert_ne!(
|
||||
account_avatar_image().expect("new proxy image").image_id,
|
||||
first.image_id
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_debug_redacts_url_and_identity() {
|
||||
let mut registry = AvatarRegistry::with_limits(100, 1, 1);
|
||||
let request = fetch(
|
||||
&mut registry,
|
||||
"private-participant",
|
||||
"https://cdn.example/secret.png",
|
||||
);
|
||||
let debug = format!("{request:?}");
|
||||
assert!(!debug.contains("private-participant"));
|
||||
assert!(!debug.contains("cdn.example"));
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
}
|
||||
154
crates/op-editor-ui/src/widgets/account_avatar_paint.rs
Normal file
154
crates/op-editor-ui/src/widgets/account_avatar_paint.rs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
//! Current-account avatar overlay shared by TopBar paint.
|
||||
|
||||
use crate::collab_avatar_runtime::{account_avatar_image, AVATAR_DECODE_EDGE_PX};
|
||||
use crate::widgets::canvas_viewport_image::note_pending_decode;
|
||||
use crate::widgets::PaintCx;
|
||||
use crate::{ImageDrawMode, Rect};
|
||||
use op_editor_core::AccountState;
|
||||
|
||||
/// Overlay a ready account image on top of the caller's initials fallback.
|
||||
///
|
||||
/// Fetch and decode misses are recorded only; paint never blocks. Returning
|
||||
/// `false` means the initials remain the visible fallback.
|
||||
pub(super) fn paint_account_avatar_image(
|
||||
cx: &mut PaintCx<'_>,
|
||||
account: &AccountState,
|
||||
rect: Rect,
|
||||
) -> bool {
|
||||
if matches!(account, AccountState::Anonymous) {
|
||||
return false;
|
||||
}
|
||||
let Some(image) = account_avatar_image() else {
|
||||
return false;
|
||||
};
|
||||
let sharp = cx.backend.image_decoded(
|
||||
image.image_id,
|
||||
image.encoded.as_ref(),
|
||||
AVATAR_DECODE_EDGE_PX,
|
||||
);
|
||||
if !sharp {
|
||||
note_pending_decode(image.image_id, AVATAR_DECODE_EDGE_PX);
|
||||
}
|
||||
if !sharp && !cx.backend.image_resident(image.image_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let radius = rect.size.x.min(rect.size.y) / 2.0;
|
||||
cx.backend.save();
|
||||
cx.backend.clip_round_rect(rect, radius);
|
||||
cx.backend.draw_image_with_mode(
|
||||
rect,
|
||||
image.image_id,
|
||||
image.encoded.as_ref(),
|
||||
ImageDrawMode::Crop,
|
||||
);
|
||||
cx.backend.restore();
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::collab_avatar_runtime::{
|
||||
complete_collab_avatar_request, lock_collab_avatar_registry_for_tests,
|
||||
register_account_avatar_url, take_collab_avatar_requests,
|
||||
};
|
||||
use crate::{Color, Point2D, RenderBackend, TextLayout};
|
||||
|
||||
#[derive(Default)]
|
||||
struct AvatarBackend {
|
||||
image_ready: bool,
|
||||
image_draws: usize,
|
||||
}
|
||||
|
||||
impl RenderBackend for AvatarBackend {
|
||||
fn begin_frame(&mut self) {}
|
||||
fn end_frame(&mut self) {}
|
||||
fn fill_rect(&mut self, _: Rect, _: Color) {}
|
||||
fn stroke_rect(&mut self, _: Rect, _: Color, _: f32) {}
|
||||
fn draw_text(&mut self, _: &TextLayout, _: Point2D) {}
|
||||
fn clip_rect(&mut self, _: Rect) {}
|
||||
fn stroke_line(&mut self, _: Point2D, _: Point2D, _: Color, _: f32) {}
|
||||
fn fill_round_rect(&mut self, _: Rect, _: f32, _: Color) {}
|
||||
fn stroke_round_rect(&mut self, _: Rect, _: f32, _: Color, _: f32) {}
|
||||
fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: Color, _: f32) {}
|
||||
fn image_decoded(&mut self, _: u64, _: &[u8], _: u32) -> bool {
|
||||
self.image_ready
|
||||
}
|
||||
fn image_resident(&mut self, _: u64) -> bool {
|
||||
self.image_ready
|
||||
}
|
||||
fn draw_image_with_mode(&mut self, _: Rect, _: u64, _: &[u8], mode: ImageDrawMode) {
|
||||
assert_eq!(mode, ImageDrawMode::Crop);
|
||||
self.image_draws += 1;
|
||||
}
|
||||
fn save(&mut self) {}
|
||||
fn restore(&mut self) {}
|
||||
fn translate(&mut self, _: Point2D) {}
|
||||
fn resize(&mut self, _: u32, _: u32) {}
|
||||
fn dpi_scale(&self) -> f32 {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
fn account() -> AccountState {
|
||||
AccountState::SignedIn {
|
||||
display_name: "Kayshen".into(),
|
||||
handle: "Kayshen".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn png_header() -> Vec<u8> {
|
||||
let mut bytes = vec![0; 32];
|
||||
bytes[..8].copy_from_slice(b"\x89PNG\r\n\x1a\n");
|
||||
bytes[12..16].copy_from_slice(b"IHDR");
|
||||
bytes[16..20].copy_from_slice(&16_u32.to_be_bytes());
|
||||
bytes[20..24].copy_from_slice(&16_u32.to_be_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_profile_image_overlays_the_initials_fallback() {
|
||||
let _guard = lock_collab_avatar_registry_for_tests();
|
||||
assert!(register_account_avatar_url(Some(
|
||||
"https://cdn.example/account.png"
|
||||
)));
|
||||
let account = account();
|
||||
let rect = Rect::xywh(0.0, 0.0, 20.0, 20.0);
|
||||
let mut backend = AvatarBackend {
|
||||
image_ready: true,
|
||||
..Default::default()
|
||||
};
|
||||
let mut cx = PaintCx {
|
||||
backend: &mut backend,
|
||||
};
|
||||
|
||||
assert!(!paint_account_avatar_image(&mut cx, &account, rect));
|
||||
let request = take_collab_avatar_requests(1).pop().unwrap();
|
||||
assert!(complete_collab_avatar_request(&request, Some(png_header())));
|
||||
assert!(paint_account_avatar_image(&mut cx, &account, rect));
|
||||
assert_eq!(backend.image_draws, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_or_unsafe_url_keeps_the_initials_fallback() {
|
||||
let _guard = lock_collab_avatar_registry_for_tests();
|
||||
let rect = Rect::xywh(0.0, 0.0, 20.0, 20.0);
|
||||
let mut backend = AvatarBackend::default();
|
||||
let mut cx = PaintCx {
|
||||
backend: &mut backend,
|
||||
};
|
||||
|
||||
assert!(!paint_account_avatar_image(
|
||||
&mut cx,
|
||||
&AccountState::Anonymous,
|
||||
rect
|
||||
));
|
||||
assert!(!register_account_avatar_url(Some(
|
||||
"http://127.0.0.1/avatar.png"
|
||||
)));
|
||||
assert!(!paint_account_avatar_image(&mut cx, &account(), rect));
|
||||
assert!(take_collab_avatar_requests(1).is_empty());
|
||||
assert_eq!(backend.image_draws, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -126,6 +126,7 @@ pub fn press_account_menu(
|
|||
Some(AccountMenuRow::SignOut) => {
|
||||
close_account_menu(state);
|
||||
state.editor_ui.account = AccountState::Anonymous;
|
||||
let _ = crate::collab_avatar_runtime::register_account_avatar_url(None);
|
||||
AccountMenuPress::SignOut
|
||||
}
|
||||
None => {
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@ pub fn apply_agent_settings_hit(
|
|||
}
|
||||
AgentSettingsHit::SignOutAccount => {
|
||||
state.editor_ui.account = AccountState::Anonymous;
|
||||
let _ = crate::collab_avatar_runtime::register_account_avatar_url(None);
|
||||
SettingsPressOutcome::effect(SettingsPress::SignOut)
|
||||
}
|
||||
AgentSettingsHit::FocusMcpPort => {
|
||||
|
|
|
|||
|
|
@ -197,6 +197,7 @@ mod icons_data;
|
|||
pub mod brand_icons;
|
||||
|
||||
// Step 4 — extra editor-chrome widgets (TS app parity).
|
||||
mod account_avatar_paint;
|
||||
pub mod account_menu;
|
||||
pub mod account_press_flow;
|
||||
pub mod agent_settings_account;
|
||||
|
|
|
|||
|
|
@ -155,7 +155,8 @@ pub struct TopBar {
|
|||
/// `hit_test` falls back to a char-count estimate.
|
||||
pub chip_text_w: Option<f32>,
|
||||
/// Sign-in state — drives the avatar button's glyph (generic user
|
||||
/// outline when signed out, an initial-letter circle when signed in).
|
||||
/// outline when signed out, profile image with an initial fallback when
|
||||
/// signed in).
|
||||
pub account: op_editor_core::AccountState,
|
||||
/// Whether the avatar button paints at all. Follows the host-set
|
||||
/// runtime release gate (`EditorUiState::account_ui_available`):
|
||||
|
|
|
|||
|
|
@ -557,8 +557,8 @@ fn paint_collaboration_chip(
|
|||
}
|
||||
|
||||
/// User-avatar button: a generic outline glyph when signed out, or a
|
||||
/// filled initial-letter circle when signed in. Shares the same
|
||||
/// hover/press ghost background as its icon-button siblings (Sun /
|
||||
/// profile image with an initial-letter fallback when signed in. Shares the
|
||||
/// same hover/press ghost background as its icon-button siblings (Sun /
|
||||
/// Globe / Maximize) so it reads consistently in the chrome row.
|
||||
pub(super) fn paint_account_button(
|
||||
cx: &mut PaintCx<'_>,
|
||||
|
|
@ -611,6 +611,11 @@ pub(super) fn paint_account_button(
|
|||
center_y + 4.0,
|
||||
),
|
||||
);
|
||||
crate::widgets::account_avatar_paint::paint_account_avatar_image(
|
||||
cx,
|
||||
account,
|
||||
avatar_rect,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,32 +2,19 @@
|
|||
|
||||
use std::sync::mpsc::{self, Receiver, TryRecvError};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use op_editor_ui::collab_avatar_runtime::{
|
||||
complete_collab_avatar_request, take_collab_avatar_requests, CollabAvatarFetchRequest,
|
||||
MAX_AVATAR_ENCODED_BYTES,
|
||||
};
|
||||
use reqwest::header::{ACCEPT, LOCATION};
|
||||
use op_host_services::profile_avatar_fetch::{
|
||||
fetch_account_avatar_blocking, fetch_profile_avatar_blocking,
|
||||
ProfileAvatarFetchError as AvatarFetchError,
|
||||
};
|
||||
|
||||
const MAX_CONCURRENT_FETCHES: usize = 3;
|
||||
const MAX_REDIRECTS: usize = 3;
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum AvatarFetchError {
|
||||
UrlNotAllowed,
|
||||
DialRejected,
|
||||
RequestFailed,
|
||||
TimedOut,
|
||||
HttpStatus,
|
||||
RedirectInvalid,
|
||||
TooManyRedirects,
|
||||
TooLarge,
|
||||
EmptyBody,
|
||||
}
|
||||
|
||||
type Fetcher = Arc<dyn Fn(&str) -> Result<Vec<u8>, AvatarFetchError> + Send + Sync>;
|
||||
type Fetcher =
|
||||
Arc<dyn Fn(&CollabAvatarFetchRequest) -> Result<Vec<u8>, AvatarFetchError> + Send + Sync>;
|
||||
|
||||
struct FetchJob {
|
||||
request: CollabAvatarFetchRequest,
|
||||
|
|
@ -52,7 +39,13 @@ pub(crate) fn lock_avatar_test_registry() -> std::sync::MutexGuard<'static, ()>
|
|||
|
||||
impl CollabAvatarHost {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self::with_fetcher(Arc::new(fetch_avatar_blocking))
|
||||
Self::with_fetcher(Arc::new(|request| {
|
||||
if request.is_current_account() {
|
||||
fetch_account_avatar_blocking(request.url())
|
||||
} else {
|
||||
fetch_profile_avatar_blocking(request.url())
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
fn with_fetcher(fetcher: Fetcher) -> Self {
|
||||
|
|
@ -106,112 +99,16 @@ impl CollabAvatarHost {
|
|||
|
||||
fn spawn_fetch(request: CollabAvatarFetchRequest, fetcher: Fetcher) -> Option<FetchJob> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let url = request.url().to_string();
|
||||
let worker_request = request.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("op-collab-avatar".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send(fetcher(&url));
|
||||
let _ = tx.send(fetcher(&worker_request));
|
||||
})
|
||||
.ok()?;
|
||||
Some(FetchJob { request, rx })
|
||||
}
|
||||
|
||||
fn fetch_avatar_blocking(url: &str) -> Result<Vec<u8>, AvatarFetchError> {
|
||||
op_host_services::chat_runtime::block_on_anywhere(fetch_avatar(url))
|
||||
}
|
||||
|
||||
async fn fetch_avatar(url: &str) -> Result<Vec<u8>, AvatarFetchError> {
|
||||
let mut url = parse_avatar_url(url)?;
|
||||
for redirect_count in 0..=MAX_REDIRECTS {
|
||||
// `public_https_client` resolves + screens every address, disables
|
||||
// proxies, and pins the socket while the request URL retains the
|
||||
// original hostname for TLS certificate verification and SNI.
|
||||
let client = with_timeout(
|
||||
REQUEST_TIMEOUT,
|
||||
op_host_services::public_https_client::public_https_client(&url),
|
||||
)
|
||||
.await?
|
||||
.map_err(|_| AvatarFetchError::DialRejected)?;
|
||||
let mut response = client
|
||||
.get(url.clone())
|
||||
.header(ACCEPT, "image/webp,image/png,image/jpeg,image/gif")
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| AvatarFetchError::RequestFailed)?;
|
||||
|
||||
if response.status().is_redirection() {
|
||||
if redirect_count == MAX_REDIRECTS {
|
||||
return Err(AvatarFetchError::TooManyRedirects);
|
||||
}
|
||||
let location = response
|
||||
.headers()
|
||||
.get(LOCATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or(AvatarFetchError::RedirectInvalid)?;
|
||||
url = parse_avatar_url(
|
||||
url.join(location)
|
||||
.map_err(|_| AvatarFetchError::RedirectInvalid)?
|
||||
.as_str(),
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Err(AvatarFetchError::HttpStatus);
|
||||
}
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > MAX_AVATAR_ENCODED_BYTES as u64)
|
||||
{
|
||||
return Err(AvatarFetchError::TooLarge);
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(
|
||||
response
|
||||
.content_length()
|
||||
.unwrap_or(0)
|
||||
.min(MAX_AVATAR_ENCODED_BYTES as u64) as usize,
|
||||
);
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|_| AvatarFetchError::RequestFailed)?
|
||||
{
|
||||
if bytes.len().saturating_add(chunk.len()) > MAX_AVATAR_ENCODED_BYTES {
|
||||
return Err(AvatarFetchError::TooLarge);
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
if bytes.is_empty() {
|
||||
return Err(AvatarFetchError::EmptyBody);
|
||||
}
|
||||
return Ok(bytes);
|
||||
}
|
||||
Err(AvatarFetchError::TooManyRedirects)
|
||||
}
|
||||
|
||||
async fn with_timeout<F, T>(duration: Duration, future: F) -> Result<T, AvatarFetchError>
|
||||
where
|
||||
F: std::future::Future<Output = T>,
|
||||
{
|
||||
tokio::time::timeout(duration, future)
|
||||
.await
|
||||
.map_err(|_| AvatarFetchError::TimedOut)
|
||||
}
|
||||
|
||||
fn parse_avatar_url(value: &str) -> Result<reqwest::Url, AvatarFetchError> {
|
||||
let url = reqwest::Url::parse(value).map_err(|_| AvatarFetchError::UrlNotAllowed)?;
|
||||
if url.scheme() != "https"
|
||||
|| url.host_str().is_none()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.fragment().is_some()
|
||||
|| url.port() == Some(0)
|
||||
{
|
||||
return Err(AvatarFetchError::UrlNotAllowed);
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -220,7 +117,7 @@ mod tests {
|
|||
register_collab_avatar_url, take_collab_avatar_requests,
|
||||
};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn png_header() -> Vec<u8> {
|
||||
let mut bytes = vec![0; 32];
|
||||
|
|
@ -318,7 +215,10 @@ mod tests {
|
|||
"https://cdn.example/avatar.png#fragment",
|
||||
"https://cdn.example:0/avatar.png",
|
||||
] {
|
||||
assert_eq!(parse_avatar_url(url), Err(AvatarFetchError::UrlNotAllowed));
|
||||
assert_eq!(
|
||||
fetch_profile_avatar_blocking(url),
|
||||
Err(AvatarFetchError::UrlNotAllowed)
|
||||
);
|
||||
}
|
||||
for url in [
|
||||
"https://127.0.0.1/avatar.png",
|
||||
|
|
@ -328,19 +228,10 @@ mod tests {
|
|||
"https://[fe80::1]/avatar.png",
|
||||
] {
|
||||
assert_eq!(
|
||||
fetch_avatar_blocking(url),
|
||||
fetch_profile_avatar_blocking(url),
|
||||
Err(AvatarFetchError::DialRejected),
|
||||
"{url}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolver_deadline_cancels_a_hanging_dial_future() {
|
||||
let result = op_host_services::chat_runtime::block_on_anywhere(with_timeout(
|
||||
Duration::from_millis(1),
|
||||
std::future::pending::<()>(),
|
||||
));
|
||||
assert_eq!(result, Err(AvatarFetchError::TimedOut));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -480,9 +480,13 @@ fn init_auth_runtime(host: &mut WidgetHostNative) {
|
|||
if let op_auth_bridge::AuthStatus::SignedIn {
|
||||
display_name,
|
||||
primary_email,
|
||||
avatar_url,
|
||||
..
|
||||
} = op_auth_bridge::poll(op_auth_bridge::SESSION_HANDLE)
|
||||
{
|
||||
let _ = op_editor_ui::collab_avatar_runtime::register_account_avatar_url(
|
||||
avatar_url.as_deref(),
|
||||
);
|
||||
host.editor_state_mut().editor_ui.account =
|
||||
op_editor_core::AccountState::SignedIn {
|
||||
handle: primary_email.unwrap_or_else(|| display_name.clone()),
|
||||
|
|
|
|||
|
|
@ -77,8 +77,12 @@ impl WidgetHostNative {
|
|||
AuthStatus::SignedIn {
|
||||
display_name,
|
||||
primary_email,
|
||||
avatar_url,
|
||||
..
|
||||
} => {
|
||||
let _ = op_editor_ui::collab_avatar_runtime::register_account_avatar_url(
|
||||
avatar_url.as_deref(),
|
||||
);
|
||||
ui.account = AccountState::SignedIn {
|
||||
handle: primary_email.unwrap_or_else(|| display_name.clone()),
|
||||
display_name,
|
||||
|
|
@ -127,6 +131,7 @@ impl WidgetHostNative {
|
|||
AuthStatus::Idle
|
||||
) {
|
||||
self.editor_state.editor_ui.account = AccountState::Anonymous;
|
||||
let _ = op_editor_ui::collab_avatar_runtime::register_account_avatar_url(None);
|
||||
self.mark_dirty();
|
||||
return true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,9 @@ async-trait = "0.1"
|
|||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
base64 = "0.22"
|
||||
getrandom = "0.3"
|
||||
hmac = "0.12"
|
||||
sha2 = "0.10"
|
||||
zip = "2"
|
||||
memmap2 = "0.9"
|
||||
glam = { version = "0.29", default-features = false, features = ["std"] }
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ pub mod mcp_serve;
|
|||
pub mod model_discovery;
|
||||
mod model_probe;
|
||||
pub mod pre_validator;
|
||||
pub mod profile_avatar_fetch;
|
||||
mod provider_dial;
|
||||
pub mod provider_probe;
|
||||
pub mod provider_probe_host;
|
||||
|
|
|
|||
280
crates/op-host-services/src/profile_avatar_fetch.rs
Normal file
280
crates/op-host-services/src/profile_avatar_fetch.rs
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
//! SSRF-safe fetch and validation for authenticated profile avatars.
|
||||
|
||||
use hmac::{Hmac, Mac};
|
||||
use op_editor_ui::collab_avatar_runtime::{
|
||||
MAX_AVATAR_ENCODED_BYTES, MAX_AVATAR_SOURCE_EDGE_PX, MAX_AVATAR_SOURCE_PIXELS,
|
||||
};
|
||||
use reqwest::header::{ACCEPT, LOCATION};
|
||||
use sha2::Sha256;
|
||||
use std::fmt;
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Duration;
|
||||
|
||||
const MAX_AVATAR_URL_BYTES: usize = 2_048;
|
||||
const MAX_REDIRECTS: usize = 3;
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
static AVATAR_REVISION_KEY: OnceLock<Option<[u8; 32]>> = OnceLock::new();
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ProfileAvatarFetchError {
|
||||
UrlNotAllowed,
|
||||
DialRejected,
|
||||
RequestFailed,
|
||||
TimedOut,
|
||||
HttpStatus,
|
||||
RedirectInvalid,
|
||||
TooManyRedirects,
|
||||
TooLarge,
|
||||
EmptyBody,
|
||||
InvalidImage,
|
||||
RevisionUnavailable,
|
||||
}
|
||||
|
||||
impl fmt::Display for ProfileAvatarFetchError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(match self {
|
||||
Self::UrlNotAllowed => "avatar URL is not allowed",
|
||||
Self::DialRejected => "avatar host is not publicly routable",
|
||||
Self::RequestFailed => "avatar request failed",
|
||||
Self::TimedOut => "avatar request timed out",
|
||||
Self::HttpStatus => "avatar server returned an error status",
|
||||
Self::RedirectInvalid => "avatar redirect is invalid",
|
||||
Self::TooManyRedirects => "avatar request redirected too many times",
|
||||
Self::TooLarge => "avatar image is too large",
|
||||
Self::EmptyBody => "avatar response is empty",
|
||||
Self::InvalidImage => "avatar response is not a supported image",
|
||||
Self::RevisionUnavailable => "avatar revision key is unavailable",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ProfileAvatarFetchError {}
|
||||
|
||||
/// Stable opaque identity for one validated avatar URL. The raw URL may carry
|
||||
/// signed CDN query parameters and must not be returned to the browser.
|
||||
pub fn profile_avatar_revision(url: &str) -> Result<String, ProfileAvatarFetchError> {
|
||||
let url = parse_avatar_url(url)?;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(avatar_revision_key()?)
|
||||
.map_err(|_| ProfileAvatarFetchError::RevisionUnavailable)?;
|
||||
mac.update(url.as_str().as_bytes());
|
||||
Ok(format!("{:x}", mac.finalize().into_bytes()))
|
||||
}
|
||||
|
||||
/// Blocking entry point for desktop workers and serve-web connection threads.
|
||||
pub fn fetch_profile_avatar_blocking(url: &str) -> Result<Vec<u8>, ProfileAvatarFetchError> {
|
||||
crate::chat_runtime::block_on_anywhere(fetch_profile_avatar(url, false))
|
||||
}
|
||||
|
||||
/// Blocking fetch for the locally authenticated account's profile image.
|
||||
///
|
||||
/// Unlike remote collaboration profiles, this source may traverse a
|
||||
/// Clash-style fake-IP TUN when the hostname resolves exclusively into RFC
|
||||
/// 2544 space.
|
||||
pub fn fetch_account_avatar_blocking(url: &str) -> Result<Vec<u8>, ProfileAvatarFetchError> {
|
||||
crate::chat_runtime::block_on_anywhere(fetch_profile_avatar(url, true))
|
||||
}
|
||||
|
||||
async fn fetch_profile_avatar(
|
||||
url: &str,
|
||||
allow_account_tunnel: bool,
|
||||
) -> Result<Vec<u8>, ProfileAvatarFetchError> {
|
||||
let mut url = parse_avatar_url(url)?;
|
||||
for redirect_count in 0..=MAX_REDIRECTS {
|
||||
let client = if allow_account_tunnel {
|
||||
with_timeout(
|
||||
REQUEST_TIMEOUT,
|
||||
crate::public_https_client::tunnel_compatible_account_asset_client(&url),
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
with_timeout(
|
||||
REQUEST_TIMEOUT,
|
||||
crate::public_https_client::public_https_client(&url),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
.map_err(|_| ProfileAvatarFetchError::DialRejected)?;
|
||||
let mut response = client
|
||||
.get(url.clone())
|
||||
.header(ACCEPT, "image/webp,image/png,image/jpeg,image/gif")
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| ProfileAvatarFetchError::RequestFailed)?;
|
||||
|
||||
if response.status().is_redirection() {
|
||||
if redirect_count == MAX_REDIRECTS {
|
||||
return Err(ProfileAvatarFetchError::TooManyRedirects);
|
||||
}
|
||||
let location = response
|
||||
.headers()
|
||||
.get(LOCATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or(ProfileAvatarFetchError::RedirectInvalid)?;
|
||||
url = parse_avatar_url(
|
||||
url.join(location)
|
||||
.map_err(|_| ProfileAvatarFetchError::RedirectInvalid)?
|
||||
.as_str(),
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Err(ProfileAvatarFetchError::HttpStatus);
|
||||
}
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > MAX_AVATAR_ENCODED_BYTES as u64)
|
||||
{
|
||||
return Err(ProfileAvatarFetchError::TooLarge);
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(
|
||||
response
|
||||
.content_length()
|
||||
.unwrap_or(0)
|
||||
.min(MAX_AVATAR_ENCODED_BYTES as u64) as usize,
|
||||
);
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|_| ProfileAvatarFetchError::RequestFailed)?
|
||||
{
|
||||
if bytes.len().saturating_add(chunk.len()) > MAX_AVATAR_ENCODED_BYTES {
|
||||
return Err(ProfileAvatarFetchError::TooLarge);
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
validate_profile_avatar_bytes(&bytes)?;
|
||||
return Ok(bytes);
|
||||
}
|
||||
Err(ProfileAvatarFetchError::TooManyRedirects)
|
||||
}
|
||||
|
||||
fn validate_profile_avatar_bytes(bytes: &[u8]) -> Result<(), ProfileAvatarFetchError> {
|
||||
if bytes.is_empty() {
|
||||
return Err(ProfileAvatarFetchError::EmptyBody);
|
||||
}
|
||||
if bytes.len() > MAX_AVATAR_ENCODED_BYTES {
|
||||
return Err(ProfileAvatarFetchError::TooLarge);
|
||||
}
|
||||
let (width, height) = op_editor_ui::image_runtime::encoded_image_dimensions(bytes)
|
||||
.ok_or(ProfileAvatarFetchError::InvalidImage)?;
|
||||
if width > MAX_AVATAR_SOURCE_EDGE_PX
|
||||
|| height > MAX_AVATAR_SOURCE_EDGE_PX
|
||||
|| u64::from(width) * u64::from(height) > MAX_AVATAR_SOURCE_PIXELS
|
||||
{
|
||||
return Err(ProfileAvatarFetchError::TooLarge);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn with_timeout<F, T>(duration: Duration, future: F) -> Result<T, ProfileAvatarFetchError>
|
||||
where
|
||||
F: std::future::Future<Output = T>,
|
||||
{
|
||||
tokio::time::timeout(duration, future)
|
||||
.await
|
||||
.map_err(|_| ProfileAvatarFetchError::TimedOut)
|
||||
}
|
||||
|
||||
fn parse_avatar_url(value: &str) -> Result<reqwest::Url, ProfileAvatarFetchError> {
|
||||
if value.is_empty()
|
||||
|| value.len() > MAX_AVATAR_URL_BYTES
|
||||
|| value.chars().any(char::is_whitespace)
|
||||
{
|
||||
return Err(ProfileAvatarFetchError::UrlNotAllowed);
|
||||
}
|
||||
let url = reqwest::Url::parse(value).map_err(|_| ProfileAvatarFetchError::UrlNotAllowed)?;
|
||||
if url.scheme() != "https"
|
||||
|| url.host_str().is_none()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.fragment().is_some()
|
||||
|| url.port() == Some(0)
|
||||
{
|
||||
return Err(ProfileAvatarFetchError::UrlNotAllowed);
|
||||
}
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
fn avatar_revision_key() -> Result<&'static [u8; 32], ProfileAvatarFetchError> {
|
||||
AVATAR_REVISION_KEY
|
||||
.get_or_init(|| {
|
||||
let mut key = [0_u8; 32];
|
||||
getrandom::fill(&mut key).ok().map(|()| key)
|
||||
})
|
||||
.as_ref()
|
||||
.ok_or(ProfileAvatarFetchError::RevisionUnavailable)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn png_header(width: u32, height: u32) -> Vec<u8> {
|
||||
let mut bytes = vec![0; 32];
|
||||
bytes[..8].copy_from_slice(b"\x89PNG\r\n\x1a\n");
|
||||
bytes[12..16].copy_from_slice(b"IHDR");
|
||||
bytes[16..20].copy_from_slice(&width.to_be_bytes());
|
||||
bytes[20..24].copy_from_slice(&height.to_be_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revision_is_stable_without_exposing_the_url() {
|
||||
let url = "https://cdn.example/avatar.png?signature=secret";
|
||||
let revision = profile_avatar_revision(url).unwrap();
|
||||
assert_eq!(revision, profile_avatar_revision(url).unwrap());
|
||||
assert_eq!(revision.len(), 64);
|
||||
assert!(!revision.contains("secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_and_ssrf_guards_reject_unsafe_targets() {
|
||||
for url in [
|
||||
"http://cdn.example/avatar.png",
|
||||
"https://user@cdn.example/avatar.png",
|
||||
"https://cdn.example/avatar.png#fragment",
|
||||
"https://cdn.example:0/avatar.png",
|
||||
] {
|
||||
assert_eq!(
|
||||
profile_avatar_revision(url),
|
||||
Err(ProfileAvatarFetchError::UrlNotAllowed)
|
||||
);
|
||||
}
|
||||
for url in [
|
||||
"https://127.0.0.1/avatar.png",
|
||||
"https://10.0.0.1/avatar.png",
|
||||
"https://169.254.169.254/latest/meta-data",
|
||||
"https://[::1]/avatar.png",
|
||||
"https://[fe80::1]/avatar.png",
|
||||
] {
|
||||
assert_eq!(
|
||||
fetch_profile_avatar_blocking(url),
|
||||
Err(ProfileAvatarFetchError::DialRejected),
|
||||
"{url}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_image_dimensions_are_bounded_before_proxying() {
|
||||
assert_eq!(validate_profile_avatar_bytes(&png_header(16, 16)), Ok(()));
|
||||
assert_eq!(
|
||||
validate_profile_avatar_bytes(&png_header(MAX_AVATAR_SOURCE_EDGE_PX + 1, 1)),
|
||||
Err(ProfileAvatarFetchError::TooLarge)
|
||||
);
|
||||
assert_eq!(
|
||||
validate_profile_avatar_bytes(b"not an image"),
|
||||
Err(ProfileAvatarFetchError::InvalidImage)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolver_deadline_cancels_a_hanging_dial_future() {
|
||||
let result = crate::chat_runtime::block_on_anywhere(with_timeout(
|
||||
Duration::from_millis(1),
|
||||
std::future::pending::<()>(),
|
||||
));
|
||||
assert_eq!(result, Err(ProfileAvatarFetchError::TimedOut));
|
||||
}
|
||||
}
|
||||
|
|
@ -46,11 +46,25 @@ pub(crate) async fn client_for(
|
|||
) -> Result<reqwest::Client, ProviderDialError> {
|
||||
match policy {
|
||||
EndpointDialPolicy::Trusted => crate::chat_builtin_http::builtin_http_client(),
|
||||
EndpointDialPolicy::PublicOnly => pinned_public_client(url).await,
|
||||
EndpointDialPolicy::PublicOnly => pinned_public_client(url, false).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn pinned_public_client(url: &str) -> Result<reqwest::Client, ProviderDialError> {
|
||||
/// Build the stricter asset-only client used by bounded profile images.
|
||||
///
|
||||
/// Clash-style fake-IP TUN resolvers use RFC 2544 `198.18.0.0/15` addresses as
|
||||
/// opaque public-host tokens. This seam accepts only a hostname whose entire
|
||||
/// resolution is in that range; literals and mixed sets remain rejected.
|
||||
pub(crate) async fn client_for_tunnel_compatible_public_asset(
|
||||
url: &str,
|
||||
) -> Result<reqwest::Client, ProviderDialError> {
|
||||
pinned_public_client(url, true).await
|
||||
}
|
||||
|
||||
async fn pinned_public_client(
|
||||
url: &str,
|
||||
allow_tunnel_fake_ip: bool,
|
||||
) -> Result<reqwest::Client, ProviderDialError> {
|
||||
let parsed = reqwest::Url::parse(url).map_err(|_| ProviderDialError::NotAUrl)?;
|
||||
let host = parsed
|
||||
.host_str()
|
||||
|
|
@ -61,6 +75,7 @@ async fn pinned_public_client(url: &str) -> Result<reqwest::Client, ProviderDial
|
|||
let port = parsed
|
||||
.port_or_known_default()
|
||||
.ok_or(ProviderDialError::MissingPort)?;
|
||||
let host_is_literal = host.parse::<std::net::IpAddr>().is_ok();
|
||||
let addrs = if let Ok(ip) = host.parse::<std::net::IpAddr>() {
|
||||
// Literal address: nothing to resolve, screening alone suffices.
|
||||
vec![SocketAddr::new(ip, port)]
|
||||
|
|
@ -73,7 +88,8 @@ async fn pinned_public_client(url: &str) -> Result<reqwest::Client, ProviderDial
|
|||
})?
|
||||
.collect()
|
||||
};
|
||||
let addrs = screen_resolved_addrs(&host, addrs)?;
|
||||
let addrs =
|
||||
screen_resolved_addrs_for_policy(&host, host_is_literal, addrs, allow_tunnel_fake_ip)?;
|
||||
// `.no_proxy()` is load-bearing, not a tidy-up: with an env/system HTTP
|
||||
// proxy configured, reqwest tunnels the request to the proxy and the
|
||||
// proxy re-resolves the target host — so `resolve_to_addrs` would be
|
||||
|
|
@ -91,15 +107,33 @@ async fn pinned_public_client(url: &str) -> Result<reqwest::Client, ProviderDial
|
|||
/// Screen a resolved address set for a `PublicOnly` dial. Empty resolutions
|
||||
/// and sets containing ANY reserved address are rejected — a mixed
|
||||
/// public/private answer is exactly the rebinding shape this guards against.
|
||||
pub(crate) fn screen_resolved_addrs(
|
||||
#[cfg(test)]
|
||||
fn screen_resolved_addrs(
|
||||
host: &str,
|
||||
addrs: Vec<SocketAddr>,
|
||||
) -> Result<Vec<SocketAddr>, ProviderDialError> {
|
||||
screen_resolved_addrs_for_policy(host, false, addrs, false)
|
||||
}
|
||||
|
||||
fn screen_resolved_addrs_for_policy(
|
||||
host: &str,
|
||||
host_is_literal: bool,
|
||||
addrs: Vec<SocketAddr>,
|
||||
allow_tunnel_fake_ip: bool,
|
||||
) -> Result<Vec<SocketAddr>, ProviderDialError> {
|
||||
if addrs.is_empty() {
|
||||
return Err(ProviderDialError::Unresolved {
|
||||
host: host.to_string(),
|
||||
});
|
||||
}
|
||||
let tunnel_fake_ip_set = allow_tunnel_fake_ip
|
||||
&& !host_is_literal
|
||||
&& addrs
|
||||
.iter()
|
||||
.all(|address| is_rfc2544_benchmark_address(address.ip()));
|
||||
if tunnel_fake_ip_set {
|
||||
return Ok(addrs);
|
||||
}
|
||||
if addrs
|
||||
.iter()
|
||||
.any(|addr| crate::web_credentials::is_restricted_ip(addr.ip()))
|
||||
|
|
@ -111,6 +145,14 @@ pub(crate) fn screen_resolved_addrs(
|
|||
Ok(addrs)
|
||||
}
|
||||
|
||||
fn is_rfc2544_benchmark_address(address: std::net::IpAddr) -> bool {
|
||||
matches!(
|
||||
address,
|
||||
std::net::IpAddr::V4(address)
|
||||
if address.octets()[0] == 198 && matches!(address.octets()[1], 18 | 19)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -184,6 +226,7 @@ mod tests {
|
|||
"192.168.1.1",
|
||||
"169.254.169.254",
|
||||
"168.63.129.16",
|
||||
"198.18.0.42",
|
||||
"::1",
|
||||
"fd00:ec2::254",
|
||||
] {
|
||||
|
|
@ -194,4 +237,21 @@ mod tests {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_asset_policy_accepts_only_hostname_fake_ip_sets() {
|
||||
let fake_ip = vec![addr("198.18.0.42")];
|
||||
assert!(
|
||||
screen_resolved_addrs_for_policy("avatar.example", false, fake_ip.clone(), true,)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(screen_resolved_addrs_for_policy("198.18.0.42", true, fake_ip, true,).is_err());
|
||||
assert!(screen_resolved_addrs_for_policy(
|
||||
"avatar.example",
|
||||
false,
|
||||
vec![addr("198.18.0.42"), addr("93.184.216.34")],
|
||||
true,
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
//! Public-only HTTPS dialing for small, unauthenticated assets.
|
||||
//!
|
||||
//! Callers still own response limits and redirect policy. This module owns
|
||||
//! the security-sensitive connect step: every hostname is resolved, every
|
||||
//! address is screened, proxies are disabled, and the client is pinned to the
|
||||
//! screened addresses so DNS rebinding cannot redirect the socket later.
|
||||
//! the security-sensitive connect step: every hostname is resolved, addresses
|
||||
//! are screened, proxies are disabled, and the client is pinned so DNS
|
||||
//! rebinding cannot redirect the socket later. A separate current-account-only
|
||||
//! seam recognizes an all-RFC-2544 hostname resolution as a Clash-style
|
||||
//! fake-IP TUN token; literals, mixed sets, and every other reserved range
|
||||
//! remain rejected.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
|
|
@ -49,6 +52,27 @@ pub async fn public_https_client(
|
|||
.map_err(|_| PublicHttpsClientError::DialRejected)
|
||||
}
|
||||
|
||||
/// Build the account-avatar-only variant that can traverse a Clash-style
|
||||
/// fake-IP TUN. Callers must establish that the URL belongs to the local
|
||||
/// authenticated account; remote collaboration data must use
|
||||
/// [`public_https_client`].
|
||||
pub async fn tunnel_compatible_account_asset_client(
|
||||
url: &reqwest::Url,
|
||||
) -> Result<reqwest::Client, PublicHttpsClientError> {
|
||||
if url.scheme() != "https"
|
||||
|| url.host_str().is_none()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.fragment().is_some()
|
||||
|| url.port() == Some(0)
|
||||
{
|
||||
return Err(PublicHttpsClientError::UrlNotAllowed);
|
||||
}
|
||||
crate::provider_dial::client_for_tunnel_compatible_public_asset(url.as_str())
|
||||
.await
|
||||
.map_err(|_| PublicHttpsClientError::DialRejected)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -73,8 +73,12 @@ pub(crate) fn status(state: &mut WebCanvasState) -> WebReply {
|
|||
AuthStatus::SignedIn {
|
||||
display_name,
|
||||
primary_email,
|
||||
avatar_url,
|
||||
..
|
||||
} => {
|
||||
let avatar_revision = avatar_url
|
||||
.as_deref()
|
||||
.and_then(|url| crate::profile_avatar_fetch::profile_avatar_revision(url).ok());
|
||||
state.editor.editor_ui.account = AccountState::SignedIn {
|
||||
handle: primary_email
|
||||
.clone()
|
||||
|
|
@ -86,6 +90,7 @@ pub(crate) fn status(state: &mut WebCanvasState) -> WebReply {
|
|||
"signed_in": true,
|
||||
"display_name": display_name,
|
||||
"primary_email": primary_email,
|
||||
"avatar_revision": avatar_revision,
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
|
|
@ -253,8 +258,12 @@ pub(crate) fn login_status(state: &mut WebCanvasState) -> WebReply {
|
|||
AuthStatus::SignedIn {
|
||||
display_name,
|
||||
primary_email,
|
||||
avatar_url,
|
||||
..
|
||||
} => {
|
||||
let avatar_revision = avatar_url
|
||||
.as_deref()
|
||||
.and_then(|url| crate::profile_avatar_fetch::profile_avatar_revision(url).ok());
|
||||
state.auth_login_handle = None;
|
||||
state.editor.editor_ui.account = AccountState::SignedIn {
|
||||
handle: primary_email
|
||||
|
|
@ -266,6 +275,7 @@ pub(crate) fn login_status(state: &mut WebCanvasState) -> WebReply {
|
|||
"state": "signed_in",
|
||||
"display_name": display_name,
|
||||
"primary_email": primary_email,
|
||||
"avatar_revision": avatar_revision,
|
||||
})
|
||||
}
|
||||
AuthStatus::Error { code } => {
|
||||
|
|
@ -292,6 +302,49 @@ pub(crate) fn login_cancel(state: &mut WebCanvasState) -> WebReply {
|
|||
ok()
|
||||
}
|
||||
|
||||
/// `POST /api/auth/avatar` — proxy the current profile image through the
|
||||
/// daemon's public-only HTTPS client. The browser receives only an opaque
|
||||
/// revision and bounded bytes, never the signed CDN URL.
|
||||
pub(crate) fn avatar() -> WebReply {
|
||||
if !op_auth_bridge::available() {
|
||||
return WebReply {
|
||||
status: "404 Not Found",
|
||||
body: crate::mcp_serve::rest_error_body("account avatar unavailable"),
|
||||
};
|
||||
}
|
||||
let AuthStatus::SignedIn {
|
||||
avatar_url: Some(url),
|
||||
..
|
||||
} = op_auth_bridge::poll(op_auth_bridge::SESSION_HANDLE)
|
||||
else {
|
||||
return WebReply {
|
||||
status: "404 Not Found",
|
||||
body: crate::mcp_serve::rest_error_body("account avatar unavailable"),
|
||||
};
|
||||
};
|
||||
let Ok(revision) = crate::profile_avatar_fetch::profile_avatar_revision(&url) else {
|
||||
return WebReply {
|
||||
status: "404 Not Found",
|
||||
body: crate::mcp_serve::rest_error_body("account avatar unavailable"),
|
||||
};
|
||||
};
|
||||
let Ok(bytes) = crate::profile_avatar_fetch::fetch_account_avatar_blocking(&url) else {
|
||||
return WebReply {
|
||||
status: "502 Bad Gateway",
|
||||
body: crate::mcp_serve::rest_error_body("account avatar fetch failed"),
|
||||
};
|
||||
};
|
||||
use base64::Engine as _;
|
||||
WebReply {
|
||||
status: "200 OK",
|
||||
body: serde_json::json!({
|
||||
"revision": revision,
|
||||
"encoded": base64::engine::general_purpose::STANDARD.encode(bytes),
|
||||
})
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/auth/logout` — drop the shared session (revokes the device
|
||||
/// token server-side on a background thread inside the library).
|
||||
pub(crate) fn logout(state: &mut WebCanvasState) -> WebReply {
|
||||
|
|
|
|||
|
|
@ -112,6 +112,18 @@ pub(super) fn serve_one<S: Read + Write>(
|
|||
)?;
|
||||
return Ok(false);
|
||||
}
|
||||
// Current-account avatar proxy: performs bounded public HTTPS I/O on this
|
||||
// connection thread, never while holding the editor-state mutex.
|
||||
if req.method == "POST" && req.path == op_editor_core::auth_routes::AVATAR {
|
||||
let reply = crate::web_auth::avatar();
|
||||
crate::mcp_serve::write_mcp_http_response_with_origin(
|
||||
stream,
|
||||
reply.status,
|
||||
&reply.body,
|
||||
cors_origin,
|
||||
)?;
|
||||
return Ok(false);
|
||||
}
|
||||
// Device-login begin: waits (per-connection thread, off the state
|
||||
// lock) for the pairing's verification URI so the popup can navigate
|
||||
// straight from this response — handled here rather than in the
|
||||
|
|
|
|||
|
|
@ -325,6 +325,40 @@ fn cross_origin_browser_cannot_write_server_credentials() {
|
|||
assert_eq!(before, crate::settings_io::fingerprint(&guard.editor));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_origin_browser_cannot_read_account_avatar() {
|
||||
let state = Mutex::new(fresh_state());
|
||||
let request = format!(
|
||||
"POST {} HTTP/1.1\r\nHost: 127.0.0.1:3100\r\nOrigin: https://evil.example\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{{}}",
|
||||
op_editor_core::auth_routes::AVATAR
|
||||
);
|
||||
let request_len = request.len();
|
||||
let mut stream = std::io::Cursor::new(request.into_bytes());
|
||||
|
||||
serve_one(&mut stream, &state, &SseHub::default()).expect("request handled");
|
||||
|
||||
let response = String::from_utf8_lossy(&stream.get_ref()[request_len..]);
|
||||
assert!(response.contains("403 Forbidden"));
|
||||
assert!(response.contains("cross-origin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_avatar_proxy_rejects_subresource_gets_without_fetching() {
|
||||
let state = Mutex::new(fresh_state());
|
||||
let request = format!(
|
||||
"GET {} HTTP/1.1\r\nHost: 127.0.0.1:3100\r\nContent-Length: 0\r\n\r\n",
|
||||
op_editor_core::auth_routes::AVATAR
|
||||
);
|
||||
let request_len = request.len();
|
||||
let mut stream = std::io::Cursor::new(request.into_bytes());
|
||||
|
||||
serve_one(&mut stream, &state, &SseHub::default()).expect("request handled");
|
||||
|
||||
let response = String::from_utf8_lossy(&stream.get_ref()[request_len..]);
|
||||
assert!(response.contains("404 Not Found"));
|
||||
assert!(!response.contains("\"encoded\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn credential_origin_check_allows_default_loopback_and_non_browser_clients() {
|
||||
for headers in [
|
||||
|
|
|
|||
|
|
@ -16,15 +16,19 @@ pub(crate) struct WebDecodeJob {
|
|||
pub(crate) fn take_web_decode_batch(max: usize) -> Vec<WebDecodeJob> {
|
||||
take_pending_decodes(max)
|
||||
.into_iter()
|
||||
.filter_map(|pending| match cached_bytes_for(pending.id) {
|
||||
Some(bytes) => Some(WebDecodeJob {
|
||||
id: pending.id,
|
||||
bytes,
|
||||
max_edge_px: pending.max_edge_px,
|
||||
}),
|
||||
None => {
|
||||
mark_decode_done(pending.id);
|
||||
None
|
||||
.filter_map(|pending| {
|
||||
match cached_bytes_for(pending.id).or_else(|| {
|
||||
op_editor_ui::collab_avatar_runtime::cached_collab_avatar_bytes(pending.id)
|
||||
}) {
|
||||
Some(bytes) => Some(WebDecodeJob {
|
||||
id: pending.id,
|
||||
bytes,
|
||||
max_edge_px: pending.max_edge_px,
|
||||
}),
|
||||
None => {
|
||||
mark_decode_done(pending.id);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
|
|
@ -101,4 +105,34 @@ mod tests {
|
|||
"a failed bridge decode must remain negatively cached"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_decode_batch_accepts_bounded_profile_avatar_bytes() {
|
||||
let _decode_guard = DECODE_REGISTRY_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner());
|
||||
let _ = op_editor_ui::collab_avatar_runtime::begin_collab_avatar_generation(0xACCA_0001);
|
||||
let _ = op_editor_ui::collab_avatar_runtime::register_account_avatar_url(None);
|
||||
clear_pending_work();
|
||||
let mut png = vec![0; 32];
|
||||
png[..8].copy_from_slice(b"\x89PNG\r\n\x1a\n");
|
||||
png[12..16].copy_from_slice(b"IHDR");
|
||||
png[16..20].copy_from_slice(&16_u32.to_be_bytes());
|
||||
png[20..24].copy_from_slice(&16_u32.to_be_bytes());
|
||||
assert!(
|
||||
op_editor_ui::collab_avatar_runtime::install_account_avatar_bytes(
|
||||
"web-avatar-revision",
|
||||
png
|
||||
)
|
||||
);
|
||||
let image = op_editor_ui::collab_avatar_runtime::account_avatar_image()
|
||||
.expect("ready account avatar");
|
||||
note_pending_decode(image.image_id, 64);
|
||||
|
||||
let batch = take_web_decode_batch(1);
|
||||
assert_eq!(batch.len(), 1);
|
||||
assert_eq!(batch[0].id, image.image_id);
|
||||
finish_web_decode(batch[0].id, true);
|
||||
clear_pending_work();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,13 @@ thread_local! {
|
|||
/// The begin POST is in flight — status polls are suppressed so they
|
||||
/// can't observe the daemon's pre-begin `idle` state.
|
||||
static BEGIN_INFLIGHT: Cell<bool> = const { Cell::new(false) };
|
||||
/// Opaque avatar revision requested by the latest authenticated status.
|
||||
static ACCOUNT_AVATAR_DESIRED: RefCell<Option<String>> = const { RefCell::new(None) };
|
||||
/// Revision currently installed in the bounded profile-avatar cache.
|
||||
static ACCOUNT_AVATAR_INSTALLED: RefCell<Option<String>> = const { RefCell::new(None) };
|
||||
/// At most one request per revision; a changed revision may supersede an
|
||||
/// older in-flight request, whose callback is discarded.
|
||||
static ACCOUNT_AVATAR_IN_FLIGHT: RefCell<Option<String>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Shared latches for the interval tick.
|
||||
|
|
@ -168,6 +175,7 @@ fn fetch_status<C: RepaintContext + 'static>(inner: &Rc<RefCell<C>>, base: &str)
|
|||
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&body) else {
|
||||
return;
|
||||
};
|
||||
sync_account_avatar(&inner, parsed["avatar_revision"].as_str());
|
||||
let mut b = inner.borrow_mut();
|
||||
let ui = &mut b.host_mut().editor_state_mut().editor_ui;
|
||||
let available = parsed["available"].as_bool().unwrap_or(false);
|
||||
|
|
@ -211,7 +219,10 @@ fn drain_actions<C: RepaintContext + 'static>(inner: &Rc<RefCell<C>>, base: &str
|
|||
close_login_popup_placeholder();
|
||||
auth_routes::LOGIN_CANCEL
|
||||
}
|
||||
PendingAuthAction::SignOut => auth_routes::LOGOUT,
|
||||
PendingAuthAction::SignOut => {
|
||||
clear_account_avatar_sync_state();
|
||||
auth_routes::LOGOUT
|
||||
}
|
||||
};
|
||||
let _ = live_sync::post_json(&format!("{base}{path}"), "{}", None);
|
||||
}
|
||||
|
|
@ -281,6 +292,7 @@ fn apply_login_status<C: RepaintContext + 'static>(
|
|||
}
|
||||
"exchanging" => ui.login_modal_status = Some(LoginFlowStatus::Exchanging),
|
||||
"signed_in" => {
|
||||
sync_account_avatar(inner, parsed["avatar_revision"].as_str());
|
||||
let display_name = parsed["display_name"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
|
|
@ -329,3 +341,125 @@ fn apply_login_status<C: RepaintContext + 'static>(
|
|||
let _ = b.repaint();
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_account_avatar<C: RepaintContext + 'static>(
|
||||
inner: &Rc<RefCell<C>>,
|
||||
revision: Option<&str>,
|
||||
) {
|
||||
let revision = revision
|
||||
.filter(|value| {
|
||||
!value.is_empty()
|
||||
&& value.len() <= 128
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
|
||||
})
|
||||
.map(str::to_owned);
|
||||
ACCOUNT_AVATAR_DESIRED.with(|desired| *desired.borrow_mut() = revision.clone());
|
||||
let Some(revision) = revision else {
|
||||
clear_account_avatar_sync_state();
|
||||
return;
|
||||
};
|
||||
if account_avatar_revision_active(&revision) {
|
||||
return;
|
||||
}
|
||||
|
||||
ACCOUNT_AVATAR_INSTALLED.with(|installed| installed.borrow_mut().take());
|
||||
let _ = op_editor_ui::collab_avatar_runtime::register_account_avatar_url(None);
|
||||
ACCOUNT_AVATAR_IN_FLIGHT.with(|in_flight| {
|
||||
*in_flight.borrow_mut() = Some(revision.clone());
|
||||
});
|
||||
let expected = revision.clone();
|
||||
let inner = inner.clone();
|
||||
let url = format!(
|
||||
"{}{}",
|
||||
crate::daemon_base::daemon_base(),
|
||||
auth_routes::AVATAR
|
||||
);
|
||||
let started = live_sync::post_json_with_status(
|
||||
&url,
|
||||
"{}",
|
||||
Rc::new(move |status, body| {
|
||||
ACCOUNT_AVATAR_IN_FLIGHT.with(|in_flight| {
|
||||
if in_flight.borrow().as_deref() == Some(expected.as_str()) {
|
||||
in_flight.borrow_mut().take();
|
||||
}
|
||||
});
|
||||
let still_desired = ACCOUNT_AVATAR_DESIRED
|
||||
.with(|desired| desired.borrow().as_deref() == Some(expected.as_str()));
|
||||
if status != 200 || !still_desired {
|
||||
return;
|
||||
}
|
||||
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&body) else {
|
||||
return;
|
||||
};
|
||||
if parsed["revision"].as_str() != Some(expected.as_str()) {
|
||||
return;
|
||||
}
|
||||
use base64::Engine as _;
|
||||
let Some(encoded) = parsed["encoded"].as_str().and_then(|value| {
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(value.as_bytes())
|
||||
.ok()
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
if !op_editor_ui::collab_avatar_runtime::install_account_avatar_bytes(
|
||||
&expected, encoded,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
ACCOUNT_AVATAR_INSTALLED.with(|installed| {
|
||||
*installed.borrow_mut() = Some(expected.clone());
|
||||
});
|
||||
let mut context = inner.borrow_mut();
|
||||
context.host_mut().mark_editor_state_dirty();
|
||||
let _ = context.repaint();
|
||||
}),
|
||||
);
|
||||
if !started {
|
||||
ACCOUNT_AVATAR_IN_FLIGHT.with(|in_flight| in_flight.borrow_mut().take());
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_account_avatar_sync_state() {
|
||||
clear_account_avatar_revision_latches();
|
||||
let _ = op_editor_ui::collab_avatar_runtime::register_account_avatar_url(None);
|
||||
}
|
||||
|
||||
fn clear_account_avatar_revision_latches() {
|
||||
ACCOUNT_AVATAR_DESIRED.with(|desired| desired.borrow_mut().take());
|
||||
ACCOUNT_AVATAR_INSTALLED.with(|installed| installed.borrow_mut().take());
|
||||
ACCOUNT_AVATAR_IN_FLIGHT.with(|in_flight| in_flight.borrow_mut().take());
|
||||
}
|
||||
|
||||
fn account_avatar_revision_active(revision: &str) -> bool {
|
||||
ACCOUNT_AVATAR_INSTALLED.with(|installed| installed.borrow().as_deref() == Some(revision))
|
||||
|| ACCOUNT_AVATAR_IN_FLIGHT
|
||||
.with(|in_flight| in_flight.borrow().as_deref() == Some(revision))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sign_out_latches_allow_same_revision_to_be_fetched_after_relogin() {
|
||||
const REVISION: &str = "same-account-revision";
|
||||
ACCOUNT_AVATAR_DESIRED.with(|desired| {
|
||||
*desired.borrow_mut() = Some(REVISION.to_string());
|
||||
});
|
||||
ACCOUNT_AVATAR_INSTALLED.with(|installed| {
|
||||
*installed.borrow_mut() = Some(REVISION.to_string());
|
||||
});
|
||||
ACCOUNT_AVATAR_IN_FLIGHT.with(|in_flight| {
|
||||
*in_flight.borrow_mut() = Some(REVISION.to_string());
|
||||
});
|
||||
assert!(account_avatar_revision_active(REVISION));
|
||||
|
||||
clear_account_avatar_revision_latches();
|
||||
|
||||
assert!(!account_avatar_revision_active(REVISION));
|
||||
ACCOUNT_AVATAR_DESIRED.with(|desired| assert!(desired.borrow().is_none()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue