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, PrefixedCalendarStore,
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<
43 AP: AuthenticationProvider,
44 CS: CalendarStore,
45 AS: AddressbookStore + PrefixedCalendarStore,
46>(
47 prefix: &'static str,
48 auth_provider: Arc<AP>,
49 cal_store: Arc<CS>,
50 addr_store: Arc<AS>,
51 frontend_config: FrontendConfig,
52 oidc_config: Option<OidcConfig>,
53) -> Router {
54 let user_router = Router::new()
55 .route("/", get(route_get_home))
56 .route("/{user}", get(route_user_named::<AP>))
57 .route("/{user}/app_token", post(route_post_app_token::<AP>))
59 .route(
60 "/{user}/app_token/{id}/delete",
62 post(route_delete_app_token::<AP>),
63 )
64 .route("/{user}/calendar", get(route_calendars::<CS>))
66 .route("/{user}/calendar/{calendar}", get(route_calendar::<CS>))
67 .route(
68 "/{user}/calendar/{calendar}/restore",
69 post(route_calendar_restore::<CS>),
70 )
71 .route("/{user}/addressbook", get(route_addressbooks::<AS>))
73 .route(
74 "/{user}/addressbook/{addressbook}",
75 get(route_addressbook::<AS>),
76 )
77 .route(
78 "/{user}/addressbook/{addressbook}/restore",
79 post(route_addressbook_restore::<AS>),
80 )
81 .layer(middleware::from_fn(unauthorized_handler));
82
83 let router = Router::new()
84 .route("/", get(route_root))
85 .nest("/user", user_router)
86 .route("/login", get(route_get_login).post(route_post_login::<AP>))
87 .route("/logout", post(route_post_logout))
88 .route(
89 "/_timezones.json",
90 get(route_timezones).head(route_timezones),
91 );
92
93 #[cfg(not(feature = "dev"))]
94 let mut router = router.route_service("/assets/{*file}", EmbedService::<Assets>::default());
95 #[cfg(feature = "dev")]
96 let mut router = router.nest_service(
97 "/assets",
98 tower_http::services::ServeDir::new(concat!(env!("CARGO_MANIFEST_DIR"), "/public/assets")),
99 );
100
101 if let Some(oidc_config) = oidc_config.clone() {
102 router = router
103 .route("/login/oidc", post(route_post_oidc))
104 .route(
105 "/login/oidc/callback",
106 get(route_get_oidc_callback::<OidcUserStore<AP>>),
107 )
108 .layer(Extension(OidcUserStore(auth_provider.clone())))
109 .layer(Extension(OidcServiceConfig {
110 default_redirect_path: "/frontend/user",
111 session_key_user_id: "user",
112 }))
113 .layer(Extension(oidc_config));
114 }
115
116 router = router
117 .layer(AuthenticationLayer::new(auth_provider.clone()))
118 .layer(Extension(auth_provider))
119 .layer(Extension(cal_store))
120 .layer(Extension(addr_store))
121 .layer(Extension(frontend_config))
122 .layer(Extension(oidc_config));
123
124 Router::new()
125 .nest(prefix, router)
126 .route("/", get(async || Redirect::to(prefix)))
127}
128
129async fn unauthorized_handler(mut request: Request, next: Next) -> Response {
130 let meth = request.method().clone();
131 let OriginalUri(uri) = request.extract_parts().await.unwrap();
132 let resp = next.run(request).await;
133 if resp.status() == StatusCode::UNAUTHORIZED {
134 let mut login_url: Url = "http://github.com/frontend/login".parse().unwrap();
136 if meth == Method::GET {
137 login_url
138 .query_pairs_mut()
139 .append_pair("redirect_uri", uri.path());
140 }
141 let path = login_url.path();
142 let query = login_url
143 .query()
144 .map(|query| format!("?{query}"))
145 .unwrap_or_default();
146 let login_url = format!("{path}{query}");
147 let mut resp = Response::builder().status(StatusCode::UNAUTHORIZED);
148 let hdrs = resp.headers_mut().unwrap();
149 hdrs.typed_insert(ContentType::html());
150 return resp
151 .body(Body::new(format!(
152 r#"<!Doctype html>
153<html>
154 <head>
155 <meta http-equiv="refresh" content="1; url={login_url}" />
156 </head>
157 <body>
158 Unauthorized, redirecting to <a href="{login_url}">login page</a>
159 </body>
160</html>"#,
161 )))
162 .unwrap();
163 }
164 resp
165}