summaryrefslogtreecommitdiff
path: root/src/ui/assets.rs
blob: b8ca00b70528fead1572cdf69816e111e6b2d5e3 (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
use std::str::FromStr;

use ::mime::Mime;
use axum::{
    http::StatusCode,
    response::{self, IntoResponse},
};
use axum_extra::TypedHeader;
use headers::{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;

impl Assets {
    pub fn load(path: impl AsRef<str>) -> Result<Asset, Error> {
        let path = path.as_ref();
        let mime = mime::from_path(path).fail("Failed to determine MIME type from asset 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)
    }

    pub 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,
    Asset(Asset),
}

impl Response {
    pub fn load(path: impl AsRef<str>, if_none_match: &IfNoneMatch) -> Result<Self, Error> {
        let asset = Assets::load(path)?;
        let response = Self::respond_with(asset, if_none_match);
        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);
        Ok(response)
    }

    fn respond_with(asset: Asset, if_none_match: &IfNoneMatch) -> Self {
        if asset.differs_from(if_none_match) {
            Self::Asset(asset)
        } 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) => asset.into_response(),
        }
    }
}

#[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(),
        }
    }
}