~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

A repository's page shows its website, and what it was deployed from

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:web/repo-site
crates/knotless/src/site.rs+17−3
@@ -114,6 +114,15 @@ pub fn did_label(did: &str) -> Option<&str> {
did.strip_prefix("did:plc:")
}
/// The label a repository's owner is served at, by `name`, their handle
/// or their DID, and their `did`: a handle's, unless it has none, then
/// their plc id. None for an owner with neither.
pub fn owner_label(name: &str, did: &str) -> Option<String> {
let named = Some(name).filter(|n| !n.starts_with("did:"));
let named = named.and_then(label);
named.or_else(|| did_label(did).map(String::from))
}
/// The address of `name`'s site, its owner at `label`.
pub fn address(label: &str, name: &str) -> String {
format!("https://{label}.{DOMAIN}/{name}/")
@@ -227,9 +236,7 @@ fn served_at(url: &RepoUrl, remote: &Remote, key: &str) -> Result<String> {
let none = format!("{url}: a private site's owner is named by a did:plc");
return label.map(|l| private_address(&l)).context(none);
}
let owner = Some(url.authority.as_str()).filter(|a| !a.starts_with("did:"));
let named = owner.and_then(label);
let label = named.or_else(|| did_label(remote.did()).map(String::from));
let label = owner_label(&url.authority, remote.did());
let served = label.map(|label| address(&label, &url.name));
served.context(format!("{url}: its owner has no name a site is served at"))
}
@@ -383,6 +390,13 @@ mod tests {
}
assert_eq!(did_label("did:plc:abc"), Some("abc"));
assert_eq!(did_label("did:web:example.com"), None);
// An owner's label: their handle's, else their plc id.
let long = format!("{}.com", "a".repeat(60));
let owner = |name: &str, did: &str| owner_label(name, did);
assert_eq!(owner("a.b", "did:plc:abc").as_deref(), Some("a-b"));
assert_eq!(owner(&long, "did:plc:abc").as_deref(), Some("abc"));
assert_eq!(owner("did:plc:abc", "did:plc:abc").as_deref(), Some("abc"));
assert_eq!(owner(&long, "did:web:example.com"), None);
assert_eq!(address("a-b", "site"), "https://a-b.knotless.page/site/");
// A private site's host, and back.
let (plc, key) = ("abcdefghijklmnopqrstuvwx", "3lbqz7ywmz22k");
crates/knotless-serve/src/web/views.rs+15−0
@@ -49,9 +49,24 @@ pub struct RepoViewDetailed {
/// How many may push to it besides its owner: none, absent.
#[serde(skip_serializing_if = "Option::is_none")]
pub collaborator_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub site: Option<SiteView>,
}
/// `dev.knotless.defs#repoViewerState`.
/// A repository's website: where it is served, and what it was deployed
/// from.
#[derive(Serialize, Clone, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SiteView {
pub url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub check: Option<String>,
pub deployed_at: String,
}
#[derive(Serialize, Clone, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct RepoViewerState {
crates/knotless-serve/src/lib.rs+4−0
@@ -81,6 +81,9 @@ pub struct App {
/// How long, in milliseconds, a viewer's reading of a private
/// repository is taken as allowed before its space is asked again.
pub reprove: std::sync::atomic::AtomicU64,
/// The domain repositories' sites are served under, as `knotless.page`,
/// once a [`sites::Sites`] serves them: their pages show the address.
pub pages_domain: std::sync::OnceLock<String>,
/// Firehose events seen per DID.
events: Mutex<HashMap<String, u64>>,
/// Of those, the ones that could change any of the account's
@@ -243,6 +246,7 @@ impl App {
repos: Mutex::default(),
viewed: Mutex::default(),
reprove: std::sync::atomic::AtomicU64::new(private::REPROVE.as_millis() as u64),
pages_domain: std::sync::OnceLock::new(),
events: Mutex::default(),
account_events: Mutex::default(),
repo_events: Mutex::default(),
crates/knotless-serve/src/sites.rs+1−0
@@ -275,6 +275,7 @@ impl Sites {
/// The sites `app` indexes, answered as labels of `domain`, their
/// owners sent from its root to their page at `home`.
pub fn new(app: Arc<App>, domain: String, home: String) -> Arc<Sites> {
app.pages_domain.get_or_init(|| domain.clone());
Arc::new(Sites {
app,
domain,
crates/knotless-serve/src/web.rs+40−2
@@ -28,7 +28,7 @@ use knotless::profile;
use knotless::read;
use knotless::records::{
Attachment, CheckOutput, CheckRecord, CheckStep, Freeing, ImageAttachment, MinutesRecord,
ProfileRecord, PullHead, RepoRecord, RepoRef, nsid,
ProfileRecord, PullHead, RepoRecord, RepoRef, SiteRecord, nsid,
};
use knotless::remote::Remote;
use knotless::session;
@@ -1535,9 +1535,12 @@ impl Repo {
.take(COLLABORATORS_SHOWN)
.map(|did| actor(app, did));
let collaborators = futures::future::join_all(shown).await;
let owner = actor(app, &self.did).await;
let site = self.site(app, &owner.handle, record.as_ref());
RepoViewDetailed {
uri: self.uri(),
owner: actor(app, &self.did).await,
owner,
site,
name: self.name.clone(),
key: record.as_ref().map(|r| r.key.clone()),
description: record.as_ref().and_then(|r| r.description.clone()),
@@ -1561,6 +1564,41 @@ impl Repo {
}
}
/// Its website, as the index last saw its record, where this server
/// serves sites: its address, by its owner's `handle` or plc id, and
/// what it was deployed from. A check counts only from a runner its
/// `record` names.
fn site(&self, app: &App, handle: &str, record: Option<&RepoRecord>) -> Option<SiteView> {
let domain = app.pages_domain.get()?;
let index = indexed(app, self)?;
let value = match self.in_space.as_deref() {
Some(space) => index.space_site(space, &self.did, &self.key),
None => index.site(&self.did, &self.key),
};
let site: SiteRecord = serde_json::from_str(&value.ok()??).ok()?;
let url = match self.in_space {
Some(_) => {
let label = knotless::site::private_label(&self.did, &self.key)?;
format!("https://{label}.{}.{domain}/", knotless::site::PRIVATE)
}
None => {
let label = knotless::site::owner_label(handle, &self.did)?;
format!("https://{label}.{domain}/{}/", self.name)
}
};
let runners = record.map(|r| r.runners.as_slice()).unwrap_or_default();
let by_runner = |uri: &String| {
let runner = knotless::aturi::AtUri::parse(uri).ok().map(|at| at.author);
runners.iter().any(|r| Some(&r.did) == runner.as_ref())
};
Some(SiteView {
url,
commit: site.commit,
check: site.check.filter(by_runner),
deployed_at: site.created_at,
})
}
/// Who `record` names among its collaborators, in its order, besides
/// its owner: not its organization's members, whom its page names.
fn pushers(&self, record: Option<&RepoRecord>) -> Vec<String> {
crates/knotless-serve/tests/pages.rs+7−0
@@ -1365,6 +1365,13 @@ fn private_sites_are_read_by_members() {
// A member logged in is given a ticket for that host alone.
let theirs = log_in(&server, &member.account);
// Their page of its repository shows it.
let repo = format!("at://{}/dev.knotless.repo/hush", owner.account.did);
let page = format!("{}?repo={repo}", server.xrpc("getRepo"));
wait_until(|| {
let (status, _, answer) = browse("GET", &page, Some(&theirs), None);
status == 200 && answer["site"]["url"] == address
});
let given = visit(&server, "localhost", tickets, Some(&theirs));
assert_eq!(given.status, 302);
let enter = format!("http://{host}/.knotless/enter?ticket=");
crates/knotless-serve/tests/sites.rs+43−2
@@ -81,6 +81,20 @@ fn sites_are_served_on_their_domain() {
let by_did = format!("{plc}.{DOMAIN}");
wait_until(|| get(&base, &host, "/site/", &[]).0 == 200);
// Its repository's pages name it, with nothing said of what it was
// deployed from; a repository with none names none.
let shown = |name: &str| -> serde_json::Value {
let repo = format!("at://{did}/dev.knotless.repo/{name}");
let asked = format!("{base}/xrpc/dev.knotless.getRepo?repo={repo}");
let answer: serde_json::Value = reqwest::blocking::get(asked).unwrap().json().unwrap();
answer["site"].clone()
};
let site = shown("site");
assert_eq!(site["url"], format!("https://{host}/site/"), "{site}");
assert!(site["deployedAt"].is_string(), "{site}");
assert!(site.get("commit").is_none() && site.get("check").is_none());
assert!(shown("bare").is_null());
// A page, as its record says, with its hash as its ETag.
let (status, headers, body) = get(&base, &host, "/site/", &[]);
assert_eq!((status, body.as_str()), (200, "<h1>home</h1>"));
@@ -144,9 +158,35 @@ fn sites_are_served_on_their_domain() {
// Deployed again as a single-page app: its routes are its page, and a
// script still missing is not.
write("index.html", b"<h1>app</h1>");
let again = ["repo", "site", &url, &dir, "--fallback", "index.html"];
let sha = "19fa13fcd6bd147877c93f9b1ac81a68b884d733";
let runner = "did:plc:runnerofsitestest2345abc";
let check = format!("at://{runner}/dev.knotless.check/built");
let again = [
"repo",
"site",
&url,
&dir,
"--fallback",
"index.html",
"--commit",
sha,
"--check",
&check,
];
assert_eq!(s.cli(&again, b"").0, 0);
wait_until(|| get(&base, &host, "/site/", &[]).2 == "<h1>app</h1>");
// Its commit as given, and its check only once the repository names
// the runner it is from.
wait_until(|| shown("site")["commit"] == sha);
assert!(shown("site").get("check").is_none());
let (key, mut repo) = s
.records("dev.knotless.repo")
.into_iter()
.find(|(_, r)| r["name"] == "site")
.unwrap();
repo["runners"] = serde_json::json!([{ "did": runner }]);
s.put_record("dev.knotless.repo", &key, repo);
wait_until(|| shown("site")["check"] == check.as_str());
let (status, _, body) = get(&base, &host, "/site/settings/me", &[]);
assert_eq!((status, body.as_str()), (200, "<h1>app</h1>"));
assert_eq!(get(&base, &host, "/site/gone.js", &[]).0, 404);
@@ -175,7 +215,8 @@ fn sites_are_served_on_their_domain() {
wait_until(|| get(&base, &host, "/site/", &[]).0 == 200);
}
// Taken down by its owner, it is gone.
// Taken down by its owner, it is gone, and its pages name none.
assert_eq!(s.cli(&["repo", "site", &url, "--down"], b"").0, 0);
wait_until(|| get(&base, &host, "/site/", &[]).0 == 404);
wait_until(|| shown("site").is_null());
}
docs/guide/sites.md+5−0
@@ -153,6 +153,11 @@ deployed:
knotless repo site knotless://alice.example.com/blog
```
The repository's page shows the site too, under its description: its
address, the commit it was deployed from, and when. You don't need to add
the address to the repository's links. A private repository's site is
shown only to its members.
There is no history of deploys. To roll back, deploy an older commit's
build again.
docs/reference/api.md+1−1
@@ -563,7 +563,7 @@ name it has now.
| `dev.knotless.listRepos?q=` | repositories matching `q` (above) |
| `dev.knotless.searchOwners?q=` | the owners of repositories whose handle holds `q` (above) |
| `dev.knotless.getActorRepos?actor=` | an account's public repositories, each with the `language` most of it is written in: from the index while a feed is followed, else as its PDS lists them; to the account itself, asking, its private ones too, each `private`, and to a member asking those it was proven a member of; asked as the account itself, apart, as `shared`, the private repositories of others it was proven a member of |
| `dev.knotless.getRepo?repo=` | a repository: its owner, description, `links` (only http and https ones), `topics`, default branch, counts, languages, `collaborators` (the first twenty its record names, besides its owner) and `collaboratorCount`, `private` if it is, and `archivedAt` if its owner archived it; with an asker, `viewer`: its `star`, a `transfer` offered to it, the `labels` it may put on the repository's pulls and issues, and `admin: true` when it may change the repository's settings, as its owner or an admin who joined the organization that owns it |
| `dev.knotless.getRepo?repo=` | a repository: its owner, description, `links` (only http and https ones), `topics`, default branch, counts, languages, `collaborators` (the first twenty its record names, besides its owner) and `collaboratorCount`, `private` if it is, `archivedAt` if its owner archived it, and `site` if it has a website this server serves: its `url`, `deployedAt`, the `commit` its deployer named, and its `check` only when that check is from a runner the repository names; with an asker, `viewer`: its `star`, a `transfer` offered to it, the `labels` it may put on the repository's pulls and issues, and `admin: true` when it may change the repository's settings, as its owner or an admin who joined the organization that owns it |
| `dev.knotless.listReleases?repo=&package=&limit=&cursor=` | its releases ([packages.md](../design/packages.md#on-the-pages)), newest first, 30 a page by default, of one `package` if given: each with `latest` when it is its package's latest, `image` when its files are an OCI layout's, its notes as Markdown and as `html`, and its files' names, sizes and SHA-256; and every package it publishes, by name, with its latest version, how many releases it has and whether it is an image. None for a private repository or without an index |
| `dev.knotless.getTree?repo=&ref=&path=` | a folder, its README and the newest checks of its last commit |
| `dev.knotless.listPaths?repo=&ref=&limit=` | every folder and file at a revision, the shallowest first, at most 50,000, and `truncated` when the deepest were left out: for Go to file |
lexicons/dev/knotless/defs.json+13−1
@@ -129,7 +129,19 @@
"archivedAt": { "type": "string", "format": "datetime", "description": "When its owner archived it, making it read only. Absent when it is not archived." },
"topics": { "type": "array", "items": { "type": "ref", "ref": "dev.knotless.repo#topic" }, "description": "What it is about, as its record names them, in its order." },
"collaborators": { "type": "array", "items": { "type": "ref", "ref": "#actorView" }, "description": "The first twenty accounts that may push to it besides its owner, as its record names them." },
"collaboratorCount": { "type": "integer", "minimum": 0, "description": "How many accounts may push to it besides its owner: more than `collaborators` holds when the list was cut short." }
"collaboratorCount": { "type": "integer", "minimum": 0, "description": "How many accounts may push to it besides its owner: more than `collaborators` holds when the list was cut short." },
"site": { "type": "ref", "ref": "#siteView", "description": "Its website, where this appview serves sites. Absent when it has none, or it is taken down." }
}
},
"siteView": {
"type": "object",
"description": "A repository's website, as its dev.knotless.site record was last indexed.",
"required": ["url", "deployedAt"],
"properties": {
"url": { "type": "string", "format": "uri", "description": "Where it is served: under its owner's label of the pages domain, or, for a private repository, at a host of its own." },
"commit": { "type": "string", "description": "The hex object ID of the commit its record says the files were built from, as whoever deployed it said, unchecked." },
"check": { "type": "string", "format": "at-uri", "description": "The dev.knotless.check of the run that built and deployed it, given only when the check is in the account of a runner the repository's record names." },
"deployedAt": { "type": "string", "format": "datetime", "description": "When it was deployed, as its record says." }
}
},
"repoViewerState": {
web/src/lib/molecules/RepoSite.sveltenew+14−0
web/src/lib/organisms/RepoHeader.svelte+9−4
web/src/lib/icons.ts+3−2
web/src/app.css+4−0