Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +18 −0
| @@ -348,6 +348,7 @@ dependencies = [ | |||
| 348 | 348 | "matchit", | |
| 349 | 349 | "memchr", | |
| 350 | 350 | "mime", | |
| 351 | + | "multer", | |
| 351 | 352 | "percent-encoding", | |
| 352 | 353 | "pin-project-lite", | |
| 353 | 354 | "serde_core", | |
| @@ -2946,6 +2947,23 @@ dependencies = [ | |||
| 2946 | 2947 | ] | |
| 2947 | 2948 | ||
| 2948 | 2949 | [[package]] | |
| 2950 | + | name = "multer" | |
| 2951 | + | version = "3.1.0" | |
| 2952 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2953 | + | checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" | |
| 2954 | + | dependencies = [ | |
| 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]] | |
| 2949 | 2967 | name = "native-tls" | |
| 2950 | 2968 | version = "0.2.18" | |
| 2951 | 2969 | source = "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 | 138 | # Web stack. No TLS/rustls features anywhere (TLS terminates at a reverse proxy), | |
| 139 | 139 | # so `ring` stays out of the graph. Versions are the current mutually-compatible | |
| 140 | 140 | # axum 0.8 set. | |
| 141 | - | axum = "0.8" | |
| 141 | + | axum = { version = "0.8", features = ["multipart"] } | |
| 142 | 142 | axum-extra = { version = "0.12", features = ["cookie"] } | |
| 143 | 143 | maud = { version = "0.27", features = ["axum"] } | |
| 144 | 144 | tower = { version = "0.5", features = ["util"] } | |
assets/base.css +73 −0
| @@ -395,6 +395,75 @@ select:focus { | |||
| 395 | 395 | font-size: 0.82em; | |
| 396 | 396 | } | |
| 397 | 397 | ||
| 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 | 467 | /* ---- Tables ---- */ | |
| 399 | 468 | table.list { | |
| 400 | 469 | width: 100%; | |
| @@ -703,4 +772,8 @@ td.diff-empty { | |||
| 703 | 772 | display: block; | |
| 704 | 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 | + | ||
| 11 | + | use std::fmt::Write as _; | |
| 12 | + | use std::path::PathBuf; | |
| 13 | + | ||
| 14 | + | use axum::extract::{Multipart, Path, State}; | |
| 15 | + | use axum::http::{StatusCode, header}; | |
| 16 | + | use axum::response::{IntoResponse, Redirect, Response}; | |
| 17 | + | use axum_extra::extract::cookie::CookieJar; | |
| 18 | + | ||
| 19 | + | use crate::AppState; | |
| 20 | + | use crate::error::AppError; | |
| 21 | + | use crate::session::RequireUser; | |
| 22 | + | ||
| 23 | + | /// The largest avatar upload accepted, in bytes (1 `MiB`). | |
| 24 | + | const 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. | |
| 28 | + | const ALLOWED_MIME: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"]; | |
| 29 | + | ||
| 30 | + | /// The on-disk path of a user's uploaded avatar. | |
| 31 | + | fn 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. | |
| 37 | + | pub 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). | |
| 65 | + | pub 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. | |
| 127 | + | fn 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. | |
| 158 | + | fn 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 @@ | |||
| 11 | 11 | use maud::{Markup, PreEscaped, html}; | |
| 12 | 12 | ||
| 13 | 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 | 14 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 18 | 15 | pub enum Icon { | |
| 19 | 16 | PanelLeft, | |
| @@ -21,23 +18,18 @@ pub enum Icon { | |||
| 21 | 18 | Folder, | |
| 22 | 19 | File, | |
| 23 | 20 | GitBranch, | |
| 24 | - | Tag, | |
| 25 | 21 | Search, | |
| 26 | 22 | Copy, | |
| 27 | - | ChevronRight, | |
| 28 | 23 | Box, | |
| 29 | 24 | Link, | |
| 30 | 25 | User, | |
| 31 | 26 | Settings, | |
| 32 | 27 | LogOut, | |
| 33 | 28 | Upload, | |
| 34 | - | Plus, | |
| 35 | 29 | Dashboard, | |
| 36 | 30 | Compass, | |
| 37 | 31 | MapPin, | |
| 38 | 32 | Globe, | |
| 39 | - | Download, | |
| 40 | - | AtSign, | |
| 41 | 33 | Github, | |
| 42 | 34 | Mastodon, | |
| 43 | 35 | } | |
| @@ -66,14 +58,10 @@ impl Icon { | |||
| 66 | 58 | Icon::GitBranch => { | |
| 67 | 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 | 61 | Icon::Search => r#"<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>"#, | |
| 73 | 62 | Icon::Copy => { | |
| 74 | 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 | 65 | Icon::Box => { | |
| 78 | 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 | 80 | Icon::Upload => { | |
| 93 | 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 | 83 | Icon::Dashboard => { | |
| 97 | 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 | 92 | Icon::Globe => { | |
| 106 | 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 | 95 | Icon::Github => { | |
| 115 | 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 | 11 | //! without JavaScript; htmx only enhances. | |
| 12 | 12 | ||
| 13 | 13 | mod assets; | |
| 14 | + | mod avatar; | |
| 14 | 15 | mod diff; | |
| 15 | 16 | mod error; | |
| 16 | 17 | mod git_http; | |
| @@ -127,8 +128,13 @@ pub fn build_router(state: AppState) -> Router { | |||
| 127 | 128 | get(pages::invite_form).post(pages::invite_submit), | |
| 128 | 129 | ) | |
| 129 | 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 | 136 | .route("/settings/theme", get(pages::set_theme)) | |
| 137 | + | .route("/avatar/{username}", get(avatar::show)) | |
| 132 | 138 | .route("/healthz", get(serve_assets::healthz)) | |
| 133 | 139 | .route("/version", get(serve_assets::version)) | |
| 134 | 140 | .route("/theme.css", get(serve_assets::theme_default)) | |
crates/web/src/pages.rs +123 −11
| @@ -19,6 +19,7 @@ use serde::Deserialize; | |||
| 19 | 19 | use crate::AppState; | |
| 20 | 20 | use crate::assets::Scheme; | |
| 21 | 21 | use crate::error::{AppError, AppResult}; | |
| 22 | + | use crate::icons::{Icon, icon}; | |
| 22 | 23 | use crate::layout::{Chrome, page}; | |
| 23 | 24 | use crate::repo::{RepoEntry, repo_card}; | |
| 24 | 25 | use crate::session::{ | |
| @@ -390,7 +391,7 @@ async fn live_invite(state: &AppState, token: &str) -> AppResult<model::Invite> | |||
| 390 | 391 | Ok(invite) | |
| 391 | 392 | } | |
| 392 | 393 | ||
| 393 | - | /// `GET /settings` — the viewer's profile summary. | |
| 394 | + | /// `GET /settings` — the viewer's profile editor. | |
| 394 | 395 | pub async fn settings( | |
| 395 | 396 | State(state): State<AppState>, | |
| 396 | 397 | RequireUser(user): RequireUser, | |
| @@ -398,19 +399,130 @@ pub async fn settings( | |||
| 398 | 399 | uri: Uri, | |
| 399 | 400 | ) -> Response { | |
| 400 | 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)] | |
| 408 | + | pub 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. | |
| 436 | + | pub 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). | |
| 462 | + | fn 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. | |
| 468 | + | fn settings_body(user: &User, csrf: &str, notice: Option<&str>) -> Markup { | |
| 469 | + | let val = |v: &Option<String>| v.clone().unwrap_or_default(); | |
| 470 | + | html! { | |
| 402 | 471 | h1 { "Settings" } | |
| 403 | - | div class="card" { | |
| 404 | - | table class="list" { | |
| 405 | - | tr { th { "Username" } td { (user.username) } } | |
| 406 | - | tr { th { "Email" } td { (user.email) } } | |
| 407 | - | tr { th { "Display name" } td { (user.display_name.clone().unwrap_or_default()) } } | |
| 408 | - | tr { th { "Administrator" } td { (if user.is_admin { "yes" } else { "no" }) } } | |
| 472 | + | @if let Some(msg) = notice { | |
| 473 | + | div class="notice notice-success" { (msg) } | |
| 474 | + | } | |
| 475 | + | section class="listing" { | |
| 476 | + | h2 { "Avatar" } | |
| 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." } | |
| 412 | - | }; | |
| 413 | - | (jar, page(&chrome, "Settings", body)).into_response() | |
| 487 | + | section class="listing" { | |
| 488 | + | h2 { "Profile" } | |
| 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 | } | |
| 415 | 527 | ||
| 416 | 528 | /// Theme-switch query. | |
crates/web/src/repo.rs +108 −7
| @@ -155,13 +155,19 @@ pub async fn user_page( | |||
| 155 | 155 | ||
| 156 | 156 | let (jar, chrome) = build_chrome(&state, jar, viewer.clone(), uri.path().to_string()); | |
| 157 | 157 | let body = html! { | |
| 158 | - | h1 { (owner.username) } | |
| 158 | + | (profile_header(&owner)) | |
| 159 | 159 | @if !groups.is_empty() { | |
| 160 | - | section { | |
| 160 | + | section class="listing" { | |
| 161 | 161 | h2 { "Groups" } | |
| 162 | - | ul class="repo-list" { | |
| 162 | + | ul class="repo-list card" { | |
| 163 | 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 | 451 | div class="blob-header" { | |
| 446 | 452 | span class="muted" { (blob.size) " bytes" } | |
| 447 | 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 | 458 | @if is_image(filename) { | |
| 451 | 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 | 1047 | span class="repo-row-meta muted" { | |
| 1040 | 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 | 1063 | repo_card("Repositories", "No repositories.", &entries) | |
| 1056 | 1064 | } | |
| 1057 | 1065 | ||
| 1066 | + | /// The profile header: avatar, name, `User · username`, pronouns, bio, and any | |
| 1067 | + | /// location and social links. | |
| 1068 | + | fn 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. | |
| 1096 | + | fn pronouns(owner: &User) -> Option<&str> { | |
| 1097 | + | non_empty(owner.pronouns.as_ref()) | |
| 1098 | + | } | |
| 1099 | + | ||
| 1100 | + | /// A field's trimmed value, or `None` when empty. | |
| 1101 | + | fn 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. | |
| 1106 | + | fn 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. | |
| 1142 | + | fn 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 | 1157 | /// The repo sub-header: slug, tabs, and clone URLs. | |
| 1059 | 1158 | fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Markup { | |
| 1060 | 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 | 1184 | div class="clone-row" { | |
| 1086 | 1185 | input type="text" readonly value=(clone_https(state, ctx)) | |
| 1087 | 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 | } | |