OXYGENHUB
# 166f6e9bf4f1 oxygenhub / crates / hub / src / web / releases.rs
20.3 KiB · Rust · last changed Postgres, step 1: every query numbers its placeholders $1, $2, ... 20 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
    if !rdb::hash_in_use(&app.db, hash).await.unwrap_or(true) {
334
        tokio::fs::remove_file(blob_path(app, hash)).await.ok();
335
    }
336
}
337
338
/// Store an uploaded asset by content and attach it to a release.
339
pub async fn store_asset(app: &Shared, release_id: i64, name: &str, content_type: &str, bytes: &[u8]) -> Result<rdb::Asset, String> {
340
    let name = name.rsplit(['/', '\\']).next().unwrap_or(name).trim();
341
    if name.is_empty() || name.starts_with('.') || name.len() > 200 {
342
        return Err("give the asset a file name".into());
343
    }
344
    if bytes.len() as u64 > app.config.limits.asset_limit.0 {
345
        return Err("the asset is larger than this hub allows".into());
346
    }
347
    let hash = hex::encode(blake3::hash(bytes).as_bytes());
348
    let path = blob_path(app, &hash);
349
    if !path.exists() {
350
        tokio::fs::create_dir_all(path.parent().unwrap()).await.map_err(|e| e.to_string())?;
351
        let tmp = path.with_extension("partial");
352
        tokio::fs::write(&tmp, bytes).await.map_err(|e| e.to_string())?;
353
        tokio::fs::rename(&tmp, &path).await.map_err(|e| e.to_string())?;
354
    }
355
    let ct = if content_type.is_empty() { "application/octet-stream" } else { content_type };
356
    rdb::add_asset(&app.db, release_id, name, bytes.len() as i64, &hash, ct).await.map_err(|e| e.to_string())
357
}
358
359
pub async fn upload(
360
    State(app): State<Shared>,
361
    viewer: Viewer,
362
    Path((owner, name, id)): Path<(String, String, i64)>,
363
    mut multipart: Multipart,
364
) -> Result<Response, Error> {
365
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer);
366
    require(&viewer, &shell)?;
367
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
368
    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 {
369
        return Err(Error::not_found(shell));
370
    };
371
    let mut token = String::new();
372
    let mut files: Vec<(String, String, Vec<u8>)> = vec![];
373
    while let Ok(Some(field)) = multipart.next_field().await {
374
        match field.name() {
375
            Some("_csrf") => token = field.text().await.unwrap_or_default(),
376
            Some("asset") => {
377
                let fname = field.file_name().unwrap_or("").to_string();
378
                let ct = field.content_type().unwrap_or("").to_string();
379
                let bytes = field.bytes().await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
380
                if !fname.is_empty() {
381
                    files.push((fname, ct, bytes.to_vec()));
382
                }
383
            }
384
            _ => {}
385
        }
386
    }
387
    csrf(&viewer, &token, &shell)?;
388
    for (fname, ct, bytes) in files {
389
        store_asset(&app, r.id, &fname, &ct, &bytes).await.map_err(|m| Error::bad(shell.clone(), m))?;
390
    }
391
    Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", r.tag)).into_response())
392
}
393
394
pub async fn download(
395
    State(app): State<Shared>,
396
    viewer: Viewer,
397
    Path((owner, name, id, file)): Path<(String, String, i64, String)>,
398
) -> Result<Response, Error> {
399
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer);
400
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
401
    let e = |e| Error::from_db(shell.clone(), e);
402
    let asset: Option<rdb::Asset> =
403
        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")
404
            .bind(id)
405
            .bind(row.id)
406
            .fetch_optional(&app.db)
407
            .await
408
            .map_err(e)?;
409
    let Some(a) = asset.filter(|a| a.name == file) else { return Err(Error::not_found(shell)) };
410
    let bytes = tokio::fs::read(blob_path(&app, &a.hash)).await.map_err(|_| Error::not_found(shell.clone()))?;
411
    rdb::count_download(&app.db, a.id).await.ok();
412
    Ok((
413
        [
414
            (header::CONTENT_TYPE, a.content_type.clone()),
415
            (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", a.name.replace('"', ""))),
416
            (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
417
            (header::CACHE_CONTROL, "public, max-age=31536000, immutable".to_string()),
418
        ],
419
        bytes,
420
    )
421
        .into_response())
422
}
423
424
#[derive(Deserialize)]
425
pub struct ArchiveQuery {
426
    #[serde(rename = "ref")]
427
    r: String,
428
    format: String,
429
}
430
431
/// `GET /{owner}/{name}/archive?ref=v1.0&format=tar.zst`: built once per
432
/// state and served from the cache after.
433
pub async fn source_archive(
434
    State(app): State<Shared>,
435
    viewer: Viewer,
436
    Path((owner, name)): Path<(String, String)>,
437
    Query(q): Query<ArchiveQuery>,
438
) -> Result<Response, Error> {
439
    let shell = Shell::new(&app, &format!("/{owner}/{name}/archive"), &viewer);
440
    let (repo, _, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
441
    let format = Format::parse(&q.format).ok_or_else(|| Error::bad(shell.clone(), "format is tar.zst or zip"))?;
442
    let label: String = q.r.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '-' }).collect();
443
    let prefix = format!("{name}-{}", label.trim_matches('-'));
444
    let data = app.config.data.clone();
445
    let spec = q.r.clone();
446
    let p = prefix.clone();
447
    let path = app
448
        .repos
449
        .read(&repo, move |r, cache| {
450
            let target = view::resolve(r, cache, &spec)?;
451
            let loaded = cache.load(r, &target.heads)?;
452
            let state = oxy_core::objects::StateObject::new(loaded.heads.clone()).hash().to_hex();
453
            let dest = archive::cache_path(&data, &state, &p, format);
454
            if !dest.exists() {
455
                archive::build(r, &loaded, &p, format, &dest)
456
                    .map_err(|e| crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(e.to_string())))?;
457
            }
458
            Ok(dest)
459
        })
460
        .await
461
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
462
    let bytes = tokio::fs::read(&path).await.map_err(|_| Error::not_found(shell.clone()))?;
463
    Ok((
464
        [
465
            (header::CONTENT_TYPE, format.mime().to_string()),
466
            (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{prefix}.{}\"", format.ext())),
467
        ],
468
        bytes,
469
    )
470
        .into_response())
471
}
472
473
/// API shape of a release.
474
pub fn to_api(app: &Shared, row: &db::repos::RepoRow, v: &ReleaseView) -> hub_api::Release {
475
    let base = format!("{}/{}", app.config.base_url(), row.slug());
476
    hub_api::Release {
477
        tag: v.r.tag.clone(),
478
        title: v.r.title.clone(),
479
        notes: v.r.notes.clone(),
480
        prerelease: v.r.prerelease,
481
        latest: v.latest,
482
        author: v.r.author.clone(),
483
        heads: v.heads.clone(),
484
        signer: v.signer.clone(),
485
        assets: v
486
            .assets
487
            .iter()
488
            .map(|a| hub_api::ReleaseAsset {
489
                name: a.name.clone(),
490
                size: a.size,
491
                hash: a.hash.clone(),
492
                content_type: a.content_type.clone(),
493
                downloads: a.downloads,
494
                url: format!("{base}/asset/{}/{}", a.id, a.name),
495
                built_by: v.built_by(&a.id).map(|p| {
496
                    let signed = !p.receipt_attest.is_empty();
497
                    hub_api::AssetBuild {
498
                        run: p.run_number,
499
                        job: p.job.clone(),
500
                        artifact: p.name.clone(),
501
                        run_url: format!("{base}/flow/{}", p.run_number),
502
                        receipt: signed.then(|| p.receipt_attest.clone()),
503
                        receipt_url: signed.then(|| format!("{base}/receipts/{}", p.receipt_attest)),
504
                        signer: signed.then(|| p.receipt_signer.clone()),
505
                    }
506
                }),
507
            })
508
            .collect(),
509
        archives: ["tar.zst", "zip"].iter().map(|f| format!("{base}/archive?ref={}&format={f}", v.r.tag)).collect(),
510
        created_ms: v.r.created_ms,
511
        url: format!("{base}/releases/tag/{}", v.r.tag),
512
    }
513
}
514
515
pub async fn api_views(app: &Shared, repo: &RepoName, row: &db::repos::RepoRow) -> Result<Vec<hub_api::Release>, sqlx::Error> {
516
    Ok(views(app, repo, row).await?.iter().map(|v| to_api(app, row, v)).collect())
517
}