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; } |