rustical_frontend/
lib.rs

1use axum::{
2    Extension, RequestExt, Router,
3    body::Body,
4    extract::{OriginalUri, Request},
5    middleware::{self, Next},
6    response::{Redirect, Response},
7    routing::{get, post},
8};
9use headers::{ContentType, HeaderMapExt};
10use http::{Method, StatusCode};
11use routes::{addressbooks::route_addressbooks, calendars::route_calendars};
12use rustical_oidc::{OidcConfig, OidcServiceConfig, route_get_oidc_callback, route_post_oidc};
13use rustical_store::{
14    AddressbookStore, CalendarStore,
15    auth::{AuthenticationProvider, middleware::AuthenticationLayer},
16};
17use std::sync::Arc;
18use url::Url;
19
20mod assets;
21mod config;
22pub mod nextcloud_login;
23mod oidc_user_store;
24pub(crate) mod pages;
25mod routes;
26
27pub use config::FrontendConfig;
28use oidc_user_store::OidcUserStore;
29
30use crate::routes::{
31    addressbook::{route_addressbook, route_addressbook_restore},
32    app_token::{route_delete_app_token, route_post_app_token},
33    calendar::{route_calendar, route_calendar_restore},
34    login::{route_get_login, route_post_login, route_post_logout},
35    user::{route_get_home, route_root, route_user_named},
36};
37#[cfg(not(feature = "dev"))]
38use assets::{Assets, EmbedService};
39
40pub fn frontend_router<AP: AuthenticationProvider, CS: CalendarStore, AS: AddressbookStore>(
41    prefix: &'static str,
42    auth_provider: Arc<AP>,
43    cal_store: Arc<CS>,
44    addr_store: Arc<AS>,
45    frontend_config: FrontendConfig,
46    oidc_config: Option<OidcConfig>,
47) -> Router {
48    let user_router = Router::new()
49        .route("/", get(route_get_home))
50        .route("/{user}", get(route_user_named::<CS, AS, AP>))
51        // App token management
52        .route("/{user}/app_token", post(route_post_app_token::<AP>))
53        .route(
54            // POST because HTML5 forms don't support DELETE method
55            "/{user}/app_token/{id}/delete",
56            post(route_delete_app_token::<AP>),
57        )
58        // Calendar
59        .route("/{user}/calendar", get(route_calendars::<CS>))
60        .route("/{user}/calendar/{calendar}", get(route_calendar::<CS>))
61        .route(
62            "/{user}/calendar/{calendar}/restore",
63            post(route_calendar_restore::<CS>),
64        )
65        // Addressbook
66        .route("/{user}/addressbook", get(route_addressbooks::<AS>))
67        .route(
68            "/{user}/addressbook/{addressbook}",
69            get(route_addressbook::<AS>),
70        )
71        .route(
72            "/{user}/addressbook/{addressbook}/restore",
73            post(route_addressbook_restore::<AS>),
74        )
75        .layer(middleware::from_fn(unauthorized_handler));
76
77    let router = Router::new()
78        .route("/", get(route_root))
79        .nest("/user", user_router)
80        .route("/login", get(route_get_login).post(route_post_login::<AP>))
81        .route("/logout", post(route_post_logout));
82
83    #[cfg(not(feature = "dev"))]
84    let mut router = router.route_service("/assets/{*file}", EmbedService::<Assets>::default());
85    #[cfg(feature = "dev")]
86    let mut router = router.nest_service(
87        "/assets",
88        tower_http::services::ServeDir::new(concat!(env!("CARGO_MANIFEST_DIR"), "/public/assets")),
89    );
90
91    if let Some(oidc_config) = oidc_config.clone() {
92        router = router
93            .route("/login/oidc", post(route_post_oidc))
94            .route(
95                "/login/oidc/callback",
96                get(route_get_oidc_callback::<OidcUserStore<AP>>),
97            )
98            .layer(Extension(OidcUserStore(auth_provider.clone())))
99            .layer(Extension(OidcServiceConfig {
100                default_redirect_path: "/frontend/user",
101                session_key_user_id: "user",
102            }))
103            .layer(Extension(oidc_config));
104    }
105
106    router = router
107        .layer(AuthenticationLayer::new(auth_provider.clone()))
108        .layer(Extension(auth_provider.clone()))
109        .layer(Extension(cal_store.clone()))
110        .layer(Extension(addr_store.clone()))
111        .layer(Extension(frontend_config.clone()))
112        .layer(Extension(oidc_config.clone()));
113
114    Router::new()
115        .nest(prefix, router)
116        .route("/", get(async || Redirect::to(prefix)))
117}
118
119async fn unauthorized_handler(mut request: Request, next: Next) -> Response {
120    let meth = request.method().clone();
121    let OriginalUri(uri) = request.extract_parts().await.unwrap();
122    let resp = next.run(request).await;
123    if resp.status() == StatusCode::UNAUTHORIZED {
124        // This is a dumb hack since parsed Urls cannot be relative
125        let mut login_url: Url = "http://github.com/frontend/login".parse().unwrap();
126        if meth == Method::GET {
127            login_url
128                .query_pairs_mut()
129                .append_pair("redirect_uri", uri.path());
130        }
131        let path = login_url.path();
132        let query = login_url
133            .query()
134            .map(|query| format!("?{query}"))
135            .unwrap_or_default();
136        let login_url = format!("{path}{query}");
137        let mut resp = Response::builder().status(StatusCode::UNAUTHORIZED);
138        let hdrs = resp.headers_mut().unwrap();
139        hdrs.typed_insert(ContentType::html());
140        return resp
141            .body(Body::new(format!(
142                r#"<!Doctype html>
143<html>
144    <head>
145        <meta http-equiv="refresh" content="1; url={login_url}" />
146    </head>
147    <body>
148        Unauthorized, redirecting to <a href="{login_url}">login page</a>
149    </body>
150</html>"#,
151            )))
152            .unwrap();
153    }
154    resp
155}