OXYGENHUB

Coverage / crates/hub/src/web/releases.rs 74.4%

343 of 461 lines run, 53 of 174 functions · run #526 on main · run missed

1
//! Releases (hub spec §9): notes and assets on a tag, generated notes from
2
//! the changes since the previous release, source archives of any state.
3
4
use super::settings::{csrf, require};
5
use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell};
6
use crate::auth::{Access, Viewer};
7
use crate::db::{self, accounts, releases as rdb};
8
use crate::repos::archive::{self, Format};
9
use crate::repos::{view, RepoName};
10
use crate::Shared;
11
use askama::Template;
12
use axum::extract::{Form, Multipart, Path, Query, State};
13
use axum::http::header;
14
use axum::response::{IntoResponse, Redirect, Response};
15
use oxy_core::hash::ChangeHash;
16
use serde::Deserialize;
17
use std::collections::HashMap;
18
19
/// A release as the pages and the API show it.
20
pub struct ReleaseView {
21
    pub r: rdb::Release,
22
    pub notes_html: String,
23
    pub assets: Vec<rdb::Asset>,
24
    pub heads: Vec<String>,
25
    pub signer: Option<String>,
26
    pub latest: bool,
27
    /// Where each asset was built, by asset id (hub spec §9).
28
    pub built: HashMap<i64, db::flow::Provenance>,
29
}
30
31
impl ReleaseView {
3213×
    pub fn built_by(&self, asset: &i64) -> Option<&db::flow::Provenance> {
3313×
        self.built.get(asset)
3413×
    }
35
}
36
3714×
async fn views(app: &Shared, repo: &RepoName, row: &db::repos::RepoRow) -> Result<Vec<ReleaseView>, sqlx::Error> {
3814×
    let list = rdb::list(&app.db, row.id).await?;
3914×
    let latest = rdb::latest(&app.db, row.id).await?.map(|r| r.id);
4014×
    let tags: HashMap<String, (Vec<String>, Option<String>)> = app
4114×
        .repos
4215×
        .read(repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| (t.name, (t.heads, t.signer))).collect()))
4314×
        .await
4414×
        .unwrap_or_default();
4514×
    let mut out = Vec::new();
4618×
    for r in list {
4718×
        let assets = rdb::assets(&app.db, r.id).await?;
4818×
        let mut built = HashMap::new();
4918×
        for a in &assets {
5013×
            if let Some(p) = db::flow::provenance(&app.db, row.id, &a.hash).await? {
515×
                built.insert(a.id, p);
528×
            }
53
        }
5418×
        let (heads, signer) = tags.get(&r.tag).cloned().unwrap_or_default();
5518×
        let notes_html = super::issues::markdown(app, row, &r.tag, &r.notes).await;
5618×
        out.push(ReleaseView { latest: Some(r.id) == latest, r, notes_html, assets, heads, signer, built });
57
    }
5814×
    Ok(out)
5914×
}
60
61
#[derive(Template)]
62
#[template(path = "releases.html")]
63
pub struct ReleasesPage {
64
    shell: Shell,
65
    repo: RepoShell,
66
    releases: Vec<ReleaseView>,
67
    unreleased: Vec<String>,
68
    one: bool,
69
}
70
713×
pub async fn list(
723×
    State(app): State<Shared>,
733×
    viewer: Viewer,
743×
    Path((owner, name)): Path<(String, String)>,
753×
) -> Result<Page<ReleasesPage>, Error> {
763×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer);
773×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
783×
    let releases = views(&app, &repo, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
793×
    let tags: Vec<String> =
803×
        app.repos.read(&repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| t.name).collect())).await.unwrap_or_default();
814×
    let unreleased = tags.into_iter().filter(|t| !releases.iter().any(|r| &r.r.tag == t)).collect();
823×
    Ok(Page(ReleasesPage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), releases, unreleased, one: false }))
833×
}
84
852×
pub async fn show(
862×
    State(app): State<Shared>,
872×
    viewer: Viewer,
882×
    Path((owner, name, tag)): Path<(String, String, String)>,
892×
) -> Result<Page<ReleasesPage>, Error> {
902×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/tag/{tag}"), &viewer);
912×
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
922×
    let releases: Vec<ReleaseView> =
932×
        views(&app, &repo, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?.into_iter().filter(|r| r.r.tag == tag).collect();
942×
    if releases.is_empty() {
950×
        return Err(Error::not_found(shell));
962×
    }
972×
    Ok(Page(ReleasesPage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), releases, unreleased: vec![], one: true }))
982×
}
99
100
/// Notes drafted from the changes in `tag`'s state that the previous
101
/// release's state lacks: one line per change, newest first.
1022×
async fn generated_notes(app: &Shared, repo: &RepoName, repo_id: i64, tag: &str) -> String {
1032×
    let previous = rdb::list(&app.db, repo_id).await.unwrap_or_default().into_iter().find(|r| r.tag != tag).map(|r| r.tag);
1042×
    let tag = tag.to_string();
1052×
    app.repos
1062×
        .read(repo, move |r, cache| {
1072×
            let Some(heads) = r.tag_heads(&tag)? else { return Ok(String::new()) };
1082×
            let now = cache.load(r, &heads)?;
1092×
            let before: Vec<ChangeHash> = match &previous {
1101×
                Some(p) => r.tag_heads(p)?.unwrap_or_default(),
1111×
                None => vec![],
112
            };
1132×
            let then = cache.load(r, &before)?;
1142×
            let mut added: Vec<_> = now
1152×
                .state
1162×
                .changes()
1172×
                .filter(|(h, _)| !then.state.contains(h))
1182×
                .map(|(h, m)| (m.hlc, *h, m.message.clone(), m.author))
1192×
                .collect();
1202×
            added.sort_by_key(|a| std::cmp::Reverse(a.0));
1212×
            let mut out = match &previous {
1221×
                Some(p) => format!("Changes since {p}:\n\n"),
1231×
                None => "Changes:\n\n".to_string(),
124
            };
1252×
            for (_, h, message, author) in added {
1262×
                let who = view::author(r, &author, &message).name;
1272×
                out.push_str(&format!("- {} ({}, {})\n", message.lines().next().unwrap_or(""), &h.to_hex()[..12], who));
1282×
            }
1292×
            Ok(out)
1302×
        })
1312×
        .await
1322×
        .unwrap_or_default()
1332×
}
134
135
#[derive(Template)]
136
#[template(path = "release_new.html")]
137
pub struct NewReleasePage {
138
    shell: Shell,
139
    repo: RepoShell,
140
    tags: Vec<String>,
141
    tag: String,
142
    notes: String,
143
    error: Option<String>,
144
}
145
146
#[derive(Deserialize)]
147
pub struct NewQuery {
148
    tag: Option<String>,
149
}
150
1511×
pub async fn new_form(
1521×
    State(app): State<Shared>,
1531×
    viewer: Viewer,
1541×
    Path((owner, name)): Path<(String, String)>,
1551×
    Query(q): Query<NewQuery>,
1561×
) -> Result<Page<NewReleasePage>, Error> {
1571×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/new"), &viewer);
1581×
    require(&viewer, &shell)?;
1591×
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
1601×
    let tags: Vec<String> = app
1611×
        .repos
1621×
        .read(&repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| t.name).collect()))
1631×
        .await
1641×
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
1651×
    let tag = q.tag.filter(|t| tags.contains(t)).or_else(|| tags.first().cloned()).unwrap_or_default();
1661×
    let notes = if tag.is_empty() { String::new() } else { generated_notes(&app, &repo, row.id, &tag).await };
1671×
    Ok(Page(NewReleasePage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), tags, tag, notes, error: None }))
1681×
}
169
170
#[derive(Deserialize)]
171
pub struct NewForm {
172
    #[serde(rename = "_csrf")]
173
    csrf_token: String,
174
    tag: String,
175
    title: String,
176
    #[serde(default)]
177
    notes: String,
178
    #[serde(default)]
179
    prerelease: Option<String>,
180
}
181
182
/// Create a release for an existing tag; shared by the form and the API.
183
#[allow(clippy::too_many_arguments)]
1844×
pub async fn create_release(
1854×
    app: &Shared,
1864×
    repo: &RepoName,
1874×
    row: &db::repos::RepoRow,
1884×
    me: &accounts::Account,
1894×
    tag: &str,
1904×
    title: &str,
1914×
    notes: Option<String>,
1924×
    prerelease: bool,
1934×
) -> Result<rdb::Release, String> {
1944×
    let exists = app
1954×
        .repos
1964×
        .read(repo, {
1974×
            let t = tag.to_string();
1984×
            move |r, _| Ok(r.tag_heads(&t)?.is_some())
199
        })
2004×
        .await
2014×
        .map_err(|e| e.to_string())?;
2024×
    if !exists {
2031×
        return Err(format!("there is no tag {tag}; create it with oxy tag create and push it"));
2043×
    }
2053×
    if rdb::get(&app.db, row.id, tag).await.map_err(|e| e.to_string())?.is_some() {
2060×
        return Err(format!("{tag} already has a release"));
2073×
    }
2083×
    let notes = match notes {
2092×
        Some(n) => n,
2101×
        None => generated_notes(app, repo, row.id, tag).await,
211
    };
2123×
    let title = if title.trim().is_empty() { tag.to_string() } else { title.trim().to_string() };
2133×
    let r = rdb::create(&app.db, row.id, tag, &title, notes.trim(), prerelease, Some((me.id, &me.username)))
2143×
        .await
2153×
        .map_err(|e| e.to_string())?;
2163×
    accounts::audit(&app.db, Some(me), "release.published", &format!("{}@{tag}", row.slug()), "").await.ok();
2173×
    crate::hooks::emit(
2183×
        app,
2193×
        row,
2203×
        "release.published",
2213×
        &me.username,
2223×
        serde_json::json!({ "tag": tag, "title": title, "prerelease": prerelease }),
2233×
    )
2243×
    .await;
2253×
    crate::notify::deliver(
2263×
        app,
2273×
        crate::notify::Event {
2283×
            kind: "release",
2293×
            title: format!("{} released {title}", row.slug()),
2303×
            url: format!("/{}/releases/tag/{tag}", row.slug()),
2313×
            actor: me,
2323×
            repo_id: row.id,
2333×
            thread: None,
2343×
            mention_text: "",
2353×
            direct: vec![],
2363×
        },
2373×
    )
2383×
    .await;
2393×
    Ok(r)
2404×
}
241
2421×
pub async fn create(
2431×
    State(app): State<Shared>,
2441×
    viewer: Viewer,
2451×
    Path((owner, name)): Path<(String, String)>,
2461×
    Form(f): Form<NewForm>,
2471×
) -> Result<Response, Error> {
2481×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/new"), &viewer);
2491×
    let me = require(&viewer, &shell)?;
2501×
    csrf(&viewer, &f.csrf_token, &shell)?;
2511×
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
2521×
    match create_release(&app, &repo, &row, &me.account, &f.tag, &f.title, Some(f.notes.clone()), f.prerelease.is_some()).await {
2531×
        Ok(r) => Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", r.tag)).into_response()),
2540×
        Err(msg) => {
2550×
            let tags = app.repos.read(&repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| t.name).collect())).await.unwrap_or_default();
2560×
            Ok(Page(NewReleasePage {
2570×
                shell,
2580×
                repo: RepoShell::new(&app, &row, "releases").with_role(role),
2590×
                tags,
2600×
                tag: f.tag,
2610×
                notes: f.notes,
2620×
                error: Some(msg),
2630×
            })
2640×
            .into_response())
265
        }
266
    }
2671×
}
268
269
#[derive(Deserialize)]
270
pub struct EditForm {
271
    #[serde(rename = "_csrf")]
272
    csrf_token: String,
273
    #[serde(default)]
274
    title: String,
275
    #[serde(default)]
276
    notes: String,
277
    #[serde(default)]
278
    prerelease: Option<String>,
279
    #[serde(default)]
280
    action: String,
281
    #[serde(default)]
282
    asset: Option<i64>,
283
}
284
2850×
pub async fn edit(
2860×
    State(app): State<Shared>,
2870×
    viewer: Viewer,
2880×
    Path((owner, name, id)): Path<(String, String, i64)>,
2890×
    Form(f): Form<EditForm>,
2900×
) -> Result<Response, Error> {
2910×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer);
2920×
    let me = require(&viewer, &shell)?;
2930×
    csrf(&viewer, &f.csrf_token, &shell)?;
2940×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
2950×
    let e = |e| Error::from_db(shell.clone(), e);
2960×
    let Some(r) = rdb::list(&app.db, row.id).await.map_err(e)?.into_iter().find(|r| r.id == id) else {
2970×
        return Err(Error::not_found(shell));
298
    };
2990×
    match f.action.as_str() {
3000×
        "delete" => {
3010×
            let assets = rdb::assets(&app.db, r.id).await.map_err(e)?;
3020×
            rdb::delete(&app.db, r.id).await.map_err(e)?;
3030×
            for a in assets {
3040×
                remove_blob_if_unused(&app, &a.hash).await;
305
            }
3060×
            accounts::audit(&app.db, Some(&me.account), "release.deleted", &format!("{owner}/{name}@{}", r.tag), "").await.ok();
3070×
            return Ok(Redirect::to(&format!("/{owner}/{name}/releases")).into_response());
308
        }
3090×
        "delete_asset" => {
3100×
            if let Some(a) = rdb::assets(&app.db, r.id).await.map_err(e)?.into_iter().find(|a| Some(a.id) == f.asset) {
3110×
                rdb::delete_asset(&app.db, a.id).await.map_err(e)?;
3120×
                remove_blob_if_unused(&app, &a.hash).await;
3130×
            }
314
        }
3150×
        _ => rdb::edit(
3160×
            &app.db,
3170×
            r.id,
3180×
            if f.title.trim().is_empty() { &r.tag } else { f.title.trim() },
3190×
            f.notes.trim(),
3200×
            f.prerelease.is_some(),
321
        )
3220×
        .await
3230×
        .map_err(e)?,
324
    }
3250×
    Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", r.tag)).into_response())
3260×
}
327
32813×
pub(crate) fn blob_path(app: &Shared, hash: &str) -> std::path::PathBuf {
32913×
    app.config.data.join("assets").join(&hash[..2]).join(hash)
33013×
}
331
3320×
async fn remove_blob_if_unused(app: &Shared, hash: &str) {
3330×
    if !rdb::hash_in_use(&app.db, hash).await.unwrap_or(true) {
3340×
        tokio::fs::remove_file(blob_path(app, hash)).await.ok();
3350×
    }
3360×
}
337
338
/// Store an uploaded asset by content and attach it to a release.
3397×
pub async fn store_asset(app: &Shared, release_id: i64, name: &str, content_type: &str, bytes: &[u8]) -> Result<rdb::Asset, String> {
3407×
    let name = name.rsplit(['/', '\\']).next().unwrap_or(name).trim();
3417×
    if name.is_empty() || name.starts_with('.') || name.len() > 200 {
3420×
        return Err("give the asset a file name".into());
3437×
    }
3447×
    if bytes.len() as u64 > app.config.limits.asset_limit.0 {
3450×
        return Err("the asset is larger than this hub allows".into());
3467×
    }
3477×
    let hash = hex::encode(blake3::hash(bytes).as_bytes());
3487×
    let path = blob_path(app, &hash);
3497×
    if !path.exists() {
3507×
        tokio::fs::create_dir_all(path.parent().unwrap()).await.map_err(|e| e.to_string())?;
3517×
        let tmp = path.with_extension("partial");
3527×
        tokio::fs::write(&tmp, bytes).await.map_err(|e| e.to_string())?;
3537×
        tokio::fs::rename(&tmp, &path).await.map_err(|e| e.to_string())?;
3540×
    }
3557×
    let ct = if content_type.is_empty() { "application/octet-stream" } else { content_type };
3567×
    rdb::add_asset(&app.db, release_id, name, bytes.len() as i64, &hash, ct).await.map_err(|e| e.to_string())
3577×
}
358
3590×
pub async fn upload(
3600×
    State(app): State<Shared>,
3610×
    viewer: Viewer,
3620×
    Path((owner, name, id)): Path<(String, String, i64)>,
3630×
    mut multipart: Multipart,
3640×
) -> Result<Response, Error> {
3650×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer);
3660×
    require(&viewer, &shell)?;
3670×
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
3680×
    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 {
3690×
        return Err(Error::not_found(shell));
370
    };
3710×
    let mut token = String::new();
3720×
    let mut files: Vec<(String, String, Vec<u8>)> = vec![];
3730×
    while let Ok(Some(field)) = multipart.next_field().await {
3740×
        match field.name() {
3750×
            Some("_csrf") => token = field.text().await.unwrap_or_default(),
3760×
            Some("asset") => {
3770×
                let fname = field.file_name().unwrap_or("").to_string();
3780×
                let ct = field.content_type().unwrap_or("").to_string();
3790×
                let bytes = field.bytes().await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
3800×
                if !fname.is_empty() {
3810×
                    files.push((fname, ct, bytes.to_vec()));
3820×
                }
383
            }
3840×
            _ => {}
385
        }
386
    }
3870×
    csrf(&viewer, &token, &shell)?;
3880×
    for (fname, ct, bytes) in files {
3890×
        store_asset(&app, r.id, &fname, &ct, &bytes).await.map_err(|m| Error::bad(shell.clone(), m))?;
390
    }
3910×
    Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", r.tag)).into_response())
3920×
}
393
394
/// `GET /{owner}/{name}/releases/download/{tag}/{file}` (oxygen#157): a
395
/// release's asset by its tag and file name, a URL known before the release
396
/// exists (as GitHub's), for packagers' recipes; redirected to the asset.
3972×
pub async fn download_by_tag(
3982×
    State(app): State<Shared>,
3992×
    viewer: Viewer,
4002×
    Path((owner, name, tag, file)): Path<(String, String, String, String)>,
4012×
) -> Result<Response, Error> {
4022×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer);
4032×
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
4042×
    let e = |e| Error::from_db(shell.clone(), e);
4052×
    let release = rdb::get(&app.db, row.id, &tag).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?;
4062×
    let asset = rdb::asset(&app.db, release.id, &file).await.map_err(e)?.ok_or_else(|| Error::not_found(shell.clone()))?;
4071×
    Ok(Redirect::to(&format!("/{owner}/{name}/asset/{}/{}", asset.id, asset.name)).into_response())
4082×
}
409
4101×
pub async fn download(
4111×
    State(app): State<Shared>,
4121×
    viewer: Viewer,
4131×
    Path((owner, name, id, file)): Path<(String, String, i64, String)>,
4141×
) -> Result<Response, Error> {
4151×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer);
4161×
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
4171×
    let e = |e| Error::from_db(shell.clone(), e);
4181×
    let asset: Option<rdb::Asset> =
4191×
        sqlx::query_as("SELECT a.* FROM release_assets a JOIN releases r ON r.id = a.release_id WHERE a.id = $1 AND r.repo_id = $2")
4201×
            .bind(id)
4211×
            .bind(row.id)
4221×
            .fetch_optional(&app.db)
4231×
            .await
4241×
            .map_err(e)?;
4251×
    let Some(a) = asset.filter(|a| a.name == file) else { return Err(Error::not_found(shell)) };
4261×
    let bytes = tokio::fs::read(blob_path(&app, &a.hash)).await.map_err(|_| Error::not_found(shell.clone()))?;
4271×
    rdb::count_download(&app.db, a.id).await.ok();
4281×
    Ok((
4291×
        [
4301×
            (header::CONTENT_TYPE, a.content_type.clone()),
4311×
            (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", a.name.replace('"', ""))),
4321×
            (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
4331×
            (header::CACHE_CONTROL, "public, max-age=31536000, immutable".to_string()),
4341×
        ],
4351×
        bytes,
4361×
    )
4371×
        .into_response())
4381×
}
439
440
#[derive(Deserialize)]
441
pub struct ArchiveQuery {
442
    #[serde(rename = "ref")]
443
    r: String,
444
    format: String,
445
}
446
447
/// `GET /{owner}/{name}/archive?ref=v1.0&format=tar.zst`: built once per
448
/// state and served from the cache after.
4494×
pub async fn source_archive(
4504×
    State(app): State<Shared>,
4514×
    viewer: Viewer,
4524×
    Path((owner, name)): Path<(String, String)>,
4534×
    Query(q): Query<ArchiveQuery>,
4544×
) -> Result<Response, Error> {
4554×
    let shell = Shell::new(&app, &format!("/{owner}/{name}/archive"), &viewer);
4564×
    let (repo, _, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
4574×
    let format = Format::parse(&q.format).ok_or_else(|| Error::bad(shell.clone(), "format is tar.zst or zip"))?;
45816×
    let label: String = q.r.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '-' }).collect();
4594×
    let prefix = format!("{name}-{}", label.trim_matches('-'));
4604×
    let data = app.config.data.clone();
4614×
    let spec = q.r.clone();
4624×
    let p = prefix.clone();
4634×
    let path = app
4644×
        .repos
4654×
        .read(&repo, move |r, cache| {
4664×
            let target = view::resolve(r, cache, &spec)?;
4674×
            let loaded = cache.load(r, &target.heads)?;
4684×
            let state = oxy_core::objects::StateObject::new(loaded.heads.clone()).hash().to_hex();
4694×
            let dest = archive::cache_path(&data, &state, &p, format);
4704×
            if !dest.exists() {
4714×
                archive::build(r, &loaded, &p, format, &dest)
4724×
                    .map_err(|e| crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(e.to_string())))?;
4730×
            }
4744×
            Ok(dest)
4754×
        })
4764×
        .await
4774×
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
4784×
    let bytes = tokio::fs::read(&path).await.map_err(|_| Error::not_found(shell.clone()))?;
4794×
    Ok((
4804×
        [
4814×
            (header::CONTENT_TYPE, format.mime().to_string()),
4824×
            (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{prefix}.{}\"", format.ext())),
4834×
        ],
4844×
        bytes,
4854×
    )
4864×
        .into_response())
4874×
}
488
489
/// API shape of a release.
49013×
pub fn to_api(app: &Shared, row: &db::repos::RepoRow, v: &ReleaseView) -> hub_api::Release {
49113×
    let base = format!("{}/{}", app.config.base_url(), row.slug());
492
    hub_api::Release {
49313×
        tag: v.r.tag.clone(),
49413×
        title: v.r.title.clone(),
49513×
        notes: v.r.notes.clone(),
49613×
        prerelease: v.r.prerelease,
49713×
        latest: v.latest,
49813×
        author: v.r.author.clone(),
49913×
        heads: v.heads.clone(),
50013×
        signer: v.signer.clone(),
50113×
        assets: v
50213×
            .assets
50313×
            .iter()
50413×
            .map(|a| hub_api::ReleaseAsset {
5057×
                name: a.name.clone(),
5067×
                size: a.size,
5077×
                hash: a.hash.clone(),
5087×
                content_type: a.content_type.clone(),
5097×
                downloads: a.downloads,
5107×
                url: format!("{base}/asset/{}/{}", a.id, a.name),
5117×
                built_by: v.built_by(&a.id).map(|p| {
5122×
                    let signed = !p.receipt_attest.is_empty();
513
                    hub_api::AssetBuild {
5142×
                        run: p.run_number,
5152×
                        job: p.job.clone(),
5162×
                        artifact: p.name.clone(),
5172×
                        run_url: format!("{base}/flow/{}", p.run_number),
5182×
                        receipt: signed.then(|| p.receipt_attest.clone()),
5192×
                        receipt_url: signed.then(|| format!("{base}/receipts/{}", p.receipt_attest)),
5202×
                        signer: signed.then(|| p.receipt_signer.clone()),
5212×
                        provenance_url: signed.then(|| provenance_url(&base, p)),
522
                    }
5232×
                }),
5247×
            })
52513×
            .collect(),
52626×
        archives: ["tar.zst", "zip"].iter().map(|f| format!("{base}/archive?ref={}&format={f}", v.r.tag)).collect(),
52713×
        created_ms: v.r.created_ms,
52813×
        url: format!("{base}/releases/tag/{}", v.r.tag),
529
    }
53013×
}
531
5329×
pub async fn api_views(app: &Shared, repo: &RepoName, row: &db::repos::RepoRow) -> Result<Vec<hub_api::Release>, sqlx::Error> {
53313×
    Ok(views(app, repo, row).await?.iter().map(|v| to_api(app, row, v)).collect())
5349×
}
535
536
/// Where a built asset's SLSA provenance is: its artifact's, which states
537
/// the same bytes (the asset links to its build by BLAKE3).
5381×
pub fn provenance_url(base: &str, p: &db::flow::Provenance) -> String {
5391×
    format!(
540
        "{base}/flow/{}/artifacts/{}/{}/provenance",
541
        p.run_number,
5421×
        super::settings::urlencode(&p.job),
5431×
        super::settings::urlencode(&p.name)
544
    )
5451×
}