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#[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Clone)]
17pub enum AssetType {
18 File(FileAsset),
20 Tailwind(TailwindAsset),
22 Metadata(MetadataAsset),
24}
25
26#[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Clone, Hash)]
28pub enum FileSource {
29 Local(PathBuf),
31 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 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 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 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 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
125fn 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
155fn 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
175fn get_mime_by_ext(trimmed: &Path) -> &'static str {
177 get_mime_from_ext(trimmed.extension().and_then(|e| e.to_str()))
178}
179
180pub 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 Some(_) => "text/html",
203 None => "application/octet-stream",
206 }
207}
208
209#[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Clone)]
211pub struct FileLocation {
212 unique_name: String,
213 source: FileSource,
214}
215
216impl FileLocation {
217 pub fn unique_name(&self) -> &str {
219 &self.unique_name
220 }
221
222 pub fn source(&self) -> &FileSource {
224 &self.source
225 }
226
227 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 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#[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 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 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 pub fn set_url_encoded(&mut self, url_encoded: bool) {
337 self.url_encoded = url_encoded;
338 }
339
340 pub fn url_encoded(&self) -> bool {
342 self.url_encoded
343 }
344
345 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 pub fn location(&self) -> &FileLocation {
362 &self.location
363 }
364
365 pub fn options(&self) -> &FileOptions {
367 &self.options
368 }
369
370 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 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 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#[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Clone)]
420pub struct MetadataAsset {
421 key: String,
422 value: String,
423}
424
425impl MetadataAsset {
426 pub fn new(key: &str, value: &str) -> Self {
428 Self {
429 key: key.to_string(),
430 value: value.to_string(),
431 }
432 }
433
434 pub fn key(&self) -> &str {
436 &self.key
437 }
438
439 pub fn value(&self) -> &str {
441 &self.value
442 }
443}
444
445#[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Clone)]
447pub struct TailwindAsset {
448 classes: String,
449}
450
451impl TailwindAsset {
452 pub fn new(classes: &str) -> Self {
454 Self {
455 classes: classes.to_string(),
456 }
457 }
458
459 pub fn classes(&self) -> &str {
461 &self.classes
462 }
463}