diff --git a/.typos.toml b/.typos.toml index 1b3a20aac..81a8fe3ad 100644 --- a/.typos.toml +++ b/.typos.toml @@ -2,7 +2,6 @@ extend-exclude = ["*.csr", "*.lock", "pnpm-lock.yaml"] [default] - extend-ignore-re = [ "(?Rm)^.*(#|//|)$", # Ignore a line by making it trail with a `spellchecker:disable-line` comment "^[0-9a-f]{7,}$", # Commit hashes @@ -14,7 +13,9 @@ extend-ignore-re = [ "\\$[A-Z0-9+][A-Za-z0-9+]{6,}[a-z0-9+]", "\\b[a-z0-9+/=][A-Za-z0-9+/=]{7,}[a-z0-9+/=][A-Z]\\b", # In the renovate config - ".ontainer" + ".ontainer", + # Allow RDNS identifiers + "([A-Za-z0-9]+)\\.[A-Za-z0-9.]+" ] [default.extend-words] diff --git a/changelog.d/+e1ec15ea.feature.md b/changelog.d/+e1ec15ea.feature.md new file mode 100644 index 000000000..f3fa6ce2d --- /dev/null +++ b/changelog.d/+e1ec15ea.feature.md @@ -0,0 +1 @@ +The account management web UI now shows users' MSC4427 profile banners. Contributed by @ginger. diff --git a/src/web/mod.rs b/src/web/mod.rs index 847ca80fe..ac881c26b 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -132,6 +132,7 @@ pub fn build(services: &Services) -> Router { .nest("/about", about::build()) .nest("/account/", account::build()) .merge(debug::build()) + .nest("/media/", media::build()) .nest("/oauth2/", oauth::build()) .nest("/oidc/", oidc::build()) .merge(resources::build()) diff --git a/src/web/pages/components/mod.rs b/src/web/pages/components/mod.rs index 49b3a2f1b..561f2b630 100644 --- a/src/web/pages/components/mod.rs +++ b/src/web/pages/components/mod.rs @@ -1,11 +1,9 @@ use std::{collections::BTreeSet, time::SystemTime}; use askama::{Template, filters::HtmlSafe}; -use base64::Engine; -use conduwuit_core::{result::FlatOk, utils}; +use conduwuit_core::utils; use conduwuit_service::{ Services, - media::mxc::Mxc, oauth::{client_metadata::ClientMetadata, grant::Scope}, }; use ruma::{OwnedDeviceId, OwnedUserId, UserId, api::client::device::Device}; @@ -30,28 +28,8 @@ impl Avatar { pub(super) async fn for_local_user(services: &Services, user_id: &UserId) -> Self { let display_name = services.users.displayname(user_id).await.ok(); - let avatar_src = async { - let avatar_url = services.users.avatar_url(user_id).await.ok()?; - let (server_name, media_id) = avatar_url.parts().ok()?; - let file = services - .media - .get(&Mxc { media_id, server_name }) - .await - .flat_ok()?; - - Some(format!( - "data:{};base64,{}", - file.content_type - .unwrap_or_else(|| "application/octet-stream".to_owned()), - file.content - .map(|content| base64::prelude::BASE64_STANDARD.encode(content)) - .unwrap_or_default(), - )) - } - .await; - - let avatar_type = if let Some(avatar_src) = avatar_src { - AvatarType::Image(avatar_src) + let avatar_type = if services.users.avatar_url(user_id).await.is_ok() { + AvatarType::Image(profile_media_url(user_id, "avatar_url")) } else if let Some(initial) = display_name .as_ref() .and_then(|display_name| display_name.chars().next()) @@ -111,16 +89,29 @@ pub(super) struct UserCard { pub user_id: OwnedUserId, pub display_name: Option, pub avatar: Avatar, + pub banner: Option, } impl HtmlSafe for UserCard {} impl UserCard { + const BANNER_KEY: &str = "chat.commet.profile_banner"; + pub(super) async fn for_local_user(services: &Services, user_id: OwnedUserId) -> Self { let display_name = services.users.displayname(&user_id).await.ok(); let avatar = Avatar::for_local_user(services, &user_id).await; + let banner = if services + .users + .get_local_profile_field(&user_id, Self::BANNER_KEY.into()) + .await + .is_some() + { + Some(profile_media_url(&user_id, Self::BANNER_KEY)) + } else { + None + }; - Self { user_id, display_name, avatar } + Self { user_id, display_name, avatar, banner } } } @@ -215,3 +206,7 @@ pub(super) struct ClientScopes { } impl HtmlSafe for ClientScopes {} + +fn profile_media_url(user_id: &UserId, profile_key: &str) -> String { + format!("{}/media/profile/{user_id}/{profile_key}", crate::ROUTE_PREFIX) +} diff --git a/src/web/pages/media.rs b/src/web/pages/media.rs new file mode 100644 index 000000000..a8b2c03ef --- /dev/null +++ b/src/web/pages/media.rs @@ -0,0 +1,78 @@ +use axum::{ + Router, + extract::{Path, State}, + http::{self, HeaderMap}, + routing::get, +}; +use conduwuit_core::result::FlatOk; +use conduwuit_service::media::mxc::Mxc; +use ruma::{OwnedUserId, profile::ProfileFieldName}; +use serde::{Deserialize, Serialize}; + +use crate::{WebError, extract::Expect, pages::Result, response, session::User}; + +pub(crate) fn build() -> Router { + Router::new().route("/profile/{user_id}/{field}", get(get_profile_media)) +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct ProfileMediaPath { + user_id: OwnedUserId, + field: ProfileFieldName, +} + +async fn get_profile_media( + State(services): State, + Expect(Path(ProfileMediaPath { user_id, field })): Expect>, + headers: HeaderMap, + requesting_user: User, +) -> Result { + if requesting_user + .into_session() + .map(|session| session.user_id) + .as_ref() + != Some(&user_id) + { + return Err(WebError::Forbidden( + "You may not access this user's profile media.".to_owned(), + )); + } + + let Some(field) = services + .users + .get_local_profile_field(&user_id, field) + .await + else { + return Err(WebError::NotFound); + }; + + let value = field.value(); + let Some(Ok(mxc)) = value.as_str().map(Mxc::try_from) else { + return Err(WebError::BadRequest("Profile field value is not a MXC URI".to_owned())); + }; + + if let Some(Ok(etag)) = headers + .get(http::header::IF_NONE_MATCH) + .and_then(|v| v.to_str().ok()) + .map(Mxc::try_from) + && mxc == etag + { + return response!(http::StatusCode::NOT_MODIFIED); + } + + let Some(media) = services.media.get(&mxc).await.flat_ok() else { + return Err(WebError::NotFound); + }; + + let content = media.content.expect("media should have content"); + + response!(( + [ + (http::header::CONTENT_TYPE, media.content_type.unwrap_or_default()), + (http::header::CONTENT_LENGTH, content.len().to_string()), + (http::header::CACHE_CONTROL, "max-age=31536000".to_owned()), + (http::header::ETAG, mxc.to_string()), + ], + content + )) +} diff --git a/src/web/pages/mod.rs b/src/web/pages/mod.rs index c09e405d3..a9c169810 100644 --- a/src/web/pages/mod.rs +++ b/src/web/pages/mod.rs @@ -16,6 +16,7 @@ pub(super) mod account; mod components; pub(super) mod debug; pub(super) mod index; +pub(super) mod media; pub(super) mod oauth; pub(super) mod oidc; pub(super) mod resources; @@ -60,8 +61,11 @@ pub(super) async fn template_context_middleware( response.headers_mut().insert( header::CONTENT_SECURITY_POLICY, HeaderValue::from_str(&format!( - "default-src 'none'; style-src 'self'; img-src 'self' https: data:; script-src \ - 'nonce-{csp_nonce}'; child-src {child_src};" + // TODO: `unsafe-inline` for `style-src` defeats the purpose of having it, but + // it's necessary to set CSS variables using the `style` attribute without + // JavaScript. Come back to this once there's a less shitty solution. + "default-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' https: data:; \ + script-src 'nonce-{csp_nonce}'; child-src {child_src};" )) .expect("should be able to build CSP header"), ); diff --git a/src/web/pages/resources/common.css b/src/web/pages/resources/common.css index 838c8ec83..86aa3363d 100644 --- a/src/web/pages/resources/common.css +++ b/src/web/pages/resources/common.css @@ -3,10 +3,17 @@ --font-stack: sans-serif; --background-color: #fff; + --text-color: #000; + --text-color-dark: #f5ebeb; --secondary: #555; + --secondary-dark: #aaa; + --bg: oklch(0.76 0.0854 317.27); + --bg-dark: oklch(0.15 0.042 317.27); --panel-bg: oklch(0.91 0.042 317.27); + --panel-bg-dark: oklch(0.24 0.03 317.27); + --card-bg: oklch(from var(--panel-bg) calc(l - 0.05) c h); --c1: oklch(0.44 0.177 353.06); --c2: oklch(0.59 0.158 150.88); --avatar-color: var(--c2); @@ -19,6 +26,9 @@ radial-gradient(42.01% 79.63% at 52.86% 0%, #d9ff5333 0%, #fff0 100%), radial-gradient(79.67% 58.09% at 0% 0%, oklch(from var(--c1) var(--background-lightness) c h) 0%, #fff0 100%); + --glass-outline: 1px solid rgba(237,231,244,.1); + --glass-filter: blur(15px) brightness(0.6); + --normal-font-size: 1rem; --small-font-size: 0.8rem; @@ -27,10 +37,10 @@ @media (prefers-color-scheme: dark) { color-scheme: dark; - --text-color: #f5ebeb; - --secondary: #999; - --bg: oklch(0.15 0.042 317.27); - --panel-bg: oklch(0.24 0.03 317.27); + --text-color: var(--text-color-dark); + --secondary: var(--secondary-dark); + --bg: var(--bg-dark); + --panel-bg: var(--panel-bg-dark); --name-lightness: 0.8; --background-lightness: 0.2; @@ -330,7 +340,7 @@ ul.bullet-separated { } } -@media (max-width: 425px) { +@media (max-width: 450px) { main { padding-block-start: 2rem; width: 100%; diff --git a/src/web/pages/resources/components.css b/src/web/pages/resources/components.css index 3443837bb..12cd5ac64 100644 --- a/src/web/pages/resources/components.css +++ b/src/web/pages/resources/components.css @@ -27,7 +27,7 @@ align-items: center; gap: 16px; - background-color: oklch(from var(--panel-bg) calc(l - 0.05) c h); + background-color: var(--card-bg); border-radius: var(--border-radius-lg); padding: 16px; @@ -41,6 +41,8 @@ .id { color: var(--secondary); font-weight: normal; + font-size: small; + font-family: monospace; } } @@ -49,6 +51,38 @@ background-color: oklch(from red 0.2 c h); border: 1px dashed red; } + + &.has-banner { + background-image: linear-gradient(90deg, var(--card-bg) 40%, transparent 100%), var(--banner-url); + background-size: cover; + background-position: right; + + @media (max-width: 450px) { + --card-bg: oklch(from var(--panel-bg-dark) calc(l - 0.05) c h); + + & { + background-image: linear-gradient(90deg, oklch(from var(--card-bg) l c h / 0.9) 0%, transparent 40%), var(--banner-url); + } + + .info { + padding: 10px; + + border-radius: var(--border-radius-sm); + outline: var(--glass-outline); + outline-offset: -1px; + + backdrop-filter: var(--glass-filter); + + .name { + color: var(--text-color-dark); + } + + .id { + color: var(--secondary-dark); + } + } + } + } } .card-list { diff --git a/src/web/pages/templates/_components/user_card.html.j2 b/src/web/pages/templates/_components/user_card.html.j2 index bd02ed6ca..e1abbd7f9 100644 --- a/src/web/pages/templates/_components/user_card.html.j2 +++ b/src/web/pages/templates/_components/user_card.html.j2 @@ -1,4 +1,8 @@ +{% if let Some(banner) = banner %} +
+{% else %}
+{% endif %} {{ avatar }}
{% if let Some(display_name) = display_name %}