chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A repository's page shows its website, and what it was deployed from
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": { | ||