grapevine/src/api/client_server/media.rs
2025-02-21 20:45:58 +00:00

919 lines
26 KiB
Rust

use std::time::Duration;
use axum::response::IntoResponse;
use http::{
HeaderName, HeaderValue, Method,
header::{CONTENT_DISPOSITION, CONTENT_SECURITY_POLICY, CONTENT_TYPE},
};
use phf::{Set, phf_set};
use ruma::{
api::{
client::{
authenticated_media as authenticated_media_client,
error::ErrorKind,
media::{self as legacy_media, create_content},
},
federation::authenticated_media as authenticated_media_fed,
},
http_headers::{ContentDisposition, ContentDispositionType},
};
use tracing::{debug, error, info, warn};
use crate::{
Ar, Error, Ra, Result,
service::media::FileMeta,
services,
utils::{self, MxcData},
};
const MXC_LENGTH: usize = 32;
/// `Content-Type`s that can be rendered inline in a browser without risking XSS
///
/// Cargo-culted from Synapse. Note that SVG can contain inline JavaScript.
static INLINE_CONTENT_TYPES: Set<&str> = phf_set! {
// Keep sorted
"application/json",
"application/ld+json",
"audio/aac",
"audio/flac",
"audio/mp4",
"audio/mpeg",
"audio/ogg",
"audio/wav",
"audio/wave",
"audio/webm",
"audio/x-flac",
"audio/x-pn-wav",
"audio/x-wav",
"image/apng",
"image/avif",
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
"text/css",
"text/csv",
"text/plain",
"video/mp4",
"video/ogg",
"video/quicktime",
"video/webm",
};
/// Value for the `Content-Security-Policy` header
///
/// Cargo-culted from Synapse.
fn content_security_policy() -> HeaderValue {
[
"sandbox",
"default-src 'none'",
"script-src 'none'",
"plugin-types application/pdf",
"style-src 'unsafe-inline'",
"media-src 'self'",
"object-src 'self'",
]
.join("; ")
.try_into()
.expect("hardcoded header value should be valid")
}
/// Determine a `Content-Disposition` header that prevents XSS
// TODO: In some of the places this function is called, we could parse the
// desired filename out of an existing `Content-Disposition` header value, such
// as what we're storing in the database or what we receive over federation.
// Doing this correctly is tricky, so I'm skipping it for now.
fn content_disposition_for(
content_type: Option<&str>,
filename: Option<String>,
) -> ContentDisposition {
let disposition_type = match content_type {
Some(x) if INLINE_CONTENT_TYPES.contains(x) => {
ContentDispositionType::Inline
}
_ => ContentDispositionType::Attachment,
};
ContentDisposition {
disposition_type,
filename,
}
}
/// Set a header, but panic if it was already set
///
/// # Panics
///
/// Panics if the header was already set.
fn set_header_or_panic(
response: &mut axum::response::Response,
header_name: HeaderName,
header_value: HeaderValue,
) {
if let Some(header_value) = response.headers().get(&header_name) {
error!(?header_name, ?header_value, "unexpected pre-existing header");
panic!(
"expected {header_name:?} to be unset but it was set to \
{header_value:?}"
);
}
response.headers_mut().insert(header_name, header_value);
}
/// # `GET /_matrix/media/r0/config`
///
/// Returns max upload size.
#[allow(deprecated)] // unauthenticated media
pub(crate) async fn get_media_config_legacy_route(
_body: Ar<legacy_media::get_media_config::v3::Request>,
) -> Result<Ra<legacy_media::get_media_config::v3::Response>> {
Ok(Ra(legacy_media::get_media_config::v3::Response {
upload_size: services().globals.max_request_size(),
}))
}
/// # `GET /_matrix/client/v1/media/config`
///
/// Returns max upload size.
pub(crate) async fn get_media_config_route(
_body: Ar<authenticated_media_client::get_media_config::v1::Request>,
) -> Result<Ra<authenticated_media_client::get_media_config::v1::Response>> {
Ok(Ra(authenticated_media_client::get_media_config::v1::Response {
upload_size: services().globals.max_request_size(),
}))
}
/// # `POST /_matrix/media/r0/upload`
///
/// Permanently save media in the server.
///
/// - Some metadata will be saved in the database
/// - Media will be saved in the media/ directory
pub(crate) async fn create_content_route(
body: Ar<create_content::v3::Request>,
) -> Result<Ra<create_content::v3::Response>> {
let media_id = utils::random_string(MXC_LENGTH);
let mxc = MxcData::new(services().globals.server_name(), &media_id)?;
services()
.media
.create(
mxc.clone().into(),
body.filename
.clone()
.map(|filename| ContentDisposition {
disposition_type: ContentDispositionType::Inline,
filename: Some(filename),
})
.as_ref(),
body.content_type.clone(),
&body.file,
)
.await?;
Ok(Ra(create_content::v3::Response {
content_uri: mxc.into(),
blurhash: None,
}))
}
/// Whether or not to allow remote content to be loaded
#[derive(Clone, Copy, PartialEq, Eq)]
enum AllowRemote {
Yes,
No,
}
impl From<bool> for AllowRemote {
fn from(allow: bool) -> Self {
if allow {
Self::Yes
} else {
Self::No
}
}
}
struct RemoteResponse {
#[allow(unused)]
metadata: authenticated_media_fed::ContentMetadata,
content: authenticated_media_fed::Content,
}
/// Fetches remote media content from a URL specified in a
/// `/_matrix/federation/v1/media/*/{mediaId}` `Location` header
#[tracing::instrument]
async fn get_redirected_content(
location: String,
) -> Result<authenticated_media_fed::Content> {
let location = location.parse().map_err(|error| {
warn!(location, %error, "Invalid redirect location");
Error::BadServerResponse("Invalid redirect location")
})?;
let response = services()
.globals
.federation_client()
.execute(reqwest::Request::new(Method::GET, location))
.await?;
let content_type = response
.headers()
.get(CONTENT_TYPE)
.map(|value| {
value.to_str().map_err(|error| {
error!(
?value,
%error,
"Invalid Content-Type header"
);
Error::BadServerResponse("Invalid Content-Type header")
})
})
.transpose()?
.map(str::to_owned);
let content_disposition = response
.headers()
.get(CONTENT_DISPOSITION)
.map(|value| {
ContentDisposition::try_from(value.as_bytes()).map_err(|error| {
error!(
?value,
%error,
"Invalid Content-Disposition header"
);
Error::BadServerResponse("Invalid Content-Disposition header")
})
})
.transpose()?;
Ok(authenticated_media_fed::Content {
file: response.bytes().await?.to_vec(),
content_type,
content_disposition,
})
}
#[tracing::instrument(skip_all)]
async fn get_remote_content_via_federation_api(
mxc: &MxcData<'_>,
) -> Result<RemoteResponse, Error> {
let authenticated_media_fed::get_content::v1::Response {
metadata,
content,
} = services()
.sending
.send_federation_request(
mxc.server_name,
authenticated_media_fed::get_content::v1::Request {
media_id: mxc.media_id.to_owned(),
timeout_ms: Duration::from_secs(20),
},
)
.await?;
let content = match content {
authenticated_media_fed::FileOrLocation::File(content) => {
debug!("Got media from remote server");
content
}
authenticated_media_fed::FileOrLocation::Location(location) => {
debug!(location, "Following redirect");
get_redirected_content(location).await?
}
};
Ok(RemoteResponse {
metadata,
content,
})
}
#[allow(deprecated)] // unauthenticated media
#[tracing::instrument(skip_all)]
async fn get_remote_content_via_legacy_api(
mxc: &MxcData<'_>,
) -> Result<RemoteResponse, Error> {
let content_response = services()
.sending
.send_federation_request(
mxc.server_name,
legacy_media::get_content::v3::Request {
allow_remote: false,
server_name: mxc.server_name.to_owned(),
media_id: mxc.media_id.to_owned(),
timeout_ms: Duration::from_secs(20),
allow_redirect: false,
},
)
.await?;
Ok(RemoteResponse {
metadata: authenticated_media_fed::ContentMetadata {},
content: authenticated_media_fed::Content {
file: content_response.file,
content_disposition: content_response.content_disposition,
content_type: content_response.content_type,
},
})
}
#[tracing::instrument]
pub(crate) async fn get_remote_content(
mxc: &MxcData<'_>,
) -> Result<RemoteResponse, Error> {
let fed_result = get_remote_content_via_federation_api(mxc).await;
let response = match fed_result {
Ok(response) => {
debug!("Got remote content via authenticated media API");
response
}
Err(Error::Federation(_, error))
if error.error_kind() == Some(&ErrorKind::Unrecognized)
// https://github.com/t2bot/matrix-media-repo/issues/609
|| error.error_kind() == Some(&ErrorKind::Unauthorized) =>
{
info!(
"Remote server does not support authenticated media, falling \
back to deprecated API"
);
get_remote_content_via_legacy_api(mxc).await?
}
Err(e) => {
return Err(e);
}
};
services()
.media
.create(
mxc.clone().into(),
response.content.content_disposition.as_ref(),
response.content.content_type.clone(),
&response.content.file,
)
.await?;
Ok(response)
}
/// # `GET /_matrix/media/r0/download/{serverName}/{mediaId}`
///
/// Load media from our server or over federation.
///
/// - Only allows federation if `allow_remote` is true
#[allow(deprecated)] // unauthenticated media
pub(crate) async fn get_content_legacy_route(
body: Ar<legacy_media::get_content::v3::Request>,
) -> Result<axum::response::Response> {
use authenticated_media_client::get_content::v1::{
Request as AmRequest, Response as AmResponse,
};
use legacy_media::get_content::v3::{
Request as LegacyRequest, Response as LegacyResponse,
};
fn convert_request(
LegacyRequest {
server_name,
media_id,
timeout_ms,
..
}: LegacyRequest,
) -> AmRequest {
AmRequest {
server_name,
media_id,
timeout_ms,
}
}
fn convert_response(
AmResponse {
file,
content_type,
content_disposition,
}: AmResponse,
) -> LegacyResponse {
LegacyResponse {
file,
content_type,
content_disposition,
cross_origin_resource_policy: Some("cross-origin".to_owned()),
}
}
let allow_remote = body.allow_remote.into();
get_content_route_ruma(body.map_body(convert_request), allow_remote)
.await
.map(|response| {
let response = convert_response(response);
let mut r = Ra(response).into_response();
set_header_or_panic(
&mut r,
CONTENT_SECURITY_POLICY,
content_security_policy(),
);
r
})
}
/// # `GET /_matrix/client/v1/media/download/{serverName}/{mediaId}`
///
/// Load media from our server or over federation.
pub(crate) async fn get_content_route(
body: Ar<authenticated_media_client::get_content::v1::Request>,
) -> Result<axum::response::Response> {
get_content_route_ruma(body, AllowRemote::Yes).await.map(|x| {
let mut r = Ra(x).into_response();
set_header_or_panic(
&mut r,
CONTENT_SECURITY_POLICY,
content_security_policy(),
);
r
})
}
async fn get_content_route_ruma(
body: Ar<authenticated_media_client::get_content::v1::Request>,
allow_remote: AllowRemote,
) -> Result<authenticated_media_client::get_content::v1::Response> {
let mxc = MxcData::new(&body.server_name, &body.media_id)?;
if let Some((
FileMeta {
content_type,
..
},
file,
)) = services().media.get(mxc.clone().into()).await?
{
Ok(authenticated_media_client::get_content::v1::Response {
file,
content_disposition: Some(content_disposition_for(
content_type.as_deref(),
None,
)),
content_type,
})
} else if &*body.server_name != services().globals.server_name()
&& allow_remote == AllowRemote::Yes
{
let remote_response = get_remote_content(&mxc).await?;
Ok(authenticated_media_client::get_content::v1::Response {
file: remote_response.content.file,
content_disposition: Some(content_disposition_for(
remote_response.content.content_type.as_deref(),
None,
)),
content_type: remote_response.content.content_type,
})
} else {
Err(Error::BadRequest(ErrorKind::NotYetUploaded, "Media not found."))
}
}
/// # `GET /_matrix/media/r0/download/{serverName}/{mediaId}/{fileName}`
///
/// Load media from our server or over federation, permitting desired filename.
///
/// - Only allows federation if `allow_remote` is true
#[allow(deprecated)] // unauthenticated media
pub(crate) async fn get_content_as_filename_legacy_route(
body: Ar<legacy_media::get_content_as_filename::v3::Request>,
) -> Result<axum::response::Response> {
use authenticated_media_client::get_content_as_filename::v1::{
Request as AmRequest, Response as AmResponse,
};
use legacy_media::get_content_as_filename::v3::{
Request as LegacyRequest, Response as LegacyResponse,
};
fn convert_request(
LegacyRequest {
server_name,
media_id,
filename,
timeout_ms,
..
}: LegacyRequest,
) -> AmRequest {
AmRequest {
server_name,
media_id,
filename,
timeout_ms,
}
}
fn convert_response(
AmResponse {
file,
content_type,
content_disposition,
}: AmResponse,
) -> LegacyResponse {
LegacyResponse {
file,
content_type,
content_disposition,
cross_origin_resource_policy: Some("cross-origin".to_owned()),
}
}
let allow_remote = body.allow_remote.into();
get_content_as_filename_route_ruma(
body.map_body(convert_request),
allow_remote,
)
.await
.map(|response| {
let response = convert_response(response);
let mut r = Ra(response).into_response();
set_header_or_panic(
&mut r,
CONTENT_SECURITY_POLICY,
content_security_policy(),
);
r
})
}
/// # `GET /_matrix/client/v1/media/download/{serverName}/{mediaId}/{fileName}`
///
/// Load media from our server or over federation, permitting desired filename.
pub(crate) async fn get_content_as_filename_route(
body: Ar<authenticated_media_client::get_content_as_filename::v1::Request>,
) -> Result<axum::response::Response> {
get_content_as_filename_route_ruma(body, AllowRemote::Yes).await.map(|x| {
let mut r = Ra(x).into_response();
set_header_or_panic(
&mut r,
CONTENT_SECURITY_POLICY,
content_security_policy(),
);
r
})
}
async fn get_content_as_filename_route_ruma(
body: Ar<authenticated_media_client::get_content_as_filename::v1::Request>,
allow_remote: AllowRemote,
) -> Result<authenticated_media_client::get_content_as_filename::v1::Response> {
let mxc = MxcData::new(&body.server_name, &body.media_id)?;
if let Some((
FileMeta {
content_type,
..
},
file,
)) = services().media.get(mxc.clone().into()).await?
{
Ok(authenticated_media_client::get_content_as_filename::v1::Response {
file,
content_disposition: Some(content_disposition_for(
content_type.as_deref(),
Some(body.filename.clone()),
)),
content_type,
})
} else if &*body.server_name != services().globals.server_name()
&& allow_remote == AllowRemote::Yes
{
let remote_response = get_remote_content(&mxc).await?;
Ok(authenticated_media_client::get_content_as_filename::v1::Response {
content_disposition: Some(content_disposition_for(
remote_response.content.content_type.as_deref(),
Some(body.filename.clone()),
)),
content_type: remote_response.content.content_type,
file: remote_response.content.file,
})
} else {
Err(Error::BadRequest(ErrorKind::NotFound, "Media not found."))
}
}
fn fix_thumbnail_headers(r: &mut axum::response::Response) {
let content_type = r
.headers()
.get(CONTENT_TYPE)
.and_then(|x| std::str::from_utf8(x.as_ref()).ok())
.map(ToOwned::to_owned);
set_header_or_panic(r, CONTENT_SECURITY_POLICY, content_security_policy());
set_header_or_panic(
r,
CONTENT_DISPOSITION,
content_disposition_for(content_type.as_deref(), None)
.to_string()
.try_into()
.expect("generated header value should be valid"),
);
}
/// # `GET /_matrix/media/r0/thumbnail/{serverName}/{mediaId}`
///
/// Load media thumbnail from our server or over federation.
///
/// - Only allows federation if `allow_remote` is true
#[allow(deprecated)] // unauthenticated media
pub(crate) async fn get_content_thumbnail_legacy_route(
body: Ar<legacy_media::get_content_thumbnail::v3::Request>,
) -> Result<axum::response::Response> {
use authenticated_media_client::get_content_thumbnail::v1::{
Request as AmRequest, Response as AmResponse,
};
use legacy_media::get_content_thumbnail::v3::{
Request as LegacyRequest, Response as LegacyResponse,
};
fn convert_request(
LegacyRequest {
server_name,
media_id,
method,
width,
height,
timeout_ms,
animated,
..
}: LegacyRequest,
) -> AmRequest {
AmRequest {
server_name,
media_id,
method,
width,
height,
timeout_ms,
animated,
}
}
fn convert_response(
AmResponse {
file,
content_type,
..
}: AmResponse,
) -> LegacyResponse {
LegacyResponse {
file,
content_type,
content_disposition: None,
cross_origin_resource_policy: Some("cross-origin".to_owned()),
}
}
let allow_remote = body.allow_remote.into();
get_content_thumbnail_route_ruma(
body.map_body(convert_request),
allow_remote,
)
.await
.map(|response| {
let response = convert_response(response);
let mut r = Ra(response).into_response();
fix_thumbnail_headers(&mut r);
r
})
}
/// # `GET /_matrix/client/v1/media/thumbnail/{serverName}/{mediaId}`
///
/// Load media thumbnail from our server or over federation.
pub(crate) async fn get_content_thumbnail_route(
body: Ar<authenticated_media_client::get_content_thumbnail::v1::Request>,
) -> Result<axum::response::Response> {
get_content_thumbnail_route_ruma(body, AllowRemote::Yes).await.map(|x| {
let mut r = Ra(x).into_response();
fix_thumbnail_headers(&mut r);
r
})
}
#[tracing::instrument(skip_all)]
async fn get_remote_thumbnail_via_federation_api(
server_name: &ruma::ServerName,
request: authenticated_media_fed::get_content_thumbnail::v1::Request,
) -> Result<RemoteResponse, Error> {
let authenticated_media_fed::get_content_thumbnail::v1::Response {
metadata,
content,
} = services()
.sending
.send_federation_request(server_name, request)
.await?;
let content = match content {
authenticated_media_fed::FileOrLocation::File(content) => {
debug!("Got thumbnail from remote server");
content
}
authenticated_media_fed::FileOrLocation::Location(location) => {
debug!(location, "Following redirect");
get_redirected_content(location).await?
}
};
Ok(RemoteResponse {
metadata,
content,
})
}
#[allow(deprecated)] // unauthenticated media
#[tracing::instrument(skip_all)]
async fn get_remote_thumbnail_via_legacy_api(
server_name: &ruma::ServerName,
authenticated_media_fed::get_content_thumbnail::v1::Request {
media_id,
method,
width,
height,
timeout_ms,
animated,
}: authenticated_media_fed::get_content_thumbnail::v1::Request,
) -> Result<RemoteResponse, Error> {
let content_response = services()
.sending
.send_federation_request(
server_name,
legacy_media::get_content_thumbnail::v3::Request {
server_name: server_name.to_owned(),
allow_remote: false,
allow_redirect: false,
media_id,
method,
width,
height,
timeout_ms,
animated,
},
)
.await?;
Ok(RemoteResponse {
metadata: authenticated_media_fed::ContentMetadata {},
content: authenticated_media_fed::Content {
file: content_response.file,
content_disposition: None,
content_type: content_response.content_type,
},
})
}
#[tracing::instrument]
pub(crate) async fn get_remote_thumbnail(
server_name: &ruma::ServerName,
request: authenticated_media_fed::get_content_thumbnail::v1::Request,
) -> Result<RemoteResponse, Error> {
let fed_result =
get_remote_thumbnail_via_federation_api(server_name, request.clone())
.await;
let response = match fed_result {
Ok(response) => {
debug!("Got remote content via authenticated media API");
response
}
Err(Error::Federation(_, error))
if error.error_kind() == Some(&ErrorKind::Unrecognized)
// https://github.com/t2bot/matrix-media-repo/issues/609
|| error.error_kind() == Some(&ErrorKind::Unauthorized) =>
{
info!(
"Remote server does not support authenticated media, falling \
back to deprecated API"
);
get_remote_thumbnail_via_legacy_api(server_name, request.clone())
.await?
}
Err(e) => {
return Err(e);
}
};
Ok(response)
}
async fn get_content_thumbnail_route_ruma(
body: Ar<authenticated_media_client::get_content_thumbnail::v1::Request>,
allow_remote: AllowRemote,
) -> Result<authenticated_media_client::get_content_thumbnail::v1::Response> {
let mxc = MxcData::new(&body.server_name, &body.media_id)?;
let width = body.width.try_into().map_err(|_| {
Error::BadRequest(ErrorKind::InvalidParam, "Width is invalid.")
})?;
let height = body.height.try_into().map_err(|_| {
Error::BadRequest(ErrorKind::InvalidParam, "Height is invalid.")
})?;
let make_response = |file, content_type| {
authenticated_media_client::get_content_thumbnail::v1::Response {
file,
content_type,
content_disposition: None,
}
};
if let Some((
FileMeta {
content_type,
..
},
file,
)) = services()
.media
.get_thumbnail(mxc.clone().into(), width, height)
.await?
{
return Ok(make_response(file, content_type));
}
if &*body.server_name != services().globals.server_name()
&& allow_remote == AllowRemote::Yes
{
let get_thumbnail_response = get_remote_thumbnail(
&body.server_name,
authenticated_media_fed::get_content_thumbnail::v1::Request {
height: body.height,
width: body.width,
method: body.method.clone(),
media_id: body.media_id.clone(),
timeout_ms: Duration::from_secs(20),
// we don't support animated thumbnails, so don't try requesting
// one - we're allowed to ignore the client's request for an
// animated thumbnail
animated: Some(false),
},
)
.await;
match get_thumbnail_response {
Ok(resp) => {
services()
.media
.upload_thumbnail(
mxc.clone().into(),
None,
resp.content.content_type.clone(),
width,
height,
&resp.content.file,
)
.await?;
return Ok(make_response(
resp.content.file,
resp.content.content_type,
));
}
Err(error) => warn!(
%error,
"Failed to fetch thumbnail via federation, trying to fetch \
original media and create thumbnail ourselves"
),
}
get_remote_content(&mxc).await?;
if let Some((
FileMeta {
content_type,
..
},
file,
)) =
services().media.get_thumbnail(mxc.into(), width, height).await?
{
return Ok(make_response(file, content_type));
}
error!("Source media doesn't exist even after fetching it from remote");
}
Err(Error::BadRequest(ErrorKind::NotYetUploaded, "Media not found."))
}