OXYGENHUB
# 8d2caf122517 oxygenhub / crates / hub / src / web / releases.rs
19.2 KiB · Rust · last changed Milestone 0.6: releases, Git mirror, code search, anchored API comments 1 month 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
}
28
29
async fn views(app: &Shared, repo: &RepoName, row: &db::repos::RepoRow) -> Result<Vec<ReleaseView>, sqlx::Error> {
30
    let list = rdb::list(&app.db, row.id).await?;
31
    let latest = rdb::latest(&app.db, row.id).await?.map(|r| r.id);
32
    let tags: HashMap<String, (Vec<String>, Option<String>)> = app
33
        .repos
34
        .read(repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| (t.name, (t.heads, t.signer))).collect()))
35
        .await
36
        .unwrap_or_default();
37
    let mut out = Vec::new();
38
    for r in list {
39
        let assets = rdb::assets(&app.db, r.id).await?;
40
        let (heads, signer) = tags.get(&r.tag).cloned().unwrap_or_default();
41
        let notes_html = super::issues::markdown(app, row.id, &format!("/{}", row.slug()), &r.notes).await;
42
        out.push(ReleaseView { latest: Some(r.id) == latest, r, notes_html, assets, heads, signer });
43
    }
44
    Ok(out)
45
}
46
47
#[derive(Template)]
48
#[template(path = "releases.html")]
49
pub struct ReleasesPage {
50
    shell: Shell,
51
    repo: RepoShell,
52
    releases: Vec<ReleaseView>,
53
    unreleased: Vec<String>,
54
    one: bool,
55
}
56
57
pub async fn list(
58
    State(app): State<Shared>,
59
    viewer: Viewer,
60
    Path((owner, name)): Path<(String, String)>,
61
) -> Result<Page<ReleasesPage>, Error> {
62
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer);
63
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
64
    let releases = views(&app, &repo, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?;
65
    let tags: Vec<String> =
66
        app.repos.read(&repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| t.name).collect())).await.unwrap_or_default();
67
    let unreleased = tags.into_iter().filter(|t| !releases.iter().any(|r| &r.r.tag == t)).collect();
68
    Ok(Page(ReleasesPage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), releases, unreleased, one: false }))
69
}
70
71
pub async fn show(
72
    State(app): State<Shared>,
73
    viewer: Viewer,
74
    Path((owner, name, tag)): Path<(String, String, String)>,
75
) -> Result<Page<ReleasesPage>, Error> {
76
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/tag/{tag}"), &viewer);
77
    let (repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
78
    let releases: Vec<ReleaseView> =
79
        views(&app, &repo, &row).await.map_err(|e| Error::from_db(shell.clone(), e))?.into_iter().filter(|r| r.r.tag == tag).collect();
80
    if releases.is_empty() {
81
        return Err(Error::not_found(shell));
82
    }
83
    Ok(Page(ReleasesPage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), releases, unreleased: vec![], one: true }))
84
}
85
86
/// Notes drafted from the changes in `tag`'s state that the previous
87
/// release's state lacks: one line per change, newest first.
88
async fn generated_notes(app: &Shared, repo: &RepoName, repo_id: i64, tag: &str) -> String {
89
    let previous = rdb::list(&app.db, repo_id).await.unwrap_or_default().into_iter().find(|r| r.tag != tag).map(|r| r.tag);
90
    let tag = tag.to_string();
91
    app.repos
92
        .read(repo, move |r, cache| {
93
            let Some(heads) = r.tag_heads(&tag)? else { return Ok(String::new()) };
94
            let now = cache.load(r, &heads)?;
95
            let before: Vec<ChangeHash> = match &previous {
96
                Some(p) => r.tag_heads(p)?.unwrap_or_default(),
97
                None => vec![],
98
            };
99
            let then = cache.load(r, &before)?;
100
            let mut added: Vec<_> = now
101
                .state
102
                .changes()
103
                .filter(|(h, _)| !then.state.contains(h))
104
                .map(|(h, m)| (m.hlc, *h, m.message.clone(), m.author))
105
                .collect();
106
            added.sort_by_key(|a| std::cmp::Reverse(a.0));
107
            let mut out = match &previous {
108
                Some(p) => format!("Changes since {p}:\n\n"),
109
                None => "Changes:\n\n".to_string(),
110
            };
111
            for (_, h, message, author) in added {
112
                let who = view::author(r, &author, &message).name;
113
                out.push_str(&format!("- {} ({}, {})\n", message.lines().next().unwrap_or(""), &h.to_hex()[..12], who));
114
            }
115
            Ok(out)
116
        })
117
        .await
118
        .unwrap_or_default()
119
}
120
121
#[derive(Template)]
122
#[template(path = "release_new.html")]
123
pub struct NewReleasePage {
124
    shell: Shell,
125
    repo: RepoShell,
126
    tags: Vec<String>,
127
    tag: String,
128
    notes: String,
129
    error: Option<String>,
130
}
131
132
#[derive(Deserialize)]
133
pub struct NewQuery {
134
    tag: Option<String>,
135
}
136
137
pub async fn new_form(
138
    State(app): State<Shared>,
139
    viewer: Viewer,
140
    Path((owner, name)): Path<(String, String)>,
141
    Query(q): Query<NewQuery>,
142
) -> Result<Page<NewReleasePage>, Error> {
143
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/new"), &viewer);
144
    require(&viewer, &shell)?;
145
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
146
    let tags: Vec<String> = app
147
        .repos
148
        .read(&repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| t.name).collect()))
149
        .await
150
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
151
    let tag = q.tag.filter(|t| tags.contains(t)).or_else(|| tags.first().cloned()).unwrap_or_default();
152
    let notes = if tag.is_empty() { String::new() } else { generated_notes(&app, &repo, row.id, &tag).await };
153
    Ok(Page(NewReleasePage { shell, repo: RepoShell::new(&app, &row, "releases").with_role(role), tags, tag, notes, error: None }))
154
}
155
156
#[derive(Deserialize)]
157
pub struct NewForm {
158
    #[serde(rename = "_csrf")]
159
    csrf_token: String,
160
    tag: String,
161
    title: String,
162
    #[serde(default)]
163
    notes: String,
164
    #[serde(default)]
165
    prerelease: Option<String>,
166
}
167
168
/// Create a release for an existing tag; shared by the form and the API.
169
#[allow(clippy::too_many_arguments)]
170
pub async fn create_release(
171
    app: &Shared,
172
    repo: &RepoName,
173
    row: &db::repos::RepoRow,
174
    me: &accounts::Account,
175
    tag: &str,
176
    title: &str,
177
    notes: Option<String>,
178
    prerelease: bool,
179
) -> Result<rdb::Release, String> {
180
    let exists = app
181
        .repos
182
        .read(repo, {
183
            let t = tag.to_string();
184
            move |r, _| Ok(r.tag_heads(&t)?.is_some())
185
        })
186
        .await
187
        .map_err(|e| e.to_string())?;
188
    if !exists {
189
        return Err(format!("there is no tag {tag}; create it with oxy tag create and push it"));
190
    }
191
    if rdb::get(&app.db, row.id, tag).await.map_err(|e| e.to_string())?.is_some() {
192
        return Err(format!("{tag} already has a release"));
193
    }
194
    let notes = match notes {
195
        Some(n) => n,
196
        None => generated_notes(app, repo, row.id, tag).await,
197
    };
198
    let title = if title.trim().is_empty() { tag.to_string() } else { title.trim().to_string() };
199
    let r = rdb::create(&app.db, row.id, tag, &title, notes.trim(), prerelease, Some((me.id, &me.username)))
200
        .await
201
        .map_err(|e| e.to_string())?;
202
    accounts::audit(&app.db, Some(me), "release.published", &format!("{}@{tag}", row.slug()), "").await.ok();
203
    crate::hooks::emit(
204
        app,
205
        row,
206
        "release.published",
207
        &me.username,
208
        serde_json::json!({ "tag": tag, "title": title, "prerelease": prerelease }),
209
    )
210
    .await;
211
    crate::notify::deliver(
212
        app,
213
        crate::notify::Event {
214
            kind: "release",
215
            title: format!("{} released {title}", row.slug()),
216
            url: format!("/{}/releases/tag/{tag}", row.slug()),
217
            actor: me,
218
            repo_id: row.id,
219
            thread: None,
220
            mention_text: "",
221
            direct: vec![],
222
        },
223
    )
224
    .await;
225
    Ok(r)
226
}
227
228
pub async fn create(
229
    State(app): State<Shared>,
230
    viewer: Viewer,
231
    Path((owner, name)): Path<(String, String)>,
232
    Form(f): Form<NewForm>,
233
) -> Result<Response, Error> {
234
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases/new"), &viewer);
235
    let me = require(&viewer, &shell)?;
236
    csrf(&viewer, &f.csrf_token, &shell)?;
237
    let (repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
238
    match create_release(&app, &repo, &row, &me.account, &f.tag, &f.title, Some(f.notes.clone()), f.prerelease.is_some()).await {
239
        Ok(r) => Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", r.tag)).into_response()),
240
        Err(msg) => {
241
            let tags = app.repos.read(&repo, |r, _| Ok(view::tags(r)?.into_iter().map(|t| t.name).collect())).await.unwrap_or_default();
242
            Ok(Page(NewReleasePage {
243
                shell,
244
                repo: RepoShell::new(&app, &row, "releases").with_role(role),
245
                tags,
246
                tag: f.tag,
247
                notes: f.notes,
248
                error: Some(msg),
249
            })
250
            .into_response())
251
        }
252
    }
253
}
254
255
#[derive(Deserialize)]
256
pub struct EditForm {
257
    #[serde(rename = "_csrf")]
258
    csrf_token: String,
259
    #[serde(default)]
260
    title: String,
261
    #[serde(default)]
262
    notes: String,
263
    #[serde(default)]
264
    prerelease: Option<String>,
265
    #[serde(default)]
266
    action: String,
267
    #[serde(default)]
268
    asset: Option<i64>,
269
}
270
271
pub async fn edit(
272
    State(app): State<Shared>,
273
    viewer: Viewer,
274
    Path((owner, name, id)): Path<(String, String, i64)>,
275
    Form(f): Form<EditForm>,
276
) -> Result<Response, Error> {
277
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer);
278
    let me = require(&viewer, &shell)?;
279
    csrf(&viewer, &f.csrf_token, &shell)?;
280
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
281
    let e = |e| Error::from_db(shell.clone(), e);
282
    let Some(r) = rdb::list(&app.db, row.id).await.map_err(e)?.into_iter().find(|r| r.id == id) else {
283
        return Err(Error::not_found(shell));
284
    };
285
    match f.action.as_str() {
286
        "delete" => {
287
            let assets = rdb::assets(&app.db, r.id).await.map_err(e)?;
288
            rdb::delete(&app.db, r.id).await.map_err(e)?;
289
            for a in assets {
290
                remove_blob_if_unused(&app, &a.hash).await;
291
            }
292
            accounts::audit(&app.db, Some(&me.account), "release.deleted", &format!("{owner}/{name}@{}", r.tag), "").await.ok();
293
            return Ok(Redirect::to(&format!("/{owner}/{name}/releases")).into_response());
294
        }
295
        "delete_asset" => {
296
            if let Some(a) = rdb::assets(&app.db, r.id).await.map_err(e)?.into_iter().find(|a| Some(a.id) == f.asset) {
297
                rdb::delete_asset(&app.db, a.id).await.map_err(e)?;
298
                remove_blob_if_unused(&app, &a.hash).await;
299
            }
300
        }
301
        _ => rdb::edit(
302
            &app.db,
303
            r.id,
304
            if f.title.trim().is_empty() { &r.tag } else { f.title.trim() },
305
            f.notes.trim(),
306
            f.prerelease.is_some(),
307
        )
308
        .await
309
        .map_err(e)?,
310
    }
311
    Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", r.tag)).into_response())
312
}
313
314
fn blob_path(app: &Shared, hash: &str) -> std::path::PathBuf {
315
    app.config.data.join("assets").join(&hash[..2]).join(hash)
316
}
317
318
async fn remove_blob_if_unused(app: &Shared, hash: &str) {
319
    if !rdb::hash_in_use(&app.db, hash).await.unwrap_or(true) {
320
        tokio::fs::remove_file(blob_path(app, hash)).await.ok();
321
    }
322
}
323
324
/// Store an uploaded asset by content and attach it to a release.
325
pub async fn store_asset(app: &Shared, release_id: i64, name: &str, content_type: &str, bytes: &[u8]) -> Result<rdb::Asset, String> {
326
    let name = name.rsplit(['/', '\\']).next().unwrap_or(name).trim();
327
    if name.is_empty() || name.starts_with('.') || name.len() > 200 {
328
        return Err("give the asset a file name".into());
329
    }
330
    if bytes.len() as u64 > app.config.limits.asset_limit.0 {
331
        return Err("the asset is larger than this hub allows".into());
332
    }
333
    let hash = hex::encode(blake3::hash(bytes).as_bytes());
334
    let path = blob_path(app, &hash);
335
    if !path.exists() {
336
        tokio::fs::create_dir_all(path.parent().unwrap()).await.map_err(|e| e.to_string())?;
337
        let tmp = path.with_extension("partial");
338
        tokio::fs::write(&tmp, bytes).await.map_err(|e| e.to_string())?;
339
        tokio::fs::rename(&tmp, &path).await.map_err(|e| e.to_string())?;
340
    }
341
    let ct = if content_type.is_empty() { "application/octet-stream" } else { content_type };
342
    rdb::add_asset(&app.db, release_id, name, bytes.len() as i64, &hash, ct).await.map_err(|e| e.to_string())
343
}
344
345
pub async fn upload(
346
    State(app): State<Shared>,
347
    viewer: Viewer,
348
    Path((owner, name, id)): Path<(String, String, i64)>,
349
    mut multipart: Multipart,
350
) -> Result<Response, Error> {
351
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer);
352
    require(&viewer, &shell)?;
353
    let (_, row, _) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
354
    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 {
355
        return Err(Error::not_found(shell));
356
    };
357
    let mut token = String::new();
358
    let mut files: Vec<(String, String, Vec<u8>)> = vec![];
359
    while let Ok(Some(field)) = multipart.next_field().await {
360
        match field.name() {
361
            Some("_csrf") => token = field.text().await.unwrap_or_default(),
362
            Some("asset") => {
363
                let fname = field.file_name().unwrap_or("").to_string();
364
                let ct = field.content_type().unwrap_or("").to_string();
365
                let bytes = field.bytes().await.map_err(|e| Error::bad(shell.clone(), e.to_string()))?;
366
                if !fname.is_empty() {
367
                    files.push((fname, ct, bytes.to_vec()));
368
                }
369
            }
370
            _ => {}
371
        }
372
    }
373
    csrf(&viewer, &token, &shell)?;
374
    for (fname, ct, bytes) in files {
375
        store_asset(&app, r.id, &fname, &ct, &bytes).await.map_err(|m| Error::bad(shell.clone(), m))?;
376
    }
377
    Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", r.tag)).into_response())
378
}
379
380
pub async fn download(
381
    State(app): State<Shared>,
382
    viewer: Viewer,
383
    Path((owner, name, id, file)): Path<(String, String, i64, String)>,
384
) -> Result<Response, Error> {
385
    let shell = Shell::new(&app, &format!("/{owner}/{name}/releases"), &viewer);
386
    let (_, row, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
387
    let e = |e| Error::from_db(shell.clone(), e);
388
    let asset: Option<rdb::Asset> =
389
        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 = ?")
390
            .bind(id)
391
            .bind(row.id)
392
            .fetch_optional(&app.db)
393
            .await
394
            .map_err(e)?;
395
    let Some(a) = asset.filter(|a| a.name == file) else { return Err(Error::not_found(shell)) };
396
    let bytes = tokio::fs::read(blob_path(&app, &a.hash)).await.map_err(|_| Error::not_found(shell.clone()))?;
397
    rdb::count_download(&app.db, a.id).await.ok();
398
    Ok((
399
        [
400
            (header::CONTENT_TYPE, a.content_type.clone()),
401
            (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{}\"", a.name.replace('"', ""))),
402
            (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
403
            (header::CACHE_CONTROL, "public, max-age=31536000, immutable".to_string()),
404
        ],
405
        bytes,
406
    )
407
        .into_response())
408
}
409
410
#[derive(Deserialize)]
411
pub struct ArchiveQuery {
412
    #[serde(rename = "ref")]
413
    r: String,
414
    format: String,
415
}
416
417
/// `GET /{owner}/{name}/archive?ref=v1.0&format=tar.zst`: built once per
418
/// state and served from the cache after.
419
pub async fn source_archive(
420
    State(app): State<Shared>,
421
    viewer: Viewer,
422
    Path((owner, name)): Path<(String, String)>,
423
    Query(q): Query<ArchiveQuery>,
424
) -> Result<Response, Error> {
425
    let shell = Shell::new(&app, &format!("/{owner}/{name}/archive"), &viewer);
426
    let (repo, _, _) = lookup(&app, &shell, &viewer, &owner, &name).await?;
427
    let format = Format::parse(&q.format).ok_or_else(|| Error::bad(shell.clone(), "format is tar.zst or zip"))?;
428
    let label: String = q.r.chars().map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') { c } else { '-' }).collect();
429
    let prefix = format!("{name}-{}", label.trim_matches('-'));
430
    let data = app.config.data.clone();
431
    let spec = q.r.clone();
432
    let p = prefix.clone();
433
    let path = app
434
        .repos
435
        .read(&repo, move |r, cache| {
436
            let target = view::resolve(r, cache, &spec)?;
437
            let loaded = cache.load(r, &target.heads)?;
438
            let state = oxy_core::objects::StateObject::new(loaded.heads.clone()).hash().to_hex();
439
            let dest = archive::cache_path(&data, &state, &p, format);
440
            if !dest.exists() {
441
                archive::build(r, &loaded, &p, format, &dest)
442
                    .map_err(|e| crate::repos::RepoError::Oxy(oxy_core::repo::RepoError::Msg(e.to_string())))?;
443
            }
444
            Ok(dest)
445
        })
446
        .await
447
        .map_err(|e| Error::from_repo(shell.clone(), e))?;
448
    let bytes = tokio::fs::read(&path).await.map_err(|_| Error::not_found(shell.clone()))?;
449
    Ok((
450
        [
451
            (header::CONTENT_TYPE, format.mime().to_string()),
452
            (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{prefix}.{}\"", format.ext())),
453
        ],
454
        bytes,
455
    )
456
        .into_response())
457
}
458
459
/// API shape of a release.
460
pub fn to_api(app: &Shared, row: &db::repos::RepoRow, v: &ReleaseView) -> hub_api::Release {
461
    let base = format!("{}/{}", app.config.base_url(), row.slug());
462
    hub_api::Release {
463
        tag: v.r.tag.clone(),
464
        title: v.r.title.clone(),
465
        notes: v.r.notes.clone(),
466
        prerelease: v.r.prerelease,
467
        latest: v.latest,
468
        author: v.r.author.clone(),
469
        heads: v.heads.clone(),
470
        signer: v.signer.clone(),
471
        assets: v
472
            .assets
473
            .iter()
474
            .map(|a| hub_api::ReleaseAsset {
475
                name: a.name.clone(),
476
                size: a.size,
477
                hash: a.hash.clone(),
478
                content_type: a.content_type.clone(),
479
                downloads: a.downloads,
480
                url: format!("{base}/asset/{}/{}", a.id, a.name),
481
            })
482
            .collect(),
483
        archives: ["tar.zst", "zip"].iter().map(|f| format!("{base}/archive?ref={}&format={f}", v.r.tag)).collect(),
484
        created_ms: v.r.created_ms,
485
        url: format!("{base}/releases/tag/{}", v.r.tag),
486
    }
487
}
488
489
pub async fn api_views(app: &Shared, repo: &RepoName, row: &db::repos::RepoRow) -> Result<Vec<hub_api::Release>, sqlx::Error> {
490
    Ok(views(app, repo, row).await?.iter().map(|v| to_api(app, row, v)).collect())
491
}