sugar_cli/upload/methods/
pinata.rs

1use std::{fs, ops::Deref, path::Path, sync::Arc};
2
3use async_trait::async_trait;
4use reqwest::{
5    header,
6    multipart::{Form, Part},
7    Client, StatusCode,
8};
9use tokio::task::JoinHandle;
10
11use crate::{common::*, config::*, upload::*};
12
13// API end point.
14const UPLOAD_ENDPOINT: &str = "/pinning/pinFileToIPFS";
15// Storage end point.
16const AUTH_TEST_URL: &str = "https://api.pinata.cloud/data/testAuthentication";
17// File size limit (10mb).
18const FILE_SIZE_LIMIT: u64 = 10 * 1024 * 1024;
19
20/// response after an nft was stored
21#[derive(Debug, Deserialize, Default)]
22#[serde(rename_all = "PascalCase")]
23pub struct PinataResponse {
24    /// Hash of the upload
25    pub ipfs_hash: String,
26}
27
28pub struct Config {
29    client: Client,
30    endpoint: String,
31    content_gateway: String,
32    parallel_limit: u16,
33}
34
35pub struct PinataMethod(Arc<Config>);
36
37impl Deref for PinataMethod {
38    type Target = Arc<Config>;
39    fn deref(&self) -> &Self::Target {
40        &self.0
41    }
42}
43
44impl PinataMethod {
45    /// Initialize a new PinataMethod.
46    pub async fn new(config_data: &ConfigData) -> Result<Self> {
47        if let Some(pinata_config) = &config_data.pinata_config {
48            let client_builder = Client::builder();
49
50            let mut headers = header::HeaderMap::new();
51            let bearer_value = format!("Bearer {}", &pinata_config.jwt);
52            let mut auth_value = header::HeaderValue::from_str(&bearer_value)?;
53            auth_value.set_sensitive(true);
54            headers.insert(header::AUTHORIZATION, auth_value);
55
56            let client = client_builder.default_headers(headers).build()?;
57            // always use the public gateway for the authentication
58            let response = client.get(AUTH_TEST_URL).send().await?;
59
60            match response.status() {
61                StatusCode::OK => {
62                    // upload endpoint
63                    let endpoint_url =
64                        url::Url::parse(&pinata_config.api_gateway)?.join(UPLOAD_ENDPOINT)?;
65
66                    // maximum number of concurrent uploads
67                    let parallel_limit = if let Some(parallel_limit) = pinata_config.parallel_limit
68                    {
69                        parallel_limit
70                    } else {
71                        PARALLEL_LIMIT as u16
72                    };
73
74                    Ok(Self(Arc::new(Config {
75                        client,
76                        endpoint: endpoint_url.to_string(),
77                        content_gateway: pinata_config.content_gateway.clone(),
78                        parallel_limit,
79                    })))
80                }
81                StatusCode::UNAUTHORIZED => Err(anyhow!("Invalid pinata JWT token.")),
82                code => Err(anyhow!("Could not initialize pinata client: {code}")),
83            }
84        } else {
85            Err(anyhow!("Missing 'pinataConfig' in config file."))
86        }
87    }
88}
89
90#[async_trait]
91impl Prepare for PinataMethod {
92    /// Verifies that no file is larger than 100MB (upload of files larger than 100MB are
93    /// not currently supported).
94    async fn prepare(
95        &self,
96        _sugar_config: &SugarConfig,
97        asset_pairs: &HashMap<isize, AssetPair>,
98        asset_indices: Vec<(DataType, &[isize])>,
99    ) -> Result<()> {
100        for (data_type, indices) in asset_indices {
101            for index in indices {
102                let item = asset_pairs.get(index).unwrap();
103                let size = match data_type {
104                    DataType::Image => {
105                        let path = Path::new(&item.image);
106                        fs::metadata(path)?.len()
107                    }
108                    DataType::Animation => {
109                        if let Some(animation) = &item.animation {
110                            let path = Path::new(animation);
111                            fs::metadata(path)?.len()
112                        } else {
113                            0
114                        }
115                    }
116                    DataType::Metadata => {
117                        let mock_uri = "x".repeat(MOCK_URI_SIZE);
118                        let animation = if item.animation.is_some() {
119                            Some(mock_uri.clone())
120                        } else {
121                            None
122                        };
123
124                        get_updated_metadata(&item.metadata, &mock_uri.clone(), &animation)?
125                            .into_bytes()
126                            .len() as u64
127                    }
128                };
129
130                if size > FILE_SIZE_LIMIT {
131                    return Err(anyhow!(
132                        "File '{}' exceeds the current 10MB file size limit",
133                        item.name,
134                    ));
135                }
136            }
137        }
138        Ok(())
139    }
140}
141
142#[async_trait]
143impl ParallelUploader for PinataMethod {
144    /// Returns the number of files that to be send in parallel.
145    fn parallel_limit(&self) -> usize {
146        self.parallel_limit as usize
147    }
148
149    fn upload_asset(&self, asset_info: AssetInfo) -> JoinHandle<Result<(String, String)>> {
150        let config = self.0.clone();
151        tokio::spawn(async move { config.send(asset_info).await })
152    }
153}
154
155impl Config {
156    async fn send(&self, asset_info: AssetInfo) -> Result<(String, String)> {
157        let data = match asset_info.data_type {
158            DataType::Image => fs::read(&asset_info.content)?,
159            DataType::Metadata => asset_info.content.into_bytes(),
160            DataType::Animation => fs::read(&asset_info.content)?,
161        };
162
163        let mut form = Form::new();
164
165        let file = Part::bytes(data)
166            .file_name(asset_info.name.clone())
167            .mime_str(asset_info.content_type.as_str())?;
168        form = form
169            .part("file", file)
170            .text("pinataOptions", "{\"wrapWithDirectory\": true}");
171
172        let response = self
173            .client
174            .post(&self.endpoint)
175            .multipart(form)
176            .send()
177            .await?;
178        let status = response.status();
179
180        if status.is_success() {
181            let body = response.json::<Value>().await?;
182            let PinataResponse { ipfs_hash } = serde_json::from_value(body)?;
183
184            let uri = url::Url::parse(&self.content_gateway)?
185                .join(&format!("/ipfs/{}/{}", ipfs_hash, asset_info.name))?;
186
187            Ok((asset_info.asset_id, uri.to_string()))
188        } else {
189            let body = response.json::<Value>().await?;
190            let details = if let Some(details) = &body["error"]["details"].as_str() {
191                details.to_string()
192            } else {
193                body.to_string()
194            };
195            Err(anyhow!(UploadError::SendDataFailed(format!(
196                "Error uploading batch ({}): {}",
197                status, details
198            ))))
199        }
200    }
201}