From 1638be0339e1636e2bad818d0ecce1c2f2b5fd55 Mon Sep 17 00:00:00 2001 From: Jason Volk Date: Tue, 27 Aug 2024 11:19:57 +0000 Subject: [PATCH] add authenticated media client api Signed-off-by: Jason Volk --- src/api/client/media.rs | 252 +++++++++++++++++++++++++++++++--- src/api/client/unversioned.rs | 1 + src/api/router.rs | 11 +- 3 files changed, 243 insertions(+), 21 deletions(-) diff --git a/src/api/client/media.rs b/src/api/client/media.rs index b8140ff7..12012711 100644 --- a/src/api/client/media.rs +++ b/src/api/client/media.rs @@ -1,14 +1,37 @@ +use std::time::Duration; + use axum::extract::State; use axum_client_ip::InsecureClientIp; use conduit::{ - utils::{self, content_disposition::make_content_disposition}, - Result, + err, + utils::{self, content_disposition::make_content_disposition, math::ruma_from_usize}, + Err, Result, +}; +use conduit_service::{ + media::{Dim, FileMeta, CACHE_CONTROL_IMMUTABLE, CORP_CROSS_ORIGIN, MXC_LENGTH}, + Services, +}; +use ruma::{ + api::client::{ + authenticated_media::{ + get_content, get_content_as_filename, get_content_thumbnail, get_media_config, get_media_preview, + }, + media::create_content, + }, + Mxc, UserId, }; -use conduit_service::media::MXC_LENGTH; -use ruma::{api::client::media::create_content, Mxc}; use crate::Ruma; +/// # `GET /_matrix/client/v1/media/config` +pub(crate) async fn get_media_config_route( + State(services): State, _body: Ruma, +) -> Result { + Ok(get_media_config::v1::Response { + upload_size: ruma_from_usize(services.globals.config.max_request_size), + }) +} + /// # `POST /_matrix/media/v3/upload` /// /// Permanently save media in the server. @@ -20,8 +43,11 @@ pub(crate) async fn create_content_route( State(services): State, InsecureClientIp(client): InsecureClientIp, body: Ruma, ) -> Result { - let sender_user = body.sender_user.as_ref().expect("user is authenticated"); - let content_disposition = make_content_disposition(None, body.content_type.as_deref(), body.filename.as_deref()); + let user = body.sender_user.as_ref().expect("user is authenticated"); + + let filename = body.filename.as_deref(); + let content_type = body.content_type.as_deref(); + let content_disposition = make_content_disposition(None, content_type, filename); let mxc = Mxc { server_name: services.globals.server_name(), media_id: &utils::random_string(MXC_LENGTH), @@ -29,17 +55,209 @@ pub(crate) async fn create_content_route( services .media - .create( - &mxc, - Some(sender_user), - Some(&content_disposition), - body.content_type.as_deref(), - &body.file, - ) - .await?; + .create(&mxc, Some(user), Some(&content_disposition), content_type, &body.file) + .await + .map(|()| create_content::v3::Response { + content_uri: mxc.to_string().into(), + blurhash: None, + }) +} - Ok(create_content::v3::Response { - content_uri: mxc.to_string().into(), - blurhash: None, +/// # `GET /_matrix/client/v1/media/thumbnail/{serverName}/{mediaId}` +/// +/// Load media thumbnail from our server or over federation. +#[tracing::instrument(skip_all, fields(%client), name = "media_thumbnail_get")] +pub(crate) async fn get_content_thumbnail_route( + State(services): State, InsecureClientIp(client): InsecureClientIp, + body: Ruma, +) -> Result { + let user = body.sender_user.as_ref().expect("user is authenticated"); + + let dim = Dim::from_ruma(body.width, body.height, body.method.clone())?; + let mxc = Mxc { + server_name: &body.server_name, + media_id: &body.media_id, + }; + + let FileMeta { + content, + content_type, + content_disposition, + } = fetch_thumbnail(&services, &mxc, user, body.timeout_ms, &dim).await?; + + Ok(get_content_thumbnail::v1::Response { + file: content.expect("entire file contents"), + content_type: content_type.map(Into::into), + cross_origin_resource_policy: Some(CORP_CROSS_ORIGIN.into()), + cache_control: Some(CACHE_CONTROL_IMMUTABLE.into()), + content_disposition, }) } + +/// # `GET /_matrix/client/v1/media/download/{serverName}/{mediaId}` +/// +/// Load media from our server or over federation. +#[tracing::instrument(skip_all, fields(%client), name = "media_get")] +pub(crate) async fn get_content_route( + State(services): State, InsecureClientIp(client): InsecureClientIp, + body: Ruma, +) -> Result { + let user = body.sender_user.as_ref().expect("user is authenticated"); + + let mxc = Mxc { + server_name: &body.server_name, + media_id: &body.media_id, + }; + + let FileMeta { + content, + content_type, + content_disposition, + } = fetch_file(&services, &mxc, user, body.timeout_ms, None).await?; + + Ok(get_content::v1::Response { + file: content.expect("entire file contents"), + content_type: content_type.map(Into::into), + cross_origin_resource_policy: Some(CORP_CROSS_ORIGIN.into()), + cache_control: Some(CACHE_CONTROL_IMMUTABLE.into()), + content_disposition, + }) +} + +/// # `GET /_matrix/client/v1/media/download/{serverName}/{mediaId}/{fileName}` +/// +/// Load media from our server or over federation as fileName. +#[tracing::instrument(skip_all, fields(%client), name = "media_get_af")] +pub(crate) async fn get_content_as_filename_route( + State(services): State, InsecureClientIp(client): InsecureClientIp, + body: Ruma, +) -> Result { + let user = body.sender_user.as_ref().expect("user is authenticated"); + + let mxc = Mxc { + server_name: &body.server_name, + media_id: &body.media_id, + }; + + let FileMeta { + content, + content_type, + content_disposition, + } = fetch_file(&services, &mxc, user, body.timeout_ms, Some(&body.filename)).await?; + + Ok(get_content_as_filename::v1::Response { + file: content.expect("entire file contents"), + content_type: content_type.map(Into::into), + cross_origin_resource_policy: Some(CORP_CROSS_ORIGIN.into()), + cache_control: Some(CACHE_CONTROL_IMMUTABLE.into()), + content_disposition, + }) +} + +/// # `GET /_matrix/client/v1/media/preview_url` +/// +/// Returns URL preview. +#[tracing::instrument(skip_all, fields(%client), name = "url_preview")] +pub(crate) async fn get_media_preview_route( + State(services): State, InsecureClientIp(client): InsecureClientIp, + body: Ruma, +) -> Result { + let sender_user = body.sender_user.as_ref().expect("user is authenticated"); + + let url = &body.url; + if !services.media.url_preview_allowed(url) { + return Err!(Request(Forbidden( + debug_warn!(%sender_user, %url, "URL is not allowed to be previewed") + ))); + } + + let preview = services.media.get_url_preview(url).await.map_err(|error| { + err!(Request(Unknown( + debug_error!(%sender_user, %url, ?error, "Failed to fetch URL preview.") + ))) + })?; + + serde_json::value::to_raw_value(&preview) + .map(get_media_preview::v1::Response::from_raw_value) + .map_err(|error| { + err!(Request(Unknown( + debug_error!(%sender_user, %url, ?error, "Failed to parse URL preview.") + ))) + }) +} + +async fn fetch_thumbnail( + services: &Services, mxc: &Mxc<'_>, user: &UserId, timeout_ms: Duration, dim: &Dim, +) -> Result { + let FileMeta { + content, + content_type, + content_disposition, + } = fetch_thumbnail_meta(services, mxc, user, timeout_ms, dim).await?; + + let content_disposition = Some(make_content_disposition( + content_disposition.as_ref(), + content_type.as_deref(), + None, + )); + + Ok(FileMeta { + content, + content_type, + content_disposition, + }) +} + +async fn fetch_file( + services: &Services, mxc: &Mxc<'_>, user: &UserId, timeout_ms: Duration, filename: Option<&str>, +) -> Result { + let FileMeta { + content, + content_type, + content_disposition, + } = fetch_file_meta(services, mxc, user, timeout_ms).await?; + + let content_disposition = Some(make_content_disposition( + content_disposition.as_ref(), + content_type.as_deref(), + filename, + )); + + Ok(FileMeta { + content, + content_type, + content_disposition, + }) +} + +async fn fetch_thumbnail_meta( + services: &Services, mxc: &Mxc<'_>, user: &UserId, timeout_ms: Duration, dim: &Dim, +) -> Result { + if let Some(filemeta) = services.media.get_thumbnail(mxc, dim).await? { + return Ok(filemeta); + } + + if services.globals.server_is_ours(mxc.server_name) { + return Err!(Request(NotFound("Local thumbnail not found."))); + } + + services + .media + .fetch_remote_thumbnail(mxc, Some(user), None, timeout_ms, dim) + .await +} + +async fn fetch_file_meta(services: &Services, mxc: &Mxc<'_>, user: &UserId, timeout_ms: Duration) -> Result { + if let Some(filemeta) = services.media.get(mxc).await? { + return Ok(filemeta); + } + + if services.globals.server_is_ours(mxc.server_name) { + return Err!(Request(NotFound("Local media not found."))); + } + + services + .media + .fetch_remote_content(mxc, Some(user), None, timeout_ms) + .await +} diff --git a/src/api/client/unversioned.rs b/src/api/client/unversioned.rs index 9a8f3220..82014cce 100644 --- a/src/api/client/unversioned.rs +++ b/src/api/client/unversioned.rs @@ -42,6 +42,7 @@ pub(crate) async fn get_supported_versions_route( "v1.3".to_owned(), "v1.4".to_owned(), "v1.5".to_owned(), + "v1.11".to_owned(), ], unstable_features: BTreeMap::from_iter([ ("org.matrix.e2e_cross_signing".to_owned(), true), diff --git a/src/api/router.rs b/src/api/router.rs index 05dac3ad..536ef6ea 100644 --- a/src/api/router.rs +++ b/src/api/router.rs @@ -139,6 +139,11 @@ pub fn build(router: Router, server: &Server) -> Router { .ruma_route(client::turn_server_route) .ruma_route(client::send_event_to_device_route) .ruma_route(client::create_content_route) + .ruma_route(client::get_content_thumbnail_route) + .ruma_route(client::get_content_route) + .ruma_route(client::get_content_as_filename_route) + .ruma_route(client::get_media_preview_route) + .ruma_route(client::get_media_config_route) .ruma_route(client::get_devices_route) .ruma_route(client::get_device_route) .ruma_route(client::update_device_route) @@ -247,8 +252,6 @@ async fn initial_sync(_uri: Uri) -> impl IntoResponse { err!(Request(GuestAccessForbidden("Guest access not implemented"))) } -async fn federation_disabled() -> impl IntoResponse { err!(Config("allow_federation", "Federation is disabled.")) } +async fn legacy_media_disabled() -> impl IntoResponse { err!(Request(Forbidden("Unauthenticated media is disabled."))) } -async fn legacy_media_disabled() -> impl IntoResponse { - err!(Config("allow_legacy_media", "Unauthenticated media is disabled.")) -} +async fn federation_disabled() -> impl IntoResponse { err!(Request(Forbidden("Federation is disabled."))) }