Skip to main content

arminius_server_client/handlers/
profile.rs

1// SPDX-FileCopyrightText: 2025 BWI GmbH
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! Implements APIs for profile queries from the matrix spec
6
7#![allow(
8	clippy::allow_attributes,
9	reason = "This source file contains enums which allow `clippy::missing_docs_in_private_items`. But an exception to this lint will mistakenly report that it's unfulfilled when used with expect, even though it is not."
10)]
11
12mod tests;
13
14use arminius_lib_component::{
15	Component, ServerState,
16	error::{InternalServerError, MatrixApiError, ResultExt as _},
17	ruma_requests::ArminiusResponse,
18};
19use arminius_lib_db::DefaultDb;
20use arminius_lib_db_user::UserQueries as _;
21use arminius_server_federation_output::clients::FederationNatsClient;
22use axum::{Router, extract::State, http::StatusCode, routing::get};
23use ruma::{
24	OwnedMxcUri,
25	api::{
26		client::profile::{get_avatar_url, get_display_name, get_profile},
27		error::ErrorKind,
28	},
29};
30use serde_json::json;
31
32use crate::client_request::UnauthenticatedClientRequest;
33
34#[allow(
35	missing_docs,
36	clippy::missing_docs_in_private_items,
37	reason = "TODO(#2003): This exception predates us enabling `clippy::allow_attributes_without_reason`, so we don't know why it was enabled"
38)]
39#[derive(Debug, thiserror::Error, MatrixApiError)]
40enum ProfileQueryError {
41	#[error(transparent)]
42	InternalServerError(#[from] InternalServerError),
43
44	/// Error when the queried profile was not found.
45	#[error("User profile not found")]
46	#[matrix_api_error(status_code = StatusCode::NOT_FOUND, error_kind = ErrorKind::NotFound)]
47	ProfileNotFound,
48}
49
50/// Build a profile response with the provided optional fields.
51fn build_profile_response(
52	avatar_url: Option<OwnedMxcUri>,
53	display_name: Option<String>,
54) -> get_profile::v3::Response {
55	let mut response = get_profile::v3::Response::new();
56
57	if let Some(avatar_url) = avatar_url {
58		response.extend([("avatar_url".to_owned(), json!(avatar_url))]);
59	}
60
61	if let Some(display_name) = display_name {
62		response.extend([("displayname".to_owned(), json!(display_name))]);
63	}
64
65	response
66}
67
68/// Handler for `GET /_matrix/client/v3/profile/{userId}` (See
69/// <https://spec.matrix.org/latest/client-server-api/#get_matrixclientv3profileuserid>).
70///
71/// This endpoint is used to query a profile.
72async fn get_profile_handler(
73	mut state: State<ServerState>,
74	UnauthenticatedClientRequest(request): UnauthenticatedClientRequest<get_profile::v3::Request>,
75) -> Result<ArminiusResponse<get_profile::v3::Response>, ProfileQueryError> {
76	if request.user_id.server_name() == state.config.server_name {
77		let user = state
78			.database
79			.fetch_user(&request.user_id)
80			.await
81			.unrecoverable_error("Fetching user")?
82			.ok_or(ProfileQueryError::ProfileNotFound)?;
83
84		let avatar_url = user
85			.avatar_id
86			.as_ref()
87			.map(|id| crate::avatar_id_to_mxc(request.user_id.server_name(), id));
88
89		Ok(build_profile_response(avatar_url, user.display_name).into())
90	} else {
91		// TODO(#1042): Cache these responses in some way.
92		tracing::debug!("Got a foreign profile request. Making a federated profile request");
93		let client = FederationNatsClient::new(state.nats_connection.client.clone());
94		let federation_response = client
95			.get_federated_profile(request.user_id)
96			.await
97			.unrecoverable_error("Federation request failed")?
98			.unrecoverable_error("Federation request failed")?;
99
100		let avatar_url =
101			federation_response.get("avatar_url").and_then(|x| x.as_str().map(OwnedMxcUri::from));
102		let display_name =
103			federation_response.get("displayname").and_then(|x| x.as_str().map(ToOwned::to_owned));
104
105		Ok(build_profile_response(avatar_url, display_name).into())
106	}
107}
108
109/// Handler for `GET /_matrix/client/v3/profile/{userId}/displayname` (See
110/// <https://spec.matrix.org/latest/client-server-api/#get_matrixclientv3profileuseriddisplayname>).
111///
112/// This endpoint is used to query an user's display name (whether locally or on
113/// remote homeservers).
114async fn get_display_name_handler(
115	mut database: State<DefaultDb>,
116	UnauthenticatedClientRequest(request): UnauthenticatedClientRequest<
117		get_display_name::v3::Request,
118	>,
119) -> Result<ArminiusResponse<get_display_name::v3::Response>, ProfileQueryError> {
120	let user = database
121		.fetch_user(&request.user_id)
122		.await
123		.unrecoverable_error("Fetching user")?
124		.ok_or(ProfileQueryError::ProfileNotFound)?;
125
126	Ok(get_display_name::v3::Response::new(user.display_name).into())
127}
128
129/// Handler for `GET /_matrix/client/v3/profile/{userId}/avatar_url` (See
130/// <https://spec.matrix.org/latest/client-server-api/#get_matrixclientv3profileuseridavatar_url>).
131///
132/// This endpoint is used to query an user's avatar URL (whether locally or on
133/// remote homeservers).
134async fn get_avatar_url_handler(
135	mut database: State<DefaultDb>,
136	UnauthenticatedClientRequest(request): UnauthenticatedClientRequest<
137		get_avatar_url::v3::Request,
138	>,
139) -> Result<ArminiusResponse<get_avatar_url::v3::Response>, ProfileQueryError> {
140	let user = database
141		.fetch_user(&request.user_id)
142		.await
143		.unrecoverable_error("Fetching user")?
144		.ok_or(ProfileQueryError::ProfileNotFound)?;
145
146	Ok(get_avatar_url::v3::Response::new(
147		user.avatar_id
148			.as_ref()
149			.map(|id| crate::avatar_id_to_mxc(request.user_id.server_name(), id)),
150	)
151	.into())
152}
153
154/// Component implementing endpoints for account management
155#[derive(Debug)]
156pub struct ProfileComponent;
157
158impl Component for ProfileComponent {
159	fn router() -> Router<ServerState> {
160		Router::new()
161			.route("/_matrix/client/v3/profile/{userId}", get(get_profile_handler))
162			.route("/_matrix/client/v3/profile/{userId}/displayname", get(get_display_name_handler))
163			.route("/_matrix/client/v3/profile/{userId}/avatar_url", get(get_avatar_url_handler))
164	}
165}