OXYGENHUB

Uploaded profile pictures and organisation logos: PNG, JPEG or WebP checked by content, cropped, scaled and re-encoded before storing, served at /avatar/{name} with the identicon as fallback, removable a160b98fb922

closes #11
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 13:28 UTC
Signature
✔ signed by Andy McCall
Hash
a160b98fb922e084cc2a6a66684d804fd2d640f221a21fa9d60d7950fad51e63
Depends on
101740454617 1553bfc9ae53 18e381ea88c2 19bb31ff1f5c 34beb1a7e376 64caed18236a 93be9ce4653e d23464c0ac60 eb0c44a9ad37
Depended on by
1062a0f8e985 c7067c7bda56 76f93eaefe45 5d90e0e045e2 08e221b77658 92a797b20912 1f78655a0466 206c1fc1b06a 83940d53664f 89e89836cae7 d1727c573f30 e71846aa07b9 7974adb0be66 8fb35df7d66e 756fe44a0f4d
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
45 atoms, 1 tree operation, 13 files

Browse the tree at this change

added crates/hub/migrations/0014_pictures.sql +4 −0
1 +
-- Uploaded pictures (hub spec §6.3): `png:<hash>` or `jpeg:<hash>` of the
2 +
-- re-encoded file under <data>/avatars/, empty for the identicon.
3 +
ALTER TABLE accounts ADD COLUMN avatar TEXT NOT NULL DEFAULT '';
4 +
ALTER TABLE organisations ADD COLUMN avatar TEXT NOT NULL DEFAULT '';
modified Cargo.lock +109 −1
…
496 unchanged lines
497 497
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
498 498
499 499
[[package]]
500 +
name = "bytemuck"
501 +
version = "1.25.2"
502 +
source = "registry+https://github.com/rust-lang/crates.io-index"
503 +
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
504 +
505 +
[[package]]
500 506
name = "byteorder"
501 507
version = "1.5.0"
502 508
source = "registry+https://github.com/rust-lang/crates.io-index"
503 509
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
510 +
511 +
[[package]]
512 +
name = "byteorder-lite"
513 +
version = "0.1.0"
514 +
source = "registry+https://github.com/rust-lang/crates.io-index"
515 +
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
504 516
505 517
[[package]]
506 518
name = "bytes"
…
517 unchanged lines
1024 1036
version = "2.5.0"
1025 1037
source = "registry+https://github.com/rust-lang/crates.io-index"
1026 1038
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
1039 +
1040 +
[[package]]
1041 +
name = "fdeflate"
1042 +
version = "0.3.7"
1043 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1044 +
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
1045 +
dependencies = [
1046 +
 "simd-adler32",
1047 +
]
1027 1048
1028 1049
[[package]]
1029 1050
name = "fiat-crypto"
…
24 unchanged lines
1054 1075
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
1055 1076
dependencies = [
1056 1077
 "crc32fast",
1057 -
 "miniz_oxide",
1078 +
 "miniz_oxide 0.9.1",
1058 1079
 "zlib-rs",
1059 1080
]
1060 1081
…
545 unchanged lines
1606 1627
]
1607 1628
1608 1629
[[package]]
1630 +
name = "image"
1631 +
version = "0.25.10"
1632 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1633 +
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
1634 +
dependencies = [
1635 +
 "bytemuck",
1636 +
 "byteorder-lite",
1637 +
 "image-webp",
1638 +
 "moxcms",
1639 +
 "num-traits",
1640 +
 "png",
1641 +
 "zune-core",
1642 +
 "zune-jpeg",
1643 +
]
1644 +
1645 +
[[package]]
1646 +
name = "image-webp"
1647 +
version = "0.2.4"
1648 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1649 +
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
1650 +
dependencies = [
1651 +
 "byteorder-lite",
1652 +
 "quick-error",
1653 +
]
1654 +
1655 +
[[package]]
1609 1656
name = "indexmap"
1610 1657
version = "2.14.2"
1611 1658
source = "registry+https://github.com/rust-lang/crates.io-index"
…
255 unchanged lines
1867 1914
version = "0.2.1"
1868 1915
source = "registry+https://github.com/rust-lang/crates.io-index"
1869 1916
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
1917 +
1918 +
[[package]]
1919 +
name = "miniz_oxide"
1920 +
version = "0.8.9"
1921 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1922 +
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
1923 +
dependencies = [
1924 +
 "adler2",
1925 +
 "simd-adler32",
1926 +
]
1870 1927
1871 1928
[[package]]
1872 1929
name = "miniz_oxide"
…
40 unchanged lines
1913 1970
dependencies = [
1914 1971
 "ctutils",
1915 1972
 "hybrid-array",
1973 +
 "num-traits",
1974 +
]
1975 +
1976 +
[[package]]
1977 +
name = "moxcms"
1978 +
version = "0.8.1"
1979 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1980 +
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
1981 +
dependencies = [
1916 1982
 "num-traits",
1983 +
 "pxfm",
1917 1984
]
1918 1985
1919 1986
[[package]]
…
208 unchanged lines
2128 2195
 "hmac",
2129 2196
 "http-body-util",
2130 2197
 "hub-api",
2198 +
 "image",
2131 2199
 "lettre",
2132 2200
 "oxy-core",
2133 2201
 "oxy-server",
…
190 unchanged lines
2324 2392
 "quick-xml",
2325 2393
 "serde",
2326 2394
 "time",
2395 +
]
2396 +
2397 +
[[package]]
2398 +
name = "png"
2399 +
version = "0.18.1"
2400 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2401 +
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
2402 +
dependencies = [
2403 +
 "bitflags",
2404 +
 "crc32fast",
2405 +
 "fdeflate",
2406 +
 "flate2",
2407 +
 "miniz_oxide 0.8.9",
2327 2408
]
2328 2409
2329 2410
[[package]]
…
54 unchanged lines
2384 2465
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
2385 2466
2386 2467
[[package]]
2468 +
name = "pxfm"
2469 +
version = "0.1.30"
2470 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2471 +
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
2472 +
2473 +
[[package]]
2387 2474
name = "qrcode"
2388 2475
version = "0.14.1"
2389 2476
source = "registry+https://github.com/rust-lang/crates.io-index"
2390 2477
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
2478 +
2479 +
[[package]]
2480 +
name = "quick-error"
2481 +
version = "2.0.1"
2482 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2483 +
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
2391 2484
2392 2485
[[package]]
2393 2486
name = "quick-xml"
…
2037 unchanged lines
4431 4524
dependencies = [
4432 4525
 "cc",
4433 4526
 "pkg-config",
4527 +
]
4528 +
4529 +
[[package]]
4530 +
name = "zune-core"
4531 +
version = "0.5.3"
4532 +
source = "registry+https://github.com/rust-lang/crates.io-index"
4533 +
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
4534 +
4535 +
[[package]]
4536 +
name = "zune-jpeg"
4537 +
version = "0.5.15"
4538 +
source = "registry+https://github.com/rust-lang/crates.io-index"
4539 +
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
4540 +
dependencies = [
4541 +
 "zune-core",
4434 4542
]
modified crates/hub/Cargo.toml +1 −0
…
27 unchanged lines
28 28
askama.workspace = true
29 29
sqlx.workspace = true
30 30
tracing.workspace = true
31 +
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
31 32
tracing-subscriber.workspace = true
32 33
clap.workspace = true
33 34
toml.workspace = true
…
29 unchanged lines
modified crates/hub/src/db/accounts.rs +22 −8
…
27 unchanged lines
28 28
    pub website: String,
29 29
    #[sqlx(default)]
30 30
    pub location: String,
31 +
    /// `png:<hash>` or `jpeg:<hash>` when a picture was uploaded.
32 +
    pub avatar: String,
31 33
}
32 34
33 35
impl Account {
…
18 unchanged lines
52 54
    pub description: String,
53 55
    pub default_role: String,
54 56
    pub created_ms: i64,
57 +
    /// `png:<hash>` or `jpeg:<hash>` when a picture was uploaded.
58 +
    pub avatar: String,
55 59
}
56 60
57 61
#[derive(Clone, Debug, FromRow)]
…
171 unchanged lines
229 233
    sqlx::query_as("SELECT * FROM organisations ORDER BY name").fetch_all(pool).await
230 234
}
231 235
236 +
/// An organisation with the account's role in it.
237 +
#[derive(FromRow)]
238 +
struct Membership {
239 +
    #[sqlx(flatten)]
240 +
    org: Organisation,
241 +
    role: String,
242 +
}
243 +
232 244
pub async fn orgs_of(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<(Organisation, String)>> {
233 -
    let rows: Vec<(i64, String, String, String, String, i64, String)> = sqlx::query_as(
234 -
        "SELECT o.id, o.name, o.display_name, o.description, o.default_role, o.created_ms, m.role FROM organisations o JOIN org_members m ON m.org_id = o.id WHERE m.account_id = ? ORDER BY o.name",
245 +
    let rows: Vec<Membership> = sqlx::query_as(
246 +
        "SELECT o.*, m.role FROM organisations o JOIN org_members m ON m.org_id = o.id WHERE m.account_id = ? ORDER BY o.name",
235 247
    )
236 248
    .bind(account_id)
237 249
    .fetch_all(pool)
238 250
    .await?;
239 -
    Ok(rows
240 -
        .into_iter()
241 -
        .map(|(id, name, display_name, description, default_role, created_ms, role)| {
242 -
            (Organisation { id, name, display_name, description, default_role, created_ms }, role)
243 -
        })
244 -
        .collect())
251 +
    Ok(rows.into_iter().map(|m| (m.org, m.role)).collect())
245 252
}
246 253
247 254
pub async fn create_org(pool: &Pool, name: &str, display_name: &str, owner: i64) -> sqlx::Result<Organisation> {
…
460 unchanged lines
708 715
        .fetch_all(pool)
709 716
        .await?;
710 717
    Ok(if found.len() == 1 { found.into_iter().next() } else { None })
718 +
}
719 +
720 +
/// Set or clear the picture of an account (`account`) or organisation (`org`).
721 +
pub async fn set_avatar(pool: &Pool, kind: &str, id: i64, value: &str) -> sqlx::Result<()> {
722 +
    let table = if kind == "org" { "organisations" } else { "accounts" };
723 +
    sqlx::query(&format!("UPDATE {table} SET avatar = ? WHERE id = ?")).bind(value).bind(id).execute(pool).await?;
724 +
    Ok(())
711 725
}
modified crates/hub/src/web/avatar.rs +174 −8
1 -
//! Identicons (hub spec §6): a five-by-five mirrored pattern and a colour
2 -
//! drawn from BLAKE3 of the lower-cased name, so every account and
3 -
//! organisation has a stable picture without uploads.
1 +
//! Pictures of accounts and organisations (hub spec §6.3): an uploaded
2 +
//! photo or logo, re-encoded so no uploaded bytes are ever served, or an
3 +
//! identicon: a five-by-five mirrored pattern and a colour drawn from
4 +
//! BLAKE3 of the lower-cased name, so everyone has a stable picture.
4 5
5 -
use axum::extract::Path;
6 +
use crate::db::accounts;
7 +
use crate::Shared;
8 +
use axum::extract::{Path, State};
6 9
use axum::http::header;
7 10
use axum::response::{IntoResponse, Response};
11 +
use std::io::Cursor;
12 +
13 +
/// Largest upload accepted.
14 +
pub const MAX_BYTES: usize = 5 << 20;
15 +
/// Largest width or height decoded.
16 +
const MAX_SIDE: u32 = 8000;
17 +
/// The stored picture is this many pixels square.
18 +
pub const SIZE: u32 = 400;
19 +
20 +
/// A re-encoded picture.
21 +
pub struct Picture {
22 +
    pub bytes: Vec<u8>,
23 +
    /// `png` or `jpeg`.
24 +
    pub format: &'static str,
25 +
}
8 26
27 +
/// Decode an upload (PNG, JPEG or WebP by content), crop it to the middle
28 +
/// square, scale it to `SIZE`, and encode it afresh: PNG when it has
29 +
/// transparency, JPEG otherwise.
30 +
pub fn process(input: &[u8]) -> Result<Picture, String> {
31 +
    if input.len() > MAX_BYTES {
32 +
        return Err(format!("A picture can be at most {} MiB.", MAX_BYTES >> 20));
33 +
    }
34 +
    let format = image::guess_format(input).map_err(|_| "That is not a PNG, JPEG or WebP image.".to_string())?;
35 +
    if !matches!(format, image::ImageFormat::Png | image::ImageFormat::Jpeg | image::ImageFormat::WebP) {
36 +
        return Err("Pictures must be PNG, JPEG or WebP.".into());
37 +
    }
38 +
    let mut reader = image::ImageReader::with_format(Cursor::new(input), format);
39 +
    let mut limits = image::Limits::default();
40 +
    limits.max_image_width = Some(MAX_SIDE);
41 +
    limits.max_image_height = Some(MAX_SIDE);
42 +
    limits.max_alloc = Some(512 << 20);
43 +
    reader.limits(limits);
44 +
    let img = reader.decode().map_err(|e| format!("The image could not be read: {e}"))?;
45 +
    let side = img.width().min(img.height());
46 +
    if side == 0 {
47 +
        return Err("The image is empty.".into());
48 +
    }
49 +
    let square = img.crop_imm((img.width() - side) / 2, (img.height() - side) / 2, side, side);
50 +
    let scaled = square.resize_exact(SIZE, SIZE, image::imageops::FilterType::Lanczos3);
51 +
    let mut out = Vec::new();
52 +
    if scaled.color().has_alpha() {
53 +
        scaled.to_rgba8().write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png).map_err(|e| e.to_string())?;
54 +
        Ok(Picture { bytes: out, format: "png" })
55 +
    } else {
56 +
        image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 88).encode_image(&scaled.to_rgb8()).map_err(|e| e.to_string())?;
57 +
        Ok(Picture { bytes: out, format: "jpeg" })
58 +
    }
59 +
}
60 +
61 +
fn file_of(app: &Shared, kind: &str, id: i64) -> std::path::PathBuf {
62 +
    app.config.data.join("avatars").join(format!("{kind}-{id}"))
63 +
}
64 +
65 +
/// Process and store an upload as the picture of an account (`account`) or
66 +
/// organisation (`org`).
67 +
pub async fn store(app: &Shared, kind: &str, id: i64, upload: Vec<u8>) -> Result<(), String> {
68 +
    let pic = tokio::task::spawn_blocking(move || process(&upload)).await.map_err(|e| e.to_string())??;
69 +
    let path = file_of(app, kind, id);
70 +
    let dir = path.parent().unwrap().to_path_buf();
71 +
    tokio::fs::create_dir_all(&dir).await.map_err(|e| e.to_string())?;
72 +
    let tmp = dir.join(format!(".{kind}-{id}.tmp"));
73 +
    tokio::fs::write(&tmp, &pic.bytes).await.map_err(|e| e.to_string())?;
74 +
    tokio::fs::rename(&tmp, &path).await.map_err(|e| e.to_string())?;
75 +
    let hash = blake3::hash(&pic.bytes).to_hex();
76 +
    accounts::set_avatar(&app.db, kind, id, &format!("{}:{}", pic.format, &hash[..16])).await.map_err(|e| e.to_string())
77 +
}
78 +
79 +
/// Go back to the identicon.
80 +
pub async fn remove(app: &Shared, kind: &str, id: i64) -> Result<(), String> {
81 +
    accounts::set_avatar(&app.db, kind, id, "").await.map_err(|e| e.to_string())?;
82 +
    let _ = tokio::fs::remove_file(file_of(app, kind, id)).await;
83 +
    Ok(())
84 +
}
85 +
9 86
/// The SVG for `name`: 15 bits choose the cells of the left three columns
10 87
/// (mirrored to five), two bytes choose the hue.
11 88
pub fn identicon(name: &str) -> String {
…
17 unchanged lines
29 106
    format!(
30 107
        r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 7 7" shape-rendering="crispEdges"><rect width="7" height="7" fill="hsl({hue},45%,92%)"/><g fill="hsl({hue},65%,45%)">{cells}</g></svg>"#
31 108
    )
109 +
}
110 +
111 +
/// A picture form: the CSRF token, the file, and `action=remove` to clear it.
112 +
pub struct Upload {
113 +
    pub csrf: String,
114 +
    pub file: Vec<u8>,
115 +
    pub remove: bool,
116 +
}
117 +
118 +
pub async fn read_upload(mut multipart: axum::extract::Multipart) -> Upload {
119 +
    let mut u = Upload { csrf: String::new(), file: Vec::new(), remove: false };
120 +
    while let Ok(Some(field)) = multipart.next_field().await {
121 +
        match field.name() {
122 +
            Some("_csrf") => u.csrf = field.text().await.unwrap_or_default(),
123 +
            Some("picture") => u.file = field.bytes().await.map(|b| b.to_vec()).unwrap_or_default(),
124 +
            Some("action") => u.remove = field.text().await.unwrap_or_default() == "remove",
125 +
            _ => {}
126 +
        }
127 +
    }
128 +
    u
129 +
}
130 +
131 +
/// Apply a picture form to an account or organisation; the message to show.
132 +
pub async fn apply(app: &Shared, kind: &str, id: i64, u: Upload) -> Result<String, String> {
133 +
    if u.remove {
134 +
        remove(app, kind, id).await?;
135 +
        return Ok("Picture removed; the generated one is back.".into());
136 +
    }
137 +
    if u.file.is_empty() {
138 +
        return Err("Choose a picture to upload.".into());
139 +
    }
140 +
    store(app, kind, id, u.file).await?;
141 +
    Ok("Picture saved.".into())
32 142
}
33 143
34 -
/// `GET /avatar/{name}`: the identicon, cached for a day.
35 -
pub async fn avatar(Path(name): Path<String>) -> Response {
144 +
/// `GET /avatar/{name}`: the uploaded picture, or the identicon.
145 +
pub async fn avatar(State(app): State<Shared>, Path(name): Path<String>) -> Response {
36 146
    let name = name.trim_end_matches(".svg");
37 -
    ([(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "public, max-age=86400")], identicon(name)).into_response()
147 +
    let uploaded = match accounts::account_by_name(&app.db, name).await.ok().flatten() {
148 +
        Some(a) => Some(("account", a.id, a.avatar)),
149 +
        None => accounts::org_by_name(&app.db, name).await.ok().flatten().map(|o| ("org", o.id, o.avatar)),
150 +
    };
151 +
    if let Some((kind, id, value)) = uploaded.filter(|(_, _, v)| !v.is_empty()) {
152 +
        if let (Some((format, hash)), Ok(bytes)) = (value.split_once(':'), tokio::fs::read(file_of(&app, kind, id)).await) {
153 +
            let mime = if format == "png" { "image/png" } else { "image/jpeg" };
154 +
            return (
155 +
                [
156 +
                    (header::CONTENT_TYPE, mime.to_string()),
157 +
                    (header::CACHE_CONTROL, "public, max-age=300".to_string()),
158 +
                    (header::ETAG, format!("\"{hash}\"")),
159 +
                    (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
160 +
                ],
161 +
                bytes,
162 +
            )
163 +
                .into_response();
164 +
        }
165 +
    }
166 +
    ([(header::CONTENT_TYPE, "image/svg+xml"), (header::CACHE_CONTROL, "public, max-age=300")], identicon(name)).into_response()
38 167
}
39 168
40 169
#[cfg(test)]
41 170
mod tests {
42 -
    use super::identicon;
171 +
    use super::{identicon, process, SIZE};
172 +
173 +
    fn encoded(img: image::DynamicImage, format: image::ImageFormat) -> Vec<u8> {
174 +
        let mut out = Vec::new();
175 +
        img.write_to(&mut std::io::Cursor::new(&mut out), format).unwrap();
176 +
        out
177 +
    }
178 +
179 +
    #[test]
180 +
    fn uploads_are_cropped_scaled_and_reencoded() {
181 +
        // A wide photo becomes a square JPEG from its middle.
182 +
        let photo = image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(900, 300, |x, _| image::Rgb([(x / 4) as u8, 80, 160])));
183 +
        let pic = process(&encoded(photo, image::ImageFormat::Png)).unwrap();
184 +
        assert_eq!(pic.format, "jpeg");
185 +
        let back = image::load_from_memory(&pic.bytes).unwrap();
186 +
        assert_eq!((back.width(), back.height()), (SIZE, SIZE));
187 +
        // A logo with transparency stays PNG with its alpha.
188 +
        let logo = image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(64, 64, |x, _| {
189 +
            image::Rgba([0, 114, 255, if x < 32 { 0 } else { 255 }])
190 +
        }));
191 +
        let pic = process(&encoded(logo, image::ImageFormat::Png)).unwrap();
192 +
        assert_eq!(pic.format, "png");
193 +
        assert!(image::load_from_memory(&pic.bytes).unwrap().color().has_alpha());
194 +
        // JPEG in, too.
195 +
        let jpeg = encoded(image::DynamicImage::ImageRgb8(image::RgbImage::new(50, 80)), image::ImageFormat::Jpeg);
196 +
        assert_eq!(process(&jpeg).unwrap().format, "jpeg");
197 +
    }
198 +
199 +
    #[test]
200 +
    fn anything_else_is_refused() {
201 +
        assert!(process(b"<svg xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>").is_err(), "no SVG");
202 +
        assert!(process(b"GIF89a\x01\0\x01\0").is_err(), "no GIF");
203 +
        assert!(process(b"\x89PNG\r\n\x1a\n broken").is_err(), "a truncated PNG");
204 +
        assert!(process(&vec![0u8; super::MAX_BYTES + 1]).err().unwrap().contains("at most 5 MiB"));
205 +
        // Larger than the side limit: refused before allocating it.
206 +
        let big = encoded(image::DynamicImage::ImageLuma8(image::GrayImage::new(8001, 1)), image::ImageFormat::Png);
207 +
        assert!(process(&big).is_err(), "8001 pixels wide");
208 +
    }
43 209
44 210
    #[test]
45 211
    fn identicons_are_stable_symmetric_and_case_blind() {
…
20 unchanged lines
modified crates/hub/src/web/mod.rs +2 −0
…
70 unchanged lines
71 71
        .route("/orgs/{name}/settings", get(orgs::settings).post(orgs::update))
72 72
        .route("/orgs/{name}/settings/members", post(orgs::member))
73 73
        .route("/orgs/{name}/settings/teams", post(orgs::team))
74 +
        .route("/orgs/{name}/settings/avatar", post(orgs::avatar).layer(DefaultBodyLimit::max(avatar::MAX_BYTES + (64 << 10))))
75 +
        .route("/settings/avatar", post(settings::avatar).layer(DefaultBodyLimit::max(avatar::MAX_BYTES + (64 << 10))))
74 76
        .route("/notifications", get(inbox::inbox))
75 77
        .route("/notifications/read", post(inbox::mark_read))
76 78
        .route("/settings/notifications", get(inbox::unsubscribe).post(inbox::prefs))
…
490 unchanged lines
modified crates/hub/src/web/orgs.rs +22 −0
…
229 unchanged lines
230 230
    accounts::audit(&app.db, Some(&me), "org.team", &org.name, &format!("{} {team_name} {}", f.action, f.username.trim())).await.ok();
231 231
    org_page(&app, shell, org, Some(notice), None).await
232 232
}
233 +
234 +
/// `POST /orgs/{name}/settings/avatar`: upload or remove the organisation's logo.
235 +
pub async fn avatar(
236 +
    State(app): State<Shared>,
237 +
    viewer: Viewer,
238 +
    Path(name): Path<String>,
239 +
    multipart: axum::extract::Multipart,
240 +
) -> Result<Page<OrgSettingsPage>, Error> {
241 +
    let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer);
242 +
    let (me, org) = owned_org(&app, &shell, &viewer, &name).await?;
243 +
    let upload = super::avatar::read_upload(multipart).await;
244 +
    csrf(&viewer, &upload.csrf, &shell)?;
245 +
    let result = super::avatar::apply(&app, "org", org.id, upload).await;
246 +
    if result.is_ok() {
247 +
        accounts::audit(&app.db, Some(&me), "org.picture", &org.name, "").await.ok();
248 +
    }
249 +
    let org = accounts::org_by_name(&app.db, &name).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(org);
250 +
    match result {
251 +
        Ok(m) => org_page(&app, shell, org, Some(m), None).await,
252 +
        Err(e) => org_page(&app, shell, org, None, Some(e)).await,
253 +
    }
254 +
}
modified crates/hub/src/web/settings.rs +23 −0
…
85 unchanged lines
86 86
    Ok(Page(SettingsPage::new(shell, me.account, &headers).with_mail(&app)))
87 87
}
88 88
89 +
/// `POST /settings/avatar`: upload or remove the profile picture.
90 +
pub async fn avatar(
91 +
    State(app): State<Shared>,
92 +
    viewer: Viewer,
93 +
    headers: HeaderMap,
94 +
    multipart: Multipart,
95 +
) -> Result<Page<SettingsPage>, Error> {
96 +
    let shell = Shell::new(&app, "/settings", &viewer);
97 +
    let me = require(&viewer, &shell)?;
98 +
    let upload = super::avatar::read_upload(multipart).await;
99 +
    csrf(&viewer, &upload.csrf, &shell)?;
100 +
    let result = super::avatar::apply(&app, "account", me.account.id, upload).await;
101 +
    if result.is_ok() {
102 +
        accounts::audit(&app.db, Some(&me.account), "profile.picture", &me.account.username, "").await.ok();
103 +
    }
104 +
    let fresh = accounts::account_by_id(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(me.account);
105 +
    let page = SettingsPage::new(shell, fresh, &headers).with_mail(&app);
106 +
    Ok(Page(match result {
107 +
        Ok(m) => page.notice(m),
108 +
        Err(e) => page.error(e),
109 +
    }))
110 +
}
111 +
89 112
/// `POST /settings/email/verify`: send a new verification link.
90 113
pub async fn resend_verification(
91 114
    State(app): State<Shared>,
…
467 unchanged lines
modified crates/hub/tests/accounts.rs +109 −0
…
281 unchanged lines
282 282
    assert!(r.cookie.is_some());
283 283
    assert!(r.location.as_deref() == Some("/"));
284 284
}
285 +
286 +
/// A multipart picture form.
287 +
fn picture_form(csrf: &str, action: &str, file: &[u8]) -> (String, Vec<u8>) {
288 +
    let boundary = "picture-boundary-1234";
289 +
    let mut body = Vec::new();
290 +
    body.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"_csrf\"\r\n\r\n{csrf}\r\n").as_bytes());
291 +
    body.extend_from_slice(format!("--{boundary}\r\nContent-Disposition: form-data; name=\"action\"\r\n\r\n{action}\r\n").as_bytes());
292 +
    body.extend_from_slice(
293 +
        format!("--{boundary}\r\nContent-Disposition: form-data; name=\"picture\"; filename=\"me.png\"\r\nContent-Type: image/png\r\n\r\n")
294 +
            .as_bytes(),
295 +
    );
296 +
    body.extend_from_slice(file);
297 +
    body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
298 +
    (format!("multipart/form-data; boundary={boundary}"), body)
299 +
}
300 +
301 +
async fn upload(app: &Shared, path: &str, cookie: &str, csrf: &str, action: &str, file: &[u8]) -> Res {
302 +
    let (ct, body) = picture_form(csrf, action, file);
303 +
    let req = Request::post(path)
304 +
        .header(header::COOKIE, format!("oxyhub={cookie}"))
305 +
        .header(header::CONTENT_TYPE, ct)
306 +
        .body(Body::from(body))
307 +
        .unwrap();
308 +
    let res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap();
309 +
    let status = res.status();
310 +
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
311 +
    Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie: None, location: None }
312 +
}
313 +
314 +
/// Status, content type and bytes of a picture.
315 +
async fn picture(app: &Shared, name: &str) -> (StatusCode, String, Vec<u8>) {
316 +
    let res = oxygenhub::router(app.clone()).oneshot(Request::get(format!("/avatar/{name}")).body(Body::empty()).unwrap()).await.unwrap();
317 +
    let ct = res.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).unwrap_or("").to_string();
318 +
    let status = res.status();
319 +
    (status, ct, res.into_body().collect().await.unwrap().to_bytes().to_vec())
320 +
}
321 +
322 +
fn png(w: u32, h: u32, alpha: bool) -> Vec<u8> {
323 +
    let img = if alpha {
324 +
        image::DynamicImage::ImageRgba8(image::RgbaImage::from_fn(w, h, |x, _| {
325 +
            image::Rgba([0, 114, 255, if x % 2 == 0 { 0 } else { 255 }])
326 +
        }))
327 +
    } else {
328 +
        image::DynamicImage::ImageRgb8(image::RgbImage::from_fn(w, h, |x, y| image::Rgb([x as u8, y as u8, 128])))
329 +
    };
330 +
    let mut out = Vec::new();
331 +
    img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
332 +
    out
333 +
}
334 +
335 +
#[tokio::test(flavor = "multi_thread")]
336 +
async fn profile_pictures_and_logos() {
337 +
    let h = hub().await;
338 +
    let app = &h.app;
339 +
    let andy = register(app, "andy", "").await;
340 +
    assert_eq!(picture(app, "andy").await.1, "image/svg+xml", "the identicon first");
341 +
    let csrf = csrf_of(&get(app, "/settings", Some(&andy)).await.body);
342 +
    assert!(get(app, "/settings", Some(&andy)).await.body.contains("id=\"picture\""));
343 +
344 +
    // A photo: cropped, scaled and re-encoded as JPEG.
345 +
    let r = upload(app, "/settings/avatar", &andy, &csrf, "upload", &png(640, 480, false)).await;
346 +
    assert!(r.body.contains("Picture saved"), "{}", r.body);
347 +
    let (st, ct, bytes) = picture(app, "andy").await;
348 +
    assert_eq!((st, ct.as_str()), (StatusCode::OK, "image/jpeg"));
349 +
    let img = image::load_from_memory(&bytes).unwrap();
350 +
    assert_eq!((img.width(), img.height()), (400, 400));
351 +
    let stored = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().avatar;
352 +
    assert!(stored.starts_with("jpeg:"), "{stored}");
353 +
354 +
    // Not an image, or no CSRF: refused, and the picture stays.
355 +
    let r = upload(
356 +
        app,
357 +
        "/settings/avatar",
358 +
        &andy,
359 +
        &csrf,
360 +
        "upload",
361 +
        b"<svg xmlns=\"http://www.w3.org/2000/svg\"><script>alert(1)</script></svg>",
362 +
    )
363 +
    .await;
364 +
    assert!(r.body.contains("not a PNG, JPEG or WebP"), "{}", r.body);
365 +
    let r = upload(app, "/settings/avatar", &andy, "wrong", "upload", &png(10, 10, false)).await;
366 +
    assert_ne!(r.status, StatusCode::OK);
367 +
    assert_eq!(picture(app, "andy").await.1, "image/jpeg");
368 +
    // Removing brings the identicon back.
369 +
    let r = upload(app, "/settings/avatar", &andy, &csrf, "remove", b"").await;
370 +
    assert!(r.body.contains("Picture removed"));
371 +
    assert_eq!(picture(app, "andy").await.1, "image/svg+xml");
372 +
373 +
    // An organisation's logo keeps its transparency; only owners may set it.
374 +
    let me = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap();
375 +
    db::accounts::create_org(&app.db, "acme", "Acme", me.id).await.unwrap();
376 +
    let org_csrf = csrf_of(&get(app, "/orgs/acme/settings", Some(&andy)).await.body);
377 +
    let r = upload(app, "/orgs/acme/settings/avatar", &andy, &org_csrf, "upload", &png(120, 120, true)).await;
378 +
    assert!(r.body.contains("Picture saved"), "{}", r.body);
379 +
    let (_, ct, bytes) = picture(app, "acme").await;
380 +
    assert_eq!(ct, "image/png");
381 +
    assert!(image::load_from_memory(&bytes).unwrap().color().has_alpha());
382 +
    let invite = {
383 +
        let admin = get(app, "/admin", Some(&andy)).await;
384 +
        let r = post(app, "/admin/invite", Some(&andy), &format!("_csrf={}", csrf_of(&admin.body))).await;
385 +
        let i = r.body.find("invite=").map(|i| i + 7).expect("an invitation");
386 +
        r.body[i..].chars().take_while(|c| c.is_ascii_alphanumeric()).collect::<String>()
387 +
    };
388 +
    let bob = register(app, "bob", &invite).await;
389 +
    let bob_csrf = csrf_of(&get(app, "/settings", Some(&bob)).await.body);
390 +
    let r = upload(app, "/orgs/acme/settings/avatar", &bob, &bob_csrf, "remove", b"").await;
391 +
    assert_eq!(r.status, StatusCode::NOT_FOUND, "not an owner");
392 +
    assert_eq!(picture(app, "acme").await.1, "image/png");
393 +
}
modified docs/spec.md +1 −1
…
236 unchanged lines
237 237
|---|---|
238 238
| `/` | Signed in: dashboard (your repositories, proposals awaiting your review, assigned issues, recent activity). Anonymous: explore. |
239 239
| `/explore` | Every repository the viewer may see; `q` filters by name, description or topic, `topic` by one topic, `sort` is `activity` (default), `stars` or `name`; each row shows its topics and stars. Topics are set under repository settings (letters, digits and dashes, at most 20); anyone signed in can star a repository, and the overview shows star and watcher counts |
240 -
| `/{owner}` | A person: identicon, display name, bio, location, website, linked signing keys, organisations, repositories the viewer can see, and their recent activity (never the email). An organisation: identicon, description, members with roles, teams with sizes, repositories, recent activity in them. `/avatar/{name}` serves the identicon (SVG, a mirrored 5×5 pattern and hue from BLAKE3 of the lower-cased name). |
240 +
| `/{owner}` | A person: identicon, display name, bio, location, website, linked signing keys, organisations, repositories the viewer can see, and their recent activity (never the email). An organisation: identicon, description, members with roles, teams with sizes, repositories, recent activity in them. `/avatar/{name}` serves the account's or organisation's picture: an uploaded photo or logo when there is one, otherwise the identicon (SVG, a mirrored 5×5 pattern and hue from BLAKE3 of the lower-cased name). Pictures are uploaded under Settings › Profile, or an organisation's settings by its owners: PNG, JPEG or WebP recognised by content (not by name), at most 5 MiB and 8 000 pixels a side, decoded, centre-cropped to a square and re-encoded at 400×400 (PNG when it has transparency, JPEG otherwise), so no uploaded bytes are ever served. Removing the picture brings the identicon back. Pictures are cached for five minutes and carry an ETag. |
241 241
| `/{owner}/{name}` | Repository overview (§6.4); while empty, a quickstart with the exact commands for this hub (remote, certificate, token, push; start from nothing; import from Git) |
242 242
| `/{owner}/{name}/fork`, `/{owner}/{name}/forks` | Fork under an owner the viewer may create repositories for (same visibility, own repository id, objects hard-linked, bookmarks copied, policy not copied); the list of forks the viewer can see. The overview shows "forked from", the fork count, and on a fork a link that opens a proposal upstream with `owner/name:bookmark` as its source |
243 243
| `/{owner}/{name}/tree/{ref}/{path}` | Directory listing at a bookmark, tag, change or state (§6.5). A `{ref}` is a bookmark name, a tag name, a change hash or unique prefix, or a state: full change hashes joined with `+` (`a1…+b2…`), the state those heads make, which is how a state with several heads is addressed permanently |
…
324 unchanged lines
modified templates/org_settings.html +15 −0
…
4 unchanged lines
5 5
<div class="sectioned">
6 6
<nav class="section-nav" aria-label="Sections">
7 7
  <a href="#general">General</a>
8 +
  <a href="#logo">Logo</a>
8 9
  <a href="#members">Members</a>
9 10
  <a href="#teams">Teams</a>
10 11
</nav>
…
7 unchanged lines
18 19
  <label for="default_role">Members' default role on the organisation's repositories</label>
19 20
  <select id="default_role" name="default_role">{% for r in ["none", "read", "triage", "write", "maintain", "admin"] %}<option value="{{ r }}"{% if org.default_role.as_str() == *r %} selected{% endif %}>{{ r }}</option>{% endfor %}</select>
20 21
  <button type="submit">SAVE</button>
22 +
</form>
23 +
<form class="card picture-card" id="logo" method="post" action="/orgs/{{ org.name }}/settings/avatar" enctype="multipart/form-data">
24 +
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
25 +
  <h2>Logo</h2>
26 +
  <div class="picture-row">
27 +
    <img class="avatar xl" src="/avatar/{{ org.name }}?v={{ org.avatar }}" alt="{{ org.name }} logo" width="96" height="96">
28 +
    <div>
29 +
      <label for="logo-file">Upload a logo</label>
30 +
      <input type="file" id="logo-file" name="picture" accept="image/png,image/jpeg,image/webp">
31 +
      <p class="small mute">PNG (transparency is kept), JPEG or WebP, up to 5 MiB. It is cropped to the middle square and resized to 400×400.</p>
32 +
      <button type="submit" name="action" value="upload">UPLOAD</button>
33 +
      {% if !org.avatar.is_empty() %}<button type="submit" name="action" value="remove" class="danger-btn">REMOVE</button>{% endif %}
34 +
    </div>
35 +
  </div>
21 36
</form>
22 37
<section class="panel" id="members">
23 38
  <h2>Members</h2>
…
25 unchanged lines
modified templates/settings.html +15 −0
…
8 unchanged lines
9 9
<div class="sectioned">
10 10
<nav class="section-nav" aria-label="Sections">
11 11
  <a href="#profile">Profile</a>
12 +
  <a href="#picture">Picture</a>
12 13
  <a href="#appearance">Appearance</a>
13 14
  <a href="#password">Password</a>
14 15
</nav>
…
9 unchanged lines
24 25
  <label for="location">Location</label><input type="text" id="location" name="location" value="{{ me.location }}" maxlength="80">
25 26
  <p class="small mute">Shown on <a href="/{{ me.username }}">your profile</a>, with a picture drawn from your username.</p>
26 27
  <button type="submit">SAVE</button>
28 +
</form>
29 +
<form class="card picture-card" id="picture" method="post" action="/settings/avatar" enctype="multipart/form-data">
30 +
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
31 +
  <h2>Picture</h2>
32 +
  <div class="picture-row">
33 +
    <img class="avatar xl" src="/avatar/{{ me.username }}?v={{ me.avatar }}" alt="Your picture" width="96" height="96">
34 +
    <div>
35 +
      <label for="picture-file">Upload a photo</label>
36 +
      <input type="file" id="picture-file" name="picture" accept="image/png,image/jpeg,image/webp">
37 +
      <p class="small mute">PNG, JPEG or WebP, up to 5 MiB. It is cropped to the middle square and resized to 400×400.{% if me.avatar.is_empty() %} Until then you have a picture drawn from your username.{% endif %}</p>
38 +
      <button type="submit" name="action" value="upload">UPLOAD</button>
39 +
      {% if !me.avatar.is_empty() %}<button type="submit" name="action" value="remove" class="danger-btn">REMOVE</button>{% endif %}
40 +
    </div>
41 +
  </div>
27 42
</form>
28 43
<form class="card" id="appearance" method="post" action="/settings/appearance">
29 44
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
…
20 unchanged lines
modified templates/style.css +6 −1
…
341 unchanged lines
342 342
.scopes label { display: block; text-transform: none; letter-spacing: 0; font-size: 14px; color: var(--ink); margin: 4px 0; }
343 343
344 344
/* avatars and owner pages */
345 -
img.avatar { display: inline-block; vertical-align: middle; border: 1px solid var(--line); image-rendering: pixelated; }
345 +
img.avatar { display: inline-block; vertical-align: middle; border: 1px solid var(--line); object-fit: cover; }
346 346
img.avatar.xs { width: 18px; height: 18px; }
347 347
img.avatar.sm { width: 24px; height: 24px; }
348 348
img.avatar.xl { width: 100%; max-width: 240px; height: auto; aspect-ratio: 1; }
…
119 unchanged lines
468 468
/* Inbox filters (hub spec §10). */
469 469
.inbox-nav h3 { margin: 14px 0 4px 12px; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; color: var(--mute); }
470 470
.inbox .rows.threads .tag.dim { opacity: .7; }
471 +
472 +
/* Picture upload (hub spec §6.3). */
473 +
.picture-row { display: flex; gap: 18px; align-items: flex-start; flex-wrap: wrap; }
474 +
.picture-row img.avatar.xl { width: 96px; height: 96px; }
475 +
.picture-row > div { flex: 1; min-width: 220px; }