// Unauthenticated media is deprecated #![allow(deprecated)] use std::time::Duration; use crate::{service::media::{FileMeta, UrlPreviewData}, services, utils, Error, Result, Ruma}; use http::header::{CONTENT_DISPOSITION, CONTENT_TYPE}; use ruma::{ api::{ client::{ authenticated_media::{ get_content, get_content_as_filename, get_content_thumbnail, get_media_config, }, error::{ErrorKind, RetryAfter}, media::{ self, create_content, get_media_preview, }, }, federation::authenticated_media::{self as federation_media, FileOrLocation}, }, http_headers::{ContentDisposition, ContentDispositionType}, media::Method, ServerName, UInt, }; use { webpage::HTML, reqwest::Url, std::{io::Cursor, net::IpAddr, sync::Arc}, image::io::Reader as ImgReader, }; const MXC_LENGTH: usize = 32; /// # `GET /_matrix/media/r0/config` /// /// Returns max upload size. pub async fn get_media_config_route( _body: Ruma, ) -> Result { Ok(media::get_media_config::v3::Response { upload_size: services().globals.max_request_size().into(), }) } /// # `GET /_matrix/client/v1/media/config` /// /// Returns max upload size. pub async fn get_media_config_auth_route( _body: Ruma, ) -> Result { Ok(get_media_config::v1::Response { upload_size: services().globals.max_request_size().into(), }) } async fn download_image( client: &reqwest::Client, url: &str, ) -> Result { let image = client.get(url).send().await?.bytes().await?; let mxc = format!( "mxc://{}/{}", services().globals.server_name(), utils::random_string(MXC_LENGTH) ); services().media .create(mxc.clone(), None, None, &image) .await?; let (width, height) = match ImgReader::new(Cursor::new(&image)).with_guessed_format() { Err(_) => (None, None), Ok(reader) => match reader.into_dimensions() { Err(_) => (None, None), Ok((width, height)) => (Some(width), Some(height)), }, }; Ok(UrlPreviewData { image: Some(mxc), image_size: Some(image.len()), image_width: width, image_height: height, ..Default::default() }) } async fn download_html( client: &reqwest::Client, url: &str, ) -> Result { let max_download_size = 300_000; let mut response = client.get(url).send().await?; let mut bytes: Vec = Vec::new(); while let Some(chunk) = response.chunk().await? { bytes.extend_from_slice(&chunk); if bytes.len() > max_download_size { break; } } let body = String::from_utf8_lossy(&bytes); let html = match HTML::from_string(body.to_string(), Some(url.to_owned())) { Ok(html) => html, Err(_) => { return Err(Error::BadRequest( ErrorKind::Unknown, "Failed to parse HTML", )) } }; let mut data = match html.opengraph.images.first() { None => UrlPreviewData::default(), Some(obj) => download_image(client, &obj.url).await?, }; let props = html.opengraph.properties; /* use OpenGraph title/description, but fall back to HTML if not available */ data.title = props.get("title").cloned().or(html.title); data.description = props.get("description").cloned().or(html.description); Ok(data) } fn url_request_allowed(addr: &IpAddr) -> bool { // could be implemented with reqwest when it supports IP filtering: // https://github.com/seanmonstar/reqwest/issues/1515 // These checks have been taken from the Rust core/net/ipaddr.rs crate, // IpAddr::V4.is_global() and IpAddr::V6.is_global(), as .is_global is not // yet stabilized. TODO: Once this is stable, this match can be simplified. match addr { IpAddr::V4(ip4) => { !(ip4.octets()[0] == 0 // "This network" || ip4.is_private() || (ip4.octets()[0] == 100 && (ip4.octets()[1] & 0b1100_0000 == 0b0100_0000)) // is_shared() || ip4.is_loopback() || ip4.is_link_local() // addresses reserved for future protocols (`192.0.0.0/24`) || (ip4.octets()[0] == 192 && ip4.octets()[1] == 0 && ip4.octets()[2] == 0) || ip4.is_documentation() || (ip4.octets()[0] == 198 && (ip4.octets()[1] & 0xfe) == 18) // is_benchmarking() || (ip4.octets()[0] & 240 == 240 && !ip4.is_broadcast()) // is_reserved() || ip4.is_broadcast()) } IpAddr::V6(ip6) => { !(ip6.is_unspecified() || ip6.is_loopback() // IPv4-mapped Address (`::ffff:0:0/96`) || matches!(ip6.segments(), [0, 0, 0, 0, 0, 0xffff, _, _]) // IPv4-IPv6 Translat. (`64:ff9b:1::/48`) || matches!(ip6.segments(), [0x64, 0xff9b, 1, _, _, _, _, _]) // Discard-Only Address Block (`100::/64`) || matches!(ip6.segments(), [0x100, 0, 0, 0, _, _, _, _]) // IETF Protocol Assignments (`2001::/23`) || (matches!(ip6.segments(), [0x2001, b, _, _, _, _, _, _] if b < 0x200) && !( // Port Control Protocol Anycast (`2001:1::1`) u128::from_be_bytes(ip6.octets()) == 0x2001_0001_0000_0000_0000_0000_0000_0001 // Traversal Using Relays around NAT Anycast (`2001:1::2`) || u128::from_be_bytes(ip6.octets()) == 0x2001_0001_0000_0000_0000_0000_0000_0002 // AMT (`2001:3::/32`) || matches!(ip6.segments(), [0x2001, 3, _, _, _, _, _, _]) // AS112-v6 (`2001:4:112::/48`) || matches!(ip6.segments(), [0x2001, 4, 0x112, _, _, _, _, _]) // ORCHIDv2 (`2001:20::/28`) || matches!(ip6.segments(), [0x2001, b, _, _, _, _, _, _] if b >= 0x20 && b <= 0x2F) )) || ((ip6.segments()[0] == 0x2001) && (ip6.segments()[1] == 0xdb8)) // is_documentation() || ((ip6.segments()[0] & 0xfe00) == 0xfc00) // is_unique_local() || ((ip6.segments()[0] & 0xffc0) == 0xfe80)) // is_unicast_link_local } } } async fn request_url_preview(url: &Url) -> Result { // resolve host to IP to ensure it's not a local IP (host guaranteed to not be None) let dns_resolver = services().globals.dns_resolver(); match dns_resolver.lookup_ip(url.host_str().unwrap()).await { Err(_) => { return Err(Error::BadServerResponse("Failed to resolve media preview host")); }, Ok(lookup) if lookup.iter().any(|ip| !url_request_allowed(&ip)) => { return Err(Error::BadRequest(ErrorKind::Unknown, "Requesting from this address forbidden")); }, Ok(_) => { }, } let client = services().globals.default_client(); let response = client.head(url.as_str()).send().await?; let content_type = match response .headers() .get(reqwest::header::CONTENT_TYPE) .and_then(|x| x.to_str().ok()) { Some(ct) => ct, None => { return Err(Error::BadRequest( ErrorKind::Unknown, "Unknown Content-Type", )) } }; let data = match content_type { html if html.starts_with("text/html") => download_html(&client, url.as_str()).await?, img if img.starts_with("image/") => download_image(&client, url.as_str()).await?, _ => { return Err(Error::BadRequest( ErrorKind::Unknown, "Unsupported Content-Type", )) } }; services().media.set_url_preview(url.as_str(), &data).await?; Ok(data) } async fn get_url_preview(url: &Url) -> Result { if let Some(preview) = services().media.get_url_preview(url.as_str()).await { return Ok(preview); } // ensure that only one request is made per URL let mutex_request = Arc::clone( services() .media .url_preview_mutex .write() .unwrap() .entry(url.as_str().to_owned()) .or_default(), ); let _request_lock = mutex_request.lock().await; match services().media.get_url_preview(url.as_str()).await { Some(preview) => Ok(preview), None => request_url_preview(url).await } } fn url_preview_allowed(url: &Url) -> bool { const DEFAULT_ALLOWLIST: &[&str] = &[ "matrix.org", "mastodon.social", "youtube.com", "wikipedia.org", ]; let mut host = match url.host_str() { None => return false, Some(h) => h.to_lowercase(), }; let allowlist = services().globals.url_preview_allowlist(); if allowlist.contains(&"*".to_owned()) { return true; } while !host.is_empty() { if allowlist.contains(&host) { return true; } if allowlist.contains(&"default".to_owned()) && DEFAULT_ALLOWLIST.contains(&host.as_str()) { return true; } /* also check higher level domains, so that e.g. `en.m.wikipedia.org` is matched by `wikipedia.org` on allowlist. */ host = match host.split_once('.') { None => return false, Some((_, domain)) => domain.to_owned(), } } false } /// # `GET /_matrix/media/r0/preview_url` /// /// Returns URL preview. pub async fn get_media_preview_route( body: Ruma, ) -> Result { let url = match Url::parse(&body.url) { Err(_) => { return Err(Error::BadRequest( ErrorKind::Unknown, "Not a valid URL", )); }, Ok(u) if u.scheme() != "http" && u.scheme() != "https" || u.host().is_none() => { return Err(Error::BadRequest( ErrorKind::Unknown, "Not a valid HTTP URL", )); }, Ok(url) => url, }; if !url_preview_allowed(&url) { return Err(Error::BadRequest( ErrorKind::Unknown, "Previewing URL not allowed", )); } if let Ok(preview) = get_url_preview(&url).await { let res = serde_json::value::to_raw_value(&preview).expect("Converting to JSON failed"); return Ok(get_media_preview::v3::Response::from_raw_value(res)); } Err(Error::BadRequest( ErrorKind::LimitExceeded { retry_after: Some(RetryAfter::Delay(Duration::from_secs(5))), }, "Retry later", )) } /// # `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 async fn create_content_route( body: Ruma, ) -> Result { let mxc = format!( "mxc://{}/{}", services().globals.server_name(), utils::random_string(MXC_LENGTH) ); services() .media .create( mxc.clone(), Some( ContentDisposition::new(ContentDispositionType::Inline) .with_filename(body.filename.clone()), ), body.content_type.as_deref(), &body.file, ) .await?; Ok(create_content::v3::Response { content_uri: mxc.into(), blurhash: None, }) } pub async fn get_remote_content( mxc: &str, server_name: &ServerName, media_id: String, ) -> Result { let content_response = match services() .sending .send_federation_request( server_name, federation_media::get_content::v1::Request { media_id: media_id.clone(), timeout_ms: Duration::from_secs(20), }, ) .await { Ok(federation_media::get_content::v1::Response { metadata: _, content: FileOrLocation::File(content), }) => get_content::v1::Response { file: content.file, content_type: content.content_type, content_disposition: content.content_disposition, }, Ok(federation_media::get_content::v1::Response { metadata: _, content: FileOrLocation::Location(url), }) => get_location_content(url).await?, Err(Error::BadRequest(ErrorKind::Unrecognized, _)) => { let media::get_content::v3::Response { file, content_type, content_disposition, .. } = services() .sending .send_federation_request( server_name, media::get_content::v3::Request { server_name: server_name.to_owned(), media_id, timeout_ms: Duration::from_secs(20), allow_remote: false, allow_redirect: true, }, ) .await?; get_content::v1::Response { file, content_type, content_disposition, } } Err(e) => return Err(e), }; services() .media .create( mxc.to_owned(), content_response.content_disposition.clone(), content_response.content_type.as_deref(), &content_response.file, ) .await?; Ok(content_response) } /// # `GET /_matrix/media/r0/download/{serverName}/{mediaId}` /// /// Load media from our server or over federation. /// /// - Only allows federation if `allow_remote` is true pub async fn get_content_route( body: Ruma, ) -> Result { let get_content::v1::Response { file, content_disposition, content_type, } = get_content(&body.server_name, body.media_id.clone(), body.allow_remote).await?; Ok(media::get_content::v3::Response { file, content_type, content_disposition, cross_origin_resource_policy: Some("cross-origin".to_owned()), }) } /// # `GET /_matrix/client/v1/media/download/{serverName}/{mediaId}` /// /// Load media from our server or over federation. pub async fn get_content_auth_route( body: Ruma, ) -> Result { get_content(&body.server_name, body.media_id.clone(), true).await } async fn get_content( server_name: &ServerName, media_id: String, allow_remote: bool, ) -> Result { let mxc = format!("mxc://{}/{}", server_name, media_id); if let Ok(Some(FileMeta { content_disposition, content_type, file, })) = services().media.get(mxc.clone()).await { Ok(get_content::v1::Response { file, content_type, content_disposition: Some(content_disposition), }) } else if server_name != services().globals.server_name() && allow_remote { let remote_content_response = get_remote_content(&mxc, server_name, media_id.clone()).await?; Ok(get_content::v1::Response { content_disposition: remote_content_response.content_disposition, content_type: remote_content_response.content_type, file: remote_content_response.file, }) } else { Err(Error::BadRequest(ErrorKind::NotFound, "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 pub async fn get_content_as_filename_route( body: Ruma, ) -> Result { let get_content_as_filename::v1::Response { file, content_type, content_disposition, } = get_content_as_filename( &body.server_name, body.media_id.clone(), body.filename.clone(), body.allow_remote, ) .await?; Ok(media::get_content_as_filename::v3::Response { file, content_type, content_disposition, cross_origin_resource_policy: Some("cross-origin".to_owned()), }) } /// # `GET /_matrix/client/v1/media/download/{serverName}/{mediaId}/{fileName}` /// /// Load media from our server or over federation, permitting desired filename. pub async fn get_content_as_filename_auth_route( body: Ruma, ) -> Result { get_content_as_filename( &body.server_name, body.media_id.clone(), body.filename.clone(), true, ) .await } async fn get_content_as_filename( server_name: &ServerName, media_id: String, filename: String, allow_remote: bool, ) -> Result { let mxc = format!("mxc://{}/{}", server_name, media_id); if let Ok(Some(FileMeta { file, content_type, .. })) = services().media.get(mxc.clone()).await { Ok(get_content_as_filename::v1::Response { file, content_type, content_disposition: Some( ContentDisposition::new(ContentDispositionType::Inline) .with_filename(Some(filename.clone())), ), }) } else if server_name != services().globals.server_name() && allow_remote { let remote_content_response = get_remote_content(&mxc, server_name, media_id.clone()).await?; Ok(get_content_as_filename::v1::Response { content_disposition: Some( ContentDisposition::new(ContentDispositionType::Inline) .with_filename(Some(filename.clone())), ), content_type: remote_content_response.content_type, file: remote_content_response.file, }) } else { Err(Error::BadRequest(ErrorKind::NotFound, "Media not found.")) } } /// # `GET /_matrix/media/r0/thumbnail/{serverName}/{mediaId}` /// /// Load media thumbnail from our server or over federation. /// /// - Only allows federation if `allow_remote` is true pub async fn get_content_thumbnail_route( body: Ruma, ) -> Result { let get_content_thumbnail::v1::Response { file, content_type } = get_content_thumbnail( &body.server_name, body.media_id.clone(), body.height, body.width, body.method.clone(), body.animated, body.allow_remote, ) .await?; Ok(media::get_content_thumbnail::v3::Response { file, content_type, cross_origin_resource_policy: Some("cross-origin".to_owned()), }) } /// # `GET /_matrix/client/v1/media/thumbnail/{serverName}/{mediaId}` /// /// Load media thumbnail from our server or over federation. pub async fn get_content_thumbnail_auth_route( body: Ruma, ) -> Result { get_content_thumbnail( &body.server_name, body.media_id.clone(), body.height, body.width, body.method.clone(), body.animated, true, ) .await } async fn get_content_thumbnail( server_name: &ServerName, media_id: String, height: UInt, width: UInt, method: Option, animated: Option, allow_remote: bool, ) -> Result { let mxc = format!("mxc://{}/{}", server_name, media_id); if let Ok(Some(FileMeta { file, content_type, .. })) = services() .media .get_thumbnail( mxc.clone(), width .try_into() .map_err(|_| Error::BadRequest(ErrorKind::InvalidParam, "Width is invalid."))?, height .try_into() .map_err(|_| Error::BadRequest(ErrorKind::InvalidParam, "Height is invalid."))?, ) .await { Ok(get_content_thumbnail::v1::Response { file, content_type }) } else if server_name != services().globals.server_name() && allow_remote { let thumbnail_response = match services() .sending .send_federation_request( server_name, federation_media::get_content_thumbnail::v1::Request { height, width, method: method.clone(), media_id: media_id.clone(), timeout_ms: Duration::from_secs(20), animated, }, ) .await { Ok(federation_media::get_content_thumbnail::v1::Response { metadata: _, content: FileOrLocation::File(content), }) => get_content_thumbnail::v1::Response { file: content.file, content_type: content.content_type, }, Ok(federation_media::get_content_thumbnail::v1::Response { metadata: _, content: FileOrLocation::Location(url), }) => { let get_content::v1::Response { file, content_type, .. } = get_location_content(url).await?; get_content_thumbnail::v1::Response { file, content_type } } Err(Error::BadRequest(ErrorKind::Unrecognized, _)) => { let media::get_content_thumbnail::v3::Response { file, content_type, .. } = services() .sending .send_federation_request( server_name, media::get_content_thumbnail::v3::Request { height, width, method: method.clone(), server_name: server_name.to_owned(), media_id: media_id.clone(), timeout_ms: Duration::from_secs(20), allow_redirect: false, animated, allow_remote: false, }, ) .await?; get_content_thumbnail::v1::Response { file, content_type } } Err(e) => return Err(e), }; services() .media .upload_thumbnail( mxc, thumbnail_response.content_type.as_deref(), width.try_into().expect("all UInts are valid u32s"), height.try_into().expect("all UInts are valid u32s"), &thumbnail_response.file, ) .await?; Ok(thumbnail_response) } else { Err(Error::BadRequest(ErrorKind::NotFound, "Media not found.")) } } async fn get_location_content(url: String) -> Result { let client = services().globals.default_client(); let response = client.get(url).send().await?; let headers = response.headers(); let content_type = headers .get(CONTENT_TYPE) .and_then(|header| header.to_str().ok()) .map(ToOwned::to_owned); let content_disposition = headers .get(CONTENT_DISPOSITION) .map(|header| header.as_bytes()) .map(TryFrom::try_from) .and_then(Result::ok); let file = response.bytes().await?.to_vec(); Ok(get_content::v1::Response { file, content_type, content_disposition, }) }