derived_cms/
app.rs

1use std::{convert::Infallible, path::PathBuf, sync::Arc};
2
3use axum::{
4    extract::{DefaultBodyLimit, Request, State},
5    http::{header::CONTENT_TYPE, HeaderMap, HeaderValue, StatusCode},
6    middleware::{self, Next},
7    response::{IntoResponse, Response},
8    routing::{get, post},
9    Extension, Router,
10};
11use derive_more::Debug;
12use i18n_embed::{
13    fluent::{fluent_language_loader, FluentLanguageLoader},
14    AssetsMultiplexor, I18nAssets,
15};
16use include_dir::{include_dir, Dir, DirEntry};
17use rust_embed::RustEmbed;
18use tower_http::services::ServeDir;
19use tracing::error;
20use unic_langid::LanguageIdentifier;
21
22use crate::{
23    context::{Context, ContextExt},
24    easymde::EditorConfig,
25    endpoints::{
26        entity_routes,
27        ui::{parse_mde_upload, UploadDir},
28    },
29    entity::Entity,
30    render,
31};
32
33static STATIC_ASSETS: Dir = include_dir!("$CARGO_MANIFEST_DIR/static");
34
35#[derive(RustEmbed)]
36#[folder = "i18n/"]
37struct Localizations;
38
39/// build an [`axum::Router`] with all routes required for API and admin interface
40#[derive(Debug)]
41pub struct App<S, E>
42where
43    S: ContextExt<Context<S>>,
44{
45    router: Router<Context<S>>,
46    names_plural: Vec<&'static str>,
47    editor_config: Option<EditorConfig>,
48    state_ext: E,
49    #[debug(skip)]
50    localizations: Vec<Box<dyn I18nAssets + Send + Sync + 'static>>,
51}
52
53impl<S> Default for App<S, ()>
54where
55    S: ContextExt<Context<S>> + 'static,
56{
57    fn default() -> Self {
58        Self {
59            router: Default::default(),
60            names_plural: Default::default(),
61            editor_config: None,
62            state_ext: Default::default(),
63            localizations: Vec::new(),
64        }
65    }
66}
67
68impl<S> App<S, ()>
69where
70    S: ContextExt<Context<S>> + 'static,
71{
72    pub fn new() -> Self {
73        Self::default()
74    }
75}
76
77impl<S, SE> App<S, SE>
78where
79    S: ContextExt<Context<S>> + 'static,
80{
81    pub fn entity<E: Entity<Context<S>> + Send + Sync>(mut self) -> Self {
82        self.names_plural.push(E::name_plural());
83        self.router = self.router.merge(entity_routes::<E, Context<S>>());
84        self
85    }
86}
87
88impl<S, SE> App<S, SE>
89where
90    S: ContextExt<Context<S>> + 'static,
91{
92    pub fn with_mdeditor(mut self, config: EditorConfig) -> Self {
93        self.editor_config = Some(config);
94        self
95    }
96}
97
98impl<S, E> App<S, E>
99where
100    S: ContextExt<Context<S>> + 'static,
101{
102    pub fn with_state(self, data: S) -> App<S, S> {
103        App {
104            router: self.router,
105            names_plural: self.names_plural,
106            editor_config: self.editor_config,
107            state_ext: data,
108            localizations: self.localizations,
109        }
110    }
111}
112
113impl<S, E> App<S, E>
114where
115    S: ContextExt<Context<S>> + 'static,
116{
117    //! Include the given assets when loading the localized messages
118    //! upon a request.
119    //! They can then be accessed in any render functions called by
120    //! this library (e.g. [`Column::render`](crate::column::Column::render)
121    //! or [`Input::render_input`](crate::input::Input::render_input)).
122    //! Note: The domain in these functions will still be `"derived_cms"`,
123    //! so make sure to add the localized messages to the correct domain
124    //! file in the assets.
125    pub fn include_localizations(
126        self,
127        assets: impl I18nAssets + Send + Sync + 'static,
128    ) -> App<S, E> {
129        let mut localizations = self.localizations;
130        localizations.push(Box::new(assets));
131        App {
132            localizations,
133            ..self
134        }
135    }
136}
137
138impl<S> App<S, S>
139where
140    S: ContextExt<Context<S>> + 'static,
141{
142    pub fn build(self, uploads_dir: impl Into<PathBuf>) -> Router {
143        let uploads_dir = uploads_dir.into();
144
145        let mut localizations = self.localizations;
146        localizations.push(Box::new(Localizations));
147        let localizations = Arc::new(AssetsMultiplexor::new(localizations));
148
149        let mut router = self
150            .router
151            .nest_service("/uploads", ServeDir::new(&uploads_dir))
152            .with_state(Context {
153                names_plural: self.names_plural,
154                editor_config: self.editor_config.clone(),
155                uploads_dir: uploads_dir.clone(),
156                ext: self.state_ext,
157            })
158            .layer(middleware::from_fn(|mut req: Request, next: Next| {
159                // add extension `()` to prevent HTTP 500 response when using default/derived impl of `EntityHooks`.
160                req.extensions_mut().insert(());
161                next.run(req)
162            }))
163            .layer(middleware::from_fn_with_state(localizations, localize))
164            .merge(include_static_files(&STATIC_ASSETS));
165        if let Some(editor_config) = self.editor_config.filter(|config| config.enable_uploads) {
166            router = router.route(
167                "/upload",
168                post(parse_mde_upload)
169                    .layer::<_, Infallible>(DefaultBodyLimit::max(editor_config.upload_max_size))
170                    .layer::<_, Infallible>(Extension(editor_config))
171                    .layer(Extension(UploadDir(uploads_dir))),
172            );
173        }
174
175        router
176    }
177}
178
179async fn localize(
180    State(localizations): State<Arc<AssetsMultiplexor>>,
181    mut req: Request,
182    next: Next,
183) -> Response {
184    let langs = req
185        .headers()
186        .get(axum::http::header::ACCEPT_LANGUAGE)
187        .and_then(|v| v.to_str().ok())
188        .map(accept_language::parse)
189        .unwrap_or_default()
190        .into_iter()
191        .filter_map(|lang| lang.parse::<LanguageIdentifier>().ok())
192        .collect::<Vec<_>>();
193    let language_loader: FluentLanguageLoader = fluent_language_loader!();
194    i18n_embed::select(&language_loader, &*localizations, &langs).unwrap();
195    req.extensions_mut().insert(Arc::new(language_loader));
196    next.run(req).await
197}
198
199pub fn include_static_files<S: Clone + Send + Sync + 'static>(dir: &'static Dir<'_>) -> Router<S> {
200    let mut app = Router::<S>::new();
201    for v in dir.entries() {
202        match v {
203            DirEntry::Dir(d) => app = app.merge(include_static_files(d)),
204            DirEntry::File(f) => {
205                if let Some(path) = f.path().to_str() {
206                    let mime = mime_guess::from_path(path)
207                        .first_or_octet_stream()
208                        .to_string();
209                    let headers = HeaderMap::from_iter([(
210                        CONTENT_TYPE,
211                        HeaderValue::from_str(&mime).unwrap(),
212                    )]);
213                    app = app.route(
214                        &format!("/{path}"),
215                        get(move || async move { (headers, f.contents()) }),
216                    )
217                }
218            }
219        }
220    }
221    app
222}
223
224pub struct AppError {
225    pub title: String,
226    pub description: String,
227}
228
229impl From<()> for AppError {
230    fn from(_value: ()) -> Self {
231        Self {
232            title: "Infallible".to_string(),
233            description: "Infallible".to_string(),
234        }
235    }
236}
237
238impl AppError {
239    pub fn new(title: String, description: String) -> Self {
240        Self { title, description }
241    }
242}
243
244impl IntoResponse for AppError {
245    fn into_response(self) -> Response {
246        error!("{}: {}", self.title, self.description);
247        (
248            StatusCode::BAD_REQUEST,
249            render::error_page(&self.title, &self.description),
250        )
251            .into_response()
252    }
253}