bevy_asset/io/
web.rs

1use crate::io::{AssetReader, AssetReaderError, Reader};
2use crate::io::{AssetSource, PathStream};
3use crate::{AssetApp, AssetPlugin};
4use alloc::boxed::Box;
5use bevy_app::{App, Plugin};
6use bevy_tasks::ConditionalSendFuture;
7use std::path::{Path, PathBuf};
8use tracing::warn;
9
10/// Adds the `http` and `https` asset sources to the app.
11///
12/// NOTE: Make sure to add this plugin *before* `AssetPlugin` to properly register http asset sources.
13///
14/// WARNING: be careful about where your URLs are coming from! URLs can potentially be exploited by an
15/// attacker to trigger vulnerabilities in our asset loaders, or DOS by downloading enormous files. We
16/// are not aware of any such vulnerabilities at the moment, just be careful!
17///
18/// Any asset path that begins with `http` (when the `http` feature is enabled) or `https` (when the
19/// `https` feature is enabled) will be loaded from the web via `fetch` (wasm) or `ureq` (native).
20///
21/// Example usage:
22///
23/// ```rust
24/// # use bevy_app::{App, Startup};
25/// # use bevy_ecs::prelude::{Commands, Res};
26/// # use bevy_asset::web::{WebAssetPlugin, AssetServer};
27/// # struct DefaultPlugins;
28/// # impl DefaultPlugins { fn set(plugin: WebAssetPlugin) -> WebAssetPlugin { plugin } }
29/// # use bevy_asset::web::AssetServer;
30/// # #[derive(Asset, TypePath, Default)]
31/// # struct Image;
32/// # #[derive(Component)]
33/// # struct Sprite;
34/// # impl Sprite { fn from_image(_: Handle<Image>) -> Self { Sprite } }
35/// # fn main() {
36/// App::new()
37///     .add_plugins(DefaultPlugins.set(WebAssetPlugin {
38///         silence_startup_warning: true,
39///     }))
40/// #   .add_systems(Startup, setup).run();
41/// # }
42/// // ...
43/// # fn setup(mut commands: Commands, asset_server: Res<AssetServer>) {
44/// commands.spawn(Sprite::from_image(asset_server.load("https://example.com/favicon.png")));
45/// # }
46/// ```
47///
48/// By default, `ureq`'s HTTP compression is disabled. To enable gzip and brotli decompression, add
49/// the following dependency and features to your Cargo.toml. This will improve bandwidth
50/// utilization when its supported by the server.
51///
52/// ```toml
53/// [target.'cfg(not(target_family = "wasm"))'.dev-dependencies]
54/// ureq = { version = "3", default-features = false, features = ["gzip", "brotli"] }
55/// ```
56#[derive(Default)]
57pub struct WebAssetPlugin {
58    pub silence_startup_warning: bool,
59}
60
61impl Plugin for WebAssetPlugin {
62    fn build(&self, app: &mut App) {
63        if !self.silence_startup_warning {
64            warn!("WebAssetPlugin is potentially insecure! Make sure to verify asset URLs are safe to load before loading them. \
65            If you promise you know what you're doing, you can silence this warning by setting silence_startup_warning: true \
66            in the WebAssetPlugin construction.");
67        }
68        if app.is_plugin_added::<AssetPlugin>() {
69            warn!("WebAssetPlugin must be added before AssetPlugin for it to work!");
70        }
71        #[cfg(feature = "http")]
72        app.register_asset_source(
73            "http",
74            AssetSource::build()
75                .with_reader(move || Box::new(WebAssetReader::Http))
76                .with_processed_reader(move || Box::new(WebAssetReader::Http)),
77        );
78
79        #[cfg(feature = "https")]
80        app.register_asset_source(
81            "https",
82            AssetSource::build()
83                .with_reader(move || Box::new(WebAssetReader::Https))
84                .with_processed_reader(move || Box::new(WebAssetReader::Https)),
85        );
86    }
87}
88
89/// Asset reader that treats paths as urls to load assets from.
90pub enum WebAssetReader {
91    /// Unencrypted connections.
92    Http,
93    /// Use TLS for setting up connections.
94    Https,
95}
96
97impl WebAssetReader {
98    fn make_uri(&self, path: &Path) -> PathBuf {
99        let prefix = match self {
100            Self::Http => "http://",
101            Self::Https => "https://",
102        };
103        PathBuf::from(prefix).join(path)
104    }
105
106    /// See [`io::get_meta_path`](`crate::io::get_meta_path`)
107    fn make_meta_uri(&self, path: &Path) -> PathBuf {
108        let meta_path = crate::io::get_meta_path(path);
109        self.make_uri(&meta_path)
110    }
111}
112
113#[cfg(target_arch = "wasm32")]
114async fn get<'a>(path: PathBuf) -> Result<Box<dyn Reader>, AssetReaderError> {
115    use crate::io::wasm::HttpWasmAssetReader;
116
117    HttpWasmAssetReader::new("")
118        .fetch_bytes(path)
119        .await
120        .map(|r| Box::new(r) as Box<dyn Reader>)
121}
122
123#[cfg(not(target_arch = "wasm32"))]
124async fn get(path: PathBuf) -> Result<Box<dyn Reader>, AssetReaderError> {
125    use crate::io::VecReader;
126    use alloc::{borrow::ToOwned, boxed::Box, vec::Vec};
127    use bevy_platform::sync::LazyLock;
128    use blocking::unblock;
129    use std::io::{self, BufReader, Read};
130
131    let str_path = path.to_str().ok_or_else(|| {
132        AssetReaderError::Io(
133            io::Error::other(std::format!("non-utf8 path: {}", path.display())).into(),
134        )
135    })?;
136
137    #[cfg(all(not(target_arch = "wasm32"), feature = "web_asset_cache"))]
138    if let Some(data) = web_asset_cache::try_load_from_cache(str_path).await? {
139        return Ok(Box::new(VecReader::new(data)));
140    }
141    use ureq::Agent;
142
143    static AGENT: LazyLock<Agent> = LazyLock::new(|| Agent::config_builder().build().new_agent());
144
145    let uri = str_path.to_owned();
146    // Use [`unblock`] to run the http request on a separately spawned thread as to not block bevy's
147    // async executor.
148    let response = unblock(|| AGENT.get(uri).call()).await;
149
150    match response {
151        Ok(mut response) => {
152            let mut reader = BufReader::new(response.body_mut().with_config().reader());
153
154            let mut buffer = Vec::new();
155            reader.read_to_end(&mut buffer)?;
156
157            #[cfg(all(not(target_arch = "wasm32"), feature = "web_asset_cache"))]
158            web_asset_cache::save_to_cache(str_path, &buffer).await?;
159
160            Ok(Box::new(VecReader::new(buffer)))
161        }
162        // ureq considers all >=400 status codes as errors
163        Err(ureq::Error::StatusCode(code)) => {
164            if code == 404 {
165                Err(AssetReaderError::NotFound(path))
166            } else {
167                Err(AssetReaderError::HttpError(code))
168            }
169        }
170        Err(err) => Err(AssetReaderError::Io(
171            io::Error::other(std::format!(
172                "unexpected error while loading asset {}: {}",
173                path.display(),
174                err
175            ))
176            .into(),
177        )),
178    }
179}
180
181impl AssetReader for WebAssetReader {
182    fn read<'a>(
183        &'a self,
184        path: &'a Path,
185    ) -> impl ConditionalSendFuture<Output = Result<Box<dyn Reader>, AssetReaderError>> {
186        get(self.make_uri(path))
187    }
188
189    async fn read_meta<'a>(&'a self, path: &'a Path) -> Result<Box<dyn Reader>, AssetReaderError> {
190        let uri = self.make_meta_uri(path);
191        get(uri).await
192    }
193
194    async fn is_directory<'a>(&'a self, _path: &'a Path) -> Result<bool, AssetReaderError> {
195        Ok(false)
196    }
197
198    async fn read_directory<'a>(
199        &'a self,
200        path: &'a Path,
201    ) -> Result<Box<PathStream>, AssetReaderError> {
202        Err(AssetReaderError::NotFound(self.make_uri(path)))
203    }
204}
205
206/// A naive implementation of a cache for assets downloaded from the web that never invalidates.
207/// `ureq` currently does not support caching, so this is a simple workaround.
208/// It should eventually be replaced by `http-cache` or similar, see [tracking issue](https://github.com/06chaynes/http-cache/issues/91)
209#[cfg(all(not(target_arch = "wasm32"), feature = "web_asset_cache"))]
210mod web_asset_cache {
211    use alloc::string::String;
212    use alloc::vec::Vec;
213    use core::hash::{Hash, Hasher};
214    use futures_lite::AsyncWriteExt;
215    use std::collections::hash_map::DefaultHasher;
216    use std::io;
217    use std::path::PathBuf;
218
219    use crate::io::Reader;
220
221    const CACHE_DIR: &str = ".web-asset-cache";
222
223    fn url_to_hash(url: &str) -> String {
224        let mut hasher = DefaultHasher::new();
225        url.hash(&mut hasher);
226        std::format!("{:x}", hasher.finish())
227    }
228
229    pub async fn try_load_from_cache(url: &str) -> Result<Option<Vec<u8>>, io::Error> {
230        let filename = url_to_hash(url);
231        let cache_path = PathBuf::from(CACHE_DIR).join(&filename);
232
233        if cache_path.exists() {
234            let mut file = async_fs::File::open(&cache_path).await?;
235            let mut buffer = Vec::new();
236            file.read_to_end(&mut buffer).await?;
237            Ok(Some(buffer))
238        } else {
239            Ok(None)
240        }
241    }
242
243    pub async fn save_to_cache(url: &str, data: &[u8]) -> Result<(), io::Error> {
244        let filename = url_to_hash(url);
245        let cache_path = PathBuf::from(CACHE_DIR).join(&filename);
246
247        async_fs::create_dir_all(CACHE_DIR).await.ok();
248
249        let mut cache_file = async_fs::File::create(&cache_path).await?;
250        cache_file.write_all(data).await?;
251
252        Ok(())
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn make_http_uri() {
262        assert_eq!(
263            WebAssetReader::Http
264                .make_uri(Path::new("example.com/favicon.png"))
265                .to_str()
266                .unwrap(),
267            "http://example.com/favicon.png"
268        );
269    }
270
271    #[test]
272    fn make_https_uri() {
273        assert_eq!(
274            WebAssetReader::Https
275                .make_uri(Path::new("example.com/favicon.png"))
276                .to_str()
277                .unwrap(),
278            "https://example.com/favicon.png"
279        );
280    }
281
282    #[test]
283    fn make_http_meta_uri() {
284        assert_eq!(
285            WebAssetReader::Http
286                .make_meta_uri(Path::new("example.com/favicon.png"))
287                .to_str()
288                .unwrap(),
289            "http://example.com/favicon.png.meta"
290        );
291    }
292
293    #[test]
294    fn make_https_meta_uri() {
295        assert_eq!(
296            WebAssetReader::Https
297                .make_meta_uri(Path::new("example.com/favicon.png"))
298                .to_str()
299                .unwrap(),
300            "https://example.com/favicon.png.meta"
301        );
302    }
303
304    #[test]
305    fn make_https_without_extension_meta_uri() {
306        assert_eq!(
307            WebAssetReader::Https
308                .make_meta_uri(Path::new("example.com/favicon"))
309                .to_str()
310                .unwrap(),
311            "https://example.com/favicon.meta"
312        );
313    }
314}