use std::{
hash::{Hash, Hasher},
path::{Path, PathBuf},
str::FromStr,
};
use anyhow::Context;
use base64::Engine;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::{cache::manifest_dir, Config, FileOptions};
#[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Clone)]
pub enum AssetType {
File(FileAsset),
Tailwind(TailwindAsset),
Metadata(MetadataAsset),
}
#[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Clone, Hash)]
pub enum FileSource {
Local(PathBuf),
Remote(Url),
}
impl FileSource {
pub fn last_segment(&self) -> &str {
match self {
Self::Local(path) => path.file_name().unwrap().to_str().unwrap(),
Self::Remote(url) => url.path_segments().unwrap().last().unwrap(),
}
}
pub fn extension(&self) -> Option<String> {
match self {
Self::Local(path) => path.extension().map(|e| e.to_str().unwrap().to_string()),
Self::Remote(url) => reqwest::blocking::get(url.as_str())
.ok()
.and_then(|request| {
request
.headers()
.get("content-type")
.and_then(|content_type| {
content_type
.to_str()
.ok()
.map(|ty| ext_of_mime(ty).to_string())
})
}),
}
}
pub fn mime_type(&self) -> Option<String> {
match self {
Self::Local(path) => get_mime_from_path(path).ok().map(|mime| mime.to_string()),
Self::Remote(url) => reqwest::blocking::get(url.as_str())
.ok()
.and_then(|request| {
request
.headers()
.get("content-type")
.and_then(|content_type| Some(content_type.to_str().ok()?.to_string()))
}),
}
}
pub fn last_updated(&self) -> Option<String> {
match self {
Self::Local(path) => path.metadata().ok().and_then(|metadata| {
metadata
.modified()
.ok()
.map(|modified| format!("{:?}", modified))
.or_else(|| {
metadata
.created()
.ok()
.map(|created| format!("{:?}", created))
})
}),
Self::Remote(url) => reqwest::blocking::get(url.as_str())
.ok()
.and_then(|request| {
request
.headers()
.get("last-modified")
.and_then(|last_modified| {
last_modified
.to_str()
.ok()
.map(|last_modified| last_modified.to_string())
})
}),
}
}
}
fn ext_of_mime(mime: &str) -> &str {
let mime = mime.split(';').next().unwrap_or_default();
match mime.trim() {
"application/octet-stream" => "bin",
"text/css" => "css",
"text/csv" => "csv",
"text/html" => "html",
"image/vnd.microsoft.icon" => "ico",
"text/javascript" => "js",
"application/json" => "json",
"application/ld+json" => "jsonld",
"application/rtf" => "rtf",
"image/svg+xml" => "svg",
"video/mp4" => "mp4",
"text/plain" => "txt",
"application/xml" => "xml",
"application/zip" => "zip",
"image/png" => "png",
"image/jpeg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
"image/avif" => "avif",
"font/ttf" => "ttf",
"font/woff" => "woff",
"font/woff2" => "woff2",
other => other.split('/').last().unwrap_or_default(),
}
}
fn get_mime_from_path(trimmed: &Path) -> std::io::Result<&'static str> {
if trimmed.extension().is_some_and(|ext| ext == "svg") {
return Ok("image/svg+xml");
}
let res = match infer::get_from_path(trimmed)?.map(|f| f.mime_type()) {
Some(f) => {
if f == "text/plain" {
get_mime_by_ext(trimmed)
} else {
f
}
}
None => get_mime_by_ext(trimmed),
};
Ok(res)
}
fn get_mime_by_ext(trimmed: &Path) -> &'static str {
get_mime_from_ext(trimmed.extension().and_then(|e| e.to_str()))
}
pub fn get_mime_from_ext(extension: Option<&str>) -> &'static str {
match extension {
Some("bin") => "application/octet-stream",
Some("css") => "text/css",
Some("csv") => "text/csv",
Some("html") => "text/html",
Some("ico") => "image/vnd.microsoft.icon",
Some("js") => "text/javascript",
Some("json") => "application/json",
Some("jsonld") => "application/ld+json",
Some("mjs") => "text/javascript",
Some("rtf") => "application/rtf",
Some("svg") => "image/svg+xml",
Some("mp4") => "video/mp4",
Some("png") => "image/png",
Some("jpg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("avif") => "image/avif",
Some("txt") => "text/plain",
Some(_) => "text/html",
None => "application/octet-stream",
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Clone)]
pub struct FileLocation {
unique_name: String,
source: FileSource,
}
impl FileLocation {
pub fn unique_name(&self) -> &str {
&self.unique_name
}
pub fn source(&self) -> &FileSource {
&self.source
}
pub fn read_to_string(&self) -> anyhow::Result<String> {
match &self.source {
FileSource::Local(path) => Ok(std::fs::read_to_string(path).with_context(|| {
format!("Failed to read file from location: {}", path.display())
})?),
FileSource::Remote(url) => {
let response = reqwest::blocking::get(url.as_str())
.with_context(|| format!("Failed to asset from url: {}", url.as_str()))?;
Ok(response.text().with_context(|| {
format!("Failed to read text for asset from url: {}", url.as_str())
})?)
}
}
}
pub fn read_to_bytes(&self) -> anyhow::Result<Vec<u8>> {
match &self.source {
FileSource::Local(path) => Ok(std::fs::read(path).with_context(|| {
format!("Failed to read file from location: {}", path.display())
})?),
FileSource::Remote(url) => {
let response = reqwest::blocking::get(url.as_str())
.with_context(|| format!("Failed to asset from url: {}", url.as_str()))?;
Ok(response.bytes().map(|b| b.to_vec()).with_context(|| {
format!("Failed to read text for asset from url: {}", url.as_str())
})?)
}
}
}
}
impl FromStr for FileSource {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match Url::parse(s) {
Ok(url) => Ok(Self::Remote(url)),
Err(_) => {
let manifest_dir = manifest_dir();
let path = manifest_dir.join(PathBuf::from(s));
let path = path
.canonicalize()
.with_context(|| format!("Failed to canonicalize path: {}", path.display()))?;
Ok(Self::Local(path))
}
}
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Clone)]
pub struct FileAsset {
location: FileLocation,
options: FileOptions,
url_encoded: bool,
}
impl FileAsset {
pub fn new(source: FileSource) -> Self {
let options = FileOptions::default_for_extension(source.extension().as_deref());
let mut myself = Self {
location: FileLocation {
unique_name: Default::default(),
source,
},
options,
url_encoded: false,
};
myself.regenerate_unique_name();
myself
}
pub fn with_options(self, options: FileOptions) -> Self {
let mut myself = Self {
location: self.location,
options,
url_encoded: false,
};
myself.regenerate_unique_name();
myself
}
pub fn set_url_encoded(&mut self, url_encoded: bool) {
self.url_encoded = url_encoded;
}
pub fn url_encoded(&self) -> bool {
self.url_encoded
}
pub fn served_location(&self) -> String {
if self.url_encoded {
let data = self.location.read_to_bytes().unwrap();
let data = base64::engine::general_purpose::STANDARD_NO_PAD.encode(data);
let mime = self.location.source.mime_type().unwrap();
format!("data:{mime};base64,{data}")
} else {
let config = Config::current();
let root = config.assets_serve_location();
let unique_name = self.location.unique_name();
format!("{root}{unique_name}")
}
}
pub fn location(&self) -> &FileLocation {
&self.location
}
pub fn options(&self) -> &FileOptions {
&self.options
}
pub fn with_options_mut(&mut self, f: impl FnOnce(&mut FileOptions)) {
f(&mut self.options);
self.regenerate_unique_name();
}
fn regenerate_unique_name(&mut self) {
const MAX_PATH_LENGTH: usize = 128;
const HASH_SIZE: usize = 16;
let manifest_dir = manifest_dir();
let last_segment = self
.location
.source
.last_segment()
.chars()
.filter(|c| c.is_alphanumeric())
.collect::<String>();
let path = manifest_dir.join(last_segment);
let updated = self.location.source.last_updated();
let extension = self
.options
.extension()
.map(|e| format!(".{e}"))
.unwrap_or_default();
let extension_and_hash_size = extension.len() + HASH_SIZE;
let mut file_name = path
.file_stem()
.unwrap()
.to_string_lossy()
.chars()
.filter(|c| c.is_alphanumeric())
.collect::<String>();
if file_name.len() + extension_and_hash_size > MAX_PATH_LENGTH {
file_name = file_name[..MAX_PATH_LENGTH - extension_and_hash_size].to_string();
}
let mut hash = std::collections::hash_map::DefaultHasher::new();
updated.hash(&mut hash);
self.options.hash(&mut hash);
self.location.source.hash(&mut hash);
let uuid = hash.finish();
self.location.unique_name = format!("{file_name}{uuid:x}{extension}");
assert!(self.location.unique_name.len() <= MAX_PATH_LENGTH);
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Clone)]
pub struct MetadataAsset {
key: String,
value: String,
}
impl MetadataAsset {
pub fn new(key: &str, value: &str) -> Self {
Self {
key: key.to_string(),
value: value.to_string(),
}
}
pub fn key(&self) -> &str {
&self.key
}
pub fn value(&self) -> &str {
&self.value
}
}
#[derive(Serialize, Deserialize, Debug, PartialEq, PartialOrd, Clone)]
pub struct TailwindAsset {
classes: String,
}
impl TailwindAsset {
pub fn new(classes: &str) -> Self {
Self {
classes: classes.to_string(),
}
}
pub fn classes(&self) -> &str {
&self.classes
}
}