manganis_common/
asset.rs

1use std::{
2    fmt::Display,
3    hash::{Hash, Hasher},
4    path::{Path, PathBuf},
5    str::FromStr,
6};
7
8use anyhow::Context;
9use base64::Engine;
10use serde::{Deserialize, Serialize};
11use url::Url;
12
13use crate::{cache::manifest_dir, Config, FileOptions};
14
15/// The type of asset
16#[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Clone)]
17pub enum AssetType {
18    /// A file asset
19    File(FileAsset),
20    /// A tailwind class asset
21    Tailwind(TailwindAsset),
22    /// A metadata asset
23    Metadata(MetadataAsset),
24}
25
26/// The source of a file asset
27#[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Clone, Hash)]
28pub enum FileSource {
29    /// A local file
30    Local(PathBuf),
31    /// A remote file
32    Remote(Url),
33}
34
35impl Display for FileSource {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        let as_string = match self {
38            Self::Local(path) => path.display().to_string(),
39            Self::Remote(url) => url.as_str().to_string(),
40        };
41        if as_string.len() > 25 {
42            write!(f, "{}...", &as_string[..25])
43        } else {
44            write!(f, "{}", as_string)
45        }
46    }
47}
48
49impl FileSource {
50    /// Returns the last segment of the file source used to generate a unique name
51    pub fn last_segment(&self) -> &str {
52        match self {
53            Self::Local(path) => path.file_name().unwrap().to_str().unwrap(),
54            Self::Remote(url) => url.path_segments().unwrap().last().unwrap(),
55        }
56    }
57
58    /// Returns the extension of the file source
59    pub fn extension(&self) -> Option<String> {
60        match self {
61            Self::Local(path) => path.extension().map(|e| e.to_str().unwrap().to_string()),
62            Self::Remote(url) => reqwest::blocking::get(url.as_str())
63                .ok()
64                .and_then(|request| {
65                    request
66                        .headers()
67                        .get("content-type")
68                        .and_then(|content_type| {
69                            content_type
70                                .to_str()
71                                .ok()
72                                .map(|ty| ext_of_mime(ty).to_string())
73                        })
74                }),
75        }
76    }
77
78    /// Attempts to get the mime type of the file source
79    pub fn mime_type(&self) -> Option<String> {
80        match self {
81            Self::Local(path) => get_mime_from_path(path).ok().map(|mime| mime.to_string()),
82            Self::Remote(url) => reqwest::blocking::get(url.as_str())
83                .ok()
84                .and_then(|request| {
85                    request
86                        .headers()
87                        .get("content-type")
88                        .and_then(|content_type| Some(content_type.to_str().ok()?.to_string()))
89                }),
90        }
91    }
92
93    /// Find when the asset was last updated
94    pub fn last_updated(&self) -> Option<String> {
95        match self {
96            Self::Local(path) => path.metadata().ok().and_then(|metadata| {
97                metadata
98                    .modified()
99                    .ok()
100                    .map(|modified| format!("{:?}", modified))
101                    .or_else(|| {
102                        metadata
103                            .created()
104                            .ok()
105                            .map(|created| format!("{:?}", created))
106                    })
107            }),
108            Self::Remote(url) => reqwest::blocking::get(url.as_str())
109                .ok()
110                .and_then(|request| {
111                    request
112                        .headers()
113                        .get("last-modified")
114                        .and_then(|last_modified| {
115                            last_modified
116                                .to_str()
117                                .ok()
118                                .map(|last_modified| last_modified.to_string())
119                        })
120                }),
121        }
122    }
123}
124
125/// Get the mime type from a URI using its extension
126fn ext_of_mime(mime: &str) -> &str {
127    let mime = mime.split(';').next().unwrap_or_default();
128    match mime.trim() {
129        "application/octet-stream" => "bin",
130        "text/css" => "css",
131        "text/csv" => "csv",
132        "text/html" => "html",
133        "image/vnd.microsoft.icon" => "ico",
134        "text/javascript" => "js",
135        "application/json" => "json",
136        "application/ld+json" => "jsonld",
137        "application/rtf" => "rtf",
138        "image/svg+xml" => "svg",
139        "video/mp4" => "mp4",
140        "text/plain" => "txt",
141        "application/xml" => "xml",
142        "application/zip" => "zip",
143        "image/png" => "png",
144        "image/jpeg" => "jpg",
145        "image/gif" => "gif",
146        "image/webp" => "webp",
147        "image/avif" => "avif",
148        "font/ttf" => "ttf",
149        "font/woff" => "woff",
150        "font/woff2" => "woff2",
151        other => other.split('/').last().unwrap_or_default(),
152    }
153}
154
155/// Get the mime type from a path-like string
156fn get_mime_from_path(trimmed: &Path) -> std::io::Result<&'static str> {
157    if trimmed.extension().is_some_and(|ext| ext == "svg") {
158        return Ok("image/svg+xml");
159    }
160
161    let res = match infer::get_from_path(trimmed)?.map(|f| f.mime_type()) {
162        Some(f) => {
163            if f == "text/plain" {
164                get_mime_by_ext(trimmed)
165            } else {
166                f
167            }
168        }
169        None => get_mime_by_ext(trimmed),
170    };
171
172    Ok(res)
173}
174
175/// Get the mime type from a URI using its extension
176fn get_mime_by_ext(trimmed: &Path) -> &'static str {
177    get_mime_from_ext(trimmed.extension().and_then(|e| e.to_str()))
178}
179
180/// Get the mime type from a URI using its extension
181pub fn get_mime_from_ext(extension: Option<&str>) -> &'static str {
182    match extension {
183        Some("bin") => "application/octet-stream",
184        Some("css") => "text/css",
185        Some("csv") => "text/csv",
186        Some("html") => "text/html",
187        Some("ico") => "image/vnd.microsoft.icon",
188        Some("js") => "text/javascript",
189        Some("json") => "application/json",
190        Some("jsonld") => "application/ld+json",
191        Some("mjs") => "text/javascript",
192        Some("rtf") => "application/rtf",
193        Some("svg") => "image/svg+xml",
194        Some("mp4") => "video/mp4",
195        Some("png") => "image/png",
196        Some("jpg") => "image/jpeg",
197        Some("gif") => "image/gif",
198        Some("webp") => "image/webp",
199        Some("avif") => "image/avif",
200        Some("txt") => "text/plain",
201        // Assume HTML when a TLD is found for eg. `dioxus:://dioxuslabs.app` | `dioxus://hello.com`
202        Some(_) => "text/html",
203        // https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types
204        // using octet stream according to this:
205        None => "application/octet-stream",
206    }
207}
208
209/// The location of an asset before and after it is collected
210#[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Clone)]
211pub struct FileLocation {
212    unique_name: String,
213    source: FileSource,
214}
215
216impl FileLocation {
217    /// Returns the unique name of the file that the asset will be served from
218    pub fn unique_name(&self) -> &str {
219        &self.unique_name
220    }
221
222    /// Returns the source of the file that the asset will be collected from
223    pub fn source(&self) -> &FileSource {
224        &self.source
225    }
226
227    /// Reads the file to a string
228    pub fn read_to_string(&self) -> anyhow::Result<String> {
229        match &self.source {
230            FileSource::Local(path) => Ok(std::fs::read_to_string(path).with_context(|| {
231                format!("Failed to read file from location: {}", path.display())
232            })?),
233            FileSource::Remote(url) => {
234                let response = reqwest::blocking::get(url.as_str())
235                    .with_context(|| format!("Failed to asset from url: {}", url.as_str()))?;
236                Ok(response.text().with_context(|| {
237                    format!("Failed to read text for asset from url: {}", url.as_str())
238                })?)
239            }
240        }
241    }
242
243    /// Reads the file to bytes
244    pub fn read_to_bytes(&self) -> anyhow::Result<Vec<u8>> {
245        match &self.source {
246            FileSource::Local(path) => Ok(std::fs::read(path).with_context(|| {
247                format!("Failed to read file from location: {}", path.display())
248            })?),
249            FileSource::Remote(url) => {
250                let response = reqwest::blocking::get(url.as_str())
251                    .with_context(|| format!("Failed to asset from url: {}", url.as_str()))?;
252                Ok(response.bytes().map(|b| b.to_vec()).with_context(|| {
253                    format!("Failed to read text for asset from url: {}", url.as_str())
254                })?)
255            }
256        }
257    }
258}
259
260impl FromStr for FileSource {
261    type Err = anyhow::Error;
262
263    fn from_str(s: &str) -> Result<Self, Self::Err> {
264        match Url::parse(s) {
265            Ok(url) => Ok(Self::Remote(url)),
266            Err(_) => {
267                let manifest_dir = manifest_dir();
268                let path = manifest_dir.join(PathBuf::from(s));
269                let path = path
270                    .canonicalize()
271                    .with_context(|| format!("Failed to canonicalize path: {}", path.display()))?;
272                Ok(Self::Local(path))
273            }
274        }
275    }
276}
277
278/// A file asset
279#[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Clone)]
280pub struct FileAsset {
281    location: FileLocation,
282    options: FileOptions,
283    url_encoded: bool,
284}
285
286impl Display for FileAsset {
287    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288        let url_encoded = if self.url_encoded {
289            " [url encoded]"
290        } else {
291            ""
292        };
293        write!(
294            f,
295            "{} [{}]{}",
296            self.location.source(),
297            self.options,
298            url_encoded
299        )
300    }
301}
302
303impl FileAsset {
304    /// Creates a new file asset
305    pub fn new(source: FileSource) -> Self {
306        let options = FileOptions::default_for_extension(source.extension().as_deref());
307
308        let mut myself = Self {
309            location: FileLocation {
310                unique_name: Default::default(),
311                source,
312            },
313            options,
314            url_encoded: false,
315        };
316
317        myself.regenerate_unique_name();
318
319        myself
320    }
321
322    /// Set the file options
323    pub fn with_options(self, options: FileOptions) -> Self {
324        let mut myself = Self {
325            location: self.location,
326            options,
327            url_encoded: false,
328        };
329
330        myself.regenerate_unique_name();
331
332        myself
333    }
334
335    /// Set whether the file asset should be url encoded
336    pub fn set_url_encoded(&mut self, url_encoded: bool) {
337        self.url_encoded = url_encoded;
338    }
339
340    /// Returns whether the file asset should be url encoded
341    pub fn url_encoded(&self) -> bool {
342        self.url_encoded
343    }
344
345    /// Returns the location where the file asset will be served from
346    pub fn served_location(&self) -> String {
347        if self.url_encoded {
348            let data = self.location.read_to_bytes().unwrap();
349            let data = base64::engine::general_purpose::STANDARD_NO_PAD.encode(data);
350            let mime = self.location.source.mime_type().unwrap();
351            format!("data:{mime};base64,{data}")
352        } else {
353            let config = Config::current();
354            let root = config.assets_serve_location();
355            let unique_name = self.location.unique_name();
356            format!("{root}{unique_name}")
357        }
358    }
359
360    /// Returns the location of the file asset
361    pub fn location(&self) -> &FileLocation {
362        &self.location
363    }
364
365    /// Returns the options for the file asset
366    pub fn options(&self) -> &FileOptions {
367        &self.options
368    }
369
370    /// Returns the options for the file asset mutably
371    pub fn with_options_mut(&mut self, f: impl FnOnce(&mut FileOptions)) {
372        f(&mut self.options);
373        self.regenerate_unique_name();
374    }
375
376    /// Regenerates the unique name of the file asset
377    fn regenerate_unique_name(&mut self) {
378        const MAX_PATH_LENGTH: usize = 128;
379        const HASH_SIZE: usize = 16;
380
381        let manifest_dir = manifest_dir();
382        let last_segment = self
383            .location
384            .source
385            .last_segment()
386            .chars()
387            .filter(|c| c.is_alphanumeric())
388            .collect::<String>();
389        let path = manifest_dir.join(last_segment);
390        let updated = self.location.source.last_updated();
391        let extension = self
392            .options
393            .extension()
394            .map(|e| format!(".{e}"))
395            .unwrap_or_default();
396        let extension_and_hash_size = extension.len() + HASH_SIZE;
397        let mut file_name = path
398            .file_stem()
399            .unwrap()
400            .to_string_lossy()
401            .chars()
402            .filter(|c| c.is_alphanumeric())
403            .collect::<String>();
404        // If the file name is too long, we need to truncate it
405        if file_name.len() + extension_and_hash_size > MAX_PATH_LENGTH {
406            file_name = file_name[..MAX_PATH_LENGTH - extension_and_hash_size].to_string();
407        }
408        let mut hash = std::collections::hash_map::DefaultHasher::new();
409        updated.hash(&mut hash);
410        self.options.hash(&mut hash);
411        self.location.source.hash(&mut hash);
412        let uuid = hash.finish();
413        self.location.unique_name = format!("{file_name}{uuid:x}{extension}");
414        assert!(self.location.unique_name.len() <= MAX_PATH_LENGTH);
415    }
416}
417
418/// A metadata asset
419#[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Clone)]
420pub struct MetadataAsset {
421    key: String,
422    value: String,
423}
424
425impl MetadataAsset {
426    /// Creates a new metadata asset
427    pub fn new(key: &str, value: &str) -> Self {
428        Self {
429            key: key.to_string(),
430            value: value.to_string(),
431        }
432    }
433
434    /// Returns the key of the metadata asset
435    pub fn key(&self) -> &str {
436        &self.key
437    }
438
439    /// Returns the value of the metadata asset
440    pub fn value(&self) -> &str {
441        &self.value
442    }
443}
444
445/// A tailwind class asset
446#[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Clone)]
447pub struct TailwindAsset {
448    classes: String,
449}
450
451impl TailwindAsset {
452    /// Creates a new tailwind class asset
453    pub fn new(classes: &str) -> Self {
454        Self {
455            classes: classes.to_string(),
456        }
457    }
458
459    /// Returns the classes of the tailwind class asset
460    pub fn classes(&self) -> &str {
461        &self.classes
462    }
463}