//! 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, /// 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>, headers: HeaderMap, path: RepoPath, ) -> Result { 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>, headers: HeaderMap, path: RepoPath, ) -> Result { 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>, headers: HeaderMap, path: RepoPath, ) -> Result { 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>, ) -> Result { 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 { 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> { 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> { 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 { 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) -> String { let updated = entries .first() .map_or("1970-01-01T00:00:00Z", |e| &e.updated); let mut out = String::from("\n"); out += "\n"; let _ = writeln!(out, "{}", xml(id)); let _ = writeln!(out, "{}", xml(title)); let _ = writeln!(out, "{}", xml(updated)); let _ = writeln!(out, "", xml(id)); let _ = writeln!( out, "", xml(page) ); for e in &entries { out += "\n"; let _ = writeln!(out, "{}", xml(&e.id)); let _ = writeln!(out, "{}", xml(&e.title)); let _ = writeln!(out, "{}", xml(&e.updated)); let _ = writeln!( out, "", xml(&e.link) ); let by = e.author.as_deref().unwrap_or(title); let _ = writeln!(out, "{}", xml(by)); let kind = if e.html { "html" } else { "text" }; let _ = writeln!( out, "{}", xml(&e.content) ); out += "\n"; } out += "\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 += "&", '<' => out += "<", '>' => out += ">", '"' => out += """, '\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 & \"c\"\u{1}\u{fffe}\u{ffff}\n"), "a <b> & "c"\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("1970-01-01T00:00:00Z")); assert!(!feed.contains("")); } }