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(
291291 Ok(claims)
292292}
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
308294async fn exchange(
309295 http: &reqwest::Client,
310296 provider: &str,
......@@ -313,7 +299,7 @@ async fn exchange(
313299 code: &str,
314300 callback: &str,
315301 flow: &Value,
316) -> Result<(String, String, Option<String>, Option<String>)> {
302) -> Result<(String, String, Option<String>)> {
317303 let form = [
318304 ("client_id", client),
319305 ("client_secret", secret),
......@@ -370,7 +356,7 @@ async fn exchange(
370356 "GitHub couldn't verify your account. Return to Shale and try again.",
371357 )
372358 })?;
373 return Ok((id.to_string(), login.to_owned(), None, None));
359 return Ok((id.to_string(), login.to_owned(), None));
374360 }
375361 let mut form = form.to_vec();
376362 form.push(("state", "none"));
......@@ -455,13 +441,7 @@ async fn exchange(
455441 && url.password().is_none()
456442 })
457443 .map(String::from);
458 let public_profile = profile["profile"].as_str().and_then(astheno_profile);
459 Ok((
460 string(&profile["sub"]).to_owned(),
461 name,
462 picture,
463 public_profile,
464 ))
444 Ok((string(&profile["sub"]).to_owned(), name, picture))
465445}
466446
467447fn account(
......@@ -470,12 +450,7 @@ fn account(
470450 subject: &str,
471451 name: &str,
472452 picture: Option<&str>,
473 public_profile: Option<&str>,
474453) -> 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 }
479454 let mut db = auth.db.lock().unwrap();
480455 let tx = db.transaction()?;
481456 let existing: Option<String> = tx
......@@ -495,10 +470,7 @@ fn account(
495470 }
496471 tx.execute(
497472 "UPDATE users SET profile=json_patch(profile,?) WHERE id=?",
498 sql![
499 json!({"firstName":name,"attributes":attributes}).to_string(),
500 id
501 ],
473 sql![json!({"firstName":name,"attributes":{"picture":picture.map(|picture| vec![picture])}}).to_string(), id],
502474 )?;
503475 tx.commit()?;
504476 return Ok(id);
......@@ -509,11 +481,10 @@ fn account(
509481 } else {
510482 mcp::hash(subject)[..24].to_owned()
511483 };
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});
513 if picture.is_none() {
514 attributes.as_object_mut().unwrap().remove("picture");
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});
485 if let Some(picture) = picture {
486 profile["attributes"]["picture"] = json!([picture]);
515487 }
516 profile["attributes"] = attributes;
517488 tx.execute(
518489 "INSERT INTO users(id,profile) VALUES (?,?)",
519490 sql![id, profile.to_string()],
......@@ -652,16 +623,9 @@ async fn handle(app: &App, request: Request) -> Result<Response> {
652623 headers
653624 })
654625 .build()?;
655 let (subject, name, picture, public_profile) =
626 let (subject, name, picture) =
656627 exchange(&http, provider, &client, &secret, code, &callback, &flow).await?;
657 let id = account(
658 auth,
659 provider,
660 &subject,
661 &name,
662 picture.as_deref(),
663 public_profile.as_deref(),
664 )?;
628 let id = account(auth, provider, &subject, &name, picture.as_deref())?;
665629 auth.create_session(&id, "dashboard", headers, None)
666630 }
667631 .await;
......@@ -826,22 +790,6 @@ mod tests {
826790 }
827791 }
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
845793 #[test]
846794 fn identities_never_link_by_name_or_email_and_cannot_gain_credentials_or_groups() {
847795 let path = std::env::temp_dir().join(format!("guest-test-{}", uuid::Uuid::new_v4()));
......@@ -858,15 +806,14 @@ mod tests {
858806 db.execute("INSERT INTO roles VALUES ('admin','infra-admin') ON CONFLICT(name) DO UPDATE SET id=excluded.id", [])
859807 .unwrap();
860808 }
861 let first = account(&auth, "github", "123", "clover", None, None).unwrap();
862 let repeat = account(&auth, "github", "123", "renamed", None, None).unwrap();
809 let first = account(&auth, "github", "123", "clover", None).unwrap();
810 let repeat = account(&auth, "github", "123", "renamed", None).unwrap();
863811 let other = account(
864812 &auth,
865813 "astheno",
866814 "123",
867815 "clover",
868816 Some("https://identity.astheno.software/avatar/123"),
869 Some("https://identity.astheno.software/user/public-id"),
870817 )
871818 .unwrap();
872819 assert_eq!(first, repeat);
......@@ -876,11 +823,7 @@ mod tests {
876823 auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]["picture"][0],
877824 "https://identity.astheno.software/avatar/123"
878825 );
879 account(&auth, "astheno", "123", "clover", None, 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 );
826 account(&auth, "astheno", "123", "clover", None).unwrap();
884827 assert!(
885828 auth::user(&auth.db.lock().unwrap(), &other).unwrap()["attributes"]
886829 .get("picture")
......@@ -911,7 +854,7 @@ mod tests {
911854 )
912855 .unwrap();
913856 }
914 assert!(account(&auth, "github", "123", "clover", None, None).is_err());
857 assert!(account(&auth, "github", "123", "clover", None).is_err());
915858 assert!(
916859 auth.create_session(&other, "file", &HeaderMap::new(), None)
917860 .is_err()
dashboard/src/shale.rs+2-2
......@@ -326,7 +326,7 @@ fn issue(html: &str, repository: &str, id: Option<u64>) -> Result<Value> {
326326 )
327327 })?;
328328 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())
330330 .next()
331331 .map(text);
332332 let time = comment
......@@ -937,7 +937,7 @@ mod tests {
937937 }
938938 #[test]
939939 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>";
941941 let parsed = issue(page, "owned", Some(3)).unwrap();
942942 assert_eq!(parsed["comments"][0]["author"], "paperclover");
943943 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};
44
55struct Profile {
66 name: String,
7 url: String,
7 url: Option<String>,
88 icon: &'static str,
99 picture: Option<String>,
1010}
1111
1212fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {
1313 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'")?;
1515 let rows = query.query_map([], |row| {
1616 Ok((
1717 row.get::<_, String>(0)?,
......@@ -19,24 +19,20 @@ fn profiles(auth: &auth::Store) -> Result<HashMap<String, Profile>> {
1919 row.get::<_, String>(2)?,
2020 row.get::<_, String>(3)?,
2121 row.get::<_, Option<String>>(4)?,
22 row.get::<_, Option<String>>(5)?,
2322 ))
2423 })?;
2524 let mut profiles = HashMap::new();
2625 for row in rows {
27 let (username, name, provider, subject, picture, public_profile) = row?;
26 let (username, name, provider, subject, picture) = row?;
2827 if name.is_empty() { continue; }
2928 let (url, icon) = match provider.as_str() {
3029 "github" => {
3130 if matches!(name.as_str(), "." | "..") { continue; }
3231 let mut url = url::Url::parse("https://github.com/")?;
3332 url.path_segments_mut().unwrap().pop_if_empty().push(&name);
34 (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"))
33 (Some(String::from(url)), include_str!("../web/sso/github.svg"))
3934 }
35 "astheno" => (None, include_str!("astheno.svg")),
4036 _ => continue,
4137 };
4238 profiles.insert(
......@@ -79,19 +75,28 @@ fn rewrite(html: &str, origin: &url::Url, profiles: &HashMap<String, Profile>) -
7975 let Some(profile) = profile else { return Ok(()); };
8076 let closing = active.clone();
8177 element.on_end_tag(lol_html::end_tag!(move |_| { closing.set(None); Ok(()) }))?;
82 element.set_attribute("href", &profile.url)?;
83 element.set_attribute("target", "_blank")?;
84 element.set_attribute("rel", "noreferrer")?;
78 if let Some(url) = &profile.url {
79 element.set_attribute("href", url)?;
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 }
8585 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}"))?;
8788 if let Some(picture) = &profile.picture {
8889 let picture = picture.replace('&', "&amp;").replace('"', "&quot;").replace('<', "&lt;");
8990 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);
9091 }
9192 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 };
9394 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 }
9498 element.append(&profile.name, ContentType::Text);
99 if profile.url.is_some() { element.append("</span>", ContentType::Html); }
95100 Ok(())
96101 }))
97102 .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> {
170175 let origin = url::Url::parse(&format!("https://{host}{uri}"))?;
171176 let profiles = profiles(&app.auth)?;
172177 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())
174179 {
175180 return Ok((
176181 StatusCode::FOUND,
177182 [
178 ("location", profile.url.as_str()),
183 ("location", url),
179184 ("cache-control", "no-store"),
180185 ("referrer-policy", "no-referrer"),
181186 ],
......@@ -275,7 +280,7 @@ mod tests {
275280 "guest-github-123".into(),
276281 Profile {
277282 name: "<&\"clover".into(),
278 url: "https://github.com/clover".into(),
283 url: Some("https://github.com/clover".into()),
279284 icon: include_str!("../web/sso/github.svg"),
280285 picture: Some("https://avatars.githubusercontent.com/u/123?s=64".into()),
281286 },
......@@ -284,7 +289,7 @@ mod tests {
284289 "guest-astheno-abc".into(),
285290 Profile {
286291 name: "Astheno user".into(),
287 url: "https://identity.astheno.software/user/123".into(),
292 url: None,
288293 icon: include_str!("astheno.svg"),
289294 picture: None,
290295 },
......@@ -293,10 +298,16 @@ mod tests {
293298 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>"#;
294299 let rewritten = rewrite(html, &origin, &profiles).unwrap();
295300 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");
297310 for link in &links[..2] {
298 assert_eq!(link.attr("target"), Some("_blank"));
299 assert_eq!(link.attr("rel"), Some("noreferrer"));
300311 assert_eq!(
301312 link.select(&Selector::parse("svg[aria-hidden=true]").unwrap())
302313 .count(),
......@@ -322,10 +333,7 @@ mod tests {
322333 );
323334 assert_eq!(links[0].text().collect::<String>().trim(), "<&\"clover");
324335 assert_eq!(links[0].attr("class"), Some("usa-nav-link"));
325 assert_eq!(
326 links[1].attr("href"),
327 Some("https://identity.astheno.software/user/123")
328 );
336 assert_eq!(links[1].attr("href"), None);
329337 for link in &links[2..] {
330338 assert_eq!(link.attr("target"), None);
331339 }
tools/dashboard-shale-page-test.py+22-16
......@@ -57,6 +57,9 @@ def main():
5757 return self.respond(blob, 'application/octet-stream')
5858 if self.path == '/large':
5959 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=''))])
6063 if self.path == '/redirect':
6164 return self.respond(b'', 'text/plain', 302, [('Location', '/snowbound/issues/26'), ('Set-Cookie', 'SessionID=new; Path=/; HttpOnly'), ('Set-Cookie', 'other=kept; Path=/')])
6265 if self.path.startswith('/-/') and args.page:
......@@ -122,11 +125,9 @@ def main():
122125 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')]:
123126 username = f'guest-{provider}-{suffix}'
124127 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']}
127128 db.execute('INSERT INTO users(id,profile) VALUES (?,?)', [username, json.dumps(profile)])
128129 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))
130131 router.ROUTE_DIR = str(root / 'routes')
131132 Path(router.ROUTE_DIR).mkdir()
132133 (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():
153154
154155 preview_port = port()
155156 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 for service in ['shale', 'shale-preview-12345678']:
158 config += f' handle_path /services/{service}/* {{\n reverse_proxy 127.0.0.1:{fixture.server_port}\n }}\n'
159 config += ' }\n handle {\n respond 403\n }\n}\n'
157 internal_host = f'dashboard.internal.studio.test:{internal_port}'
158 start = rendered.index(internal_host + ' {')
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)
160160 config_path = root / 'Caddyfile'
161161 config_path.write_text(config)
162162 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():
186186 time.sleep(.05)
187187 assert status == 200, (status, body, (root / 'dashboard.log').read_text(), (root / 'caddy.log').read_text())
188188 links = Links(body.decode()).links
189 for url in ['https://github.com/paperclover', 'https://identity.astheno.software/user/00653DG7HZ7MCGTW2BPG7RMGQB']:
190 link = next(link for link in links if link['href'] == url)
191 assert link['target'] == '_blank' and link['rel'] == 'noreferrer', link
192 assert any(link['href'] == '/~guest-github-unknown' for link in links)
193 assert any(link['href'] == '/~guest-github-24465214/repo' for link in links)
189 link = next(link for link in links if link.get('href') == 'https://github.com/paperclover')
190 assert link['target'] == '_blank' and link['rel'] == 'noreferrer', link
191 assert any(link.get('href') == '/~guest-github-unknown' for link in links)
192 assert any(link.get('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
194195 assert b'https://avatars.githubusercontent.com/u/24465214?s=64' in body
195196 assert b'rewrite-test' in body
196197 assert headers['Cache-Control'] == 'no-store' and headers.get('ETag') is None and headers.get('Last-Modified') is None
197198 assert observations[-1][1] == '/snowbound/issues/26?query=kept', observations[-1]
199 assert observations[-1][2]['host'] == 'shale.studio.test', observations[-1]
198200 assert observations[-1][2]['cookie'] == 'SessionID=preserved', observations[-1]
199201 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')]:
201 status, headers, body = request(path)
202 assert status == 302 and headers['Location'] == target and headers['Referrer-Policy'] == 'no-referrer', (path, status, dict(headers), observations[-1][:2])
202 status, headers, body = request('/~guest-github-24465214')
203 assert status == 302 and headers['Location'] == 'https://github.com/paperclover' and headers['Referrer-Policy'] == 'no-referrer'
204 assert request('/~guest-astheno-abc/')[0] == 200
205 assert observations[-1][1] == '/~guest-astheno-abc/'
203206 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']
204209 status, headers, body = request('/redirect')
205210 assert status == 302 and headers['Location'] == '/snowbound/issues/26' and len(headers.get_all('Set-Cookie')) == 2
206211 with opener.open(f'http://127.0.0.1:{preview_port}/snowbound/issues/26') as response:
207212 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)
209215 assert b'/~guest-github-24465214' in request('/repo/info/refs')[2]
210216 assert request('/binary')[2] == blob
211217 assert len(request('/large')[2]) == 9 * 1024 * 1024
tools/router.py+6-5
......@@ -24,13 +24,13 @@ def nomad(path, token):
2424 return json.load(response)
2525
2626
27def proxy(upstreams, indent, uncompressed=False, upstream_host=False):
27def proxy(upstreams, indent, uncompressed=False, host=None):
2828 lines = [f"{indent}reverse_proxy {upstreams} {{", f"{indent} lb_try_duration 5s",
2929 f"{indent} fail_duration 30s"]
3030 if uncompressed:
3131 lines.append(f"{indent} header_up Accept-Encoding identity")
32 if upstream_host:
33 lines.append(f"{indent} header_up Host {{upstream_hostport}}")
32 if host:
33 lines.append(f"{indent} header_up Host {host}")
3434 return [*lines, f"{indent}}}"]
3535
3636
......@@ -357,10 +357,11 @@ def render(token):
357357 f" @dashboard header Studio-Proxy-Token {dashboard_proof}",
358358 " handle @dashboard {", " request_header -Studio-Proxy-Token",
359359 " 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}"), " }"]
361361 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}"
362363 lines += [f" handle_path /services/{service}/* {{",
363 *proxy(" ".join(sorted(upstreams)), " ", upstream_host=True), " }"]
364 *proxy(" ".join(sorted(upstreams)), " ", host=host), " }"]
364365 lines += [" handle {", " respond 404", " }", " }", " handle {", " respond 403", " }", "}"]
365366 return "\n".join(lines) + "\n"
366367