sugar_cli/upload/methods/
pinata.rs1use 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
13const UPLOAD_ENDPOINT: &str = "/pinning/pinFileToIPFS";
15const AUTH_TEST_URL: &str = "https://api.pinata.cloud/data/testAuthentication";
17const FILE_SIZE_LIMIT: u64 = 10 * 1024 * 1024;
19
20#[derive(Debug, Deserialize, Default)]
22#[serde(rename_all = "PascalCase")]
23pub struct PinataResponse {
24 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 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 let response = client.get(AUTH_TEST_URL).send().await?;
59
60 match response.status() {
61 StatusCode::OK => {
62 let endpoint_url =
64 url::Url::parse(&pinata_config.api_gateway)?.join(UPLOAD_ENDPOINT)?;
65
66 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 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 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}