summaryrefslogtreecommitdiff
path: root/src/ui/assets.rs
blob: 2fa703b8ba9b4fd4ed66e03d2bf6e7358253c7c6 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
use std::{str::FromStr, time::Duration};

use ::mime::Mime;
use axum::{
    http::StatusCode,
    response::{self, IntoResponse, IntoResponseParts, ResponseParts},
};
use axum_extra::TypedHeader;
use headers::{CacheControl, ContentType, ETag, IfNoneMatch};
use rust_embed::EmbeddedFile;

use super::{error::NotFound, mime};
use crate::error::{
    Internal,
    failed::{Failed, ResultExt},
};

#[derive(rust_embed::Embed)]
#[folder = "$OUT_DIR/ui"]
pub struct Assets;

// Prefer the corresponding methods in `Response` unless you specifically do not want HTTP caching
// behaviours.
impl Assets {
    pub fn load(path: &str) -> Result<Asset, Error> {
        let mime = mime::from_path(path)
            .fail_with(|| format!("Failed to determine MIME type from asset path: {path}"))?;
        let file = Self::get(path).ok_or(Error::NotFound(path.into()))?;
        let asset = Asset::new(mime, file)?;

        Ok(asset)
    }

    pub fn index() -> Result<Asset, Internal> {
        // "not found" in this case really is an internal error, as it should
        // never happen. `index.html` is a known-valid path with a known-valid
        // file extension.
        Ok(Self::load("index.html")?)
    }
}

pub struct Asset {
    mime: Mime,
    file: EmbeddedFile,
    etag: ETag,
}

impl Asset {
    fn new(mime: Mime, file: EmbeddedFile) -> Result<Self, Failed> {
        let etag = Self::etag_from(&file).fail("Failed to compute ETag")?;
        Ok(Self { mime, file, etag })
    }

    fn etag_from(file: &EmbeddedFile) -> Result<ETag, <ETag as FromStr>::Err> {
        let digest = file.metadata.sha256_hash();
        let digest = hex::encode(digest);

        let tag = format!("\"{digest}\"");
        let tag = ETag::from_str(&tag)?;

        Ok(tag)
    }

    fn differs_from(&self, if_none_match: &IfNoneMatch) -> bool {
        if_none_match.precondition_passes(&self.etag)
    }
}

impl IntoResponse for Asset {
    fn into_response(self) -> response::Response {
        let Self { mime, file, etag } = self;
        (
            StatusCode::OK,
            TypedHeader(ContentType::from(mime)),
            TypedHeader(etag),
            file.data,
        )
            .into_response()
    }
}

// Clippy warns us here because the `Asset` variant is the size of, well, the Asset struct - itself
// quite large due to the embedded file metadata. There isn't a better alternative that I can see,
// and it's not causing any performance or stack exhaustion problems in practice.
#[expect(clippy::large_enum_variant)]
pub enum Response {
    NotModified,
    // I've opted to make Mutability part of the conditional-HTTP-response logic here and not part
    // of the underlying Asset struct, even though it would be pretty reasonable to deem that it is
    // the Asset that is immutable rather than its specific binding to an HTTP result. Assets don't
    // know their own path, and there isn't a tidy way to tag that information onto Asset at
    // construction without refactoring a _ton_ of things that are fine the way they are, though,
    // and since mutability only affects an HTTP header and nothing else, the resulting structs
    // work well enough.
    Asset(Asset, Mutability),
}

// This interface parallels the `Assets` interface, above. However, unlike `Assets`, this supports
// browser and intermediate server cache mechanisms, both by considering the provided
// `If-None-Match` header against each asset's `ETag`, and by tacking on cache control headers for
// static assets that we commit to never changing.
impl Response {
    pub fn load(path: &str, if_none_match: &IfNoneMatch) -> Result<Self, Error> {
        let mutability = Mutability::from_path(path);
        let asset = Assets::load(path)?;
        let response = Self::respond_with(asset, if_none_match, mutability);
        Ok(response)
    }

    pub fn index(if_none_match: &IfNoneMatch) -> Result<Self, Internal> {
        let asset = Assets::index()?;
        let response = Self::respond_with(asset, if_none_match, Mutability::Mutable);
        Ok(response)
    }

    fn respond_with(asset: Asset, if_none_match: &IfNoneMatch, mutability: Mutability) -> Self {
        if asset.differs_from(if_none_match) {
            Self::Asset(asset, mutability)
        } else {
            Self::NotModified
        }
    }
}

impl IntoResponse for Response {
    fn into_response(self) -> response::Response {
        match self {
            Self::NotModified => StatusCode::NOT_MODIFIED.into_response(),
            Self::Asset(asset, mutability) => (mutability, asset).into_response(),
        }
    }
}

pub enum Mutability {
    Immutable,
    Mutable,
}

impl Mutability {
    fn from_path(path: &str) -> Self {
        // This is a complete hack and depends intimately on the output of SvelteKit's static site
        // adapter in ways that are not documented anywhere I can find. On the other hand, I also
        // can't find any sign of a better way to do this, and I think the odds of us (or SvelteKit)
        // messing with these paths in a way that breaks this cacheability check are pretty low.
        if path.starts_with("_app/immutable/") {
            Self::Immutable
        } else {
            Self::Mutable
        }
    }
}

impl IntoResponseParts for Mutability {
    type Error = <TypedHeader<CacheControl> as IntoResponseParts>::Error;

    fn into_response_parts(self, res: ResponseParts) -> Result<ResponseParts, Self::Error> {
        // 90 days is pretty arbitrary. Everything _works_ if immutable assets get
        // re-requested more frequently than this (or even on every page view), but it eats
        // up bandwidth and battery to transfer them, and they only ever change to and from
        // "404 Not Found" (which we do not instruct browsers to cache). If it was free to
        // cache them forever, we'd ask for that.
        //
        // If a user doesn't visit the app for 90 days, they're probably better off re-downloading
        // the whole thing if they ever come back, and better off getting that cache space back for
        // something else, until then.
        match self {
            Self::Immutable => TypedHeader(
                CacheControl::new()
                    .with_immutable()
                    .with_max_age(Duration::from_hours(90 * 24)),
            )
            .into_response_parts(res),
            Self::Mutable => ().into_response_parts(res),
        }
    }
}

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("not found: {0}")]
    NotFound(String),
    #[error(transparent)]
    Failed(#[from] Failed),
}

impl IntoResponse for Error {
    fn into_response(self) -> response::Response {
        match self {
            Self::NotFound(_) => NotFound(self.to_string()).into_response(),
            Self::Failed(_) => Internal::from(self).into_response(),
        }
    }
}