| 1 |
|
| 2 |
|
| 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 |
|
| 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 |
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 |
|
| 101 |
|
| 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 |
|
| 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 |
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 |
|
| 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 = ? AND r.repo_id = ?") |
| 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 |
|
| 432 |
|
| 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 |
|
| 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 |
} |