//! Releases (hub spec ยง9): notes and assets on a tag, generated notes from //! the changes since the previous release, source archives of any state. use super::settings::{csrf, require}; use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell}; use crate::auth::{Access, Viewer}; use crate::db::{self, accounts, releases as rdb}; use crate::repos::archive::{self, Format}; use crate::repos::{view, RepoName}; use crate::Shared; use askama::Template; use axum::extract::{Form, Multipart, Path, Query, State}; use axum::http::header; use axum::response::{IntoResponse, Redirect, Response}; use oxy_core::hash::ChangeHash; use serde::Deserialize; use std::collections::HashMap; /// A release as the pages and the API show it. pub struct ReleaseView { pub r: rdb::Release, pub notes_html: String, pub assets: Vec, pub heads: Vec, pub signer: Option, pub latest: bool, } async fn views(app: &Shared, repo: &RepoName, row: &db::repos::RepoRow) -> Result, sqlx::Error> { let list = rdb::list(&app.db, row.id).await?; let latest = rdb::latest(&app.db, row.id).await?.map(|r| r.id); let tags: HashMap, Option)> = app .repos .read(repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| (t.name, (t.heads, t.signer))).collect())) .await .unwrap_or_default(); let mut out = Vec::new(); for r in list { let assets = rdb::assets(&app.db, r.id).await?; let (heads, signer) = tags.get(&r.tag).cloned().unwrap_or_default(); let notes_html = super::issues::markdown(app, row.id, &format!("/{}", row.slug()), &r.notes).await; out.push(ReleaseView { latest: Some(r.id) == latest, r, notes_html, assets, heads, signer }); } Ok(out) } #[derive(Template)] #[template(path = "releases.html")] pub struct ReleasesPage { shell: Shell, repo: RepoShell, releases: Vec, unreleased: Vec, one: bool, } pub async fn list( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer); let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let releases = views(&app, &repo, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?; let tags: Vec = app.repos.read(&repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| t.name).collect())).await.unwrap_or_default(); let unreleased = tags.into_iter().filter(|t| !releases.iter().any(|r| &r.r.tag == t)).collect(); Ok(Page(ReleasesPage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), releases, unreleased, one: false })) } pub async fn show( State(app): State, viewer: Viewer, Path((owner, name, tag)): Path<(String, String, String)>, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/tag/{tag}"), &viewer); let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; let releases: Vec = views(&app, &repo, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?.into_iter().filter(|r| r.r.tag == tag).collect(); if releases.is_empty() { return Err(Error::not_found(shell)); } Ok(Page(ReleasesPage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), releases, unreleased: vec![], one: true })) } /// Notes drafted from the changes in `tag`'s state that the previous /// release's state lacks: one line per change, newest first. async fn generated_notes(app: &Shared, repo: &RepoName, repo_id: i64, tag: &str) -> String { let previous = rdb::list(&app.db, repo_id).await.unwrap_or_default().into_iter().find(|r| r.tag != tag).map(|r| r.tag); let tag = tag.to_string(); app.repos .read(repo, move |r, cache| { let Some(heads) = r.tag_heads(&tag)? else { return Ok(String::new()) }; let now = cache.load(r, &heads)?; let before: Vec = match &previous { Some(p) => r.tag_heads(p)?.unwrap_or_default(), None => vec![], }; let then = cache.load(r, &before)?; let mut added: Vec<_> = now .state .changes() .filter(|(h, _)| !then.state.contains(h)) .map(|(h, m)| (m.hlc, *h, m.message.clone(), m.author)) .collect(); added.sort_by_key(|a| std::cmp::Reverse(a.0)); let mut out = match &previous { Some(p) => format!("Changes since {p}:\n\n"), None => "Changes:\n\n".to_string(), }; for (_, h, message, author) in added { let who = view::author(r, &author, &message).name; out.push_str(&format!("- {} ({}, {})\n", message.lines().next().unwrap_or(""), &h.to_hex()[..12], who)); } Ok(out) }) .await .unwrap_or_default() } #[derive(Template)] #[template(path = "release_new.html")] pub struct NewReleasePage { shell: Shell, repo: RepoShell, tags: Vec, tag: String, notes: String, error: Option, } #[derive(Deserialize)] pub struct NewQuery { tag: Option, } pub async fn new_form( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result, Error> { let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/new"), &viewer); require(&viewer, &shell)?; let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; let tags: Vec = app .repos .read(&repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| t.name).collect())) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; let tag = q.tag.filter(|t| tags.contains(t)).or_else(|| tags.first().cloned()).unwrap_or_default(); let notes = if tag.is_empty() { String::new() } else { generated_notes(&app, &repo, row.id, &tag).await }; Ok(Page(NewReleasePage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), tags, tag, notes, error: None })) } #[derive(Deserialize)] pub struct NewForm { #[serde(rename = "_csrf")] csrf_token: String, tag: String, title: String, #[serde(default)] notes: String, #[serde(default)] prerelease: Option, } /// Create a release for an existing tag; shared by the form and the API. #[allow(clippy::too_many_arguments)] pub async fn create_release( app: &Shared, repo: &RepoName, row: &db::repos::RepoRow, me: &accounts::Account, tag: &str, title: &str, notes: Option, prerelease: bool, ) -> Result { let exists = app .repos .read(repo, { let t = tag.to_string(); move |r, _| Ok(r.tag_heads(&t)?.is_some()) }) .await .map_err(|e| e.to_string())?; if !exists { return Err(format!("there is no tag {tag}; create it with oxy tag create and push it")); } if rdb::get(&app.db, row.id, tag).await.map_err(|e| e.to_string())?.is_some() { return Err(format!("{tag} already has a release")); } let notes = match notes { Some(n) => n, None => generated_notes(app, repo, row.id, tag).await, }; let title = if title.trim().is_empty() { tag.to_string() } else { title.trim().to_string() }; let r = rdb::create(&app.db, row.id, tag, &title, notes.trim(), prerelease, Some((me.id, &me.username))) .await .map_err(|e| e.to_string())?; accounts::audit(&app.db, Some(me), "release.published", &format!("{}@{tag}", row.slug()), "").await.ok(); crate::hooks::emit( app, row, "release.published", &me.username, serde_json::json!({ "tag": tag, "title": title, "prerelease": prerelease }), ) .await; crate::notify::deliver( app, crate::notify::Event { kind: "release", title: format!("{} released {title}", row.slug()), url: format!("/{}/releases/tag/{tag}", row.slug()), actor: me, repo_id: row.id, thread: None, mention_text: "", direct: vec![], }, ) .await; Ok(r) } pub async fn create( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/new"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; match create_release(&app, &repo, &row, &me.account, &f.tag, &f.title, Some(f.notes.clone()), f.prerelease.is_some()).await { Ok(r) => Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", r.tag)).into_response()), Err(msg) => { let tags = app.repos.read(&repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| t.name).collect())).await.unwrap_or_default(); Ok(Page(NewReleasePage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), tags, tag: f.tag, notes: f.notes, error: Some(msg), }) .into_response()) } } } #[derive(Deserialize)] pub struct EditForm { #[serde(rename = "_csrf")] csrf_token: String, #[serde(default)] title: String, #[serde(default)] notes: String, #[serde(default)] prerelease: Option, #[serde(default)] action: String, #[serde(default)] asset: Option, } pub async fn edit( State(app): State, viewer: Viewer, Path((owner, name, id)): Path<(String, String, i64)>, Form(f): Form, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer); let me = require(&viewer, &shell)?; csrf(&viewer, &f.csrf_token, &shell)?; let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; let e = |e| Error::from_db(shell.clone(), e); let Some(r) = rdb::list(&app.db, row.id).await.map_err(e)?.into_iter().find(|r| r.id == id) else { return Err(Error::not_found(shell)); }; match f.action.as_str() { "delete" => { let assets = rdb::assets(&app.db, r.id).await.map_err(e)?; rdb::delete(&app.db, r.id).await.map_err(e)?; for a in assets { remove_blob_if_unused(&app, &a.hash).await; } accounts::audit(&app.db, Some(&me.account), "release.deleted", &format!("{owner}/{name}@{}", r.tag), "").await.ok(); return Ok(Redirect::to(&format!("/{owner}/{name}/releases")).into_response()); } "delete_asset" => { if let Some(a) = rdb::assets(&app.db, r.id).await.map_err(e)?.into_iter().find(|a| Some(a.id) == f.asset) { rdb::delete_asset(&app.db, a.id).await.map_err(e)?; remove_blob_if_unused(&app, &a.hash).await; } } _ => rdb::edit( &app.db, r.id, if f.title.trim().is_empty() { &r.tag } else { f.title.trim() }, f.notes.trim(), f.prerelease.is_some(), ) .await .map_err(e)?, } Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", r.tag)).into_response()) } fn blob_path(app: &Shared, hash: &str) -> std::path::PathBuf { app.config.data.join("assets").join(&hash[..2]).join(hash) } async fn remove_blob_if_unused(app: &Shared, hash: &str) { if !rdb::hash_in_use(&app.db, hash).await.unwrap_or(true) { tokio::fs::remove_file(blob_path(app, hash)).await.ok(); } } /// Store an uploaded asset by content and attach it to a release. pub async fn store_asset(app: &Shared, release_id: i64, name: &str, content_type: &str, bytes: &[u8]) -> Result { let name = name.rsplit(['/', '\\']).next().unwrap_or(name).trim(); if name.is_empty() || name.starts_with('.') || name.len() > 200 { return Err("give the asset a file name".into()); } if bytes.len() as u64 > app.config.limits.asset_limit.0 { return Err("the asset is larger than this hub allows".into()); } let hash = hex::encode(blake3::hash(bytes).as_bytes()); let path = blob_path(app, &hash); if !path.exists() { tokio::fs::create_dir_all(path.parent().unwrap()).await.map_err(|e| e.to_string())?; let tmp = path.with_extension("partial"); tokio::fs::write(&tmp, bytes).await.map_err(|e| e.to_string())?; tokio::fs::rename(&tmp, &path).await.map_err(|e| e.to_string())?; } let ct = if content_type.is_empty() { "application/octet-stream" } else { content_type }; rdb::add_asset(&app.db, release_id, name, bytes.len() as i64, &hash, ct).await.map_err(|e| e.to_string()) } pub async fn upload( State(app): State, viewer: Viewer, Path((owner, name, id)): Path<(String, String, i64)>, mut multipart: Multipart, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer); require(&viewer, &shell)?; let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?; let Some(r) = rdb::list(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.into_iter().find(|r| r.id == id) else { return Err(Error::not_found(shell)); }; let mut token = String::new(); let mut files: Vec<(String, String, Vec)> = vec![]; while let Ok(Some(field)) = multipart.next_field().await { match field.name() { Some("_csrf") => token = field.text().await.unwrap_or_default(), Some("asset") => { let fname = field.file_name().unwrap_or("").to_string(); let ct = field.content_type().unwrap_or("").to_string(); let bytes = field.bytes().await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?; if !fname.is_empty() { files.push((fname, ct, bytes.to_vec())); } } _ => {} } } csrf(&viewer, &token, &shell)?; for (fname, ct, bytes) in files { store_asset(&app, r.id, &fname, &ct, &bytes).await.map_err(|m| Error::bad(shell.clone(), m))?; } Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", r.tag)).into_response()) } pub async fn download( State(app): State, viewer: Viewer, Path((owner, name, id, file)): Path<(String, String, i64, String)>, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer); let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; let e = |e| Error::from_db(shell.clone(), e); let asset: Option = sqlx::query_as("SELECT a.* FROM release_assets a JOIN releases r ON r.id = a.release_id WHERE a.id = ? AND r.repo_id = ?") .bind(id) .bind(row.id) .fetch_optional(&app.db) .await .map_err(e)?; let Some(a) = asset.filter(|a| a.name == file) else { return Err(Error::not_found(shell)) }; let bytes = tokio::fs::read(blob_path(&app, &a.hash)).await.map_err(|_| Error::not_found(shell.clone()))?; rdb::count_download(&app.db, a.id).await.ok(); Ok(( [ (header::CONTENT_TYPE, a.content_type.clone()), (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", a.name.replace('"', ""))), (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()), (header::CACHE_CONTROL, "public, max-age=31536000, immutable".to_string()), ], bytes, ) .into_response()) } #[derive(Deserialize)] pub struct ArchiveQuery { #[serde(rename = "ref")] r: String, format: String, } /// `GET /{owner}/{name}/archive?ref=v1.0&format=tar.zst`: built once per /// state and served from the cache after. pub async fn source_archive( State(app): State, viewer: Viewer, Path((owner, name)): Path<(String, String)>, Query(q): Query, ) -> Result { let shell = Shell::new(&app, &format!("/{owner}/{name}/archive"), &viewer); let (repo, _, _) = lookup(&app, &shell, &viewer, &owner, &name).await?; let format = Format::parse(&q.format).ok_or_else(|| Error::bad(shell.clone(), "format is tar.zst or zip"))?; let label: String = q.r.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '-' }).collect(); let prefix = format!("{name}-{}", label.trim_matches('-')); let data = app.config.data.clone(); let spec = q.r.clone(); let p = prefix.clone(); let path = app .repos .read(&repo, move |r, cache| { let target = view::resolve(r, cache, &spec)?; let loaded = cache.load(r, &target.heads)?; let state = oxy_core::objects::StateObject::new(loaded.heads.clone()).hash().to_hex(); let dest = archive::cache_path(&data, &state, &p, format); if !dest.exists() { archive::build(r, &loaded, &p, format, &dest) .map_err(|e| crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(e.to_string())))?; } Ok(dest) }) .await .map_err(|e| Error::from_repo(shell.clone(), e))?; let bytes = tokio::fs::read(&path).await.map_err(|_| Error::not_found(shell.clone()))?; Ok(( [ (header::CONTENT_TYPE, format.mime().to_string()), (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{prefix}.{}\"", format.ext())), ], bytes, ) .into_response()) } /// API shape of a release. pub fn to_api(app: &Shared, row: &db::repos::RepoRow, v: &ReleaseView) -> hub_api::Release { let base = format!("{}/{}", app.config.base_url(), row.slug()); hub_api::Release { tag: v.r.tag.clone(), title: v.r.title.clone(), notes: v.r.notes.clone(), prerelease: v.r.prerelease, latest: v.latest, author: v.r.author.clone(), heads: v.heads.clone(), signer: v.signer.clone(), assets: v .assets .iter() .map(|a| hub_api::ReleaseAsset { name: a.name.clone(), size: a.size, hash: a.hash.clone(), content_type: a.content_type.clone(), downloads: a.downloads, url: format!("{base}/asset/{}/{}", a.id, a.name), }) .collect(), archives: ["tar.zst", "zip"].iter().map(|f| format!("{base}/archive?ref={}&format={f}", v.r.tag)).collect(), created_ms: v.r.created_ms, url: format!("{base}/releases/tag/{}", v.r.tag), } } pub async fn api_views(app: &Shared, repo: &RepoName, row: &db::repos::RepoRow) -> Result, sqlx::Error> { Ok(views(app, repo, row).await?.iter().map(|v| to_api(app, row, v)).collect()) }