Skip to main content

hypercall_api/handlers/
profile_image.rs

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/// Set the authenticated wallet's profile image.
56#[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}