tuwunel_api/client/account/3pid/
email_validate.rs1use std::net::IpAddr;
2
3use axum::{
4 extract::{Form, Request, State},
5 response::{Html, IntoResponse, Response},
6};
7use const_str::format as const_format;
8use http::{
9 StatusCode,
10 header::{CACHE_CONTROL, CONTENT_SECURITY_POLICY, REFERRER_POLICY},
11};
12use serde::Deserialize;
13use tuwunel_core::utils::html::escape as html_escape;
14
15use crate::ClientIp;
16
17static VALIDATE_CSP: &str = "default-src 'none'; style-src 'self'; form-action 'self'; \
20 frame-ancestors 'none'; base-uri 'none';";
21
22static VALIDATE_HEAD: &str = r#"
23 <meta charset="UTF-8">
24 <link rel="stylesheet" href="/_tuwunel/oidc/account.css">
25"#;
26
27static GENERIC_FAILURE: &str =
28 "This verification link is invalid or has expired. Request a new one from your client.";
29
30#[derive(Debug, Default, Deserialize)]
31pub(crate) struct ValidateParams {
32 sid: Option<String>,
33 client_secret: Option<String>,
34 token: Option<String>,
35}
36
37pub(crate) async fn get_email_validate_route(
43 State(services): State<crate::State>,
44 ClientIp(client): ClientIp,
45 request: Request,
46) -> Response {
47 if let Some(limited) = rate_limited(services, client) {
48 return limited;
49 }
50
51 let params: ValidateParams =
52 serde_html_form::from_str(request.uri().query().unwrap_or_default()).unwrap_or_default();
53
54 validate_html(StatusCode::OK, confirm_html(¶ms))
55}
56
57pub(crate) async fn post_email_validate_route(
63 State(services): State<crate::State>,
64 ClientIp(client): ClientIp,
65 Form(params): Form<ValidateParams>,
66) -> Response {
67 if let Some(limited) = rate_limited(services, client) {
68 return limited;
69 }
70
71 let (Some(sid), Some(client_secret), Some(token)) =
72 (¶ms.sid, ¶ms.client_secret, ¶ms.token)
73 else {
74 return validate_html(StatusCode::OK, error_html(GENERIC_FAILURE));
75 };
76
77 match services
78 .threepid
79 .validate_pending_token(sid, client_secret, token)
80 .await
81 {
82 | Ok(()) => validate_html(
83 StatusCode::OK,
84 result_html(
85 "Email verified",
86 "Your email address has been verified. Return to your client to continue.",
87 ),
88 ),
89 | Err(_) => validate_html(StatusCode::OK, error_html(GENERIC_FAILURE)),
90 }
91}
92
93fn rate_limited(services: crate::State, client: IpAddr) -> Option<Response> {
94 services
95 .threepid
96 .check_ip_rate_limit(client)
97 .is_err()
98 .then(|| {
99 validate_html(
100 StatusCode::TOO_MANY_REQUESTS,
101 error_html("Too many requests. Please wait and try again."),
102 )
103 })
104}
105
106fn confirm_html(params: &ValidateParams) -> String {
107 let escape = |value: &Option<String>| html_escape(value.as_deref().unwrap_or_default());
108
109 CONFIRM_HTML
111 .replace("{token}", &escape(¶ms.token))
112 .replace("{client_secret}", &escape(¶ms.client_secret))
113 .replace("{sid}", &escape(¶ms.sid))
114}
115
116static CONFIRM_HTML: &str = const_format!(
117 r#"
118<!DOCTYPE html>
119<html lang="en">
120 <head>
121 {VALIDATE_HEAD}
122 <title>Verify your email address</title>
123 </head>
124 <body>
125 <h1>Verify your email address</h1>
126 <p>Confirm that you want to verify this email address.</p>
127 <form method="POST" action="/_tuwunel/3pid/email/validate">
128 <input type="hidden" name="sid" value="{{sid}}">
129 <input type="hidden" name="client_secret" value="{{client_secret}}">
130 <input type="hidden" name="token" value="{{token}}">
131 <button type="submit" class="primary">Verify</button>
132 </form>
133 </body>
134</html>"#
135);
136
137fn result_html(title: &str, message: &str) -> String {
138 RESULT_HTML
139 .replace("{title}", &html_escape(title))
140 .replace("{message}", &html_escape(message))
141}
142
143static RESULT_HTML: &str = const_format!(
144 r#"
145<!DOCTYPE html>
146<html lang="en">
147 <head>
148 {VALIDATE_HEAD}
149 <title>{{title}}</title>
150 </head>
151 <body>
152 <h1>{{title}}</h1>
153 <p>{{message}}</p>
154 </body>
155</html>"#
156);
157
158fn error_html(message: &str) -> String { ERROR_HTML.replace("{msg}", &html_escape(message)) }
159
160static ERROR_HTML: &str = const_format!(
161 r#"
162<!DOCTYPE html>
163<html lang="en">
164 <head>
165 {VALIDATE_HEAD}
166 <title>Verification failed</title>
167 </head>
168 <body>
169 <h1 class="err">Verification failed</h1>
170 <p>{{msg}}</p>
171 </body>
172</html>"#
173);
174
175fn validate_html(status: StatusCode, html: String) -> Response {
176 let headers = [
177 (CACHE_CONTROL, "no-store"),
178 (CONTENT_SECURITY_POLICY, VALIDATE_CSP),
179 (REFERRER_POLICY, "no-referrer"),
180 ];
181
182 (status, headers, Html(html)).into_response()
183}