diff --git a/README.md b/README.md index d253f74e5..ae48a1a99 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,33 @@ ensures the primary seed is deterministic; if you list multiple, users are bucketed across them and may land on a different node. +## Repository aliases + +`radicle-httpd` can map a short, human-readable alias to a repository ID +(RID), so a repository can be referred to by name instead of its full +`rad:` identifier. Aliases are configured on the node with the +`--alias ` flag, which may be repeated: + +```sh +radicle-httpd --alias heartwood rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 +``` + +An alias works anywhere the RID is accepted: `git clone`, the HTTP API, the +raw endpoints, and Explorer URLs — the router resolves it transparently. +With the alias above, these two Explorer URLs point at the same repository: + +``` +https://radicle.network/nodes/seed.example.com/rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5/issues +https://radicle.network/nodes/seed.example.com/heartwood/issues +``` + +and the repository can be cloned over Git by its alias: + +```sh +git clone https://seed.example.com/heartwood.git +``` + + ## Contributing * For detailed contribution guidelines, refer to the [CONTRIBUTING.md][con] diff --git a/crates/radicle-httpd/src/api.rs b/crates/radicle-httpd/src/api.rs index 6e2cf4fd6..385b2b4e6 100644 --- a/crates/radicle-httpd/src/api.rs +++ b/crates/radicle-httpd/src/api.rs @@ -125,6 +125,13 @@ impl Context { }) } + /// Resolve a repo path segment (RID or alias) to a [`RepoId`], mapping an + /// unknown segment to [`Error::NotFound`]. + #[allow(clippy::result_large_err)] + pub(crate) fn resolve_repo(&self, repo: &str) -> Result { + crate::resolve_rid(repo, &self.repo_aliases).ok_or(error::Error::NotFound) + } + /// Return the configured alias for a repo, if any. When several aliases /// point at the same repo, the lexicographically smallest is returned so /// the result is stable across requests. @@ -136,6 +143,11 @@ impl Context { .min() } + #[cfg(test)] + pub fn set_repo_aliases(&mut self, aliases: HashMap) { + self.repo_aliases = Arc::new(aliases); + } + /// The search backend client, if one is configured and reachable at /// startup. `None` means listing and search use the storage walk. pub fn search(&self) -> Option<&SearchClient> { diff --git a/crates/radicle-httpd/src/api/v1/node.rs b/crates/radicle-httpd/src/api/v1/node.rs index 7489d7f6c..8f5c46a9f 100644 --- a/crates/radicle-httpd/src/api/v1/node.rs +++ b/crates/radicle-httpd/src/api/v1/node.rs @@ -7,7 +7,7 @@ use serde_json::json; use tokio::time::{timeout, Duration}; use radicle::crypto::ssh::fmt; -use radicle::identity::{Did, RepoId}; +use radicle::identity::Did; use radicle::node::address::Store as AddressStore; use radicle::node::routing::Store; use radicle::node::{AliasStore, Config, Handle, NodeId, UserAgent}; @@ -173,8 +173,9 @@ async fn node_policies_repos_handler(State(ctx): State) -> impl IntoRes /// `GET /node/policies/repos/:rid` async fn node_policies_repo_handler( State(ctx): State, - Path(rid): Path, + Path(rid): Path, ) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; let policies = ctx.profile.policies()?; let policy = policies.seed_policy(&rid)?; diff --git a/crates/radicle-httpd/src/api/v1/repos.rs b/crates/radicle-httpd/src/api/v1/repos.rs index 2284a151d..d0f9b5ca1 100644 --- a/crates/radicle-httpd/src/api/v1/repos.rs +++ b/crates/radicle-httpd/src/api/v1/repos.rs @@ -15,7 +15,6 @@ use serde_json::json; use radicle::cob::{issue::cache::Issues as _, patch::cache::Patches as _}; use radicle::git::fmt::{Qualified, RefString}; -use radicle::identity::RepoId; use radicle::node::{Alias, AliasStore, NodeId}; use radicle::storage::{ReadRepository, RemoteRepository}; @@ -405,7 +404,8 @@ async fn repo_search_handler( /// Get repo metadata. /// `GET /repos/:rid` -async fn repo_handler(State(ctx): State, Path(rid): Path) -> impl IntoResponse { +async fn repo_handler(State(ctx): State, Path(rid): Path) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; let info = api::blocking(move || { let (repo, doc) = ctx.repo(rid)?; ctx.repo_info(&repo, doc) @@ -429,9 +429,10 @@ pub struct CommitsQueryString { /// `GET /repos/:rid/commits?parent=` async fn history_handler( State(ctx): State, - Path(rid): Path, + Path(rid): Path, Query(qs): Query, ) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; let CommitsQueryString { since, until, @@ -494,8 +495,9 @@ async fn history_handler( /// `GET /repos/:rid/commits/:sha` async fn commit_handler( State(ctx): State, - Path((rid, sha)): Path<(RepoId, Oid)>, + Path((rid, sha)): Path<(String, Oid)>, ) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; let response = api::blocking(move || { let (repo, _) = ctx.repo(rid)?; let repo = Repository::open(repo.path())?; @@ -563,8 +565,9 @@ async fn commit_handler( /// `GET /repos/:rid/diff/:base/:oid` async fn diff_handler( State(ctx): State, - Path((rid, base, oid)): Path<(RepoId, Oid, Oid)>, + Path((rid, base, oid)): Path<(String, Oid, Oid)>, ) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; let response = api::blocking(move || { let (repo, _) = ctx.repo(rid)?; let repo = Repository::open(repo.path())?; @@ -633,8 +636,9 @@ async fn diff_handler( /// `GET /repos/:rid/diff/:base/:oid/stats` async fn diff_stats_handler( State(ctx): State, - Path((rid, base, oid)): Path<(RepoId, Oid, Oid)>, + Path((rid, base, oid)): Path<(String, Oid, Oid)>, ) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; let stats = api::blocking(move || { let (repo, _) = ctx.repo(rid)?; diff_stats(&repo, base, oid) @@ -725,8 +729,9 @@ fn numstat(repo_dir: &std::path::Path, base: Oid, head: Oid) -> Option<(usize, u /// `GET /repos/:rid/activity` async fn activity_handler( State(ctx): State, - Path(rid): Path, + Path(rid): Path, ) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; let timestamps = api::blocking(move || { let (repo, _) = ctx.repo(rid)?; let current_date = chrono::Utc::now().timestamp(); @@ -758,7 +763,7 @@ async fn activity_handler( /// `GET /repos/:rid/tree/:sha/` async fn tree_handler_root( State(ctx): State, - Path((rid, sha)): Path<(RepoId, Oid)>, + Path((rid, sha)): Path<(String, Oid)>, ) -> impl IntoResponse { tree_handler(State(ctx), Path((rid, sha, String::new()))).await } @@ -767,8 +772,9 @@ async fn tree_handler_root( /// `GET /repos/:rid/tree/:sha/*path` async fn tree_handler( State(ctx): State, - Path((rid, sha, path)): Path<(RepoId, Oid, String)>, + Path((rid, sha, path)): Path<(String, Oid, String)>, ) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; if let Some(ref cache) = ctx.cache { let hit = { let mut cache = cache.tree.lock().await; @@ -806,8 +812,9 @@ async fn tree_handler( /// `GET /repos/:rid/stats/tree/:sha` async fn stats_tree_handler( State(ctx): State, - Path((rid, sha)): Path<(RepoId, Oid)>, + Path((rid, sha)): Path<(String, Oid)>, ) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; let stats = api::blocking(move || { let (repo, _) = ctx.repo(rid)?; let repo = Repository::open(repo.path())?; @@ -822,8 +829,9 @@ async fn stats_tree_handler( /// `GET /repos/:rid/stats/commits/:sha` async fn stats_commits_handler( State(ctx): State, - Path((rid, sha)): Path<(RepoId, Oid)>, + Path((rid, sha)): Path<(String, Oid)>, ) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; let commits = api::blocking(move || { let (repo, _) = ctx.repo(rid)?; commit_count(&repo, sha) @@ -868,7 +876,8 @@ fn commit_count(repo: &radicle::storage::git::Repository, head: Oid) -> Result, Path(rid): Path) -> impl IntoResponse { +async fn remotes_handler(State(ctx): State, Path(rid): Path) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; let remotes = api::blocking(move || { let (repo, doc) = ctx.repo(rid)?; let delegates = doc.delegates(); @@ -890,8 +899,9 @@ async fn remotes_handler(State(ctx): State, Path(rid): Path) -> /// `GET /repos/:rid/remotes/:peer` async fn remote_handler( State(ctx): State, - Path((rid, node_id)): Path<(RepoId, NodeId)>, + Path((rid, node_id)): Path<(String, NodeId)>, ) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; let info = api::blocking(move || { let (repo, doc) = ctx.repo(rid)?; let delegates = doc.delegates(); @@ -1109,8 +1119,9 @@ fn last_path_commit( /// `GET /repos/:rid/blob/:sha/*path` async fn blob_handler( State(ctx): State, - Path((rid, sha, path)): Path<(RepoId, Oid, String)>, + Path((rid, sha, path)): Path<(String, Oid, String)>, ) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; let outcome = api::blocking(move || { let (repo, _) = ctx.repo(rid)?; let surf_repo = Repository::open(repo.path())?; @@ -1148,8 +1159,9 @@ async fn blob_handler( /// `GET /repos/:rid/readme/:sha` async fn readme_handler( State(ctx): State, - Path((rid, sha)): Path<(RepoId, Oid)>, + Path((rid, sha)): Path<(String, Oid)>, ) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; let outcome = api::blocking(move || { let (repo, _) = ctx.repo(rid)?; let surf_repo = Repository::open(repo.path())?; @@ -1207,9 +1219,10 @@ async fn readme_handler( /// `GET /repos/:rid/issues` async fn issues_handler( State(ctx): State, - Path(rid): Path, + Path(rid): Path, Query(qs): Query>, ) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; let issues = api::blocking(move || { let (repo, _) = ctx.repo(rid)?; let CobsQuery { @@ -1249,8 +1262,9 @@ async fn issues_handler( /// `GET /repos/:rid/issues/:id` async fn issue_handler( State(ctx): State, - Path((rid, issue_id)): Path<(RepoId, Oid)>, + Path((rid, issue_id)): Path<(String, Oid)>, ) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; let value = api::blocking(move || { let (repo, _) = ctx.repo(rid)?; let issue = ctx @@ -1271,9 +1285,10 @@ async fn issue_handler( /// `GET /repos/:rid/patches` async fn patches_handler( State(ctx): State, - Path(rid): Path, + Path(rid): Path, Query(qs): Query>, ) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; let patches = api::blocking(move || { let (repo, _) = ctx.repo(rid)?; let CobsQuery { @@ -1312,8 +1327,9 @@ async fn patches_handler( /// `GET /repos/:rid/patches/:id` async fn patch_handler( State(ctx): State, - Path((rid, patch_id)): Path<(RepoId, Oid)>, + Path((rid, patch_id)): Path<(String, Oid)>, ) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; let value = api::blocking(move || { let (repo, _) = ctx.repo(rid)?; let patches = ctx.profile.patches(&repo)?; @@ -1699,6 +1715,35 @@ mod routes { ); } + #[tokio::test] + async fn test_repo_alias_resolution() { + let tmp = tempfile::tempdir().unwrap(); + let mut ctx = seed(tmp.path()); + let rid = RID.parse().unwrap(); + ctx.set_repo_aliases(std::collections::HashMap::from_iter([( + "hello".to_string(), + rid, + )])); + let app = super::router(ctx); + + // The alias resolves to the same repo as the RID, and the repo info + // advertises the configured alias. + let response = get(&app, "/repos/hello").await; + assert_eq!(response.status(), StatusCode::OK); + let body = response.json().await; + assert_eq!(body["rid"], json!(RID)); + assert_eq!(body["alias"], json!("hello")); + + // The RID itself keeps working and still advertises the alias. + let response = get(&app, format!("/repos/{RID}")).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.json().await["alias"], json!("hello")); + + // An unknown alias is not found. + let response = get(&app, "/repos/nope").await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + #[tokio::test] async fn test_search_repos() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/radicle-httpd/src/api/v1/repos/job.rs b/crates/radicle-httpd/src/api/v1/repos/job.rs index 84ffce2a6..785f61f0e 100644 --- a/crates/radicle-httpd/src/api/v1/repos/job.rs +++ b/crates/radicle-httpd/src/api/v1/repos/job.rs @@ -125,8 +125,9 @@ impl FindJobs for JobsSource<'_> { /// `GET /repos/:rid/jobs/:sha` pub async fn handler( State(ctx): State, - Path((rid, sha)): Path<(RepoId, Oid)>, + Path((rid, sha)): Path<(String, Oid)>, ) -> impl IntoResponse { + let rid = ctx.resolve_repo(&rid)?; let jobs = crate::api::blocking(move || { let aliases = ctx.profile.aliases(); JobsSource { ctx: &ctx, rid }.jobs_by_commit(sha, &aliases) diff --git a/crates/radicle-httpd/src/git.rs b/crates/radicle-httpd/src/git.rs index 435565035..0dc5deb7d 100644 --- a/crates/radicle-httpd/src/git.rs +++ b/crates/radicle-httpd/src/git.rs @@ -25,7 +25,7 @@ use tower_http::decompression::RequestDecompressionLayer; use crate::error::GitError as Error; -pub fn router(profile: Arc, aliases: HashMap) -> Router { +pub fn router(profile: Arc, aliases: Arc>) -> Router { Router::new() .route( "/{rid}/{*path}", @@ -35,7 +35,7 @@ pub fn router(profile: Arc, aliases: HashMap) -> Router } async fn git_handler( - State((profile, aliases)): State<(Arc, HashMap)>, + State((profile, aliases)): State<(Arc, Arc>)>, AxumPath((repository, path)): AxumPath<(String, String)>, method: Method, headers: HeaderMap, @@ -45,14 +45,8 @@ async fn git_handler( ) -> impl IntoResponse { let query = query.0.unwrap_or_default(); let name = repository.strip_suffix(".git").unwrap_or(&repository); - let rid: RepoId = match name.parse() { - Ok(rid) => rid, - Err(_) => { - let Some(rid) = aliases.get(name) else { - return Err(Error::NotFound); - }; - *rid - } + let Some(rid) = crate::resolve_rid(name, &aliases) else { + return Err(Error::NotFound); }; let (nid, path): (Option, &str) = { @@ -196,6 +190,7 @@ mod routes { use std::collections::HashMap; use std::net::SocketAddr; use std::str::FromStr; + use std::sync::Arc; use axum::extract::connect_info::MockConnectInfo; use axum::http::StatusCode; @@ -208,9 +203,9 @@ mod routes { async fn test_info_request() { let tmp = tempfile::tempdir().unwrap(); let ctx = test::seed(tmp.path()); - let app = super::router(ctx.profile().to_owned(), HashMap::new()).layer(MockConnectInfo( - DualAddr::Tcp(SocketAddr::from(([0, 0, 0, 0], 8080))), - )); + let app = super::router(ctx.profile().to_owned(), Arc::new(HashMap::new())).layer( + MockConnectInfo(DualAddr::Tcp(SocketAddr::from(([0, 0, 0, 0], 8080)))), + ); let response = get(&app, format!("/{RID}.git/info/refs")).await; @@ -223,7 +218,10 @@ mod routes { let ctx = test::seed(tmp.path()); let app = super::router( ctx.profile().to_owned(), - HashMap::from_iter([(String::from("heartwood"), RepoId::from_str(RID).unwrap())]), + Arc::new(HashMap::from_iter([( + String::from("heartwood"), + RepoId::from_str(RID).unwrap(), + )])), ) .layer(MockConnectInfo(DualAddr::Tcp(SocketAddr::from(( [0, 0, 0, 0], diff --git a/crates/radicle-httpd/src/lib.rs b/crates/radicle-httpd/src/lib.rs index 0f7d67544..ad389df30 100644 --- a/crates/radicle-httpd/src/lib.rs +++ b/crates/radicle-httpd/src/lib.rs @@ -46,6 +46,15 @@ mod tracing_extra; /// Default cache HTTP size. pub const DEFAULT_CACHE_SIZE: NonZeroUsize = NonZeroUsize::new(100).unwrap(); +/// Resolve a repo path segment to a [`RepoId`]. The segment may be either a +/// canonical RID or one of the aliases configured via `--alias`. Returns +/// `None` when it is neither, letting each caller map that to its own error. +pub(crate) fn resolve_rid(name: &str, aliases: &HashMap) -> Option { + name.parse::() + .ok() + .or_else(|| aliases.get(name).copied()) +} + #[derive(Debug, Clone)] pub struct Options { pub aliases: HashMap, @@ -170,8 +179,9 @@ pub async fn run(options: Options) -> anyhow::Result<()> { /// Create a router consisting of other sub-routers. fn router(options: Options, profile: Arc, ctx: api::Context) -> anyhow::Result { let api_router = api::router(ctx); - let git_router = git::router(profile.clone(), options.aliases); - let raw_router = raw::router(profile); + let aliases = Arc::new(options.aliases); + let git_router = git::router(profile.clone(), aliases.clone()); + let raw_router = raw::router(profile, aliases); let app = Router::new() .route("/", get(root_index_handler)) diff --git a/crates/radicle-httpd/src/main.rs b/crates/radicle-httpd/src/main.rs index b06a3382a..d21ce9d3b 100644 --- a/crates/radicle-httpd/src/main.rs +++ b/crates/radicle-httpd/src/main.rs @@ -24,8 +24,9 @@ Options --listen
Address to listen on: TCP address (e.g., 127.0.0.1:8080) or Unix socket path (e.g., /tmp/radicle.sock) (default: 0.0.0.0:8080) - --alias, -a Provide alias and RID pairs to shorten git clone commands for repositories, + --alias, -a Provide alias and RID pairs to use in place of the RID for a repository, e.g. heartwood and rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 to produce https://seed.radicle.dev/heartwood.git + Aliases work anywhere the RID is accepted: git clone, the JSON API and raw endpoints. --cache Max amount of items in cache for /tree endpoints (default: 100) --version, -v Print program version --help, -h Print help diff --git a/crates/radicle-httpd/src/raw.rs b/crates/radicle-httpd/src/raw.rs index e403442e3..71d3b43da 100644 --- a/crates/radicle-httpd/src/raw.rs +++ b/crates/radicle-httpd/src/raw.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; @@ -104,22 +105,29 @@ enum Committish<'a> { Ref(&'a str), } -pub fn router(profile: Arc) -> Router { +pub fn router(profile: Arc, aliases: Arc>) -> Router { Router::new() .route("/{rid}/{sha}", get(commit_handler)) .route("/{rid}/{sha}/{*path}", get(file_by_commit_handler)) .route("/{rid}/head/{*path}", get(file_by_canonical_head_handler)) .route("/{rid}/archive/{*refname}", get(archive_by_refname_handler)) .route("/{rid}/blobs/{oid}", get(file_by_oid_handler)) - .with_state(profile) + .with_state((profile, aliases)) +} + +/// Resolve a repo path segment to a [`RepoId`], mapping an unknown segment to +/// [`Error::NotFound`]. +fn resolve_rid(repo: &str, aliases: &HashMap) -> Result { + crate::resolve_rid(repo, aliases).ok_or(Error::NotFound) } async fn commit_handler( method: Method, - Path((rid, sha)): Path<(RepoId, String)>, + Path((rid, sha)): Path<(String, String)>, Query(q): Query, - State(profile): State>, + State((profile, aliases)): State<(Arc, Arc>)>, ) -> Result, Error> { + let rid = resolve_rid(&rid, &aliases)?; let storage = &profile.storage; let repo = storage.repository(rid)?; @@ -140,9 +148,10 @@ async fn commit_handler( } async fn file_by_commit_handler( - Path((rid, sha, path)): Path<(RepoId, Oid, String)>, - State(profile): State>, + Path((rid, sha, path)): Path<(String, Oid, String)>, + State((profile, aliases)): State<(Arc, Arc>)>, ) -> impl IntoResponse { + let rid = resolve_rid(&rid, &aliases)?; let storage = &profile.storage; let repo = storage.repository(rid)?; @@ -162,10 +171,11 @@ async fn file_by_commit_handler( async fn archive_by_refname_handler( method: Method, - Path((rid, refname)): Path<(RepoId, String)>, + Path((rid, refname)): Path<(String, String)>, Query(q): Query, - State(profile): State>, + State((profile, aliases)): State<(Arc, Arc>)>, ) -> Result, Error> { + let rid = resolve_rid(&rid, &aliases)?; let (refname, format) = ArchiveFormat::detect(&refname); archive_by_committish( method, @@ -279,9 +289,10 @@ async fn archive_by_committish( } async fn file_by_canonical_head_handler( - Path((rid, path)): Path<(RepoId, String)>, - State(profile): State>, + Path((rid, path)): Path<(String, String)>, + State((profile, aliases)): State<(Arc, Arc>)>, ) -> impl IntoResponse { + let rid = resolve_rid(&rid, &aliases)?; let storage = &profile.storage; let repo = storage.repository(rid)?; @@ -320,10 +331,11 @@ fn blob_response( } async fn file_by_oid_handler( - Path((rid, oid)): Path<(RepoId, Oid)>, - State(profile): State>, + Path((rid, oid)): Path<(String, Oid)>, + State((profile, aliases)): State<(Arc, Arc>)>, Query(_qs): Query, ) -> impl IntoResponse { + let rid = resolve_rid(&rid, &aliases)?; let storage = &profile.storage; let repo = storage.repository(rid)?; @@ -351,6 +363,9 @@ async fn file_by_oid_handler( #[cfg(test)] mod routes { + use std::collections::HashMap; + use std::sync::Arc; + use axum::http::StatusCode; use crate::test::{self, get, RID, RID_PRIVATE}; @@ -360,7 +375,7 @@ mod routes { async fn test_file_handler() { let tmp = tempfile::tempdir().unwrap(); let ctx = test::seed(tmp.path()); - let app = super::router(ctx.profile().to_owned()); + let app = super::router(ctx.profile().to_owned(), Arc::new(HashMap::new())); let response = get(&app, format!("/{RID}/head/dir1/README")).await; @@ -376,4 +391,24 @@ mod routes { let response = get(&app, format!("/{RID_PRIVATE}/head/README")).await; assert_eq!(response.status(), StatusCode::NOT_FOUND); } + + #[tokio::test] + async fn test_alias_resolution() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test::seed(tmp.path()); + let aliases = Arc::new(HashMap::from_iter([( + "hello".to_string(), + RID.parse().unwrap(), + )])); + let app = super::router(ctx.profile().to_owned(), aliases); + + // The alias serves the same content as the RID. + let response = get(&app, "/hello/head/dir1/README").await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.body().await, "Hello World from dir1!\n"); + + // An unknown alias is not found. + let response = get(&app, "/nope/head/dir1/README").await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } } diff --git a/src/components/RepoCard.svelte b/src/components/RepoCard.svelte index 658020e85..9086ea226 100644 --- a/src/components/RepoCard.svelte +++ b/src/components/RepoCard.svelte @@ -8,6 +8,7 @@ absoluteTimestamp, formatTimestamp, formatRepositoryId, + repoSegment, twemoji, } from "@app/lib/utils"; @@ -154,7 +155,7 @@
diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 291a8998b..b2a20b0cb 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -76,6 +76,16 @@ export function truncateId(pubkey: string): string { return `${pubkey.substring(0, 6)}…${pubkey.slice(-6)}`; } +// The URL path segment to use for a repo: its configured alias when the node +// advertises one, otherwise the canonical RID. Keeps alias-based URLs sticky +// across in-app navigation, since the backend resolves either form. +export function repoSegment(repo: { + rid: string; + alias?: string | undefined; +}): string { + return repo.alias ?? repo.rid; +} + export function formatCommit(oid: string): string { return oid.substring(0, 7); } diff --git a/src/views/repos/Commit.svelte b/src/views/repos/Commit.svelte index f836b6ad2..2071d41d6 100644 --- a/src/views/repos/Commit.svelte +++ b/src/views/repos/Commit.svelte @@ -23,6 +23,7 @@ export let baseUrl: BaseUrl; export let commit: Commit; export let repo: Repo; + export let repoId: string; export let nodeId: string; export let nodeAvatarUrl: string | undefined; @@ -74,13 +75,13 @@ } - + Commits @@ -112,7 +113,7 @@ @@ -180,7 +181,7 @@
diff --git a/src/views/repos/Header.svelte b/src/views/repos/Header.svelte index 21189d4f1..f935fec0d 100644 --- a/src/views/repos/Header.svelte +++ b/src/views/repos/Header.svelte @@ -18,6 +18,7 @@ export let baseUrl: BaseUrl; export let activeTab: ActiveTab = undefined; export let repo: Repo; + export let repoId: string; let shareIcon: "link" | "checkmark" = "link"; @@ -29,19 +30,19 @@ if (activeTab === "issues") { return routeToPath({ resource: "repo.issues", - repo: repo.rid, + repo: repoId, node: baseUrl, }); } else if (activeTab === "patches") { return routeToPath({ resource: "repo.patches", - repo: repo.rid, + repo: repoId, node: baseUrl, }); } else { return routeToPath({ resource: "repo.source", - repo: repo.rid, + repo: repoId, node: baseUrl, path: "/", }); @@ -103,7 +104,7 @@ @@ -115,7 +116,7 @@ {/if} - + diff --git a/src/views/repos/History.svelte b/src/views/repos/History.svelte index 04cee0e4e..8dd6ae8e2 100644 --- a/src/views/repos/History.svelte +++ b/src/views/repos/History.svelte @@ -33,6 +33,7 @@ export let peer: string | undefined; export let peers: PeerRefs[]; export let repo: Repo; + export let repoId: string; export let revision: string | undefined; export let tree: Tree; export let nodeId: string; @@ -63,7 +64,7 @@ $: baseRoute = { resource: "repo.history", node: baseUrl, - repo: repo.rid, + repo: repoId, } as Extract; $: { allCommitHeaders = commitHeaders; @@ -102,13 +103,13 @@ } - + Commits @@ -122,7 +123,7 @@ alias={repo.alias} name={repo.payloads["xyz.radicle.project"].data.name} /> - +
{group.date}
- + {/each} diff --git a/src/views/repos/Issue.svelte b/src/views/repos/Issue.svelte index 58481e4bd..40a111f7e 100644 --- a/src/views/repos/Issue.svelte +++ b/src/views/repos/Issue.svelte @@ -26,6 +26,7 @@ export let baseUrl: BaseUrl; export let issue: Issue; export let repo: Repo; + export let repoId: string; export let rawPath: (commit?: string) => string; export let nodeId: string; export let nodeAvatarUrl: string | undefined; @@ -128,6 +129,7 @@ {nodeId} {nodeAvatarUrl} {repo} + {repoId} activeTab="issues" stylePaddingBottom="0"> @@ -135,7 +137,7 @@ diff --git a/src/views/repos/Issues.svelte b/src/views/repos/Issues.svelte index dd2783aab..f6d2f78fd 100644 --- a/src/views/repos/Issues.svelte +++ b/src/views/repos/Issues.svelte @@ -19,6 +19,7 @@ export let baseUrl: BaseUrl; export let issues: Issue[]; export let repo: Repo; + export let repoId: string; export let status: IssueState["status"]; export let nodeId: string; export let nodeAvatarUrl: string | undefined; @@ -103,13 +104,13 @@ } - + Issues @@ -119,7 +120,7 @@ @@ -136,7 +137,7 @@ @@ -153,12 +154,7 @@ - + {#if error} diff --git a/src/views/repos/Layout.svelte b/src/views/repos/Layout.svelte index 29bb2fab8..28ccdf2a8 100644 --- a/src/views/repos/Layout.svelte +++ b/src/views/repos/Layout.svelte @@ -14,6 +14,7 @@ export let activeTab: ActiveTab | undefined = undefined; export let baseUrl: BaseUrl; export let repo: Repo; + export let repoId: string; export let stylePaddingBottom: string = "2.5rem"; export let nodeId: string; export let nodeAvatarUrl: string | undefined; @@ -113,7 +114,7 @@
- + @@ -151,7 +152,7 @@ title="Home" route={{ resource: "repo.source", - repo: repo.rid, + repo: repoId, node: baseUrl, path: "/", }}> @@ -168,7 +169,7 @@ title={`${repo.payloads["xyz.radicle.project"].meta.issues.open} Issues`} route={{ resource: "repo.issues", - repo: repo.rid, + repo: repoId, node: baseUrl, }}>
{/if} {#if view.name === "diff"} @@ -454,7 +456,7 @@ style:padding="0 1rem" style:display="flex" class="global-hide-on-small-desktop-up"> - + {/if} {#if view.name === "diff"} @@ -469,7 +471,7 @@ @@ -480,7 +482,7 @@ diff --git a/src/views/repos/Patch/RevisionSelector.svelte b/src/views/repos/Patch/RevisionSelector.svelte index d6a9a0b38..3edeea797 100644 --- a/src/views/repos/Patch/RevisionSelector.svelte +++ b/src/views/repos/Patch/RevisionSelector.svelte @@ -1,6 +1,6 @@ - + Patches @@ -124,7 +125,7 @@ @@ -141,7 +142,7 @@ @@ -158,7 +159,7 @@ @@ -175,7 +176,7 @@ @@ -192,12 +193,7 @@ - + {#if error} diff --git a/src/views/repos/Source.svelte b/src/views/repos/Source.svelte index 6ecaf217d..63dcbbf0e 100644 --- a/src/views/repos/Source.svelte +++ b/src/views/repos/Source.svelte @@ -30,6 +30,7 @@ export let peer: string | undefined; export let peers: PeerRefs[]; export let repo: Repo; + export let repoId: string; export let rawPath: (commit?: string) => string; export let revision: string | undefined; export let seedingPolicy: SeedingPolicy; @@ -62,7 +63,7 @@ $: baseRoute = { resource: "repo.source", node: baseUrl, - repo: repo.rid, + repo: repoId, path: "/", } as Extract; @@ -129,6 +130,7 @@ {nodeId} {nodeAvatarUrl} {repo} + {repoId} activeTab="source" stylePaddingBottom="0"> @@ -145,7 +147,7 @@ alias={repo.alias} name={repo.payloads["xyz.radicle.project"].data.name} /> - +
@@ -180,7 +183,7 @@ {#if mobileFileTree}
diff --git a/src/views/repos/Source/Header.svelte b/src/views/repos/Source/Header.svelte index d4bea54d3..ebd061f2b 100644 --- a/src/views/repos/Source/Header.svelte +++ b/src/views/repos/Source/Header.svelte @@ -25,6 +25,7 @@ export let peer: string | undefined; export let peers: PeerRefs[]; export let repo: Repo; + export let repoId: string; export let baseRoute: Extract< RepoRoute, { resource: "repo.source" } | { resource: "repo.history" } @@ -132,7 +133,7 @@ variant={commitButtonVariant} styleMinWidth="0" hideSummaryOnMobile - repoId={repo.rid} + {repoId} commit={lastCommit} baseUrl={node} /> {#if !onCanonical} @@ -155,7 +156,7 @@ {#if !onCanonical} diff --git a/src/views/repos/Source/RepoNameHeader.svelte b/src/views/repos/Source/RepoNameHeader.svelte index aaa322aa7..8dba634b5 100644 --- a/src/views/repos/Source/RepoNameHeader.svelte +++ b/src/views/repos/Source/RepoNameHeader.svelte @@ -13,6 +13,7 @@ import RepoAvatar from "@app/components/RepoAvatar.svelte"; export let repo: Repo; + export let repoId: string; export let baseUrl: BaseUrl; export let seedingPolicy: SeedingPolicy; @@ -114,7 +115,7 @@ diff --git a/src/views/repos/router.ts b/src/views/repos/router.ts index 971eeb5e1..fcf29aaea 100644 --- a/src/views/repos/router.ts +++ b/src/views/repos/router.ts @@ -152,6 +152,13 @@ interface RepoPatchesRoute { search?: string; } +// `repoId` on each loaded route is the repo path segment the user navigated in +// with: an alias when one was used, otherwise the canonical RID. It carries a +// dual meaning. In-repo links are built from it so the alias/RID form is +// preserved across navigation, and it is also handed to child components as the +// repo identifier for their API calls. The httpd resolves either form, so both +// uses are safe (`repo.rid` remains available on the `Repo` object when the +// canonical id is specifically needed, e.g. for stable cache keys). export type RepoLoadedRoute = | { resource: "repo.source"; @@ -160,6 +167,7 @@ export type RepoLoadedRoute = seedingPolicy: SeedingPolicy; commit: string; repo: Repo; + repoId: string; peers: PeerRefs[]; peer: string | undefined; revision: string | undefined; @@ -178,6 +186,7 @@ export type RepoLoadedRoute = seedingPolicy: SeedingPolicy; commit: string; repo: Repo; + repoId: string; peers: PeerRefs[]; peer: string | undefined; revision: string | undefined; @@ -192,6 +201,7 @@ export type RepoLoadedRoute = params: { baseUrl: BaseUrl; repo: Repo; + repoId: string; commit: Commit; nodeId: string; nodeAvatarUrl: string | undefined; @@ -202,6 +212,7 @@ export type RepoLoadedRoute = params: { baseUrl: BaseUrl; repo: Repo; + repoId: string; rawPath: (commit?: string) => string; issue: Issue; nodeId: string; @@ -213,6 +224,7 @@ export type RepoLoadedRoute = params: { baseUrl: BaseUrl; repo: Repo; + repoId: string; issues: Issue[]; status: IssueState["status"]; nodeId: string; @@ -224,6 +236,7 @@ export type RepoLoadedRoute = params: { baseUrl: BaseUrl; repo: Repo; + repoId: string; patches: Patch[]; status: PatchState["status"]; nodeId: string; @@ -235,6 +248,7 @@ export type RepoLoadedRoute = params: { baseUrl: BaseUrl; repo: Repo; + repoId: string; rawPath: (commit?: string) => string; patch: Patch; stats: Diff["stats"]; @@ -335,6 +349,7 @@ export async function loadRepoRoute( resource: "repo.commit", params: { baseUrl: route.node, + repoId: route.repo, repo, commit, nodeId: node.id, @@ -386,6 +401,7 @@ async function loadPatchesView( resource: "repo.patches", params: { baseUrl: route.node, + repoId: route.repo, patches, status, repo, @@ -415,6 +431,7 @@ async function loadIssuesView( resource: "repo.issues", params: { baseUrl: route.node, + repoId: route.repo, issues, status, repo, @@ -440,7 +457,9 @@ async function loadTreeView( if ( (previousLoaded.resource === "repo.source" || previousLoaded.resource === "repo.history") && - previousLoaded.params.repo.rid === route.repo && + [previousLoaded.params.repo.rid, previousLoaded.params.repo.alias].includes( + route.repo, + ) && previousLoaded.params.peer === route.peer ) { repoPromise = Promise.resolve(previousLoaded.params.repo); @@ -529,6 +548,7 @@ async function loadTreeView( resource: "repo.source", params: { baseUrl: route.node, + repoId: route.repo, seedingPolicy, commit, repo, @@ -606,7 +626,9 @@ async function loadHistoryView( if ( (previousLoaded.resource === "repo.source" || previousLoaded.resource === "repo.history") && - previousLoaded.params.repo.rid === route.repo && + [previousLoaded.params.repo.rid, previousLoaded.params.repo.alias].includes( + route.repo, + ) && previousLoaded.params.peer === route.peer ) { repoPromise = Promise.resolve(previousLoaded.params.repo); @@ -663,7 +685,9 @@ async function loadHistoryView( if ( (previousLoaded.resource === "repo.source" || previousLoaded.resource === "repo.history") && - previousLoaded.params.repo.rid === route.repo && + [previousLoaded.params.repo.rid, previousLoaded.params.repo.alias].includes( + route.repo, + ) && previousLoaded.params.commit === commitId ) { treePromise = Promise.resolve(previousLoaded.params.tree); @@ -684,6 +708,7 @@ async function loadHistoryView( resource: "repo.history", params: { baseUrl: route.node, + repoId: route.repo, seedingPolicy, commit: commitId, repo, @@ -714,6 +739,7 @@ async function loadIssueView(route: RepoIssueRoute): Promise { resource: "repo.issue", params: { baseUrl: route.node, + repoId: route.repo, repo, rawPath, issue, @@ -739,7 +765,9 @@ async function loadPatchView( if ( previousLoaded.resource === "repo.patch" && - previousLoaded.params.repo.rid === route.repo && + [previousLoaded.params.repo.rid, previousLoaded.params.repo.alias].includes( + route.repo, + ) && previousLoaded.params.patch.id === route.patch ) { repoPromise = Promise.resolve(previousLoaded.params.repo); @@ -821,6 +849,7 @@ async function loadPatchView( resource: "repo.patch", params: { baseUrl: route.node, + repoId: route.repo, repo, rawPath, patch, diff --git a/tests/unit/repoRouter.test.ts b/tests/unit/repoRouter.test.ts index dea9cccc0..09fc6bc03 100644 --- a/tests/unit/repoRouter.test.ts +++ b/tests/unit/repoRouter.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "vitest"; -import { testExports } from "@app/views/repos/router"; +import { + repoRouteToPath, + resolveRepoRoute, + testExports, +} from "@app/views/repos/router"; // Defining the window.origin value, since vitest doesn't provide one. window.origin = "http://localhost:3000"; @@ -12,3 +16,25 @@ describe("isOid", () => { expect(testExports.isOid(oid)).toEqual(expected); }); }); + +describe("repo alias in routes", () => { + const node = { hostname: "localhost", port: 8080, scheme: "http" }; + const rid = "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5"; + + test.each([ + { segment: "heartwood", description: "an alias" }, + { segment: rid, description: "a RID" }, + ])( + "preserves $description as the repo segment through the router", + ({ segment }) => { + // The segment (alias or RID) is parsed in as `route.repo` verbatim … + const route = resolveRepoRoute(node, segment, ["issues"], ""); + expect(route).not.toBeNull(); + expect(route?.repo).toEqual(segment); + + // … and rendered back into the path unchanged, so in-repo links keep + // whichever form the user navigated in with. + expect(repoRouteToPath(route!)).toContain(`/${segment}/issues`); + }, + ); +}); diff --git a/tests/unit/utils.test.ts b/tests/unit/utils.test.ts index cc5378f66..424354be0 100644 --- a/tests/unit/utils.test.ts +++ b/tests/unit/utils.test.ts @@ -286,3 +286,18 @@ describe("isLocal", () => { }); }); }); + +describe("repoSegment", () => { + const rid = "rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5"; + + test("prefers the alias when the node advertises one", () => { + expect(utils.repoSegment({ rid, alias: "heartwood" })).toEqual("heartwood"); + }); + + test.each([ + { repo: { rid }, description: "no alias field" }, + { repo: { rid, alias: undefined }, description: "undefined alias" }, + ])("falls back to the RID with $description", ({ repo }) => { + expect(utils.repoSegment(repo)).toEqual(rid); + }); +});