~ 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 commits, tags and releases as Atom feeds

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:serve/atom
crates/knotless-serve/src/web/feeds.rsnew+262−0
@@ -0,0 +1,262 @@
//! Atom feeds of a repository, for feed readers: the commits on its
//! default branch, its tags and its releases, newest first.
use super::releases::Published;
use super::*;
use knotless::read::Commit;
use knotless::records::ReleaseRecord;
/// The entries a feed holds.
const ENTRIES: usize = 30;
/// One entry of a feed.
struct Entry {
title: String,
/// What names it for good.
id: String,
/// Its page.
link: String,
updated: String,
author: Option<String>,
/// Text, or with `html` HTML.
content: String,
html: bool,
}
/// `/{authority}/{repo}/commits.atom`: the commits on the default branch.
pub async fn commits(
State(app): State<Arc<App>>,
headers: HeaderMap,
path: RepoPath,
) -> Result<Response> {
read_as_viewer(&app, &headers, made(&app, path, Kind::Commits)).await
}
/// `/{authority}/{repo}/tags.atom`: the tags that name a commit.
pub async fn tags(
State(app): State<Arc<App>>,
headers: HeaderMap,
path: RepoPath,
) -> Result<Response> {
read_as_viewer(&app, &headers, made(&app, path, Kind::Tags)).await
}
/// `/{authority}/{repo}/releases.atom`: each version of each package.
pub async fn releases(
State(app): State<Arc<App>>,
headers: HeaderMap,
path: RepoPath,
) -> Result<Response> {
read_as_viewer(&app, &headers, made(&app, path, Kind::Releases)).await
}
#[derive(Clone, Copy)]
enum Kind {
Commits,
Tags,
Releases,
}
/// `answer` read as whoever the cookie names, as the raw route reads: a
/// private repository's feed is its members' alone, and kept by no cache.
async fn read_as_viewer(
app: &App,
headers: &HeaderMap,
answer: impl Future<Output = Result<Response>>,
) -> Result<Response> {
let viewer = crate::login::viewer(app, headers);
let (made, touched) = crate::private::reading(viewer, None, answer).await;
let mut res = made?;
if touched {
res = crate::private::unshared(res);
}
res.headers_mut()
.append(header::VARY, HeaderValue::from_static("cookie"));
Ok(res)
}
async fn made(app: &App, Path((authority, name)): RepoPath, kind: Kind) -> Result<Response> {
let repo = Repo::open(app, &authority, &name).await?;
let named = format!("{}/{}", repo.authority, repo.name);
let (title, page, file, entries) = match kind {
Kind::Commits => {
let title = format!("Commits to {named}");
let page = repo.href(&format!("/commits/{}", url_path(&repo.head)));
(title, page, "commits", commit_entries(&repo)?)
}
Kind::Tags => (
format!("Tags of {named}"),
repo.href("/tags"),
"tags",
tag_entries(&repo)?,
),
Kind::Releases => {
let entries = release_entries(app, &repo);
(
format!("Releases of {named}"),
repo.href("/releases"),
"releases",
entries,
)
}
};
let feed = repo.href(&format!("/{file}.atom"));
Ok((
[
(header::CONTENT_TYPE, "application/atom+xml; charset=utf-8"),
(header::CACHE_CONTROL, "no-cache"),
],
atom(&title, &absolute(feed), &absolute(page), entries),
)
.into_response())
}
/// The newest commits on the default branch, each by its author, its
/// message as text; none on a repository with nothing pushed.
fn commit_entries(repo: &Repo) -> Result<Vec<Entry>> {
let objects = repo.objects()?;
// HEAD names its branch before anything is pushed to it.
if objects.resolve(&repo.head).is_err() {
return Ok(Vec::new());
}
let log = objects
.log(&repo.head, "", 0, ENTRIES)
.map_err(AppError::internal)?;
Ok(log.into_iter().map(|c| commit_entry(repo, c)).collect())
}
fn commit_entry(repo: &Repo, c: Commit) -> Entry {
let subject = c.message.lines().next().unwrap_or_default().to_string();
let link = absolute(repo.href(&format!("/commit/{}", c.sha)));
Entry {
title: subject,
id: link.clone(),
link,
updated: c.committer.date,
author: Some(c.author.name),
content: c.message,
html: false,
}
}
/// The newest tags, each named by the commit it names, with that commit's
/// subject.
fn tag_entries(repo: &Repo) -> Result<Vec<Entry>> {
let tags = repo.objects()?.tags().map_err(AppError::internal)?;
// Named by the tag, not its commit: two tags of one commit are two
// entries.
let entry = |(name, c): (String, Commit)| Entry {
id: absolute(repo.href(&format!("/tree/{}", url_path(&name)))),
link: absolute(repo.href(&format!("/tree/{}", url_path(&name)))),
title: name,
updated: c.committer.date,
author: Some(c.author.name),
content: c.message.lines().next().unwrap_or_default().to_string(),
html: false,
};
Ok(tags.into_iter().take(ENTRIES).map(entry).collect())
}
/// The newest releases, each named by its record and linked to its
/// package's releases, with its notes as the releases page draws them.
fn release_entries(app: &App, repo: &Repo) -> Vec<Entry> {
let base = repo.href("");
let page = |package: &str| {
let query = format!("/releases?package={}", url_path(package));
absolute(repo.href(&query))
};
let entry = |(uri, r, _): (String, ReleaseRecord, bool)| Entry {
title: format!("{} {}", r.package, r.version),
id: uri,
link: page(&r.package),
updated: r.created_at,
author: None,
content: r
.notes
.as_deref()
.map(|n| markdown(n, &base, &base, "", None))
.unwrap_or_default(),
html: true,
};
let all = Published::of(app, repo).all;
all.into_iter().take(ENTRIES).map(entry).collect()
}
/// An Atom feed, `title`, at `id`, of the page `page`: updated when its
/// newest entry was, or at the epoch when it has none.
fn atom(title: &str, id: &str, page: &str, entries: Vec<Entry>) -> String {
let updated = entries
.first()
.map_or("1970-01-01T00:00:00Z", |e| &e.updated);
let mut out = String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
out += "<feed xmlns=\"http://www.w3.org/2005/Atom\">\n";
let _ = writeln!(out, "<id>{}</id>", xml(id));
let _ = writeln!(out, "<title>{}</title>", xml(title));
let _ = writeln!(out, "<updated>{}</updated>", xml(updated));
let _ = writeln!(out, "<link rel=\"self\" href=\"{}\"/>", xml(id));
let _ = writeln!(
out,
"<link rel=\"alternate\" type=\"text/html\" href=\"{}\"/>",
xml(page)
);
for e in &entries {
out += "<entry>\n";
let _ = writeln!(out, "<id>{}</id>", xml(&e.id));
let _ = writeln!(out, "<title>{}</title>", xml(&e.title));
let _ = writeln!(out, "<updated>{}</updated>", xml(&e.updated));
let _ = writeln!(
out,
"<link rel=\"alternate\" type=\"text/html\" href=\"{}\"/>",
xml(&e.link)
);
let by = e.author.as_deref().unwrap_or(title);
let _ = writeln!(out, "<author><name>{}</name></author>", xml(by));
let kind = if e.html { "html" } else { "text" };
let _ = writeln!(
out,
"<content type=\"{kind}\">{}</content>",
xml(&e.content)
);
out += "</entry>\n";
}
out += "</feed>\n";
out
}
/// `s` as XML text: its markup escaped, and the control characters XML
/// cannot hold left out.
fn xml(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => out += "&amp;",
'<' => out += "&lt;",
'>' => out += "&gt;",
'"' => out += "&quot;",
'\t' | '\n' | '\r' => out.push(c),
c if c < ' ' || c == '\u{fffe}' || c == '\u{ffff}' => {}
c => out.push(c),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn xml_escapes_markup_and_drops_what_it_cannot_hold() {
assert_eq!(
xml("a <b> & \"c\"\u{1}\u{fffe}\u{ffff}\n"),
"a &lt;b&gt; &amp; &quot;c&quot;\n"
);
}
#[test]
fn an_empty_feed_is_dated_at_the_epoch() {
let feed = atom("T", "https://x/f.atom", "https://x/p", Vec::new());
assert!(feed.contains("<updated>1970-01-01T00:00:00Z</updated>"));
assert!(!feed.contains("<entry>"));
}
}
crates/knotless-serve/src/web/releases.rs+3−3
@@ -108,14 +108,14 @@ fn image(r: &ReleaseRecord) -> bool {
/// A repository's releases, newest first, each with whether it is its
/// package's latest, as [`release::latest`] finds it.
struct Published {
all: Vec<(String, ReleaseRecord, bool)>,
pub(super) struct Published {
pub(super) all: Vec<(String, ReleaseRecord, bool)>,
}
impl Published {
/// `repo`'s, as the index has them: none for a private repository,
/// which cannot publish, or without an index.
fn of(app: &App, repo: &Repo) -> Published {
pub(super) fn of(app: &App, repo: &Repo) -> Published {
let index = app.index.as_ref().filter(|_| repo.in_space.is_none());
let found = index.and_then(|i| i.releases(&repo.did, &repo.key).ok());
Published::new(found.unwrap_or_default())
crates/knotless-serve/src/web.rs+4−0
@@ -50,6 +50,7 @@ mod badges;
mod controls;
mod delegation;
mod feed;
mod feeds;
mod follows;
mod items;
mod landing;
@@ -235,6 +236,9 @@ fn landing_routes() -> Router<Arc<App>> {
pub fn routes() -> Router<Arc<App>> {
let compressed = Router::new()
.route("/{authority}/{repo}/raw/{*rest}", get(raw))
.route("/{authority}/{repo}/commits.atom", get(feeds::commits))
.route("/{authority}/{repo}/tags.atom", get(feeds::tags))
.route("/{authority}/{repo}/releases.atom", get(feeds::releases))
.route("/card.png", get(home_card))
.route("/{authority}/{repo}/card.png", get(repo_card))
.route(
crates/knotless-serve/tests/gateway.rs+10−0
@@ -2793,6 +2793,16 @@ fn private_repositories_on_the_pages() {
assert!(zip.2.contains("cookie"), "{}", zip.2);
assert!(zip.3.contains("hidden-main/notes.md"));
assert_eq!(looked(&archive, None).0, 404);
// Its feed, as its files: to members alone, kept by no cache.
let feed = format!(
"{}/{}/hidden/commits.atom",
server.base, owner.account.handle
);
let commits = looked(&feed, theirs);
assert_eq!((commits.0, commits.1.as_str()), (200, "private, no-store"));
assert!(commits.2.contains("cookie"), "{}", commits.2);
assert!(commits.3.contains("<entry>"));
assert_eq!(looked(&feed, None).0, 404);
// A public repository, to the same member, is theirs alone to cache,
// but may be kept.
let open = owner.url("open");
crates/knotless-serve/tests/web.rs+59−0
@@ -615,6 +615,42 @@ fn browsing_a_repository() {
for missing in ["main.rar", "main", "nope.zip", "tree.zip", "-x.zip"] {
assert_eq!(archive(missing).status(), 404, "{missing}");
}
// Its commits and tags as Atom feeds, the newest first, linked to
// their pages in full.
let feed = |name: &str| {
let res = site.get(&format!("{repo}/{name}.atom"));
assert_eq!(res.status(), 200, "{name}");
let kind = res.headers()["content-type"].to_str().unwrap().to_string();
assert_eq!(kind, "application/atom+xml; charset=utf-8");
res.text().unwrap()
};
let commits = feed("commits");
assert_eq!(commits.matches("<entry>").count(), 30);
assert!(commits.contains(&format!("<title>Commits to {h}/browsed</title>")));
assert!(commits.contains(&format!("{}{repo}/commits/main\"", site.base)));
let first = &commits[commits.find("<entry>").unwrap()..];
assert!(first.contains("<title>Lay out the tree</title>"));
assert!(first.contains(&format!(
"<id>{}{repo}/commit/{}</id>",
site.base,
tip.trim()
)));
assert!(first.contains(
"<content type=\"text\">Lay out the tree\n\nWith a body that says why.</content>"
));
let tagged = feed("tags");
assert_eq!(tagged.matches("<entry>").count(), 2);
assert!(tagged.contains("<title>v1</title>"));
assert!(tagged.contains(&format!("href=\"{}{repo}/tree/v2\"", site.base)));
// v1 and v2 tag one commit, and are two entries all the same.
for tag in ["v1", "v2"] {
let id = format!("<id>{}{repo}/tree/{tag}</id>", site.base);
assert!(tagged.contains(&id), "{tag}");
}
// With nothing released, a feed of no entries.
let none = feed("releases");
assert!(!none.contains("<entry>"));
assert!(none.contains("<updated>1970-01-01T00:00:00Z</updated>"));
let log = site.data(&format!("{repo}/commits/main"));
assert_eq!(log["commits"].as_array().unwrap().len(), 50);
@@ -769,6 +805,11 @@ fn browsing_a_repository() {
assert_eq!(history["commits"], serde_json::json!([]));
assert_eq!(history["ref"], "main");
assert_eq!(history.get("cursor"), None);
let feed = site
.get(&format!("/{h}/empty/commits.atom"))
.text()
.unwrap();
assert!(feed.contains("<feed") && !feed.contains("<entry>"));
// Anything else there is not found.
let other = site.get(&at(&format!("/{h}/empty/commits/other")));
assert_eq!(other.status().as_u16(), 404);
@@ -3069,6 +3110,24 @@ fn the_releases() {
let bad = format!("{}&cursor=x", xrpc("listReleases", &[("repo", &repo)]));
assert_eq!(site.status(&bad).0, 400);
// As an Atom feed: each version, named by its record, with its notes
// as HTML, linked to its package's releases.
let feed = site
.get(&format!("/{handle}/shipped/releases.atom"))
.text()
.unwrap();
assert_eq!(feed.matches("<entry>").count(), 4);
assert!(feed.contains("<title>tool 0.1.1</title>"));
assert!(feed.contains(&format!("<id>{}</id>", r["uri"].as_str().unwrap())));
assert!(feed.contains(&format!(
"{}/{handle}/shipped/releases?package=tool\"",
site.base
)));
assert!(feed.contains("<content type=\"html\">&lt;p&gt;See &lt;a href=&quot;"));
assert!(feed.contains(&format!(
"<author><name>Releases of {handle}/shipped</name></author>"
)));
// The repository's pages say, under its name, the newest that is its
// package's latest, and its packages; the one with none says nothing.
let head = site.json(&xrpc("getRepo", &[("repo", &repo)]));
docs/design/access.md+2−2
@@ -265,8 +265,8 @@ sequenceDiagram
own, and those they were proven a member of.
Only queries asked by someone read as them: the queries carrying the
session cookie, a service token or a delegation token, and the raw file
and archive routes. Cards, clones,
session cookie, a service token or a delegation token, and the raw file,
archive and feed routes. Cards, clones,
the object API, and refreshes the server starts on its own read as no one,
so they only ever find public repositories. Anything private never reaches
the appview index, so feeds, search, stars and activity never show it.
docs/design/serve.md+7−0
@@ -265,6 +265,13 @@ Details:
turn. One still writing after ten minutes, as for a client that stalls,
is cut short, so that slow clients cannot keep the rest waiting. One at
a commit's hash never changes, so it is cached for good.
- A repository has three Atom feeds, for feed readers: `commits.atom`, the
newest 30 commits on its default branch; `tags.atom`, its newest 30 tags
that name a commit; and `releases.atom`, its newest 30 releases, with
their notes drawn as the releases page draws them. The commits, tags and
releases pages name theirs in a `<link rel="alternate">`, where readers
look. Each is read as the viewer, so a private repository's feed is its
members', and a reader with no session finds nothing.
## The appview index
docs/reference/api.md+2−1
@@ -38,7 +38,7 @@ A repository no one may read publicly is read as the asker, with the
session they logged in to the pages with: a member of it reads it as a
public one, and anyone else, asker or not, gets the 404 `NotFound` a
repository never made gets ([access.md](../design/access.md#browsing)).
`GET /<owner>/<name>/raw/<rev>/<path>` and `/archive/<rev>.zip` read as the cookie's account too,
`GET /<owner>/<name>/raw/<rev>/<path>`, `/archive/<rev>.zip` and the `.atom` feeds read as the cookie's account too,
and varies by cookie; the cards, git and `/api/` routes read as no one.
A query may instead prove its asker a member with a delegation token,
@@ -745,6 +745,7 @@ they are, never as a page. Served beside them, not as queries:
| --- | --- |
| `/<authority>/<repo>/raw/<rev>/<path>` | a file's bytes |
| `/<authority>/<repo>/archive/<rev>.tar.gz`, `.zip` | the files at a branch, tag or commit as one archive, as `git archive` writes it, in a folder `<repo>-<rev>` (each character but a letter, a digit, `.`, `_` and `-` made `-`), `Content-Disposition: attachment` with that name and the ending; `Cache-Control` immutable at a commit's full hash, else `no-cache`; not compressed again. Another ending, or a revision naming no commit, is 404 |
| `/<authority>/<repo>/commits.atom`, `tags.atom`, `releases.atom` | Atom feeds, `application/atom+xml`, of the newest 30 commits on the default branch (each by its author, its message as text, its id its commit page), tags naming a commit (its id and link the tree at the tag, so two tags of one commit are two entries) and releases (its id its record's URI, its notes as HTML, its link the package's releases); with no entries, one dated at the epoch |
| `/<authority>/<repo>/releases/download/<package>/<version>/<file>` | a file of a release the index has, `application/octet-stream`, `Content-Disposition: attachment` with its name, `Content-Length`, its SHA-256 as `ETag` (a matching `If-None-Match` is 304), read once from the owner's PDS, checked against its record and kept, as `/releases/` keeps its own ([packages.md](../design/packages.md#on-the-pages)); not compressed. One the index does not have, taken down, or of a private repository is 404, and one that cannot be read 502 |
| `/card.png`, `/<authority>/<repo>/card.png`, `/<authority>/<repo>/<pull or issue>/<did>/<rkey>/card.png` | social cards, 1200×630 PNG |
docs/development.md+1−1
@@ -71,7 +71,7 @@ knotless-serve.
`http://127.0.0.1:2590`).
- The app's `handleFetch` sends its own-origin `/xrpc` fetches straight to `KNOTLESS_SERVE`, since knotless-serve can share the
site's origin, which Kit would answer itself.
- `vite dev` proxies `/xrpc`, `/<authority>/<repo>/raw/` and `/archive/` to
- `vite dev` proxies `/xrpc`, `/<authority>/<repo>/raw/`, `/archive/` and the `.atom` feeds to
`KNOTLESS_SERVE`.
- An element is marked `data-drawing` until its diagrams are drawn, and
the root gets `data-ready` once mounted. Screenshots wait for both.
web/src/lib/atoms/Meta.svelte+3−2
@@ -3,9 +3,9 @@
import { counted } from '#lib/notifications.svelte.ts';
/**
* A page's title, with how many notifications are new in the tab, and what it says of itself to whatever unfurls a link
* to it: what it is about, and the card it unfurls into, by its path.
* to it: what it is about, and the card it unfurls into, by its path; and the Atom `feed` of what it lists, for feed readers to find.
*/
let { title, about, card }: { title: string; about?: string | null; card?: string } = $props();
let { title, about, card, feed }: { title: string; about?: string | null; card?: string; feed?: { title: string; href: string } } = $props();
</script>
<svelte:head>
@@ -22,4 +22,5 @@
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
{/if}
{#if feed}<link rel="alternate" type="application/atom+xml" title={feed.title} href={feed.href} />{/if}
</svelte:head>
web/src/lib/templates/RepoPage.svelte+6−3
web/src/routes/[authority]/[repo]/commits/[...rest]/+page.svelte+1−1
web/src/routes/[authority]/[repo]/releases/+page.svelte+1−1
web/src/routes/[authority]/[repo]/tags/+page.svelte+1−1
web/vite.config.ts+1−1