diff --git a/dashboard/Cargo.lock b/dashboard/Cargo.lock
index 9300a90690c8b109e60ae8c9d0b1b015bd2c619e..9500fd93e90dd7b9b479cec57fd5bb74fa9d0ff4 100644
--- a/dashboard/Cargo.lock
+++ b/dashboard/Cargo.lock
@@ -11,6 +11,12 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
[[package]]
name = "android_system_properties"
version = "0.1.6"
@@ -324,19 +330,42 @@ dependencies = [
"typenum",
]
+[[package]]
+name = "cssparser"
+version = "0.36.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
+dependencies = [
+ "cssparser-macros 0.6.1",
+ "dtoa-short",
+ "itoa",
+ "phf",
+ "smallvec",
+]
+
[[package]]
name = "cssparser"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
dependencies = [
- "cssparser-macros",
+ "cssparser-macros 0.7.1",
"dtoa-short",
"itoa",
"phf",
"smallvec",
]
+[[package]]
+name = "cssparser-macros"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
+dependencies = [
+ "quote",
+ "syn 2.0.119",
+]
+
[[package]]
name = "cssparser-macros"
version = "0.7.1"
@@ -503,6 +532,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
[[package]]
name = "foreign-types"
version = "0.3.2"
@@ -694,7 +729,7 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
- "foldhash",
+ "foldhash 0.1.5",
]
[[package]]
@@ -702,6 +737,11 @@ name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.2.0",
+]
[[package]]
name = "hashlink"
@@ -729,6 +769,7 @@ dependencies = [
"chrono",
"futures",
"globset",
+ "lol_html",
"openssl",
"rand 0.9.5",
"regex",
@@ -1080,6 +1121,25 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+[[package]]
+name = "lol_html"
+version = "3.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5adbb62638edf7e6bc88835cd3ea388bdd53382af42045da0e414ea78aa0c91a"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "cssparser 0.36.0",
+ "encoding_rs",
+ "foldhash 0.2.0",
+ "hashbrown 0.17.1",
+ "memchr",
+ "mime",
+ "precomputed-hash",
+ "selectors 0.37.0",
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "lru-slab"
version = "0.1.3"
@@ -1649,12 +1709,14 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-rustls",
+ "tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
+ "wasm-streams",
"web-sys",
"webpki-roots",
]
@@ -1836,14 +1898,33 @@ version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2"
dependencies = [
- "cssparser",
+ "cssparser 0.37.0",
"ego-tree",
"html5ever",
"precomputed-hash",
- "selectors",
+ "selectors 0.38.0",
"tendril",
]
+[[package]]
+name = "selectors"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cfaaa6035167f0e604e42723c7650d59ee269ef220d7bbe0565602c8a0173b9"
+dependencies = [
+ "bitflags",
+ "cssparser 0.36.0",
+ "derive_more",
+ "log",
+ "new_debug_unreachable",
+ "phf",
+ "phf_codegen",
+ "precomputed-hash",
+ "rustc-hash",
+ "servo_arc",
+ "smallvec",
+]
+
[[package]]
name = "selectors"
version = "0.38.0"
@@ -1851,7 +1932,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d"
dependencies = [
"bitflags",
- "cssparser",
+ "cssparser 0.37.0",
"derive_more",
"log",
"new_debug_unreachable",
@@ -2582,6 +2663,19 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "wasm-streams"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "web-sys"
version = "0.3.106"
diff --git a/dashboard/Cargo.toml b/dashboard/Cargo.toml
index 0d43b62eb3a91716c8e07d1ccc9fb9409677ae62..14be074d6b72e34aa060aba83fb4fb170f3e2713 100644
--- a/dashboard/Cargo.toml
+++ b/dashboard/Cargo.toml
@@ -13,8 +13,9 @@ futures = "0.3"
globset = "0.4"
openssl = "0.10"
rand = "0.9"
+lol_html = "3"
regex = "1"
-reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart"] }
+reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "multipart", "stream"] }
rmcp = { version = "3.5.0", default-features = false, features = ["server", "transport-streamable-http-server"] }
rusqlite = { version = "0.37", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
diff --git a/dashboard/src/astheno.svg b/dashboard/src/astheno.svg
new file mode 100644
index 0000000000000000000000000000000000000000..2c5fe85aad44c9bce6bd5e45a6ae9c3f9d3aa1c8
--- /dev/null
+++ b/dashboard/src/astheno.svg
@@ -0,0 +1,50 @@
+
+
diff --git a/dashboard/src/auth.rs b/dashboard/src/auth.rs
index 87b0a25bbf73d835b578d973e142687db4cb3e8b..f4d98867023d8c3e8579812771f4a466988c15c3 100644
--- a/dashboard/src/auth.rs
+++ b/dashboard/src/auth.rs
@@ -560,6 +560,9 @@ impl Store {
}
pub async fn route(State(app): State>, request: Request) -> Result {
+ if request.uri().path() == "/auth/shale/page" {
+ return shale_page::proxy(app, request).await;
+ }
if request.uri().path().starts_with("/auth/guest/") {
return Ok(guest::route(State(app), request).await);
}
diff --git a/dashboard/src/guest.rs b/dashboard/src/guest.rs
index 60be1d70349053da8f8cdd86b0ba99140a64fbdf..3e4c592074438ecea29c3a416bf6b0f916ac1325 100644
--- a/dashboard/src/guest.rs
+++ b/dashboard/src/guest.rs
@@ -299,7 +299,7 @@ async fn exchange(
code: &str,
callback: &str,
flow: &Value,
-) -> Result<(String, String)> {
+) -> Result<(String, String, Option)> {
let form = [
("client_id", client),
("client_secret", secret),
@@ -356,7 +356,7 @@ async fn exchange(
"GitHub couldn't verify your account. Return to Shale and try again.",
)
})?;
- return Ok((id.to_string(), login.to_owned()));
+ return Ok((id.to_string(), login.to_owned(), None));
}
let mut form = form.to_vec();
form.push(("state", "none"));
@@ -430,10 +430,27 @@ async fn exchange(
.chars()
.take(128)
.collect();
- Ok((string(&profile["sub"]).to_owned(), name))
+ let picture = profile["picture"]
+ .as_str()
+ .filter(|value| value.len() <= 2048)
+ .and_then(|value| url::Url::parse(value).ok())
+ .filter(|url| {
+ url.scheme() == "https"
+ && url.host_str().is_some()
+ && url.username().is_empty()
+ && url.password().is_none()
+ })
+ .map(String::from);
+ Ok((string(&profile["sub"]).to_owned(), name, picture))
}
-fn account(auth: &auth::Store, provider: &str, subject: &str, name: &str) -> Result {
+fn account(
+ auth: &auth::Store,
+ provider: &str,
+ subject: &str,
+ name: &str,
+ picture: Option<&str>,
+) -> Result {
let mut db = auth.db.lock().unwrap();
let tx = db.transaction()?;
let existing: Option = tx
@@ -451,6 +468,11 @@ fn account(auth: &auth::Store, provider: &str, subject: &str, name: &str) -> Res
"This guest account is disabled. Contact Clover.",
));
}
+ tx.execute(
+ "UPDATE users SET profile=json_patch(profile,?) WHERE id=?",
+ sql![json!({"firstName":name,"attributes":{"picture":picture.map(|picture| vec![picture])}}).to_string(), id],
+ )?;
+ tx.commit()?;
return Ok(id);
}
let id = uuid::Uuid::new_v4().to_string();
@@ -459,7 +481,10 @@ fn account(auth: &auth::Store, provider: &str, subject: &str, name: &str) -> Res
} else {
mcp::hash(subject)[..24].to_owned()
};
- let profile = json!({"kind":"guest","guestProvider":provider,"username":format!("guest-{provider}-{suffix}"),"enabled":true,"email":null,"emailVerified":false,"firstName":name,"lastName":null,"requiredActions":[],"attributes":{},"createdTimestamp":(now()*1000.0) as i64});
+ let mut profile = json!({"kind":"guest","guestProvider":provider,"username":format!("guest-{provider}-{suffix}"),"enabled":true,"email":null,"emailVerified":false,"firstName":name,"lastName":null,"requiredActions":[],"attributes":{},"createdTimestamp":(now()*1000.0) as i64});
+ if let Some(picture) = picture {
+ profile["attributes"]["picture"] = json!([picture]);
+ }
tx.execute(
"INSERT INTO users(id,profile) VALUES (?,?)",
sql![id, profile.to_string()],
@@ -598,9 +623,9 @@ async fn handle(app: &App, request: Request) -> Result {
headers
})
.build()?;
- let (subject, name) =
+ let (subject, name, picture) =
exchange(&http, provider, &client, &secret, code, &callback, &flow).await?;
- let id = account(auth, provider, &subject, &name)?;
+ let id = account(auth, provider, &subject, &name, picture.as_deref())?;
auth.create_session(&id, "dashboard", headers, None)
}
.await;
@@ -781,16 +806,34 @@ mod tests {
db.execute("INSERT INTO roles VALUES ('admin','infra-admin')", [])
.unwrap();
}
- let first = account(&auth, "github", "123", "clover").unwrap();
- let repeat = account(&auth, "github", "123", "renamed").unwrap();
- let other = account(&auth, "astheno", "123", "clover").unwrap();
+ let first = account(&auth, "github", "123", "clover", None).unwrap();
+ let repeat = account(&auth, "github", "123", "renamed", None).unwrap();
+ let other = account(
+ &auth,
+ "astheno",
+ "123",
+ "clover",
+ Some("https://identity.astheno.software/avatar/123"),
+ )
+ .unwrap();
assert_eq!(first, repeat);
assert_ne!(first, "owner");
assert_ne!(first, other);
+ assert_eq!(
+ auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]["picture"][0],
+ "https://identity.astheno.software/avatar/123"
+ );
+ account(&auth, "astheno", "123", "clover", None).unwrap();
+ assert!(
+ auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]
+ .get("picture")
+ .is_none()
+ );
{
let db = auth.db.lock().unwrap();
let profile = auth::user(&db, &first).unwrap();
assert!(is_guest(&profile));
+ assert_eq!(profile["firstName"], "renamed");
assert!(profile["email"].is_null());
assert_eq!(profile["groups"], json!([]));
assert!(
@@ -811,7 +854,7 @@ mod tests {
)
.unwrap();
}
- assert!(account(&auth, "github", "123", "clover").is_err());
+ assert!(account(&auth, "github", "123", "clover", None).is_err());
assert!(
auth.create_session(&other, "file", &HeaderMap::new(), None)
.is_err()
diff --git a/dashboard/src/main.rs b/dashboard/src/main.rs
index dcb5c295334b76e58bdd6e78fc5bfb87e8ef76b4..1082d8038f884e17f583f96c66a4f1cca91522cd 100644
--- a/dashboard/src/main.rs
+++ b/dashboard/src/main.rs
@@ -12,6 +12,7 @@ mod observability;
mod oidc;
mod relay;
mod shale;
+mod shale_page;
mod storage;
mod telemetry;
mod users;
@@ -527,6 +528,9 @@ async fn main() -> std::result::Result<(), Box> {
}
request.headers_mut().remove("Studio-Proxy-Token");
}
+ if request.uri().path() == "/auth/shale/page" {
+ return next.run(request).await;
+ }
let path = request.uri().path().to_owned();
let asset = path.starts_with("/assets/")
|| path.starts_with("/fonts/")
diff --git a/dashboard/src/shale.rs b/dashboard/src/shale.rs
index 3226ae2bc9368653b5879fc7700d81c436a8ad53..39d5dfa82bddc47abd893ea4161cac0e9c9dff65 100644
--- a/dashboard/src/shale.rs
+++ b/dashboard/src/shale.rs
@@ -326,7 +326,7 @@ fn issue(html: &str, repository: &str, id: Option) -> Result {
)
})?;
let author = comment
- .select(&Selector::parse(".n-card__header a[href^='/~']").unwrap())
+ .select(&Selector::parse(".n-card__header a[href^='/~'], .n-card__header a[href^='https://github.com/'], .n-card__header a[href^='https://identity.astheno.software/user/']").unwrap())
.next()
.map(text);
let time = comment
@@ -935,6 +935,14 @@ mod tests {
assert!(issue(&page.replace("r1616-ga87d2f5.zig.0.16.0", "r1758-new"), "owned", Some(3)).is_err());
assert!(issue(&page.replace("Issue #3", "Issue #0"), "owned", None).is_err());
}
+ #[test]
+ fn issue_comment_authors_include_external_guest_profiles() {
+ let page = "#3Title
";
+ let parsed = issue(page, "owned", Some(3)).unwrap();
+ assert_eq!(parsed["comments"][0]["author"], "paperclover");
+ assert_eq!(parsed["comments"][1]["author"], "Astheno user");
+ }
+
#[test]
fn account_identity_uses_html_text_and_rejects_login_or_changed_markup() {
assert_eq!(
diff --git a/dashboard/src/shale_page.rs b/dashboard/src/shale_page.rs
new file mode 100644
index 0000000000000000000000000000000000000000..17f1383fadb10474063c30a65af5b436c4f1632e
--- /dev/null
+++ b/dashboard/src/shale_page.rs
@@ -0,0 +1,336 @@
+use crate::*;
+use axum::body::Body;
+use lol_html::{RewriteStrSettings, element, html_content::ContentType, text};
+
+struct Profile {
+ name: String,
+ url: String,
+ icon: &'static str,
+ picture: Option,
+}
+
+fn profiles(auth: &auth::Store) -> Result> {
+ let db = auth.db.lock().unwrap();
+ let mut query = db.prepare("SELECT json_extract(profile,'$.username'),coalesce(json_extract(profile,'$.firstName'),''),provider,subject,json_extract(profile,'$.attributes.picture[0]') FROM users JOIN external_identities ON user_id=users.id WHERE json_extract(profile,'$.kind')='guest'")?;
+ let rows = query.query_map([], |row| {
+ Ok((
+ row.get::<_, String>(0)?,
+ row.get::<_, String>(1)?,
+ row.get::<_, String>(2)?,
+ row.get::<_, String>(3)?,
+ row.get::<_, Option>(4)?,
+ ))
+ })?;
+ let mut profiles = HashMap::new();
+ for row in rows {
+ let (username, name, provider, subject, picture) = row?;
+ let (mut url, id, icon) = match provider.as_str() {
+ "github" => (
+ url::Url::parse("https://github.com/")?,
+ name.as_str(),
+ include_str!("../web/sso/github.svg"),
+ ),
+ "astheno" => (
+ url::Url::parse("https://identity.astheno.software/user/")?,
+ subject.as_str(),
+ include_str!("astheno.svg"),
+ ),
+ _ => continue,
+ };
+ if name.is_empty() || id.is_empty() || matches!(id, "." | "..") {
+ continue;
+ }
+ url.path_segments_mut().unwrap().pop_if_empty().push(id);
+ profiles.insert(
+ username,
+ Profile {
+ name,
+ url: url.into(),
+ icon,
+ picture: if provider == "github" {
+ Some(format!(
+ "https://avatars.githubusercontent.com/u/{subject}?s=64"
+ ))
+ } else {
+ picture
+ },
+ },
+ );
+ }
+ Ok(profiles)
+}
+
+fn account_path(path: &str) -> Option<&str> {
+ let name = path.strip_prefix("/~")?;
+ let name = name.strip_suffix('/').unwrap_or(name);
+ (name.starts_with("guest-") && !name.contains('/')).then_some(name)
+}
+
+fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap) -> Result {
+ use std::{cell::Cell, rc::Rc};
+ let active = Rc::new(Cell::new(None::));
+ let images = active.clone();
+ let labels = active.clone();
+ Ok(lol_html::rewrite_str(html, RewriteStrSettings::new()
+ .append_element_content_handler(element!("a[href]", |element| {
+ let profile = element.get_attribute("href")
+ .and_then(|href| origin.join(&href).ok())
+ .filter(|target| target.origin() == origin.origin())
+ .and_then(|target| account_path(target.path()).and_then(|name| profiles.get(name)));
+ active.set(profile.map(|profile| profile.picture.is_some()));
+ let Some(profile) = profile else { return Ok(()); };
+ let closing = active.clone();
+ element.on_end_tag(lol_html::end_tag!(move |_| { closing.set(None); Ok(()) }))?;
+ element.set_attribute("href", &profile.url)?;
+ element.set_attribute("target", "_blank")?;
+ element.set_attribute("rel", "noreferrer")?;
+ if let Some(picture) = &profile.picture {
+ let picture = picture.replace('&', "&").replace('"', """).replace('<', "<");
+ element.prepend(&format!("
"), ContentType::Html);
+ }
+ let icon = profile.icon.trim().replace("