mas_matrix_synapse/
lib.rs

1// Copyright 2024 New Vector Ltd.
2// Copyright 2023, 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
7use 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
20/// Encountered when trying to register a user ID which has been taken.
21/// — <https://spec.matrix.org/v1.10/client-server-api/#other-error-codes>
22const M_USER_IN_USE: &str = "M_USER_IN_USE";
23/// Encountered when trying to register a user ID which is not valid.
24/// — <https://spec.matrix.org/v1.10/client-server-api/#other-error-codes>
25const 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/// Response body of
160/// `/_synapse/admin/v1/username_available?username={localpart}`
161#[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        // It's annoying, but the POST endpoint doesn't let us set the display name
351        // of the device, so we have to do it manually.
352        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        // Get the list of current devices
455        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        // First, delete all the devices that are not needed anymore
485        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        // Then, create the devices that are missing. There is no batching API to do
509        // this, so we do this sequentially, which is fine as the API is idempotent.
510        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}