mas_router/
url_builder.rs

1// Copyright 2024 New Vector Ltd.
2// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only
5// Please see LICENSE in the repository root for full details.
6
7//! Utility to build URLs
8
9use ulid::Ulid;
10use url::Url;
11
12use crate::traits::Route;
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct UrlBuilder {
16    http_base: Url,
17    prefix: String,
18    assets_base: String,
19    issuer: Url,
20}
21
22impl UrlBuilder {
23    /// Create an absolute URL for a route
24    #[must_use]
25    pub fn absolute_url_for<U>(&self, destination: &U) -> Url
26    where
27        U: Route,
28    {
29        destination.absolute_url(&self.http_base)
30    }
31
32    /// Create a relative URL for a route, prefixed with the base URL
33    #[must_use]
34    pub fn relative_url_for<U>(&self, destination: &U) -> String
35    where
36        U: Route,
37    {
38        format!(
39            "{prefix}{destination}",
40            prefix = self.prefix,
41            destination = destination.path_and_query()
42        )
43    }
44
45    /// The prefix added to all relative URLs
46    #[must_use]
47    pub fn prefix(&self) -> Option<&str> {
48        if self.prefix.is_empty() {
49            None
50        } else {
51            Some(&self.prefix)
52        }
53    }
54
55    /// Create a (relative) redirect response to a route
56    pub fn redirect<U>(&self, destination: &U) -> axum::response::Redirect
57    where
58        U: Route,
59    {
60        let uri = self.relative_url_for(destination);
61        axum::response::Redirect::to(&uri)
62    }
63
64    /// Create an absolute redirect response to a route
65    pub fn absolute_redirect<U>(&self, destination: &U) -> axum::response::Redirect
66    where
67        U: Route,
68    {
69        let uri = self.absolute_url_for(destination);
70        axum::response::Redirect::to(uri.as_str())
71    }
72
73    /// Create a new [`UrlBuilder`] from a base URL
74    ///
75    /// # Panics
76    ///
77    /// Panics if the base URL contains a fragment, a query, credentials or
78    /// isn't HTTP/HTTPS;
79    #[must_use]
80    pub fn new(base: Url, issuer: Option<Url>, assets_base: Option<String>) -> Self {
81        assert!(
82            base.scheme() == "http" || base.scheme() == "https",
83            "base URL must be HTTP/HTTPS"
84        );
85        assert_eq!(base.query(), None, "base URL must not contain a query");
86        assert_eq!(
87            base.fragment(),
88            None,
89            "base URL must not contain a fragment"
90        );
91        assert_eq!(base.username(), "", "base URL must not contain credentials");
92        assert_eq!(
93            base.password(),
94            None,
95            "base URL must not contain credentials"
96        );
97
98        let issuer = issuer.unwrap_or_else(|| base.clone());
99        let prefix = base.path().trim_end_matches('/').to_owned();
100        let assets_base = assets_base.unwrap_or_else(|| format!("{prefix}/assets/"));
101        Self {
102            http_base: base,
103            prefix,
104            assets_base,
105            issuer,
106        }
107    }
108
109    /// Site public hostname
110    ///
111    /// # Panics
112    ///
113    /// Panics if the base URL does not have a host
114    #[must_use]
115    pub fn public_hostname(&self) -> &str {
116        self.http_base
117            .host_str()
118            .expect("base URL must have a host")
119    }
120
121    /// HTTP base
122    #[must_use]
123    pub fn http_base(&self) -> Url {
124        self.http_base.clone()
125    }
126
127    /// OIDC issuer
128    #[must_use]
129    pub fn oidc_issuer(&self) -> Url {
130        self.issuer.clone()
131    }
132
133    /// OIDC discovery document URL
134    #[must_use]
135    pub fn oidc_discovery(&self) -> Url {
136        crate::endpoints::OidcConfiguration.absolute_url(&self.issuer)
137    }
138
139    /// OAuth 2.0 authorization endpoint
140    #[must_use]
141    pub fn oauth_authorization_endpoint(&self) -> Url {
142        self.absolute_url_for(&crate::endpoints::OAuth2AuthorizationEndpoint)
143    }
144
145    /// OAuth 2.0 token endpoint
146    #[must_use]
147    pub fn oauth_token_endpoint(&self) -> Url {
148        self.absolute_url_for(&crate::endpoints::OAuth2TokenEndpoint)
149    }
150
151    /// OAuth 2.0 introspection endpoint
152    #[must_use]
153    pub fn oauth_introspection_endpoint(&self) -> Url {
154        self.absolute_url_for(&crate::endpoints::OAuth2Introspection)
155    }
156
157    /// OAuth 2.0 revocation endpoint
158    #[must_use]
159    pub fn oauth_revocation_endpoint(&self) -> Url {
160        self.absolute_url_for(&crate::endpoints::OAuth2Revocation)
161    }
162
163    /// OAuth 2.0 client registration endpoint
164    #[must_use]
165    pub fn oauth_registration_endpoint(&self) -> Url {
166        self.absolute_url_for(&crate::endpoints::OAuth2RegistrationEndpoint)
167    }
168
169    /// OAuth 2.0 device authorization endpoint
170    #[must_use]
171    pub fn oauth_device_authorization_endpoint(&self) -> Url {
172        self.absolute_url_for(&crate::endpoints::OAuth2DeviceAuthorizationEndpoint)
173    }
174
175    /// OAuth 2.0 device code link
176    #[must_use]
177    pub fn device_code_link(&self) -> Url {
178        self.absolute_url_for(&crate::endpoints::DeviceCodeLink::default())
179    }
180
181    /// OAuth 2.0 device code link full URL
182    #[must_use]
183    pub fn device_code_link_full(&self, code: String) -> Url {
184        self.absolute_url_for(&crate::endpoints::DeviceCodeLink::with_code(code))
185    }
186
187    // OIDC userinfo endpoint
188    #[must_use]
189    pub fn oidc_userinfo_endpoint(&self) -> Url {
190        self.absolute_url_for(&crate::endpoints::OidcUserinfo)
191    }
192
193    /// JWKS URI
194    #[must_use]
195    pub fn jwks_uri(&self) -> Url {
196        self.absolute_url_for(&crate::endpoints::OAuth2Keys)
197    }
198
199    /// Static asset
200    #[must_use]
201    pub fn static_asset(&self, path: String) -> Url {
202        self.absolute_url_for(&crate::endpoints::StaticAsset::new(path))
203    }
204
205    /// Static asset base
206    #[must_use]
207    pub fn assets_base(&self) -> &str {
208        &self.assets_base
209    }
210
211    /// GraphQL endpoint
212    #[must_use]
213    pub fn graphql_endpoint(&self) -> Url {
214        self.absolute_url_for(&crate::endpoints::GraphQL)
215    }
216
217    /// Upstream redirect URI
218    #[must_use]
219    pub fn upstream_oauth_callback(&self, id: Ulid) -> Url {
220        self.absolute_url_for(&crate::endpoints::UpstreamOAuth2Callback::new(id))
221    }
222
223    /// Upstream authorize URI
224    #[must_use]
225    pub fn upstream_oauth_authorize(&self, id: Ulid) -> Url {
226        self.absolute_url_for(&crate::endpoints::UpstreamOAuth2Authorize::new(id))
227    }
228
229    /// Account management URI
230    #[must_use]
231    pub fn account_management_uri(&self) -> Url {
232        self.absolute_url_for(&crate::endpoints::Account::default())
233    }
234
235    /// Account recovery link
236    #[must_use]
237    pub fn account_recovery_link(&self, ticket: String) -> Url {
238        self.absolute_url_for(&crate::endpoints::AccountRecoveryFinish::new(ticket))
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    #[test]
245    #[should_panic(expected = "base URL must be HTTP/HTTPS")]
246    fn test_invalid_base_url_scheme() {
247        let _ = super::UrlBuilder::new(url::Url::parse("file:///tmp/").unwrap(), None, None);
248    }
249
250    #[test]
251    #[should_panic(expected = "base URL must not contain a query")]
252    fn test_invalid_base_url_query() {
253        let _ = super::UrlBuilder::new(
254            url::Url::parse("https://example.com/?foo=bar").unwrap(),
255            None,
256            None,
257        );
258    }
259
260    #[test]
261    #[should_panic(expected = "base URL must not contain a fragment")]
262    fn test_invalid_base_url_fragment() {
263        let _ = super::UrlBuilder::new(
264            url::Url::parse("https://example.com/#foo").unwrap(),
265            None,
266            None,
267        );
268    }
269
270    #[test]
271    #[should_panic(expected = "base URL must not contain credentials")]
272    fn test_invalid_base_url_credentials() {
273        let _ = super::UrlBuilder::new(
274            url::Url::parse("https://foo@example.com/").unwrap(),
275            None,
276            None,
277        );
278    }
279
280    #[test]
281    fn test_url_prefix() {
282        let builder = super::UrlBuilder::new(
283            url::Url::parse("https://example.com/foo/").unwrap(),
284            None,
285            None,
286        );
287        assert_eq!(builder.prefix, "/foo");
288
289        let builder =
290            super::UrlBuilder::new(url::Url::parse("https://example.com/").unwrap(), None, None);
291        assert_eq!(builder.prefix, "");
292    }
293
294    #[test]
295    fn test_absolute_uri_prefix() {
296        let builder = super::UrlBuilder::new(
297            url::Url::parse("https://example.com/foo/").unwrap(),
298            None,
299            None,
300        );
301
302        let uri = builder.absolute_url_for(&crate::endpoints::OAuth2AuthorizationEndpoint);
303        assert_eq!(uri.as_str(), "https://example.com/foo/authorize");
304    }
305}