Skip to main content

mas_handlers/
lib.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2021-2024 The Matrix.org Foundation C.I.C.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8#![deny(clippy::future_not_send)]
9#![allow(
10    // Some axum handlers need that
11    clippy::unused_async,
12    // Because of how axum handlers work, we sometime have take many arguments
13    clippy::too_many_arguments,
14    // Code generated by tracing::instrument trigger this when returning an `impl Trait`
15    // See https://github.com/tokio-rs/tracing/issues/2613
16    clippy::let_with_type_underscore,
17)]
18
19use std::{
20    convert::Infallible,
21    sync::{Arc, LazyLock},
22    time::Duration,
23};
24
25use axum::{
26    Extension, Router,
27    extract::{FromRef, FromRequestParts, OriginalUri, RawQuery, State},
28    http::Method,
29    response::{Html, IntoResponse},
30    routing::{MethodRouter, get, post},
31};
32use headers::HeaderName;
33use hyper::{
34    StatusCode, Version,
35    header::{
36        ACCEPT, ACCEPT_LANGUAGE, AUTHORIZATION, CONTENT_LANGUAGE, CONTENT_LENGTH, CONTENT_TYPE,
37        X_FRAME_OPTIONS,
38    },
39};
40use mas_axum_utils::{InternalError, cookies::CookieJar};
41use mas_data_model::SiteConfig;
42use mas_http::CorsLayerExt;
43use mas_keystore::{Encrypter, Keystore};
44use mas_matrix::HomeserverConnection;
45use mas_policy::Policy;
46use mas_router::{Route, UrlBuilder};
47use mas_storage::{BoxRepository, BoxRepositoryFactory};
48use mas_templates::{ErrorContext, NotFoundContext, TemplateContext, Templates};
49use opentelemetry::metrics::Meter;
50use sqlx::PgPool;
51use tower::util::AndThenLayer;
52use tower_http::{
53    cors::{Any, CorsLayer},
54    set_header::SetResponseHeaderLayer,
55};
56
57use self::{graphql::ExtraRouterParameters, passwords::PasswordManager};
58
59mod admin;
60mod compat;
61mod graphql;
62mod health;
63mod oauth2;
64pub mod passwords;
65pub mod upstream_oauth2;
66mod views;
67
68mod activity_tracker;
69mod captcha;
70#[cfg(test)]
71mod cleanup_tests;
72mod client_ip;
73mod preferred_language;
74mod rate_limit;
75mod room_id;
76mod session;
77#[cfg(test)]
78mod test_utils;
79mod username_template;
80
81static METER: LazyLock<Meter> = LazyLock::new(|| {
82    let scope = opentelemetry::InstrumentationScope::builder(env!("CARGO_PKG_NAME"))
83        .with_version(env!("CARGO_PKG_VERSION"))
84        .with_schema_url(opentelemetry_semantic_conventions::SCHEMA_URL)
85        .build();
86
87    opentelemetry::global::meter_with_scope(scope)
88});
89
90/// Implement `From<E>` for `RouteError`, for "internal server error" kind of
91/// errors.
92#[macro_export]
93macro_rules! impl_from_error_for_route {
94    ($route_error:ty : $error:ty) => {
95        impl From<$error> for $route_error {
96            fn from(e: $error) -> Self {
97                Self::Internal(Box::new(e))
98            }
99        }
100    };
101    ($error:ty) => {
102        impl_from_error_for_route!(self::RouteError: $error);
103    };
104}
105
106pub use mas_axum_utils::{ErrorWrapper, cookies::CookieManager};
107use mas_data_model::{BoxClock, BoxRng};
108
109pub use self::{
110    activity_tracker::{ActivityTracker, Bound as BoundActivityTracker},
111    admin::router as admin_api_router,
112    client_ip::ClientIp,
113    graphql::{
114        GraphQLOperation, Schema as GraphQLSchema, schema as graphql_schema,
115        schema_builder as graphql_schema_builder,
116    },
117    preferred_language::PreferredLanguage,
118    rate_limit::{Limiter, RequesterFingerprint},
119    upstream_oauth2::cache::MetadataCache,
120};
121
122pub fn healthcheck_router<S>() -> Router<S>
123where
124    S: Clone + Send + Sync + 'static,
125    PgPool: FromRef<S>,
126{
127    Router::new().route(mas_router::Healthcheck::route(), get(self::health::get))
128}
129
130pub fn graphql_router<S>(undocumented_oauth2_access: bool) -> Router<S>
131where
132    S: Clone + Send + Sync + 'static,
133    graphql::Schema: FromRef<S>,
134    BoundActivityTracker: FromRequestParts<S>,
135    BoxRepository: FromRequestParts<S>,
136    BoxClock: FromRequestParts<S>,
137    Encrypter: FromRef<S>,
138    CookieJar: FromRequestParts<S>,
139    Limiter: FromRef<S>,
140    RequesterFingerprint: FromRequestParts<S>,
141{
142    Router::new()
143        .route(
144            mas_router::GraphQL::route(),
145            get(self::graphql::get).post(self::graphql::post),
146        )
147        // Pass the undocumented_oauth2_access parameter through the request extension, as it is
148        // per-listener
149        .layer(Extension(ExtraRouterParameters {
150            undocumented_oauth2_access,
151        }))
152        .layer(
153            CorsLayer::new()
154                .allow_origin(Any)
155                .allow_methods(Any)
156                .allow_otel_headers([
157                    AUTHORIZATION,
158                    ACCEPT,
159                    ACCEPT_LANGUAGE,
160                    CONTENT_LANGUAGE,
161                    CONTENT_TYPE,
162                ]),
163        )
164}
165
166pub fn discovery_router<S>() -> Router<S>
167where
168    S: Clone + Send + Sync + 'static,
169    Keystore: FromRef<S>,
170    SiteConfig: FromRef<S>,
171    UrlBuilder: FromRef<S>,
172    BoxClock: FromRequestParts<S>,
173    BoxRng: FromRequestParts<S>,
174{
175    Router::new()
176        .route(
177            mas_router::OidcConfiguration::route(),
178            get(self::oauth2::discovery::get),
179        )
180        .route(
181            mas_router::Webfinger::route(),
182            get(self::oauth2::webfinger::get),
183        )
184        .layer(
185            CorsLayer::new()
186                .allow_origin(Any)
187                .allow_methods(Any)
188                .allow_otel_headers([
189                    AUTHORIZATION,
190                    ACCEPT,
191                    ACCEPT_LANGUAGE,
192                    CONTENT_LANGUAGE,
193                    CONTENT_TYPE,
194                ])
195                .max_age(Duration::from_hours(1)),
196        )
197}
198
199pub fn api_router<S>() -> Router<S>
200where
201    S: Clone + Send + Sync + 'static,
202    Keystore: FromRef<S>,
203    UrlBuilder: FromRef<S>,
204    BoxRepository: FromRequestParts<S>,
205    ActivityTracker: FromRequestParts<S>,
206    BoundActivityTracker: FromRequestParts<S>,
207    Encrypter: FromRef<S>,
208    reqwest::Client: FromRef<S>,
209    SiteConfig: FromRef<S>,
210    Templates: FromRef<S>,
211    Arc<dyn HomeserverConnection>: FromRef<S>,
212    BoxClock: FromRequestParts<S>,
213    BoxRng: FromRequestParts<S>,
214    Policy: FromRequestParts<S>,
215{
216    // All those routes are API-like, with a common CORS layer
217    Router::new()
218        .route(
219            mas_router::OAuth2Keys::route(),
220            get(self::oauth2::keys::get),
221        )
222        .route(
223            mas_router::OidcUserinfo::route(),
224            get(self::oauth2::userinfo::get).post(self::oauth2::userinfo::get),
225        )
226        .route(
227            mas_router::OAuth2Introspection::route(),
228            post(self::oauth2::introspection::post),
229        )
230        .route(
231            mas_router::OAuth2Revocation::route(),
232            post(self::oauth2::revoke::post),
233        )
234        .route(
235            mas_router::OAuth2TokenEndpoint::route(),
236            post(self::oauth2::token::post),
237        )
238        .route(
239            mas_router::OAuth2RegistrationEndpoint::route(),
240            post(self::oauth2::registration::post),
241        )
242        .route(
243            mas_router::OAuth2DeviceAuthorizationEndpoint::route(),
244            post(self::oauth2::device::authorize::post),
245        )
246        .layer(
247            CorsLayer::new()
248                .allow_origin(Any)
249                .allow_methods(Any)
250                .allow_otel_headers([
251                    AUTHORIZATION,
252                    ACCEPT,
253                    ACCEPT_LANGUAGE,
254                    CONTENT_LANGUAGE,
255                    CONTENT_TYPE,
256                    // Swagger will send this header, so we have to allow it to avoid CORS errors
257                    HeaderName::from_static("x-requested-with"),
258                ])
259                .max_age(Duration::from_hours(1)),
260        )
261}
262
263pub fn compat_router<S>(templates: Templates) -> Router<S>
264where
265    S: Clone + Send + Sync + 'static,
266    UrlBuilder: FromRef<S>,
267    SiteConfig: FromRef<S>,
268    Arc<dyn HomeserverConnection>: FromRef<S>,
269    PasswordManager: FromRef<S>,
270    Limiter: FromRef<S>,
271    BoxRepositoryFactory: FromRef<S>,
272    BoundActivityTracker: FromRequestParts<S>,
273    RequesterFingerprint: FromRequestParts<S>,
274    BoxRepository: FromRequestParts<S>,
275    BoxClock: FromRequestParts<S>,
276    BoxRng: FromRequestParts<S>,
277    Policy: FromRequestParts<S>,
278{
279    // A sub-router for human-facing routes with error handling
280    let human_router = Router::new()
281        .route(
282            mas_router::CompatLoginSsoRedirect::route(),
283            get(self::compat::login_sso_redirect::get),
284        )
285        .route(
286            mas_router::CompatLoginSsoRedirectIdp::route(),
287            get(self::compat::login_sso_redirect::get),
288        )
289        .route(
290            mas_router::CompatLoginSsoRedirectSlash::route(),
291            get(self::compat::login_sso_redirect::get),
292        )
293        .layer(AndThenLayer::new(
294            async move |response: axum::response::Response| {
295                Ok::<_, Infallible>(recover_error(&templates, response))
296            },
297        ));
298
299    // A sub-router for API-facing routes with CORS
300    let api_router = Router::new()
301        .route(
302            mas_router::CompatLogin::route(),
303            get(self::compat::login::get).post(self::compat::login::post),
304        )
305        .route(
306            mas_router::CompatLogout::route(),
307            post(self::compat::logout::post),
308        )
309        .route(
310            mas_router::CompatLogoutAll::route(),
311            post(self::compat::logout_all::post),
312        )
313        .route(
314            mas_router::CompatRefresh::route(),
315            post(self::compat::refresh::post),
316        )
317        .layer(
318            CorsLayer::new()
319                .allow_origin(Any)
320                .allow_methods(Any)
321                .allow_otel_headers([
322                    AUTHORIZATION,
323                    ACCEPT,
324                    ACCEPT_LANGUAGE,
325                    CONTENT_LANGUAGE,
326                    CONTENT_TYPE,
327                    HeaderName::from_static("x-requested-with"),
328                ])
329                .max_age(Duration::from_hours(1)),
330        );
331
332    Router::new().merge(human_router).merge(api_router)
333}
334
335/// A `GET` route which redirects to another route, keeping the query string
336fn redirect_to<S>(route: &'static str) -> MethodRouter<S>
337where
338    S: Clone + Send + Sync + 'static,
339    UrlBuilder: FromRef<S>,
340{
341    get(
342        async move |State(url_builder): State<UrlBuilder>, RawQuery(query): RawQuery| {
343            let prefix = url_builder.prefix().unwrap_or_default();
344            let destination = if let Some(query) = query {
345                format!("{prefix}{route}?{query}")
346            } else {
347                format!("{prefix}{route}")
348            };
349
350            axum::response::Redirect::to(&destination)
351        },
352    )
353}
354
355pub fn human_router<S>(templates: Templates) -> Router<S>
356where
357    S: Clone + Send + Sync + 'static,
358    UrlBuilder: FromRef<S>,
359    PreferredLanguage: FromRequestParts<S>,
360    BoxRepository: FromRequestParts<S>,
361    CookieJar: FromRequestParts<S>,
362    BoundActivityTracker: FromRequestParts<S>,
363    RequesterFingerprint: FromRequestParts<S>,
364    Encrypter: FromRef<S>,
365    Templates: FromRef<S>,
366    Keystore: FromRef<S>,
367    PasswordManager: FromRef<S>,
368    MetadataCache: FromRef<S>,
369    SiteConfig: FromRef<S>,
370    Limiter: FromRef<S>,
371    reqwest::Client: FromRef<S>,
372    Arc<dyn HomeserverConnection>: FromRef<S>,
373    BoxClock: FromRequestParts<S>,
374    BoxRng: FromRequestParts<S>,
375    Policy: FromRequestParts<S>,
376{
377    Router::new()
378        // XXX: hard-coded redirect from /account to /account/
379        .route("/account", redirect_to(mas_router::Account::route()))
380        .route(mas_router::Account::route(), get(self::views::app::get))
381        .route(
382            mas_router::AccountWildcard::route(),
383            get(self::views::app::get),
384        )
385        .route(
386            mas_router::AccountRecoveryFinish::route(),
387            get(self::views::app::get_anonymous),
388        )
389        .route(
390            mas_router::ChangePasswordDiscovery::route(),
391            get(async |State(url_builder): State<UrlBuilder>| {
392                url_builder.redirect(&mas_router::AccountPasswordChange)
393            }),
394        )
395        .route(mas_router::Index::route(), get(self::views::index::get))
396        .route(
397            mas_router::Login::route(),
398            get(self::views::login::get).post(self::views::login::post),
399        )
400        .route(mas_router::Logout::route(), post(self::views::logout::post))
401        .route(
402            mas_router::OpenInviteRoom::route(),
403            get(self::views::open_room::get),
404        )
405        .route(
406            mas_router::Register::route(),
407            get(self::views::register::get).post(self::views::register::post),
408        )
409        // Keeps links to the former password registration page working
410        .route(
411            "/register/password",
412            redirect_to(mas_router::Register::route()),
413        )
414        .route(
415            mas_router::RegisterVerifyEmail::route(),
416            get(self::views::register::steps::verify_email::get)
417                .post(self::views::register::steps::verify_email::post),
418        )
419        .route(
420            mas_router::RegisterToken::route(),
421            get(self::views::register::steps::registration_token::get)
422                .post(self::views::register::steps::registration_token::post),
423        )
424        .route(
425            mas_router::RegisterDisplayName::route(),
426            get(self::views::register::steps::display_name::get)
427                .post(self::views::register::steps::display_name::post),
428        )
429        .route(
430            mas_router::RegisterFinish::route(),
431            get(self::views::register::steps::finish::get),
432        )
433        .route(
434            mas_router::AccountRecoveryStart::route(),
435            get(self::views::recovery::start::get).post(self::views::recovery::start::post),
436        )
437        .route(
438            mas_router::AccountRecoveryProgress::route(),
439            get(self::views::recovery::progress::get).post(self::views::recovery::progress::post),
440        )
441        .route(
442            mas_router::OAuth2AuthorizationEndpoint::route(),
443            get(self::oauth2::authorization::get),
444        )
445        .route(
446            mas_router::Consent::route(),
447            get(self::oauth2::authorization::consent::get)
448                .post(self::oauth2::authorization::consent::post),
449        )
450        .route(
451            mas_router::CompatLoginSsoComplete::route(),
452            get(self::compat::login_sso_complete::get).post(self::compat::login_sso_complete::post),
453        )
454        .route(
455            mas_router::UpstreamOAuth2Authorize::route(),
456            get(self::upstream_oauth2::authorize::get),
457        )
458        .route(
459            mas_router::UpstreamOAuth2Callback::route(),
460            get(self::upstream_oauth2::callback::handler)
461                .post(self::upstream_oauth2::callback::handler),
462        )
463        .route(
464            mas_router::UpstreamOAuth2Link::route(),
465            get(self::upstream_oauth2::link::get).post(self::upstream_oauth2::link::post),
466        )
467        .route(
468            mas_router::UpstreamOAuth2BackchannelLogout::route(),
469            post(self::upstream_oauth2::backchannel_logout::post),
470        )
471        .route(
472            mas_router::DeviceCodeLink::route(),
473            get(self::oauth2::device::link::get).post(self::oauth2::device::link::post),
474        )
475        .route(
476            mas_router::DeviceCodeConsent::route(),
477            get(self::oauth2::device::consent::get).post(self::oauth2::device::consent::post),
478        )
479        .layer(AndThenLayer::new(
480            async move |response: axum::response::Response| {
481                Ok::<_, Infallible>(recover_error(&templates, response))
482            },
483        ))
484        .layer(SetResponseHeaderLayer::if_not_present(
485            X_FRAME_OPTIONS,
486            http::HeaderValue::from_static("DENY"),
487        ))
488}
489
490fn recover_error(
491    templates: &Templates,
492    response: axum::response::Response,
493) -> axum::response::Response {
494    // Error responses should have an ErrorContext attached to them
495    let ext = response.extensions().get::<ErrorContext>();
496    if let Some(ctx) = ext
497        && let Ok(res) = templates.render_error(ctx)
498    {
499        let (mut parts, _original_body) = response.into_parts();
500        parts.headers.remove(CONTENT_TYPE);
501        parts.headers.remove(CONTENT_LENGTH);
502        return (parts, Html(res)).into_response();
503    }
504
505    response
506}
507
508/// The fallback handler for all routes that don't match anything else.
509///
510/// # Errors
511///
512/// Returns an error if the template rendering fails.
513pub async fn fallback(
514    State(templates): State<Templates>,
515    OriginalUri(uri): OriginalUri,
516    method: Method,
517    version: Version,
518    PreferredLanguage(locale): PreferredLanguage,
519) -> Result<impl IntoResponse, InternalError> {
520    let ctx = NotFoundContext::new(&method, version, &uri).with_language(locale);
521    // XXX: this should look at the Accept header and return JSON if requested
522
523    let res = templates.render_not_found(&ctx)?;
524
525    Ok((StatusCode::NOT_FOUND, Html(res)))
526}