//! GraphQL schema extension for blob uploads use async_graphql::dynamic::{Field, FieldFuture, FieldValue, InputValue, Object, TypeRef}; use async_graphql::Error; use base64::engine::general_purpose; use base64::Engine; use crate::atproto_extensions::upload_blob as atproto_upload_blob; use crate::auth; use crate::graphql::schema_builder::BlobContainer; /// Creates the BlobUploadResponse GraphQL type pub fn create_blob_upload_response_type() -> Object { let mut response = Object::new("BlobUploadResponse"); // Return the Blob type instead of JSON to ensure consistent ref field handling response = response.field(Field::new("blob", TypeRef::named_nn("Blob"), |ctx| { FieldFuture::new(async move { // The BlobContainer is passed through from the mutation resolver // The Blob type resolver will handle extracting the fields let container = ctx.parent_value.try_downcast_ref::()?; Ok(Some(FieldValue::owned_any(container.clone()))) }) })); response } /// Add uploadBlob mutation to the Mutation type pub fn add_upload_blob_mutation( mutation: Object, auth_base_url: String, ) -> Object { mutation.field( Field::new( "uploadBlob", TypeRef::named_nn("BlobUploadResponse"), move |ctx| { let auth_base = auth_base_url.clone(); FieldFuture::new(async move { // Get GraphQL context which contains auth info (same pattern as create/update mutations) let gql_ctx = ctx.data::() .map_err(|_| Error::new("Missing GraphQL context"))?; // Check if user is authenticated let token = gql_ctx.auth_token.as_ref() .ok_or_else(|| Error::new("Authentication required"))?; // Get data and mimeType arguments let data_base64 = ctx .args .get("data") .ok_or_else(|| Error::new("data argument is required"))? .string()?; let mime_type = ctx .args .get("mimeType") .ok_or_else(|| Error::new("mimeType argument is required"))? .string()?; // Decode base64 data let blob_data = general_purpose::STANDARD .decode(data_base64) .map_err(|e| Error::new(format!("Invalid base64 data: {}", e)))?; // Verify OAuth token to get user info (needed for DID) let user_info = auth::verify_oauth_token_cached( token, &auth_base, gql_ctx.auth_cache.clone(), ) .await .map_err(|e| Error::new(format!("Invalid token: {}", e)))?; // Get ATProto DPoP auth and PDS URL for this user let (dpop_auth, pds_url) = auth::get_atproto_auth_for_user_cached( token, &auth_base, gql_ctx.auth_cache.clone(), ) .await .map_err(|e| Error::new(format!("Failed to get ATProto auth: {}", e)))?; // Upload blob to user's PDS let http_client = reqwest::Client::new(); let upload_result = atproto_upload_blob( &http_client, &dpop_auth, &pds_url, blob_data, mime_type, ) .await .map_err(|e| Error::new(format!("Failed to upload blob: {}", e)))?; // Extract the DID from user info let did = user_info.did.unwrap_or(user_info.sub); // Create BlobContainer with flattened ref field (CID string) // This ensures the GraphQL Blob type returns ref as a String, not an object let blob_container = BlobContainer { blob_ref: upload_result.blob.r#ref.link.clone(), // Extract CID from ref.$link mime_type: upload_result.blob.mime_type.clone(), size: upload_result.blob.size as i64, did, }; Ok(Some(FieldValue::owned_any(blob_container))) }) }, ) .argument(InputValue::new("data", TypeRef::named_nn(TypeRef::STRING))) .argument(InputValue::new("mimeType", TypeRef::named_nn(TypeRef::STRING))) .description("Upload a blob to the user's AT Protocol repository"), ) }