fabrica

hanna/fabrica

feat(web): user profiles, settings editor, and avatars

dd5f6ed · hanna committed on 2026-07-25

Redesign the user page as a profile: avatar, name, `User · username`,
pronouns, bio, and location/website/GitHub/Mastodon links with their icons.
Add a settings editor (display name, pronouns, bio, location, website, and
named social handles) plus an avatar uploader. Serve `/avatar/{username}`
from an uploaded PNG/JPEG/GIF/WebP (≤ 1 MiB, stored on disk) or a stable
FNV-derived identicon SVG — no gravatar, no network requests (§9.5). Both
mutating routes verify the double-submit CSRF token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
8 files changed · +495 −39UnifiedSplit
Cargo.lock +18 −0
@@ -348,6 +348,7 @@ dependencies = [
348 "matchit",348 "matchit",
349 "memchr",349 "memchr",
350 "mime",350 "mime",
351 "multer",
351 "percent-encoding",352 "percent-encoding",
352 "pin-project-lite",353 "pin-project-lite",
353 "serde_core",354 "serde_core",
@@ -2946,6 +2947,23 @@ dependencies = [
2946]2947]
29472948
2948[[package]]2949[[package]]
2950name = "multer"
2951version = "3.1.0"
2952source = "registry+https://github.com/rust-lang/crates.io-index"
2953checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
2954dependencies = [
2955 "bytes",
2956 "encoding_rs",
2957 "futures-util",
2958 "http",
2959 "httparse",
2960 "memchr",
2961 "mime",
2962 "spin",
2963 "version_check",
2964]
2965
2966[[package]]
2949name = "native-tls"2967name = "native-tls"
2950version = "0.2.18"2968version = "0.2.18"
2951source = "registry+https://github.com/rust-lang/crates.io-index"2969source = "registry+https://github.com/rust-lang/crates.io-index"
Cargo.toml +1 −1
@@ -138,7 +138,7 @@ lettre = { version = "0.11", default-features = false, features = [
138# Web stack. No TLS/rustls features anywhere (TLS terminates at a reverse proxy),138# Web stack. No TLS/rustls features anywhere (TLS terminates at a reverse proxy),
139# so `ring` stays out of the graph. Versions are the current mutually-compatible139# so `ring` stays out of the graph. Versions are the current mutually-compatible
140# axum 0.8 set.140# axum 0.8 set.
141axum = "0.8"141axum = { version = "0.8", features = ["multipart"] }
142axum-extra = { version = "0.12", features = ["cookie"] }142axum-extra = { version = "0.12", features = ["cookie"] }
143maud = { version = "0.27", features = ["axum"] }143maud = { version = "0.27", features = ["axum"] }
144tower = { version = "0.5", features = ["util"] }144tower = { version = "0.5", features = ["util"] }
assets/base.css +73 −0
@@ -395,6 +395,75 @@ select:focus {
395 font-size: 0.82em;395 font-size: 0.82em;
396}396}
397397
398/* ---- Avatars & profile header ---- */
399.avatar {
400 border-radius: 50%;
401 object-fit: cover;
402 background: var(--fb-bg-inset);
403 border: 1px solid var(--fb-border);
404}
405.avatar-lg {
406 width: 5rem;
407 height: 5rem;
408}
409.avatar-xl {
410 width: 6rem;
411 height: 6rem;
412}
413.profile-head {
414 display: flex;
415 gap: 1.25rem;
416 align-items: flex-start;
417 margin-bottom: 1.5rem;
418 padding-bottom: 1.5rem;
419 border-bottom: 1px solid var(--fb-border);
420}
421.profile-meta {
422 min-width: 0;
423}
424.profile-meta h1 {
425 margin-bottom: 0.15rem;
426}
427.profile-sub {
428 display: inline-flex;
429 align-items: center;
430 gap: 0.4rem;
431}
432.profile-pronouns {
433 margin-left: 0.5rem;
434 padding: 0.05rem 0.5rem;
435 border: 1px solid var(--fb-border);
436 border-radius: 999px;
437 font-size: 0.82em;
438}
439.profile-bio {
440 margin: 0.6rem 0 0;
441 white-space: pre-wrap;
442}
443.profile-links {
444 list-style: none;
445 margin: 0.6rem 0 0;
446 padding: 0;
447 display: flex;
448 flex-wrap: wrap;
449 gap: 0.25rem 1.25rem;
450}
451.profile-links li {
452 display: inline-flex;
453 align-items: center;
454 gap: 0.4rem;
455}
456.avatar-settings {
457 display: flex;
458 align-items: center;
459 gap: 1.25rem;
460 flex-wrap: wrap;
461}
462.avatar-settings form {
463 flex: 1;
464 min-width: 15rem;
465}
466
398/* ---- Tables ---- */467/* ---- Tables ---- */
399table.list {468table.list {
400 width: 100%;469 width: 100%;
@@ -703,4 +772,8 @@ td.diff-empty {
703 display: block;772 display: block;
704 overflow-x: auto;773 overflow-x: auto;
705 }774 }
775 /* Stack the profile header on narrow screens. */
776 .profile-head {
777 flex-direction: column;
778 }
706}779}
crates/web/src/avatar.rs +165 −0
@@ -0,0 +1,165 @@
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Avatars: an uploaded image when the user has one, else a deterministic
6//! identicon generated from the username. Bytes live on disk under
7//! `{storage.data_dir}/avatars/{id}`; the `users.avatar_mime` column records the
8//! content type (its presence means "serve the file"). No network requests and
9//! no remote gravatar (§9.5).
10
11use std::fmt::Write as _;
12use std::path::PathBuf;
13
14use axum::extract::{Multipart, Path, State};
15use axum::http::{StatusCode, header};
16use axum::response::{IntoResponse, Redirect, Response};
17use axum_extra::extract::cookie::CookieJar;
18
19use crate::AppState;
20use crate::error::AppError;
21use crate::session::RequireUser;
22
23/// The largest avatar upload accepted, in bytes (1 `MiB`).
24const MAX_AVATAR_BYTES: usize = 1024 * 1024;
25
26/// The raster image types accepted for upload. SVG is deliberately excluded to
27/// avoid serving user-supplied markup; identicons are our own SVG.
28const ALLOWED_MIME: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"];
29
30/// The on-disk path of a user's uploaded avatar.
31fn avatar_path(state: &AppState, user_id: &str) -> PathBuf {
32 state.config.storage.data_dir.join("avatars").join(user_id)
33}
34
35/// `GET /avatar/{username}` — the user's uploaded avatar, or a generated
36/// identicon. Always renders an image so `<img>` tags never break.
37pub async fn show(State(state): State<AppState>, Path(username): Path<String>) -> Response {
38 let user = state.store.user_by_username(&username).await.ok().flatten();
39 if let Some(user) = &user
40 && let Some(mime) = &user.avatar_mime
41 && let Ok(bytes) = tokio::fs::read(avatar_path(&state, &user.id)).await
42 {
43 return (
44 [
45 (header::CONTENT_TYPE, mime.as_str()),
46 (header::CACHE_CONTROL, "private, max-age=60"),
47 (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
48 ],
49 bytes,
50 )
51 .into_response();
52 }
53 (
54 [
55 (header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"),
56 (header::CACHE_CONTROL, "public, max-age=3600"),
57 ],
58 identicon(&username),
59 )
60 .into_response()
61}
62
63/// `POST /settings/avatar` — accept an image upload, store it, and record its
64/// content type. Multipart fields: `_csrf` (token) and `avatar` (the file).
65pub async fn upload(
66 State(state): State<AppState>,
67 RequireUser(user): RequireUser,
68 jar: CookieJar,
69 mut multipart: Multipart,
70) -> Response {
71 let mut csrf: Option<String> = None;
72 let mut mime: Option<String> = None;
73 let mut data: Option<Vec<u8>> = None;
74
75 while let Ok(Some(field)) = multipart.next_field().await {
76 match field.name() {
77 Some("_csrf") => csrf = field.text().await.ok(),
78 Some("avatar") => {
79 let content_type = field.content_type().map(str::to_string);
80 match field.bytes().await {
81 Ok(bytes) if !bytes.is_empty() => {
82 mime = content_type;
83 data = Some(bytes.to_vec());
84 }
85 _ => {}
86 }
87 }
88 _ => {}
89 }
90 }
91
92 // CSRF: the double-submit cookie must match the submitted token.
93 if crate::session::verify_csrf(&jar, &axum::http::HeaderMap::new(), csrf.as_deref()).is_err() {
94 return AppError::Forbidden.into_response();
95 }
96
97 let (Some(bytes), Some(mime)) = (data, mime) else {
98 // Nothing chosen — treat as a no-op and return to settings.
99 return Redirect::to("/settings").into_response();
100 };
101 if bytes.len() > MAX_AVATAR_BYTES {
102 return AppError::BadRequest("Avatar must be 1 MiB or smaller.".to_string())
103 .into_response();
104 }
105 if !ALLOWED_MIME.contains(&mime.as_str()) {
106 return AppError::BadRequest("Avatar must be a PNG, JPEG, GIF, or WebP image.".to_string())
107 .into_response();
108 }
109
110 let dir = state.config.storage.data_dir.join("avatars");
111 if let Err(e) = tokio::fs::create_dir_all(&dir).await {
112 return AppError::internal(e).into_response();
113 }
114 if let Err(e) = tokio::fs::write(dir.join(&user.id), &bytes).await {
115 return AppError::internal(e).into_response();
116 }
117 if let Err(e) = state.store.set_avatar_mime(&user.id, Some(&mime)).await {
118 return AppError::from(e).into_response();
119 }
120 (StatusCode::SEE_OTHER, Redirect::to("/settings")).into_response()
121}
122
123/// Render a deterministic 5×5 mirrored identicon SVG for `seed` (the username).
124///
125/// The layout and hue derive from a stable FNV-1a hash, so the same name always
126/// yields the same badge across restarts and machines.
127fn identicon(seed: &str) -> Vec<u8> {
128 let hash = fnv1a(seed.to_lowercase().as_bytes());
129 // Hue from the top bits; a calm, legible fill against the light plate.
130 let hue = u32::try_from(hash % 360).unwrap_or(210);
131 let fill = format!("hsl({hue}, 55%, 55%)");
132
133 // 3 columns decide the pattern; columns 3 and 4 mirror 1 and 0.
134 let mut cells = String::new();
135 for row in 0..5u32 {
136 for col in 0..3u32 {
137 let bit = row * 3 + col;
138 if (hash >> bit) & 1 == 1 {
139 let mirror = 4 - col;
140 let _ = write!(cells, r#"<rect x="{col}" y="{row}" width="1" height="1"/>"#);
141 if mirror != col {
142 let _ = write!(
143 cells,
144 r#"<rect x="{mirror}" y="{row}" width="1" height="1"/>"#
145 );
146 }
147 }
148 }
149 }
150
151 format!(
152 r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 5 5" shape-rendering="crispEdges" role="img"><rect width="5" height="5" fill="#e8e8e8"/><g fill="{fill}">{cells}</g></svg>"##
153 )
154 .into_bytes()
155}
156
157/// FNV-1a 64-bit hash — small, dependency-free, and stable across runs.
158fn fnv1a(bytes: &[u8]) -> u64 {
159 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
160 for &b in bytes {
161 hash ^= u64::from(b);
162 hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
163 }
164 hash
165}
crates/web/src/icons.rs +0 −19
@@ -11,9 +11,6 @@
11use maud::{Markup, PreEscaped, html};11use maud::{Markup, PreEscaped, html};
1212
13/// A vendored icon.13/// A vendored icon.
14// The full glyph set is vendored up front; the profile, explore, and settings
15// views wire in the remaining variants. Removed once every variant is consumed.
16#[allow(dead_code)]
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Icon {15pub enum Icon {
19 PanelLeft,16 PanelLeft,
@@ -21,23 +18,18 @@ pub enum Icon {
21 Folder,18 Folder,
22 File,19 File,
23 GitBranch,20 GitBranch,
24 Tag,
25 Search,21 Search,
26 Copy,22 Copy,
27 ChevronRight,
28 Box,23 Box,
29 Link,24 Link,
30 User,25 User,
31 Settings,26 Settings,
32 LogOut,27 LogOut,
33 Upload,28 Upload,
34 Plus,
35 Dashboard,29 Dashboard,
36 Compass,30 Compass,
37 MapPin,31 MapPin,
38 Globe,32 Globe,
39 Download,
40 AtSign,
41 Github,33 Github,
42 Mastodon,34 Mastodon,
43}35}
@@ -66,14 +58,10 @@ impl Icon {
66 Icon::GitBranch => {58 Icon::GitBranch => {
67 r#"<path d="M15 6a9 9 0 0 0-9 9V3"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/>"#59 r#"<path d="M15 6a9 9 0 0 0-9 9V3"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/>"#
68 }60 }
69 Icon::Tag => {
70 r#"<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>"#
71 }
72 Icon::Search => r#"<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>"#,61 Icon::Search => r#"<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>"#,
73 Icon::Copy => {62 Icon::Copy => {
74 r#"<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>"#63 r#"<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>"#
75 }64 }
76 Icon::ChevronRight => r#"<path d="m9 18 6-6-6-6"/>"#,
77 Icon::Box => {65 Icon::Box => {
78 r#"<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>"#66 r#"<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>"#
79 }67 }
@@ -92,7 +80,6 @@ impl Icon {
92 Icon::Upload => {80 Icon::Upload => {
93 r#"<path d="M12 3v12"/><path d="m17 8-5-5-5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>"#81 r#"<path d="M12 3v12"/><path d="m17 8-5-5-5 5"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>"#
94 }82 }
95 Icon::Plus => r#"<path d="M5 12h14"/><path d="M12 5v14"/>"#,
96 Icon::Dashboard => {83 Icon::Dashboard => {
97 r#"<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>"#84 r#"<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>"#
98 }85 }
@@ -105,12 +92,6 @@ impl Icon {
105 Icon::Globe => {92 Icon::Globe => {
106 r#"<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>"#93 r#"<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>"#
107 }94 }
108 Icon::Download => {
109 r#"<path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/>"#
110 }
111 Icon::AtSign => {
112 r#"<circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8"/>"#
113 }
114 Icon::Github => {95 Icon::Github => {
115 r#"<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>"#96 r#"<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>"#
116 }97 }
crates/web/src/lib.rs +7 −1
@@ -11,6 +11,7 @@
11//! without JavaScript; htmx only enhances.11//! without JavaScript; htmx only enhances.
1212
13mod assets;13mod assets;
14mod avatar;
14mod diff;15mod diff;
15mod error;16mod error;
16mod git_http;17mod git_http;
@@ -127,8 +128,13 @@ pub fn build_router(state: AppState) -> Router {
127 get(pages::invite_form).post(pages::invite_submit),128 get(pages::invite_form).post(pages::invite_submit),
128 )129 )
129 .route("/search", get(search::global))130 .route("/search", get(search::global))
130 .route("/settings", get(pages::settings))131 .route(
132 "/settings",
133 get(pages::settings).post(pages::settings_submit),
134 )
135 .route("/settings/avatar", post(avatar::upload))
131 .route("/settings/theme", get(pages::set_theme))136 .route("/settings/theme", get(pages::set_theme))
137 .route("/avatar/{username}", get(avatar::show))
132 .route("/healthz", get(serve_assets::healthz))138 .route("/healthz", get(serve_assets::healthz))
133 .route("/version", get(serve_assets::version))139 .route("/version", get(serve_assets::version))
134 .route("/theme.css", get(serve_assets::theme_default))140 .route("/theme.css", get(serve_assets::theme_default))
crates/web/src/pages.rs +123 −11
@@ -19,6 +19,7 @@ use serde::Deserialize;
19use crate::AppState;19use crate::AppState;
20use crate::assets::Scheme;20use crate::assets::Scheme;
21use crate::error::{AppError, AppResult};21use crate::error::{AppError, AppResult};
22use crate::icons::{Icon, icon};
22use crate::layout::{Chrome, page};23use crate::layout::{Chrome, page};
23use crate::repo::{RepoEntry, repo_card};24use crate::repo::{RepoEntry, repo_card};
24use crate::session::{25use crate::session::{
@@ -390,7 +391,7 @@ async fn live_invite(state: &AppState, token: &str) -> AppResult<model::Invite>
390 Ok(invite)391 Ok(invite)
391}392}
392393
393/// `GET /settings` — the viewer's profile summary.394/// `GET /settings` — the viewer's profile editor.
394pub async fn settings(395pub async fn settings(
395 State(state): State<AppState>,396 State(state): State<AppState>,
396 RequireUser(user): RequireUser,397 RequireUser(user): RequireUser,
@@ -398,19 +399,130 @@ pub async fn settings(
398 uri: Uri,399 uri: Uri,
399) -> Response {400) -> Response {
400 let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());401 let (jar, chrome) = build_chrome(&state, jar, Some(user.clone()), uri.path().to_string());
401 let body = html! {402 let body = settings_body(&user, &chrome.csrf, None);
403 (jar, page(&chrome, "Settings", body)).into_response()
404}
405
406/// Profile settings form fields. Empty strings clear the corresponding column.
407#[derive(Debug, Deserialize)]
408pub struct SettingsForm {
409 /// Display name.
410 #[serde(default)]
411 display_name: String,
412 /// Pronouns.
413 #[serde(default)]
414 pronouns: String,
415 /// Free-text bio.
416 #[serde(default)]
417 bio: String,
418 /// Website URL.
419 #[serde(default)]
420 website: String,
421 /// Location.
422 #[serde(default)]
423 location: String,
424 /// `GitHub` handle.
425 #[serde(default)]
426 social_github: String,
427 /// Mastodon handle.
428 #[serde(default)]
429 social_mastodon: String,
430 /// CSRF token (`_csrf`).
431 #[serde(rename = "_csrf")]
432 csrf: String,
433}
434
435/// `POST /settings` — persist the viewer's profile fields.
436pub async fn settings_submit(
437 State(state): State<AppState>,
438 RequireUser(user): RequireUser,
439 jar: CookieJar,
440 headers: HeaderMap,
441 Form(form): Form<SettingsForm>,
442) -> Response {
443 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
444 return AppError::Forbidden.into_response();
445 }
446 let update = store::ProfileUpdate {
447 display_name: norm(&form.display_name),
448 pronouns: norm(&form.pronouns),
449 bio: norm(&form.bio),
450 website: norm(&form.website),
451 location: norm(&form.location),
452 social_github: norm(&form.social_github).map(|s| s.trim_start_matches('@').to_string()),
453 social_mastodon: norm(&form.social_mastodon),
454 };
455 match state.store.update_profile(&user.id, update).await {
456 Ok(_) => Redirect::to("/settings").into_response(),
457 Err(err) => AppError::from(err).into_response(),
458 }
459}
460
461/// Trim a form string, mapping the empty result to `None` (clears the column).
462fn norm(s: &str) -> Option<String> {
463 let t = s.trim();
464 (!t.is_empty()).then(|| t.to_string())
465}
466
467/// Render the settings page: avatar, profile fields, and read-only account info.
468fn settings_body(user: &User, csrf: &str, notice: Option<&str>) -> Markup {
469 let val = |v: &Option<String>| v.clone().unwrap_or_default();
470 html! {
402 h1 { "Settings" }471 h1 { "Settings" }
403 div class="card" {472 @if let Some(msg) = notice {
404 table class="list" {473 div class="notice notice-success" { (msg) }
405 tr { th { "Username" } td { (user.username) } }474 }
406 tr { th { "Email" } td { (user.email) } }475 section class="listing" {
407 tr { th { "Display name" } td { (user.display_name.clone().unwrap_or_default()) } }476 h2 { "Avatar" }
408 tr { th { "Administrator" } td { (if user.is_admin { "yes" } else { "no" }) } }477 div class="card avatar-settings" {
478 img class="avatar avatar-lg" src={ "/avatar/" (user.username) } alt="Your avatar";
479 form method="post" action="/settings/avatar" enctype="multipart/form-data" {
480 input type="hidden" name="_csrf" value=(csrf);
481 p class="muted" { "PNG, JPEG, GIF, or WebP up to 1 MiB. Leave blank for a generated identicon." }
482 input type="file" name="avatar" accept="image/png,image/jpeg,image/gif,image/webp";
483 button class="btn" type="submit" { (icon(Icon::Upload)) span { "Upload" } }
484 }
409 }485 }
410 }486 }
411 p class="muted" { "Key and token management arrive with the settings pages." }487 section class="listing" {
412 };488 h2 { "Profile" }
413 (jar, page(&chrome, "Settings", body)).into_response()489 div class="card" {
490 form method="post" action="/settings" {
491 input type="hidden" name="_csrf" value=(csrf);
492 label for="display_name" { "Display name" }
493 input type="text" id="display_name" name="display_name"
494 value=(val(&user.display_name)) autocomplete="name";
495 label for="pronouns" { "Pronouns" }
496 input type="text" id="pronouns" name="pronouns"
497 value=(val(&user.pronouns)) placeholder="they/them";
498 label for="bio" { "Bio" }
499 textarea id="bio" name="bio" rows="3" { (val(&user.bio)) }
500 label for="location" { "Location" }
501 input type="text" id="location" name="location" value=(val(&user.location));
502 label for="website" { "Website" }
503 input type="text" id="website" name="website"
504 value=(val(&user.website)) placeholder="https://example.com";
505 label for="social_github" { "GitHub" }
506 input type="text" id="social_github" name="social_github"
507 value=(val(&user.social_github)) placeholder="username";
508 label for="social_mastodon" { "Mastodon" }
509 input type="text" id="social_mastodon" name="social_mastodon"
510 value=(val(&user.social_mastodon)) placeholder="@user@instance";
511 button class="btn btn-primary" type="submit" { "Save profile" }
512 }
513 }
514 }
515 section class="listing" {
516 h2 { "Account" }
517 div class="card" {
518 table class="list" {
519 tr { th { "Username" } td { (user.username) } }
520 tr { th { "Email" } td { (user.email) } }
521 tr { th { "Administrator" } td { (if user.is_admin { "yes" } else { "no" }) } }
522 }
523 }
524 }
525 }
414}526}
415527
416/// Theme-switch query.528/// Theme-switch query.
crates/web/src/repo.rs +108 −7
@@ -155,13 +155,19 @@ pub async fn user_page(
155155
156 let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string());156 let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string());
157 let body = html! {157 let body = html! {
158 h1 { (owner.username) }158 (profile_header(&owner))
159 @if !groups.is_empty() {159 @if !groups.is_empty() {
160 section {160 section class="listing" {
161 h2 { "Groups" }161 h2 { "Groups" }
162 ul class="repo-list" {162 ul class="repo-list card" {
163 @for group in &groups {163 @for group in &groups {
164 li { a href={ "/" (owner.username) "/" (group.path) } { (group.path) } }164 li {
165 a class="repo-row" href={ "/" (owner.username) "/" (group.path) } {
166 span class="repo-row-name" {
167 (icon(Icon::Folder)) span { (group.path) }
168 }
169 }
170 }
165 }171 }
166 }172 }
167 }173 }
@@ -445,7 +451,9 @@ fn render_blob(
445 div class="blob-header" {451 div class="blob-header" {
446 span class="muted" { (blob.size) " bytes" }452 span class="muted" { (blob.size) " bytes" }
447 span { a class="btn" href=(raw_url) { "Raw" }453 span { a class="btn" href=(raw_url) { "Raw" }
448 button class="btn" type="button" data-clipboard=(raw_url) { "Copy path" } }454 button class="btn" type="button" data-clipboard=(raw_url) {
455 (icon(Icon::Copy)) span { "Copy path" }
456 } }
449 }457 }
450 @if is_image(filename) {458 @if is_image(filename) {
451 p { img src=(raw_url) alt=(filename) style="max-width:100%"; }459 p { img src=(raw_url) alt=(filename) style="max-width:100%"; }
@@ -1038,7 +1046,7 @@ pub(crate) fn repo_card(title: &str, empty: &str, entries: &[RepoEntry]) -> Mark
1038 }1046 }
1039 span class="repo-row-meta muted" {1047 span class="repo-row-meta muted" {
1040 (if e.private { "private" } else { "public" })1048 (if e.private { "private" } else { "public" })
1041 " · default: " (e.default_branch)1049 " · " (icon(Icon::GitBranch)) " " (e.default_branch)
1042 }1050 }
1043 }1051 }
1044 }1052 }
@@ -1055,6 +1063,97 @@ fn owned_repo_card(owner: &str, repos: &[Repo]) -> Markup {
1055 repo_card("Repositories", "No repositories.", &entries)1063 repo_card("Repositories", "No repositories.", &entries)
1056}1064}
10571065
1066/// The profile header: avatar, name, `User · username`, pronouns, bio, and any
1067/// location and social links.
1068fn profile_header(owner: &User) -> Markup {
1069 let name = owner.display_name.as_deref().unwrap_or(&owner.username);
1070 html! {
1071 header class="profile-head" {
1072 img class="avatar avatar-xl" src={ "/avatar/" (owner.username) } alt="";
1073 div class="profile-meta" {
1074 h1 { (name) }
1075 p class="profile-sub muted" {
1076 (icon(Icon::User)) span { "User · " (owner.username) }
1077 @if let Some(pronouns) = pronouns(owner) {
1078 span class="profile-pronouns" { (pronouns) }
1079 }
1080 }
1081 @if let Some(bio) = non_empty(owner.bio.as_ref()) {
1082 p class="profile-bio" { (bio) }
1083 }
1084 @let links = profile_links(owner);
1085 @if !links.is_empty() {
1086 ul class="profile-links muted" {
1087 @for link in &links { (link) }
1088 }
1089 }
1090 }
1091 }
1092 }
1093}
1094
1095/// The trimmed pronouns, if set.
1096fn pronouns(owner: &User) -> Option<&str> {
1097 non_empty(owner.pronouns.as_ref())
1098}
1099
1100/// A field's trimmed value, or `None` when empty.
1101fn non_empty(field: Option<&String>) -> Option<&str> {
1102 field.map(|s| s.trim()).filter(|s| !s.is_empty())
1103}
1104
1105/// The profile's location and social links, each an icon-prefixed row.
1106fn profile_links(owner: &User) -> Vec<Markup> {
1107 let mut out = Vec::new();
1108 if let Some(location) = non_empty(owner.location.as_ref()) {
1109 out.push(html! { li { (icon(Icon::MapPin)) span { (location) } } });
1110 }
1111 if let Some(site) = non_empty(owner.website.as_ref()) {
1112 let href = if site.starts_with("http://") || site.starts_with("https://") {
1113 site.to_string()
1114 } else {
1115 format!("https://{site}")
1116 };
1117 let shown = site
1118 .trim_start_matches("https://")
1119 .trim_start_matches("http://");
1120 out.push(html! {
1121 li { a href=(href) rel="nofollow noopener" { (icon(Icon::Globe)) span { (shown) } } }
1122 });
1123 }
1124 if let Some(handle) = non_empty(owner.social_github.as_ref()) {
1125 let handle = handle.trim_start_matches('@');
1126 out.push(html! {
1127 li {
1128 a href=(format!("https://github.com/{handle}")) rel="nofollow noopener" {
1129 (icon(Icon::Github)) span { (handle) }
1130 }
1131 }
1132 });
1133 }
1134 if let Some(handle) = non_empty(owner.social_mastodon.as_ref()) {
1135 out.push(html! { li { (mastodon_link(handle)) } });
1136 }
1137 out
1138}
1139
1140/// Render a Mastodon `@user@instance` handle as a link when well-formed, else as
1141/// plain text.
1142fn mastodon_link(handle: &str) -> Markup {
1143 let trimmed = handle.trim_start_matches('@');
1144 if let Some((user, instance)) = trimmed.split_once('@')
1145 && !user.is_empty()
1146 && !instance.is_empty()
1147 {
1148 return html! {
1149 a href=(format!("https://{instance}/@{user}")) rel="nofollow noopener me" {
1150 (icon(Icon::Mastodon)) span { "@" (user) "@" (instance) }
1151 }
1152 };
1153 }
1154 html! { span { (icon(Icon::Mastodon)) span { (handle) } } }
1155}
1156
1058/// The repo sub-header: slug, tabs, and clone URLs.1157/// The repo sub-header: slug, tabs, and clone URLs.
1059fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Markup {1158fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Markup {
1060 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);1159 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
@@ -1085,7 +1184,9 @@ fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Marku
1085 div class="clone-row" {1184 div class="clone-row" {
1086 input type="text" readonly value=(clone_https(state, ctx))1185 input type="text" readonly value=(clone_https(state, ctx))
1087 aria-label="HTTPS clone URL";1186 aria-label="HTTPS clone URL";
1088 button class="btn" type="button" data-clipboard=(clone_https(state, ctx)) { "Copy" }1187 button class="btn" type="button" data-clipboard=(clone_https(state, ctx)) {
1188 (icon(Icon::Copy)) span { "Copy" }
1189 }
1089 }1190 }
1090 }1191 }
1091 }1192 }