diff --git a/agents.md b/agents.md index 3ac972fa3eed2c9f3dd44908db22c2875d376100..5ee37ec83766aed6f2e47bfb84c50145a616823a 100644 --- a/agents.md +++ b/agents.md @@ -52,7 +52,7 @@ Strict **CSP** that blocks `eval` would break the current app. Other projects ma - **`ResolveExternal`:** GitHub resolver buttons are browser actions through **`POST /ui`**. Success responses morph **`#external-resolver-status`** then redirect to the sanitized shareable **`GET`** page so imported children render through the normal page path; errors morph the same status region. Resolver results are durable system ingests, while cooldown state is RAM-only. Implementation lives under **`server/src/resolvers/`** (GitHub resolver + import card JSON); ontology item pages and the **`GET /vote/compare`** left/right columns use **`render_item_body_in_scope`** in **`server/src/html/mod.rs`**, which calls **`server/src/resolvers/mod.rs::try_render_resolver_item_body`** before falling back to the usual **`
`** linkified view.
- **Import thread:** one forum thread per GitHub repo (`import:https:::github.com:{owner}:{repo}`), shared by repo root / issues / pulls / commits / releases imports.
- **Issues:** each open issue is its own **`system:github-resolver`** ingest in that repo import thread (so a later redact removes that issue from the garden). Refresh pages **all** open issues from the GitHub API (up to the page safety cap), keeps still-open single-issue posts, and **`SystemRedact`s** posts for closed/missing issues (and multi-issue bulk posts, which are re-imported as singles). The `slug-github-card` fence payload is **base64(JSON)** so markdown fences in issue bodies cannot terminate the DSL toggle-fence; decode yields the real excerpt string for rich/markdown rendering.
-
+ - **External URL wire form:** Canonical garden paths are **`/-/https://host/…`**. Legacy **`/-/host/…`** and collapsed **`/-/https:/host/…`** permanently redirect to the canonical form. Action `next` fields always use the canonical path.
- **Garden pin / compare voting:** Cookie **`slug_garden_pin`** via **`set_garden_pin`**. Pairwise UI: **`GET /vote/compare?…`** / **`GET /r/:room_key/vote/compare?…`** (fullscreen **`GET`** page: no HUD; other garden pages). HUD (**`#slug-pin-hud`**): only when **`layout`** passes garden metadata on **`body`**; the label is **`POST /ui`** **`set_garden_pin`** **`clear:true`** (**`slug_ui.js`**), not a permalink to the item.
- **Browser auth redirects:** `/login`, `/join/:token`, `/auth/login`, and `/auth/choose-username` may carry **`next`** (or legacy **`redirect`**) as a **safe local path only**. The value is stored on the RAM-only pending session and applied after OAuth / username selection.
diff --git a/server/src/html/breadcrumb_path.rs b/server/src/html/breadcrumb_path.rs
index 7c08edc94669ecd521c784502a193df7f00329d2..1a674ae19f633e42ba3717d51a97d0efe5132cfe 100644
--- a/server/src/html/breadcrumb_path.rs
+++ b/server/src/html/breadcrumb_path.rs
@@ -90,8 +90,9 @@ fn external_ancestor_chain(item: &ItemId) -> Vec {
impl ExternalOntologyPath {
pub(super) fn from_input(path: &str) -> Self {
let p = path.trim_start_matches('/');
+ let p = slug_types::repair_collapsed_http_scheme(p);
let raw = if p.starts_with("http://") || p.starts_with("https://") {
- p.to_string()
+ p
} else if p.is_empty() {
"-/.".to_string()
} else {
@@ -103,6 +104,33 @@ impl ExternalOntologyPath {
Self::from_item(parsed)
}
+ /// When the request path is legacy `host/path` (or a collapsed `https:/…`), return the
+ /// canonical garden local path `/-/https://host/path` for a redirect.
+ pub(super) fn legacy_redirect_target(request_path: &str) -> Option {
+ let p = request_path.trim_start_matches('/');
+ if p.is_empty() {
+ return None;
+ }
+ let repaired = slug_types::repair_collapsed_http_scheme(p);
+ // Already canonical full-URL wire form.
+ if repaired.starts_with("http://") || repaired.starts_with("https://") {
+ // Collapsed scheme was repaired — still redirect so the address bar shows `https://`.
+ if repaired.as_str() != p {
+ let item = ItemId::parse(&repaired)?;
+ let disp = item.display_path();
+ return Some(format!("/{}", disp.trim_start_matches('/')));
+ }
+ return None;
+ }
+ // Legacy host-first: `github.com/org/repo`
+ let item = ItemId::parse(&format!("-/{repaired}"))?;
+ if !(item.as_str().starts_with("https://") || item.as_str().starts_with("http://")) {
+ return None;
+ }
+ let disp = item.display_path();
+ Some(format!("/{}", disp.trim_start_matches('/')))
+ }
+
pub(super) fn from_item(item: ItemId) -> Self {
let chain = external_ancestor_chain(&item);
Self { item, chain }
@@ -158,4 +186,26 @@ mod tests {
"https://example.com/a/b"
);
}
+
+ #[test]
+ fn legacy_host_path_redirects_to_https_wire_form() {
+ assert_eq!(
+ ExternalOntologyPath::legacy_redirect_target("github.com/org/repo").as_deref(),
+ Some("/-/https://github.com/org/repo")
+ );
+ assert_eq!(
+ ExternalOntologyPath::legacy_redirect_target("https://github.com/org/repo").as_deref(),
+ None
+ );
+ }
+
+ #[test]
+ fn collapsed_https_scheme_redirects_to_repaired_canonical() {
+ assert_eq!(
+ ExternalOntologyPath::legacy_redirect_target("https:/github.com/org/repo").as_deref(),
+ Some("/-/https://github.com/org/repo")
+ );
+ let repaired = ExternalOntologyPath::from_input("https:/github.com/org/repo");
+ assert_eq!(repaired.as_str(), "https://github.com/org/repo");
+ }
}
diff --git a/server/src/html/garden/render.rs b/server/src/html/garden/render.rs
index a6b4963c61c67d96e9445ef9fe52e300e77b6d6f..dfda796835a35a0204252ee1445afe884c1b3bc1 100644
--- a/server/src/html/garden/render.rs
+++ b/server/src/html/garden/render.rs
@@ -57,11 +57,14 @@ pub(super) async fn render_scope_view(
let external_href = external_source_href(&model.item);
let external_can_embed = external_frame_allowed(&model.item);
let (garden_room, garden_prefix) = garden_layout_meta(&nav);
- let next_for_pin = uri
- .path_and_query()
- .map(|pq| pq.as_str().to_string())
- .filter(|s| !s.is_empty())
- .unwrap_or_else(|| "/".to_string());
+ // Canonical `/-/https://…` (or room-scoped) path — not the raw request URI (legacy host-first).
+ let next_for_pin = {
+ let base = item_href(&model.item, &nav);
+ match uri.query() {
+ Some(q) if !q.is_empty() && !base.contains('?') => format!("{base}?{q}"),
+ _ => base,
+ }
+ };
let url_key = canonical_view_url(&uri);
let view_count = state.views.get_views(&url_key);
diff --git a/server/src/html/garden/routes.rs b/server/src/html/garden/routes.rs
index 84f995db56e488b5438315b74d2c51e187e91c58..b6fd4f8d573b45493bf6596ee833eec1b3917476 100644
--- a/server/src/html/garden/routes.rs
+++ b/server/src/html/garden/routes.rs
@@ -213,6 +213,13 @@ pub async fn external_ontology_path(
jar: CookieJar,
uri: Uri,
) -> impl IntoResponse {
+ if let Some(canonical) = ExternalOntologyPath::legacy_redirect_target(&path) {
+ let loc = match uri.query() {
+ Some(q) if !q.is_empty() => format!("{canonical}?{q}"),
+ _ => canonical,
+ };
+ return axum::response::Redirect::permanent(&loc).into_response();
+ }
let path = ExternalOntologyPath::from_input(&path);
render_scope_view(
state,
@@ -222,6 +229,7 @@ pub async fn external_ontology_path(
uri,
)
.await
+ .into_response()
}
pub async fn room_garden_index(
@@ -370,8 +378,22 @@ pub async fn room_external_ontology_path(
return room_not_found_page(&jar, &uri).into_response();
}
drop(reduced);
+ if let Some(canonical_tail) = ExternalOntologyPath::legacy_redirect_target(&path) {
+ // canonical_tail is `/-/https://…`; under a room it becomes `/r/{seg}/-/https://…`
+ let tail = canonical_tail
+ .strip_prefix("/-/")
+ .unwrap_or(canonical_tail.trim_start_matches('/'));
+ let base = format!("{}-/{tail}", nav.garden_root_url().trim_end_matches('~'));
+ let loc = match uri.query() {
+ Some(q) if !q.is_empty() => format!("{base}?{q}"),
+ _ => base,
+ };
+ return axum::response::Redirect::permanent(&loc).into_response();
+ }
let path = ExternalOntologyPath::from_input(&path);
- render_scope_view(state, GardenBrowsePath::External(path), nav, jar, uri).await
+ render_scope_view(state, GardenBrowsePath::External(path), nav, jar, uri)
+ .await
+ .into_response()
}
pub async fn room_ontology_path(
diff --git a/test/browser_github_resolver.clj b/test/browser_github_resolver.clj
index 8f43039931d4a4b9cd6272462912dc1393361ccf..d7d5ed5645e8cf461ad37708019b819d1922fa27 100644
--- a/test/browser_github_resolver.clj
+++ b/test/browser_github_resolver.clj
@@ -130,6 +130,14 @@
(page/navigate pg (str base-url "/login"))
(is (wait-for-text pg "body" "@alice" 15000) "alice session after login")
+ (page/navigate pg (str base-url "/-/github.com/octo/hello"))
+ ;; Legacy host-first URL should land on canonical /-/https://…
+ (is (wait-for-text pg "body" "GitHub resolver" 15000)
+ "legacy URL reaches resolver page after redirect")
+ (let [url (page/url pg)]
+ (is (str/includes? url "/-/https://github.com/octo/hello")
+ (str "address bar uses canonical https wire form, got " url)))
+
(page/navigate pg (str base-url "/-/https://github.com/octo"))
(is (wait-for-text pg "#external-resolver-panel" "GitHub resolver" 15000)
"user page shows resolver panel")
diff --git a/types/src/item_wire.rs b/types/src/item_wire.rs
index 73447a6ca6d6c937ceb0c6c7ddf506c21c6ab452..5c823f403320998642725d3c583940c88902091d 100644
--- a/types/src/item_wire.rs
+++ b/types/src/item_wire.rs
@@ -23,6 +23,21 @@ fn finalize_external_identity_url(s: String) -> String {
strip_redundant_root_slash(&normalized).unwrap_or(normalized)
}
+/// Some clients/proxies collapse `https://` → `https:/` inside a path. Repair before parsing.
+pub fn repair_collapsed_http_scheme(s: &str) -> String {
+ if let Some(rest) = s.strip_prefix("https:/") {
+ if !rest.starts_with('/') {
+ return format!("https://{rest}");
+ }
+ }
+ if let Some(rest) = s.strip_prefix("http:/") {
+ if !rest.starts_with('/') {
+ return format!("http://{rest}");
+ }
+ }
+ s.to_string()
+}
+
/// `url::Url` serializes bare hosts with a `/` path; we keep host-only items slash-free for stable
/// keys matching the pre-normalizer spellings.
fn strip_redundant_root_slash(s: &str) -> Option {
@@ -46,15 +61,15 @@ pub fn canonicalize_item(input: &str) -> String {
}
if let Some(rest) = s.strip_prefix("-/") {
- let rest = rest.trim().trim_start_matches('/');
- // New wire form: `/-/https://host/path` (full URL after the dash prefix).
+ let rest = repair_collapsed_http_scheme(rest.trim().trim_start_matches('/'));
+ // Canonical wire form: `/-/https://host/path` (full URL after the dash prefix).
if rest.starts_with("http://") || rest.starts_with("https://") {
- return finalize_external_identity_url(rest.to_string());
+ return finalize_external_identity_url(rest);
}
// Legacy wire form: `/-/host/path` (host-first segments).
let (host, tail) = rest
.split_once('/')
- .map_or((rest, ""), |(h, t)| (h, t));
+ .map_or((rest.as_str(), ""), |(h, t)| (h, t));
let host = host.trim().to_lowercase();
if host.is_empty() {
return String::new();
@@ -83,6 +98,8 @@ pub fn canonicalize_item(input: &str) -> String {
};
}
+ let s = repair_collapsed_http_scheme(s);
+
if let Some(rest) = s.strip_prefix("https://") {
let (host, tail) = rest.split_once('/').map_or((rest, ""), |(h, t)| (h, t));
let host = host.trim().to_lowercase();
@@ -103,7 +120,7 @@ pub fn canonicalize_item(input: &str) -> String {
}
let is_tilde = s.starts_with("~/");
- let rest = s.strip_prefix("~/").or_else(|| s.strip_prefix("/")).unwrap_or(s);
+ let rest = s.strip_prefix("~/").or_else(|| s.strip_prefix("/")).unwrap_or(&s);
let tail = rest
.split('/')
diff --git a/types/src/lib.rs b/types/src/lib.rs
index 717c6b3fa6e11d6ca54f4143a69e4c666d73bd82..8435ef8b19318b0acb9ba99ed10202cc6d80b157 100644
--- a/types/src/lib.rs
+++ b/types/src/lib.rs
@@ -10,7 +10,7 @@ pub mod thread_xml;
pub use item_id::ItemId;
pub use item_wire::{
canonicalize_item, item_parent_path, item_path_segments, normalize_slug_ontology_storage_url,
- SLUG_TILDE_ONTOLOGY_ROOT,
+ repair_collapsed_http_scheme, SLUG_TILDE_ONTOLOGY_ROOT,
};
pub use paths::{
canonicalize_tag, ForumThreadUrl, GardenItemUrl, RelativePath,
diff --git a/types/src/paths.rs b/types/src/paths.rs
index 0b72582de4764e56356867279bf4eb340af2e82a..e80697ed35132685f655827d6f497a446ba4e616 100644
--- a/types/src/paths.rs
+++ b/types/src/paths.rs
@@ -516,6 +516,26 @@ mod tests {
);
}
+ #[test]
+ fn canonicalize_repairs_collapsed_https_scheme_in_dash_path() {
+ assert_eq!(
+ canonicalize_item("-/https:/github.com/org/repo"),
+ "https://github.com/org/repo"
+ );
+ assert_eq!(
+ canonicalize_item("https:/github.com/org/repo"),
+ "https://github.com/org/repo"
+ );
+ }
+
+ #[test]
+ fn legacy_and_https_dash_forms_share_storage_identity() {
+ assert_eq!(
+ canonicalize_item("-/github.com/org/repo"),
+ canonicalize_item("-/https://github.com/org/repo")
+ );
+ }
+
#[test]
fn item_id_parent_external_strips_last_segment() {
let c = ItemId::parse("https://spotify.com/track/1").unwrap();