The smokesignal.events web application
at main 954 lines 30 kB view raw
1use atproto_client::client::{DPoPAuth, post_dpop_bytes_with_headers, post_dpop_json}; 2use atproto_client::com::atproto::repo::PutRecordRequest; 3use atproto_record::lexicon::TypedBlob; 4use axum::{ 5 extract::{Multipart, State}, 6 response::IntoResponse, 7}; 8use axum_extra::extract::Cached; 9use axum_htmx::{HxRequest, HxRetarget}; 10use bytes::Bytes; 11use http::StatusCode; 12use reqwest::header::CONTENT_TYPE; 13 14use crate::{ 15 atproto::{auth::create_dpop_auth_from_session, lexicon::profile::Profile}, 16 contextual_error, 17 http::{ 18 context::WebContext, 19 errors::{CommonError, WebError, blob_error::BlobError}, 20 middleware_auth::Auth, 21 middleware_i18n::Language, 22 }, 23 select_template, 24 storage::profile::{profile_get_by_aturi, profile_insert}, 25}; 26 27use image::GenericImageView; 28use serde::{Deserialize, Serialize}; 29use std::collections::HashMap; 30 31#[derive(Deserialize)] 32struct CreateBlobResponse { 33 blob: TypedBlob, 34} 35 36#[derive(Serialize)] 37struct UploadHeaderResponse { 38 cid: String, 39 width: u32, 40 height: u32, 41 size: usize, 42} 43 44#[derive(Serialize)] 45struct UploadThumbnailResponse { 46 cid: String, 47 width: u32, 48 height: u32, 49 size: usize, 50} 51 52#[derive(Deserialize)] 53struct PutRecordSuccessResponse { 54 uri: String, 55 cid: String, 56} 57 58/// Upload a blob to the PDS and return the TypedBlob reference 59async fn upload_blob_to_pds( 60 http_client: &reqwest::Client, 61 pds_endpoint: &str, 62 dpop_auth: &DPoPAuth, 63 data: &[u8], 64 mime_type: &str, 65) -> Result<TypedBlob, BlobError> { 66 let upload_url = format!("{}/xrpc/com.atproto.repo.uploadBlob", pds_endpoint); 67 68 let mut headers = http::HeaderMap::default(); 69 headers.insert(CONTENT_TYPE, mime_type.parse().unwrap()); 70 71 let blob_response = post_dpop_bytes_with_headers( 72 http_client, 73 dpop_auth, 74 &upload_url, 75 Bytes::copy_from_slice(data), 76 &headers, 77 ) 78 .await 79 .map_err(|e| BlobError::BlobUploadFailed(e.to_string()))?; 80 81 serde_json::from_value::<CreateBlobResponse>(blob_response) 82 .map(|created_blob| created_blob.blob) 83 .map_err(|e| BlobError::UploadResponseParseFailed(e.to_string())) 84} 85 86/// Handle profile avatar upload 87pub(crate) async fn upload_profile_avatar( 88 State(web_context): State<WebContext>, 89 Language(language): Language, 90 HxRequest(hx_request): HxRequest, 91 Cached(auth): Cached<Auth>, 92 mut multipart: Multipart, 93) -> Result<impl IntoResponse, WebError> { 94 let error_template = select_template!(false, hx_request, language); 95 96 // Require authentication 97 let current_handle = auth.require_flat()?; 98 let session = auth.session().ok_or(CommonError::NotAuthorized)?; 99 100 // Extract the file from multipart data 101 let mut file_data: Option<Vec<u8>> = None; 102 103 while let Some(field) = multipart 104 .next_field() 105 .await 106 .map_err(|e| BlobError::MultipartError(e.to_string()))? 107 { 108 if field.name() == Some("avatar") { 109 file_data = Some( 110 field 111 .bytes() 112 .await 113 .map_err(|e| BlobError::FileReadFailed(e.to_string()))? 114 .to_vec(), 115 ); 116 break; 117 } 118 } 119 120 let file_data = file_data.ok_or(BlobError::NoAvatarFile)?; 121 122 // Validate and process the image 123 crate::image::validate_image(&file_data, 3_000_000)?; 124 let processed_data = crate::image::process_avatar(&file_data)?; 125 126 // Get the PDS endpoint for the user 127 let pds_endpoint = current_handle.pds.clone(); 128 129 // Create DPoP auth 130 let dpop_auth = create_dpop_auth_from_session(session)?; 131 132 // Upload blob to PDS 133 let blob = upload_blob_to_pds( 134 &web_context.http_client, 135 &pds_endpoint, 136 &dpop_auth, 137 &processed_data, 138 "image/png", 139 ) 140 .await?; 141 142 // Store the avatar image locally in content storage immediately 143 // This ensures the image is available when the page reloads, without waiting for Jetstream 144 let avatar_cid = &blob.inner.ref_.link; 145 let image_path = format!("{}.png", avatar_cid); 146 if let Err(err) = web_context 147 .content_storage 148 .write_content(&image_path, &processed_data) 149 .await 150 { 151 tracing::warn!( 152 ?err, 153 cid = %avatar_cid, 154 "Failed to store avatar image locally, will be fetched via Jetstream" 155 ); 156 } 157 158 // Get the profile aturi 159 let profile_aturi = format!( 160 "at://{}/events.smokesignal.profile/self", 161 current_handle.did 162 ); 163 164 // Get existing profile for CAS 165 let existing_profile = profile_get_by_aturi(&web_context.pool, &profile_aturi) 166 .await 167 .ok() 168 .flatten(); 169 170 let (mut profile, swap_record_cid) = if let Some(ref existing) = existing_profile { 171 let existing_record: Profile = serde_json::from_value(existing.record.0.clone()) 172 .map_err(|e| BlobError::ExistingProfileParseFailed(e.to_string()))?; 173 (existing_record, Some(existing.cid.clone())) 174 } else { 175 ( 176 Profile { 177 display_name: None, 178 description: None, 179 profile_host: None, 180 facets: None, 181 avatar: None, 182 banner: None, 183 extra: HashMap::new(), 184 }, 185 None, 186 ) 187 }; 188 189 // Update avatar 190 profile.avatar = Some(blob); 191 192 // Validate the profile 193 profile 194 .validate() 195 .map_err(|e| BlobError::ProfileValidationFailed(e.to_string()))?; 196 197 // Create PutRecord request with CAS 198 let put_record_request = PutRecordRequest { 199 repo: current_handle.did.clone(), 200 collection: "events.smokesignal.profile".to_string(), 201 record_key: "self".to_string(), 202 validate: false, 203 record: serde_json::to_value(&profile) 204 .map_err(|e| BlobError::ProfileSerializeFailed(e.to_string()))?, 205 swap_record: swap_record_cid, 206 swap_commit: None, 207 }; 208 209 // Send the request 210 let put_record_url = format!("{}/xrpc/com.atproto.repo.putRecord", pds_endpoint); 211 let record_value = serde_json::to_value(&put_record_request) 212 .map_err(|e| BlobError::PutRecordSerializeFailed(e.to_string()))?; 213 let response = match post_dpop_json( 214 &web_context.http_client, 215 &dpop_auth, 216 &put_record_url, 217 record_value, 218 ) 219 .await 220 { 221 Ok(response) => response, 222 Err(e) => { 223 // Check for InvalidSwap error 224 let err_string = e.to_string(); 225 if err_string.contains("InvalidSwap") { 226 let default_context = minijinja::context! { 227 language => language.to_string(), 228 current_handle => current_handle.clone(), 229 }; 230 return contextual_error!( 231 web_context, 232 language, 233 error_template, 234 default_context, 235 CommonError::InvalidSwap, 236 StatusCode::CONFLICT 237 ); 238 } 239 return Err(BlobError::PutRecordFailed(e.to_string()).into()); 240 } 241 }; 242 243 // Update the profile in the local database immediately 244 // This ensures the profile is visible without waiting for Jetstream 245 if let Ok(put_response) = serde_json::from_value::<PutRecordSuccessResponse>(response) { 246 let display_name_for_db = profile 247 .display_name 248 .as_ref() 249 .filter(|s| !s.trim().is_empty()) 250 .map(|s| s.as_str()) 251 .unwrap_or(&current_handle.handle); 252 253 if let Err(err) = profile_insert( 254 &web_context.pool, 255 &put_response.uri, 256 &put_response.cid, 257 &current_handle.did, 258 display_name_for_db, 259 &profile, 260 ) 261 .await 262 { 263 tracing::warn!( 264 ?err, 265 "Failed to update local profile after avatar upload, will be synced via Jetstream" 266 ); 267 } 268 } 269 270 // Redirect back to settings page 271 Ok(( 272 StatusCode::OK, 273 HxRetarget("/settings".to_string()), 274 [("HX-Refresh", "true")], 275 ) 276 .into_response()) 277} 278 279/// Handle profile banner upload 280pub(crate) async fn upload_profile_banner( 281 State(web_context): State<WebContext>, 282 Language(language): Language, 283 HxRequest(hx_request): HxRequest, 284 Cached(auth): Cached<Auth>, 285 mut multipart: Multipart, 286) -> Result<impl IntoResponse, WebError> { 287 let error_template = select_template!(false, hx_request, language); 288 let current_handle = auth.require_flat()?; 289 let session = auth.session().ok_or(CommonError::NotAuthorized)?; 290 291 let mut file_data: Option<Vec<u8>> = None; 292 while let Some(field) = multipart 293 .next_field() 294 .await 295 .map_err(|e| BlobError::MultipartError(e.to_string()))? 296 { 297 if field.name() == Some("banner") { 298 file_data = Some( 299 field 300 .bytes() 301 .await 302 .map_err(|e| BlobError::FileReadFailed(e.to_string()))? 303 .to_vec(), 304 ); 305 break; 306 } 307 } 308 309 let file_data = file_data.ok_or(BlobError::NoBannerFile)?; 310 crate::image::validate_image(&file_data, 3_000_000)?; 311 let processed_data = crate::image::process_banner(&file_data)?; 312 313 let pds_endpoint = current_handle.pds.clone(); 314 315 // Create DPoP auth 316 let dpop_auth = create_dpop_auth_from_session(session)?; 317 318 let blob = upload_blob_to_pds( 319 &web_context.http_client, 320 &pds_endpoint, 321 &dpop_auth, 322 &processed_data, 323 "image/png", 324 ) 325 .await?; 326 327 // Store the banner image locally in content storage immediately 328 // This ensures the image is available when the page reloads, without waiting for Jetstream 329 let banner_cid = &blob.inner.ref_.link; 330 let image_path = format!("{}.png", banner_cid); 331 if let Err(err) = web_context 332 .content_storage 333 .write_content(&image_path, &processed_data) 334 .await 335 { 336 tracing::warn!( 337 ?err, 338 cid = %banner_cid, 339 "Failed to store banner image locally, will be fetched via Jetstream" 340 ); 341 } 342 343 let profile_aturi = format!( 344 "at://{}/events.smokesignal.profile/self", 345 current_handle.did 346 ); 347 let existing_profile = profile_get_by_aturi(&web_context.pool, &profile_aturi) 348 .await 349 .ok() 350 .flatten(); 351 352 let (mut profile, swap_record_cid) = if let Some(ref existing) = existing_profile { 353 let existing_record: Profile = serde_json::from_value(existing.record.0.clone()) 354 .map_err(|e| BlobError::ExistingProfileParseFailed(e.to_string()))?; 355 (existing_record, Some(existing.cid.clone())) 356 } else { 357 ( 358 Profile { 359 display_name: None, 360 description: None, 361 profile_host: None, 362 facets: None, 363 avatar: None, 364 banner: None, 365 extra: HashMap::new(), 366 }, 367 None, 368 ) 369 }; 370 371 profile.banner = Some(blob); 372 profile 373 .validate() 374 .map_err(|e| BlobError::ProfileValidationFailed(e.to_string()))?; 375 376 let put_record_request = PutRecordRequest { 377 repo: current_handle.did.clone(), 378 collection: "events.smokesignal.profile".to_string(), 379 record_key: "self".to_string(), 380 validate: false, 381 record: serde_json::to_value(&profile) 382 .map_err(|e| BlobError::ProfileSerializeFailed(e.to_string()))?, 383 swap_record: swap_record_cid, 384 swap_commit: None, 385 }; 386 387 let put_record_url = format!("{}/xrpc/com.atproto.repo.putRecord", pds_endpoint); 388 let record_value = serde_json::to_value(&put_record_request) 389 .map_err(|e| BlobError::PutRecordSerializeFailed(e.to_string()))?; 390 let response = match post_dpop_json( 391 &web_context.http_client, 392 &dpop_auth, 393 &put_record_url, 394 record_value, 395 ) 396 .await 397 { 398 Ok(response) => response, 399 Err(e) => { 400 let err_string = e.to_string(); 401 if err_string.contains("InvalidSwap") { 402 let default_context = minijinja::context! { language => language.to_string(), current_handle => current_handle.clone() }; 403 return contextual_error!( 404 web_context, 405 language, 406 error_template, 407 default_context, 408 CommonError::InvalidSwap, 409 StatusCode::CONFLICT 410 ); 411 } 412 return Err(BlobError::PutRecordFailed(e.to_string()).into()); 413 } 414 }; 415 416 // Update the profile in the local database immediately 417 // This ensures the profile is visible without waiting for Jetstream 418 if let Ok(put_response) = serde_json::from_value::<PutRecordSuccessResponse>(response) { 419 let display_name_for_db = profile 420 .display_name 421 .as_ref() 422 .filter(|s| !s.trim().is_empty()) 423 .map(|s| s.as_str()) 424 .unwrap_or(&current_handle.handle); 425 426 if let Err(err) = profile_insert( 427 &web_context.pool, 428 &put_response.uri, 429 &put_response.cid, 430 &current_handle.did, 431 display_name_for_db, 432 &profile, 433 ) 434 .await 435 { 436 tracing::warn!( 437 ?err, 438 "Failed to update local profile after banner upload, will be synced via Jetstream" 439 ); 440 } 441 } 442 443 Ok(( 444 StatusCode::OK, 445 HxRetarget("/settings".to_string()), 446 [("HX-Refresh", "true")], 447 ) 448 .into_response()) 449} 450 451/// Handle profile avatar deletion 452pub(crate) async fn delete_profile_avatar( 453 State(web_context): State<WebContext>, 454 Language(language): Language, 455 HxRequest(hx_request): HxRequest, 456 Cached(auth): Cached<Auth>, 457) -> Result<impl IntoResponse, WebError> { 458 let error_template = select_template!(false, hx_request, language); 459 let current_handle = auth.require_flat()?; 460 let session = auth.session().ok_or(CommonError::NotAuthorized)?; 461 let profile_aturi = format!( 462 "at://{}/events.smokesignal.profile/self", 463 current_handle.did 464 ); 465 let existing_profile = profile_get_by_aturi(&web_context.pool, &profile_aturi) 466 .await 467 .ok() 468 .flatten(); 469 470 let (mut profile, swap_record_cid) = if let Some(ref existing) = existing_profile { 471 let existing_record: Profile = serde_json::from_value(existing.record.0.clone()) 472 .map_err(|e| BlobError::ExistingProfileParseFailed(e.to_string()))?; 473 (existing_record, Some(existing.cid.clone())) 474 } else { 475 return Ok(( 476 StatusCode::OK, 477 HxRetarget("/settings".to_string()), 478 [("HX-Refresh", "true")], 479 ) 480 .into_response()); 481 }; 482 483 profile.avatar = None; 484 profile 485 .validate() 486 .map_err(|e| BlobError::ProfileValidationFailed(e.to_string()))?; 487 488 let pds_endpoint = current_handle.pds.clone(); 489 490 // Create DPoP auth 491 let dpop_auth = create_dpop_auth_from_session(session)?; 492 493 let put_record_request = PutRecordRequest { 494 repo: current_handle.did.clone(), 495 collection: "events.smokesignal.profile".to_string(), 496 record_key: "self".to_string(), 497 validate: false, 498 record: serde_json::to_value(&profile) 499 .map_err(|e| BlobError::ProfileSerializeFailed(e.to_string()))?, 500 swap_record: swap_record_cid, 501 swap_commit: None, 502 }; 503 504 let put_record_url = format!("{}/xrpc/com.atproto.repo.putRecord", pds_endpoint); 505 let record_value = serde_json::to_value(&put_record_request) 506 .map_err(|e| BlobError::PutRecordSerializeFailed(e.to_string()))?; 507 let response = match post_dpop_json( 508 &web_context.http_client, 509 &dpop_auth, 510 &put_record_url, 511 record_value, 512 ) 513 .await 514 { 515 Ok(response) => response, 516 Err(e) => { 517 let err_string = e.to_string(); 518 if err_string.contains("InvalidSwap") { 519 let default_context = minijinja::context! { language => language.to_string(), current_handle => current_handle.clone() }; 520 return contextual_error!( 521 web_context, 522 language, 523 error_template, 524 default_context, 525 CommonError::InvalidSwap, 526 StatusCode::CONFLICT 527 ); 528 } 529 return Err(BlobError::PutRecordFailed(e.to_string()).into()); 530 } 531 }; 532 533 // Update the profile in the local database immediately 534 if let Ok(put_response) = serde_json::from_value::<PutRecordSuccessResponse>(response) { 535 let display_name_for_db = profile 536 .display_name 537 .as_ref() 538 .filter(|s| !s.trim().is_empty()) 539 .map(|s| s.as_str()) 540 .unwrap_or(&current_handle.handle); 541 542 if let Err(err) = profile_insert( 543 &web_context.pool, 544 &put_response.uri, 545 &put_response.cid, 546 &current_handle.did, 547 display_name_for_db, 548 &profile, 549 ) 550 .await 551 { 552 tracing::warn!( 553 ?err, 554 "Failed to update local profile after avatar deletion, will be synced via Jetstream" 555 ); 556 } 557 } 558 559 Ok(( 560 StatusCode::OK, 561 HxRetarget("/settings".to_string()), 562 [("HX-Refresh", "true")], 563 ) 564 .into_response()) 565} 566 567/// Handle profile banner deletion 568pub(crate) async fn delete_profile_banner( 569 State(web_context): State<WebContext>, 570 Language(language): Language, 571 HxRequest(hx_request): HxRequest, 572 Cached(auth): Cached<Auth>, 573) -> Result<impl IntoResponse, WebError> { 574 let error_template = select_template!(false, hx_request, language); 575 let current_handle = auth.require_flat()?; 576 let session = auth.session().ok_or(CommonError::NotAuthorized)?; 577 let profile_aturi = format!( 578 "at://{}/events.smokesignal.profile/self", 579 current_handle.did 580 ); 581 let existing_profile = profile_get_by_aturi(&web_context.pool, &profile_aturi) 582 .await 583 .ok() 584 .flatten(); 585 586 let (mut profile, swap_record_cid) = if let Some(ref existing) = existing_profile { 587 let existing_record: Profile = serde_json::from_value(existing.record.0.clone()) 588 .map_err(|e| BlobError::ExistingProfileParseFailed(e.to_string()))?; 589 (existing_record, Some(existing.cid.clone())) 590 } else { 591 return Ok(( 592 StatusCode::OK, 593 HxRetarget("/settings".to_string()), 594 [("HX-Refresh", "true")], 595 ) 596 .into_response()); 597 }; 598 599 profile.banner = None; 600 profile 601 .validate() 602 .map_err(|e| BlobError::ProfileValidationFailed(e.to_string()))?; 603 604 let pds_endpoint = current_handle.pds.clone(); 605 606 // Create DPoP auth 607 let dpop_auth = create_dpop_auth_from_session(session)?; 608 609 let put_record_request = PutRecordRequest { 610 repo: current_handle.did.clone(), 611 collection: "events.smokesignal.profile".to_string(), 612 record_key: "self".to_string(), 613 validate: false, 614 record: serde_json::to_value(&profile) 615 .map_err(|e| BlobError::ProfileSerializeFailed(e.to_string()))?, 616 swap_record: swap_record_cid, 617 swap_commit: None, 618 }; 619 620 let put_record_url = format!("{}/xrpc/com.atproto.repo.putRecord", pds_endpoint); 621 let record_value = serde_json::to_value(&put_record_request) 622 .map_err(|e| BlobError::PutRecordSerializeFailed(e.to_string()))?; 623 let response = match post_dpop_json( 624 &web_context.http_client, 625 &dpop_auth, 626 &put_record_url, 627 record_value, 628 ) 629 .await 630 { 631 Ok(response) => response, 632 Err(e) => { 633 let err_string = e.to_string(); 634 if err_string.contains("InvalidSwap") { 635 let default_context = minijinja::context! { language => language.to_string(), current_handle => current_handle.clone() }; 636 return contextual_error!( 637 web_context, 638 language, 639 error_template, 640 default_context, 641 CommonError::InvalidSwap, 642 StatusCode::CONFLICT 643 ); 644 } 645 return Err(BlobError::PutRecordFailed(e.to_string()).into()); 646 } 647 }; 648 649 // Update the profile in the local database immediately 650 if let Ok(put_response) = serde_json::from_value::<PutRecordSuccessResponse>(response) { 651 let display_name_for_db = profile 652 .display_name 653 .as_ref() 654 .filter(|s| !s.trim().is_empty()) 655 .map(|s| s.as_str()) 656 .unwrap_or(&current_handle.handle); 657 658 if let Err(err) = profile_insert( 659 &web_context.pool, 660 &put_response.uri, 661 &put_response.cid, 662 &current_handle.did, 663 display_name_for_db, 664 &profile, 665 ) 666 .await 667 { 668 tracing::warn!( 669 ?err, 670 "Failed to update local profile after banner deletion, will be synced via Jetstream" 671 ); 672 } 673 } 674 675 Ok(( 676 StatusCode::OK, 677 HxRetarget("/settings".to_string()), 678 [("HX-Refresh", "true")], 679 ) 680 .into_response()) 681} 682 683/// Handle event header image upload 684pub(crate) async fn upload_event_header( 685 State(web_context): State<WebContext>, 686 Cached(auth): Cached<Auth>, 687 mut multipart: Multipart, 688) -> Result<impl IntoResponse, WebError> { 689 let current_handle = auth.require_flat()?; 690 let session = auth.session().ok_or(CommonError::NotAuthorized)?; 691 692 // Extract the file from multipart data 693 let mut file_data: Option<Vec<u8>> = None; 694 695 while let Some(field) = multipart 696 .next_field() 697 .await 698 .map_err(|e| BlobError::MultipartError(e.to_string()))? 699 { 700 if field.name() == Some("header") { 701 file_data = Some( 702 field 703 .bytes() 704 .await 705 .map_err(|e| BlobError::FileReadFailed(e.to_string()))? 706 .to_vec(), 707 ); 708 break; 709 } 710 } 711 712 let file_data = file_data.ok_or(BlobError::NoHeaderFile)?; 713 714 // Validate image with 3MB limit 715 crate::image::validate_image(&file_data, 3_000_000)?; 716 let processed_data = crate::image::process_event_header(&file_data)?; 717 718 // Get the PDS endpoint for the user 719 let pds_endpoint = current_handle.pds.clone(); 720 721 // Create DPoP auth 722 let dpop_auth = create_dpop_auth_from_session(session)?; 723 724 // Upload blob to PDS 725 let blob = upload_blob_to_pds( 726 &web_context.http_client, 727 &pds_endpoint, 728 &dpop_auth, 729 &processed_data, 730 "image/png", 731 ) 732 .await?; 733 734 // Extract CID and size from blob reference 735 let cid = blob.inner.ref_.link.clone(); 736 let size = blob.inner.size as usize; 737 738 // Store the header image locally in content storage immediately 739 // This ensures the image is available when the event is created/edited, 740 // without waiting for store_event_header_from_pds to download from PDS 741 let image_path = format!("{}.png", cid); 742 if let Err(err) = web_context 743 .content_storage 744 .write_content(&image_path, &processed_data) 745 .await 746 { 747 tracing::warn!( 748 ?err, 749 cid = %cid, 750 "Failed to store event header image locally, will be fetched from PDS later" 751 ); 752 } 753 754 // Return JSON response with CID, dimensions, and size 755 let response = UploadHeaderResponse { 756 cid, 757 width: 1500, 758 height: 500, 759 size, 760 }; 761 762 Ok((StatusCode::OK, axum::Json(response)).into_response()) 763} 764 765/// Download an event header image from PDS and store it in content storage. 766/// 767/// This is called after uploading a header to PDS and creating/editing an event 768/// to ensure the image is also stored locally for serving. 769/// 770/// The function is idempotent - if the content already exists, it returns success. 771pub(crate) async fn store_event_header_from_pds( 772 http_client: &reqwest::Client, 773 content_storage: &std::sync::Arc<dyn crate::storage::content::ContentStorage>, 774 pds_endpoint: &str, 775 did: &str, 776 header_cid: &str, 777) -> Result<(), BlobError> { 778 use atproto_client::com::atproto::repo::get_blob; 779 use image::{GenericImageView, ImageFormat}; 780 781 let image_path = format!("{}.png", header_cid); 782 783 // Check if already exists (idempotent) 784 if content_storage 785 .content_exists(&image_path) 786 .await 787 .map_err(|e| BlobError::ContentStorageError(e.to_string()))? 788 { 789 tracing::debug!(cid = %header_cid, "Header image already exists in storage"); 790 return Ok(()); 791 } 792 793 // Download the blob from PDS 794 let image_bytes = get_blob(http_client, pds_endpoint, did, header_cid) 795 .await 796 .map_err(|e| BlobError::BlobDownloadFailed(e.to_string()))?; 797 798 // Validate size (max 3MB) 799 const MAX_SIZE: usize = 3 * 1024 * 1024; 800 if image_bytes.len() > MAX_SIZE { 801 return Err(BlobError::FileTooLarge); 802 } 803 804 // Load and validate the image 805 let img = image::load_from_memory(&image_bytes) 806 .map_err(|e| BlobError::ImageLoadFailed(e.to_string()))?; 807 808 // Validate format 809 let format = image::guess_format(&image_bytes) 810 .map_err(|e| BlobError::ImageFormatDetectionFailed(e.to_string()))?; 811 812 match format { 813 ImageFormat::Jpeg | ImageFormat::Png | ImageFormat::WebP => {} 814 _ => return Err(BlobError::UnsupportedImageFormat), 815 } 816 817 // Validate dimensions and aspect ratio 818 let (actual_width, actual_height) = img.dimensions(); 819 if actual_height == 0 820 || actual_height > 12000 821 || actual_width == 0 822 || actual_width > 12000 823 || actual_width <= actual_height 824 { 825 return Err(BlobError::InvalidImageDimensions); 826 } 827 828 // Validate 3:1 aspect ratio (allow 10% deviation) for new headers 829 // Also accept 16:9 (2% deviation) for backward compatibility with existing headers 830 let aspect = (actual_width as f64) / (actual_height as f64); 831 let is_3_1 = (aspect - 3.0).abs() / 3.0 < 0.10; 832 let is_16_9 = (aspect - (16.0 / 9.0)).abs() < 0.02; 833 if !is_3_1 && !is_16_9 { 834 return Err(BlobError::InvalidAspectRatio); 835 } 836 837 // Resize to appropriate dimensions based on aspect ratio 838 let final_image = if is_3_1 { 839 // New 3:1 format - resize to 1500x500 840 if actual_width != 1500 || actual_height != 500 { 841 img.resize_exact(1500, 500, image::imageops::FilterType::Lanczos3) 842 } else { 843 img 844 } 845 } else { 846 // Legacy 16:9 format - resize to 1600x900 847 if actual_width != 1600 || actual_height != 900 { 848 img.resize_exact(1600, 900, image::imageops::FilterType::Lanczos3) 849 } else { 850 img 851 } 852 }; 853 854 // Convert to PNG 855 let mut png_buffer = std::io::Cursor::new(Vec::new()); 856 final_image 857 .write_to(&mut png_buffer, ImageFormat::Png) 858 .map_err(|e| BlobError::ImageEncodingFailed(e.to_string()))?; 859 let png_bytes = png_buffer.into_inner(); 860 861 // Store in content storage 862 content_storage 863 .write_content(&image_path, &png_bytes) 864 .await 865 .map_err(|e| BlobError::ContentStorageError(e.to_string()))?; 866 867 tracing::info!(cid = %header_cid, "Successfully stored event header image"); 868 Ok(()) 869} 870 871/// Handle event thumbnail image upload 872pub(crate) async fn upload_event_thumbnail( 873 State(web_context): State<WebContext>, 874 Cached(auth): Cached<Auth>, 875 mut multipart: Multipart, 876) -> Result<impl IntoResponse, WebError> { 877 let current_handle = auth.require_flat()?; 878 let session = auth.session().ok_or(CommonError::NotAuthorized)?; 879 880 // Extract the file from multipart data 881 let mut file_data: Option<Vec<u8>> = None; 882 883 while let Some(field) = multipart 884 .next_field() 885 .await 886 .map_err(|e| BlobError::MultipartError(e.to_string()))? 887 { 888 if field.name() == Some("thumbnail") { 889 file_data = Some( 890 field 891 .bytes() 892 .await 893 .map_err(|e| BlobError::FileReadFailed(e.to_string()))? 894 .to_vec(), 895 ); 896 break; 897 } 898 } 899 900 let file_data = file_data.ok_or(BlobError::NoThumbnailFile)?; 901 902 // Validate image with 3MB limit 903 crate::image::validate_image(&file_data, 3_000_000)?; 904 let processed_data = crate::image::process_event_thumbnail(&file_data)?; 905 906 // Get the processed image dimensions 907 let img = image::load_from_memory(&processed_data) 908 .map_err(|e| BlobError::ImageLoadFailed(e.to_string()))?; 909 let (width, height) = img.dimensions(); 910 911 // Get the PDS endpoint for the user 912 let pds_endpoint = current_handle.pds.clone(); 913 914 // Create DPoP auth 915 let dpop_auth = create_dpop_auth_from_session(session)?; 916 917 // Upload blob to PDS 918 let blob = upload_blob_to_pds( 919 &web_context.http_client, 920 &pds_endpoint, 921 &dpop_auth, 922 &processed_data, 923 "image/png", 924 ) 925 .await?; 926 927 // Extract CID and size from blob reference 928 let cid = blob.inner.ref_.link.clone(); 929 let size = blob.inner.size as usize; 930 931 // Store the thumbnail image locally in content storage immediately 932 let image_path = format!("{}.png", cid); 933 if let Err(err) = web_context 934 .content_storage 935 .write_content(&image_path, &processed_data) 936 .await 937 { 938 tracing::warn!( 939 ?err, 940 cid = %cid, 941 "Failed to store event thumbnail image locally, will be fetched from PDS later" 942 ); 943 } 944 945 // Return JSON response with CID, dimensions, and size 946 let response = UploadThumbnailResponse { 947 cid, 948 width, 949 height, 950 size, 951 }; 952 953 Ok((StatusCode::OK, axum::Json(response)).into_response()) 954}