mirror of
https://forgejo.ellis.link/continuwuation/continuwuity.git
synced 2026-11-12 19:56:48 +00:00
feat: Show profile banners in web UI
This commit is contained in:
1 parent
f95292a83e
commit
32c8ff5b1b
9 files changed
+164
-36
No files matched your search
+3
-2
@@ -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]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
The account management web UI now shows users' MSC4427 profile banners. Contributed by @ginger.
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
))
|
||||
}
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in new issue
Block a user