OXYGENHUB
# f2d2776b2a14 oxygenhub / crates / hub / src / web / releases.rs
22.1 KiB · Rust · last changed OxygenHub 0.9.19: attachments on issues and comments, over the API and oh --attach; images by their bytes shown in place, everything else a download (closes #350) 4 days ago source blame history raw
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 {
32
    pub fn built_by(&self, asset: &i64) -> Option<&db::flow::Provenance> {
33
        self.built.get(asset)
34
    }
35
}
36
37
async fn views(app: &Shared, repo: &RepoName, row: &db::repos::RepoRow) -> Result<Vec<ReleaseView>, sqlx::Error> {
38
    let list = rdb::list(&app.db, row.id).await?;
39
    let latest = rdb::latest(&app.db, row.id).await?.map(|r| r.id);
40
    let tags: HashMap<String, (Vec<String>, Option<String>)> = app
41
        .repos
42
        .read(repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| (t.name, (t.heads, t.signer))).collect()))
43
        .await
44
        .unwrap_or_default();
45
    let mut out = Vec::new();
46
    for r in list {
47
        let assets = rdb::assets(&app.db, r.id).await?;
48
        let mut built = HashMap::new();
49
        for a in &assets {
50
            if let Some(p) = db::flow::provenance(&app.db, row.id, &a.hash).await? {
51
                built.insert(a.id, p);
52
            }
53
        }
54
        let (heads, signer) = tags.get(&r.tag).cloned().unwrap_or_default();
55
        let notes_html = super::issues::markdown(app, row, &r.tag, &r.notes).await;
56
        out.push(ReleaseView { latest: Some(r.id) == latest, r, notes_html, assets, heads, signer, built });
57
    }
58
    Ok(out)
59
}
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
71
pub async fn list(
72
    State(app): State<Shared>,
73
    viewer: Viewer,
74
    Path((owner, name)): Path<(String, String)>,
75
) -> Result<Page<ReleasesPage>, Error> {
76
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer);
77
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
78
    let releases = views(&app, &repo, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
79
    let tags: Vec<String> =
80
        app.repos.read(&repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| t.name).collect())).await.unwrap_or_default();
81
    let unreleased = tags.into_iter().filter(|t| !releases.iter().any(|r| &r.r.tag == t)).collect();
82
    Ok(Page(ReleasesPage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), releases, unreleased, one: false }))
83
}
84
85
pub async fn show(
86
    State(app): State<Shared>,
87
    viewer: Viewer,
88
    Path((owner, name, tag)): Path<(String, String, String)>,
89
) -> Result<Page<ReleasesPage>, Error> {
90
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/tag/{tag}"), &viewer);
91
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
92
    let releases: Vec<ReleaseView> =
93
        views(&app, &repo, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?.into_iter().filter(|r| r.r.tag == tag).collect();
94
    if releases.is_empty() {
95
        return Err(Error::not_found(shell));
96
    }
97
    Ok(Page(ReleasesPage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), releases, unreleased: vec![], one: true }))
98
}
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.
102
async fn generated_notes(app: &Shared, repo: &RepoName, repo_id: i64, tag: &str) -> String {
103
    let previous = rdb::list(&app.db, repo_id).await.unwrap_or_default().into_iter().find(|r| r.tag != tag).map(|r| r.tag);
104
    let tag = tag.to_string();
105
    app.repos
106
        .read(repo, move |r, cache| {
107
            let Some(heads) = r.tag_heads(&tag)? else { return Ok(String::new()) };
108
            let now = cache.load(r, &heads)?;
109
            let before: Vec<ChangeHash> = match &previous {
110
                Some(p) => r.tag_heads(p)?.unwrap_or_default(),
111
                None => vec![],
112
            };
113
            let then = cache.load(r, &before)?;
114
            let mut added: Vec<_> = now
115
                .state
116
                .changes()
117
                .filter(|(h, _)| !then.state.contains(h))
118
                .map(|(h, m)| (m.hlc, *h, m.message.clone(), m.author))
119
                .collect();
120
            added.sort_by_key(|a| std::cmp::Reverse(a.0));
121
            let mut out = match &previous {
122
                Some(p) => format!("Changes since {p}:\n\n"),
123
                None => "Changes:\n\n".to_string(),
124
            };
125
            for (_, h, message, author) in added {
126
                let who = view::author(r, &author, &message).name;
127
                out.push_str(&format!("- {} ({}, {})\n", message.lines().next().unwrap_or(""), &h.to_hex()[..12], who));
128
            }
129
            Ok(out)
130
        })
131
        .await
132
        .unwrap_or_default()
133
}
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
151
pub async fn new_form(
152
    State(app): State<Shared>,
153
    viewer: Viewer,
154
    Path((owner, name)): Path<(String, String)>,
155
    Query(q): Query<NewQuery>,
156
) -> Result<Page<NewReleasePage>, Error> {
157
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/new"), &viewer);
158
    require(&viewer, &shell)?;
159
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
160
    let tags: Vec<String> = app
161
        .repos
162
        .read(&repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| t.name).collect()))
163
        .await
164
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
165
    let tag = q.tag.filter(|t| tags.contains(t)).or_else(|| tags.first().cloned()).unwrap_or_default();
166
    let notes = if tag.is_empty() { String::new() } else { generated_notes(&app, &repo, row.id, &tag).await };
167
    Ok(Page(NewReleasePage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), tags, tag, notes, error: None }))
168
}
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)]
184
pub async fn create_release(
185
    app: &Shared,
186
    repo: &RepoName,
187
    row: &db::repos::RepoRow,
188
    me: &accounts::Account,
189
    tag: &str,
190
    title: &str,
191
    notes: Option<String>,
192
    prerelease: bool,
193
) -> Result<rdb::Release, String> {
194
    let exists = app
195
        .repos
196
        .read(repo, {
197
            let t = tag.to_string();
198
            move |r, _| Ok(r.tag_heads(&t)?.is_some())
199
        })
200
        .await
201
        .map_err(|e| e.to_string())?;
202
    if !exists {
203
        return Err(format!("there is no tag {tag}; create it with oxy tag create and push it"));
204
    }
205
    if rdb::get(&app.db, row.id, tag).await.map_err(|e| e.to_string())?.is_some() {
206
        return Err(format!("{tag} already has a release"));
207
    }
208
    let notes = match notes {
209
        Some(n) => n,
210
        None => generated_notes(app, repo, row.id, tag).await,
211
    };
212
    let title = if title.trim().is_empty() { tag.to_string() } else { title.trim().to_string() };
213
    let r = rdb::create(&app.db, row.id, tag, &title, notes.trim(), prerelease, Some((me.id, &me.username)))
214
        .await
215
        .map_err(|e| e.to_string())?;
216
    accounts::audit(&app.db, Some(me), "release.published", &format!("{}@{tag}", row.slug()), "").await.ok();
217
    crate::hooks::emit(
218
        app,
219
        row,
220
        "release.published",
221
        &me.username,
222
        serde_json::json!({ "tag": tag, "title": title, "prerelease": prerelease }),
223
    )
224
    .await;
225
    crate::notify::deliver(
226
        app,
227
        crate::notify::Event {
228
            kind: "release",
229
            title: format!("{} released {title}", row.slug()),
230
            url: format!("/{}/releases/tag/{tag}", row.slug()),
231
            actor: me,
232
            repo_id: row.id,
233
            thread: None,
234
            mention_text: "",
235
            direct: vec![],
236
        },
237
    )
238
    .await;
239
    Ok(r)
240
}
241
242
pub async fn create(
243
    State(app): State<Shared>,
244
    viewer: Viewer,
245
    Path((owner, name)): Path<(String, String)>,
246
    Form(f): Form<NewForm>,
247
) -> Result<Response, Error> {
248
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/new"), &viewer);
249
    let me = require(&viewer, &shell)?;
250
    csrf(&viewer, &f.csrf_token, &shell)?;
251
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
252
    match create_release(&app, &repo, &row, &me.account, &f.tag, &f.title, Some(f.notes.clone()), f.prerelease.is_some()).await {
253
        Ok(r) => Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", r.tag)).into_response()),
254
        Err(msg) => {
255
            let tags = app.repos.read(&repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| t.name).collect())).await.unwrap_or_default();
256
            Ok(Page(NewReleasePage {
257
                shell,
258
                repo: RepoShell::new(&app, &row, "releases").with_role(role),
259
                tags,
260
                tag: f.tag,
261
                notes: f.notes,
262
                error: Some(msg),
263
            })
264
            .into_response())
265
        }
266
    }
267
}
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
285
pub async fn edit(
286
    State(app): State<Shared>,
287
    viewer: Viewer,
288
    Path((owner, name, id)): Path<(String, String, i64)>,
289
    Form(f): Form<EditForm>,
290
) -> Result<Response, Error> {
291
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer);
292
    let me = require(&viewer, &shell)?;
293
    csrf(&viewer, &f.csrf_token, &shell)?;
294
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
295
    let e = |e| Error::from_db(shell.clone(), e);
296
    let Some(r) = rdb::list(&app.db, row.id).await.map_err(e)?.into_iter().find(|r| r.id == id) else {
297
        return Err(Error::not_found(shell));
298
    };
299
    match f.action.as_str() {
300
        "delete" => {
301
            let assets = rdb::assets(&app.db, r.id).await.map_err(e)?;
302
            rdb::delete(&app.db, r.id).await.map_err(e)?;
303
            for a in assets {
304
                remove_blob_if_unused(&app, &a.hash).await;
305
            }
306
            accounts::audit(&app.db, Some(&me.account), "release.deleted", &format!("{owner}/{name}@{}", r.tag), "").await.ok();
307
            return Ok(Redirect::to(&format!("/{owner}/{name}/releases")).into_response());
308
        }
309
        "delete_asset" => {
310
            if let Some(a) = rdb::assets(&app.db, r.id).await.map_err(e)?.into_iter().find(|a| Some(a.id) == f.asset) {
311
                rdb::delete_asset(&app.db, a.id).await.map_err(e)?;
312
                remove_blob_if_unused(&app, &a.hash).await;
313
            }
314
        }
315
        _ => rdb::edit(
316
            &app.db,
317
            r.id,
318
            if f.title.trim().is_empty() { &r.tag } else { f.title.trim() },
319
            f.notes.trim(),
320
            f.prerelease.is_some(),
321
        )
322
        .await
323
        .map_err(e)?,
324
    }
325
    Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", r.tag)).into_response())
326
}
327
328
pub(crate) fn blob_path(app: &Shared, hash: &str) -> std::path::PathBuf {
329
    app.config.data.join("assets").join(&hash[..2]).join(hash)
330
}
331
332
async fn remove_blob_if_unused(app: &Shared, hash: &str) {
333
    // Attachments (#350) keep blobs in the same store.
334
    let attached = crate::db::attachments::hash_in_use(&app.db, hash).await.unwrap_or(true);
335
    if !attached && !rdb::hash_in_use(&app.db, hash).await.unwrap_or(true) {
336
        tokio::fs::remove_file(blob_path(app, hash)).await.ok();
337
    }
338
}
339
340
/// Store an uploaded asset by content and attach it to a release.
341
pub async fn store_asset(app: &Shared, release_id: i64, name: &str, content_type: &str, bytes: &[u8]) -> Result<rdb::Asset, String> {
342
    let name = name.rsplit(['/', '\\']).next().unwrap_or(name).trim();
343
    if name.is_empty() || name.starts_with('.') || name.len() > 200 {
344
        return Err("give the asset a file name".into());
345
    }
346
    if bytes.len() as u64 > app.config.limits.asset_limit.0 {
347
        return Err("the asset is larger than this hub allows".into());
348
    }
349
    let hash = hex::encode(blake3::hash(bytes).as_bytes());
350
    let path = blob_path(app, &hash);
351
    if !path.exists() {
352
        tokio::fs::create_dir_all(path.parent().unwrap()).await.map_err(|e| e.to_string())?;
353
        let tmp = path.with_extension("partial");
354
        tokio::fs::write(&tmp, bytes).await.map_err(|e| e.to_string())?;
355
        tokio::fs::rename(&tmp, &path).await.map_err(|e| e.to_string())?;
356
    }
357
    let ct = if content_type.is_empty() { "application/octet-stream" } else { content_type };
358
    rdb::add_asset(&app.db, release_id, name, bytes.len() as i64, &hash, ct).await.map_err(|e| e.to_string())
359
}
360
361
pub async fn upload(
362
    State(app): State<Shared>,
363
    viewer: Viewer,
364
    Path((owner, name, id)): Path<(String, String, i64)>,
365
    mut multipart: Multipart,
366
) -> Result<Response, Error> {
367
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer);
368
    require(&viewer, &shell)?;
369
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
370
    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 {
371
        return Err(Error::not_found(shell));
372
    };
373
    let mut token = String::new();
374
    let mut files: Vec<(String, String, Vec<u8>)> = vec![];
375
    while let Ok(Some(field)) = multipart.next_field().await {
376
        match field.name() {
377
            Some("_csrf") => token = field.text().await.unwrap_or_default(),
378
            Some("asset") => {
379
                let fname = field.file_name().unwrap_or("").to_string();
380
                let ct = field.content_type().unwrap_or("").to_string();
381
                let bytes = field.bytes().await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
382
                if !fname.is_empty() {
383
                    files.push((fname, ct, bytes.to_vec()));
384
                }
385
            }
386
            _ => {}
387
        }
388
    }
389
    csrf(&viewer, &token, &shell)?;
390
    for (fname, ct, bytes) in files {
391
        store_asset(&app, r.id, &fname, &ct, &bytes).await.map_err(|m| Error::bad(shell.clone(), m))?;
392
    }
393
    Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", r.tag)).into_response())
394
}
395
396
pub async fn download(
397
    State(app): State<Shared>,
398
    viewer: Viewer,
399
    Path((owner, name, id, file)): Path<(String, String, i64, String)>,
400
) -> Result<Response, Error> {
401
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer);
402
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
403
    let e = |e| Error::from_db(shell.clone(), e);
404
    let asset: Option<rdb::Asset> =
405
        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")
406
            .bind(id)
407
            .bind(row.id)
408
            .fetch_optional(&app.db)
409
            .await
410
            .map_err(e)?;
411
    let Some(a) = asset.filter(|a| a.name == file) else { return Err(Error::not_found(shell)) };
412
    let bytes = tokio::fs::read(blob_path(&app, &a.hash)).await.map_err(|_| Error::not_found(shell.clone()))?;
413
    rdb::count_download(&app.db, a.id).await.ok();
414
    Ok((
415
        [
416
            (header::CONTENT_TYPE, a.content_type.clone()),
417
            (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", a.name.replace('"', ""))),
418
            (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
419
            (header::CACHE_CONTROL, "public, max-age=31536000, immutable".to_string()),
420
        ],
421
        bytes,
422
    )
423
        .into_response())
424
}
425
426
/// `GET /{owner}/{name}/attachments/{id}/{file}` (#350): an attachment, to
427
/// whoever may read the repository. Only an image whose bytes say so shows
428
/// in place; anything else is a download. Nothing here can run in the
429
/// hub's origin: no sniffing, a policy that allows nothing, and never
430
/// cached by a shared cache (the repository may be private).
431
pub async fn attachment(
432
    State(app): State<Shared>,
433
    viewer: Viewer,
434
    Path((owner, name, id, file)): Path<(String, String, i64, String)>,
435
) -> Result<Response, Error> {
436
    let shell = Shell::new(&app, &format!("/{owner}/{name}"), &viewer);
437
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
438
    let a = crate::db::attachments::get(&app.db, row.id, id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
439
    let Some(a) = a.filter(|a| a.name == file) else { return Err(Error::not_found(shell)) };
440
    let bytes = tokio::fs::read(blob_path(&app, &a.hash)).await.map_err(|_| Error::not_found(shell.clone()))?;
441
    let inline = crate::api::attachment_image_type(&bytes).is_some();
442
    let disposition = format!("{}; filename=\"{}\"", if inline { "inline" } else { "attachment" }, a.name.replace('"', ""));
443
    let ct = if inline { a.content_type.clone() } else { "application/octet-stream".into() };
444
    Ok((
445
        [
446
            (header::CONTENT_TYPE, ct),
447
            (header::CONTENT_DISPOSITION, disposition),
448
            (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
449
            (header::CONTENT_SECURITY_POLICY, "default-src 'none'; sandbox".to_string()),
450
            (header::CACHE_CONTROL, "private, max-age=86400".to_string()),
451
        ],
452
        bytes,
453
    )
454
        .into_response())
455
}
456
457
#[derive(Deserialize)]
458
pub struct ArchiveQuery {
459
    #[serde(rename = "ref")]
460
    r: String,
461
    format: String,
462
}
463
464
/// `GET /{owner}/{name}/archive?ref=v1.0&format=tar.zst`: built once per
465
/// state and served from the cache after.
466
pub async fn source_archive(
467
    State(app): State<Shared>,
468
    viewer: Viewer,
469
    Path((owner, name)): Path<(String, String)>,
470
    Query(q): Query<ArchiveQuery>,
471
) -> Result<Response, Error> {
472
    let shell = Shell::new(&app, &format!("/{owner}/{name}/archive"), &viewer);
473
    let (repo, _, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
474
    let format = Format::parse(&q.format).ok_or_else(|| Error::bad(shell.clone(), "format is tar.zst or zip"))?;
475
    let label: String = q.r.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '-' }).collect();
476
    let prefix = format!("{name}-{}", label.trim_matches('-'));
477
    let data = app.config.data.clone();
478
    let spec = q.r.clone();
479
    let p = prefix.clone();
480
    let path = app
481
        .repos
482
        .read(&repo, move |r, cache| {
483
            let target = view::resolve(r, cache, &spec)?;
484
            let loaded = cache.load(r, &target.heads)?;
485
            let state = oxy_core::objects::StateObject::new(loaded.heads.clone()).hash().to_hex();
486
            let dest = archive::cache_path(&data, &state, &p, format);
487
            if !dest.exists() {
488
                archive::build(r, &loaded, &p, format, &dest)
489
                    .map_err(|e| crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(e.to_string())))?;
490
            }
491
            Ok(dest)
492
        })
493
        .await
494
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
495
    let bytes = tokio::fs::read(&path).await.map_err(|_| Error::not_found(shell.clone()))?;
496
    Ok((
497
        [
498
            (header::CONTENT_TYPE, format.mime().to_string()),
499
            (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{prefix}.{}\"", format.ext())),
500
        ],
501
        bytes,
502
    )
503
        .into_response())
504
}
505
506
/// API shape of a release.
507
pub fn to_api(app: &Shared, row: &db::repos::RepoRow, v: &ReleaseView) -> hub_api::Release {
508
    let base = format!("{}/{}", app.config.base_url(), row.slug());
509
    hub_api::Release {
510
        tag: v.r.tag.clone(),
511
        title: v.r.title.clone(),
512
        notes: v.r.notes.clone(),
513
        prerelease: v.r.prerelease,
514
        latest: v.latest,
515
        author: v.r.author.clone(),
516
        heads: v.heads.clone(),
517
        signer: v.signer.clone(),
518
        assets: v
519
            .assets
520
            .iter()
521
            .map(|a| hub_api::ReleaseAsset {
522
                name: a.name.clone(),
523
                size: a.size,
524
                hash: a.hash.clone(),
525
                content_type: a.content_type.clone(),
526
                downloads: a.downloads,
527
                url: format!("{base}/asset/{}/{}", a.id, a.name),
528
                built_by: v.built_by(&a.id).map(|p| {
529
                    let signed = !p.receipt_attest.is_empty();
530
                    hub_api::AssetBuild {
531
                        run: p.run_number,
532
                        job: p.job.clone(),
533
                        artifact: p.name.clone(),
534
                        run_url: format!("{base}/flow/{}", p.run_number),
535
                        receipt: signed.then(|| p.receipt_attest.clone()),
536
                        receipt_url: signed.then(|| format!("{base}/receipts/{}", p.receipt_attest)),
537
                        signer: signed.then(|| p.receipt_signer.clone()),
538
                    }
539
                }),
540
            })
541
            .collect(),
542
        archives: ["tar.zst", "zip"].iter().map(|f| format!("{base}/archive?ref={}&format={f}", v.r.tag)).collect(),
543
        created_ms: v.r.created_ms,
544
        url: format!("{base}/releases/tag/{}", v.r.tag),
545
    }
546
}
547
548
pub async fn api_views(app: &Shared, repo: &RepoName, row: &db::repos::RepoRow) -> Result<Vec<hub_api::Release>, sqlx::Error> {
549
    Ok(views(app, repo, row).await?.iter().map(|v| to_api(app, row, v)).collect())
550
}