forked from
slices.network/slices
Highly ambitious ATProtocol AppView service and sdks
1//! GraphQL schema extension for blob uploads
2
3use async_graphql::dynamic::{Field, FieldFuture, FieldValue, InputValue, Object, TypeRef};
4use async_graphql::Error;
5use base64::engine::general_purpose;
6use base64::Engine;
7
8use crate::atproto_extensions::upload_blob as atproto_upload_blob;
9use crate::auth;
10use crate::graphql::schema_builder::BlobContainer;
11
12/// Creates the BlobUploadResponse GraphQL type
13pub fn create_blob_upload_response_type() -> Object {
14 let mut response = Object::new("BlobUploadResponse");
15
16 // Return the Blob type instead of JSON to ensure consistent ref field handling
17 response = response.field(Field::new("blob", TypeRef::named_nn("Blob"), |ctx| {
18 FieldFuture::new(async move {
19 // The BlobContainer is passed through from the mutation resolver
20 // The Blob type resolver will handle extracting the fields
21 let container = ctx.parent_value.try_downcast_ref::<BlobContainer>()?;
22 Ok(Some(FieldValue::owned_any(container.clone())))
23 })
24 }));
25
26 response
27}
28
29/// Add uploadBlob mutation to the Mutation type
30pub fn add_upload_blob_mutation(
31 mutation: Object,
32 auth_base_url: String,
33) -> Object {
34 mutation.field(
35 Field::new(
36 "uploadBlob",
37 TypeRef::named_nn("BlobUploadResponse"),
38 move |ctx| {
39 let auth_base = auth_base_url.clone();
40
41 FieldFuture::new(async move {
42 // Get GraphQL context which contains auth info (same pattern as create/update mutations)
43 let gql_ctx = ctx.data::<crate::graphql::GraphQLContext>()
44 .map_err(|_| Error::new("Missing GraphQL context"))?;
45
46 // Check if user is authenticated
47 let token = gql_ctx.auth_token.as_ref()
48 .ok_or_else(|| Error::new("Authentication required"))?;
49
50 // Get data and mimeType arguments
51 let data_base64 = ctx
52 .args
53 .get("data")
54 .ok_or_else(|| Error::new("data argument is required"))?
55 .string()?;
56
57 let mime_type = ctx
58 .args
59 .get("mimeType")
60 .ok_or_else(|| Error::new("mimeType argument is required"))?
61 .string()?;
62
63 // Decode base64 data
64 let blob_data = general_purpose::STANDARD
65 .decode(data_base64)
66 .map_err(|e| Error::new(format!("Invalid base64 data: {}", e)))?;
67
68 // Verify OAuth token to get user info (needed for DID)
69 let user_info = auth::verify_oauth_token_cached(
70 token,
71 &auth_base,
72 gql_ctx.auth_cache.clone(),
73 )
74 .await
75 .map_err(|e| Error::new(format!("Invalid token: {}", e)))?;
76
77 // Get ATProto DPoP auth and PDS URL for this user
78 let (dpop_auth, pds_url) = auth::get_atproto_auth_for_user_cached(
79 token,
80 &auth_base,
81 gql_ctx.auth_cache.clone(),
82 )
83 .await
84 .map_err(|e| Error::new(format!("Failed to get ATProto auth: {}", e)))?;
85
86 // Upload blob to user's PDS
87 let http_client = reqwest::Client::new();
88 let upload_result = atproto_upload_blob(
89 &http_client,
90 &dpop_auth,
91 &pds_url,
92 blob_data,
93 mime_type,
94 )
95 .await
96 .map_err(|e| Error::new(format!("Failed to upload blob: {}", e)))?;
97
98 // Extract the DID from user info
99 let did = user_info.did.unwrap_or(user_info.sub);
100
101 // Create BlobContainer with flattened ref field (CID string)
102 // This ensures the GraphQL Blob type returns ref as a String, not an object
103 let blob_container = BlobContainer {
104 blob_ref: upload_result.blob.r#ref.link.clone(), // Extract CID from ref.$link
105 mime_type: upload_result.blob.mime_type.clone(),
106 size: upload_result.blob.size as i64,
107 did,
108 };
109
110 Ok(Some(FieldValue::owned_any(blob_container)))
111 })
112 },
113 )
114 .argument(InputValue::new("data", TypeRef::named_nn(TypeRef::STRING)))
115 .argument(InputValue::new("mimeType", TypeRef::named_nn(TypeRef::STRING)))
116 .description("Upload a blob to the user's AT Protocol repository"),
117 )
118}