authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 00:19:46-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-05 15:36:02-07:00
logae3420908b83c1e9384bb93c7da8cba4efffa636
tree6ce7782803819811b7763d847e5d409b6015e5c0
parent92445a1ff69766728a676fabd182092161c589bb
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

Underline only GitHub names, unlink Astheno identities, and preserve Shale OAuth callback hosts

Assisted-by: gpt-6

5 files changed, 76 insertions(+), 118 deletions(-)

dashboard/src/guest.rs+13-70
...@@ -291,20 +291,6 @@ fn signed_claims(...@@ -291,20 +291,6 @@ fn signed_claims(
291 Ok(claims)291 Ok(claims)
292}292}
293293
294pub(crate) fn astheno_profile(value: &str) -> Option<String> {
295 let url = url::Url::parse(value).ok()?;
296 (url.origin().ascii_serialization() == ASTHENO
297 && url.username().is_empty()
298 && url.password().is_none()
299 && url.query().is_none()
300 && url.fragment().is_none()
301 && url
302 .path()
303 .strip_prefix("/user/")
304 .is_some_and(|id| !id.is_empty() && !id.contains('/')))
305 .then(|| url.into())
306}
307
308async fn exchange(294async fn exchange(
309 http: &reqwest::Client,295 http: &reqwest::Client,
310 provider: &str,296 provider: &str,
...@@ -313,7 +299,7 @@ async fn exchange(...@@ -313,7 +299,7 @@ async fn exchange(
313 code: &str,299 code: &str,
314 callback: &str,300 callback: &str,
315 flow: &Value,301 flow: &Value,
316) -> Result<(String, String, Option<String>, Option<String>)> {302) -> Result<(String, String, Option<String>)> {
317 let form = [303 let form = [
318 ("client_id", client),304 ("client_id", client),
319 ("client_secret", secret),305 ("client_secret", secret),
...@@ -370,7 +356,7 @@ async fn exchange(...@@ -370,7 +356,7 @@ async fn exchange(
370 "GitHub couldn't verify your account. Return to Shale and try again.",356 "GitHub couldn't verify your account. Return to Shale and try again.",
371 )357 )
372 })?;358 })?;
373 return Ok((id.to_string(), login.to_owned(), None, None));359 return Ok((id.to_string(), login.to_owned(), None));
374 }360 }
375 let mut form = form.to_vec();361 let mut form = form.to_vec();
376 form.push(("state", "none"));362 form.push(("state", "none"));
...@@ -455,13 +441,7 @@ async fn exchange(...@@ -455,13 +441,7 @@ async fn exchange(
455 && url.password().is_none()441 && url.password().is_none()
456 })442 })
457 .map(String::from);443 .map(String::from);
458 let public_profile = profile["profile"].as_str().and_then(astheno_profile);444 Ok((string(&profile["sub"]).to_owned(), name, picture))
459 Ok((
460 string(&profile["sub"]).to_owned(),
461 name,
462 picture,
463 public_profile,
464 ))
465}445}
466446
467fn account(447fn account(
...@@ -470,12 +450,7 @@ fn account(...@@ -470,12 +450,7 @@ fn account(
470 subject: &str,450 subject: &str,
471 name: &str,451 name: &str,
472 picture: Option<&str>,452 picture: Option<&str>,
473 public_profile: Option<&str>,
474) -> Result<String> {453) -> Result<String> {
475 let mut attributes = json!({"picture":picture.map(|picture| vec![picture])});
476 if let Some(profile) = public_profile {
477 attributes["profile"] = json!([profile]);
478 }
479 let mut db = auth.db.lock().unwrap();454 let mut db = auth.db.lock().unwrap();
480 let tx = db.transaction()?;455 let tx = db.transaction()?;
481 let existing: Option<String> = tx456 let existing: Option<String> = tx
...@@ -495,10 +470,7 @@ fn account(...@@ -495,10 +470,7 @@ fn account(
495 }470 }
496 tx.execute(471 tx.execute(
497 "UPDATE users SET profile=json_patch(profile,?) WHERE id=?",472 "UPDATE users SET profile=json_patch(profile,?) WHERE id=?",
498 sql![473 sql![json!({"firstName":name,"attributes":{"picture":picture.map(|picture| vec![picture])}}).to_string(), id],
499 json!({"firstName":name,"attributes":attributes}).to_string(),
500 id
501 ],
502 )?;474 )?;
503 tx.commit()?;475 tx.commit()?;
504 return Ok(id);476 return Ok(id);
...@@ -509,11 +481,10 @@ fn account(...@@ -509,11 +481,10 @@ fn account(
509 } else {481 } else {
510 mcp::hash(subject)[..24].to_owned()482 mcp::hash(subject)[..24].to_owned()
511 };483 };
512 let mut profile = json!({"kind":"guest","guestProvider":provider,"username":format!("guest-{provider}-{suffix}"),"enabled":true,"email":null,"emailVerified":false,"firstName":name,"lastName":null,"requiredActions":[],"createdTimestamp":(now()*1000.0) as i64});484 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});
513 if picture.is_none() {485 if let Some(picture) = picture {
514 attributes.as_object_mut().unwrap().remove("picture");486 profile["attributes"]["picture"] = json!([picture]);
515 }487 }
516 profile["attributes"] = attributes;
517 tx.execute(488 tx.execute(
518 "INSERT INTO users(id,profile) VALUES (?,?)",489 "INSERT INTO users(id,profile) VALUES (?,?)",
519 sql![id, profile.to_string()],490 sql![id, profile.to_string()],
...@@ -652,16 +623,9 @@ async fn handle(app: &App, request: Request) -> Result<Response> {...@@ -652,16 +623,9 @@ async fn handle(app: &App, request: Request) -> Result<Response> {
652 headers623 headers
653 })624 })
654 .build()?;625 .build()?;
655 let (subject, name, picture, public_profile) =626 let (subject, name, picture) =
656 exchange(&http, provider, &client, &secret, code, &callback, &flow).await?;627 exchange(&http, provider, &client, &secret, code, &callback, &flow).await?;
657 let id = account(628 let id = account(auth, provider, &subject, &name, picture.as_deref())?;
658 auth,
659 provider,
660 &subject,
661 &name,
662 picture.as_deref(),
663 public_profile.as_deref(),
664 )?;
665 auth.create_session(&id, "dashboard", headers, None)629 auth.create_session(&id, "dashboard", headers, None)
666 }630 }
667 .await;631 .await;
...@@ -826,22 +790,6 @@ mod tests {...@@ -826,22 +790,6 @@ mod tests {
826 }790 }
827 }791 }
828792
829 #[test]
830 fn public_astheno_profiles_stay_on_the_identity_origin() {
831 let profile = "https://identity.astheno.software/user/00653PKPFWTHWVX7K6NZ06ZW79";
832 assert_eq!(astheno_profile(profile).as_deref(), Some(profile));
833 for value in [
834 "http://identity.astheno.software/user/id",
835 "https://other.test/user/id",
836 "https://identity.astheno.software/user/",
837 "https://identity.astheno.software/user/id/extra",
838 "https://identity.astheno.software/user/id?query=yes",
839 "https://user@identity.astheno.software/user/id",
840 ] {
841 assert!(astheno_profile(value).is_none());
842 }
843 }
844
845 #[test]793 #[test]
846 fn identities_never_link_by_name_or_email_and_cannot_gain_credentials_or_groups() {794 fn identities_never_link_by_name_or_email_and_cannot_gain_credentials_or_groups() {
847 let path = std::env::temp_dir().join(format!("guest-test-{}", uuid::Uuid::new_v4()));795 let path = std::env::temp_dir().join(format!("guest-test-{}", uuid::Uuid::new_v4()));
...@@ -858,15 +806,14 @@ mod tests {...@@ -858,15 +806,14 @@ mod tests {
858 db.execute("INSERT INTO roles VALUES ('admin','infra-admin') ON CONFLICT(name) DO UPDATE SET id=excluded.id", [])806 db.execute("INSERT INTO roles VALUES ('admin','infra-admin') ON CONFLICT(name) DO UPDATE SET id=excluded.id", [])
859 .unwrap();807 .unwrap();
860 }808 }
861 let first = account(&auth, "github", "123", "clover", None, None).unwrap();809 let first = account(&auth, "github", "123", "clover", None).unwrap();
862 let repeat = account(&auth, "github", "123", "renamed", None, None).unwrap();810 let repeat = account(&auth, "github", "123", "renamed", None).unwrap();
863 let other = account(811 let other = account(
864 &auth,812 &auth,
865 "astheno",813 "astheno",
866 "123",814 "123",
867 "clover",815 "clover",
868 Some("https://identity.astheno.software/avatar/123"),816 Some("https://identity.astheno.software/avatar/123"),
869 Some("https://identity.astheno.software/user/public-id"),
870 )817 )
871 .unwrap();818 .unwrap();
872 assert_eq!(first, repeat);819 assert_eq!(first, repeat);
...@@ -876,11 +823,7 @@ mod tests {...@@ -876,11 +823,7 @@ mod tests {
876 auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]["picture"][0],823 auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]["picture"][0],
877 "https://identity.astheno.software/avatar/123"824 "https://identity.astheno.software/avatar/123"
878 );825 );
879 account(&auth, "astheno", "123", "clover", None, None).unwrap();826 account(&auth, "astheno", "123", "clover", None).unwrap();
880 assert_eq!(
881 auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]["profile"][0],
882 "https://identity.astheno.software/user/public-id"
883 );
884 assert!(827 assert!(
885 auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]828 auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]
886 .get("picture")829 .get("picture")
...@@ -911,7 +854,7 @@ mod tests {...@@ -911,7 +854,7 @@ mod tests {
911 )854 )
912 .unwrap();855 .unwrap();
913 }856 }
914 assert!(account(&auth, "github", "123", "clover", None, None).is_err());857 assert!(account(&auth, "github", "123", "clover", None).is_err());
915 assert!(858 assert!(
916 auth.create_session(&other, "file", &HeaderMap::new(), None)859 auth.create_session(&other, "file", &HeaderMap::new(), None)
917 .is_err()860 .is_err()
dashboard/src/shale.rs+2-2
...@@ -326,7 +326,7 @@ fn issue(html: &str, repository: &str, id: Option<u64>) -> Result<Value> {...@@ -326,7 +326,7 @@ fn issue(html: &str, repository: &str, id: Option<u64>) -> Result<Value> {
326 )326 )
327 })?;327 })?;
328 let author = comment328 let author = comment
329 .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())329 .select(&Selector::parse(".n-card__header a[href^='/~'], .n-card__header a[href^='https://github.com/'], .n-card__header a:not([href]).va-middle-childs").unwrap())
330 .next()330 .next()
331 .map(text);331 .map(text);
332 let time = comment332 let time = comment
...@@ -937,7 +937,7 @@ mod tests {...@@ -937,7 +937,7 @@ mod tests {
937 }937 }
938 #[test]938 #[test]
939 fn issue_comment_authors_include_external_guest_profiles() {939 fn issue_comment_authors_include_external_guest_profiles() {
940 let page = "<head><meta name='astheno.shale.repo.name' content='owned'></head><body id=page-issue><h1><span>#3</span><span>Title</span></h1><dl class=sidebar><dd><span class=issuestatus-done>Done</span></dd></dl><ul><li class=comment id=c1><div class=n-card__header><a href='https://github.com/paperclover'>paperclover</a></div><div class=markdown>Comment</div></li><li class=comment id=c2><div class=n-card__header><a href='https://identity.astheno.software/user/123'>Astheno user</a></div><div class=markdown>Comment</div></li></ul></body>";940 let page = "<head><meta name='astheno.shale.repo.name' content='owned'></head><body id=page-issue><h1><span>#3</span><span>Title</span></h1><dl class=sidebar><dd><span class=issuestatus-done>Done</span></dd></dl><ul><li class=comment id=c1><div class=n-card__header><a href='https://github.com/paperclover'>paperclover</a></div><div class=markdown>Comment</div></li><li class=comment id=c2><div class=n-card__header><a class='usa-link va-middle-childs'>Astheno user</a></div><div class=markdown>Comment</div></li></ul></body>";
941 let parsed = issue(page, "owned", Some(3)).unwrap();941 let parsed = issue(page, "owned", Some(3)).unwrap();
942 assert_eq!(parsed["comments"][0]["author"], "paperclover");942 assert_eq!(parsed["comments"][0]["author"], "paperclover");
943 assert_eq!(parsed["comments"][1]["author"], "Astheno user");943 assert_eq!(parsed["comments"][1]["author"], "Astheno user");
dashboard/src/shale_page.rs+33-25
...@@ -4,14 +4,14 @@ use lol_html::{RewriteStrSettings, element, html_content::ContentType, text};...@@ -4,14 +4,14 @@ use lol_html::{RewriteStrSettings, element, html_content::ContentType, text};
44
5struct Profile {5struct Profile {
6 name: String,6 name: String,
7 url: String,7 url: Option<String>,
8 icon: &'static str,8 icon: &'static str,
9 picture: Option<String>,9 picture: Option<String>,
10}10}
1111
12fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {12fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {
13 let db = auth.db.lock().unwrap();13 let db = auth.db.lock().unwrap();
14 let mut query = db.prepare("SELECT json_extract(profile,'$.username'),coalesce(json_extract(profile,'$.firstName'),''),provider,subject,json_extract(profile,'$.attributes.picture[0]'),json_extract(profile,'$.attributes.profile[0]') FROM users JOIN external_identities ON user_id=users.id WHERE json_extract(profile,'$.kind')='guest'")?;14 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'")?;
15 let rows = query.query_map([], |row| {15 let rows = query.query_map([], |row| {
16 Ok((16 Ok((
17 row.get::<_, String>(0)?,17 row.get::<_, String>(0)?,
...@@ -19,24 +19,20 @@ fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {...@@ -19,24 +19,20 @@ fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {
19 row.get::<_, String>(2)?,19 row.get::<_, String>(2)?,
20 row.get::<_, String>(3)?,20 row.get::<_, String>(3)?,
21 row.get::<_, Option<String>>(4)?,21 row.get::<_, Option<String>>(4)?,
22 row.get::<_, Option<String>>(5)?,
23 ))22 ))
24 })?;23 })?;
25 let mut profiles = HashMap::new();24 let mut profiles = HashMap::new();
26 for row in rows {25 for row in rows {
27 let (username, name, provider, subject, picture, public_profile) = row?;26 let (username, name, provider, subject, picture) = row?;
28 if name.is_empty() { continue; }27 if name.is_empty() { continue; }
29 let (url, icon) = match provider.as_str() {28 let (url, icon) = match provider.as_str() {
30 "github" => {29 "github" => {
31 if matches!(name.as_str(), "." | "..") { continue; }30 if matches!(name.as_str(), "." | "..") { continue; }
32 let mut url = url::Url::parse("https://github.com/")?;31 let mut url = url::Url::parse("https://github.com/")?;
33 url.path_segments_mut().unwrap().pop_if_empty().push(&name);32 url.path_segments_mut().unwrap().pop_if_empty().push(&name);
34 (String::from(url), include_str!("../web/sso/github.svg"))33 (Some(String::from(url)), include_str!("../web/sso/github.svg"))
35 }
36 "astheno" => {
37 let Some(url) = public_profile.as_deref().and_then(guest::astheno_profile) else { continue; };
38 (url, include_str!("astheno.svg"))
39 }34 }
35 "astheno" => (None, include_str!("astheno.svg")),
40 _ => continue,36 _ => continue,
41 };37 };
42 profiles.insert(38 profiles.insert(
...@@ -79,19 +75,28 @@ fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap<String, Profile>) -...@@ -79,19 +75,28 @@ fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap<String, Profile>) -
79 let Some(profile) = profile else { return Ok(()); };75 let Some(profile) = profile else { return Ok(()); };
80 let closing = active.clone();76 let closing = active.clone();
81 element.on_end_tag(lol_html::end_tag!(move |_| { closing.set(None); Ok(()) }))?;77 element.on_end_tag(lol_html::end_tag!(move |_| { closing.set(None); Ok(()) }))?;
82 element.set_attribute("href", &profile.url)?;78 if let Some(url) = &profile.url {
83 element.set_attribute("target", "_blank")?;79 element.set_attribute("href", url)?;
84 element.set_attribute("rel", "noreferrer")?;80 element.set_attribute("target", "_blank")?;
81 element.set_attribute("rel", "noreferrer")?;
82 } else {
83 for attribute in ["href", "target", "rel", "tabindex"] { element.remove_attribute(attribute); }
84 }
85 let style = element.get_attribute("style").unwrap_or_default();85 let style = element.get_attribute("style").unwrap_or_default();
86 element.set_attribute("style", &format!("{style};text-decoration-line:underline;text-decoration-color:currentColor"))?;86 let cursor = if profile.url.is_some() { "" } else { ";cursor:default" };
87 element.set_attribute("style", &format!("{style};text-decoration:none{cursor}"))?;
87 if let Some(picture) = &profile.picture {88 if let Some(picture) = &profile.picture {
88 let picture = picture.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");89 let picture = picture.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
89 element.prepend(&format!("<img src=\"{picture}\" alt=\"\" width=\"16\" height=\"16\" referrerpolicy=\"no-referrer\" style=\"width:1em;height:1em;object-fit:cover;vertical-align:-.125em;margin-right:.3em\">"), ContentType::Html);90 element.prepend(&format!("<img src=\"{picture}\" alt=\"\" width=\"16\" height=\"16\" referrerpolicy=\"no-referrer\" style=\"width:1em;height:1em;object-fit:cover;vertical-align:-.125em;margin-right:.3em\">"), ContentType::Html);
90 }91 }
91 let icon = profile.icon.trim().replace("<svg ", "<svg aria-hidden=\"true\" focusable=\"false\" style=\"color:inherit;stroke:currentColor;width:.85em;height:.85em;vertical-align:-.1em;margin-right:.05em\" ");92 let icon = profile.icon.trim().replace("<svg ", "<svg aria-hidden=\"true\" focusable=\"false\" style=\"color:inherit;stroke:currentColor;width:.85em;height:.85em;vertical-align:-.1em;margin-right:.05em\" ");
92 let icon = if profile.url.starts_with("https://github.com/") { icon.replace("<path ", "<path style=\"fill:currentColor;stroke:none\" ") } else { icon };93 let icon = if profile.url.is_some() { icon.replace("<path ", "<path style=\"fill:currentColor;stroke:none\" ") } else { icon };
93 element.append(&icon, ContentType::Html);94 element.append(&icon, ContentType::Html);
95 if profile.url.is_some() {
96 element.append("<span style=\"text-decoration:underline;text-decoration-color:currentColor\">", ContentType::Html);
97 }
94 element.append(&profile.name, ContentType::Text);98 element.append(&profile.name, ContentType::Text);
99 if profile.url.is_some() { element.append("</span>", ContentType::Html); }
95 Ok(())100 Ok(())
96 }))101 }))
97 .append_element_content_handler(element!("a[href] img, a[href] span", move |child| {102 .append_element_content_handler(element!("a[href] img, a[href] span", move |child| {
...@@ -170,12 +175,12 @@ pub async fn proxy(app: Arc<App>, request: Request) -> Result<Response> {...@@ -170,12 +175,12 @@ pub async fn proxy(app: Arc<App>, request: Request) -> Result<Response> {
170 let origin = url::Url::parse(&format!("https://{host}{uri}"))?;175 let origin = url::Url::parse(&format!("https://{host}{uri}"))?;
171 let profiles = profiles(&app.auth)?;176 let profiles = profiles(&app.auth)?;
172 if matches!(parts.method, Method::GET | Method::HEAD)177 if matches!(parts.method, Method::GET | Method::HEAD)
173 && let Some(profile) = account_path(uri.path()).and_then(|name| profiles.get(name))178 && let Some(url) = account_path(uri.path()).and_then(|name| profiles.get(name)).and_then(|profile| profile.url.as_deref())
174 {179 {
175 return Ok((180 return Ok((
176 StatusCode::FOUND,181 StatusCode::FOUND,
177 [182 [
178 ("location", profile.url.as_str()),183 ("location", url),
179 ("cache-control", "no-store"),184 ("cache-control", "no-store"),
180 ("referrer-policy", "no-referrer"),185 ("referrer-policy", "no-referrer"),
181 ],186 ],
...@@ -275,7 +280,7 @@ mod tests {...@@ -275,7 +280,7 @@ mod tests {
275 "guest-github-123".into(),280 "guest-github-123".into(),
276 Profile {281 Profile {
277 name: "<&\"clover".into(),282 name: "<&\"clover".into(),
278 url: "https://github.com/clover".into(),283 url: Some("https://github.com/clover".into()),
279 icon: include_str!("../web/sso/github.svg"),284 icon: include_str!("../web/sso/github.svg"),
280 picture: Some("https://avatars.githubusercontent.com/u/123?s=64".into()),285 picture: Some("https://avatars.githubusercontent.com/u/123?s=64".into()),
281 },286 },
...@@ -284,7 +289,7 @@ mod tests {...@@ -284,7 +289,7 @@ mod tests {
284 "guest-astheno-abc".into(),289 "guest-astheno-abc".into(),
285 Profile {290 Profile {
286 name: "Astheno user".into(),291 name: "Astheno user".into(),
287 url: "https://identity.astheno.software/user/123".into(),292 url: None,
288 icon: include_str!("astheno.svg"),293 icon: include_str!("astheno.svg"),
289 picture: None,294 picture: None,
290 },295 },
...@@ -293,10 +298,16 @@ mod tests {...@@ -293,10 +298,16 @@ mod tests {
293 let html = r#"<a class="usa-nav-link" href="/~guest-github-123"><img src="avatar"><span>~guest-github-123</span></a><a href="https://shale.paperclover.net/~guest-astheno-abc/"><img src="astheno-avatar" alt="avatar">guest</a><a href="/~guest-github-unknown">unknown</a><a href="https://other.example/~guest-github-123">external</a><a href="/~guest-github-123/repo">repo</a><pre>~guest-github-123</pre>"#;298 let html = r#"<a class="usa-nav-link" href="/~guest-github-123"><img src="avatar"><span>~guest-github-123</span></a><a href="https://shale.paperclover.net/~guest-astheno-abc/"><img src="astheno-avatar" alt="avatar">guest</a><a href="/~guest-github-unknown">unknown</a><a href="https://other.example/~guest-github-123">external</a><a href="/~guest-github-123/repo">repo</a><pre>~guest-github-123</pre>"#;
294 let rewritten = rewrite(html, &origin, &profiles).unwrap();299 let rewritten = rewrite(html, &origin, &profiles).unwrap();
295 let page = Html::parse_document(&rewritten);300 let page = Html::parse_document(&rewritten);
296 let links: Vec<_> = page.select(&Selector::parse("a").unwrap()).collect();301 let links: Vec<_> = page.select(&Selector::parse("body > a").unwrap()).collect();
302 assert_eq!(links[0].attr("target"), Some("_blank"));
303 assert_eq!(links[0].attr("rel"), Some("noreferrer"));
304 assert!(links[0].attr("style").unwrap().contains("text-decoration:none"));
305 assert!(links[0].select(&Selector::parse("span[style]").unwrap()).next().unwrap().attr("style").unwrap().contains("text-decoration:underline"));
306 assert_eq!(links[1].value().name(), "a");
307 assert_eq!(links[1].attr("target"), None);
308 assert_eq!(links[1].attr("rel"), None);
309 assert_eq!(links[1].text().collect::<String>().trim(), "Astheno user");
297 for link in &links[..2] {310 for link in &links[..2] {
298 assert_eq!(link.attr("target"), Some("_blank"));
299 assert_eq!(link.attr("rel"), Some("noreferrer"));
300 assert_eq!(311 assert_eq!(
301 link.select(&Selector::parse("svg[aria-hidden=true]").unwrap())312 link.select(&Selector::parse("svg[aria-hidden=true]").unwrap())
302 .count(),313 .count(),
...@@ -322,10 +333,7 @@ mod tests {...@@ -322,10 +333,7 @@ mod tests {
322 );333 );
323 assert_eq!(links[0].text().collect::<String>().trim(), "<&\"clover");334 assert_eq!(links[0].text().collect::<String>().trim(), "<&\"clover");
324 assert_eq!(links[0].attr("class"), Some("usa-nav-link"));335 assert_eq!(links[0].attr("class"), Some("usa-nav-link"));
325 assert_eq!(336 assert_eq!(links[1].attr("href"), None);
326 links[1].attr("href"),
327 Some("https://identity.astheno.software/user/123")
328 );
329 for link in &links[2..] {337 for link in &links[2..] {
330 assert_eq!(link.attr("target"), None);338 assert_eq!(link.attr("target"), None);
331 }339 }
tools/dashboard-shale-page-test.py+22-16
...@@ -57,6 +57,9 @@ def main():...@@ -57,6 +57,9 @@ def main():
57 return self.respond(blob, 'application/octet-stream')57 return self.respond(blob, 'application/octet-stream')
58 if self.path == '/large':58 if self.path == '/large':
59 return self.respond(b' ' * (9 * 1024 * 1024), 'text/html')59 return self.respond(b' ' * (9 * 1024 * 1024), 'text/html')
60 if self.path == '/-/login':
61 callback = 'https://' + self.headers['Host'] + '/-/callback'
62 return self.respond(b'', 'text/plain', 302, [('Location', 'https://snowglobe.studio.test/auth/oidc/authorize?redirect_uri=' + urllib.parse.quote(callback, safe=''))])
60 if self.path == '/redirect':63 if self.path == '/redirect':
61 return self.respond(b'', 'text/plain', 302, [('Location', '/snowbound/issues/26'), ('Set-Cookie', 'SessionID=new; Path=/; HttpOnly'), ('Set-Cookie', 'other=kept; Path=/')])64 return self.respond(b'', 'text/plain', 302, [('Location', '/snowbound/issues/26'), ('Set-Cookie', 'SessionID=new; Path=/; HttpOnly'), ('Set-Cookie', 'other=kept; Path=/')])
62 if self.path.startswith('/-/') and args.page:65 if self.path.startswith('/-/') and args.page:
...@@ -122,11 +125,9 @@ def main():...@@ -122,11 +125,9 @@ def main():
122 for provider, subject, suffix, name in [('github', '24465214', '24465214', 'paperclover'), ('astheno', 'pairwise-astheno-subject', 'abc', 'Astheno user'), ('github', '777', '777', None), ('astheno', 'unmapped-pairwise-subject', 'unknown', 'Unmapped guest')]:125 for provider, subject, suffix, name in [('github', '24465214', '24465214', 'paperclover'), ('astheno', 'pairwise-astheno-subject', 'abc', 'Astheno user'), ('github', '777', '777', None), ('astheno', 'unmapped-pairwise-subject', 'unknown', 'Unmapped guest')]:
123 username = f'guest-{provider}-{suffix}'126 username = f'guest-{provider}-{suffix}'
124 profile = {'kind': 'guest', 'enabled': True, 'username': username, 'guestProvider': provider, 'firstName': name}127 profile = {'kind': 'guest', 'enabled': True, 'username': username, 'guestProvider': provider, 'firstName': name}
125 if provider == 'astheno' and suffix == 'abc':
126 profile['attributes'] = {'profile': ['https://identity.astheno.software/user/00653DG7HZ7MCGTW2BPG7RMGQB']}
127 db.execute('INSERT INTO users(id,profile) VALUES (?,?)', [username, json.dumps(profile)])128 db.execute('INSERT INTO users(id,profile) VALUES (?,?)', [username, json.dumps(profile)])
128 db.execute('INSERT INTO external_identities VALUES (?,?,?)', [provider, subject, username])129 db.execute('INSERT INTO external_identities VALUES (?,?,?)', [provider, subject, username])
129 os.environ.update(STUDIO_DOMAIN='studio.test', STUDIO_DASHBOARD_PORT=str(dashboard_port), STUDIO_PROXY_TOKEN_FILE=str(token))130 os.environ.update(STUDIO_DOMAIN='studio.test', STUDIO_DASHBOARD_PORT=str(dashboard_port), STUDIO_PROXY_TOKEN_FILE=str(token), STUDIO_INTERNAL_PORT=str(internal_port))
130 router.ROUTE_DIR = str(root / 'routes')131 router.ROUTE_DIR = str(root / 'routes')
131 Path(router.ROUTE_DIR).mkdir()132 Path(router.ROUTE_DIR).mkdir()
132 (Path(router.ROUTE_DIR) / 'shale.json').write_text(json.dumps({'headHtml': {'shale.studio.test': {'/snowbound/*': '<meta name="rewrite-test" content="kept">'}}}))133 (Path(router.ROUTE_DIR) / 'shale.json').write_text(json.dumps({'headHtml': {'shale.studio.test': {'/snowbound/*': '<meta name="rewrite-test" content="kept">'}}}))
...@@ -153,10 +154,9 @@ def main():...@@ -153,10 +154,9 @@ def main():
153154
154 preview_port = port()155 preview_port = port()
155 config = '{\n admin off\n auto_https off\n}\n' + local_site('shale.studio.test', gateway_port) + '\n' + local_site('shale-preview-12345678.studio.test', preview_port)156 config = '{\n admin off\n auto_https off\n}\n' + local_site('shale.studio.test', gateway_port) + '\n' + local_site('shale-preview-12345678.studio.test', preview_port)
156 config += f'\nhttps://localhost:{internal_port} {{\n tls {certificate} {gateway_key}\n @trusted header Studio-Proxy-Token {proof}\n handle @trusted {{\n request_header -Studio-Proxy-Token\n'157 internal_host = f'dashboard.internal.studio.test:{internal_port}'
157 for service in ['shale', 'shale-preview-12345678']:158 start = rendered.index(internal_host + ' {')
158 config += f' handle_path /services/{service}/* {{\n reverse_proxy 127.0.0.1:{fixture.server_port}\n }}\n'159 config += '\n' + rendered[start:].replace(internal_host + ' {', f'https://localhost:{internal_port} {{', 1).replace(' tls internal\n', f' tls {certificate} {gateway_key}\n', 1)
159 config += ' }\n handle {\n respond 403\n }\n}\n'
160 config_path = root / 'Caddyfile'160 config_path = root / 'Caddyfile'
161 config_path.write_text(config)161 config_path.write_text(config)
162 subprocess.run([str(args.caddy), 'validate', '--config', str(config_path), '--adapter', 'caddyfile'], check=True, capture_output=True, env=caddy_env)162 subprocess.run([str(args.caddy), 'validate', '--config', str(config_path), '--adapter', 'caddyfile'], check=True, capture_output=True, env=caddy_env)
...@@ -186,26 +186,32 @@ def main():...@@ -186,26 +186,32 @@ def main():
186 time.sleep(.05)186 time.sleep(.05)
187 assert status == 200, (status, body, (root / 'dashboard.log').read_text(), (root / 'caddy.log').read_text())187 assert status == 200, (status, body, (root / 'dashboard.log').read_text(), (root / 'caddy.log').read_text())
188 links = Links(body.decode()).links188 links = Links(body.decode()).links
189 for url in ['https://github.com/paperclover', 'https://identity.astheno.software/user/00653DG7HZ7MCGTW2BPG7RMGQB']:189 link = next(link for link in links if link.get('href') == 'https://github.com/paperclover')
190 link = next(link for link in links if link['href'] == url)190 assert link['target'] == '_blank' and link['rel'] == 'noreferrer', link
191 assert link['target'] == '_blank' and link['rel'] == 'noreferrer', link191 assert any(link.get('href') == '/~guest-github-unknown' for link in links)
192 assert any(link['href'] == '/~guest-github-unknown' for link in links)192 assert any(link.get('href') == '/~guest-github-24465214/repo' for link in links)
193 assert any(link['href'] == '/~guest-github-24465214/repo' for link in links)193 assert b'Astheno user' in body and not any(link.get('href') == '/~guest-astheno-abc' for link in links)
194 assert b'identity.astheno.software/user/' not in body and b'pairwise-astheno-subject' not in body
194 assert b'https://avatars.githubusercontent.com/u/24465214?s=64' in body195 assert b'https://avatars.githubusercontent.com/u/24465214?s=64' in body
195 assert b'rewrite-test' in body196 assert b'rewrite-test' in body
196 assert headers['Cache-Control'] == 'no-store' and headers.get('ETag') is None and headers.get('Last-Modified') is None197 assert headers['Cache-Control'] == 'no-store' and headers.get('ETag') is None and headers.get('Last-Modified') is None
197 assert observations[-1][1] == '/snowbound/issues/26?query=kept', observations[-1]198 assert observations[-1][1] == '/snowbound/issues/26?query=kept', observations[-1]
199 assert observations[-1][2]['host'] == 'shale.studio.test', observations[-1]
198 assert observations[-1][2]['cookie'] == 'SessionID=preserved', observations[-1]200 assert observations[-1][2]['cookie'] == 'SessionID=preserved', observations[-1]
199 assert not any(key.lower().startswith('studio-') for key in observations[-1][2]), observations[-1]201 assert not any(key.lower().startswith('studio-') for key in observations[-1][2]), observations[-1]
200 for path, target in [('/~guest-github-24465214', 'https://github.com/paperclover'), ('/~guest-astheno-abc/', 'https://identity.astheno.software/user/00653DG7HZ7MCGTW2BPG7RMGQB')]:202 status, headers, body = request('/~guest-github-24465214')
201 status, headers, body = request(path)203 assert status == 302 and headers['Location'] == 'https://github.com/paperclover' and headers['Referrer-Policy'] == 'no-referrer'
202 assert status == 302 and headers['Location'] == target and headers['Referrer-Policy'] == 'no-referrer', (path, status, dict(headers), observations[-1][:2])204 assert request('/~guest-astheno-abc/')[0] == 200
205 assert observations[-1][1] == '/~guest-astheno-abc/'
203 assert request('/~guest-astheno-unknown')[0] == 200206 assert request('/~guest-astheno-unknown')[0] == 200
207 status, headers, body = request('/-/login')
208 assert status == 302 and urllib.parse.parse_qs(urllib.parse.urlparse(headers['Location']).query)['redirect_uri'] == ['https://shale.studio.test/-/callback']
204 status, headers, body = request('/redirect')209 status, headers, body = request('/redirect')
205 assert status == 302 and headers['Location'] == '/snowbound/issues/26' and len(headers.get_all('Set-Cookie')) == 2210 assert status == 302 and headers['Location'] == '/snowbound/issues/26' and len(headers.get_all('Set-Cookie')) == 2
206 with opener.open(f'http://127.0.0.1:{preview_port}/snowbound/issues/26') as response:211 with opener.open(f'http://127.0.0.1:{preview_port}/snowbound/issues/26') as response:
207 assert response.status == 200212 assert response.status == 200
208 assert any(link['href'] == 'https://github.com/paperclover' for link in Links(response.read().decode()).links)213 assert observations[-1][2]['host'] == 'shale-preview-12345678.studio.test'
214 assert any(link.get('href') == 'https://github.com/paperclover' for link in Links(response.read().decode()).links)
209 assert b'/~guest-github-24465214' in request('/repo/info/refs')[2]215 assert b'/~guest-github-24465214' in request('/repo/info/refs')[2]
210 assert request('/binary')[2] == blob216 assert request('/binary')[2] == blob
211 assert len(request('/large')[2]) == 9 * 1024 * 1024217 assert len(request('/large')[2]) == 9 * 1024 * 1024
tools/router.py+6-5
...@@ -24,13 +24,13 @@ def nomad(path, token):...@@ -24,13 +24,13 @@ def nomad(path, token):
24 return json.load(response)24 return json.load(response)
2525
2626
27def proxy(upstreams, indent, uncompressed=False, upstream_host=False):27def proxy(upstreams, indent, uncompressed=False, host=None):
28 lines = [f"{indent}reverse_proxy {upstreams} {{", f"{indent} lb_try_duration 5s",28 lines = [f"{indent}reverse_proxy {upstreams} {{", f"{indent} lb_try_duration 5s",
29 f"{indent} fail_duration 30s"]29 f"{indent} fail_duration 30s"]
30 if uncompressed:30 if uncompressed:
31 lines.append(f"{indent} header_up Accept-Encoding identity")31 lines.append(f"{indent} header_up Accept-Encoding identity")
32 if upstream_host:32 if host:
33 lines.append(f"{indent} header_up Host {{upstream_hostport}}")33 lines.append(f"{indent} header_up Host {host}")
34 return [*lines, f"{indent}}}"]34 return [*lines, f"{indent}}}"]
3535
3636
...@@ -357,10 +357,11 @@ def render(token):...@@ -357,10 +357,11 @@ def render(token):
357 f" @dashboard header Studio-Proxy-Token {dashboard_proof}",357 f" @dashboard header Studio-Proxy-Token {dashboard_proof}",
358 " handle @dashboard {", " request_header -Studio-Proxy-Token",358 " handle @dashboard {", " request_header -Studio-Proxy-Token",
359 " request_header -User-Name", " request_header -User-Groups",359 " request_header -User-Name", " request_header -User-Groups",
360 " handle_path /nomad/* {", *proxy("127.0.0.1:4646", " ", upstream_host=True), " }"]360 " handle_path /nomad/* {", *proxy("127.0.0.1:4646", " ", host="{upstream_hostport}"), " }"]
361 for service, upstreams in sorted(internal_services.items()):361 for service, upstreams in sorted(internal_services.items()):
362 host = f"{service}.{os.environ['STUDIO_DOMAIN']}" if service == "shale" or re.fullmatch(r"shale-preview-[0-9a-f]{8}", service) else "{upstream_hostport}"
362 lines += [f" handle_path /services/{service}/* {{",363 lines += [f" handle_path /services/{service}/* {{",
363 *proxy(" ".join(sorted(upstreams)), " ", upstream_host=True), " }"]364 *proxy(" ".join(sorted(upstreams)), " ", host=host), " }"]
364 lines += [" handle {", " respond 404", " }", " }", " handle {", " respond 403", " }", "}"]365 lines += [" handle {", " respond 404", " }", " }", " handle {", " respond 403", " }", "}"]
365 return "\n".join(lines) + "\n"366 return "\n".join(lines) + "\n"
366367