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#[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 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 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}