1use axum::extract::State;
2use base64::{engine::general_purpose, Engine as _};
3use hypercall_types::WalletAddress;
4use reqwest::StatusCode;
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7use std::time::Duration;
8use utoipa::ToSchema;
9
10use super::AppState;
11use crate::{error::ApiError, middleware::SignerContext, sonic_json::SonicJson};
12
13const MAX_PROFILE_IMAGE_BYTES: usize = 5 * 1024 * 1024;
14const DEFAULT_CLOUDFLARE_API_BASE_URL: &str = "https://api.cloudflare.com/client/v4";
15const DEFAULT_CLOUDFLARE_VARIANT: &str = "public";
16const EXTERNAL_REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
17
18#[derive(Debug, Deserialize, ToSchema)]
19pub struct SetProfileImageRequest {
20 pub image_data_base64: String,
21 pub content_type: String,
22 pub image_sha256: String,
23}
24
25#[derive(Debug, Serialize, ToSchema)]
26pub struct SetProfileImageResponse {
27 pub wallet: WalletAddress,
28 pub profile_image_url: String,
29}
30
31#[derive(Debug, Deserialize)]
32struct CloudflareImagesResponse {
33 success: bool,
34 result: Option<CloudflareImage>,
35 errors: Option<Vec<CloudflareError>>,
36}
37
38#[derive(Debug, Deserialize)]
39struct CloudflareImage {
40 id: String,
41 variants: Vec<String>,
42}
43
44#[derive(Debug, Deserialize)]
45struct CloudflareError {
46 code: Option<u64>,
47 message: String,
48}
49
50struct UploadedCloudflareImage {
51 id: String,
52 url: String,
53}
54
55#[utoipa::path(
57 post,
58 path = "/profile/image",
59 request_body = SetProfileImageRequest,
60 responses(
61 (status = 200, description = "Profile image set", body = SetProfileImageResponse),
62 (status = 400, description = "Validation error"),
63 (status = 500, description = "Internal server error"),
64 ),
65 security(("eip712_signature" = [])),
66 tag = "Portfolio"
67)]
68pub async fn set_profile_image(
69 State(state): State<AppState>,
70 signer_ctx: SignerContext,
71 SonicJson(request): SonicJson<SetProfileImageRequest>,
72) -> Result<SonicJson<SetProfileImageResponse>, ApiError> {
73 let wallet = WalletAddress(signer_ctx.wallet_address.0);
74 let content_type = normalize_content_type(&request.content_type)?;
75 let bytes = decode_image_data(&request.image_data_base64)?;
76 if bytes.len() > MAX_PROFILE_IMAGE_BYTES {
77 return Err(ApiError::bad_request("profile image exceeds 5 MB"));
78 }
79 verify_image_hash(&bytes, &request.image_sha256)?;
80
81 let uploaded_image = upload_cloudflare_image(wallet, &content_type, bytes).await?;
82 let previous_profile_image_url = match state
83 .competition_service
84 .set_profile_image_url(wallet, &uploaded_image.url)
85 .await
86 {
87 Ok(previous_profile_image_url) => previous_profile_image_url,
88 Err(error) => {
89 tracing::error!(%wallet, error = %error, "failed to persist profile image URL");
90 if let Err(cleanup_error) = delete_cloudflare_image(&uploaded_image.id).await {
91 tracing::error!(
92 %wallet,
93 image_id = %uploaded_image.id,
94 error = ?cleanup_error,
95 "failed to delete orphaned profile image after DB persistence failure"
96 );
97 }
98 return Err(ApiError::internal_error("Failed to set profile image"));
99 }
100 };
101
102 if let Some(previous_profile_image_url) = previous_profile_image_url {
103 if previous_profile_image_url != uploaded_image.url {
104 if let Some(previous_image_id) =
105 cloudflare_image_id_from_url(&previous_profile_image_url)
106 {
107 if previous_image_id != uploaded_image.id {
108 if let Err(cleanup_error) = delete_cloudflare_image(&previous_image_id).await {
109 tracing::error!(
110 %wallet,
111 image_id = %previous_image_id,
112 error = ?cleanup_error,
113 "failed to delete replaced profile image"
114 );
115 }
116 }
117 }
118 }
119 }
120
121 Ok(SonicJson(SetProfileImageResponse {
122 wallet,
123 profile_image_url: uploaded_image.url,
124 }))
125}
126
127fn normalize_content_type(raw: &str) -> Result<String, ApiError> {
128 let content_type = raw.trim().to_ascii_lowercase();
129 match content_type.as_str() {
130 "image/jpeg" | "image/png" | "image/webp" => Ok(content_type),
131 _ => Err(ApiError::bad_request(
132 "profile image must be JPEG, PNG, or WEBP",
133 )),
134 }
135}
136
137fn decode_image_data(raw: &str) -> Result<Vec<u8>, ApiError> {
138 let encoded = raw
139 .split_once(',')
140 .map(|(_, data)| data)
141 .unwrap_or(raw)
142 .trim();
143 general_purpose::STANDARD
144 .decode(encoded)
145 .or_else(|_| general_purpose::URL_SAFE_NO_PAD.decode(encoded))
146 .map_err(|_| ApiError::bad_request("profile image data is not valid base64"))
147}
148
149fn verify_image_hash(bytes: &[u8], expected: &str) -> Result<(), ApiError> {
150 let expected = expected
151 .trim()
152 .trim_start_matches("0x")
153 .to_ascii_lowercase();
154 if expected.len() != 64 || !expected.chars().all(|ch| ch.is_ascii_hexdigit()) {
155 return Err(ApiError::bad_request(
156 "image_sha256 must be a SHA-256 hex digest",
157 ));
158 }
159 let actual = hex::encode(Sha256::digest(bytes));
160 if actual != expected {
161 return Err(ApiError::bad_request(
162 "image_sha256 does not match image data",
163 ));
164 }
165 Ok(())
166}
167
168async fn upload_cloudflare_image(
169 wallet: WalletAddress,
170 content_type: &str,
171 bytes: Vec<u8>,
172) -> Result<UploadedCloudflareImage, ApiError> {
173 let image_id = profile_image_id_for_wallet(wallet);
174 match upload_cloudflare_image_once(&image_id, content_type, bytes.clone()).await {
175 Ok(uploaded_image) => return Ok(uploaded_image),
176 Err(CloudflareUploadError::DuplicateCustomId) => {
177 delete_cloudflare_image(&image_id).await?;
178 }
179 Err(CloudflareUploadError::Api(error)) => return Err(error),
180 }
181
182 upload_cloudflare_image_once(&image_id, content_type, bytes)
183 .await
184 .map_err(|error| match error {
185 CloudflareUploadError::DuplicateCustomId => {
186 ApiError::internal_error("Failed to replace profile image")
187 }
188 CloudflareUploadError::Api(error) => error,
189 })
190}
191
192enum CloudflareUploadError {
193 DuplicateCustomId,
194 Api(ApiError),
195}
196
197async fn upload_cloudflare_image_once(
198 image_id: &str,
199 content_type: &str,
200 bytes: Vec<u8>,
201) -> Result<UploadedCloudflareImage, CloudflareUploadError> {
202 let account_id = std::env::var("CLOUDFLARE_IMAGES_ACCOUNT_ID")
203 .map_err(|_| cloudflare_upload_error("Cloudflare Images is not configured"))?;
204 let api_token = std::env::var("CLOUDFLARE_IMAGES_API_TOKEN")
205 .map_err(|_| cloudflare_upload_error("Cloudflare Images is not configured"))?;
206 let api_base_url = std::env::var("CLOUDFLARE_IMAGES_API_BASE_URL")
207 .unwrap_or_else(|_| DEFAULT_CLOUDFLARE_API_BASE_URL.to_string());
208 let variant = std::env::var("CLOUDFLARE_IMAGES_VARIANT")
209 .unwrap_or_else(|_| DEFAULT_CLOUDFLARE_VARIANT.to_string());
210 let url = format!(
211 "{}/accounts/{}/images/v1",
212 api_base_url.trim().trim_end_matches('/'),
213 account_id.trim()
214 );
215 let part = reqwest::multipart::Part::bytes(bytes)
216 .file_name(profile_image_filename(content_type))
217 .mime_str(content_type)
218 .map_err(|_| cloudflare_upload_error("invalid profile image content type"))?;
219 let metadata = serde_json::to_string(&serde_json::json!({
220 "source": "hypercall-profile",
221 }))
222 .map_err(|_| cloudflare_upload_error("Failed to prepare profile image metadata"))?;
223 let form = reqwest::multipart::Form::new()
224 .text("id", image_id.to_string())
225 .part("file", part)
226 .text("requireSignedURLs", "false")
227 .text("metadata", metadata);
228 let client = reqwest::Client::builder()
229 .timeout(EXTERNAL_REQUEST_TIMEOUT)
230 .build()
231 .map_err(|error| {
232 tracing::error!(error = %error, "Cloudflare Images client build failed");
233 cloudflare_upload_error("Failed to upload profile image")
234 })?;
235 let response = client
236 .post(url)
237 .bearer_auth(api_token.trim())
238 .multipart(form)
239 .send()
240 .await
241 .map_err(|error| {
242 tracing::error!(error = %error, "Cloudflare Images upload failed");
243 cloudflare_upload_error("Failed to upload profile image")
244 })?;
245 let status = response.status();
246 let body = response.text().await.map_err(|error| {
247 tracing::error!(error = %error, "Cloudflare Images response read failed");
248 cloudflare_upload_error("Failed to upload profile image")
249 })?;
250 if !status.is_success() {
251 if cloudflare_response_has_error_code(&body, 5409) {
252 return Err(CloudflareUploadError::DuplicateCustomId);
253 }
254 tracing::error!(%status, body, "Cloudflare Images returned error");
255 return Err(cloudflare_upload_error("Failed to upload profile image"));
256 }
257 let response = serde_json::from_str::<CloudflareImagesResponse>(&body).map_err(|error| {
258 tracing::error!(error = %error, "Cloudflare Images response parse failed");
259 cloudflare_upload_error("Failed to upload profile image")
260 })?;
261 if !response.success {
262 if response
263 .errors
264 .as_ref()
265 .is_some_and(|errors| errors.iter().any(|error| error.code == Some(5409)))
266 {
267 return Err(CloudflareUploadError::DuplicateCustomId);
268 }
269 let message = response
270 .errors
271 .unwrap_or_default()
272 .into_iter()
273 .map(|error| error.message)
274 .collect::<Vec<_>>()
275 .join(", ");
276 tracing::error!(message, "Cloudflare Images upload rejected");
277 return Err(cloudflare_upload_error("Failed to upload profile image"));
278 }
279 let image = response
280 .result
281 .ok_or_else(|| cloudflare_upload_error("Cloudflare Images response missing result"))?;
282 let url = image
283 .variants
284 .iter()
285 .find(|url| url.ends_with(&format!("/{}", variant.trim())))
286 .cloned()
287 .or_else(|| image.variants.first().cloned())
288 .ok_or_else(|| cloudflare_upload_error("Cloudflare Images response missing variant"))?;
289 if let Some(custom_url) = custom_delivery_url(&url) {
290 return Ok(UploadedCloudflareImage {
291 id: image.id,
292 url: custom_url,
293 });
294 }
295 Ok(UploadedCloudflareImage { id: image.id, url })
296}
297
298fn cloudflare_upload_error(message: &'static str) -> CloudflareUploadError {
299 CloudflareUploadError::Api(ApiError::internal_error(message))
300}
301
302fn profile_image_id_for_wallet(wallet: WalletAddress) -> String {
303 wallet.as_hex().to_ascii_lowercase()
304}
305
306fn profile_image_filename(content_type: &str) -> &'static str {
307 match content_type {
308 "image/jpeg" => "profile-image.jpg",
309 "image/png" => "profile-image.png",
310 "image/webp" => "profile-image.webp",
311 _ => "profile-image",
312 }
313}
314
315fn cloudflare_response_has_error_code(body: &str, expected_code: u64) -> bool {
316 serde_json::from_str::<CloudflareImagesResponse>(body)
317 .ok()
318 .and_then(|response| response.errors)
319 .is_some_and(|errors| errors.iter().any(|error| error.code == Some(expected_code)))
320}
321
322fn custom_delivery_url(cloudflare_variant_url: &str) -> Option<String> {
323 let host = std::env::var("CLOUDFLARE_IMAGES_DELIVERY_DOMAIN")
324 .or_else(|_| std::env::var("CLOUDFLARE_IMAGES_DELIVERY_HOST"))
325 .ok()?;
326 custom_delivery_url_for_host(&host, cloudflare_variant_url)
327}
328
329fn custom_delivery_url_for_host(host: &str, cloudflare_variant_url: &str) -> Option<String> {
330 let host = host
331 .trim()
332 .trim_start_matches("https://")
333 .trim_start_matches("http://")
334 .trim_end_matches('/');
335 if host.is_empty() {
336 return None;
337 }
338
339 let delivery_path = cloudflare_variant_url
340 .trim_start_matches("https://")
341 .trim_start_matches("http://")
342 .split_once('/')
343 .map(|(_, path)| path)?;
344 Some(format!(
345 "https://{host}/cdn-cgi/imagedelivery/{delivery_path}"
346 ))
347}
348
349fn cloudflare_image_id_from_url(url: &str) -> Option<String> {
350 let url = reqwest::Url::parse(url).ok()?;
351 let segments = url.path_segments()?.collect::<Vec<_>>();
352 if let ["cdn-cgi", "imagedelivery", _account_hash, image_id, ..] = segments.as_slice() {
353 return Some((*image_id).to_string());
354 }
355 if url.host_str() == Some("imagedelivery.net") {
356 return segments.get(1).map(|image_id| (*image_id).to_string());
357 }
358 segments.first().map(|image_id| (*image_id).to_string())
359}
360
361async fn delete_cloudflare_image(image_id: &str) -> Result<(), ApiError> {
362 let account_id = std::env::var("CLOUDFLARE_IMAGES_ACCOUNT_ID")
363 .map_err(|_| ApiError::internal_error("Cloudflare Images is not configured"))?;
364 let api_token = std::env::var("CLOUDFLARE_IMAGES_API_TOKEN")
365 .map_err(|_| ApiError::internal_error("Cloudflare Images is not configured"))?;
366 let api_base_url = std::env::var("CLOUDFLARE_IMAGES_API_BASE_URL")
367 .unwrap_or_else(|_| DEFAULT_CLOUDFLARE_API_BASE_URL.to_string());
368 let url = format!(
369 "{}/accounts/{}/images/v1/{}",
370 api_base_url.trim().trim_end_matches('/'),
371 account_id.trim(),
372 image_id
373 );
374 let client = reqwest::Client::builder()
375 .timeout(EXTERNAL_REQUEST_TIMEOUT)
376 .build()
377 .map_err(|error| {
378 tracing::error!(error = %error, "Cloudflare Images delete client build failed");
379 ApiError::internal_error("Failed to delete profile image")
380 })?;
381 let response = client
382 .delete(url)
383 .bearer_auth(api_token.trim())
384 .send()
385 .await
386 .map_err(|error| {
387 tracing::error!(error = %error, "Cloudflare Images delete failed");
388 ApiError::internal_error("Failed to delete profile image")
389 })?;
390 let status = response.status();
391 if status.is_success() || status == StatusCode::NOT_FOUND {
392 return Ok(());
393 }
394 let body = response.text().await.unwrap_or_default();
395 tracing::error!(%status, body, "Cloudflare Images delete returned error");
396 Err(ApiError::internal_error("Failed to delete profile image"))
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 #[test]
404 fn validates_image_hash() {
405 let bytes = b"profile";
406 let hash = hex::encode(Sha256::digest(bytes));
407 verify_image_hash(bytes, &hash).unwrap();
408 assert!(verify_image_hash(bytes, "00").is_err());
409 }
410
411 #[test]
412 fn decodes_plain_and_data_url_base64() {
413 let encoded = general_purpose::STANDARD.encode(b"abc");
414 assert_eq!(decode_image_data(&encoded).unwrap(), b"abc");
415 assert_eq!(
416 decode_image_data(&format!("data:image/png;base64,{encoded}")).unwrap(),
417 b"abc"
418 );
419 }
420
421 #[test]
422 fn rejects_unsupported_content_types() {
423 assert!(normalize_content_type("image/png").is_ok());
424 assert!(normalize_content_type("text/html").is_err());
425 }
426
427 #[test]
428 fn profile_image_id_is_lowercase_wallet_address() {
429 let wallet = "0x1EA156C6722c11c04A73dC9080e55f6AC0e676A3"
430 .parse::<WalletAddress>()
431 .unwrap();
432
433 assert_eq!(
434 profile_image_id_for_wallet(wallet),
435 "0x1ea156c6722c11c04a73dc9080e55f6ac0e676a3"
436 );
437 }
438
439 #[test]
440 fn chooses_file_extension_for_content_type() {
441 assert_eq!(profile_image_filename("image/jpeg"), "profile-image.jpg");
442 assert_eq!(profile_image_filename("image/png"), "profile-image.png");
443 assert_eq!(profile_image_filename("image/webp"), "profile-image.webp");
444 }
445
446 #[test]
447 fn detects_cloudflare_duplicate_custom_id_error_with_pretty_json() {
448 let body = r#"{
449 "result": null,
450 "success": false,
451 "errors": [
452 {
453 "code": 5409,
454 "message": "Resource already exists. Verify the resource information and try again."
455 }
456 ],
457 "messages": []
458 }"#;
459
460 assert!(cloudflare_response_has_error_code(body, 5409));
461 assert!(!cloudflare_response_has_error_code(body, 5404));
462 }
463
464 #[test]
465 fn custom_delivery_url_uses_cloudflare_imagedelivery_path() {
466 let url = custom_delivery_url_for_host(
467 "hypercall.xyz",
468 "https://imagedelivery.net/account_hash/image-id/public",
469 )
470 .unwrap();
471
472 assert_eq!(
473 url,
474 "https://hypercall.xyz/cdn-cgi/imagedelivery/account_hash/image-id/public"
475 );
476 }
477
478 #[test]
479 fn extracts_cloudflare_image_id_from_delivery_urls() {
480 assert_eq!(
481 cloudflare_image_id_from_url("https://imagedelivery.net/account_hash/image-id/public"),
482 Some("image-id".to_string())
483 );
484 assert_eq!(
485 cloudflare_image_id_from_url(
486 "https://hypercall.xyz/cdn-cgi/imagedelivery/account_hash/image-id/public"
487 ),
488 Some("image-id".to_string())
489 );
490 assert_eq!(
491 cloudflare_image_id_from_url("https://hypercall.xyz/image-id/public"),
492 Some("image-id".to_string())
493 );
494 }
495}