mirror of
https://seed.radicle.dev/rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5.git
synced 2026-11-13 18:40:50 +08:00
Split releases into trusted and untrusted scopes
- Upgrade radicle-artifact to 0.23 - Use radicle-artifact Filters, Trust and Scope for releases - Count only releases a user can browse: delegate + other - Count releases only for a single repo, not in repo lists - Add the scope=trusted|untrusted query - Keep allAuthors for older clients until a major release - Replace the All filter with Delegates and Others in the UI - Open lists and releases on Delegates, also when empty - Show the Others filter whenever it has content - Name the scope in the empty artifacts message - Filter by creator on older nodes, which ignore scope - Page releases on the raw response length - Warn on a release by a non-delegate - Warn on a delegate's release while it shows others' artifacts - Count only delegate attestations in a badge - Warn about browser links added by non-delegates - Say browser downloads are not checked against the CID - Move the scope and warning logic to `src/lib/releases.ts` - Test artifact scopes, scope routes, opening on Delegates and the warnings
This commit is contained in:
Generated
+9
-8
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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<repo::Info, error::Error> {
|
||||
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<repo::Info, error::Error> {
|
||||
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<repo::Info, error::Error> {
|
||||
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.
|
||||
///
|
||||
|
||||
@@ -59,12 +59,27 @@ pub struct CobsQuery<T> {
|
||||
pub struct ReleasesQuery {
|
||||
pub page: Option<usize>,
|
||||
pub per_page: Option<usize>,
|
||||
/// Include releases and artifacts authored by non-delegates.
|
||||
/// Whose releases and artifacts to show. Defaults to trusted.
|
||||
pub scope: Option<ReleaseScope>,
|
||||
/// 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<bool>,
|
||||
/// Include artifacts redacted by their author or a delegate.
|
||||
pub show_redacted: Option<bool>,
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -417,7 +417,7 @@ async fn repo_handler(State(ctx): State<Context>, Path(rid): Path<String>) -> 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,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<Did>,
|
||||
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<Filter>,
|
||||
delegates: &BTreeSet<Did>,
|
||||
filters: Option<Filters>,
|
||||
) -> 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::<Vec<_>>();
|
||||
|
||||
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<Context>,
|
||||
Path(rid): Path<String>,
|
||||
@@ -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::<Vec<_>>();
|
||||
|
||||
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::<Vec<_>>(),
|
||||
)
|
||||
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::<Vec<_>>()
|
||||
};
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -51,6 +51,22 @@ const tagInfoSchema = object({
|
||||
|
||||
export type TagInfo = z.infer<typeof tagInfoSchema>;
|
||||
|
||||
// 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<typeof releaseCountsSchema>;
|
||||
|
||||
// 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;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { Artifact, Author, ReleaseScope } from "@http-client";
|
||||
|
||||
export interface ArtifactView {
|
||||
shown: Artifact[];
|
||||
redactedCount: number;
|
||||
counts: Record<ReleaseScope, number>;
|
||||
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<string>,
|
||||
): 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<string>,
|
||||
scope: ReleaseScope,
|
||||
showRedacted: boolean,
|
||||
): ArtifactView {
|
||||
const byScope: Record<ReleaseScope, Artifact[]> = {
|
||||
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<string>,
|
||||
): 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<string>,
|
||||
artifactScope: ReleaseScope,
|
||||
): "release" | "artifacts" | undefined {
|
||||
if (!delegates.has(creator)) {
|
||||
return "release";
|
||||
}
|
||||
return artifactScope === "untrusted" ? "artifacts" : undefined;
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 @@
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<!-- Only nodes that report a release count support the release API; hide the
|
||||
<!-- Only nodes that report release counts support the release API; hide the
|
||||
tab on older nodes where the field is absent. -->
|
||||
{#if repo.payloads["xyz.radicle.project"].meta.releases !== undefined}
|
||||
{#if releaseCount !== undefined}
|
||||
<Link
|
||||
route={{
|
||||
resource: "repo.releases",
|
||||
@@ -179,7 +182,7 @@
|
||||
class="counter"
|
||||
class:hover={hover && activeTab !== "releases"}
|
||||
class:selected={activeTab === "releases"}>
|
||||
{repo.payloads["xyz.radicle.project"].meta.releases}
|
||||
{releaseCount}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { Artifact, BaseUrl, Release, Repo } from "@http-client";
|
||||
import type {
|
||||
Artifact,
|
||||
BaseUrl,
|
||||
Release,
|
||||
ReleaseScope,
|
||||
Repo,
|
||||
} from "@http-client";
|
||||
|
||||
import * as utils from "@app/lib/utils";
|
||||
import {
|
||||
artifactView,
|
||||
attestedBy,
|
||||
redactedByTrusted,
|
||||
releaseWarning,
|
||||
} from "@app/lib/releases";
|
||||
|
||||
import Badge from "@app/components/Badge.svelte";
|
||||
import Button from "@app/components/Button.svelte";
|
||||
@@ -15,12 +27,13 @@
|
||||
import Link from "@app/components/Link.svelte";
|
||||
import NodeId from "@app/components/NodeId.svelte";
|
||||
import Separator from "./Separator.svelte";
|
||||
import UntrustedWarning from "./Release/UntrustedWarning.svelte";
|
||||
|
||||
export let baseUrl: BaseUrl;
|
||||
export let release: Release;
|
||||
export let repo: Repo;
|
||||
export let repoId: string;
|
||||
export let allAuthors: boolean;
|
||||
export let scope: ReleaseScope | undefined = undefined;
|
||||
export let nodeId: string;
|
||||
export let nodeAvatarUrl: string | undefined;
|
||||
|
||||
@@ -33,14 +46,6 @@
|
||||
|
||||
$: delegateIds = new Set(repo.delegates.map(d => d.id));
|
||||
|
||||
// Whether the artifact was redacted by a trusted party (its author or a
|
||||
// delegate), mirroring the backend's default-hidden rule.
|
||||
function redactedByTrusted(artifact: Artifact, delegates: Set<string>) {
|
||||
return artifact.redactions.some(
|
||||
r => r.user.id === artifact.author.id || delegates.has(r.user.id),
|
||||
);
|
||||
}
|
||||
|
||||
// Label for the redacted badge. Delegate takes precedence over author,
|
||||
// since the author may also be a delegate.
|
||||
function redactedByLabel(artifact: Artifact, delegates: Set<string>): string {
|
||||
@@ -48,46 +53,26 @@
|
||||
return byDelegate ? "Redacted by delegate" : "Redacted by author";
|
||||
}
|
||||
|
||||
// Artifacts visible under the given redaction toggle.
|
||||
function visible(
|
||||
list: Artifact[],
|
||||
show: boolean,
|
||||
delegates: Set<string>,
|
||||
): Artifact[] {
|
||||
return show ? list : list.filter(a => !redactedByTrusted(a, delegates));
|
||||
}
|
||||
|
||||
$: delegateArtifacts = release.artifacts.filter(a =>
|
||||
delegateIds.has(a.author.id),
|
||||
);
|
||||
// Artifacts are scoped to delegate authors by default; the endpoint returns
|
||||
// all of them, so filtering happens client-side. With no delegate artifact
|
||||
// at all, fall back to every author.
|
||||
$: authorArtifacts =
|
||||
allAuthors || delegateArtifacts.length === 0
|
||||
? release.artifacts
|
||||
: delegateArtifacts;
|
||||
$: shownArtifacts = visible(authorArtifacts, showRedacted, delegateIds);
|
||||
// The redacted count is the hidden set within the current author scope.
|
||||
$: redactedCount = authorArtifacts.filter(a =>
|
||||
redactedByTrusted(a, delegateIds),
|
||||
).length;
|
||||
|
||||
// Segment counts reflect what each choice would actually show, so an
|
||||
// artifact hidden as redacted never counts towards a scope. They label the
|
||||
// segments only: the author scope and the filter visibility come from the
|
||||
// unfiltered sets, so toggling redacted artifacts never moves them.
|
||||
$: delegateCount = visible(
|
||||
delegateArtifacts,
|
||||
showRedacted,
|
||||
$: activeScope = scope ?? "trusted";
|
||||
$: view = artifactView(
|
||||
release.artifacts,
|
||||
delegateIds,
|
||||
).length;
|
||||
$: allCount = visible(release.artifacts, showRedacted, delegateIds).length;
|
||||
activeScope,
|
||||
showRedacted,
|
||||
);
|
||||
$: shownArtifacts = view.shown;
|
||||
$: redactedCount = view.redactedCount;
|
||||
$: delegateCount = view.counts.trusted;
|
||||
$: otherCount = view.counts.untrusted;
|
||||
$: showFilters = view.showFilters;
|
||||
|
||||
// Filter only when both scopes hold something.
|
||||
$: showFilters =
|
||||
delegateArtifacts.length > 0 &&
|
||||
delegateArtifacts.length !== release.artifacts.length;
|
||||
$: warning = releaseWarning(release.creator.id, delegateIds, activeScope);
|
||||
$: untrustedWarning =
|
||||
warning === "release"
|
||||
? "Not from a delegate. Only download if you trust the author."
|
||||
: warning === "artifacts"
|
||||
? "Not from delegates. Only download if you trust the authors."
|
||||
: undefined;
|
||||
|
||||
// Format a byte count as a human-readable size (mirrors the CLI display).
|
||||
function formatBytes(bytes: number): string {
|
||||
@@ -186,6 +171,10 @@
|
||||
.redacted-toggle {
|
||||
margin-left: auto;
|
||||
}
|
||||
.untrusted-warning {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.filter-divider {
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
@@ -382,7 +371,7 @@
|
||||
resource: "repo.releases",
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
allAuthors,
|
||||
scope: delegateIds.has(release.creator.id) ? undefined : "untrusted",
|
||||
}}>
|
||||
Releases
|
||||
</Link>
|
||||
@@ -436,6 +425,11 @@
|
||||
</CobHeader>
|
||||
|
||||
<div class="artifacts">
|
||||
{#if untrustedWarning}
|
||||
<div class="untrusted-warning">
|
||||
<UntrustedWarning text={untrustedWarning} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if showFilters || redactedCount > 0}
|
||||
<div class="filter">
|
||||
{#if showFilters}
|
||||
@@ -449,14 +443,14 @@
|
||||
}}>
|
||||
<Button
|
||||
let:hover
|
||||
variant={!allAuthors ? "gray" : "background"}>
|
||||
variant={activeScope === "trusted" ? "gray" : "background"}>
|
||||
<Icon name="badge" />
|
||||
<div class="title-counter">
|
||||
Delegates
|
||||
<span
|
||||
class="counter"
|
||||
class:selected={!allAuthors}
|
||||
class:hover={hover && allAuthors}>
|
||||
class:selected={activeScope === "trusted"}
|
||||
class:hover={hover && activeScope !== "trusted"}>
|
||||
{delegateCount}
|
||||
</span>
|
||||
</div>
|
||||
@@ -468,19 +462,22 @@
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
release: release.id,
|
||||
allAuthors: true,
|
||||
scope: "untrusted",
|
||||
}}>
|
||||
<Button
|
||||
let:hover
|
||||
variant={allAuthors ? "gray" : "background"}>
|
||||
title="Non-delegates"
|
||||
variant={activeScope === "untrusted"
|
||||
? "gray"
|
||||
: "background"}>
|
||||
<Icon name="avatar-incognito" />
|
||||
<div class="title-counter">
|
||||
All
|
||||
Others
|
||||
<span
|
||||
class="counter"
|
||||
class:selected={allAuthors}
|
||||
class:hover={hover && !allAuthors}>
|
||||
{allCount}
|
||||
class:selected={activeScope === "untrusted"}
|
||||
class:hover={hover && activeScope !== "untrusted"}>
|
||||
{otherCount}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
@@ -505,7 +502,11 @@
|
||||
{#if shownArtifacts.length === 0}
|
||||
<div class="empty-artifacts">
|
||||
<Icon name="attach" />
|
||||
No artifacts
|
||||
{!showFilters
|
||||
? "No artifacts"
|
||||
: activeScope === "trusted"
|
||||
? "No artifacts by delegates"
|
||||
: "No artifacts by others"}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -520,6 +521,10 @@
|
||||
{@const locationCount = artifact.locations.length}
|
||||
{@const cliOnly =
|
||||
locationCount > 0 && !artifact.locations.some(l => isWebUrl(l.url))}
|
||||
{@const attestedByDelegates = attestedBy(
|
||||
artifact.attestations,
|
||||
delegateIds,
|
||||
)}
|
||||
{@const redactions = delegatesFirst(
|
||||
artifact.redactions,
|
||||
r => r.user.id,
|
||||
@@ -534,6 +539,15 @@
|
||||
{redactedByLabel(artifact, delegateIds)}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if attestedByDelegates > 0}
|
||||
<Badge size="tiny" variant="positive">
|
||||
<Icon name="checkmark" />
|
||||
Attested by {attestedByDelegates} delegate{attestedByDelegates ===
|
||||
1
|
||||
? ""
|
||||
: "s"}
|
||||
</Badge>
|
||||
{/if}
|
||||
<div class="artifact-actions">
|
||||
{#if size}
|
||||
<span class="size">{size}</span>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import Popover from "@app/components/Popover.svelte";
|
||||
import Radio from "@app/components/Radio.svelte";
|
||||
import ExternalLink from "@app/components/ExternalLink.svelte";
|
||||
import UntrustedWarning from "./UntrustedWarning.svelte";
|
||||
|
||||
export let artifact: Artifact;
|
||||
export let rid: string;
|
||||
@@ -25,6 +26,8 @@
|
||||
(a, b) =>
|
||||
Number(delegateIds.has(b.user.id)) - Number(delegateIds.has(a.user.id)),
|
||||
);
|
||||
$: authorTrusted = delegateIds.has(artifact.author.id);
|
||||
$: linksByOthers = webLocations.some(l => !delegateIds.has(l.user.id));
|
||||
// Any location at all can be fetched with the CLI, web locations included.
|
||||
$: downloadable = artifact.locations.length > 0;
|
||||
$: command = `rad artifact -r ${rid} download --cid ${artifact.cid}`;
|
||||
@@ -50,6 +53,9 @@
|
||||
.warning :global(svg) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.untrusted {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.locations {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -90,6 +96,12 @@
|
||||
</Button>
|
||||
|
||||
<div slot="popover" style:width="24rem" class="popover">
|
||||
{#if !authorTrusted}
|
||||
<div class="untrusted">
|
||||
<UntrustedWarning
|
||||
text="Not from a delegate. Only download if you trust the author." />
|
||||
</div>
|
||||
{/if}
|
||||
<div style:margin-bottom="1.5rem">
|
||||
<Radio ariaLabel="Toggle download method" styleGap="2px">
|
||||
<Button
|
||||
@@ -131,8 +143,14 @@
|
||||
{:else}
|
||||
<div class="warning">
|
||||
<Icon name="warning" />
|
||||
These downloads are not verified.
|
||||
These downloads are not checked against the CID.
|
||||
</div>
|
||||
{#if authorTrusted && linksByOthers}
|
||||
<div class="untrusted">
|
||||
<UntrustedWarning
|
||||
text="Links without a delegate badge are not from a delegate. Only use them if you trust who added them." />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="locations">
|
||||
{#each webLocations as location (location.url)}
|
||||
<div class="location">
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
export let delegateIds: Set<string>;
|
||||
export let release: Release;
|
||||
export let repoId: string;
|
||||
export let allAuthors: boolean = false;
|
||||
|
||||
// The COB has no title; fall back to the tag name, then the release id.
|
||||
$: title = release.title || release.tagName || release.id;
|
||||
@@ -84,7 +83,6 @@
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
release: release.id,
|
||||
allAuthors,
|
||||
}}>
|
||||
<InlineTitle fontSize="body-m-regular" content={title} />
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@app/components/Icon.svelte";
|
||||
|
||||
export let text: string;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
color: var(--color-text-secondary);
|
||||
font: var(--txt-body-s-regular);
|
||||
}
|
||||
.icon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-feedback-warning-text);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="banner" role="note">
|
||||
<span class="icon"><Icon name="warning" /></span>
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import type { BaseUrl, Release, Repo } from "@http-client";
|
||||
import type { BaseUrl, Release, ReleaseScope, Repo } from "@http-client";
|
||||
|
||||
import { HttpdClient } from "@http-client";
|
||||
import { RELEASES_PER_PAGE } from "./router";
|
||||
import { RELEASES_PER_PAGE, releasesQuery, scopeReleases } from "./router";
|
||||
import { baseUrlToString } from "@app/lib/utils";
|
||||
|
||||
import Button from "@app/components/Button.svelte";
|
||||
@@ -15,12 +15,14 @@
|
||||
import Placeholder from "@app/components/Placeholder.svelte";
|
||||
import ReleaseTeaser from "@app/views/repos/Release/ReleaseTeaser.svelte";
|
||||
import Separator from "./Separator.svelte";
|
||||
import UntrustedWarning from "@app/views/repos/Release/UntrustedWarning.svelte";
|
||||
|
||||
export let baseUrl: BaseUrl;
|
||||
export let releases: Release[];
|
||||
export let lastPageFull: boolean;
|
||||
export let repo: Repo;
|
||||
export let repoId: string;
|
||||
export let allAuthors: boolean;
|
||||
export let scope: ReleaseScope;
|
||||
export let showFilters: boolean;
|
||||
export let nodeId: string;
|
||||
export let nodeAvatarUrl: string | undefined;
|
||||
@@ -30,21 +32,19 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let error: any;
|
||||
let allReleases: Release[];
|
||||
let morePages: boolean;
|
||||
|
||||
$: {
|
||||
allReleases = releases;
|
||||
morePages = lastPageFull;
|
||||
page = 0;
|
||||
}
|
||||
|
||||
$: delegateIds = new Set(repo.delegates.map(d => d.id));
|
||||
// The delegate count isn't in repo metadata; derive it from the loaded
|
||||
// pages. It labels the segment only, and reads as "30+" while pages are
|
||||
// outstanding; whether the segments show at all is settled by the router.
|
||||
$: delegateReleaseCount = allAuthors
|
||||
? allReleases.filter(r => delegateIds.has(r.creator.id)).length
|
||||
: allReleases.length;
|
||||
|
||||
$: releaseCount = repo.payloads["xyz.radicle.project"].meta.releases;
|
||||
// Older nodes report a single count, so their segments go without counters.
|
||||
$: releasesMeta = repo.payloads["xyz.radicle.project"].meta.releases;
|
||||
$: counts = typeof releasesMeta === "object" ? releasesMeta : undefined;
|
||||
|
||||
const api = new HttpdClient(baseUrl);
|
||||
|
||||
@@ -54,12 +54,12 @@
|
||||
// doesn't leave a gap behind on retry.
|
||||
const next = page + 1;
|
||||
try {
|
||||
const response = await api.repo.getAllReleases(repo.rid, {
|
||||
allAuthors,
|
||||
page: next,
|
||||
perPage: RELEASES_PER_PAGE,
|
||||
});
|
||||
allReleases = [...allReleases, ...response];
|
||||
const response = await api.repo.getAllReleases(
|
||||
repo.rid,
|
||||
releasesQuery(scope, next),
|
||||
);
|
||||
allReleases = [...allReleases, ...scopeReleases(response, repo, scope)];
|
||||
morePages = response.length === RELEASES_PER_PAGE;
|
||||
page = next;
|
||||
} catch (e) {
|
||||
error = e;
|
||||
@@ -69,10 +69,9 @@
|
||||
}
|
||||
|
||||
// The count for the current filter isn't in repo metadata, so offer "More"
|
||||
// whenever every loaded page came back full; a short page means we've reached
|
||||
// the end.
|
||||
$: showMoreButton =
|
||||
!loading && !error && allReleases.length === (page + 1) * RELEASES_PER_PAGE;
|
||||
// while the last page came back full; a short page means we've reached the
|
||||
// end.
|
||||
$: showMoreButton = !loading && !error && morePages;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -83,6 +82,10 @@
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.warning {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.counter {
|
||||
border-radius: var(--border-radius-sm);
|
||||
background-color: var(--color-surface-mid);
|
||||
@@ -141,16 +144,20 @@
|
||||
<div class="header">
|
||||
<Link
|
||||
route={{ resource: "repo.releases", repo: repoId, node: baseUrl }}>
|
||||
<Button let:hover variant={!allAuthors ? "gray" : "background"}>
|
||||
<Button
|
||||
let:hover
|
||||
variant={scope === "trusted" ? "gray" : "background"}>
|
||||
<Icon name="badge" />
|
||||
<div class="title-counter">
|
||||
Delegates
|
||||
<span
|
||||
class="counter"
|
||||
class:selected={!allAuthors}
|
||||
class:hover={hover && allAuthors}>
|
||||
{delegateReleaseCount}{showMoreButton ? "+" : ""}
|
||||
</span>
|
||||
{#if counts}
|
||||
<span
|
||||
class="counter"
|
||||
class:selected={scope === "trusted"}
|
||||
class:hover={hover && scope !== "trusted"}>
|
||||
{counts.delegate}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -159,18 +166,21 @@
|
||||
resource: "repo.releases",
|
||||
repo: repoId,
|
||||
node: baseUrl,
|
||||
allAuthors: true,
|
||||
scope: "untrusted",
|
||||
}}>
|
||||
<Button let:hover variant={allAuthors ? "gray" : "background"}>
|
||||
<Button
|
||||
let:hover
|
||||
title="Non-delegates"
|
||||
variant={scope === "untrusted" ? "gray" : "background"}>
|
||||
<Icon name="avatar-incognito" />
|
||||
<div class="title-counter">
|
||||
All
|
||||
{#if releaseCount !== undefined}
|
||||
Others
|
||||
{#if counts}
|
||||
<span
|
||||
class="counter"
|
||||
class:selected={allAuthors}
|
||||
class:hover={hover && !allAuthors}>
|
||||
{releaseCount}
|
||||
class:selected={scope === "untrusted"}
|
||||
class:hover={hover && scope !== "untrusted"}>
|
||||
{counts.other}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -178,6 +188,12 @@
|
||||
</Link>
|
||||
</div>
|
||||
{/if}
|
||||
{#if scope === "untrusted"}
|
||||
<div class="warning">
|
||||
<UntrustedWarning
|
||||
text="Not from delegates. Only download from authors you trust." />
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<List items={allReleases}>
|
||||
@@ -186,7 +202,6 @@
|
||||
let:item
|
||||
{baseUrl}
|
||||
{repoId}
|
||||
{allAuthors}
|
||||
{delegateIds}
|
||||
release={item} />
|
||||
</List>
|
||||
@@ -203,9 +218,11 @@
|
||||
<div class="placeholder">
|
||||
<Placeholder
|
||||
iconName="parcel"
|
||||
caption={showFilters && !allAuthors
|
||||
? "No releases by delegates"
|
||||
: "No releases"} />
|
||||
caption={!showFilters
|
||||
? "No releases"
|
||||
: scope === "trusted"
|
||||
? "No releases by delegates"
|
||||
: "No releases by others"} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
PatchState,
|
||||
PeerRefs,
|
||||
Release,
|
||||
ReleaseScope,
|
||||
Remote,
|
||||
Repo,
|
||||
Revision,
|
||||
@@ -111,7 +112,7 @@ interface RepoReleasesRoute {
|
||||
resource: "repo.releases";
|
||||
node: BaseUrl;
|
||||
repo: string;
|
||||
allAuthors?: boolean;
|
||||
scope?: ReleaseScope;
|
||||
}
|
||||
|
||||
interface RepoReleaseRoute {
|
||||
@@ -119,7 +120,7 @@ interface RepoReleaseRoute {
|
||||
node: BaseUrl;
|
||||
repo: string;
|
||||
release: string;
|
||||
allAuthors?: boolean;
|
||||
scope?: ReleaseScope;
|
||||
}
|
||||
|
||||
interface RepoIssueRoute {
|
||||
@@ -284,7 +285,8 @@ export type RepoLoadedRoute =
|
||||
repo: Repo;
|
||||
repoId: string;
|
||||
releases: Release[];
|
||||
allAuthors: boolean;
|
||||
lastPageFull: boolean;
|
||||
scope: ReleaseScope;
|
||||
showFilters: boolean;
|
||||
nodeId: string;
|
||||
nodeAvatarUrl: string | undefined;
|
||||
@@ -297,7 +299,7 @@ export type RepoLoadedRoute =
|
||||
repo: Repo;
|
||||
repoId: string;
|
||||
release: Release;
|
||||
allAuthors: boolean;
|
||||
scope?: ReleaseScope;
|
||||
nodeId: string;
|
||||
nodeAvatarUrl: string | undefined;
|
||||
};
|
||||
@@ -504,81 +506,77 @@ function releasesNotSupported(node: BaseUrl): NotFoundRoute {
|
||||
};
|
||||
}
|
||||
|
||||
// The query for a page of releases in one author scope. Older nodes ignore
|
||||
// `scope`, so ask them for every author too when the scope is untrusted.
|
||||
export function releasesQuery(scope: ReleaseScope, page: number) {
|
||||
return {
|
||||
scope,
|
||||
allAuthors: scope === "untrusted" ? true : undefined,
|
||||
page,
|
||||
perPage: RELEASES_PER_PAGE,
|
||||
};
|
||||
}
|
||||
|
||||
// Keep only the releases in the author scope. A newer node has done this
|
||||
// already; an older one answers the untrusted scope with every author. Its pages
|
||||
// then come back short, so page on the length of the response, not of this.
|
||||
export function scopeReleases(
|
||||
releases: Release[],
|
||||
repo: Repo,
|
||||
scope: ReleaseScope,
|
||||
): Release[] {
|
||||
if (scope === "trusted") {
|
||||
return releases;
|
||||
}
|
||||
const delegateIds = new Set(repo.delegates.map(d => d.id));
|
||||
return releases.filter(r => !delegateIds.has(r.creator.id));
|
||||
}
|
||||
|
||||
async function loadReleasesView(
|
||||
route: RepoReleasesRoute,
|
||||
): Promise<RepoLoadedRoute | NotFoundRoute> {
|
||||
const api = new HttpdClient(route.node);
|
||||
const allAuthors = route.allAuthors || false;
|
||||
// Open on delegates even when they have no releases, so untrusted content
|
||||
// shows only when asked for.
|
||||
const scope = route.scope ?? "trusted";
|
||||
|
||||
// Fetch releases alongside the repo so the common path stays parallel; the
|
||||
// repo's `meta.releases` then tells us whether a failure is an unsupported
|
||||
// node or a genuine error. The delegate scope is a subset of every author,
|
||||
// so the all-authors view sizes both scopes from its own page, while the
|
||||
// delegate view has to ask for the other scope to size it.
|
||||
const [repo, releasesResult, everyAuthorPage, node] = await Promise.all([
|
||||
// node or a genuine error.
|
||||
const [repo, releasesResult, node] = await Promise.all([
|
||||
api.repo.getByRid(route.repo),
|
||||
api.repo
|
||||
.getAllReleases(route.repo, {
|
||||
allAuthors,
|
||||
page: 0,
|
||||
perPage: RELEASES_PER_PAGE,
|
||||
})
|
||||
.then(
|
||||
releases => ({ releases }),
|
||||
(error: unknown) => ({ error }),
|
||||
),
|
||||
allAuthors
|
||||
? undefined
|
||||
: api.repo
|
||||
.getAllReleases(route.repo, {
|
||||
allAuthors: true,
|
||||
page: 0,
|
||||
perPage: RELEASES_PER_PAGE,
|
||||
})
|
||||
// Only the filter rides on this, so a failure here shouldn't take
|
||||
// the page down with it.
|
||||
.catch(() => undefined),
|
||||
api.repo.getAllReleases(route.repo, releasesQuery(scope, 0)).then(
|
||||
releases => ({ releases }),
|
||||
(error: unknown) => ({ error }),
|
||||
),
|
||||
api.getNode(),
|
||||
]);
|
||||
|
||||
const releaseCount = repo.payloads["xyz.radicle.project"].meta.releases;
|
||||
if (releaseCount === undefined) {
|
||||
// Older nodes report a single count here, newer ones the buckets, and
|
||||
// either way its absence means no release API.
|
||||
const releasesMeta = repo.payloads["xyz.radicle.project"].meta.releases;
|
||||
if (releasesMeta === undefined) {
|
||||
return releasesNotSupported(route.node);
|
||||
}
|
||||
if ("error" in releasesResult) {
|
||||
throw releasesResult.error;
|
||||
}
|
||||
const page = releasesResult.releases;
|
||||
|
||||
const releases = releasesResult.releases;
|
||||
|
||||
// Offer the author filter only where the two scopes hold different releases,
|
||||
// so a repo whose releases are all by delegates carries no filter with an
|
||||
// identical list behind it, and one with none of its own still leaves a way
|
||||
// through to the others. Both sizes come from a list the endpoint returned:
|
||||
// `meta.releases` counts every release COB, including ones the endpoint
|
||||
// hides such as fully redacted releases, so it would overcount either scope.
|
||||
// A full page means the list may go on, so keep the filter rather than have
|
||||
// it appear or vanish as the user pages, and keep it too if the other scope
|
||||
// failed to load. Decided here so it stays fixed for the life of the route.
|
||||
const delegateIds = new Set(repo.delegates.map(d => d.id));
|
||||
const delegateReleaseCount = allAuthors
|
||||
? releases.filter(r => delegateIds.has(r.creator.id)).length
|
||||
: releases.length;
|
||||
const everyAuthorCount = allAuthors
|
||||
? releases.length
|
||||
: everyAuthorPage?.length;
|
||||
// Others is one click away whenever it holds releases.
|
||||
const showFilters =
|
||||
everyAuthorCount === undefined ||
|
||||
everyAuthorCount === RELEASES_PER_PAGE ||
|
||||
delegateReleaseCount !== everyAuthorCount;
|
||||
typeof releasesMeta !== "number"
|
||||
? releasesMeta.other > 0
|
||||
: scope === "untrusted" || (await legacyHasOthers(api, route.repo, repo));
|
||||
|
||||
return {
|
||||
resource: "repo.releases",
|
||||
params: {
|
||||
baseUrl: route.node,
|
||||
repoId: route.repo,
|
||||
releases,
|
||||
allAuthors,
|
||||
releases: scopeReleases(page, repo, scope),
|
||||
lastPageFull: page.length === RELEASES_PER_PAGE,
|
||||
scope,
|
||||
showFilters,
|
||||
repo,
|
||||
nodeId: node.id,
|
||||
@@ -587,11 +585,29 @@ async function loadReleasesView(
|
||||
};
|
||||
}
|
||||
|
||||
// Older nodes report a single release count without the delegate and
|
||||
// non-delegate buckets, so look for others in the first page of every author.
|
||||
// A full page means others may come later, and only the filter depends on
|
||||
// this, so a full page or a failed fetch keeps the filter. Remove this once
|
||||
// nodes without the buckets are no longer supported.
|
||||
async function legacyHasOthers(
|
||||
api: HttpdClient,
|
||||
repoId: string,
|
||||
repo: Repo,
|
||||
): Promise<boolean> {
|
||||
const everyAuthor = await api.repo
|
||||
.getAllReleases(repoId, releasesQuery("untrusted", 0))
|
||||
.catch(() => undefined);
|
||||
if (everyAuthor === undefined || everyAuthor.length === RELEASES_PER_PAGE) {
|
||||
return true;
|
||||
}
|
||||
return scopeReleases(everyAuthor, repo, "untrusted").length > 0;
|
||||
}
|
||||
|
||||
async function loadReleaseView(
|
||||
route: RepoReleaseRoute,
|
||||
): Promise<RepoLoadedRoute | NotFoundRoute> {
|
||||
const api = new HttpdClient(route.node);
|
||||
const allAuthors = route.allAuthors || false;
|
||||
|
||||
const [repo, releaseResult, node] = await Promise.all([
|
||||
api.repo.getByRid(route.repo),
|
||||
@@ -616,7 +632,7 @@ async function loadReleaseView(
|
||||
repoId: route.repo,
|
||||
repo,
|
||||
release: releaseResult.release,
|
||||
allAuthors,
|
||||
scope: route.scope,
|
||||
nodeId: node.id,
|
||||
nodeAvatarUrl: node.avatarUrl,
|
||||
},
|
||||
@@ -1153,23 +1169,25 @@ export function resolveRepoRoute(
|
||||
return resolvePatchesRoute(node, repo, segments, urlSearch);
|
||||
} else if (content === "releases") {
|
||||
const release = segments.shift();
|
||||
const allAuthors =
|
||||
new URLSearchParams(sanitizeQueryString(urlSearch)).get("allAuthors") ===
|
||||
"true";
|
||||
const scope =
|
||||
new URLSearchParams(sanitizeQueryString(urlSearch)).get("scope") ===
|
||||
"untrusted"
|
||||
? "untrusted"
|
||||
: undefined;
|
||||
if (release) {
|
||||
return {
|
||||
resource: "repo.release",
|
||||
node,
|
||||
repo,
|
||||
release,
|
||||
allAuthors,
|
||||
scope,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
resource: "repo.releases",
|
||||
node,
|
||||
repo,
|
||||
allAuthors,
|
||||
scope,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
@@ -1295,14 +1313,14 @@ export function repoRouteToPath(route: RepoRoute): string {
|
||||
return patchRouteToPath(route);
|
||||
} else if (route.resource === "repo.releases") {
|
||||
let url = [...pathSegments, "releases"].join("/");
|
||||
if (route.allAuthors) {
|
||||
url += "?allAuthors=true";
|
||||
if (route.scope === "untrusted") {
|
||||
url += "?scope=untrusted";
|
||||
}
|
||||
return url;
|
||||
} else if (route.resource === "repo.release") {
|
||||
let url = [...pathSegments, "releases", route.release].join("/");
|
||||
if (route.allAuthors) {
|
||||
url += "?allAuthors=true";
|
||||
if (route.scope === "untrusted") {
|
||||
url += "?scope=untrusted";
|
||||
}
|
||||
return url;
|
||||
} else {
|
||||
|
||||
@@ -182,12 +182,16 @@ test.describe(() => {
|
||||
await page.goto(`${peer.uiUrl()}/${rid}/releases/${releaseId}`);
|
||||
await expect(page.getByText("delegate-artifact")).toBeVisible();
|
||||
await expect(page.getByText("eve-artifact")).toBeHidden();
|
||||
await expect(page.getByText("Not from delegates.")).toBeHidden();
|
||||
|
||||
await page.getByRole("link", { name: "All 2" }).click();
|
||||
// The scopes don't overlap: others shows only Eve's artifact.
|
||||
await page.getByRole("link", { name: "Others 1" }).click();
|
||||
await expect(page.getByText("Not from delegates.")).toBeVisible();
|
||||
await expect(page).toHaveURL(
|
||||
`${peer.uiUrl()}/${rid}/releases/${releaseId}?allAuthors=true`,
|
||||
`${peer.uiUrl()}/${rid}/releases/${releaseId}?scope=untrusted`,
|
||||
);
|
||||
await expect(page.getByText("eve-artifact")).toBeVisible();
|
||||
await expect(page.getByText("delegate-artifact")).toBeHidden();
|
||||
|
||||
await page.getByRole("link", { name: "Delegates 1" }).click();
|
||||
await expect(page).toHaveURL(
|
||||
@@ -195,4 +199,40 @@ test.describe(() => {
|
||||
);
|
||||
await expect(page.getByText("eve-artifact")).toBeHidden();
|
||||
});
|
||||
|
||||
test("open others' release on delegate artifacts", async ({
|
||||
page,
|
||||
peer,
|
||||
peerManager,
|
||||
}) => {
|
||||
const name = "others-release";
|
||||
const { rid, repoFolder } = await createRepo(peer, { name });
|
||||
const { contributor, repoFolder: contributorFolder } =
|
||||
await createContributor(peerManager, peer, { rid, repoName: name });
|
||||
const { releaseId } = await registerArtifact(
|
||||
contributor,
|
||||
contributorFolder,
|
||||
{ name: "eve-artifact" },
|
||||
);
|
||||
await syncFrom(peer, repoFolder);
|
||||
await registerArtifact(peer, repoFolder, {
|
||||
name: "delegate-artifact",
|
||||
release: releaseId,
|
||||
});
|
||||
|
||||
await page.goto(`${peer.uiUrl()}/${rid}/releases?scope=untrusted`);
|
||||
await page.locator(`a[href$="/releases/${releaseId}"]`).click();
|
||||
await expect(page).toHaveURL(
|
||||
`${peer.uiUrl()}/${rid}/releases/${releaseId}`,
|
||||
);
|
||||
await expect(page.getByText("Not from a delegate.")).toBeVisible();
|
||||
await expect(page.getByText("delegate-artifact")).toBeVisible();
|
||||
await expect(page.getByText("eve-artifact")).toBeHidden();
|
||||
|
||||
// The breadcrumb leads back to the others' releases.
|
||||
await page.locator('a[href$="/releases?scope=untrusted"]').click();
|
||||
await expect(page).toHaveURL(
|
||||
`${peer.uiUrl()}/${rid}/releases?scope=untrusted`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,11 +58,12 @@ test.describe(() => {
|
||||
await expect(page.locator(".release-teaser")).toHaveCount(1);
|
||||
await expect(page.getByText("eve commit")).toBeHidden();
|
||||
|
||||
await page.getByRole("link", { name: "All 2" }).click();
|
||||
// The scopes don't overlap: others shows only Eve's release.
|
||||
await page.getByRole("link", { name: "Others 1" }).click();
|
||||
await expect(page).toHaveURL(
|
||||
`${peer.uiUrl()}/${rid}/releases?allAuthors=true`,
|
||||
`${peer.uiUrl()}/${rid}/releases?scope=untrusted`,
|
||||
);
|
||||
await expect(page.locator(".release-teaser")).toHaveCount(2);
|
||||
await expect(page.locator(".release-teaser")).toHaveCount(1);
|
||||
await expect(page.getByText("eve commit")).toBeVisible();
|
||||
|
||||
await page.getByRole("link", { name: "Delegates 1" }).click();
|
||||
@@ -70,6 +71,41 @@ test.describe(() => {
|
||||
await expect(page.locator(".release-teaser")).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("open on delegates when only others released", async ({
|
||||
page,
|
||||
peer,
|
||||
peerManager,
|
||||
}) => {
|
||||
const name = "release-others-only";
|
||||
const { rid, repoFolder } = await createRepo(peer, { name });
|
||||
|
||||
const { contributor, repoFolder: contributorFolder } =
|
||||
await createContributor(peerManager, peer, { rid, repoName: name });
|
||||
await contributor.git(
|
||||
["commit", "--allow-empty", "--message", "eve commit"],
|
||||
{ cwd: contributorFolder },
|
||||
);
|
||||
await contributor.git(["push", "rad", "HEAD:refs/heads/eve"], {
|
||||
cwd: contributorFolder,
|
||||
});
|
||||
const { stdout: eveHead } = await contributor.git(["rev-parse", "HEAD"], {
|
||||
cwd: contributorFolder,
|
||||
});
|
||||
await registerArtifact(contributor, contributorFolder, {
|
||||
name: "eve-binary",
|
||||
revision: eveHead,
|
||||
});
|
||||
await syncFrom(peer, repoFolder);
|
||||
|
||||
// Untrusted content shows only when asked for, even with nothing else.
|
||||
await page.goto(`${peer.uiUrl()}/${rid}/releases`);
|
||||
await expect(page.getByText("No releases by delegates")).toBeVisible();
|
||||
await expect(page.getByText("eve commit")).toBeHidden();
|
||||
|
||||
await page.getByRole("link", { name: "Others 1" }).click();
|
||||
await expect(page.getByText("eve commit")).toBeVisible();
|
||||
});
|
||||
|
||||
test("empty release listing", async ({ page, peer }) => {
|
||||
const { rid } = await createRepo(peer, { name: "no-releases" });
|
||||
|
||||
@@ -97,9 +133,10 @@ test.describe(() => {
|
||||
await page.goto(`${peer.uiUrl()}/${rid}/releases`);
|
||||
await expect(page.getByText("No releases", { exact: true })).toBeVisible();
|
||||
|
||||
// The tab counter comes from the repo metadata, which counts every release
|
||||
// COB, redacted or not.
|
||||
await expect(page.getByRole("link", { name: "Releases 1" })).toBeVisible();
|
||||
// The tab counter comes from the repo metadata, which counts only the
|
||||
// releases left with an unredacted artifact, so it agrees with the empty
|
||||
// listing. The tab itself stays, since the node does serve releases.
|
||||
await expect(page.getByRole("link", { name: "Releases 0" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { Artifact } from "@http-client";
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
artifactView,
|
||||
attestedBy,
|
||||
redactedByTrusted,
|
||||
releaseWarning,
|
||||
} from "@app/lib/releases";
|
||||
|
||||
const delegate = "did:key:z6MkDelegate";
|
||||
const other = "did:key:z6MkOther";
|
||||
const delegates = new Set([delegate]);
|
||||
|
||||
function artifact(
|
||||
cid: string,
|
||||
author: string,
|
||||
redactedBy: string[] = [],
|
||||
): Artifact {
|
||||
return {
|
||||
cid,
|
||||
author: { id: author },
|
||||
redactions: redactedBy.map(id => ({ user: { id } })),
|
||||
} as Artifact;
|
||||
}
|
||||
|
||||
describe("redactedByTrusted", () => {
|
||||
test.each([
|
||||
{ by: [delegate], author: other, expected: true },
|
||||
{ by: [other], author: other, expected: true },
|
||||
{ by: [other], author: delegate, expected: false },
|
||||
{ by: [], author: other, expected: false },
|
||||
])("redacted by $by for $author => $expected", ({ by, author, expected }) => {
|
||||
expect(redactedByTrusted(artifact("a", author, by), delegates)).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("artifactView", () => {
|
||||
const ours = artifact("ours", delegate);
|
||||
const ourRedacted = artifact("ours-redacted", delegate, [delegate]);
|
||||
const theirs = artifact("theirs", other);
|
||||
|
||||
test("splits artifacts into disjoint scopes", () => {
|
||||
const all = [ours, theirs];
|
||||
expect(artifactView(all, delegates, "trusted", false).shown).toEqual([
|
||||
ours,
|
||||
]);
|
||||
expect(artifactView(all, delegates, "untrusted", false).shown).toEqual([
|
||||
theirs,
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps the asked scope when it is empty", () => {
|
||||
const view = artifactView([theirs], delegates, "trusted", false);
|
||||
expect(view.shown).toEqual([]);
|
||||
expect(view.showFilters).toBe(true);
|
||||
});
|
||||
|
||||
test("hides the filter without artifacts by others", () => {
|
||||
const view = artifactView([ours], delegates, "trusted", false);
|
||||
expect(view.showFilters).toBe(false);
|
||||
});
|
||||
|
||||
test("hides redacted artifacts unless asked", () => {
|
||||
const all = [ours, ourRedacted];
|
||||
expect(artifactView(all, delegates, "trusted", false).shown).toEqual([
|
||||
ours,
|
||||
]);
|
||||
expect(artifactView(all, delegates, "trusted", true).shown).toEqual(all);
|
||||
});
|
||||
|
||||
test("counts redacted artifacts in the current scope only", () => {
|
||||
const all = [ourRedacted, artifact("x", other, [other])];
|
||||
expect(artifactView(all, delegates, "trusted", false).redactedCount).toBe(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("counts only what each scope would show", () => {
|
||||
const view = artifactView(
|
||||
[ours, ourRedacted, theirs],
|
||||
delegates,
|
||||
"trusted",
|
||||
false,
|
||||
);
|
||||
expect(view.counts).toEqual({ trusted: 1, untrusted: 1 });
|
||||
});
|
||||
|
||||
test("keeps the filter when only redacted artifacts by others exist", () => {
|
||||
const view = artifactView(
|
||||
[ours, artifact("x", other, [other])],
|
||||
delegates,
|
||||
"trusted",
|
||||
false,
|
||||
);
|
||||
expect(view.showFilters).toBe(true);
|
||||
});
|
||||
|
||||
test("handles a release without artifacts", () => {
|
||||
const view = artifactView([], delegates, "trusted", false);
|
||||
expect(view).toEqual({
|
||||
shown: [],
|
||||
redactedCount: 0,
|
||||
counts: { trusted: 0, untrusted: 0 },
|
||||
showFilters: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("attestedBy", () => {
|
||||
test("counts only delegate attestations", () => {
|
||||
expect(attestedBy([{ id: delegate }, { id: other }], delegates)).toBe(1);
|
||||
});
|
||||
|
||||
test("counts nothing without attestations", () => {
|
||||
expect(attestedBy([], delegates)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("releaseWarning", () => {
|
||||
test.each(["trusted" as const, "untrusted" as const])(
|
||||
"warns about a release by a non-delegate in the %s scope",
|
||||
scope => {
|
||||
expect(releaseWarning(other, delegates, scope)).toBe("release");
|
||||
},
|
||||
);
|
||||
|
||||
test("warns about others' artifacts on a delegate's release", () => {
|
||||
expect(releaseWarning(delegate, delegates, "untrusted")).toBe("artifacts");
|
||||
});
|
||||
|
||||
test("trusts a delegate's release with delegate artifacts", () => {
|
||||
expect(releaseWarning(delegate, delegates, "trusted")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -110,6 +110,33 @@ describe("route invariant when parsed", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("repos.releases", () => {
|
||||
expectParsingInvariant({
|
||||
resource: "repo.releases",
|
||||
node,
|
||||
repo: "REPO",
|
||||
});
|
||||
});
|
||||
|
||||
test("repos.releases with untrusted scope", () => {
|
||||
expectParsingInvariant({
|
||||
resource: "repo.releases",
|
||||
node,
|
||||
repo: "REPO",
|
||||
scope: "untrusted",
|
||||
});
|
||||
});
|
||||
|
||||
test("repos.release with untrusted scope", () => {
|
||||
expectParsingInvariant({
|
||||
resource: "repo.release",
|
||||
node,
|
||||
repo: "REPO",
|
||||
release: "RELEASE",
|
||||
scope: "untrusted",
|
||||
});
|
||||
});
|
||||
|
||||
test("repos.issues with status", () => {
|
||||
expectParsingInvariant({
|
||||
resource: "repo.issues",
|
||||
|
||||
Reference in New Issue
Block a user