feat: Show profile banners in web UI

This commit is contained in:
Ginger authored and Ellis Git committed 2026-09-11 14:14:39 +00:00
1 parent f95292a83e
commit 32c8ff5b1b
9 files changed
+164 -36

No files matched your search

+3 -2
View File
@@ -2,7 +2,6 @@
extend-exclude = ["*.csr", "*.lock", "pnpm-lock.yaml"]
[default]
extend-ignore-re = [
"(?Rm)^.*(#|//|<!--)\\s*spellchecker:disable-line(\\s*-->)$", # 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]
+1
View File
@@ -0,0 +1 @@
The account management web UI now shows users' MSC4427 profile banners. Contributed by @ginger.
+1
View File
@@ -132,6 +132,7 @@ pub fn build(services: &Services) -> Router<state::State> {
.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())
+21 -26
View File
@@ -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<String>,
pub avatar: Avatar,
pub banner: Option<String>,
}
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)
}
+78
View File
@@ -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<crate::State> {
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<crate::State>,
Expect(Path(ProfileMediaPath { user_id, field })): Expect<Path<ProfileMediaPath>>,
headers: HeaderMap,
requesting_user: User<true>,
) -> 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
))
}
+6 -2
View File
@@ -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"),
);
+15 -5
View File
@@ -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%;
+35 -1
View File
@@ -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 {
@@ -1,4 +1,8 @@
{% if let Some(banner) = banner %}
<div class="card red-avatar has-banner" style="--banner-url: url('{{ banner }}');">
{% else %}
<div class="card red-avatar">
{% endif %}
{{ avatar }}
<div class="info">
{% if let Some(display_name) = display_name %}