1use std::collections::HashSet;
8
9use anyhow::{Context, bail};
10use error::SynapseResponseExt;
11use http::{Method, StatusCode};
12use mas_http::RequestBuilderExt as _;
13use mas_matrix::{HomeserverConnection, MatrixUser, ProvisionRequest};
14use serde::{Deserialize, Serialize};
15use tracing::debug;
16use url::Url;
17
18static SYNAPSE_AUTH_PROVIDER: &str = "oauth-delegated";
19
20const M_USER_IN_USE: &str = "M_USER_IN_USE";
23const M_INVALID_USERNAME: &str = "M_INVALID_USERNAME";
26
27mod error;
28
29#[derive(Clone)]
30pub struct SynapseConnection {
31 homeserver: String,
32 endpoint: Url,
33 access_token: String,
34 http_client: reqwest::Client,
35}
36
37impl SynapseConnection {
38 #[must_use]
39 pub fn new(
40 homeserver: String,
41 endpoint: Url,
42 access_token: String,
43 http_client: reqwest::Client,
44 ) -> Self {
45 Self {
46 homeserver,
47 endpoint,
48 access_token,
49 http_client,
50 }
51 }
52
53 fn builder(&self, method: Method, url: &str) -> reqwest::RequestBuilder {
54 self.http_client
55 .request(
56 method,
57 self.endpoint
58 .join(url)
59 .map(String::from)
60 .unwrap_or_default(),
61 )
62 .bearer_auth(&self.access_token)
63 }
64
65 fn post(&self, url: &str) -> reqwest::RequestBuilder {
66 self.builder(Method::POST, url)
67 }
68
69 fn get(&self, url: &str) -> reqwest::RequestBuilder {
70 self.builder(Method::GET, url)
71 }
72
73 fn put(&self, url: &str) -> reqwest::RequestBuilder {
74 self.builder(Method::PUT, url)
75 }
76
77 fn delete(&self, url: &str) -> reqwest::RequestBuilder {
78 self.builder(Method::DELETE, url)
79 }
80}
81
82#[derive(Serialize, Deserialize)]
83struct ExternalID {
84 auth_provider: String,
85 external_id: String,
86}
87
88#[derive(Serialize, Deserialize)]
89#[serde(rename_all = "lowercase")]
90enum ThreePIDMedium {
91 Email,
92 Msisdn,
93}
94
95#[derive(Serialize, Deserialize)]
96struct ThreePID {
97 medium: ThreePIDMedium,
98 address: String,
99}
100
101#[derive(Default, Serialize, Deserialize)]
102struct SynapseUser {
103 #[serde(
104 default,
105 rename = "displayname",
106 skip_serializing_if = "Option::is_none"
107 )]
108 display_name: Option<String>,
109
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 avatar_url: Option<String>,
112
113 #[serde(default, rename = "threepids", skip_serializing_if = "Option::is_none")]
114 three_pids: Option<Vec<ThreePID>>,
115
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 external_ids: Option<Vec<ExternalID>>,
118
119 #[serde(default, skip_serializing_if = "Option::is_none")]
120 deactivated: Option<bool>,
121}
122
123#[derive(Deserialize)]
124struct SynapseDeviceListResponse {
125 devices: Vec<SynapseDevice>,
126}
127
128#[derive(Serialize, Deserialize)]
129struct SynapseDevice {
130 device_id: String,
131
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 dehydrated: Option<bool>,
134}
135
136#[derive(Serialize)]
137struct SynapseUpdateDeviceRequest<'a> {
138 display_name: Option<&'a str>,
139}
140
141#[derive(Serialize)]
142struct SynapseDeleteDevicesRequest {
143 devices: Vec<String>,
144}
145
146#[derive(Serialize)]
147struct SetDisplayNameRequest<'a> {
148 displayname: &'a str,
149}
150
151#[derive(Serialize)]
152struct SynapseDeactivateUserRequest {
153 erase: bool,
154}
155
156#[derive(Serialize)]
157struct SynapseAllowCrossSigningResetRequest {}
158
159#[derive(Deserialize)]
162struct UsernameAvailableResponse {
163 available: bool,
164}
165
166#[async_trait::async_trait]
167impl HomeserverConnection for SynapseConnection {
168 fn homeserver(&self) -> &str {
169 &self.homeserver
170 }
171
172 #[tracing::instrument(
173 name = "homeserver.query_user",
174 skip_all,
175 fields(
176 matrix.homeserver = self.homeserver,
177 matrix.mxid = mxid,
178 ),
179 err(Debug),
180 )]
181 async fn query_user(&self, mxid: &str) -> Result<MatrixUser, anyhow::Error> {
182 let mxid = urlencoding::encode(mxid);
183
184 let response = self
185 .get(&format!("_synapse/admin/v2/users/{mxid}"))
186 .send_traced()
187 .await
188 .context("Failed to query user from Synapse")?;
189
190 let response = response
191 .error_for_synapse_error()
192 .await
193 .context("Unexpected HTTP response while querying user from Synapse")?;
194
195 let body: SynapseUser = response
196 .json()
197 .await
198 .context("Failed to deserialize response while querying user from Synapse")?;
199
200 Ok(MatrixUser {
201 displayname: body.display_name,
202 avatar_url: body.avatar_url,
203 deactivated: body.deactivated.unwrap_or(false),
204 })
205 }
206
207 #[tracing::instrument(
208 name = "homeserver.is_localpart_available",
209 skip_all,
210 fields(
211 matrix.homeserver = self.homeserver,
212 matrix.localpart = localpart,
213 ),
214 err(Debug),
215 )]
216 async fn is_localpart_available(&self, localpart: &str) -> Result<bool, anyhow::Error> {
217 let localpart = urlencoding::encode(localpart);
218
219 let response = self
220 .get(&format!(
221 "_synapse/admin/v1/username_available?username={localpart}"
222 ))
223 .send_traced()
224 .await
225 .context("Failed to query localpart availability from Synapse")?;
226
227 match response.error_for_synapse_error().await {
228 Ok(resp) => {
229 let response: UsernameAvailableResponse = resp.json().await.context(
230 "Unexpected response while querying localpart availability from Synapse",
231 )?;
232
233 Ok(response.available)
234 }
235
236 Err(err)
237 if err.errcode() == Some(M_INVALID_USERNAME)
238 || err.errcode() == Some(M_USER_IN_USE) =>
239 {
240 debug!(
241 error = &err as &dyn std::error::Error,
242 "Localpart is not available"
243 );
244 Ok(false)
245 }
246
247 Err(err) => Err(err).context("Failed to query localpart availability from Synapse"),
248 }
249 }
250
251 #[tracing::instrument(
252 name = "homeserver.provision_user",
253 skip_all,
254 fields(
255 matrix.homeserver = self.homeserver,
256 matrix.mxid = request.mxid(),
257 user.id = request.sub(),
258 ),
259 err(Debug),
260 )]
261 async fn provision_user(&self, request: &ProvisionRequest) -> Result<bool, anyhow::Error> {
262 let mut body = SynapseUser {
263 external_ids: Some(vec![ExternalID {
264 auth_provider: SYNAPSE_AUTH_PROVIDER.to_owned(),
265 external_id: request.sub().to_owned(),
266 }]),
267 ..SynapseUser::default()
268 };
269
270 request
271 .on_displayname(|displayname| {
272 body.display_name = Some(displayname.unwrap_or_default().to_owned());
273 })
274 .on_avatar_url(|avatar_url| {
275 body.avatar_url = Some(avatar_url.unwrap_or_default().to_owned());
276 })
277 .on_emails(|emails| {
278 body.three_pids = Some(
279 emails
280 .unwrap_or_default()
281 .iter()
282 .map(|email| ThreePID {
283 medium: ThreePIDMedium::Email,
284 address: email.clone(),
285 })
286 .collect(),
287 );
288 });
289
290 let mxid = urlencoding::encode(request.mxid());
291 let response = self
292 .put(&format!("_synapse/admin/v2/users/{mxid}"))
293 .json(&body)
294 .send_traced()
295 .await
296 .context("Failed to provision user in Synapse")?;
297
298 let response = response
299 .error_for_synapse_error()
300 .await
301 .context("Unexpected HTTP response while provisioning user in Synapse")?;
302
303 match response.status() {
304 StatusCode::CREATED => Ok(true),
305 StatusCode::OK => Ok(false),
306 code => bail!("Unexpected HTTP code while provisioning user in Synapse: {code}"),
307 }
308 }
309
310 #[tracing::instrument(
311 name = "homeserver.create_device",
312 skip_all,
313 fields(
314 matrix.homeserver = self.homeserver,
315 matrix.mxid = mxid,
316 matrix.device_id = device_id,
317 ),
318 err(Debug),
319 )]
320 async fn create_device(
321 &self,
322 mxid: &str,
323 device_id: &str,
324 initial_display_name: Option<&str>,
325 ) -> Result<(), anyhow::Error> {
326 let encoded_mxid = urlencoding::encode(mxid);
327
328 let response = self
329 .post(&format!("_synapse/admin/v2/users/{encoded_mxid}/devices"))
330 .json(&SynapseDevice {
331 device_id: device_id.to_owned(),
332 dehydrated: None,
333 })
334 .send_traced()
335 .await
336 .context("Failed to create device in Synapse")?;
337
338 let response = response
339 .error_for_synapse_error()
340 .await
341 .context("Unexpected HTTP response while creating device in Synapse")?;
342
343 if response.status() != StatusCode::CREATED {
344 bail!(
345 "Unexpected HTTP code while creating device in Synapse: {}",
346 response.status()
347 );
348 }
349
350 if let Some(display_name) = initial_display_name {
353 self.update_device_display_name(mxid, device_id, display_name)
354 .await?;
355 }
356
357 Ok(())
358 }
359
360 #[tracing::instrument(
361 name = "homeserver.update_device_display_name",
362 skip_all,
363 fields(
364 matrix.homeserver = self.homeserver,
365 matrix.mxid = mxid,
366 matrix.device_id = device_id,
367 ),
368 err(Debug),
369 )]
370 async fn update_device_display_name(
371 &self,
372 mxid: &str,
373 device_id: &str,
374 display_name: &str,
375 ) -> Result<(), anyhow::Error> {
376 let device_id = urlencoding::encode(device_id);
377 let response = self
378 .put(&format!(
379 "_synapse/admin/v2/users/{mxid}/devices/{device_id}"
380 ))
381 .json(&SynapseUpdateDeviceRequest {
382 display_name: Some(display_name),
383 })
384 .send_traced()
385 .await
386 .context("Failed to update device display name in Synapse")?;
387
388 let response = response
389 .error_for_synapse_error()
390 .await
391 .context("Unexpected HTTP response while updating device display name in Synapse")?;
392
393 if response.status() != StatusCode::OK {
394 bail!(
395 "Unexpected HTTP code while updating device display name in Synapse: {}",
396 response.status()
397 );
398 }
399
400 Ok(())
401 }
402
403 #[tracing::instrument(
404 name = "homeserver.delete_device",
405 skip_all,
406 fields(
407 matrix.homeserver = self.homeserver,
408 matrix.mxid = mxid,
409 matrix.device_id = device_id,
410 ),
411 err(Debug),
412 )]
413 async fn delete_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> {
414 let mxid = urlencoding::encode(mxid);
415 let device_id = urlencoding::encode(device_id);
416
417 let response = self
418 .delete(&format!(
419 "_synapse/admin/v2/users/{mxid}/devices/{device_id}"
420 ))
421 .send_traced()
422 .await
423 .context("Failed to delete device in Synapse")?;
424
425 let response = response
426 .error_for_synapse_error()
427 .await
428 .context("Unexpected HTTP response while deleting device in Synapse")?;
429
430 if response.status() != StatusCode::OK {
431 bail!(
432 "Unexpected HTTP code while deleting device in Synapse: {}",
433 response.status()
434 );
435 }
436
437 Ok(())
438 }
439
440 #[tracing::instrument(
441 name = "homeserver.sync_devices",
442 skip_all,
443 fields(
444 matrix.homeserver = self.homeserver,
445 matrix.mxid = mxid,
446 ),
447 err(Debug),
448 )]
449 async fn sync_devices(
450 &self,
451 mxid: &str,
452 devices: HashSet<String>,
453 ) -> Result<(), anyhow::Error> {
454 let mxid_url = urlencoding::encode(mxid);
456
457 let response = self
458 .get(&format!("_synapse/admin/v2/users/{mxid_url}/devices"))
459 .send_traced()
460 .await
461 .context("Failed to query devices from Synapse")?;
462
463 let response = response.error_for_synapse_error().await?;
464
465 if response.status() != StatusCode::OK {
466 bail!(
467 "Unexpected HTTP code while querying devices from Synapse: {}",
468 response.status()
469 );
470 }
471
472 let body: SynapseDeviceListResponse = response
473 .json()
474 .await
475 .context("Failed to parse response while querying devices from Synapse")?;
476
477 let existing_devices: HashSet<String> = body
478 .devices
479 .into_iter()
480 .filter(|d| d.dehydrated != Some(true))
481 .map(|d| d.device_id)
482 .collect();
483
484 let to_delete = existing_devices.difference(&devices).cloned().collect();
486
487 let response = self
488 .post(&format!(
489 "_synapse/admin/v2/users/{mxid_url}/delete_devices"
490 ))
491 .json(&SynapseDeleteDevicesRequest { devices: to_delete })
492 .send_traced()
493 .await
494 .context("Failed to delete devices from Synapse")?;
495
496 let response = response
497 .error_for_synapse_error()
498 .await
499 .context("Unexpected HTTP response while deleting devices from Synapse")?;
500
501 if response.status() != StatusCode::OK {
502 bail!(
503 "Unexpected HTTP code while deleting devices from Synapse: {}",
504 response.status()
505 );
506 }
507
508 for device_id in devices.difference(&existing_devices) {
511 self.create_device(mxid, device_id, None).await?;
512 }
513
514 Ok(())
515 }
516
517 #[tracing::instrument(
518 name = "homeserver.delete_user",
519 skip_all,
520 fields(
521 matrix.homeserver = self.homeserver,
522 matrix.mxid = mxid,
523 erase = erase,
524 ),
525 err(Debug),
526 )]
527 async fn delete_user(&self, mxid: &str, erase: bool) -> Result<(), anyhow::Error> {
528 let mxid = urlencoding::encode(mxid);
529
530 let response = self
531 .post(&format!("_synapse/admin/v1/deactivate/{mxid}"))
532 .json(&SynapseDeactivateUserRequest { erase })
533 .send_traced()
534 .await
535 .context("Failed to deactivate user in Synapse")?;
536
537 let response = response
538 .error_for_synapse_error()
539 .await
540 .context("Unexpected HTTP response while deactivating user in Synapse")?;
541
542 if response.status() != StatusCode::OK {
543 bail!(
544 "Unexpected HTTP code while deactivating user in Synapse: {}",
545 response.status()
546 );
547 }
548
549 Ok(())
550 }
551
552 #[tracing::instrument(
553 name = "homeserver.reactivate_user",
554 skip_all,
555 fields(
556 matrix.homeserver = self.homeserver,
557 matrix.mxid = mxid,
558 ),
559 err(Debug),
560 )]
561 async fn reactivate_user(&self, mxid: &str) -> Result<(), anyhow::Error> {
562 let mxid = urlencoding::encode(mxid);
563 let response = self
564 .put(&format!("_synapse/admin/v2/users/{mxid}"))
565 .json(&SynapseUser {
566 deactivated: Some(false),
567 ..SynapseUser::default()
568 })
569 .send_traced()
570 .await
571 .context("Failed to reactivate user in Synapse")?;
572
573 let response = response
574 .error_for_synapse_error()
575 .await
576 .context("Unexpected HTTP response while reactivating user in Synapse")?;
577
578 match response.status() {
579 StatusCode::CREATED | StatusCode::OK => Ok(()),
580 code => bail!("Unexpected HTTP code while reactivating user in Synapse: {code}",),
581 }
582 }
583
584 #[tracing::instrument(
585 name = "homeserver.set_displayname",
586 skip_all,
587 fields(
588 matrix.homeserver = self.homeserver,
589 matrix.mxid = mxid,
590 matrix.displayname = displayname,
591 ),
592 err(Debug),
593 )]
594 async fn set_displayname(&self, mxid: &str, displayname: &str) -> Result<(), anyhow::Error> {
595 let mxid = urlencoding::encode(mxid);
596 let response = self
597 .put(&format!("_matrix/client/v3/profile/{mxid}/displayname"))
598 .json(&SetDisplayNameRequest { displayname })
599 .send_traced()
600 .await
601 .context("Failed to set displayname in Synapse")?;
602
603 let response = response
604 .error_for_synapse_error()
605 .await
606 .context("Unexpected HTTP response while setting displayname in Synapse")?;
607
608 if response.status() != StatusCode::OK {
609 bail!(
610 "Unexpected HTTP code while setting displayname in Synapse: {}",
611 response.status()
612 );
613 }
614
615 Ok(())
616 }
617
618 #[tracing::instrument(
619 name = "homeserver.unset_displayname",
620 skip_all,
621 fields(
622 matrix.homeserver = self.homeserver,
623 matrix.mxid = mxid,
624 ),
625 err(Display),
626 )]
627 async fn unset_displayname(&self, mxid: &str) -> Result<(), anyhow::Error> {
628 self.set_displayname(mxid, "").await
629 }
630
631 #[tracing::instrument(
632 name = "homeserver.allow_cross_signing_reset",
633 skip_all,
634 fields(
635 matrix.homeserver = self.homeserver,
636 matrix.mxid = mxid,
637 ),
638 err(Debug),
639 )]
640 async fn allow_cross_signing_reset(&self, mxid: &str) -> Result<(), anyhow::Error> {
641 let mxid = urlencoding::encode(mxid);
642
643 let response = self
644 .post(&format!(
645 "_synapse/admin/v1/users/{mxid}/_allow_cross_signing_replacement_without_uia"
646 ))
647 .json(&SynapseAllowCrossSigningResetRequest {})
648 .send_traced()
649 .await
650 .context("Failed to allow cross-signing reset in Synapse")?;
651
652 let response = response
653 .error_for_synapse_error()
654 .await
655 .context("Unexpected HTTP response while allowing cross-signing reset in Synapse")?;
656
657 if response.status() != StatusCode::OK {
658 bail!(
659 "Unexpected HTTP code while allowing cross-signing reset in Synapse: {}",
660 response.status(),
661 );
662 }
663
664 Ok(())
665 }
666}