diff --git a/explorer/Cargo.lock b/explorer/Cargo.lock index eb1eabf03..1efce2a76 100644 --- a/explorer/Cargo.lock +++ b/explorer/Cargo.lock @@ -1816,9 +1816,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iroh-base" -version = "1.0.3" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8" +checksum = "6ae9092be76c9f5429776ab3394f5a5a78dc19dc2524267e884a5b9781546c3f" dependencies = [ "curve25519-dalek 5.0.0", "data-encoding", @@ -2610,9 +2610,9 @@ dependencies = [ [[package]] name = "radicle-artifact" -version = "0.18.0" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8166746c3d6bf1618d97aebb48d8b3239c31527fc96fc235309f7c47543ba23" +checksum = "4cef8738ae3f4298ca4f17c5aa3a0b03b4eae1b2a31440fb8606a42281620530" dependencies = [ "blake3", "cid", @@ -2633,9 +2633,9 @@ dependencies = [ [[package]] name = "radicle-artifact-client" -version = "0.18.0" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59abfdeba1a2476c45264014ccc0e9d86cee55eb327e755506e5bc8c0d2b0153" +checksum = "1ce1435fbdc3a5913094845e4a1572f19b2a3927ff0457ca7756754772d48d9e" dependencies = [ "cid", "radicle", @@ -2647,13 +2647,14 @@ dependencies = [ [[package]] name = "radicle-artifact-core" -version = "0.18.0" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba6ef833ec05bb0731b61854c8f39467896d593845c69c0f9437e6f6f2d7bf36" +checksum = "e8f04cd3826d7da39f6ba773cff7d0475f33cd69bf32937ba707c300660f44aa" dependencies = [ "blake3", "cid", "dunce", + "indexmap", "iroh-base", "multibase", "multihash", diff --git a/explorer/crates/radicle-httpd/Cargo.toml b/explorer/crates/radicle-httpd/Cargo.toml index 6ccd98c6d..ec76f3d90 100644 --- a/explorer/crates/radicle-httpd/Cargo.toml +++ b/explorer/crates/radicle-httpd/Cargo.toml @@ -33,7 +33,7 @@ lru = { version = "0.18.2" } mime_guess = { version = "2.0.5" } nonempty = { version = "0.12.0", features = ["serialize"] } radicle.workspace = true -radicle-artifact = { version = "0.18", optional = true } +radicle-artifact = { version = "0.23", optional = true } radicle-job = { version = "0.6.0" } radicle-search = { path = "../radicle-search" } radicle-surf = { workspace = true, features = ["serde"] } diff --git a/explorer/crates/radicle-httpd/src/api.rs b/explorer/crates/radicle-httpd/src/api.rs index e93ad6901..378d89e7a 100644 --- a/explorer/crates/radicle-httpd/src/api.rs +++ b/explorer/crates/radicle-httpd/src/api.rs @@ -15,7 +15,7 @@ use radicle::storage::git::Repository; use radicle::storage::{ReadRepository, ReadStorage}; use radicle::{git, web, Profile}; #[cfg(feature = "artifacts")] -use radicle_artifact::Releases; +use radicle_artifact::{cache_db_path, Releases}; use tokio::sync::RwLock; mod error; @@ -156,8 +156,30 @@ impl Context { self.search.as_ref() } + /// Repo metadata for listings. Leaves out the release counts, because + /// they read every release of the repo. #[allow(clippy::result_large_err)] pub fn repo_info(&self, repo: &Repository, doc: DocAt) -> Result { + self.build_repo_info(repo, doc, false) + } + + /// Repo metadata for a single repo, with the release counts. + #[allow(clippy::result_large_err)] + pub fn repo_info_with_releases( + &self, + repo: &Repository, + doc: DocAt, + ) -> Result { + self.build_repo_info(repo, doc, true) + } + + #[allow(clippy::result_large_err)] + fn build_repo_info( + &self, + repo: &Repository, + doc: DocAt, + with_releases: bool, + ) -> Result { let DocAt { doc, .. } = doc; let rid = repo.id(); @@ -185,7 +207,9 @@ impl Context { "issues": issues, "patches": patches }); - add_releases_meta(&mut meta, repo); + if with_releases { + add_releases_meta(&mut meta, repo, &self.profile); + } Some(( id.clone(), @@ -242,20 +266,21 @@ impl Context { } } -/// Add the release count to a project payload's `meta` object. +/// Add the count of releases a user can browse to a project payload's `meta` +/// object: those not left with every artifact redacted by a trusted party. #[cfg(feature = "artifacts")] -fn add_releases_meta(meta: &mut Value, repo: &Repository) { - let releases = Releases::open(repo) +fn add_releases_meta(meta: &mut Value, repo: &Repository, profile: &Profile) { + let counts = Releases::open_cached(repo, cache_db_path(profile.cobs())) .ok() - .and_then(|releases| releases.count().ok()) + .and_then(|releases| releases.counts().ok()) .unwrap_or_default(); - meta["releases"] = json!(releases); + meta["releases"] = json!(counts.delegate + counts.other); } /// Without artifact support there is no release store, so the key is omitted. #[cfg(not(feature = "artifacts"))] -fn add_releases_meta(_meta: &mut Value, _repo: &Repository) {} +fn add_releases_meta(_meta: &mut Value, _repo: &Repository, _profile: &Profile) {} /// Run a blocking closure on the blocking thread pool. /// diff --git a/explorer/crates/radicle-httpd/src/api/query.rs b/explorer/crates/radicle-httpd/src/api/query.rs index 3a1ed8642..56493236f 100644 --- a/explorer/crates/radicle-httpd/src/api/query.rs +++ b/explorer/crates/radicle-httpd/src/api/query.rs @@ -59,12 +59,27 @@ pub struct CobsQuery { pub struct ReleasesQuery { pub page: Option, pub per_page: Option, - /// Include releases and artifacts authored by non-delegates. + /// Whose releases and artifacts to show. Defaults to trusted. + pub scope: Option, + /// Compat: older clients send `allAuthors=true` for every author. + /// Ignored when `scope` is set. Server: remove in a major release, see + /// docs/api-changes.md. pub all_authors: Option, /// Include artifacts redacted by their author or a delegate. pub show_redacted: Option, } +/// The two disjoint author scopes of the releases list. A web node has no +/// local user, so trusted means delegates. +#[cfg(feature = "artifacts")] +#[derive(Default, Serialize, Deserialize, Clone, Copy)] +#[serde(rename_all = "camelCase")] +pub enum ReleaseScope { + #[default] + Trusted, + Untrusted, +} + #[derive(Default, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub enum IssueStatus { diff --git a/explorer/crates/radicle-httpd/src/api/v1/delegates.rs b/explorer/crates/radicle-httpd/src/api/v1/delegates.rs index 1a327e072..83a004c54 100644 --- a/explorer/crates/radicle-httpd/src/api/v1/delegates.rs +++ b/explorer/crates/radicle-httpd/src/api/v1/delegates.rs @@ -131,7 +131,6 @@ mod routes { "open": 1, "closed": 0, }, - "releases": 0, } } }, @@ -169,7 +168,6 @@ mod routes { "open": 0, "closed": 0, }, - "releases": 0, } } }, @@ -229,7 +227,6 @@ mod routes { "open": 1, "closed": 0, }, - "releases": 0, } } }, @@ -267,7 +264,6 @@ mod routes { "open": 0, "closed": 0, }, - "releases": 0, } } }, diff --git a/explorer/crates/radicle-httpd/src/api/v1/repos.rs b/explorer/crates/radicle-httpd/src/api/v1/repos.rs index 08bcc8828..65611bb55 100644 --- a/explorer/crates/radicle-httpd/src/api/v1/repos.rs +++ b/explorer/crates/radicle-httpd/src/api/v1/repos.rs @@ -417,7 +417,7 @@ async fn repo_handler(State(ctx): State, Path(rid): Path) -> im let rid = ctx.resolve_repo(&rid)?; let info = api::blocking(move || { let (repo, doc) = ctx.repo(rid)?; - ctx.repo_info(&repo, doc) + ctx.repo_info_with_releases(&repo, doc) }) .await?; @@ -1404,7 +1404,6 @@ mod routes { "open": 1, "closed": 0, }, - "releases": 0, } } }, @@ -1442,7 +1441,6 @@ mod routes { "open": 0, "closed": 0, }, - "releases": 0, } } }, @@ -1493,7 +1491,6 @@ mod routes { "open": 1, "closed": 0, }, - "releases": 0, } } }, @@ -1531,7 +1528,6 @@ mod routes { "open": 0, "closed": 0, }, - "releases": 0, } } }, diff --git a/explorer/crates/radicle-httpd/src/api/v1/repos/releases.rs b/explorer/crates/radicle-httpd/src/api/v1/repos/releases.rs index 4548312f6..8d85b8e12 100644 --- a/explorer/crates/radicle-httpd/src/api/v1/repos/releases.rs +++ b/explorer/crates/radicle-httpd/src/api/v1/repos/releases.rs @@ -1,76 +1,39 @@ +use std::collections::BTreeSet; + use axum::extract::State; use axum::response::IntoResponse; use axum::Json; use serde_json::{json, Value}; use radicle::git::Oid; -use radicle::identity::doc::Delegates; +use radicle::identity::Did; use radicle::node::AliasStore; use radicle::storage::git::Repository; use radicle_artifact::display::{CommitTitle, TagName}; -use radicle_artifact::{cache_db_path, Artifact, Cid, Release, ReleaseId, Releases}; +use radicle_artifact::trust::{Scope, Trust}; +use radicle_artifact::{cache_db_path, Artifact, Cid, Filters, Release, ReleaseId, Releases}; use crate::api; use crate::api::error::Error; use crate::api::json::Author; -use crate::api::query::{ReleasesQuery, MAX_PER_PAGE}; +use crate::api::query::{ReleaseScope, ReleasesQuery, MAX_PER_PAGE}; use crate::api::Context; use crate::axum_extra::{Path, Query}; /// Default number of releases returned per page. const DEFAULT_PER_PAGE: usize = 30; -/// Whether an artifact was redacted by its own author or by a delegate. -fn redacted_by_trusted(artifact: &Artifact, delegates: &Delegates) -> bool { - artifact - .redactions() - .keys() - .any(|did| did == artifact.author() || delegates.contains(did)) -} - -/// How far the caller widened the default, delegate-scoped release view. -#[derive(Clone, Copy)] -struct Filter { - /// Include releases and artifacts authored by non-delegates. - all_authors: bool, - /// Include artifacts redacted by their author or a delegate. - show_redacted: bool, -} - -impl Filter { - /// Whether an artifact is shown under this view: authored by a delegate - /// (or `all_authors`), and not redacted by its author or a delegate (or - /// `show_redacted`). - fn show_artifact(&self, artifact: &Artifact, delegates: &Delegates) -> bool { - (self.all_authors || delegates.contains(artifact.author())) - && (self.show_redacted || !redacted_by_trusted(artifact, delegates)) - } - - /// Whether a release is shown under this view: created by a delegate (or - /// `all_authors`), and not left with all of its artifacts redacted by a - /// trusted party (or `show_redacted`). - fn show_release(&self, release: &Release, delegates: &Delegates) -> bool { - (self.all_authors || delegates.contains(release.creator())) - && (self.show_redacted || !release_redacted(release, delegates)) - } -} - -/// Whether every artifact of a release was redacted by a trusted party. A -/// release without artifacts is not redacted; it has nothing to redact. -fn release_redacted(release: &Release, delegates: &Delegates) -> bool { - let artifacts = release.artifacts(); - - !artifacts.is_empty() - && artifacts - .values() - .all(|artifact| redacted_by_trusted(artifact, delegates)) -} - /// Serialize a single artifact. Locations are flattened across contributors /// into `{ user, url }` entries; attestations are the attesting nodes; -/// redactions carry the flagging user and its reason. -fn artifact_json(cid: &Cid, artifact: &Artifact, aliases: &impl AliasStore) -> Value { +/// redactions carry the flagging user and its reason; metadata is only what +/// its author or a delegate wrote. +fn artifact_json( + cid: &Cid, + artifact: &Artifact, + delegates: &BTreeSet, + aliases: &impl AliasStore, +) -> Value { let locations = artifact .locations() .iter() @@ -97,12 +60,12 @@ fn artifact_json(cid: &Cid, artifact: &Artifact, aliases: &impl AliasStore) -> V "locations": locations, "attestations": attestations, "redactions": redactions, - "metadata": artifact.metadata(), + "metadata": artifact.trusted_metadata(delegates), }) } /// Serialize a release. The COB has no title field: it is resolved from the -/// tag message when a tag is linked, otherwise the commit summary. `filter` +/// tag message when a tag is linked, otherwise the commit summary. `filters` /// omits the artifacts hidden under the current view; `None` keeps all of /// them, as a release fetched by id does. fn release_json( @@ -110,8 +73,8 @@ fn release_json( release: &Release, repo: &Repository, aliases: &impl AliasStore, - delegates: &Delegates, - filter: Option, + delegates: &BTreeSet, + filters: Option, ) -> Value { let title = release .tag() @@ -122,10 +85,8 @@ fn release_json( let artifacts = release .artifacts() .iter() - .filter(|(_, artifact)| { - filter.is_none_or(|filter| filter.show_artifact(artifact, delegates)) - }) - .map(|(cid, artifact)| artifact_json(cid, artifact, aliases)) + .filter(|(_, artifact)| filters.is_none_or(|f| f.shows_artifact(artifact))) + .map(|(cid, artifact)| artifact_json(cid, artifact, delegates, aliases)) .collect::>(); json!({ @@ -140,13 +101,27 @@ fn release_json( }) } +/// The filters a listed release shows its artifacts under. A release page opens +/// on the delegates' artifacts, so a teaser counts those in either scope. +fn listed_filters(filters: Filters<'_>) -> Filters<'_> { + match filters.scope { + Scope::All => filters, + Scope::Trusted | Scope::Untrusted => Filters { + scope: Scope::Trusted, + ..filters + }, + } +} + /// Get repo releases list, newest first. /// `GET /repos/:rid/releases` /// -/// Scoped to releases created by a delegate and artifacts authored by a -/// delegate (hiding those redacted by a trusted party) unless widened with -/// `allAuthors=true` / `showRedacted=true`. A release whose artifacts were all -/// redacted is hidden with them. +/// Scoped to releases created by a delegate, or with `scope=untrusted` to +/// those by non-delegates, so the two scopes never overlap. Each release lists +/// only the artifacts authored by a delegate, in either scope. Artifacts +/// redacted by a trusted party are hidden unless `showRedacted=true`. A release +/// whose artifacts were all redacted is hidden with them; a release with no +/// artifacts is shown. pub async fn list_handler( State(ctx): State, Path(rid): Path, @@ -154,54 +129,52 @@ pub async fn list_handler( ) -> impl IntoResponse { let rid = ctx.resolve_repo(&rid)?; let releases = api::blocking(move || { - let (repo, doc) = ctx.repo(rid)?; - let delegates = doc.delegates(); + let (repo, _) = ctx.repo(rid)?; let aliases = ctx.profile.aliases(); let ReleasesQuery { page, per_page, + scope, all_authors, show_redacted, } = qs; let page = page.unwrap_or(0); let per_page = per_page.unwrap_or(DEFAULT_PER_PAGE).min(MAX_PER_PAGE); - let filter = Filter { - all_authors: all_authors.unwrap_or(false), - show_redacted: show_redacted.unwrap_or(false), - }; // Read through the SQLite cache; it self-warms on read and is shared - // with other release reads on this node. - let cache = cache_db_path(ctx.profile.cobs()); - let mut releases: Vec<_> = Releases::open_cached(&repo, cache)? - .all()? - .into_iter() - .filter_map(|r| { - let (id, release) = r.ok()?; - filter - .show_release(&release, delegates) - .then_some((id, release)) + // with other release reads on this node. `list` is sorted newest + // first and lazy, so only the rows up to the requested page are read. + let store = Releases::open_cached(&repo, cache_db_path(ctx.profile.cobs()))?; + // A web node has no local user, so only delegates are trusted unless + // widened. + let filters = Filters { + trust: Trust::new(store.delegates(), None), + scope: match (scope, all_authors) { + (Some(ReleaseScope::Untrusted), _) => Scope::Untrusted, + (None, Some(true)) => Scope::All, + _ => Scope::Trusted, + }, + include_redacted: show_redacted.unwrap_or(false), + }; + let releases = store + .list()? + .filter_map(Result::ok) + .filter(|(_, release)| filters.shows_release(release)) + .skip(page * per_page) + .take(per_page) + .map(|(id, release)| { + release_json( + ReleaseId::from(id), + &release, + &repo, + &aliases, + store.delegates(), + Some(listed_filters(filters)), + ) }) - .collect(); - releases.sort_by_key(|(_, release)| std::cmp::Reverse(release.timestamp())); + .collect::>(); - Ok::<_, Error>( - releases - .into_iter() - .skip(page * per_page) - .take(per_page) - .map(|(id, release)| { - release_json( - ReleaseId::from(id), - &release, - &repo, - &aliases, - delegates, - Some(filter), - ) - }) - .collect::>(), - ) + Ok::<_, Error>(releases) }) .await?; @@ -216,11 +189,11 @@ pub async fn get_handler( ) -> impl IntoResponse { let rid = ctx.resolve_repo(&rid)?; let value = api::blocking(move || { - let (repo, doc) = ctx.repo(rid)?; - let delegates = doc.delegates(); + let (repo, _) = ctx.repo(rid)?; let aliases = ctx.profile.aliases(); let cache = cache_db_path(ctx.profile.cobs()); - let release = Releases::open_cached(&repo, cache)? + let store = Releases::open_cached(&repo, cache)?; + let release = store .get(&ReleaseId::from(release_id))? .ok_or(Error::NotFound)?; @@ -229,7 +202,7 @@ pub async fn get_handler( &release, &repo, &aliases, - delegates, + store.delegates(), None, )) }) @@ -258,7 +231,7 @@ mod routes { use url::Url; use crate::api::Context; - use crate::test::{get, seed, DID, HEAD, RID}; + use crate::test::{get, seed, DID, HEAD, PARENT, RID}; /// A valid CIDv1 string; the release COB stores it verbatim. const CID: &str = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; @@ -379,20 +352,35 @@ mod routes { } #[tokio::test] - async fn test_repos_releases_hides_non_delegate_creator() { + async fn test_repos_releases_scopes_are_disjoint() { let tmp = tempfile::tempdir().unwrap(); let ctx = seed(tmp.path()); - create_release(&ctx, NON_DELEGATE_SEED); + let delegate_id = create_release(&ctx, DELEGATE_SEED); + let other_id = create_release(&ctx, NON_DELEGATE_SEED); let app = app(ctx); + let ids = |body: serde_json::Value| { + body.as_array() + .unwrap() + .iter() + .map(|release| release["id"].as_str().unwrap().to_owned()) + .collect::>() + }; // Default view is scoped to delegate creators. let response = get(&app, format!("/repos/{RID}/releases")).await; - assert_eq!(response.json().await, json!([])); + assert_eq!(ids(response.json().await), [delegate_id.as_str()]); - // `allAuthors=true` widens it to include non-delegate creators. + // `scope=untrusted` shows only non-delegate creators. + let response = get(&app, format!("/repos/{RID}/releases?scope=untrusted")).await; + assert_eq!(ids(response.json().await), [other_id.as_str()]); + + // Older clients send `allAuthors=true` for every creator. let response = get(&app, format!("/repos/{RID}/releases?allAuthors=true")).await; - let body = response.json().await; - assert_eq!(body.as_array().unwrap().len(), 1); + let mut all = ids(response.json().await); + all.sort(); + let mut expected = vec![delegate_id.as_str(), other_id.as_str()]; + expected.sort(); + assert_eq!(all, expected); } #[tokio::test] @@ -431,11 +419,59 @@ mod routes { assert_eq!(artifacts.len(), 1); assert_eq!(artifacts[0]["cid"], json!(CID)); - // `allAuthors=true` widens it to include the non-delegate's artifact. - let response = get(&app, format!("/repos/{RID}/releases?allAuthors=true")).await; + // A delegate created it, so it is not among the others' releases. + let response = get(&app, format!("/repos/{RID}/releases?scope=untrusted")).await; + assert_eq!(response.json().await, json!([])); + } + + #[tokio::test] + async fn test_repos_releases_list_only_delegate_artifacts() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = seed(tmp.path()); + + // A delegate release with only a non-delegate artifact, and a + // non-delegate release with an artifact by each. + { + let signer = SigningKey::from_seed(Seed::new(DELEGATE_SEED)); + let other_signer = SigningKey::from_seed(Seed::new(NON_DELEGATE_SEED)); + let rid = RepoId::from_str(RID).unwrap(); + let repo = ctx.profile().storage.repository_mut(rid).unwrap(); + let mut releases = Releases::open(&repo).unwrap(); + let cid = Cid::from_str(CID).unwrap(); + let other_cid = Cid::from_str(CID_2).unwrap(); + + let mut release = releases + .create(Oid::from_str(HEAD).unwrap(), None, &signer) + .unwrap(); + release + .register_artifact(other_cid, "eve-build".to_string(), &other_signer) + .unwrap(); + + let mut release = releases + .create(Oid::from_str(PARENT).unwrap(), None, &other_signer) + .unwrap(); + release + .register_artifact(cid, "linux-amd64".to_string(), &signer) + .unwrap(); + release + .register_artifact(other_cid, "eve-build".to_string(), &other_signer) + .unwrap(); + } + let app = app(ctx); + + // The scope never widens to the non-delegate's artifact. + let response = get(&app, format!("/repos/{RID}/releases")).await; let body = response.json().await; + assert_eq!(body[0]["oid"], json!(HEAD)); + assert_eq!(body[0]["artifacts"], json!([])); + + // Among the others' releases too, only the delegate's artifact counts. + let response = get(&app, format!("/repos/{RID}/releases?scope=untrusted")).await; + let body = response.json().await; + assert_eq!(body[0]["oid"], json!(PARENT)); let artifacts = body[0]["artifacts"].as_array().unwrap(); - assert_eq!(artifacts.len(), 2); + assert_eq!(artifacts.len(), 1); + assert_eq!(artifacts[0]["cid"], json!(CID)); } #[tokio::test] @@ -476,6 +512,29 @@ mod routes { ); } + #[tokio::test] + async fn test_repos_releases_shows_empty_release() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = seed(tmp.path()); + { + let signer = SigningKey::from_seed(Seed::new(DELEGATE_SEED)); + let rid = RepoId::from_str(RID).unwrap(); + let repo = ctx.profile().storage.repository_mut(rid).unwrap(); + let mut releases = Releases::open(&repo).unwrap(); + releases + .create(Oid::from_str(HEAD).unwrap(), None, &signer) + .unwrap(); + } + let app = app(ctx); + + // A release with no artifacts has nothing redacted, so the default + // view shows it. + let response = get(&app, format!("/repos/{RID}/releases")).await; + let body = response.json().await; + assert_eq!(body.as_array().unwrap().len(), 1); + assert_eq!(body[0]["artifacts"], json!([])); + } + #[tokio::test] async fn test_repos_releases_alias_resolution() { let tmp = tempfile::tempdir().unwrap(); diff --git a/explorer/http-client/index.ts b/explorer/http-client/index.ts index 64f2fea6b..3180366c1 100644 --- a/explorer/http-client/index.ts +++ b/explorer/http-client/index.ts @@ -4,6 +4,8 @@ import type { DiffResponse, Job, PeerRefs, + ReleaseScope, + ReleaseCounts, Remote, Repo, RepoListQuery, @@ -96,6 +98,8 @@ export type { Reaction, Redaction, Release, + ReleaseScope, + ReleaseCounts, Remote, Repo, RepoListQuery, diff --git a/explorer/http-client/lib/repo.ts b/explorer/http-client/lib/repo.ts index 43094f9c7..072a136f0 100644 --- a/explorer/http-client/lib/repo.ts +++ b/explorer/http-client/lib/repo.ts @@ -51,6 +51,22 @@ const tagInfoSchema = object({ export type TagInfo = z.infer; +// Releases bucketed by whether a delegate created them and whether a trusted +// party redacted every artifact they carry. A release with no artifacts is not +// redacted. +const releaseCountsSchema = object({ + delegate: number(), + delegateRedacted: number(), + other: number(), + otherRedacted: number(), +}); + +export type ReleaseCounts = z.infer; + +// The two disjoint author scopes of the releases list: releases created by a +// delegate, or by anyone else. +export type ReleaseScope = "trusted" | "untrusted"; + const repoSchema = object({ rid: string(), payloads: object({ @@ -72,8 +88,9 @@ const repoSchema = object({ open: number(), closed: number(), }), - // Optional: absent on older nodes that don't report release counts. - releases: optional(number()), + // Absent on older nodes that don't report release counts, and a + // single count on nodes that report one instead of the buckets. + releases: optional(union([releaseCountsSchema, number()])), }), }), }), @@ -600,6 +617,9 @@ export class Client { query?: { page?: number; perPage?: number; + scope?: ReleaseScope; + // Older nodes ignore `scope` and only know this superset of both + // scopes. allAuthors?: boolean; showRedacted?: boolean; }, diff --git a/explorer/src/lib/releases.ts b/explorer/src/lib/releases.ts new file mode 100644 index 000000000..ba823d263 --- /dev/null +++ b/explorer/src/lib/releases.ts @@ -0,0 +1,74 @@ +import type { Artifact, Author, ReleaseScope } from "@http-client"; + +export interface ArtifactView { + shown: Artifact[]; + redactedCount: number; + counts: Record; + showFilters: boolean; +} + +// Whether the artifact was redacted by a trusted party (its author or a +// delegate), mirroring the backend's default-hidden rule. +export function redactedByTrusted( + artifact: Artifact, + delegates: Set, +): boolean { + return artifact.redactions.some( + r => r.user.id === artifact.author.id || delegates.has(r.user.id), + ); +} + +// The artifacts of a release in one author scope. The endpoint returns every +// author, so the split happens here. The scope asked for is shown even when it +// is empty: switching to others by itself would show untrusted content unasked. +export function artifactView( + artifacts: Artifact[], + delegates: Set, + scope: ReleaseScope, + showRedacted: boolean, +): ArtifactView { + const byScope: Record = { + trusted: artifacts.filter(a => delegates.has(a.author.id)), + untrusted: artifacts.filter(a => !delegates.has(a.author.id)), + }; + const visible = (list: Artifact[]) => + showRedacted ? list : list.filter(a => !redactedByTrusted(a, delegates)); + + return { + shown: visible(byScope[scope]), + // The hidden set within the current author scope. + redactedCount: byScope[scope].filter(a => redactedByTrusted(a, delegates)) + .length, + // Counts reflect what each scope would show, so an artifact hidden as + // redacted never counts. The filter itself comes from the unfiltered sets, + // so toggling redacted artifacts never hides it. + counts: { + trusted: visible(byScope.trusted).length, + untrusted: visible(byScope.untrusted).length, + }, + // Others is one click away whenever it holds something. + showFilters: byScope.untrusted.length > 0, + }; +} + +// Anyone can attest their own artifact, so only delegates count. +export function attestedBy( + attestations: Author[], + delegates: Set, +): number { + return attestations.filter(a => delegates.has(a.id)).length; +} + +// A non-delegate's release is untrusted as a whole, as its creator chose its +// title, tag and commit. A delegate's release is untrusted only while it +// shows artifacts by non-delegates. +export function releaseWarning( + creator: string, + delegates: Set, + artifactScope: ReleaseScope, +): "release" | "artifacts" | undefined { + if (!delegates.has(creator)) { + return "release"; + } + return artifactScope === "untrusted" ? "artifacts" : undefined; +} diff --git a/explorer/src/lib/utils.ts b/explorer/src/lib/utils.ts index 071955029..d637a5f31 100644 --- a/explorer/src/lib/utils.ts +++ b/explorer/src/lib/utils.ts @@ -13,6 +13,20 @@ export function defaultBranch(repo: Repo): string { return repo.payloads["xyz.radicle.project"].data.defaultBranch; } +// Counts the releases a user can browse: the two unredacted buckets, or the +// single count older nodes report in place of the buckets. Undefined means the +// node doesn't report releases at all, which is how the release API is +// detected. +export function visibleReleaseCount(repo: Repo): number | undefined { + const releases = repo.payloads["xyz.radicle.project"].meta.releases; + if (releases === undefined) { + return undefined; + } + return typeof releases === "number" + ? releases + : releases.delegate + releases.other; +} + export function unqualifyBranch(refname: string): string { return refname.startsWith(REFS_HEADS) ? refname.slice(REFS_HEADS.length) diff --git a/explorer/src/views/repos/Header.svelte b/explorer/src/views/repos/Header.svelte index 4a734c861..4b153d25a 100644 --- a/explorer/src/views/repos/Header.svelte +++ b/explorer/src/views/repos/Header.svelte @@ -9,7 +9,7 @@ import config from "@app/lib/config"; import debounce from "lodash/debounce"; import { routeToPath } from "@app/lib/router"; - import { toClipboard } from "@app/lib/utils"; + import { toClipboard, visibleReleaseCount } from "@app/lib/utils"; import Button from "@app/components/Button.svelte"; import Icon from "@app/components/Icon.svelte"; @@ -21,6 +21,9 @@ export let repo: Repo; export let repoId: string; + // Undefined on older nodes that don't report release counts. + $: releaseCount = visibleReleaseCount(repo); + let shareIcon: "link" | "checkmark" = "link"; const restoreIcon = debounce(() => { @@ -160,9 +163,9 @@ - - {#if repo.payloads["xyz.radicle.project"].meta.releases !== undefined} + {#if releaseCount !== undefined} - {repo.payloads["xyz.radicle.project"].meta.releases} + {releaseCount} diff --git a/explorer/src/views/repos/Release.svelte b/explorer/src/views/repos/Release.svelte index ee084c917..dd14e2fcd 100644 --- a/explorer/src/views/repos/Release.svelte +++ b/explorer/src/views/repos/Release.svelte @@ -1,7 +1,19 @@ + + + + diff --git a/explorer/src/views/repos/Releases.svelte b/explorer/src/views/repos/Releases.svelte index 2c5479c6a..878b6a714 100644 --- a/explorer/src/views/repos/Releases.svelte +++ b/explorer/src/views/repos/Releases.svelte @@ -1,8 +1,8 @@