The smokesignal.events web application

feature: profiles

+2011 -21
+1 -1
Cargo.toml
··· 37 anyhow = "1.0" 38 async-trait = "0.1" 39 axum-extra = { version = "0.10", features = ["cookie", "cookie-private", "form", "query", "cookie-key-expansion", "typed-header", "typed-routing"] } 40 - axum = { version = "0.8", features = ["http2", "macros"] } 41 axum-template = { version = "3.0", features = ["minijinja-autoreload", "minijinja"] } 42 base64 = "0.22" 43 chrono-tz = { version = "0.10", features = ["serde"] }
··· 37 anyhow = "1.0" 38 async-trait = "0.1" 39 axum-extra = { version = "0.10", features = ["cookie", "cookie-private", "form", "query", "cookie-key-expansion", "typed-header", "typed-routing"] } 40 + axum = { version = "0.8", features = ["http2", "macros", "multipart"] } 41 axum-template = { version = "3.0", features = ["minijinja-autoreload", "minijinja"] } 42 base64 = "0.22" 43 chrono-tz = { version = "0.10", features = ["serde"] }
+18
docker-compose.yml
··· 51 /usr/bin/mc policy set public myminio/smokesignal-badges; 52 exit 0; 53 " 54 volumes: 55 minio_data: 56 driver: local 57 postgres_data: 58 driver: local 59 pgadmin_data: 60 driver: local 61 62 networks:
··· 51 /usr/bin/mc policy set public myminio/smokesignal-badges; 52 exit 0; 53 " 54 + 55 + redis: 56 + image: redis:7-alpine 57 + container_name: smokesignal_redis 58 + ports: 59 + - "6379:6379" 60 + volumes: 61 + - redis_data:/data 62 + command: redis-server --appendonly yes 63 + healthcheck: 64 + test: ["CMD", "redis-cli", "ping"] 65 + interval: 5s 66 + timeout: 3s 67 + retries: 5 68 + restart: unless-stopped 69 + 70 volumes: 71 minio_data: 72 driver: local 73 postgres_data: 74 driver: local 75 pgadmin_data: 76 + driver: local 77 + redis_data: 78 driver: local 79 80 networks:
+64
lexicon/events.smokesignal.profile.json
···
··· 1 + { 2 + "lexicon": 1, 3 + "id": "events.smokesignal.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A user profile for Smoke Signal", 8 + "key": "literal:self", 9 + "record": { 10 + "type": "object", 11 + "properties": { 12 + "displayName": { 13 + "type": "string", 14 + "description": "The display name of the identity.", 15 + "maxGraphemes": 200, 16 + "maxLength": 200 17 + }, 18 + "profile_host": { 19 + "type": "string", 20 + "description": "The service used for profile links", 21 + "knownValues": [ 22 + "bsky.app", 23 + "blacksky.community" 24 + ] 25 + }, 26 + "description": { 27 + "type": "string", 28 + "description": "A free text description of the identity.", 29 + "maxGraphemes": 2000, 30 + "maxLength": 2000 31 + }, 32 + "facets": { 33 + "type": "array", 34 + "description": "Annotations of text (mentions, URLs, hashtags, etc) in the description.", 35 + "items": { 36 + "type": "ref", 37 + "ref": "app.bsky.richtext.facet" 38 + } 39 + }, 40 + "avatar": { 41 + "type": "blob", 42 + "description": "Small image to be displayed next to events. AKA, 'profile picture'", 43 + "accept": ["image/png", "image/jpeg"], 44 + "maxSize": 1000000 45 + }, 46 + "banner": { 47 + "type": "blob", 48 + "description": "Larger horizontal image to display behind profile view.", 49 + "accept": ["image/png", "image/jpeg"], 50 + "maxSize": 1000000 51 + } 52 + } 53 + } 54 + }, 55 + "hiring": { 56 + "type": "token", 57 + "description": "Indicates the identity is actively hiring" 58 + }, 59 + "forhire": { 60 + "type": "token", 61 + "description": "Indicates the identity is available for hire" 62 + } 63 + } 64 + }
+10
migrations/20251024000000_profile_storage.sql
···
··· 1 + CREATE TABLE profiles ( 2 + aturi VARCHAR(1024) PRIMARY KEY, 3 + cid VARCHAR(256) NOT NULL, 4 + did VARCHAR(256) NOT NULL, 5 + display_name VARCHAR(1024) NOT NULL, 6 + record JSON NOT NULL, 7 + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW () 8 + ); 9 + 10 + CREATE INDEX idx_profiles_did ON profiles (did);
+1
src/atproto/lexicon/mod.rs
···
··· 1 + pub mod profile;
+367
src/atproto/lexicon/profile.rs
···
··· 1 + use std::collections::HashMap; 2 + use std::fmt::Write; 3 + 4 + use serde::{Deserialize, Serialize}; 5 + use atproto_record::lexicon::app::bsky::richtext::facet::Facet; 6 + use atproto_record::lexicon::TypedBlob; 7 + 8 + pub const NSID: &str = "events.smokesignal.profile"; 9 + 10 + #[derive(Clone, Serialize, Deserialize)] 11 + #[serde(rename_all = "camelCase")] 12 + pub struct Profile { 13 + #[serde(skip_serializing_if = "Option::is_none")] 14 + pub display_name: Option<String>, 15 + 16 + #[serde(skip_serializing_if = "Option::is_none")] 17 + pub description: Option<String>, 18 + 19 + #[serde(skip_serializing_if = "Option::is_none")] 20 + pub profile_host: Option<String>, 21 + 22 + #[serde(skip_serializing_if = "Option::is_none")] 23 + pub facets: Option<Vec<Facet>>, 24 + 25 + /// Avatar image (1:1 square aspect ratio) 26 + #[serde(skip_serializing_if = "Option::is_none")] 27 + pub avatar: Option<TypedBlob>, 28 + 29 + /// Banner image (16:9 aspect ratio) 30 + #[serde(skip_serializing_if = "Option::is_none")] 31 + pub banner: Option<TypedBlob>, 32 + 33 + /// Extension fields for forward compatibility 34 + #[serde(flatten)] 35 + pub extra: HashMap<String, serde_json::Value>, 36 + } 37 + 38 + impl Profile { 39 + pub fn validate(&self) -> Result<(), String> { 40 + if let Some(display_name) = &self.display_name 41 + && display_name.len() > 200 42 + { 43 + return Err("Display name must be 200 characters or less".to_string()); 44 + } 45 + 46 + if let Some(description) = &self.description { 47 + if description.len() > 5000 { 48 + return Err("Description must be 5000 characters or less".to_string()); 49 + } 50 + } 51 + 52 + if let Some(profile_host) = &self.profile_host 53 + && profile_host != "bsky.app" 54 + && profile_host != "blacksky.community" 55 + && profile_host != "smokesignal.events" 56 + { 57 + return Err("Profile host must be 'bsky.app', 'blacksky.community', or 'smokesignal.events'".to_string()); 58 + } 59 + 60 + // Validate facets if present 61 + if let Some(facets) = &self.facets 62 + && let Some(description) = &self.description 63 + { 64 + let description_bytes = description.len(); 65 + 66 + for facet in facets { 67 + // Validate byte indices 68 + if facet.index.byte_start >= facet.index.byte_end { 69 + return Err("Facet byte_start must be less than byte_end".to_string()); 70 + } 71 + if facet.index.byte_end > description_bytes { 72 + return Err("Facet byte indices exceed description length".to_string()); 73 + } 74 + 75 + // Validate features 76 + if facet.features.is_empty() { 77 + return Err("Facet must have at least one feature".to_string()); 78 + } 79 + 80 + for feature in &facet.features { 81 + match feature { 82 + atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature::Mention(mention) => { 83 + if !mention.did.starts_with("did:") { 84 + return Err("Mention DID must be a valid DID".to_string()); 85 + } 86 + } 87 + atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature::Link(link) => { 88 + if !link.uri.starts_with("http://") 89 + && !link.uri.starts_with("https://") 90 + { 91 + return Err( 92 + "Link URI must be a valid HTTP(S) URL".to_string() 93 + ); 94 + } 95 + } 96 + atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature::Tag(tag) => { 97 + if tag.tag.is_empty() || tag.tag.len() > 640 { 98 + return Err( 99 + "Tag must be between 1 and 640 characters".to_string() 100 + ); 101 + } 102 + } 103 + } 104 + } 105 + } 106 + } 107 + 108 + Ok(()) 109 + } 110 + 111 + /// Render the description with facets as HTML 112 + pub fn render_description_html(&self) -> Option<String> { 113 + let description = self.description.as_ref()?; 114 + 115 + // First, check if the description contains HTML tags 116 + if contains_html_tags(description) { 117 + let cleaned = ammonia::clean(description); 118 + return Some(cleaned.replace('\n', "<br>")); 119 + } 120 + 121 + let description_bytes = description.as_bytes(); 122 + 123 + // If no facets, just return escaped description 124 + let Some(facets) = &self.facets else { 125 + return Some(html_escape(description)); 126 + }; 127 + 128 + // Sort facets by start position to process them in order 129 + let mut sorted_facets: Vec<_> = facets.iter().collect(); 130 + sorted_facets.sort_by_key(|f| f.index.byte_start); 131 + 132 + let mut html = String::new(); 133 + let mut last_end = 0; 134 + 135 + for facet in sorted_facets { 136 + // Add any text before this facet (HTML-escaped) 137 + if facet.index.byte_start > last_end { 138 + let text_before = 139 + std::str::from_utf8(&description_bytes[last_end..facet.index.byte_start]) 140 + .unwrap_or(""); 141 + html.push_str(&html_escape(text_before)); 142 + } 143 + 144 + // Get the text covered by this facet 145 + let facet_text = std::str::from_utf8( 146 + &description_bytes[facet.index.byte_start..facet.index.byte_end], 147 + ) 148 + .unwrap_or(""); 149 + 150 + // Process the facet based on its feature type 151 + if let Some(feature) = facet.features.first() { 152 + match feature { 153 + atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature::Mention(mention) => { 154 + write!( 155 + &mut html, 156 + r#"<a href="/{}">{}</a>"#, 157 + html_escape(&mention.did), 158 + html_escape(facet_text) 159 + ) 160 + .unwrap(); 161 + } 162 + atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature::Link(link) => { 163 + if link.uri.starts_with("http://") 164 + || link.uri.starts_with("https://") 165 + || link.uri.starts_with("/") 166 + { 167 + write!( 168 + &mut html, 169 + r#"<a href="{}" target="_blank" rel="noopener noreferrer nofollow">{}</a>"#, 170 + html_escape(&link.uri), 171 + html_escape(facet_text) 172 + ) 173 + .unwrap(); 174 + } else { 175 + html.push_str(&html_escape(facet_text)); 176 + } 177 + } 178 + atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature::Tag(tag) => { 179 + let encoded_tag = urlencoding::encode(&tag.tag); 180 + write!( 181 + &mut html, 182 + r##"<a href="#{}">{}</a>"##, 183 + encoded_tag, 184 + html_escape(facet_text) 185 + ) 186 + .unwrap(); 187 + } 188 + } 189 + } 190 + 191 + last_end = facet.index.byte_end; 192 + } 193 + 194 + // Add any remaining text after the last facet 195 + if last_end < description_bytes.len() { 196 + let remaining_text = std::str::from_utf8(&description_bytes[last_end..]).unwrap_or(""); 197 + html.push_str(&html_escape(remaining_text)); 198 + } 199 + 200 + // Sanitize the final HTML output 201 + let mut builder = ammonia::Builder::new(); 202 + builder 203 + .tags(std::collections::HashSet::from(["a", "br"])) 204 + .link_rel(None) 205 + .url_relative(ammonia::UrlRelative::PassThrough) 206 + .attribute_filter(|element, attribute, value| match (element, attribute) { 207 + ("a", "href") => { 208 + if value.starts_with('/') 209 + || value.starts_with("http://") 210 + || value.starts_with("https://") 211 + || value.starts_with("#") 212 + { 213 + Some(value.into()) 214 + } else { 215 + None 216 + } 217 + } 218 + ("a", "target") => { 219 + if value == "_blank" { 220 + Some(value.into()) 221 + } else { 222 + None 223 + } 224 + } 225 + ("a", "rel") => { 226 + // Allow rel attributes that contain nofollow, noopener, or noreferrer 227 + if value.contains("nofollow") 228 + || value.contains("noopener") 229 + || value.contains("noreferrer") 230 + { 231 + Some(value.into()) 232 + } else { 233 + None 234 + } 235 + } 236 + ("br", _) => None, 237 + _ => None, 238 + }); 239 + 240 + Some(builder.clean(&html).to_string()) 241 + } 242 + 243 + /// Parse facets from the description text, respecting the limit. 244 + /// 245 + /// This function extracts mentions, URLs, and hashtags from the profile description 246 + /// and creates AT Protocol facets with proper byte indices. 247 + /// Only the first `limit` facets are kept. 248 + pub async fn parse_facets( 249 + &mut self, 250 + identity_resolver: &dyn atproto_identity::resolve::IdentityResolver, 251 + limit: usize, 252 + ) { 253 + let description = match &self.description { 254 + Some(d) => d, 255 + None => return, 256 + }; 257 + 258 + let mut facets = Vec::new(); 259 + 260 + // Parse mentions using shared function 261 + let mention_spans = crate::facets::parse_mentions(description); 262 + for span in mention_spans { 263 + // Try to resolve the handle to a DID 264 + let at_uri = format!("at://{}", span.handle); 265 + let did_result = match identity_resolver.resolve(&at_uri).await { 266 + Ok(doc) => Ok(doc), 267 + Err(_) => identity_resolver.resolve(&span.handle).await, 268 + }; 269 + 270 + if let Ok(did_doc) = did_result { 271 + facets.push(Facet { 272 + index: atproto_record::lexicon::app::bsky::richtext::facet::ByteSlice { 273 + byte_start: span.start, 274 + byte_end: span.end, 275 + }, 276 + features: vec![atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature::Mention( 277 + atproto_record::lexicon::app::bsky::richtext::facet::Mention { 278 + did: did_doc.id.to_string(), 279 + } 280 + )], 281 + }); 282 + 283 + if facets.len() >= limit { 284 + break; 285 + } 286 + } 287 + } 288 + 289 + // Parse URLs using shared function 290 + if facets.len() < limit { 291 + let url_spans = crate::facets::parse_urls(description); 292 + for span in url_spans { 293 + facets.push(Facet { 294 + index: atproto_record::lexicon::app::bsky::richtext::facet::ByteSlice { 295 + byte_start: span.start, 296 + byte_end: span.end, 297 + }, 298 + features: vec![atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature::Link( 299 + atproto_record::lexicon::app::bsky::richtext::facet::Link { 300 + uri: span.url, 301 + } 302 + )], 303 + }); 304 + 305 + if facets.len() >= limit { 306 + break; 307 + } 308 + } 309 + } 310 + 311 + // Parse hashtags using shared function 312 + if facets.len() < limit { 313 + let tag_spans = crate::facets::parse_tags(description); 314 + for span in tag_spans { 315 + facets.push(Facet { 316 + index: atproto_record::lexicon::app::bsky::richtext::facet::ByteSlice { 317 + byte_start: span.start, 318 + byte_end: span.end, 319 + }, 320 + features: vec![atproto_record::lexicon::app::bsky::richtext::facet::FacetFeature::Tag( 321 + atproto_record::lexicon::app::bsky::richtext::facet::Tag { 322 + tag: span.tag, 323 + } 324 + )], 325 + }); 326 + 327 + if facets.len() >= limit { 328 + break; 329 + } 330 + } 331 + } 332 + 333 + // Only set facets if we found any 334 + if !facets.is_empty() { 335 + self.facets = Some(facets); 336 + } 337 + } 338 + } 339 + 340 + /// HTML escape helper function 341 + fn html_escape(text: &str) -> String { 342 + text.chars() 343 + .map(|c| match c { 344 + '&' => "&amp;".to_string(), 345 + '<' => "&lt;".to_string(), 346 + '>' => "&gt;".to_string(), 347 + '"' => "&quot;".to_string(), 348 + '\'' => "&#39;".to_string(), 349 + c => c.to_string(), 350 + }) 351 + .collect() 352 + } 353 + 354 + /// Check if text contains HTML tags 355 + fn contains_html_tags(text: &str) -> bool { 356 + let mut chars = text.chars().peekable(); 357 + while let Some(ch) = chars.next() { 358 + if ch == '<' 359 + && let Some(&next_ch) = chars.peek() 360 + { 361 + if next_ch.is_ascii_alphabetic() || next_ch == '/' || next_ch == '!' { 362 + return true; 363 + } 364 + } 365 + } 366 + false 367 + }
+1
src/atproto/mod.rs
··· 1 pub mod auth; 2 pub mod utils;
··· 1 pub mod auth; 2 + pub mod lexicon; 3 pub mod utils;
+1 -1
src/bin/smokesignal.rs
··· 329 compression: false, 330 zstd_dictionary_location: String::new(), 331 jetstream_hostname: inner_config.jetstream_hostname.clone(), 332 - collections: vec!["community.lexicon.calendar.rsvp".to_string(), "community.lexicon.calendar.event".to_string()], 333 dids: vec![], 334 max_message_size_bytes: Some(20 * 1024 * 1024), // 10MB 335 cursor: None,
··· 329 compression: false, 330 zstd_dictionary_location: String::new(), 331 jetstream_hostname: inner_config.jetstream_hostname.clone(), 332 + collections: vec!["community.lexicon.calendar.rsvp".to_string(), "community.lexicon.calendar.event".to_string(), "events.smokesignal.profile".to_string()], 333 dids: vec![], 334 max_message_size_bytes: Some(20 * 1024 * 1024), // 10MB 335 cursor: None,
+3 -2
src/consumer.rs
··· 5 use tokio::sync::mpsc; 6 7 use atproto_record::lexicon::community::lexicon::{calendar::rsvp::NSID as RSVP_NSID, calendar::event::NSID as EVENT_NSID}; 8 9 pub type SmokeSignalEventReceiver = mpsc::UnboundedReceiver<SmokeSignalEvent>; 10 ··· 43 async fn handle_event(&self, event: JetstreamEvent) -> Result<()> { 44 let incoming_event = match event { 45 JetstreamEvent::Commit { did, commit, .. } => { 46 - if commit.collection != RSVP_NSID && commit.collection != EVENT_NSID { 47 return Ok(()); 48 } 49 ··· 56 } 57 } 58 JetstreamEvent::Delete { did, commit, .. } => { 59 - if commit.collection != RSVP_NSID && commit.collection != EVENT_NSID { 60 return Ok(()); 61 } 62 SmokeSignalEvent::Delete {
··· 5 use tokio::sync::mpsc; 6 7 use atproto_record::lexicon::community::lexicon::{calendar::rsvp::NSID as RSVP_NSID, calendar::event::NSID as EVENT_NSID}; 8 + use crate::atproto::lexicon::profile::NSID as PROFILE_NSID; 9 10 pub type SmokeSignalEventReceiver = mpsc::UnboundedReceiver<SmokeSignalEvent>; 11 ··· 44 async fn handle_event(&self, event: JetstreamEvent) -> Result<()> { 45 let incoming_event = match event { 46 JetstreamEvent::Commit { did, commit, .. } => { 47 + if commit.collection != RSVP_NSID && commit.collection != EVENT_NSID && commit.collection != PROFILE_NSID { 48 return Ok(()); 49 } 50 ··· 57 } 58 } 59 JetstreamEvent::Delete { did, commit, .. } => { 60 + if commit.collection != RSVP_NSID && commit.collection != EVENT_NSID && commit.collection != PROFILE_NSID { 61 return Ok(()); 62 } 63 SmokeSignalEvent::Delete {
+14
src/http/errors/common_error.rs
··· 30 /// or appears to be corrupted or tampered with. 31 #[error("error-smokesignal-common-4 Invalid event format or corrupted data")] 32 InvalidEventFormat, 33 }
··· 30 /// or appears to be corrupted or tampered with. 31 #[error("error-smokesignal-common-4 Invalid event format or corrupted data")] 32 InvalidEventFormat, 33 + 34 + /// Error when an image file has an invalid format. 35 + /// 36 + /// This error occurs when an uploaded image file is not in a supported 37 + /// format (PNG or JPEG). 38 + #[error("error-smokesignal-common-5 Invalid image format: only PNG and JPEG are supported")] 39 + InvalidImageFormat, 40 + 41 + /// Error when a record update fails due to a concurrent modification. 42 + /// 43 + /// This error occurs when attempting to update a record but the record 44 + /// has been modified by another request since it was last read (CAS failure). 45 + #[error("error-smokesignal-common-6 The record has been modified by another request. Please refresh and try again.")] 46 + InvalidSwap, 47 }
+422
src/http/handle_blob.rs
···
··· 1 + use anyhow::{anyhow, Result}; 2 + use atproto_client::client::{post_dpop_bytes_with_headers, post_dpop_json, DPoPAuth}; 3 + use atproto_client::com::atproto::repo::PutRecordRequest; 4 + use atproto_record::lexicon::TypedBlob; 5 + use axum::{extract::{Multipart, State}, response::IntoResponse}; 6 + use axum_extra::extract::Cached; 7 + use axum_htmx::{HxRequest, HxRetarget}; 8 + use bytes::Bytes; 9 + use http::StatusCode; 10 + use reqwest::header::CONTENT_TYPE; 11 + 12 + use crate::{ 13 + atproto::{ 14 + auth::{create_dpop_auth_from_aip_session, create_dpop_auth_from_oauth_session}, 15 + lexicon::profile::Profile, 16 + }, 17 + config::OAuthBackendConfig, 18 + contextual_error, 19 + http::{ 20 + context::WebContext, 21 + errors::{CommonError, WebError}, 22 + middleware_auth::Auth, 23 + middleware_i18n::Language, 24 + }, 25 + select_template, 26 + storage::profile::profile_get_by_aturi, 27 + }; 28 + 29 + use serde::Deserialize; 30 + use std::collections::HashMap; 31 + 32 + #[derive(Deserialize)] 33 + struct CreateBlobResponse { 34 + blob: TypedBlob, 35 + } 36 + 37 + /// Upload a blob to the PDS and return the TypedBlob reference 38 + async fn upload_blob_to_pds( 39 + http_client: &reqwest::Client, 40 + pds_endpoint: &str, 41 + dpop_auth: &DPoPAuth, 42 + data: &[u8], 43 + mime_type: &str, 44 + ) -> Result<TypedBlob> { 45 + let upload_url = format!("{}/xrpc/com.atproto.repo.uploadBlob", pds_endpoint); 46 + 47 + let mut headers = http::HeaderMap::default(); 48 + headers.insert(CONTENT_TYPE, mime_type.parse().unwrap()); 49 + 50 + let blob_response = post_dpop_bytes_with_headers( 51 + http_client, 52 + dpop_auth, 53 + &upload_url, 54 + Bytes::copy_from_slice(data), 55 + &headers, 56 + ) 57 + .await 58 + .map_err(|e| anyhow!("error-smokesignal-blob-1 Failed to upload blob: {}", e))?; 59 + 60 + serde_json::from_value::<CreateBlobResponse>(blob_response) 61 + .map(|created_blob| created_blob.blob) 62 + .map_err(|e| anyhow!("error-smokesignal-blob-2 Failed to parse upload response: {}", e)) 63 + } 64 + 65 + /// Handle profile avatar upload 66 + pub(crate) async fn upload_profile_avatar( 67 + State(web_context): State<WebContext>, 68 + Language(language): Language, 69 + HxRequest(hx_request): HxRequest, 70 + Cached(auth): Cached<Auth>, 71 + mut multipart: Multipart, 72 + ) -> Result<impl IntoResponse, WebError> { 73 + let error_template = select_template!(false, hx_request, language); 74 + 75 + // Require authentication 76 + let current_handle = auth.require_flat()?; 77 + 78 + // Extract the file from multipart data 79 + let mut file_data: Option<Vec<u8>> = None; 80 + 81 + while let Some(field) = multipart.next_field().await.map_err(|e| { 82 + anyhow!("error-smokesignal-blob-3 Multipart error: {}", e) 83 + })? { 84 + if field.name() == Some("avatar") { 85 + file_data = Some( 86 + field 87 + .bytes() 88 + .await 89 + .map_err(|e| { 90 + anyhow!( 91 + "error-smokesignal-blob-4 Failed to read file: {}", 92 + e 93 + ) 94 + })? 95 + .to_vec(), 96 + ); 97 + break; 98 + } 99 + } 100 + 101 + let file_data = file_data.ok_or_else(|| { 102 + anyhow!("error-smokesignal-blob-5 No avatar file provided") 103 + })?; 104 + 105 + // Validate and process the image 106 + crate::image::validate_image(&file_data, 1_000_000).map_err(|e| { 107 + anyhow!("error-smokesignal-blob-6 Image validation failed: {}", e) 108 + })?; 109 + 110 + let processed_data = match crate::image::process_avatar(&file_data) { 111 + Ok(data) => data, 112 + Err(_e) => { 113 + let default_context = minijinja::context! { 114 + language => language.to_string(), 115 + current_handle => current_handle.clone(), 116 + }; 117 + return contextual_error!( 118 + web_context, 119 + language, 120 + error_template, 121 + default_context, 122 + CommonError::InvalidImageFormat, 123 + StatusCode::BAD_REQUEST 124 + ); 125 + } 126 + }; 127 + 128 + // Get the PDS endpoint for the user 129 + let pds_endpoint = current_handle.pds.clone(); 130 + 131 + // Create DPoP authentication based on backend type 132 + let dpop_auth = match (&auth, &web_context.config.oauth_backend) { 133 + (Auth::Pds { session, .. }, OAuthBackendConfig::ATProtocol { .. }) => { 134 + create_dpop_auth_from_oauth_session(session)? 135 + } 136 + (Auth::Aip { access_token, .. }, OAuthBackendConfig::AIP { hostname, .. }) => { 137 + create_dpop_auth_from_aip_session(&web_context.http_client, hostname, access_token).await? 138 + } 139 + _ => { 140 + return Err(anyhow!("error-smokesignal-blob-7 Mismatched auth and backend config").into()); 141 + } 142 + }; 143 + 144 + // Upload blob to PDS 145 + let blob = upload_blob_to_pds( 146 + &web_context.http_client, 147 + &pds_endpoint, 148 + &dpop_auth, 149 + &processed_data, 150 + "image/png", 151 + ) 152 + .await?; 153 + 154 + // Get the profile aturi 155 + let profile_aturi = format!( 156 + "at://{}/events.smokesignal.profile/self", 157 + current_handle.did 158 + ); 159 + 160 + // Get existing profile for CAS 161 + let existing_profile = profile_get_by_aturi(&web_context.pool, &profile_aturi) 162 + .await 163 + .ok() 164 + .flatten(); 165 + 166 + let (mut profile, swap_record_cid) = if let Some(ref existing) = existing_profile { 167 + let existing_record: Profile = serde_json::from_value(existing.record.0.clone()) 168 + .map_err(|e| anyhow!("error-smokesignal-blob-8 Failed to parse existing profile: {}", e))?; 169 + (existing_record, Some(existing.cid.clone())) 170 + } else { 171 + (Profile { 172 + display_name: None, 173 + description: None, 174 + profile_host: None, 175 + facets: None, 176 + avatar: None, 177 + banner: None, 178 + extra: HashMap::new(), 179 + }, None) 180 + }; 181 + 182 + // Update avatar 183 + profile.avatar = Some(blob); 184 + 185 + // Validate the profile 186 + profile.validate().map_err(|e| { 187 + anyhow!("error-smokesignal-blob-9 Profile validation failed: {}", e) 188 + })?; 189 + 190 + // Create PutRecord request with CAS 191 + let put_record_request = PutRecordRequest { 192 + repo: current_handle.did.clone(), 193 + collection: "events.smokesignal.profile".to_string(), 194 + record_key: "self".to_string(), 195 + validate: false, 196 + record: serde_json::to_value(&profile) 197 + .map_err(|e| anyhow!("error-smokesignal-blob-10 Failed to serialize profile: {}", e))?, 198 + swap_record: swap_record_cid, 199 + swap_commit: None, 200 + }; 201 + 202 + // Send the request 203 + let put_record_url = format!("{}/xrpc/com.atproto.repo.putRecord", pds_endpoint); 204 + let record_value = serde_json::to_value(&put_record_request) 205 + .map_err(|e| anyhow!("error-smokesignal-blob-11 Failed to serialize putRecord request: {}", e))?; 206 + let _response = post_dpop_json( 207 + &web_context.http_client, 208 + &dpop_auth, 209 + &put_record_url, 210 + record_value, 211 + ) 212 + .await 213 + .map_err(|e| { 214 + // Check for InvalidSwap error 215 + let err_string = e.to_string(); 216 + if err_string.contains("InvalidSwap") { 217 + let default_context = minijinja::context! { 218 + language => language.to_string(), 219 + current_handle => current_handle.clone(), 220 + }; 221 + return contextual_error!( 222 + web_context, 223 + language, 224 + error_template, 225 + default_context, 226 + CommonError::InvalidSwap, 227 + StatusCode::CONFLICT 228 + ) 229 + .unwrap_err(); 230 + } 231 + anyhow!("error-smokesignal-blob-12 putRecord failed: {}", e) 232 + })?; 233 + 234 + // Redirect back to settings page 235 + Ok(( 236 + StatusCode::OK, 237 + HxRetarget("/settings".to_string()), 238 + [("HX-Refresh", "true")], 239 + ) 240 + .into_response()) 241 + } 242 + 243 + /// Handle profile banner upload 244 + pub(crate) async fn upload_profile_banner( 245 + State(web_context): State<WebContext>, 246 + Language(language): Language, 247 + HxRequest(hx_request): HxRequest, 248 + Cached(auth): Cached<Auth>, 249 + mut multipart: Multipart, 250 + ) -> Result<impl IntoResponse, WebError> { 251 + let error_template = select_template!(false, hx_request, language); 252 + let current_handle = auth.require_flat()?; 253 + 254 + let mut file_data: Option<Vec<u8>> = None; 255 + while let Some(field) = multipart.next_field().await.map_err(|e| anyhow!("error-smokesignal-blob-13 Multipart error: {}", e))? { 256 + if field.name() == Some("banner") { 257 + file_data = Some(field.bytes().await.map_err(|e| anyhow!("error-smokesignal-blob-14 Failed to read file: {}", e))?.to_vec()); 258 + break; 259 + } 260 + } 261 + 262 + let file_data = file_data.ok_or_else(|| anyhow!("error-smokesignal-blob-15 No banner file provided"))?; 263 + crate::image::validate_image(&file_data, 1_000_000).map_err(|e| anyhow!("error-smokesignal-blob-16 Image validation failed: {}", e))?; 264 + 265 + let processed_data = match crate::image::process_banner(&file_data) { 266 + Ok(data) => data, 267 + Err(_e) => { 268 + let default_context = minijinja::context! { language => language.to_string(), current_handle => current_handle.clone() }; 269 + return contextual_error!(web_context, language, error_template, default_context, CommonError::InvalidImageFormat, StatusCode::BAD_REQUEST); 270 + } 271 + }; 272 + 273 + let pds_endpoint = current_handle.pds.clone(); 274 + let dpop_auth = match (&auth, &web_context.config.oauth_backend) { 275 + (Auth::Pds { session, .. }, OAuthBackendConfig::ATProtocol { .. }) => create_dpop_auth_from_oauth_session(session)?, 276 + (Auth::Aip { access_token, .. }, OAuthBackendConfig::AIP { hostname, .. }) => create_dpop_auth_from_aip_session(&web_context.http_client, hostname, access_token).await?, 277 + _ => return Err(anyhow!("error-smokesignal-blob-17 Mismatched auth and backend config").into()), 278 + }; 279 + 280 + let blob = upload_blob_to_pds(&web_context.http_client, &pds_endpoint, &dpop_auth, &processed_data, "image/png").await?; 281 + let profile_aturi = format!("at://{}/events.smokesignal.profile/self", current_handle.did); 282 + let existing_profile = profile_get_by_aturi(&web_context.pool, &profile_aturi).await.ok().flatten(); 283 + 284 + let (mut profile, swap_record_cid) = if let Some(ref existing) = existing_profile { 285 + let existing_record: Profile = serde_json::from_value(existing.record.0.clone()).map_err(|e| anyhow!("error-smokesignal-blob-18 Failed to parse existing profile: {}", e))?; 286 + (existing_record, Some(existing.cid.clone())) 287 + } else { 288 + (Profile { display_name: None, description: None, profile_host: None, facets: None, avatar: None, banner: None, extra: HashMap::new() }, None) 289 + }; 290 + 291 + profile.banner = Some(blob); 292 + profile.validate().map_err(|e| anyhow!("error-smokesignal-blob-19 Profile validation failed: {}", e))?; 293 + 294 + let put_record_request = PutRecordRequest { 295 + repo: current_handle.did.clone(), 296 + collection: "events.smokesignal.profile".to_string(), 297 + record_key: "self".to_string(), 298 + validate: false, 299 + record: serde_json::to_value(&profile).map_err(|e| anyhow!("error-smokesignal-blob-20 Failed to serialize profile: {}", e))?, 300 + swap_record: swap_record_cid, 301 + swap_commit: None, 302 + }; 303 + 304 + let put_record_url = format!("{}/xrpc/com.atproto.repo.putRecord", pds_endpoint); 305 + let record_value = serde_json::to_value(&put_record_request).map_err(|e| anyhow!("error-smokesignal-blob-21 Failed to serialize putRecord request: {}", e))?; 306 + let _response = post_dpop_json(&web_context.http_client, &dpop_auth, &put_record_url, record_value).await.map_err(|e| { 307 + let err_string = e.to_string(); 308 + if err_string.contains("InvalidSwap") { 309 + let default_context = minijinja::context! { language => language.to_string(), current_handle => current_handle.clone() }; 310 + return contextual_error!(web_context, language, error_template, default_context, CommonError::InvalidSwap, StatusCode::CONFLICT).unwrap_err(); 311 + } 312 + anyhow!("error-smokesignal-blob-22 putRecord failed: {}", e) 313 + })?; 314 + 315 + Ok((StatusCode::OK, HxRetarget("/settings".to_string()), [("HX-Refresh", "true")]).into_response()) 316 + } 317 + 318 + /// Handle profile avatar deletion 319 + pub(crate) async fn delete_profile_avatar( 320 + State(web_context): State<WebContext>, 321 + Language(language): Language, 322 + HxRequest(hx_request): HxRequest, 323 + Cached(auth): Cached<Auth>, 324 + ) -> Result<impl IntoResponse, WebError> { 325 + let error_template = select_template!(false, hx_request, language); 326 + let current_handle = auth.require_flat()?; 327 + let profile_aturi = format!("at://{}/events.smokesignal.profile/self", current_handle.did); 328 + let existing_profile = profile_get_by_aturi(&web_context.pool, &profile_aturi).await.ok().flatten(); 329 + 330 + let (mut profile, swap_record_cid) = if let Some(ref existing) = existing_profile { 331 + let existing_record: Profile = serde_json::from_value(existing.record.0.clone()).map_err(|e| anyhow!("error-smokesignal-blob-23 Failed to parse existing profile: {}", e))?; 332 + (existing_record, Some(existing.cid.clone())) 333 + } else { 334 + return Ok((StatusCode::OK, HxRetarget("/settings".to_string()), [("HX-Refresh", "true")]).into_response()); 335 + }; 336 + 337 + profile.avatar = None; 338 + profile.validate().map_err(|e| anyhow!("error-smokesignal-blob-24 Profile validation failed: {}", e))?; 339 + 340 + let pds_endpoint = current_handle.pds.clone(); 341 + let dpop_auth = match (&auth, &web_context.config.oauth_backend) { 342 + (Auth::Pds { session, .. }, OAuthBackendConfig::ATProtocol { .. }) => create_dpop_auth_from_oauth_session(session)?, 343 + (Auth::Aip { access_token, .. }, OAuthBackendConfig::AIP { hostname, .. }) => create_dpop_auth_from_aip_session(&web_context.http_client, hostname, access_token).await?, 344 + _ => return Err(anyhow!("error-smokesignal-blob-25 Mismatched auth and backend config").into()), 345 + }; 346 + 347 + let put_record_request = PutRecordRequest { 348 + repo: current_handle.did.clone(), 349 + collection: "events.smokesignal.profile".to_string(), 350 + record_key: "self".to_string(), 351 + validate: false, 352 + record: serde_json::to_value(&profile).map_err(|e| anyhow!("error-smokesignal-blob-26 Failed to serialize profile: {}", e))?, 353 + swap_record: swap_record_cid, 354 + swap_commit: None, 355 + }; 356 + 357 + let put_record_url = format!("{}/xrpc/com.atproto.repo.putRecord", pds_endpoint); 358 + let record_value = serde_json::to_value(&put_record_request).map_err(|e| anyhow!("error-smokesignal-blob-27 Failed to serialize putRecord request: {}", e))?; 359 + let _response = post_dpop_json(&web_context.http_client, &dpop_auth, &put_record_url, record_value).await.map_err(|e| { 360 + let err_string = e.to_string(); 361 + if err_string.contains("InvalidSwap") { 362 + let default_context = minijinja::context! { language => language.to_string(), current_handle => current_handle.clone() }; 363 + return contextual_error!(web_context, language, error_template, default_context, CommonError::InvalidSwap, StatusCode::CONFLICT).unwrap_err(); 364 + } 365 + anyhow!("error-smokesignal-blob-28 putRecord failed: {}", e) 366 + })?; 367 + 368 + Ok((StatusCode::OK, HxRetarget("/settings".to_string()), [("HX-Refresh", "true")]).into_response()) 369 + } 370 + 371 + /// Handle profile banner deletion 372 + pub(crate) async fn delete_profile_banner( 373 + State(web_context): State<WebContext>, 374 + Language(language): Language, 375 + HxRequest(hx_request): HxRequest, 376 + Cached(auth): Cached<Auth>, 377 + ) -> Result<impl IntoResponse, WebError> { 378 + let error_template = select_template!(false, hx_request, language); 379 + let current_handle = auth.require_flat()?; 380 + let profile_aturi = format!("at://{}/events.smokesignal.profile/self", current_handle.did); 381 + let existing_profile = profile_get_by_aturi(&web_context.pool, &profile_aturi).await.ok().flatten(); 382 + 383 + let (mut profile, swap_record_cid) = if let Some(ref existing) = existing_profile { 384 + let existing_record: Profile = serde_json::from_value(existing.record.0.clone()).map_err(|e| anyhow!("error-smokesignal-blob-29 Failed to parse existing profile: {}", e))?; 385 + (existing_record, Some(existing.cid.clone())) 386 + } else { 387 + return Ok((StatusCode::OK, HxRetarget("/settings".to_string()), [("HX-Refresh", "true")]).into_response()); 388 + }; 389 + 390 + profile.banner = None; 391 + profile.validate().map_err(|e| anyhow!("error-smokesignal-blob-30 Profile validation failed: {}", e))?; 392 + 393 + let pds_endpoint = current_handle.pds.clone(); 394 + let dpop_auth = match (&auth, &web_context.config.oauth_backend) { 395 + (Auth::Pds { session, .. }, OAuthBackendConfig::ATProtocol { .. }) => create_dpop_auth_from_oauth_session(session)?, 396 + (Auth::Aip { access_token, .. }, OAuthBackendConfig::AIP { hostname, .. }) => create_dpop_auth_from_aip_session(&web_context.http_client, hostname, access_token).await?, 397 + _ => return Err(anyhow!("error-smokesignal-blob-31 Mismatched auth and backend config").into()), 398 + }; 399 + 400 + let put_record_request = PutRecordRequest { 401 + repo: current_handle.did.clone(), 402 + collection: "events.smokesignal.profile".to_string(), 403 + record_key: "self".to_string(), 404 + validate: false, 405 + record: serde_json::to_value(&profile).map_err(|e| anyhow!("error-smokesignal-blob-32 Failed to serialize profile: {}", e))?, 406 + swap_record: swap_record_cid, 407 + swap_commit: None, 408 + }; 409 + 410 + let put_record_url = format!("{}/xrpc/com.atproto.repo.putRecord", pds_endpoint); 411 + let record_value = serde_json::to_value(&put_record_request).map_err(|e| anyhow!("error-smokesignal-blob-33 Failed to serialize putRecord request: {}", e))?; 412 + let _response = post_dpop_json(&web_context.http_client, &dpop_auth, &put_record_url, record_value).await.map_err(|e| { 413 + let err_string = e.to_string(); 414 + if err_string.contains("InvalidSwap") { 415 + let default_context = minijinja::context! { language => language.to_string(), current_handle => current_handle.clone() }; 416 + return contextual_error!(web_context, language, error_template, default_context, CommonError::InvalidSwap, StatusCode::CONFLICT).unwrap_err(); 417 + } 418 + anyhow!("error-smokesignal-blob-34 putRecord failed: {}", e) 419 + })?; 420 + 421 + Ok((StatusCode::OK, HxRetarget("/settings".to_string()), [("HX-Refresh", "true")]).into_response()) 422 + }
+20 -3
src/http/handle_content.rs
··· 14 State(web_context): State<WebContext>, 15 Path(cid): Path<String>, 16 ) -> Result<impl IntoResponse, WebError> { 17 // Check if content exists 18 - let exists = match web_context.content_storage.content_exists(&cid).await { 19 Ok(exists) => exists, 20 Err(_) => return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()), 21 }; ··· 25 } 26 27 // Read the content data 28 - let content_data = match web_context.content_storage.read_content(&cid).await { 29 Ok(data) => data, 30 Err(_) => return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()), 31 }; 32 33 // Return the content with appropriate headers 34 Ok(Response::builder() 35 .status(StatusCode::OK) 36 - .header(header::CONTENT_TYPE, "application/octet-stream") 37 .header(header::CACHE_CONTROL, "public, max-age=86400") // Cache for 1 day 38 .body(Body::from(content_data)) 39 .unwrap()
··· 14 State(web_context): State<WebContext>, 15 Path(cid): Path<String>, 16 ) -> Result<impl IntoResponse, WebError> { 17 + // Strip file extension if present (e.g., ".png" from URLs like /content/{cid}.png) 18 + // The content is stored with just the CID, but templates use URLs with extensions 19 + let cid_without_ext = cid 20 + .strip_suffix(".png") 21 + .or_else(|| cid.strip_suffix(".jpg")) 22 + .or_else(|| cid.strip_suffix(".jpeg")) 23 + .unwrap_or(&cid); 24 + 25 // Check if content exists 26 + let exists = match web_context.content_storage.content_exists(cid_without_ext).await { 27 Ok(exists) => exists, 28 Err(_) => return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()), 29 }; ··· 33 } 34 35 // Read the content data 36 + let content_data = match web_context.content_storage.read_content(cid_without_ext).await { 37 Ok(data) => data, 38 Err(_) => return Ok((StatusCode::INTERNAL_SERVER_ERROR).into_response()), 39 }; 40 41 + // Detect content type from the original path extension 42 + let content_type = if cid.ends_with(".png") { 43 + "image/png" 44 + } else if cid.ends_with(".jpg") || cid.ends_with(".jpeg") { 45 + "image/jpeg" 46 + } else { 47 + "application/octet-stream" 48 + }; 49 + 50 // Return the content with appropriate headers 51 Ok(Response::builder() 52 .status(StatusCode::OK) 53 + .header(header::CONTENT_TYPE, content_type) 54 .header(header::CACHE_CONTROL, "public, max-age=86400") // Cache for 1 day 55 .body(Body::from(content_data)) 56 .unwrap()
+1 -1
src/http/handle_oauth_aip_login.rs
··· 104 state: state.clone(), 105 nonce: nonce.clone(), 106 code_challenge, 107 - scope: "openid email profile atproto account:email blob:image/* repo:community.lexicon.calendar.event repo:community.lexicon.calendar.rsvp".to_string(), 108 }; 109 110 // Get AIP server configuration - config validation ensures these are set when oauth_backend is AIP
··· 104 state: state.clone(), 105 nonce: nonce.clone(), 106 code_challenge, 107 + scope: "openid email profile atproto account:email blob:image/* repo:community.lexicon.calendar.event repo:community.lexicon.calendar.rsvp repo:events.smokesignal.profile".to_string(), 108 }; 109 110 // Get AIP server configuration - config validation ensures these are set when oauth_backend is AIP
+3 -1
src/http/handle_oauth_callback.rs
··· 171 tracing::error!(error = ?err, "Unable to remove oauth_request"); 172 } 173 174 // For standard OAuth, create a PDS session 175 let cookie_value: String = WebSession::Pds { 176 - did: token_response.sub.clone().unwrap(), 177 session_group: "".to_string(), // Simplified for initial pass 178 } 179 .try_into()?;
··· 171 tracing::error!(error = ?err, "Unable to remove oauth_request"); 172 } 173 174 + let did = token_response.sub.clone().unwrap(); 175 + 176 // For standard OAuth, create a PDS session 177 let cookie_value: String = WebSession::Pds { 178 + did: did.clone(), 179 session_group: "".to_string(), // Simplified for initial pass 180 } 181 .try_into()?;
+25
src/http/handle_profile.rs
··· 26 errors::StorageError, 27 event::{activity_list_recent_for_did, event_list_did_by_start_time, model::EventWithRole}, 28 identity_profile::{handle_for_did, handle_for_handle, handles_by_did}, 29 }, 30 }; 31 ··· 113 .clone() 114 .is_some_and(|inner_current_entity| inner_current_entity.did == profile.did); 115 116 let default_context = template_context! { 117 current_handle => ctx.current_handle, 118 language => ctx.language.to_string(), 119 canonical_url => format!("https://{}/{}", ctx.web_context.config.external_base, profile.did), 120 profile, 121 is_self, 122 }; 123
··· 26 errors::StorageError, 27 event::{activity_list_recent_for_did, event_list_did_by_start_time, model::EventWithRole}, 28 identity_profile::{handle_for_did, handle_for_handle, handles_by_did}, 29 + profile::profile_get_by_did, 30 }, 31 }; 32 ··· 114 .clone() 115 .is_some_and(|inner_current_entity| inner_current_entity.did == profile.did); 116 117 + // Fetch the profile record from the profiles table 118 + let profile_record_data = profile_get_by_did(&ctx.web_context.pool, &profile.did).await?; 119 + 120 + // Extract profile information and render description with facets 121 + let (profile_record, profile_display_name, profile_description_html) = if let Some(prof_rec) = &profile_record_data { 122 + let display_name = prof_rec.display_name.clone(); 123 + 124 + // Parse the record JSON to get full profile data 125 + let prof_data = if let Ok(prof_data) = serde_json::from_value::<crate::atproto::lexicon::profile::Profile>(prof_rec.record.0.clone()) { 126 + Some(prof_data) 127 + } else { 128 + None 129 + }; 130 + 131 + let description_html = prof_data.as_ref().and_then(|pd| pd.render_description_html().filter(|s| !s.is_empty())); 132 + 133 + (prof_data, Some(display_name), description_html) 134 + } else { 135 + (None, None, None) 136 + }; 137 + 138 let default_context = template_context! { 139 current_handle => ctx.current_handle, 140 language => ctx.language.to_string(), 141 canonical_url => format!("https://{}/{}", ctx.web_context.config.external_base, profile.did), 142 profile, 143 + profile_record, 144 + profile_display_name, 145 + profile_description_html, 146 is_self, 147 }; 148
+292
src/http/handle_settings.rs
··· 1 use anyhow::Result; 2 use atproto_identity::resolve::IdentityResolver; 3 use axum::{extract::State, response::IntoResponse}; 4 use axum_extra::extract::{Cached, Form}; ··· 9 use serde::Deserialize; 10 use std::{borrow::Cow, sync::Arc}; 11 use unic_langid::LanguageIdentifier; 12 13 use crate::{ 14 contextual_error, ··· 21 identity_profile::{ 22 HandleField, handle_for_did, handle_update_field, identity_profile_set_email, 23 }, 24 webhook::{webhook_delete, webhook_list_by_did, webhook_toggle_enabled, webhook_upsert}, 25 }, 26 task_webhooks::TaskWork, ··· 57 service: String, 58 } 59 60 pub(crate) async fn handle_settings( 61 State(web_context): State<WebContext>, 62 Language(language): Language, ··· 92 vec![] 93 }; 94 95 // Render the form 96 Ok(( 97 StatusCode::OK, ··· 103 languages => supported_languages, 104 webhooks => webhooks, 105 webhooks_enabled => web_context.config.enable_webhooks, 106 ..default_context, 107 }, 108 ), ··· 718 ) 719 .into_response()) 720 }
··· 1 use anyhow::Result; 2 + use atproto_client::com::atproto::repo::{PutRecordRequest, PutRecordResponse, put_record}; 3 use atproto_identity::resolve::IdentityResolver; 4 use axum::{extract::State, response::IntoResponse}; 5 use axum_extra::extract::{Cached, Form}; ··· 10 use serde::Deserialize; 11 use std::{borrow::Cow, sync::Arc}; 12 use unic_langid::LanguageIdentifier; 13 + use crate::atproto::auth::{ 14 + create_dpop_auth_from_aip_session, create_dpop_auth_from_oauth_session, 15 + }; 16 + use crate::atproto::lexicon::profile::{Profile as ProfileRecord, NSID as ProfileNSID}; 17 + use crate::config::OAuthBackendConfig; 18 19 use crate::{ 20 contextual_error, ··· 27 identity_profile::{ 28 HandleField, handle_for_did, handle_update_field, identity_profile_set_email, 29 }, 30 + profile::profile_get_by_did, 31 webhook::{webhook_delete, webhook_list_by_did, webhook_toggle_enabled, webhook_upsert}, 32 }, 33 task_webhooks::TaskWork, ··· 64 service: String, 65 } 66 67 + #[derive(Deserialize, Clone, Debug)] 68 + pub(crate) struct ProfileForm { 69 + display_name: Option<String>, 70 + description: Option<String>, 71 + profile_host: Option<String>, 72 + } 73 + 74 pub(crate) async fn handle_settings( 75 State(web_context): State<WebContext>, 76 Language(language): Language, ··· 106 vec![] 107 }; 108 109 + // Get profile data if it exists 110 + let profile_record = profile_get_by_did(&web_context.pool, &current_handle.did).await?; 111 + let (profile, profile_display_name, profile_description, profile_host) = if let Some(prof_rec) = &profile_record { 112 + // Parse the record JSON to get full profile data 113 + if let Ok(prof_data) = serde_json::from_value::<crate::atproto::lexicon::profile::Profile>(prof_rec.record.0.clone()) { 114 + let display_name = prof_data.display_name.clone(); 115 + let description = prof_data.description.clone(); 116 + let host = prof_data.profile_host.clone(); 117 + (Some(prof_data), display_name, description, host) 118 + } else { 119 + (None, None, None, None) 120 + } 121 + } else { 122 + (None, None, None, None) 123 + }; 124 + 125 // Render the form 126 Ok(( 127 StatusCode::OK, ··· 133 languages => supported_languages, 134 webhooks => webhooks, 135 webhooks_enabled => web_context.config.enable_webhooks, 136 + profile, 137 + profile_display_name, 138 + profile_description, 139 + profile_host, 140 ..default_context, 141 }, 142 ), ··· 752 ) 753 .into_response()) 754 } 755 + 756 + #[tracing::instrument(skip_all, err)] 757 + pub(crate) async fn handle_profile_update( 758 + State(web_context): State<WebContext>, 759 + Language(language): Language, 760 + identity_resolver: State<Arc<dyn IdentityResolver>>, 761 + Cached(auth): Cached<Auth>, 762 + Form(profile_form): Form<ProfileForm>, 763 + ) -> Result<impl IntoResponse, WebError> { 764 + let current_handle = auth.require_flat()?; 765 + 766 + let default_context = template_context! { 767 + current_handle => current_handle.clone(), 768 + language => language.to_string(), 769 + }; 770 + 771 + let error_template = select_template!(false, true, language); 772 + let render_template = format!("{}/settings.profile.html", language.to_string().to_lowercase()); 773 + 774 + // Clean and validate the input 775 + let display_name = profile_form 776 + .display_name 777 + .clone() 778 + .filter(|s| !s.trim().is_empty()) 779 + .or_else(|| Some(current_handle.handle.clone())); 780 + 781 + let description = profile_form.description.clone().filter(|s| !s.trim().is_empty()); 782 + 783 + let profile_host = profile_form.profile_host.clone().and_then(|host| { 784 + let trimmed = host.trim(); 785 + if trimmed.is_empty() { 786 + None 787 + } else { 788 + Some(trimmed.to_string()) 789 + } 790 + }); 791 + 792 + // Validate profile_host if provided 793 + if let Some(ref host) = profile_host { 794 + if host != "bsky.app" && host != "blacksky.community" && host != "smokesignal.events" { 795 + return contextual_error!( 796 + web_context, 797 + language, 798 + error_template, 799 + default_context, 800 + "Invalid profile host value" 801 + ); 802 + } 803 + } 804 + 805 + // Get existing profile from storage to get CID for swap record (CAS operation) 806 + let profile_aturi = format!("at://{}/{}/self", current_handle.did, ProfileNSID); 807 + let existing_profile = crate::storage::profile::profile_get_by_aturi(&web_context.pool, &profile_aturi).await.ok().flatten(); 808 + 809 + // Start with existing profile or create new one 810 + let (mut profile, swap_record_cid) = if let Some(ref existing) = existing_profile { 811 + // Deserialize existing profile and keep all existing data 812 + let mut existing_record: ProfileRecord = match serde_json::from_value(existing.record.0.clone()) { 813 + Ok(record) => record, 814 + Err(err) => { 815 + return contextual_error!( 816 + web_context, 817 + language, 818 + error_template, 819 + default_context, 820 + format!("Failed to parse existing profile: {}", err) 821 + ); 822 + } 823 + }; 824 + 825 + // Update only the fields being modified 826 + existing_record.display_name = display_name.clone(); 827 + existing_record.description = description.clone(); 828 + existing_record.profile_host = profile_host.clone(); 829 + existing_record.facets = None; // Will be re-parsed below if needed 830 + 831 + let existing_cid = Some(existing.cid.clone()).filter(|value| !value.is_empty()); 832 + 833 + (existing_record, existing_cid) 834 + } else { 835 + // No existing profile, create new one 836 + ( 837 + ProfileRecord { 838 + display_name: display_name.clone(), 839 + description: description.clone(), 840 + profile_host: profile_host.clone(), 841 + facets: None, 842 + avatar: None, 843 + banner: None, 844 + extra: std::collections::HashMap::new(), 845 + }, 846 + None, 847 + ) 848 + }; 849 + 850 + // Parse facets from description if present 851 + if description.is_some() { 852 + profile 853 + .parse_facets( 854 + identity_resolver.as_ref(), 855 + web_context.config.facets_max, 856 + ) 857 + .await; 858 + } 859 + 860 + // Validate the profile 861 + if let Err(e) = profile.validate() { 862 + return contextual_error!( 863 + web_context, 864 + language, 865 + error_template, 866 + default_context, 867 + format!("Invalid profile: {}", e) 868 + ); 869 + } 870 + 871 + // Create DPoP authentication based on backend type 872 + let dpop_auth = match (&auth, &web_context.config.oauth_backend) { 873 + (Auth::Pds { session, .. }, OAuthBackendConfig::ATProtocol { .. }) => { 874 + match create_dpop_auth_from_oauth_session(session) { 875 + Ok(auth) => auth, 876 + Err(err) => { 877 + return contextual_error!( 878 + web_context, 879 + language, 880 + error_template, 881 + default_context, 882 + format!("Failed to create authentication: {}", err) 883 + ); 884 + } 885 + } 886 + } 887 + (Auth::Aip { access_token, .. }, OAuthBackendConfig::AIP { hostname, .. }) => { 888 + match create_dpop_auth_from_aip_session(&web_context.http_client, hostname, access_token).await { 889 + Ok(auth) => auth, 890 + Err(err) => { 891 + return contextual_error!( 892 + web_context, 893 + language, 894 + error_template, 895 + default_context, 896 + format!("Failed to create authentication: {}", err) 897 + ); 898 + } 899 + } 900 + } 901 + _ => { 902 + return contextual_error!( 903 + web_context, 904 + language, 905 + error_template, 906 + default_context, 907 + "Invalid authentication configuration" 908 + ); 909 + } 910 + }; 911 + 912 + // Create PutRecord request with swap record for atomic update 913 + let record_value = match serde_json::to_value(&profile) { 914 + Ok(val) => val, 915 + Err(err) => { 916 + return contextual_error!( 917 + web_context, 918 + language, 919 + error_template, 920 + default_context, 921 + format!("Failed to serialize profile: {}", err) 922 + ); 923 + } 924 + }; 925 + 926 + let put_record_request = PutRecordRequest { 927 + repo: current_handle.did.clone(), 928 + collection: ProfileNSID.to_string(), 929 + record_key: "self".to_string(), 930 + record: record_value, 931 + swap_record: swap_record_cid, 932 + swap_commit: None, 933 + validate: false, 934 + }; 935 + 936 + // Update the record in the user's PDS 937 + let update_result = put_record( 938 + &web_context.http_client, 939 + &atproto_client::client::Auth::DPoP(dpop_auth), 940 + &current_handle.pds, 941 + put_record_request, 942 + ) 943 + .await; 944 + 945 + match update_result { 946 + Ok(PutRecordResponse::StrongRef { uri, cid, .. }) => { 947 + tracing::info!("Profile updated successfully: {} {}", uri, cid); 948 + 949 + // The profile will be picked up by Jetstream and stored in our database 950 + 951 + // Return updated profile section 952 + Ok(( 953 + StatusCode::OK, 954 + RenderHtml( 955 + &render_template, 956 + web_context.engine.clone(), 957 + template_context! { 958 + current_handle => current_handle.clone(), 959 + language => language.to_string(), 960 + profile => profile, 961 + profile_display_name => profile.display_name, 962 + profile_description => profile.description, 963 + profile_host => profile.profile_host, 964 + }, 965 + ), 966 + ) 967 + .into_response()) 968 + } 969 + Ok(PutRecordResponse::Error(err)) => { 970 + tracing::error!(error = err.error_message(), "Failed to update profile"); 971 + let error_msg = format!("{:?}", err.error_message()); 972 + if error_msg.contains("InvalidSwap") { 973 + return contextual_error!( 974 + web_context, 975 + language, 976 + error_template, 977 + default_context, 978 + "Your recent profile changes are still syncing. Please wait a moment and try again." 979 + ); 980 + } else { 981 + return contextual_error!( 982 + web_context, 983 + language, 984 + error_template, 985 + default_context, 986 + format!("Failed to update profile: {:?}", err.error_message()) 987 + ); 988 + } 989 + } 990 + Err(err) => { 991 + tracing::error!(?err, "Failed to update profile"); 992 + let error_msg = err.to_string(); 993 + if error_msg.contains("InvalidSwap") { 994 + return contextual_error!( 995 + web_context, 996 + language, 997 + error_template, 998 + default_context, 999 + "Your recent profile changes are still syncing. Please wait a moment and try again." 1000 + ); 1001 + } else { 1002 + return contextual_error!( 1003 + web_context, 1004 + language, 1005 + error_template, 1006 + default_context, 1007 + format!("Failed to update profile: {}", err) 1008 + ); 1009 + } 1010 + } 1011 + } 1012 + }
+1
src/http/mod.rs
··· 1 pub mod auth_utils; 2 pub mod cache_countries; 3 pub mod context; 4 pub mod errors; 5 pub mod event_form; 6 pub mod event_view;
··· 1 pub mod auth_utils; 2 pub mod cache_countries; 3 pub mod context; 4 + pub mod handle_blob; 5 pub mod errors; 6 pub mod event_form; 7 pub mod event_view;
+11 -2
src/http/server.rs
··· 27 handle_admin_index::handle_admin_index, 28 handle_admin_rsvp::handle_admin_rsvp, 29 handle_admin_rsvps::handle_admin_rsvps, 30 handle_content::handle_content, 31 handle_create_event::{ 32 handle_create_event, handle_location_at_builder, ··· 49 handle_set_language::handle_set_language, 50 handle_settings::{ 51 handle_add_webhook, handle_discover_events_update, handle_discover_rsvps_update, 52 - handle_email_update, handle_language_update, handle_list_webhooks, handle_remove_webhook, 53 - handle_settings, handle_test_webhook, handle_timezone_update, handle_toggle_webhook, 54 }, 55 handle_view_event::handle_view_event, 56 handle_wellknown::handle_wellknown_did_web, ··· 139 "/settings/discover_rsvps", 140 post(handle_discover_rsvps_update), 141 ) 142 .route("/settings/webhooks", get(handle_list_webhooks)) 143 .route("/settings/webhooks/add", post(handle_add_webhook)) 144 .route("/settings/webhooks/toggle", post(handle_toggle_webhook))
··· 27 handle_admin_index::handle_admin_index, 28 handle_admin_rsvp::handle_admin_rsvp, 29 handle_admin_rsvps::handle_admin_rsvps, 30 + handle_blob::{ 31 + delete_profile_avatar, delete_profile_banner, upload_profile_avatar, upload_profile_banner, 32 + }, 33 handle_content::handle_content, 34 handle_create_event::{ 35 handle_create_event, handle_location_at_builder, ··· 52 handle_set_language::handle_set_language, 53 handle_settings::{ 54 handle_add_webhook, handle_discover_events_update, handle_discover_rsvps_update, 55 + handle_email_update, handle_language_update, handle_list_webhooks, handle_profile_update, 56 + handle_remove_webhook, handle_settings, handle_test_webhook, handle_timezone_update, 57 + handle_toggle_webhook, 58 }, 59 handle_view_event::handle_view_event, 60 handle_wellknown::handle_wellknown_did_web, ··· 143 "/settings/discover_rsvps", 144 post(handle_discover_rsvps_update), 145 ) 146 + .route("/settings/profile", post(handle_profile_update)) 147 + .route("/settings/avatar", post(upload_profile_avatar)) 148 + .route("/settings/avatar/delete", post(delete_profile_avatar)) 149 + .route("/settings/banner", post(upload_profile_banner)) 150 + .route("/settings/banner/delete", post(delete_profile_banner)) 151 .route("/settings/webhooks", get(handle_list_webhooks)) 152 .route("/settings/webhooks/add", post(handle_add_webhook)) 153 .route("/settings/webhooks/toggle", post(handle_toggle_webhook))
+13
src/http/templates.rs
··· 17 ) 18 } 19 20 #[cfg(feature = "reload")] 21 pub mod reload_env { 22 use std::path::PathBuf; ··· 34 env.set_lstrip_blocks(true); 35 env.add_global("base", format!("https://{}", http_external)); 36 env.add_global("version", version.clone()); 37 env.set_loader(path_loader(&template_path)); 38 notifier.set_fast_reload(true); 39 notifier.watch_path(&template_path, true); ··· 52 env.set_lstrip_blocks(true); 53 env.add_global("base", format!("https://{}", http_external)); 54 env.add_global("version", version.clone()); 55 minijinja_embed::load_templates!(&mut env); 56 env 57 }
··· 17 ) 18 } 19 20 + /// Generate an external profile URL based on the user's preferred profile host 21 + fn external_profile_url(profile_host: Option<String>, did: String) -> String { 22 + let host = profile_host.as_deref().unwrap_or("bsky.app"); 23 + match host { 24 + "bsky.app" => format!("https://bsky.app/profile/{}", did), 25 + "blacksky.community" => format!("https://blacksky.community/profile/{}", did), 26 + "smokesignal.events" => format!("https://smokesignal.events/{}", did), 27 + _ => format!("https://bsky.app/profile/{}", did), // fallback to bsky.app 28 + } 29 + } 30 + 31 #[cfg(feature = "reload")] 32 pub mod reload_env { 33 use std::path::PathBuf; ··· 45 env.set_lstrip_blocks(true); 46 env.add_global("base", format!("https://{}", http_external)); 47 env.add_global("version", version.clone()); 48 + env.add_function("external_profile_url", super::external_profile_url); 49 env.set_loader(path_loader(&template_path)); 50 notifier.set_fast_reload(true); 51 notifier.watch_path(&template_path, true); ··· 64 env.set_lstrip_blocks(true); 65 env.add_global("base", format!("https://{}", http_external)); 66 env.add_global("version", version.clone()); 67 + env.add_function("external_profile_url", super::external_profile_url); 68 minijinja_embed::load_templates!(&mut env); 69 env 70 }
+111
src/image.rs
···
··· 1 + use anyhow::{anyhow, Result}; 2 + use image::{imageops::FilterType, ImageFormat}; 3 + use image::GenericImageView; 4 + 5 + /// Validate image data and ensure it's a valid image 6 + pub(crate) fn validate_image(data: &[u8], max_size: usize) -> Result<()> { 7 + if data.len() > max_size { 8 + return Err(anyhow!( 9 + "error-smokesignal-image-1 Image size exceeds maximum: {} bytes > {} bytes", 10 + data.len(), 11 + max_size 12 + )); 13 + } 14 + 15 + // Try to load the image to ensure it's valid 16 + image::load_from_memory(data).map_err(|e| { 17 + anyhow!( 18 + "error-smokesignal-image-2 Invalid image data: {}", 19 + e 20 + ) 21 + })?; 22 + 23 + Ok(()) 24 + } 25 + 26 + /// Process avatar image: validate 1:1 aspect ratio, resize to 400x400, convert to PNG 27 + pub(crate) fn process_avatar(data: &[u8]) -> Result<Vec<u8>> { 28 + // Load the image 29 + let img = image::load_from_memory(data).map_err(|e| { 30 + anyhow!( 31 + "error-smokesignal-image-3 Failed to load avatar image: {}", 32 + e 33 + ) 34 + })?; 35 + 36 + let (width, height) = img.dimensions(); 37 + 38 + // Validate 1:1 aspect ratio (allow 5% deviation) 39 + let aspect_ratio = width as f32 / height as f32; 40 + if (aspect_ratio - 1.0).abs() > 0.05 { 41 + return Err(anyhow!( 42 + "error-smokesignal-image-4 Avatar must have 1:1 aspect ratio: got {}:{}", 43 + width, 44 + height 45 + )); 46 + } 47 + 48 + // Resize to 400x400 if needed 49 + let resized = if width != 400 || height != 400 { 50 + img.resize_exact(400, 400, FilterType::Lanczos3) 51 + } else { 52 + img 53 + }; 54 + 55 + // Convert to PNG 56 + let mut png_buffer = std::io::Cursor::new(Vec::new()); 57 + resized 58 + .write_to(&mut png_buffer, ImageFormat::Png) 59 + .map_err(|e| { 60 + anyhow!( 61 + "error-smokesignal-image-5 Failed to encode avatar as PNG: {}", 62 + e 63 + ) 64 + })?; 65 + 66 + Ok(png_buffer.into_inner()) 67 + } 68 + 69 + /// Process banner image: validate 16:9 aspect ratio, resize to 1600x900, convert to PNG 70 + pub(crate) fn process_banner(data: &[u8]) -> Result<Vec<u8>> { 71 + // Load the image 72 + let img = image::load_from_memory(data).map_err(|e| { 73 + anyhow!( 74 + "error-smokesignal-image-6 Failed to load banner image: {}", 75 + e 76 + ) 77 + })?; 78 + 79 + let (width, height) = img.dimensions(); 80 + 81 + // Validate 16:9 aspect ratio (allow 10% deviation) 82 + let aspect_ratio = width as f32 / height as f32; 83 + let expected_ratio = 16.0 / 9.0; 84 + if (aspect_ratio - expected_ratio).abs() / expected_ratio > 0.10 { 85 + return Err(anyhow!( 86 + "error-smokesignal-image-7 Banner must have 16:9 aspect ratio: got {}:{}", 87 + width, 88 + height 89 + )); 90 + } 91 + 92 + // Resize to 1600x900 if needed 93 + let resized = if width != 1600 || height != 900 { 94 + img.resize_exact(1600, 900, FilterType::Lanczos3) 95 + } else { 96 + img 97 + }; 98 + 99 + // Convert to PNG 100 + let mut png_buffer = std::io::Cursor::new(Vec::new()); 101 + resized 102 + .write_to(&mut png_buffer, ImageFormat::Png) 103 + .map_err(|e| { 104 + anyhow!( 105 + "error-smokesignal-image-8 Failed to encode banner as PNG: {}", 106 + e 107 + ) 108 + })?; 109 + 110 + Ok(png_buffer.into_inner()) 111 + }
+1
src/lib.rs
··· 6 pub mod facets; 7 pub mod http; 8 pub mod i18n; 9 pub mod key_provider; 10 pub mod processor; 11 pub mod processor_errors;
··· 6 pub mod facets; 7 pub mod http; 8 pub mod i18n; 9 + pub mod image; 10 pub mod key_provider; 11 pub mod processor; 12 pub mod processor_errors;
+256
src/processor.rs
··· 27 use atproto_record::lexicon::community::lexicon::calendar::rsvp::{ 28 NSID as LexiconCommunityRSVPNSID, Rsvp, RsvpStatus, 29 }; 30 31 pub struct ContentFetcher { 32 pool: StoragePool, ··· 89 "community.lexicon.calendar.rsvp" => { 90 self.handle_rsvp_commit(did, rkey, cid, record).await 91 } 92 _ => Ok(()), 93 }; 94 if let Err(e) = result { ··· 107 } 108 "community.lexicon.calendar.rsvp" => { 109 self.handle_rsvp_delete(did, rkey).await 110 } 111 _ => Ok(()), 112 }; ··· 237 238 rsvp_delete(&self.pool, &aturi).await?; 239 240 Ok(()) 241 } 242
··· 27 use atproto_record::lexicon::community::lexicon::calendar::rsvp::{ 28 NSID as LexiconCommunityRSVPNSID, Rsvp, RsvpStatus, 29 }; 30 + use crate::atproto::lexicon::profile::{Profile, NSID as ProfileNSID}; 31 + use crate::storage::profile::profile_insert; 32 + use crate::storage::profile::profile_delete; 33 + use crate::storage::denylist::denylist_exists; 34 35 pub struct ContentFetcher { 36 pool: StoragePool, ··· 93 "community.lexicon.calendar.rsvp" => { 94 self.handle_rsvp_commit(did, rkey, cid, record).await 95 } 96 + "events.smokesignal.profile" => { 97 + self.handle_profile_commit(did, rkey, cid, record).await 98 + } 99 _ => Ok(()), 100 }; 101 if let Err(e) = result { ··· 114 } 115 "community.lexicon.calendar.rsvp" => { 116 self.handle_rsvp_delete(did, rkey).await 117 + } 118 + "events.smokesignal.profile" => { 119 + self.handle_profile_delete(did, rkey).await 120 } 121 _ => Ok(()), 122 }; ··· 247 248 rsvp_delete(&self.pool, &aturi).await?; 249 250 + Ok(()) 251 + } 252 + 253 + async fn handle_profile_commit( 254 + &self, 255 + did: &str, 256 + rkey: &str, 257 + cid: &str, 258 + record: &Value, 259 + ) -> Result<()> { 260 + tracing::info!("Processing profile: {} for {}", rkey, did); 261 + 262 + let aturi = format!("at://{did}/{ProfileNSID}/{rkey}"); 263 + 264 + // Check denylist before proceeding 265 + if denylist_exists(&self.pool, &[did, &aturi]).await? { 266 + tracing::info!("User {} is in denylist, skipping profile update", did); 267 + return Ok(()); 268 + } 269 + 270 + let profile_record: Profile = serde_json::from_value(record.clone())?; 271 + 272 + // Get the identity to resolve the handle for display_name fallback and PDS endpoint 273 + let document = self.ensure_identity_stored(did).await?; 274 + let handle = document 275 + .also_known_as 276 + .first() 277 + .and_then(|aka| aka.strip_prefix("at://")) 278 + .unwrap_or(did); 279 + 280 + // Use displayName from profile, or fallback to handle 281 + let display_name = profile_record 282 + .display_name 283 + .as_ref() 284 + .filter(|s| !s.trim().is_empty()) 285 + .map(|s| s.as_str()) 286 + .unwrap_or(handle); 287 + 288 + profile_insert(&self.pool, &aturi, cid, did, display_name, &profile_record).await?; 289 + 290 + // Download avatar and banner blobs if present 291 + let pds_endpoints = document.pds_endpoints(); 292 + if let Some(pds_endpoint) = pds_endpoints.first() { 293 + // Download avatar if present 294 + if let Some(ref avatar) = profile_record.avatar { 295 + if let Err(e) = self.download_avatar(pds_endpoint, did, avatar).await { 296 + tracing::warn!( 297 + error = ?e, 298 + did = %did, 299 + "Failed to download avatar for profile" 300 + ); 301 + } 302 + } 303 + 304 + // Download banner if present 305 + if let Some(ref banner) = profile_record.banner { 306 + if let Err(e) = self.download_banner(pds_endpoint, did, banner).await { 307 + tracing::warn!( 308 + error = ?e, 309 + did = %did, 310 + "Failed to download banner for profile" 311 + ); 312 + } 313 + } 314 + } else { 315 + tracing::debug!(did = %did, "No PDS endpoint found for profile blob download"); 316 + } 317 + 318 + Ok(()) 319 + } 320 + 321 + async fn handle_profile_delete(&self, did: &str, rkey: &str) -> Result<()> { 322 + let aturi = format!("at://{did}/{ProfileNSID}/{rkey}"); 323 + profile_delete(&self.pool, &aturi).await?; 324 + Ok(()) 325 + } 326 + 327 + /// Download and process avatar blob (1:1 aspect ratio, max 1MB) 328 + async fn download_avatar( 329 + &self, 330 + pds: &str, 331 + did: &str, 332 + avatar: &atproto_record::lexicon::TypedBlob, 333 + ) -> Result<()> { 334 + let cid = &avatar.inner.ref_.link; 335 + let image_path = format!("{}.png", cid); 336 + 337 + // Check if already exists 338 + if self.content_storage.content_exists(&image_path).await? { 339 + tracing::debug!(cid = %cid, "Avatar already exists in storage"); 340 + return Ok(()); 341 + } 342 + 343 + // Validate mime type 344 + if avatar.inner.mime_type != "image/png" && avatar.inner.mime_type != "image/jpeg" { 345 + tracing::debug!( 346 + mime_type = %avatar.inner.mime_type, 347 + "Skipping avatar with unsupported mime type" 348 + ); 349 + return Ok(()); 350 + } 351 + 352 + // Validate size (max 1MB) 353 + if avatar.inner.size > 1_000_000 { 354 + tracing::debug!(size = avatar.inner.size, "Skipping avatar that exceeds max size"); 355 + return Ok(()); 356 + } 357 + 358 + // Download the blob 359 + let image_bytes = match get_blob(&self.http_client, pds, did, cid).await { 360 + Ok(bytes) => bytes, 361 + Err(e) => { 362 + tracing::warn!(error = ?e, cid = %cid, "Failed to download avatar blob"); 363 + return Ok(()); // Don't fail the whole operation 364 + } 365 + }; 366 + 367 + // Validate and process the image 368 + let img = match image::load_from_memory(&image_bytes) { 369 + Ok(img) => img, 370 + Err(e) => { 371 + tracing::warn!(error = ?e, cid = %cid, "Failed to load avatar image"); 372 + return Ok(()); 373 + } 374 + }; 375 + 376 + let (width, height) = img.dimensions(); 377 + 378 + // Validate 1:1 aspect ratio (allow small deviation) 379 + let aspect_ratio = width as f32 / height as f32; 380 + if (aspect_ratio - 1.0).abs() > 0.05 { 381 + tracing::debug!( 382 + width, 383 + height, 384 + aspect_ratio, 385 + "Skipping avatar with non-square aspect ratio" 386 + ); 387 + return Ok(()); 388 + } 389 + 390 + // Resize to standard size (400x400) 391 + let resized = if width != 400 || height != 400 { 392 + img.resize_exact(400, 400, image::imageops::FilterType::Lanczos3) 393 + } else { 394 + img 395 + }; 396 + 397 + // Convert to PNG 398 + let mut png_buffer = std::io::Cursor::new(Vec::new()); 399 + resized.write_to(&mut png_buffer, ImageFormat::Png)?; 400 + let png_bytes = png_buffer.into_inner(); 401 + 402 + // Store in content storage 403 + self.content_storage 404 + .write_content(&image_path, &png_bytes) 405 + .await?; 406 + 407 + tracing::info!(cid = %cid, "Successfully downloaded and processed avatar"); 408 + Ok(()) 409 + } 410 + 411 + /// Download and process banner blob (16:9 aspect ratio, max 1MB) 412 + async fn download_banner( 413 + &self, 414 + pds: &str, 415 + did: &str, 416 + banner: &atproto_record::lexicon::TypedBlob, 417 + ) -> Result<()> { 418 + let cid = &banner.inner.ref_.link; 419 + let image_path = format!("{}.png", cid); 420 + 421 + // Check if already exists 422 + if self.content_storage.content_exists(&image_path).await? { 423 + tracing::debug!(cid = %cid, "Banner already exists in storage"); 424 + return Ok(()); 425 + } 426 + 427 + // Validate mime type 428 + if banner.inner.mime_type != "image/png" && banner.inner.mime_type != "image/jpeg" { 429 + tracing::debug!( 430 + mime_type = %banner.inner.mime_type, 431 + "Skipping banner with unsupported mime type" 432 + ); 433 + return Ok(()); 434 + } 435 + 436 + // Validate size (max 1MB) 437 + if banner.inner.size > 1_000_000 { 438 + tracing::debug!(size = banner.inner.size, "Skipping banner that exceeds max size"); 439 + return Ok(()); 440 + } 441 + 442 + // Download the blob 443 + let image_bytes = match get_blob(&self.http_client, pds, did, cid).await { 444 + Ok(bytes) => bytes, 445 + Err(e) => { 446 + tracing::warn!(error = ?e, cid = %cid, "Failed to download banner blob"); 447 + return Ok(()); // Don't fail the whole operation 448 + } 449 + }; 450 + 451 + // Validate and process the image 452 + let img = match image::load_from_memory(&image_bytes) { 453 + Ok(img) => img, 454 + Err(e) => { 455 + tracing::warn!(error = ?e, cid = %cid, "Failed to load banner image"); 456 + return Ok(()); 457 + } 458 + }; 459 + 460 + let (width, height) = img.dimensions(); 461 + 462 + // Validate 16:9 aspect ratio (allow small deviation) 463 + let aspect_ratio = width as f32 / height as f32; 464 + let expected_ratio = 16.0 / 9.0; 465 + if (aspect_ratio - expected_ratio).abs() > 0.1 { 466 + tracing::debug!( 467 + width, 468 + height, 469 + aspect_ratio, 470 + "Skipping banner with non-16:9 aspect ratio" 471 + ); 472 + return Ok(()); 473 + } 474 + 475 + // Resize to standard size (1600x900) 476 + let target_height = 900; 477 + let target_width = ((target_height as f32) * aspect_ratio) as u32; 478 + 479 + let resized = if width != target_width || height != target_height { 480 + img.resize_exact(target_width, target_height, image::imageops::FilterType::Lanczos3) 481 + } else { 482 + img 483 + }; 484 + 485 + // Convert to PNG 486 + let mut png_buffer = std::io::Cursor::new(Vec::new()); 487 + resized.write_to(&mut png_buffer, ImageFormat::Png)?; 488 + let png_bytes = png_buffer.into_inner(); 489 + 490 + // Store in content storage 491 + self.content_storage 492 + .write_content(&image_path, &png_bytes) 493 + .await?; 494 + 495 + tracing::info!(cid = %cid, "Successfully downloaded and processed banner"); 496 Ok(()) 497 } 498
-7
src/storage/cache.rs
··· 3 4 use crate::storage::errors::CacheError; 5 6 - pub(crate) const OAUTH_REFRESH_QUEUE: &str = "auth_session:oauth:refresh"; 7 - pub(crate) const OAUTH_REFRESH_HEARTBEATS: &str = "auth_session:oauth:refresh:workers"; 8 - 9 - pub(crate) fn build_worker_queue(worker_id: &str) -> String { 10 - format!("{}:{}", OAUTH_REFRESH_QUEUE, worker_id) 11 - } 12 - 13 pub fn create_cache_pool(redis_url: &str) -> Result<Pool> { 14 let cfg = Config::from_url(redis_url); 15 cfg.create_pool(Some(Runtime::Tokio1))
··· 3 4 use crate::storage::errors::CacheError; 5 6 pub fn create_cache_pool(redis_url: &str) -> Result<Pool> { 7 let cfg = Config::from_url(redis_url); 8 cfg.create_pool(Some(Runtime::Tokio1))
+1
src/storage/mod.rs
··· 7 pub mod event; 8 pub mod identity_profile; 9 pub mod oauth; 10 pub mod types; 11 pub mod webhook; 12 pub use types::*;
··· 7 pub mod event; 8 pub mod identity_profile; 9 pub mod oauth; 10 + pub mod profile; 11 pub mod types; 12 pub mod webhook; 13 pub use types::*;
+135
src/storage/profile.rs
···
··· 1 + use chrono::Utc; 2 + use serde_json::json; 3 + 4 + use super::StoragePool; 5 + use super::errors::StorageError; 6 + 7 + pub mod model { 8 + use chrono::{DateTime, Utc}; 9 + use serde::{Deserialize, Serialize}; 10 + use sqlx::FromRow; 11 + 12 + #[derive(Clone, FromRow, Deserialize, Serialize, Debug)] 13 + pub struct Profile { 14 + pub aturi: String, 15 + pub cid: String, 16 + pub did: String, 17 + pub display_name: String, 18 + pub record: sqlx::types::Json<serde_json::Value>, 19 + pub updated_at: Option<DateTime<Utc>>, 20 + } 21 + } 22 + 23 + pub use model::Profile; 24 + 25 + pub async fn profile_insert<T: serde::Serialize>( 26 + pool: &StoragePool, 27 + aturi: &str, 28 + cid: &str, 29 + did: &str, 30 + display_name: &str, 31 + record: &T, 32 + ) -> Result<(), StorageError> { 33 + let mut tx = pool 34 + .begin() 35 + .await 36 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 37 + 38 + let now = Utc::now(); 39 + 40 + sqlx::query("INSERT INTO profiles (aturi, cid, did, display_name, record, updated_at) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (aturi) DO UPDATE SET cid = $2, display_name = $4, record = $5, updated_at = $6") 41 + .bind(aturi) 42 + .bind(cid) 43 + .bind(did) 44 + .bind(display_name) 45 + .bind(json!(record)) 46 + .bind(now) 47 + .execute(tx.as_mut()) 48 + .await 49 + .map_err(StorageError::UnableToExecuteQuery)?; 50 + 51 + tx.commit() 52 + .await 53 + .map_err(StorageError::CannotCommitDatabaseTransaction) 54 + } 55 + 56 + pub async fn profile_get_by_did( 57 + pool: &StoragePool, 58 + did: &str, 59 + ) -> Result<Option<Profile>, StorageError> { 60 + if did.trim().is_empty() { 61 + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 62 + "DID cannot be empty".into(), 63 + ))); 64 + } 65 + 66 + let mut tx = pool 67 + .begin() 68 + .await 69 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 70 + 71 + let profile = sqlx::query_as::<_, Profile>("SELECT * FROM profiles WHERE did = $1") 72 + .bind(did) 73 + .fetch_optional(tx.as_mut()) 74 + .await 75 + .map_err(StorageError::UnableToExecuteQuery)?; 76 + 77 + tx.commit() 78 + .await 79 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 80 + 81 + Ok(profile) 82 + } 83 + 84 + pub async fn profile_get_by_aturi( 85 + pool: &StoragePool, 86 + aturi: &str, 87 + ) -> Result<Option<Profile>, StorageError> { 88 + if aturi.trim().is_empty() { 89 + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 90 + "AT-URI cannot be empty".into(), 91 + ))); 92 + } 93 + 94 + let mut tx = pool 95 + .begin() 96 + .await 97 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 98 + 99 + let profile = sqlx::query_as::<_, Profile>("SELECT * FROM profiles WHERE aturi = $1") 100 + .bind(aturi) 101 + .fetch_optional(tx.as_mut()) 102 + .await 103 + .map_err(StorageError::UnableToExecuteQuery)?; 104 + 105 + tx.commit() 106 + .await 107 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 108 + 109 + Ok(profile) 110 + } 111 + 112 + pub async fn profile_delete(pool: &StoragePool, aturi: &str) -> Result<(), StorageError> { 113 + if aturi.trim().is_empty() { 114 + return Err(StorageError::UnableToExecuteQuery(sqlx::Error::Protocol( 115 + "AT-URI cannot be empty".into(), 116 + ))); 117 + } 118 + 119 + let mut tx = pool 120 + .begin() 121 + .await 122 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 123 + 124 + sqlx::query("DELETE FROM profiles WHERE aturi = $1") 125 + .bind(aturi) 126 + .execute(tx.as_mut()) 127 + .await 128 + .map_err(StorageError::UnableToExecuteQuery)?; 129 + 130 + tx.commit() 131 + .await 132 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 133 + 134 + Ok(()) 135 + }
+24 -3
templates/en-us/profile.common.html
··· 1 {%- from "pagination.html" import view_pagination -%} 2 <section class="section"> 3 <div class="container"> 4 - <h1 class="title">@{{ profile.handle }}</h1> 5 <div class="buttons"> 6 - <a class="button is-link is-outlined" href="https://bsky.app/profile/{{ profile.did }}" target="_blank"> 7 <span class="icon"> 8 <i class="fab fa-bluesky"></i> 9 </span> 10 - <span>Bluesky</span> 11 </a> 12 13 {% if is_self %}
··· 1 {%- from "pagination.html" import view_pagination -%} 2 + {% if profile_record and profile_record.banner %} 3 + <div style="width: 100%; max-height: 400px; overflow: hidden; margin-bottom: 0;"> 4 + <img src="/content/{{ profile_record.banner.ref['$link'] }}.png" alt="Profile banner" style="width: 100%; height: auto; display: block; object-fit: cover;"> 5 + </div> 6 + {% endif %} 7 <section class="section"> 8 <div class="container"> 9 + <div style="display: flex; align-items: start; gap: 1.5rem; margin-bottom: 1rem;"> 10 + {% if profile_record and profile_record.avatar %} 11 + <img src="/content/{{ profile_record.avatar.ref['$link'] }}.png" alt="Profile avatar" style="width: 120px; height: 120px; border-radius: 50%; border: 3px solid hsl(171, 100%, 41%); flex-shrink: 0; object-fit: cover;"> 12 + {% endif %} 13 + <div style="flex: 1;"> 14 + <h1 class="title">{% if profile_record and profile_record.displayName %}{{ profile_record.displayName }}{% else %}@{{ profile.handle }}{% endif %}</h1> 15 + {% if profile_record and profile_record.displayName %} 16 + <p class="subtitle is-6">@{{ profile.handle }}</p> 17 + {% endif %} 18 + {% if profile_record and profile_record.description %} 19 + <div class="content"> 20 + {{ profile_record.description }} 21 + </div> 22 + {% endif %} 23 + </div> 24 + </div> 25 <div class="buttons"> 26 + {% set profile_host = profile_record.profileHost if profile_record and profile_record.profileHost else "bsky.app" %} 27 + <a class="button is-link is-outlined" href="{{ external_profile_url(profile_record.profileHost if profile_record else none, profile.did) }}" target="_blank"> 28 <span class="icon"> 29 <i class="fab fa-bluesky"></i> 30 </span> 31 + <span>{% if profile_host == "bsky.app" %}Bluesky{% elif profile_host == "blacksky.community" %}Blacksky{% else %}Profile{% endif %}</span> 32 </a> 33 34 {% if is_self %}
+11
templates/en-us/settings.common.html
··· 49 50 <div class="columns"> 51 <div class="column"> 52 <h2 class="subtitle">Discovery Settings</h2> 53 <div id="discover-events-form" class="mb-4"> 54 {% include "en-us/settings.discover_events.html" %}
··· 49 50 <div class="columns"> 51 <div class="column"> 52 + <h2 class="subtitle">Profile</h2> 53 + <div id="profile-form" class="mb-4"> 54 + {% include "en-us/settings.profile.html" %} 55 + </div> 56 + </div> 57 + </div> 58 + 59 + <hr> 60 + 61 + <div class="columns"> 62 + <div class="column"> 63 <h2 class="subtitle">Discovery Settings</h2> 64 <div id="discover-events-form" class="mb-4"> 65 {% include "en-us/settings.discover_events.html" %}
+204
templates/en-us/settings.profile.html
···
··· 1 + <form hx-post="/settings/profile" hx-target="#profile-form" hx-swap="innerHTML"> 2 + <div class="field"> 3 + <label class="label">Display Name</label> 4 + <div class="control"> 5 + <input class="input" type="text" name="display_name" value="{{ profile_display_name }}" placeholder="{{ current_handle.handle }}"> 6 + </div> 7 + <p class="help">Leave blank to use your handle as display name</p> 8 + </div> 9 + 10 + <div class="field"> 11 + <label class="label">Description</label> 12 + <div class="control"> 13 + <textarea class="textarea" name="description" rows="4" placeholder="Tell others about yourself...">{{ profile_description }}</textarea> 14 + </div> 15 + <p class="help">Min 20 characters, max 2000 characters</p> 16 + </div> 17 + 18 + <div class="field"> 19 + <label class="label">Profile Host</label> 20 + <div class="control"> 21 + <div class="select is-fullwidth"> 22 + <select name="profile_host"> 23 + <option value="">None (default)</option> 24 + <option value="bsky.app" {% if profile_host == "bsky.app" %}selected{% endif %}>Bluesky</option> 25 + <option value="blacksky.community" {% if profile_host == "blacksky.community" %}selected{% endif %}>Blacksky Community</option> 26 + <option value="smokesignal.events" {% if profile_host == "smokesignal.events" %}selected{% endif %}>Smokesignal Events</option> 27 + </select> 28 + </div> 29 + </div> 30 + <p class="help">Choose where your profile link points</p> 31 + </div> 32 + 33 + <div class="field"> 34 + <div class="control"> 35 + <button class="button is-primary" type="submit"> 36 + <span class="icon"> 37 + <i class="fas fa-save"></i> 38 + </span> 39 + <span>Update Profile</span> 40 + </button> 41 + </div> 42 + </div> 43 + </form> 44 + 45 + <div class="mt-5"> 46 + <h3 class="subtitle is-5">Profile Images</h3> 47 + 48 + <div class="field"> 49 + <label class="label">Avatar (1:1 square, max 1MB)</label> 50 + {% if profile.avatar %} 51 + <div class="mb-3"> 52 + <img src="/content/{{ profile.avatar.ref['$link'] }}.png" alt="Current avatar" style="width: 150px; height: 150px; object-fit: cover; border-radius: 4px;"> 53 + <form hx-post="/settings/avatar/delete" hx-swap="outerHTML" class="mt-2"> 54 + <button class="button is-small is-danger" type="submit"> 55 + <span class="icon is-small"><i class="fas fa-trash"></i></span> 56 + <span>Delete Avatar</span> 57 + </button> 58 + </form> 59 + </div> 60 + {% endif %} 61 + <div class="file"> 62 + <label class="file-label"> 63 + <input class="file-input" type="file" name="avatar" id="avatar-input" accept="image/png,image/jpeg"> 64 + <span class="file-cta"> 65 + <span class="icon"><i class="fas fa-upload"></i></span> 66 + <span class="file-label">Choose avatar...</span> 67 + </span> 68 + </label> 69 + </div> 70 + <canvas id="avatar-canvas" style="display:none; max-width: 400px; margin-top: 1rem; border: 1px solid #dbdbdb;"></canvas> 71 + <button id="avatar-crop" class="button is-primary mt-2" style="display:none;">Upload Cropped Avatar</button> 72 + </div> 73 + 74 + <div class="field mt-4"> 75 + <label class="label">Banner (16:9 aspect ratio, max 1MB)</label> 76 + {% if profile.banner %} 77 + <div class="mb-3"> 78 + <img src="/content/{{ profile.banner.ref['$link'] }}.png" alt="Current banner" style="max-width: 100%; height: auto; border-radius: 4px;"> 79 + <form hx-post="/settings/banner/delete" hx-swap="outerHTML" class="mt-2"> 80 + <button class="button is-small is-danger" type="submit"> 81 + <span class="icon is-small"><i class="fas fa-trash"></i></span> 82 + <span>Delete Banner</span> 83 + </button> 84 + </form> 85 + </div> 86 + {% endif %} 87 + <div class="file"> 88 + <label class="file-label"> 89 + <input class="file-input" type="file" name="banner" id="banner-input" accept="image/png,image/jpeg"> 90 + <span class="file-cta"> 91 + <span class="icon"><i class="fas fa-upload"></i></span> 92 + <span class="file-label">Choose banner...</span> 93 + </span> 94 + </label> 95 + </div> 96 + <canvas id="banner-canvas" style="display:none; max-width: 100%; margin-top: 1rem; border: 1px solid #dbdbdb;"></canvas> 97 + <button id="banner-crop" class="button is-primary mt-2" style="display:none;">Upload Cropped Banner</button> 98 + </div> 99 + </div> 100 + 101 + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.css"/> 102 + <script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.1/cropper.min.js"></script> 103 + <script> 104 + let avatarCropper = null; 105 + let bannerCropper = null; 106 + 107 + document.getElementById('avatar-input').addEventListener('change', function(e) { 108 + const file = e.target.files[0]; 109 + if (!file) return; 110 + 111 + const reader = new FileReader(); 112 + reader.onload = function(event) { 113 + const canvas = document.getElementById('avatar-canvas'); 114 + const img = new Image(); 115 + img.onload = function() { 116 + canvas.width = img.width; 117 + canvas.height = img.height; 118 + canvas.style.display = 'block'; 119 + document.getElementById('avatar-crop').style.display = 'inline-block'; 120 + 121 + const ctx = canvas.getContext('2d'); 122 + ctx.drawImage(img, 0, 0); 123 + 124 + if (avatarCropper) avatarCropper.destroy(); 125 + avatarCropper = new Cropper(canvas, { 126 + aspectRatio: 1, 127 + viewMode: 1, 128 + }); 129 + }; 130 + img.src = event.target.result; 131 + }; 132 + reader.readAsDataURL(file); 133 + }); 134 + 135 + document.getElementById('banner-input').addEventListener('change', function(e) { 136 + const file = e.target.files[0]; 137 + if (!file) return; 138 + 139 + const reader = new FileReader(); 140 + reader.onload = function(event) { 141 + const canvas = document.getElementById('banner-canvas'); 142 + const img = new Image(); 143 + img.onload = function() { 144 + canvas.width = img.width; 145 + canvas.height = img.height; 146 + canvas.style.display = 'block'; 147 + document.getElementById('banner-crop').style.display = 'inline-block'; 148 + 149 + const ctx = canvas.getContext('2d'); 150 + ctx.drawImage(img, 0, 0); 151 + 152 + if (bannerCropper) bannerCropper.destroy(); 153 + bannerCropper = new Cropper(canvas, { 154 + aspectRatio: 16 / 9, 155 + viewMode: 1, 156 + }); 157 + }; 158 + img.src = event.target.result; 159 + }; 160 + reader.readAsDataURL(file); 161 + }); 162 + 163 + document.getElementById('avatar-crop').addEventListener('click', function() { 164 + if (!avatarCropper) return; 165 + 166 + avatarCropper.getCroppedCanvas({ 167 + width: 400, 168 + height: 400, 169 + }).toBlob(function(blob) { 170 + const formData = new FormData(); 171 + formData.append('avatar', blob, 'avatar.png'); 172 + 173 + fetch('/settings/avatar', { 174 + method: 'POST', 175 + body: formData, 176 + }).then(response => { 177 + if (response.ok) { 178 + window.location.reload(); 179 + } 180 + }); 181 + }, 'image/png'); 182 + }); 183 + 184 + document.getElementById('banner-crop').addEventListener('click', function() { 185 + if (!bannerCropper) return; 186 + 187 + bannerCropper.getCroppedCanvas({ 188 + width: 1600, 189 + height: 900, 190 + }).toBlob(function(blob) { 191 + const formData = new FormData(); 192 + formData.append('banner', blob, 'banner.png'); 193 + 194 + fetch('/settings/banner', { 195 + method: 'POST', 196 + body: formData, 197 + }).then(response => { 198 + if (response.ok) { 199 + window.location.reload(); 200 + } 201 + }); 202 + }, 'image/png'); 203 + }); 204 + </script>