rustical_frontend/
lib.rs

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