~ 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

Private repositories have sites, read by their members alone

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:serve/private-sites
.changeset/private-sites.mdnew+7−0
@@ -0,0 +1,7 @@
---
default: minor
---
# Private repositories have sites
`knotless repo site` now deploys a private repository's site. It is kept in the repository's space and read only by its members, at a host of its own, `https://<repo key>-<plc id>.private.knotless.page/`. A member who opens it logs in at knotless.dev if they are not logged in already. Someone who is not a member gets a 404, and a member you remove stops reading it within a minute.
crates/knotless/src/oauth.rs+1−1
@@ -1000,7 +1000,7 @@ mod tests {
// The PDS grants each type's collections, so they go unnamed.
assert!(!SCOPE.contains("collection="));
assert!(SCOPE.ends_with(" space:dev.knotless.space?authority=*&manage=create&manage=update&manage=delete space:dev.knotless.orgSpace?authority=*&manage=create&manage=update&manage=delete"));
assert!(SPELLED_OUT.contains("&collection=dev.knotless.rerun&collection=dev.knotless.orgMember&manage=create&manage=update&manage=delete"));
assert!(SPELLED_OUT.contains("&collection=dev.knotless.rerun&collection=dev.knotless.orgMember&collection=dev.knotless.site&manage=create&manage=update&manage=delete"));
assert!(SPELLED_OUT.ends_with(" space:dev.knotless.orgSpace?authority=*&collection=dev.knotless.orgMember&collection=dev.knotless.orgAcceptance&manage=create&manage=update&manage=delete"));
assert!(SCOPE.contains(" repo:dev.knotless.pull "));
assert!(SCOPE.contains(" repo:dev.knotless.check "));
crates/knotless/src/site.rs+78−13
@@ -1,7 +1,9 @@
//! Sites: a repository's static website, its files over blobs in its
//! owner's account under one record keyed by the repository's key,
//! replaced on each deploy, and served at `<owner>.knotless.page/<repo>/`
//! (see docs/design/pages.md).
//! replaced on each deploy, and served at `<owner>.knotless.page/<repo>/`;
//! a private repository's in its space, at a host of its own,
//! `<key>-<plc id>.private.knotless.page`, to its members alone (see
//! docs/design/pages.md).
use crate::env::Ctx;
use crate::push;
@@ -117,6 +119,35 @@ pub fn address(label: &str, name: &str) -> String {
format!("https://{label}.{DOMAIN}/{name}/")
}
/// The label under [`DOMAIN`] that private sites' hosts are each a label
/// of, kept apart from owners' labels so none shares an origin.
pub const PRIVATE: &str = "private";
/// The label of the host `did`'s private repository with `key` has its
/// site at: the key, then the owner's plc id. None for an owner with no
/// plc id. A key is a TID and a plc id has no `-`, so the label is read
/// back whole ([`private_site`]).
pub fn private_label(did: &str, key: &str) -> Option<String> {
did_label(did).map(|plc| format!("{key}-{plc}"))
}
/// The owner and repository key a private site's `label` names, as
/// [`private_label`] wrote it.
pub fn private_site(label: &str) -> Option<(String, String)> {
let (key, plc) = label.split_once('-')?;
let base32 = |s: &str| {
s.bytes()
.all(|b| b.is_ascii_lowercase() || (b'2'..=b'7').contains(&b))
};
let named = key.len() == 13 && plc.len() == 24 && base32(key) && base32(plc);
named.then(|| (format!("did:plc:{plc}"), key.to_string()))
}
/// The address of the private site at `label`.
pub fn private_address(label: &str) -> String {
format!("https://{label}.{PRIVATE}.{DOMAIN}/")
}
/// Whether `path` is a site's file's: `/`-separated, with no leading `/`,
/// no empty, `.` or `..` segment, and at most [`MAX_PATH`] bytes.
pub fn valid_path(path: &str) -> bool {
@@ -177,12 +208,10 @@ pub struct Deployed {
pub address: String,
}
/// `url`'s repository, public and found, with its key.
/// `url`'s repository, found, with its key: a private one's site is
/// kept in its space, as its packs are.
async fn open(ctx: &Ctx, url: &RepoUrl) -> Result<(Remote, String)> {
let remote = Remote::open(ctx, url).await?;
if remote.space.is_some() {
bail!("{url}: a private repository's site is not published yet");
}
let Some(repo) = remote.repo().await? else {
bail!("{url}: no such repository");
};
@@ -190,8 +219,14 @@ async fn open(ctx: &Ctx, url: &RepoUrl) -> Result<(Remote, String)> {
}
/// Where `url`'s site is served: at its owner's handle, as `url` names
/// them, or else at the DID of the account `remote` found.
fn served_at(url: &RepoUrl, remote: &Remote) -> Result<String> {
/// them, or else at the DID of the account `remote` found; a private
/// one's, at its own host, named by `key` and that DID.
fn served_at(url: &RepoUrl, remote: &Remote, key: &str) -> Result<String> {
if remote.space.is_some() {
let label = private_label(remote.did(), key);
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));
@@ -223,7 +258,7 @@ pub async fn deploy(ctx: &Ctx, url: &RepoUrl, spec: Deploy) -> Result<Deployed>
}
}
let (remote, key) = open(ctx, url).await?;
let address = served_at(url, &remote)?;
let address = served_at(url, &remote, &key)?;
let live = remote.get_record::<SiteRecord>(nsid::SITE, &key).await?;
let auth = session::ensure(ctx, &remote.identity).await?.auth();
let mut held: HashMap<String, Vec<ReleasePart>> = HashMap::new();
@@ -270,6 +305,7 @@ pub async fn deploy(ctx: &Ctx, url: &RepoUrl, spec: Deploy) -> Result<Deployed>
None => remote::create(nsid::SITE, &key, typed),
};
remote.apply(vec![write], &auth).await?;
shown(ctx, &remote, url).await;
Ok(Deployed {
uploaded,
files: count,
@@ -289,15 +325,25 @@ pub async fn take_down(ctx: &Ctx, url: &RepoUrl) -> Result<()> {
bail!("{url} has no site");
}
let auth = session::ensure(ctx, &remote.identity).await?.auth();
remote
.apply(vec![remote::delete(nsid::SITE, &key)], &auth)
.await
let deleted = remote::delete(nsid::SITE, &key);
remote.apply(vec![deleted], &auth).await?;
shown(ctx, &remote, url).await;
Ok(())
}
/// Shows the appview a private repository whose site changed, as nothing
/// written in a space reaches the firehose: its site is served from what
/// the appview syncs of it.
async fn shown(ctx: &Ctx, remote: &Remote, url: &RepoUrl) {
if remote.space.is_some() {
crate::space::show_appview(ctx, remote.did(), &url.name).await;
}
}
/// `url`'s live site, and where it is served, if it has one.
pub async fn read(ctx: &Ctx, url: &RepoUrl) -> Result<Option<(SiteRecord, String)>> {
let (remote, key) = open(ctx, url).await?;
let address = served_at(url, &remote)?;
let address = served_at(url, &remote, &key)?;
let live = remote.get_record::<SiteRecord>(nsid::SITE, &key).await?;
Ok(live.map(|l| (l.value, address)))
}
@@ -338,6 +384,25 @@ mod tests {
assert_eq!(did_label("did:plc:abc"), Some("abc"));
assert_eq!(did_label("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");
let did = format!("did:plc:{plc}");
let label = private_label(&did, key).unwrap();
assert_eq!(label, format!("{key}-{plc}"));
assert_eq!(private_site(&label), Some((did, key.to_string())));
assert_eq!(
private_address(&label),
format!("https://{label}.private.knotless.page/")
);
assert_eq!(private_label("did:web:example.com", key), None);
for not in [
"chadtmiller-com",
"short-abcdefghijklmnopqrstuvwx",
&format!("{key}-{plc}x"),
"3LBQZ7YWMZ22K-abcdefghijklmnopqrstuvwx",
] {
assert_eq!(private_site(not), None, "{not}");
}
}
#[test]
crates/knotless/tests/cli.rs+4−2
@@ -1696,8 +1696,10 @@ fn sites() {
if net().spaces {
let hidden = s.url("hidden");
assert_eq!(s.cli(&["repo", "create", &hidden, "--private"], b"").0, 0);
let (ok, _, err) = s.knotless(&["repo", "site", &hidden, &dir], b"");
assert!(!ok && err.contains("private repository's site"), "{err}");
// A private repository's site has a host of its own, under private.
let (ok, out, err) = s.knotless(&["repo", "site", &hidden, &dir], b"");
assert!(ok, "{err}");
assert!(out.contains(".private.knotless.page/"), "{out}");
}
}
crates/knotless-serve/src/index.rs+48−11
@@ -1263,13 +1263,14 @@ impl Index {
],
)
} else if collection == nsid::SITE {
// A public repository's site, keyed by the repository's key:
// one that is not a site's record is passed by, as is one in a
// space, which has no site yet, and one with a file whose hash
// is not one, as the hash names the file it is kept in.
// A repository's site, keyed by the repository's key, and a
// private one's by its space too, as its URI names it: one that
// is not a site's record is passed by, as is one with a file
// whose hash is not one, as the hash names the file it is kept
// in.
let site = serde_json::from_value::<SiteRecord>(value.clone());
let hashed = |s: SiteRecord| s.files.iter().all(|f| valid_sha256(&f.sha256));
let kept = site.is_ok_and(hashed) && space.is_empty();
let kept = site.is_ok_and(hashed);
db.execute(
"INSERT OR REPLACE INTO site SELECT ?1, ?2, ?3, ?4 WHERE ?5",
params![uri, did, rkey, text, kept],
@@ -1632,12 +1633,40 @@ impl Index {
/// The site of `did`'s repository `key`, as its record was last
/// indexed: none when it has none, or it is taken down.
pub fn site(&self, did: &str, key: &str) -> rusqlite::Result<Option<String>> {
self.site_at(&format!("at://{did}/{}/{key}", nsid::SITE))
}
/// The site of `did`'s private repository `key`, in the space `space`,
/// as [`Index::site`].
pub fn space_site(
&self,
space: &str,
did: &str,
key: &str,
) -> rusqlite::Result<Option<String>> {
self.site_at(&record_uri(space, did, nsid::SITE, key))
}
/// The private repository `did` owns with `key`, by its name: none
/// for one this index keeps no space of.
pub fn private_repo(&self, did: &str, key: &str) -> rusqlite::Result<Option<String>> {
let db = self.readers.take(&self.db);
let mut stmt =
db.prepare_cached("SELECT uri, name FROM repo WHERE did = ?1 AND key = ?2")?;
let rows = stmt.query_map([did, key], uri_and_name)?;
let private =
|(uri, _): &(String, String)| AtUri::parse(uri).is_ok_and(|at| at.space.is_some());
let found = rows.filter_map(Result::ok).find(private);
Ok(found.map(|(_, name)| name))
}
/// The site whose record is `uri`, as [`Index::site`].
fn site_at(&self, uri: &str) -> rusqlite::Result<Option<String>> {
let db = self.readers.take(&self.db);
let uri = format!("at://{did}/{}/{key}", nsid::SITE);
let mut stmt = db.prepare_cached(
"SELECT value FROM site WHERE uri = ?1 AND uri NOT IN down AND did NOT IN down",
)?;
stmt.query_row([&uri], |row| row.get(0)).optional()
stmt.query_row([uri], |row| row.get(0)).optional()
}
/// How many accounts starred `did`'s repository with `key`, leaving
@@ -4282,6 +4311,11 @@ fn org_rows(
}
}
/// A row's first two columns, a repository's URI and its name.
fn uri_and_name(row: &rusqlite::Row) -> rusqlite::Result<(String, String)> {
Ok((row.get(0)?, row.get(1)?))
}
fn secret_row(row: &rusqlite::Row) -> rusqlite::Result<Secret> {
let refs: String = row.get(2)?;
Ok(Secret {
@@ -7093,21 +7127,24 @@ mod tests {
index.put("did:a", nsid::SITE, "k", &site).unwrap();
let kept = index.site("did:a", "k").unwrap().unwrap();
assert_eq!(serde_json::from_str::<Value>(&kept).unwrap(), site);
// Not a site's record, or one in a space: passed by.
// Not a site's record: passed by.
index
.put("did:a", nsid::SITE, "j", &json!({"files": 1}))
.unwrap();
let space = "ats://did:a/dev.knotless.space/s";
assert_eq!(index.site("did:a", "j").unwrap(), None);
// One in a space is its private repository's, found there alone.
let space = "at://did:a/space/dev.knotless.space/s";
index
.put_from_space(space, "did:a", nsid::SITE, "i", &site)
.unwrap();
assert_eq!(index.site("did:a", "j").unwrap(), None);
assert_eq!(index.site("did:a", "i").unwrap(), None);
let private = index.space_site(space, "did:a", "i").unwrap().unwrap();
assert_eq!(serde_json::from_str::<Value>(&private).unwrap(), site);
// A hash that would name a path outside where files are kept.
let out = format!("{:.<64}", "../../../../etc/passwd");
let bad = json!({"files": [file(&out)], "createdAt": "2026-11-03T00:00:00Z"});
index.put("did:a", nsid::SITE, "h", &bad).unwrap();
assert_eq!(index.site("did:a", "h").unwrap(), None);
assert_eq!(index.site("did:a", "i").unwrap(), None);
index.delete("did:a", nsid::SITE, "k").unwrap();
assert_eq!(index.site("did:a", "k").unwrap(), None);
}
crates/knotless-serve/src/login.rs+12−5
@@ -154,13 +154,20 @@ pub fn viewer(app: &App, headers: &HeaderMap) -> Option<String> {
fn held(app: &App, token: &str) -> Option<String> {
let key = oauth::client_key(&app.ctx).ok()?;
let claims: Claims = crypto::verify_jwt(token, &key.public()).ok()?;
let ctx = ctx_for(app, &claims.did);
let held = claims.kind == "web" && holds(app, &claims.did, claims.iat);
held.then_some(claims.did)
}
/// Whether what was signed for `did` at `iat`, in microseconds since the
/// epoch, still holds: they have logged in here since, are not taken
/// down, and have not logged out since it was signed.
pub fn holds(app: &App, did: &str, iat: u64) -> bool {
let ctx = ctx_for(app, did);
let out: u64 = std::fs::read_to_string(ctx.env.config_dir.join(LOGGED_OUT))
.ok()
.and_then(|t| t.trim().parse().ok())
.unwrap_or(0);
let held = claims.kind == "web" && claims.iat > out && !app.taken_down(&claims.did);
(held && session::load(&ctx, &claims.did).is_some()).then_some(claims.did)
iat > out && !app.taken_down(did) && session::load(&ctx, did).is_some()
}
/// The other accounts a request's cookies hold, with their tokens, the one
@@ -203,7 +210,7 @@ fn set_others(gw: &Gateway, others: &[(String, String)]) -> HeaderValue {
}
/// The value of the cookie `name` the request holds.
fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
pub fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
let all = headers.get_all(header::COOKIE).into_iter();
let pairs = all
.filter_map(|v| v.to_str().ok())
@@ -292,7 +299,7 @@ fn cookie_for(gw: &Gateway, cookie: &str) -> HeaderValue {
}
/// A page to go back to: a path of this site, else its front page.
fn back_to(back: Option<&str>) -> String {
pub fn back_to(back: Option<&str>) -> String {
match back {
Some(b) if b.starts_with('/') && !b.starts_with("//") && !b.contains('\\') => b.into(),
_ => "/".into(),
crates/knotless-serve/src/sites.rs+371−30
401 lines changed: not loaded until asked for.
crates/knotless-serve/src/spaces.rs+3−1
@@ -45,7 +45,7 @@ use std::time::{Duration, Instant};
/// indexes them from the firehose for public ones; and an organization's
/// records, as its space and its private repositories' spaces keep them,
/// naming its invitations and hidden members.
const COLLECTIONS: [&str; 18] = [
const COLLECTIONS: [&str; 19] = [
nsid::REPO,
nsid::REDIRECT,
nsid::REF,
@@ -64,6 +64,8 @@ const COLLECTIONS: [&str; 18] = [
nsid::ORG,
nsid::ORG_MEMBER,
nsid::ORG_ACCEPTANCE,
// A private repository's site, served to its members.
nsid::SITE,
];
/// How long a space may go unsynced before a member reading it syncs it
crates/knotless-serve/tests/pages.rs+230−1
@@ -7,6 +7,7 @@ mod common;
use common::*;
use knotless_serve::gateway::Gateway;
use knotless_serve::sites::{Sites, hosted};
use knotless_serve::{App, router};
use serde_json::{Value, json};
use std::sync::Arc;
@@ -46,7 +47,9 @@ fn server() -> Server {
let did = gw.did.clone();
let index = knotless_serve::index::Index::open(&s.path("index.db")).unwrap();
let app = App::build(ctx(&s), Duration::ZERO, Some(gw), Some(index));
let router = router(app.clone());
// Repositories' sites, on their domain, as any host the proxy says.
let sites = Sites::new(app.clone(), "knotless.page".into(), format!("{base}/"));
let router = hosted(router(app.clone()), sites);
rt.spawn(async move { axum::serve(listener, router).await.unwrap() });
let firehose = net().pds.replace("http://", "ws://");
rt.spawn(knotless_serve::firehose::watch(app.clone(), firehose));
@@ -1248,3 +1251,229 @@ fn an_organization_from_the_pages() {
assert_eq!(viewer(o).get("organization"), None);
assert_eq!(roster(None)["organization"], false);
}
/// What a request to `server` as the site at `host` got: its status, where
/// it goes, the cookie it sets, how it may be cached, and its body.
struct Visit {
status: u16,
location: String,
cookie: Option<String>,
cache: String,
body: String,
}
fn visit(server: &Server, host: &str, path: &str, cookie: Option<&str>) -> Visit {
let client = reqwest::blocking::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let mut req = client.get(format!("{}{path}", server.base));
req = req.header("x-forwarded-host", host);
if let Some(cookie) = cookie {
req = req.header("cookie", cookie);
}
let res = req.send().unwrap();
let header = |name: &str| {
let value = res.headers().get(name);
value.map(|v| v.to_str().unwrap().to_string())
};
let location = header("location").unwrap_or_default();
let cookie = header("set-cookie").map(|c| c.split(';').next().unwrap().to_string());
let cache = header("cache-control").unwrap_or_default();
Visit {
status: res.status().as_u16(),
location,
cookie,
cache,
body: res.text().unwrap(),
}
}
/// A private repository's site, deployed into its space and read by its
/// members alone: sent from its host to the pages for a ticket, which
/// they are given once logged in and proven a member, and let in by the
/// pass the host trades it for. A stranger is given none; a member
/// removed, or logged out, reads it no more.
#[test]
fn private_sites_are_read_by_members() {
if !net().spaces {
return;
}
let server = server();
let mut owner = person(account());
// The appview hears of a deploy, as nothing in a space reaches the
// firehose.
owner.set("KNOTLESS_APPVIEW", &server.base);
let member = person(account());
let stranger = person(account());
let url = owner.url("hush");
assert_eq!(owner.cli(&["repo", "create", &url, "--private"], b"").0, 0);
let work = owner.work("w", 1);
owner.git(&work, &["push", "-q", &url, "main"]);
let share = |undo: bool| {
let mut share = vec!["repo", "share", &url, &member.account.handle];
if undo {
share.push("--undo");
}
owner.cli(&share, b"").0
};
assert_eq!(share(false), 0);
let dist = owner.path("dist");
std::fs::create_dir_all(dist.join("docs")).unwrap();
std::fs::write(dist.join("index.html"), b"<h1>hushed</h1>").unwrap();
std::fs::write(dist.join("docs/index.html"), b"docs").unwrap();
let dir = dist.to_string_lossy().into_owned();
let (code, out) = owner.cli(&["repo", "site", &url, &dir], b"");
assert_eq!(code, 0, "{out}");
// At a host of its own, named by its key and its owner's plc id.
let address = out.lines().last().unwrap();
let host = address
.strip_prefix("https://")
.and_then(|a| a.strip_suffix("/"))
.unwrap()
.to_string();
let plc = owner.account.did.strip_prefix("did:plc:").unwrap();
assert!(
host.ends_with(&format!("-{plc}.private.knotless.page")),
"{host}"
);
// Its record is in the space, not among the owner's public ones.
assert!(owner.records("dev.knotless.site").is_empty());
let (_, live) = owner.cli(&["repo", "site", &url], b"");
assert!(live.starts_with(&format!("{address}\t2 files\t")), "{live}");
// No pass: sent to the pages for a ticket, back to the page asked.
let first = visit(&server, &host, "/docs/?x=1", None);
assert_eq!(first.status, 302);
assert_eq!(
first.location,
format!(
"{}/oauth/site?host={host}&to=%2Fdocs%2F%3Fx%3D1",
server.base
)
);
assert_eq!(first.cache, "private, no-store");
let tickets = first.location.strip_prefix(&server.base).unwrap();
// Logged in as no one, the pages ask for a login, and come back.
let asked = visit(&server, "localhost", tickets, None);
assert_eq!(asked.status, 302);
assert!(
asked.location.starts_with("/?login=%2Foauth%2Fsite%3F"),
"{}",
asked.location
);
// A member logged in is given a ticket for that host alone.
let theirs = log_in(&server, &member.account);
let given = visit(&server, "localhost", tickets, Some(&theirs));
assert_eq!(given.status, 302);
let enter = format!("http://{host}/.knotless/enter?ticket=");
assert!(given.location.starts_with(&enter), "{}", given.location);
let enter = given
.location
.strip_prefix(&format!("http://{host}"))
.unwrap();
assert!(enter.ends_with("&to=%2Fdocs%2F%3Fx%3D1"), "{enter}");
// Not taken on another site's host.
let other_key = if host.starts_with('2') { "3" } else { "2" };
let elsewhere = format!("{other_key}{}", &host[1..]);
let wrong = visit(&server, &elsewhere, enter, None);
assert!(
wrong.location.contains("/oauth/site?"),
"{}",
wrong.location
);
assert_eq!(wrong.cookie, None);
// Traded on its own for a pass, once.
let entered = visit(&server, &host, enter, None);
assert_eq!(
(entered.status, entered.location.as_str()),
(302, "/docs/?x=1")
);
let pass = entered.cookie.unwrap();
assert!(pass.starts_with("knotless_site="), "{pass}");
let again = visit(&server, &host, enter, None);
assert!(
again.location.contains("/oauth/site?"),
"{}",
again.location
);
assert_eq!(again.cookie, None);
// With it, the site, for them alone.
let page = visit(&server, &host, "/", Some(&pass));
assert_eq!((page.status, page.body.as_str()), (200, "<h1>hushed</h1>"));
assert_eq!(page.cache, "private, no-store");
assert_eq!(visit(&server, &host, "/docs/", Some(&pass)).body, "docs");
let moved = visit(&server, &host, "/docs", Some(&pass));
assert_eq!((moved.status, moved.location.as_str()), (302, "docs/"));
assert_eq!(visit(&server, &host, "/gone.js", Some(&pass)).status, 404);
assert_eq!(visit(&server, &host, "/%ff", Some(&pass)).status, 404);
// The pass is the host's alone.
let other = visit(&server, &elsewhere, "/", Some(&pass));
assert!(
other.location.contains("/oauth/site?"),
"{}",
other.location
);
// A stranger logged in is given no ticket.
let strangers = log_in(&server, &stranger.account);
let refused = visit(&server, "localhost", tickets, Some(&strangers));
assert_eq!(refused.status, 404);
// Nor is a host that names no private site's.
let odd = "/oauth/site?host=nobody.knotless.page&to=%2F";
assert_eq!(visit(&server, "localhost", odd, Some(&theirs)).status, 404);
// Removed, the member reads it no more once membership is asked
// again; shared again, they do.
assert_eq!(share(true), 0);
server
.app
.reprove
.store(0, std::sync::atomic::Ordering::Relaxed);
assert_eq!(visit(&server, &host, "/", Some(&pass)).status, 404);
assert_eq!(
visit(&server, "localhost", tickets, Some(&theirs)).status,
404
);
assert_eq!(share(false), 0);
assert_eq!(visit(&server, &host, "/", Some(&pass)).status, 200);
// Redeployed, the new site is read once the space is synced, as
// following it hears of the write.
let space = knotless::space::uri(&owner.account.did, "hush");
let index = server.app.spaces.clone().unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let synced = || {
let c = ctx(&owner);
rt.block_on(knotless_serve::spaces::sync(&c, &index, &space))
.unwrap();
};
std::fs::write(dist.join("index.html"), b"<h1>again</h1>").unwrap();
assert_eq!(owner.cli(&["repo", "site", &url, &dir], b"").0, 0);
synced();
assert_eq!(
visit(&server, &host, "/", Some(&pass)).body,
"<h1>again</h1>"
);
// Logged out, their pass no longer holds.
let logout = format!("{}/oauth/logout", server.base);
assert_eq!(browse("POST", &logout, Some(&theirs), None).0, 200);
let out = visit(&server, &host, "/", Some(&pass));
assert!(out.location.contains("/oauth/site?"), "{}", out.location);
// Taken down by its owner, it is gone.
let back = log_in(&server, &member.account);
let given = visit(&server, "localhost", tickets, Some(&back));
let enter = given
.location
.strip_prefix(&format!("http://{host}"))
.unwrap();
let pass = visit(&server, &host, enter, None).cookie.unwrap();
assert_eq!(visit(&server, &host, "/", Some(&pass)).status, 200);
assert_eq!(owner.cli(&["repo", "site", &url, "--down"], b"").0, 0);
synced();
assert_eq!(visit(&server, &host, "/", Some(&pass)).status, 404);
}
deploy/README.md+5−3
@@ -152,8 +152,10 @@ knotless.dev {
With `KNOTLESS_PAGES_DOMAIN` (`knotless.page`), the proxy answers
repositories' sites too ([../docs/design/pages.md](../docs/design/pages.md)):
the domain and every owner's label under it, on one certificate from the
DNS challenge. That takes Caddy built with the `caddy-dns/cloudflare`
the domain and every owner's label under it, and each private site's
label under `private.` there, on wildcard certificates from the DNS
challenge. The zone's `*` record covers `*.private` too, as nothing is
at `private` itself. That takes Caddy built with the `caddy-dns/cloudflare`
module, which `setup.sh` builds from a Dockerfile beside the compose file,
and the zone's own Cloudflare token, Zone:Read and DNS:Edit on that zone
alone. Give the token once, as `KNOTLESS_PAGES_DNS_TOKEN` in
@@ -162,7 +164,7 @@ alone. Give the token once, as `KNOTLESS_PAGES_DNS_TOKEN` in
A run with nothing changed rebuilds and recreates nothing.
```caddy
knotless.page, *.knotless.page {
knotless.page, *.knotless.page, *.private.knotless.page {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
deploy/setup.sh+5−4
@@ -17,9 +17,10 @@
# - Caddy, in /etc/knotless-proxy, with a certificate for the domain from
# HTTP-01 once its records point here, and www redirected to it;
# - with KNOTLESS_PAGES_DOMAIN (knotless.page), repositories' sites there
# too: Caddy built with the caddy-dns/cloudflare module, holding one
# certificate for the domain and every name one label under it from the
# DNS challenge, with the zone's own token, KNOTLESS_PAGES_DNS_TOKEN
# too: Caddy built with the caddy-dns/cloudflare module, holding
# certificates for the domain, every name one label under it, and every
# private site's one label under private.<domain>, from the DNS
# challenge, with the zone's own token, KNOTLESS_PAGES_DNS_TOKEN
# (Zone:Read and DNS:Edit on that zone alone). The token is given once,
# kept in /etc/knotless-proxy/cloudflare.env: a run without it keeps the
# one there;
@@ -107,7 +108,7 @@ if [ -n "$PAGES" ]; then
}
cat >>/etc/knotless-proxy/Caddyfile <<EOF
$PAGES, *.$PAGES {
$PAGES, *.$PAGES, *.private.$PAGES {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
docs/design/pages.md+63−24
docs/design/serve.md+6−2
docs/guide/sites.md+20−3
docs/reference/api.md+1−0
docs/reference/cli.md+1−1
lexicons/dev/knotless/space.json+3−2
web/src/lib/organisms/LoginDialog.svelte+12−1