Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
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 | ] |
| 2947 | 2948 | ||
| 2948 | [[package]] | 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 | name = "native-tls" | 2967 | name = "native-tls" |
| 2950 | version = "0.2.18" | 2968 | version = "0.2.18" |
| 2951 | source = "registry+https://github.com/rust-lang/crates.io-index" | 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 | # 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-compatible | 139 | # so `ring` stays out of the graph. Versions are the current mutually-compatible |
| 140 | # axum 0.8 set. | 140 | # axum 0.8 set. |
| 141 | axum = "0.8" | 141 | axum = { version = "0.8", features = ["multipart"] } |
| 142 | axum-extra = { version = "0.12", features = ["cookie"] } | 142 | axum-extra = { version = "0.12", features = ["cookie"] } |
| 143 | maud = { version = "0.27", features = ["axum"] } | 143 | maud = { version = "0.27", features = ["axum"] } |
| 144 | tower = { version = "0.5", features = ["util"] } | 144 | tower = { 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 | } |
| 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 | /* ---- Tables ---- */ | 467 | /* ---- Tables ---- */ |
| 399 | table.list { | 468 | table.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 | |||
| 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 | use maud::{Markup, PreEscaped, html}; | 11 | use maud::{Markup, PreEscaped, html}; |
| 12 | 12 | ||
| 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)] |
| 18 | pub enum Icon { | 15 | pub 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. |
| 12 | 12 | ||
| 13 | mod assets; | 13 | mod assets; |
| 14 | mod avatar; | ||
| 14 | mod diff; | 15 | mod diff; |
| 15 | mod error; | 16 | mod error; |
| 16 | mod git_http; | 17 | mod 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; | |||
| 19 | use crate::AppState; | 19 | use crate::AppState; |
| 20 | use crate::assets::Scheme; | 20 | use crate::assets::Scheme; |
| 21 | use crate::error::{AppError, AppResult}; | 21 | use crate::error::{AppError, AppResult}; |
| 22 | use crate::icons::{Icon, icon}; | ||
| 22 | use crate::layout::{Chrome, page}; | 23 | use crate::layout::{Chrome, page}; |
| 23 | use crate::repo::{RepoEntry, repo_card}; | 24 | use crate::repo::{RepoEntry, repo_card}; |
| 24 | use crate::session::{ | 25 | use 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 | } |
| 392 | 393 | ||
| 393 | /// `GET /settings` — the viewer's profile summary. | 394 | /// `GET /settings` — the viewer's profile editor. |
| 394 | pub async fn settings( | 395 | pub 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)] | ||
| 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 | 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 | } |
| 415 | 527 | ||
| 416 | /// Theme-switch query. | 528 | /// Theme-switch query. |
crates/web/src/repo.rs +108 −7
| @@ -155,13 +155,19 @@ pub async fn user_page( | |||
| 155 | 155 | ||
| 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 | } |
| 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 | /// The repo sub-header: slug, tabs, and clone URLs. | 1157 | /// The repo sub-header: slug, tabs, and clone URLs. |
| 1059 | fn repo_header(state: &AppState, ctx: &RepoCtx, active: Tab, rev: &str) -> Markup { | 1158 | fn 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 | } |