sugar_cli/upload/
assets.rs

1use std::{
2    ffi::OsStr,
3    fs::{self, DirEntry, File, OpenOptions},
4    io::{BufReader, Read},
5};
6
7use data_encoding::HEXLOWER;
8use glob::glob;
9use regex::{Regex, RegexBuilder};
10use ring::digest::{Context, SHA256};
11use serde::Serialize;
12use serde_json;
13
14use crate::{common::*, validate::format::Metadata};
15
16#[derive(Debug, Clone)]
17pub enum DataType {
18    Image,
19    Metadata,
20    Animation,
21}
22
23#[derive(Debug, Clone, Serialize)]
24pub struct AssetPair {
25    pub name: String,
26    pub metadata: String,
27    pub metadata_hash: String,
28    pub image: String,
29    pub image_hash: String,
30    pub animation: Option<String>,
31    pub animation_hash: Option<String>,
32}
33
34impl AssetPair {
35    pub fn into_cache_item(self) -> CacheItem {
36        CacheItem {
37            name: self.name,
38            image_hash: self.image_hash,
39            image_link: String::new(),
40            metadata_hash: self.metadata_hash,
41            metadata_link: String::new(),
42            on_chain: false,
43            animation_hash: self.animation_hash,
44            animation_link: None,
45        }
46    }
47}
48
49pub fn get_cache_item<'a>(path: &Path, cache: &'a mut Cache) -> Result<(String, &'a CacheItem)> {
50    let file_stem = String::from(
51        path.file_stem()
52            .and_then(OsStr::to_str)
53            .expect("Failed to get convert path file ext to valid unicode."),
54    );
55
56    // id of the asset (to be used to update the cache link)
57    let asset_id = if file_stem == "collection" {
58        String::from("-1")
59    } else {
60        file_stem
61    };
62
63    let cache_item: &CacheItem = cache
64        .items
65        .get(&asset_id)
66        .ok_or_else(|| anyhow!("Failed to get config item at index '{}'", asset_id))?;
67
68    Ok((asset_id, cache_item))
69}
70
71pub fn get_data_size(assets_dir: &Path, extension: &str) -> Result<u64> {
72    let path = assets_dir
73        .join(format!("*.{extension}"))
74        .to_str()
75        .expect("Failed to convert asset directory path from unicode.")
76        .to_string();
77
78    let assets = glob(&path)?;
79
80    let mut total_size = 0;
81
82    for asset in assets {
83        let asset_path = asset?;
84        let size = fs::metadata(asset_path)?.len();
85        total_size += size;
86    }
87
88    Ok(total_size)
89}
90
91pub fn list_files(assets_dir: &str, include_collection: bool) -> Result<Vec<DirEntry>> {
92    let files = fs::read_dir(assets_dir)
93        .map_err(|_| anyhow!("Failed to read assets directory"))?
94        .filter_map(|entry| entry.ok())
95        .filter(|entry| {
96            let is_file = entry
97                .metadata()
98                .expect("Failed to retrieve metadata from file")
99                .is_file();
100
101            let path = entry.path();
102            let file_stem = path
103                .file_stem()
104                .unwrap_or_default()
105                .to_str()
106                .expect("Failed to convert file name to valid unicode.");
107
108            let is_collection = include_collection && file_stem == "collection";
109            let is_numeric = file_stem.chars().all(|c| c.is_ascii_digit());
110
111            is_file && (is_numeric || is_collection)
112        });
113
114    Ok(files.collect())
115}
116
117pub fn get_asset_pairs(assets_dir: &str) -> Result<HashMap<isize, AssetPair>> {
118    // filters out directories and hidden files
119    let filtered_files = list_files(assets_dir, true)?;
120
121    let paths = filtered_files
122        .into_iter()
123        .map(|entry| {
124            let file_name_as_string =
125                String::from(entry.path().file_name().unwrap().to_str().unwrap());
126            file_name_as_string
127        })
128        .collect::<Vec<String>>();
129
130    let mut asset_pairs: HashMap<isize, AssetPair> = HashMap::new();
131
132    let paths_ref = &paths;
133
134    let animation_exists_regex =
135        Regex::new("^(.+)\\.((mp3)|(mp4)|(mov)|(webm)|(glb))$").expect("Failed to create regex.");
136
137    // since there doesn't have to be video for each image/json pair, need to get rid of
138    // invalid file names before entering metadata filename loop
139    for x in paths_ref {
140        if let Some(captures) = animation_exists_regex.captures(x) {
141            if &captures[1] != "collection" && captures[1].parse::<usize>().is_err() {
142                let error = anyhow!("Couldn't parse filename '{}' to a valid index number.", x);
143                error!("{:?}", error);
144                return Err(error);
145            }
146        }
147    }
148
149    let metadata_filenames = paths_ref
150        .clone()
151        .into_iter()
152        .filter(|p| p.to_lowercase().ends_with(".json"))
153        .collect::<Vec<String>>();
154
155    ensure_sequential_files(metadata_filenames.clone())?;
156
157    for metadata_filename in metadata_filenames {
158        let i = metadata_filename.split('.').next().unwrap();
159        let is_collection_index = i == "collection";
160
161        let index: isize = if is_collection_index {
162            -1
163        } else if let Ok(index) = i.parse::<isize>() {
164            index
165        } else {
166            let error = anyhow!(
167                "Couldn't parse filename '{}' to a valid index number.",
168                metadata_filename
169            );
170            error!("{:?}", error);
171            return Err(error);
172        };
173
174        let img_pattern = format!("^{}\\.((jpg)|(jpeg)|(gif)|(png))$", i);
175
176        let img_regex = RegexBuilder::new(&img_pattern)
177            .case_insensitive(true)
178            .build()
179            .expect("Failed to create regex.");
180
181        let img_filenames = paths_ref
182            .clone()
183            .into_iter()
184            .filter(|p| img_regex.is_match(p))
185            .collect::<Vec<String>>();
186
187        let img_filename = if img_filenames.len() != 1 {
188            let error = if is_collection_index {
189                anyhow!("Couldn't find the collection image filename.")
190            } else {
191                anyhow!(
192                    "Couldn't find an image filename at index {}.",
193                    i.parse::<isize>().unwrap()
194                )
195            };
196            error!("{:?}", error);
197            return Err(error);
198        } else {
199            &img_filenames[0]
200        };
201
202        // need a similar check for animation as above, this one checking if there is animation
203        // on specific index
204
205        let animation_pattern = format!("^{}\\.((mp3)|(mp4)|(mov)|(webm)|(glb))$", i);
206        let animation_regex = RegexBuilder::new(&animation_pattern)
207            .case_insensitive(true)
208            .build()
209            .expect("Failed to create regex.");
210
211        let animation_filenames = paths_ref
212            .clone()
213            .into_iter()
214            .filter(|p| animation_regex.is_match(p))
215            .collect::<Vec<String>>();
216
217        let metadata_filepath = Path::new(assets_dir)
218            .join(&metadata_filename)
219            .to_str()
220            .expect("Failed to convert metadata path from unicode.")
221            .to_string();
222
223        let m = File::open(&metadata_filepath)?;
224        let metadata: Metadata = serde_json::from_reader(m).map_err(|e| {
225            anyhow!("Failed to read metadata file '{metadata_filepath}' with error: {e}")
226        })?;
227        let name = metadata.name.clone();
228
229        let img_filepath = Path::new(assets_dir)
230            .join(img_filename)
231            .to_str()
232            .expect("Failed to convert image path from unicode.")
233            .to_string();
234
235        let animation_filename = if animation_filenames.len() == 1 {
236            let animation_filepath = Path::new(assets_dir)
237                .join(&animation_filenames[0])
238                .to_str()
239                .expect("Failed to convert animation path from unicode.")
240                .to_string();
241
242            Some(animation_filepath)
243        } else {
244            None
245        };
246
247        let animation_hash = if let Some(animation_file) = &animation_filename {
248            let encoded_filename = encode(animation_file)?;
249            Some(encoded_filename)
250        } else {
251            None
252        };
253
254        let asset_pair = AssetPair {
255            name,
256            metadata: metadata_filepath.clone(),
257            metadata_hash: encode(&metadata_filepath)?,
258            image: img_filepath.clone(),
259            image_hash: encode(&img_filepath)?,
260            animation_hash,
261            animation: animation_filename,
262        };
263
264        asset_pairs.insert(index, asset_pair);
265    }
266
267    Ok(asset_pairs)
268}
269
270pub fn encode(file: &str) -> Result<String> {
271    let input = File::open(file)?;
272    let mut reader = BufReader::new(input);
273    let mut context = Context::new(&SHA256);
274    let mut buffer = [0; 1024];
275
276    loop {
277        let count = reader.read(&mut buffer)?;
278        if count == 0 {
279            break;
280        }
281        context.update(&buffer[..count]);
282    }
283
284    Ok(HEXLOWER.encode(context.finish().as_ref()))
285}
286
287fn ensure_sequential_files(metadata_filenames: Vec<String>) -> Result<()> {
288    let mut metadata_indices = metadata_filenames
289        .into_iter()
290        .filter(|f| !f.starts_with("collection"))
291        .map(|f| {
292            f.split('.')
293                .next()
294                .unwrap()
295                .to_string()
296                .parse::<usize>()
297                .map_err(|_| {
298                    anyhow!(
299                        "Couldn't parse metadata filename '{}' to a valid index number.",
300                        f
301                    )
302                })
303        })
304        .collect::<Result<Vec<usize>>>()?;
305    metadata_indices.sort_unstable();
306
307    metadata_indices
308        .into_iter()
309        .enumerate()
310        .try_for_each(|(i, file_index)| {
311            if i != file_index {
312                Err(anyhow!("Missing metadata file '{}.json'", i))
313            } else {
314                Ok(())
315            }
316        })
317}
318
319pub fn get_updated_metadata(
320    metadata_file: &str,
321    image_link: &str,
322    animation_link: &Option<String>,
323) -> Result<String> {
324    let mut metadata: Metadata = {
325        let m = OpenOptions::new()
326            .read(true)
327            .open(metadata_file)
328            .map_err(|e| {
329                anyhow!("Failed to read metadata file '{metadata_file}' with error: {e}")
330            })?;
331        serde_json::from_reader(&m)?
332    };
333
334    for file in &mut metadata.properties.files {
335        if file.uri.eq(&metadata.image) {
336            file.uri = image_link.to_string();
337        }
338        if let Some(ref animation_link) = animation_link {
339            if let Some(ref animation_url) = metadata.animation_url {
340                if file.uri.eq(animation_url) {
341                    file.uri = animation_link.to_string();
342                }
343            }
344        }
345    }
346
347    metadata.image = image_link.to_string();
348
349    if animation_link.is_some() {
350        // only updates the link if we have a new value
351        metadata.animation_url = animation_link.clone();
352    }
353
354    Ok(serde_json::to_string(&metadata).unwrap())
355}
356
357pub fn is_complete_uri(value: &str) -> bool {
358    matches!(url::Url::parse(value), Ok(_))
359}