sugar_cli/upload/methods/
nft_storage.rs

1use std::{
2    fs,
3    path::Path,
4    sync::{
5        atomic::{AtomicBool, Ordering},
6        Arc,
7    },
8};
9
10use async_trait::async_trait;
11use reqwest::{
12    header,
13    multipart::{Form, Part},
14    Client, StatusCode,
15};
16use tokio::time::{sleep, Duration};
17
18use crate::{common::*, config::*, upload::*};
19
20// API end point.
21const NFT_STORAGE_API_URL: &str = "https://api.nft.storage";
22// Storage end point.
23const NFT_STORAGE_GATEWAY_URL: &str = "https://nftstorage.link/ipfs";
24// Request time window (ms) to avoid the rate limit.
25const REQUEST_WAIT: u64 = 10000;
26// File size limit (100mb).
27const FILE_SIZE_LIMIT: u64 = 100 * 1024 * 1024;
28// Number of files per request limit.
29const FILE_COUNT_LIMIT: u64 = 100;
30
31pub enum NftStorageError {
32    ApiError(Value),
33}
34
35/// response after an nft was stored
36#[derive(Debug, Deserialize, Default)]
37pub struct StoreNftResponse {
38    /// status of the request
39    pub ok: bool,
40    /// stored nft data
41    pub value: NftValue,
42}
43
44/// main obj that hold all the response data
45#[derive(Debug, Deserialize, Default)]
46#[serde(default)]
47pub struct NftValue {
48    /// ipfs cid (file hash)
49    pub cid: String,
50}
51
52/// response after an error
53#[derive(Debug, Deserialize, Default)]
54pub struct StoreNftError {
55    /// status of the request
56    pub ok: bool,
57    /// stored nft error
58    pub error: NftError,
59}
60
61/// hold the error message
62#[derive(Debug, Deserialize, Default)]
63#[serde(default)]
64pub struct NftError {
65    /// error message
66    pub message: String,
67}
68
69pub struct NftStorageMethod {
70    client: Arc<Client>,
71}
72
73impl NftStorageMethod {
74    /// Initialize a new NftStorageHandler.
75    pub async fn new(config_data: &ConfigData) -> Result<Self> {
76        if let Some(auth_token) = &config_data.nft_storage_auth_token {
77            let client_builder = Client::builder();
78
79            let mut headers = header::HeaderMap::new();
80            let bearer_value = format!("Bearer {}", auth_token);
81            let mut auth_value = header::HeaderValue::from_str(&bearer_value)?;
82            auth_value.set_sensitive(true);
83            headers.insert(header::AUTHORIZATION, auth_value);
84
85            let client = client_builder.default_headers(headers).build()?;
86
87            let url = format!("{}/", NFT_STORAGE_API_URL);
88            let response = client.get(url).send().await?;
89
90            match response.status() {
91                StatusCode::OK => Ok(Self {
92                    client: Arc::new(client),
93                }),
94                StatusCode::UNAUTHORIZED => {
95                    Err(anyhow!("Invalid nft.storage authentication token."))
96                }
97                code => Err(anyhow!("Could not initialize nft.storage client: {code}")),
98            }
99        } else {
100            Err(anyhow!(
101                "Missing 'nftStorageAuthToken' value in config file."
102            ))
103        }
104    }
105}
106
107#[async_trait]
108impl Prepare for NftStorageMethod {
109    /// Verifies that no file is larger than 100MB (upload of files larger than 100MB are
110    /// not currently supported).
111    async fn prepare(
112        &self,
113        _sugar_config: &SugarConfig,
114        asset_pairs: &HashMap<isize, AssetPair>,
115        asset_indices: Vec<(DataType, &[isize])>,
116    ) -> Result<()> {
117        for (data_type, indices) in asset_indices {
118            for index in indices {
119                let item = asset_pairs.get(index).unwrap();
120                let size = match data_type {
121                    DataType::Image => {
122                        let path = Path::new(&item.image);
123                        fs::metadata(path)?.len()
124                    }
125                    DataType::Animation => {
126                        if let Some(animation) = &item.animation {
127                            let path = Path::new(animation);
128                            fs::metadata(path)?.len()
129                        } else {
130                            0
131                        }
132                    }
133                    DataType::Metadata => {
134                        let mock_uri = "x".repeat(MOCK_URI_SIZE);
135                        let animation = if item.animation.is_some() {
136                            Some(mock_uri.clone())
137                        } else {
138                            None
139                        };
140
141                        get_updated_metadata(&item.metadata, &mock_uri.clone(), &animation)?
142                            .into_bytes()
143                            .len() as u64
144                    }
145                };
146
147                if size > FILE_SIZE_LIMIT {
148                    return Err(anyhow!(
149                        "File '{}' exceeds the current 100MB file size limit",
150                        item.name,
151                    ));
152                }
153            }
154        }
155        Ok(())
156    }
157}
158
159#[async_trait]
160impl Uploader for NftStorageMethod {
161    /// Upload the data to Nft Storage
162    async fn upload(
163        &self,
164        _sugar_config: &SugarConfig,
165        cache: &mut Cache,
166        data_type: DataType,
167        assets: &mut Vec<AssetInfo>,
168        progress: &ProgressBar,
169        interrupted: Arc<AtomicBool>,
170    ) -> Result<Vec<UploadError>> {
171        let mut batches: Vec<Vec<&AssetInfo>> = Vec::new();
172        let mut current: Vec<&AssetInfo> = Vec::new();
173        let mut upload_size = 0;
174        let mut upload_count = 0;
175
176        for asset_info in assets {
177            let size = match data_type {
178                DataType::Image | DataType::Animation => {
179                    let path = Path::new(&asset_info.content);
180                    fs::metadata(path)?.len()
181                }
182                DataType::Metadata => {
183                    let content = String::from(&asset_info.content);
184                    content.into_bytes().len() as u64
185                }
186            };
187
188            if (upload_size + size) > FILE_SIZE_LIMIT || (upload_count + 1) > FILE_COUNT_LIMIT {
189                batches.push(current);
190                current = Vec::new();
191                upload_size = 0;
192                upload_count = 0;
193            }
194
195            upload_size += size;
196            upload_count += 1;
197            current.push(asset_info);
198        }
199        // adds the last chunk (if there is one)
200        if !current.is_empty() {
201            batches.push(current);
202        }
203
204        let mut errors = Vec::new();
205        // sets the length of the progress bar as the number of batches
206        progress.set_length(batches.len() as u64);
207
208        while !interrupted.load(Ordering::SeqCst) && !batches.is_empty() {
209            let batch = batches.remove(0);
210            let mut form = Form::new();
211
212            for asset_info in &batch {
213                let data = match asset_info.data_type {
214                    DataType::Image | DataType::Animation => fs::read(&asset_info.content)?,
215                    DataType::Metadata => {
216                        let content = String::from(&asset_info.content);
217                        content.into_bytes()
218                    }
219                };
220
221                let file = Part::bytes(data)
222                    .file_name(asset_info.name.clone())
223                    .mime_str(asset_info.content_type.as_str())?;
224                form = form.part("file", file);
225            }
226
227            let response = self
228                .client
229                .post(format!("{NFT_STORAGE_API_URL}/upload"))
230                .multipart(form)
231                .send()
232                .await?;
233            let status = response.status();
234
235            if status.is_success() {
236                let body = response.json::<Value>().await?;
237                let StoreNftResponse {
238                    value: NftValue { cid },
239                    ..
240                }: StoreNftResponse = serde_json::from_value(body)?;
241
242                // updates the cache content
243
244                for asset_info in batch {
245                    let id = asset_info.asset_id.clone();
246                    let uri = format!("{NFT_STORAGE_GATEWAY_URL}/{cid}/{}", asset_info.name);
247                    // cache item to update
248                    let item = cache.items.get_mut(&id).unwrap();
249
250                    match data_type {
251                        DataType::Image => item.image_link = uri,
252                        DataType::Metadata => item.metadata_link = uri,
253                        DataType::Animation => item.animation_link = Some(uri),
254                    }
255                }
256                // syncs cache (checkpoint)
257                cache.sync_file()?;
258                // updates the progress bar
259                progress.inc(1);
260            } else {
261                let body = response.json::<Value>().await?;
262                let StoreNftError {
263                    error: NftError { message },
264                    ..
265                }: StoreNftError = serde_json::from_value(body)?;
266
267                errors.push(UploadError::SendDataFailed(format!(
268                    "Error uploading batch ({}): {}",
269                    status, message
270                )));
271            }
272            if !batches.is_empty() {
273                // wait to minimize the chance of getting caught by the rate limit
274                sleep(Duration::from_millis(REQUEST_WAIT)).await;
275            }
276        }
277
278        Ok(errors)
279    }
280}