1use std::path::PathBuf;
15
16use infinite_manifest::WorldManifest;
17
18pub struct AssetSource {
20 base_dir: PathBuf,
22 base_url: Option<String>,
25 cache_dir: PathBuf,
27}
28
29impl AssetSource {
30 pub fn local(base_dir: impl Into<PathBuf>) -> Self {
33 Self {
34 base_dir: base_dir.into(),
35 base_url: None,
36 cache_dir: default_cache(),
37 }
38 }
39
40 pub fn hosted(
43 base_dir: impl Into<PathBuf>,
44 base_url: Option<String>,
45 cache_dir: impl Into<PathBuf>,
46 ) -> Self {
47 Self {
48 base_dir: base_dir.into(),
49 base_url,
50 cache_dir: cache_dir.into(),
51 }
52 }
53
54 pub fn resolve(&self, uri: &str) -> Option<PathBuf> {
58 match self.absolute_url(uri) {
59 Some(url) => self.fetch_cached(&url),
60 None => Some(self.base_dir.join(uri)),
61 }
62 }
63
64 pub fn prefetch(&self, manifest: &WorldManifest) {
67 for a in &manifest.assets {
68 if self.absolute_url(&a.uri).is_some() && self.resolve(&a.uri).is_none() {
69 tracing::warn!("asset: could not fetch '{}'", a.uri);
70 }
71 }
72 }
73
74 fn absolute_url(&self, uri: &str) -> Option<String> {
77 if uri.starts_with("http://") || uri.starts_with("https://") {
78 Some(uri.to_string())
79 } else if let Some(rest) = uri.strip_prefix("ipfs://") {
80 Some(format!("https://ipfs.io/ipfs/{rest}"))
81 } else if let Some(base) = &self.base_url {
82 join_url(base, uri)
83 } else {
84 None
85 }
86 }
87
88 fn fetch_cached(&self, url: &str) -> Option<PathBuf> {
90 let path = self.cache_dir.join(cache_name(url));
91 if path.exists() {
92 return Some(path);
93 }
94 let bytes = match http_get_bytes(url) {
95 Ok(b) => b,
96 Err(e) => {
97 tracing::warn!("asset: fetch failed for {url}: {e}");
98 return None;
99 }
100 };
101 std::fs::create_dir_all(&self.cache_dir).ok()?;
102 std::fs::write(&path, &bytes).ok()?;
103 tracing::info!("asset: fetched {} ({} KB)", url, bytes.len() / 1024);
104 Some(path)
105 }
106}
107
108fn default_cache() -> PathBuf {
110 std::env::temp_dir().join("infinite_thread_assets")
111}
112
113fn join_url(base: &str, uri: &str) -> Option<String> {
115 reqwest::Url::parse(base)
116 .ok()?
117 .join(uri)
118 .ok()
119 .map(|u| u.to_string())
120}
121
122fn cache_name(url: &str) -> String {
125 let mut h: u64 = 0xcbf29ce484222325;
126 for b in url.bytes() {
127 h ^= b as u64;
128 h = h.wrapping_mul(0x100000001b3);
129 }
130 let ext = url
131 .rsplit('/')
132 .next()
133 .and_then(|seg| seg.split(['?', '#']).next())
134 .and_then(|name| name.rsplit_once('.'))
135 .map(|(_, e)| e)
136 .filter(|e| e.len() <= 5 && e.chars().all(|c| c.is_ascii_alphanumeric()))
137 .unwrap_or("bin");
138 format!("{h:016x}.{ext}")
139}
140
141fn http_get_bytes(url: &str) -> Result<Vec<u8>, String> {
143 let rt = tokio::runtime::Builder::new_current_thread()
144 .enable_all()
145 .build()
146 .map_err(|e| e.to_string())?;
147 rt.block_on(async {
148 let client = reqwest::Client::builder()
150 .user_agent("Infinite-Thread-Browser/0.1 (+https://github.com/Pixygon/Infinite)")
151 .build()
152 .map_err(|e| e.to_string())?;
153 let resp = client.get(url).send().await.map_err(|e| e.to_string())?;
154 if !resp.status().is_success() {
155 return Err(format!("HTTP {}", resp.status().as_u16()));
156 }
157 resp.bytes()
158 .await
159 .map(|b| b.to_vec())
160 .map_err(|e| e.to_string())
161 })
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use std::path::Path;
168
169 #[test]
170 fn local_relative_uris_join_the_base_dir() {
171 let src = AssetSource::local("/worlds/gallery");
172 assert_eq!(
173 src.resolve("meshes/statue.glb"),
174 Some(PathBuf::from("/worlds/gallery/meshes/statue.glb"))
175 );
176 assert!(src.absolute_url("meshes/statue.glb").is_none());
178 }
179
180 #[test]
181 fn absolute_and_ipfs_uris_become_urls() {
182 let src = AssetSource::local("/w");
183 assert_eq!(
184 src.absolute_url("https://cdn.example/tree.glb").as_deref(),
185 Some("https://cdn.example/tree.glb")
186 );
187 assert_eq!(
188 src.absolute_url("ipfs://Qm123/model.glb").as_deref(),
189 Some("https://ipfs.io/ipfs/Qm123/model.glb")
190 );
191 }
192
193 #[test]
194 fn hosted_relative_uris_resolve_against_the_world_url() {
195 let src = AssetSource::hosted(
196 Path::new("/cache"),
197 Some("https://studio.example/.well-known/thread/gallery/".into()),
198 "/cache",
199 );
200 assert_eq!(
201 src.absolute_url("meshes/statue.glb").as_deref(),
202 Some("https://studio.example/.well-known/thread/gallery/meshes/statue.glb")
203 );
204 }
205
206 #[test]
212 #[ignore = "requires network"]
213 fn a_remote_gltf_fetches_once_and_caches() {
214 const URL: &str = "https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/Box/glTF-Binary/Box.glb";
216 let cache = std::env::temp_dir().join(format!("loom-remote-asset-{}", std::process::id()));
217 let _ = std::fs::remove_dir_all(&cache);
218 let src = AssetSource::hosted(Path::new("/nowhere"), None, &cache);
219
220 let manifest: WorldManifest = serde_json::from_value(serde_json::json!({
222 "thread": "0.1", "world": { "id": "remote-test", "title": "Remote" },
223 "assets": [{ "id": "box", "uri": URL, "kind": "gltf" }]
224 }))
225 .unwrap();
226 src.prefetch(&manifest);
227
228 let path = src
229 .resolve(URL)
230 .expect("remote glb resolves to a local path");
231 let bytes = std::fs::read(&path).expect("cached file is readable");
232 assert_eq!(
233 &bytes[..4],
234 b"glTF",
235 "the cached bytes are a real binary glTF"
236 );
237
238 let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
240 assert_eq!(src.resolve(URL).as_deref(), Some(path.as_path()));
241 assert_eq!(
242 std::fs::metadata(&path).unwrap().modified().unwrap(),
243 mtime,
244 "not re-fetched"
245 );
246
247 let _ = std::fs::remove_dir_all(&cache);
248 }
249
250 #[test]
251 fn cache_names_are_stable_and_keep_the_extension() {
252 let a = cache_name("https://cdn/tree.glb");
253 assert_eq!(a, cache_name("https://cdn/tree.glb"), "deterministic");
254 assert!(a.ends_with(".glb"));
255 assert!(
256 cache_name("https://cdn/img.png?v=2").ends_with(".png"),
257 "strips query"
258 );
259 assert!(
260 cache_name("https://cdn/noext").ends_with(".bin"),
261 "falls back"
262 );
263 assert_ne!(
264 cache_name("https://cdn/a.glb"),
265 cache_name("https://cdn/b.glb")
266 );
267 }
268}