The smokesignal.events web application
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(¤t_handle.handle);
252
253 if let Err(err) = profile_insert(
254 &web_context.pool,
255 &put_response.uri,
256 &put_response.cid,
257 ¤t_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(¤t_handle.handle);
425
426 if let Err(err) = profile_insert(
427 &web_context.pool,
428 &put_response.uri,
429 &put_response.cid,
430 ¤t_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(¤t_handle.handle);
541
542 if let Err(err) = profile_insert(
543 &web_context.pool,
544 &put_response.uri,
545 &put_response.cid,
546 ¤t_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(¤t_handle.handle);
657
658 if let Err(err) = profile_insert(
659 &web_context.pool,
660 &put_response.uri,
661 &put_response.cid,
662 ¤t_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}