fabrica

hanna/fabrica

31070 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Releases: tag-based release notes with uploaded assets and source archives.
6//!
7//! A release is cut from a git tag (existing, or newly created from a target
8//! branch), carries markdown notes and an optional pre-release / draft label,
9//! and any number of uploaded asset files (stored under `data_dir/releases`,
10//! keyed by asset id). The list and detail views mirror GitHub's releases page.
11
12use std::path::{Path as FsPath, PathBuf};
13use std::sync::atomic::{AtomicU64, Ordering};
14
15use axum::body::Body;
16use axum::extract::{Multipart, Path, State};
17use axum::http::{HeaderMap, StatusCode, Uri, header};
18use axum::response::{IntoResponse, Redirect, Response};
19use axum_extra::extract::cookie::CookieJar;
20use futures_util::TryStreamExt as _;
21use maud::{Markup, html};
22use model::User;
23use serde::Deserialize;
24use tokio::io::AsyncWriteExt as _;
25use tokio_util::io::ReaderStream;
26
27use crate::AppState;
28use crate::error::{AppError, AppResult};
29use crate::icons::{Icon, icon};
30use crate::layout::page;
31use crate::markdown;
32use crate::pages::build_chrome;
33use crate::repo::{
34 Pagination, RepoCtx, Tab, branch_names, git_read, page_slice, pagination_nav, repo_header,
35 require_readable_repo,
36};
37use crate::session::{MaybeUser, verify_csrf};
38
39/// The on-disk directory holding release asset files.
40fn assets_dir(state: &AppState) -> PathBuf {
41 state.config.storage.data_dir.join("releases")
42}
43
44/// The release that should carry the "Latest" badge: the newest published,
45/// non-pre-release. `releases` is newest-first.
46fn latest_release_id(releases: &[store::Release]) -> Option<String> {
47 releases
48 .iter()
49 .find(|r| !r.is_draft && !r.is_prerelease)
50 .map(|r| r.id.clone())
51}
52
53/// `GET …/-/releases` — the release list.
54pub(crate) async fn list(
55 state: &AppState,
56 viewer: Option<User>,
57 jar: CookieJar,
58 uri: &Uri,
59 ctx: RepoCtx,
60) -> AppResult<Response> {
61 if !ctx.repo.releases_enabled {
62 return Err(AppError::NotFound);
63 }
64 let can_write = viewer.is_some() && ctx.access >= auth::Access::Write;
65 let mut releases = state.store.list_releases(&ctx.repo.id).await?;
66 if !can_write {
67 releases.retain(|r| !r.is_draft);
68 }
69 let latest = latest_release_id(&releases);
70 let paging = Pagination::from_query(uri.query());
71 let (shown, has_next) = page_slice(&releases, paging);
72
73 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
74 let branches = branch_names(state, &ctx.repo.id).await;
75 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
76 let mut cards = Vec::with_capacity(shown.len());
77 for r in &shown {
78 cards.push(
79 release_card(
80 state,
81 &base,
82 r,
83 latest.as_deref() == Some(&r.id),
84 can_write,
85 &chrome.csrf,
86 )
87 .await,
88 );
89 }
90 let body = html! {
91 (repo_header(state, &ctx, Tab::Releases, &ctx.repo.default_branch, &branches))
92 div class="releases-head" {
93 h2 { "Releases" }
94 @if can_write {
95 a class="btn btn-primary" href=(format!("{base}/-/releases/new")) {
96 (icon(Icon::Plus)) span { "Draft a new release" }
97 }
98 }
99 }
100 @if cards.is_empty() {
101 div class="card" { p class="muted" { "No releases yet." } }
102 } @else {
103 @for card in &cards { (card) }
104 @if has_next || paging.page > 1 {
105 (pagination_nav(uri.path(), uri.query(), paging, has_next))
106 }
107 }
108 };
109 let title = format!("{}/{}: releases", ctx.owner.username, ctx.repo.path);
110 Ok((jar, page(&chrome, &title, body)).into_response())
111}
112
113/// `GET …/-/releases/{tag}` — a single release.
114pub(crate) async fn view(
115 state: &AppState,
116 viewer: Option<User>,
117 jar: CookieJar,
118 uri: &Uri,
119 ctx: RepoCtx,
120 tag: &str,
121) -> AppResult<Response> {
122 if !ctx.repo.releases_enabled {
123 return Err(AppError::NotFound);
124 }
125 let can_write = viewer.is_some() && ctx.access >= auth::Access::Write;
126 let release = state
127 .store
128 .release_by_tag(&ctx.repo.id, tag)
129 .await?
130 .ok_or(AppError::NotFound)?;
131 if release.is_draft && !can_write {
132 return Err(AppError::NotFound);
133 }
134 let all = state.store.list_releases(&ctx.repo.id).await?;
135 let latest = latest_release_id(&all);
136 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
137 let branches = branch_names(state, &ctx.repo.id).await;
138 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
139 let card = release_card(
140 state,
141 &base,
142 &release,
143 latest.as_deref() == Some(&release.id),
144 can_write,
145 &chrome.csrf,
146 )
147 .await;
148 let body = html! {
149 (repo_header(state, &ctx, Tab::Releases, &ctx.repo.default_branch, &branches))
150 nav class="crumbs" aria-label="Breadcrumb" {
151 a href=(format!("{base}/-/releases")) { "Releases" }
152 span class="slug-sep" { "/" }
153 span class="crumb-current" { (if release.name.is_empty() { &release.tag } else { &release.name }) }
154 }
155 (card)
156 };
157 let title = format!(
158 "{} · {}/{}",
159 release.name, ctx.owner.username, ctx.repo.path
160 );
161 Ok((jar, page(&chrome, &title, body)).into_response())
162}
163
164/// Render one release: header (title, badges, tag/date/author), notes, assets,
165/// and source archives.
166async fn release_card(
167 state: &AppState,
168 base: &str,
169 r: &store::Release,
170 is_latest: bool,
171 can_write: bool,
172 csrf: &str,
173) -> Markup {
174 let author = match &r.author_id {
175 Some(id) => crate::issues::username(state, id).await,
176 None => "unknown".to_string(),
177 };
178 let assets = state
179 .store
180 .list_release_assets(&r.id)
181 .await
182 .unwrap_or_default();
183 html! {
184 section class="release card" {
185 div class="release-head" {
186 div {
187 h2 class="release-title" {
188 a href=(format!("{base}/-/releases/{}", r.tag)) { (r.name) }
189 @if is_latest { " " span class="badge badge-verified" { "Latest" } }
190 @if r.is_prerelease { " " span class="badge badge-archived" { "pre-release" } }
191 @if r.is_draft { " " span class="badge badge-private" { "draft" } }
192 }
193 div class="release-meta muted" {
194 (icon(Icon::Box)) " " (r.tag)
195 " · " (author) " · " (crate::repo::fmt_date(r.created_at))
196 }
197 }
198 @if can_write {
199 div class="release-actions" {
200 a class="btn" href=(format!("/release/{}/edit", r.id)) { "Edit" }
201 form method="post" action=(format!("/release/{}/delete", r.id)) class="inline-form" {
202 input type="hidden" name="_csrf" value=(csrf);
203 button class="btn btn-danger" type="submit" { "Delete" }
204 }
205 }
206 }
207 }
208 @if let Some(body) = &r.body {
209 @if !body.trim().is_empty() {
210 div class="release-notes markdown" { (markdown::render_with_refs(body, base)) }
211 }
212 }
213 div class="release-assets" {
214 h3 { "Assets" }
215 ul class="asset-list" {
216 @for a in &assets {
217 li class="asset-row" {
218 (icon(Icon::File))
219 a class="asset-name" href=(format!("/release-asset/{}", a.id)) { (a.name) }
220 span class="asset-size muted" { (fmt_bytes(a.size)) }
221 }
222 }
223 li class="asset-row source" {
224 (icon(Icon::Download))
225 a class="asset-name" href=(format!("{base}/-/archive/{}.zip", r.tag)) { "Source code (zip)" }
226 }
227 li class="asset-row source" {
228 (icon(Icon::Download))
229 a class="asset-name" href=(format!("{base}/-/archive/{}.tar.gz", r.tag)) { "Source code (tar.gz)" }
230 }
231 }
232 }
233 }
234 }
235}
236
237// ---- Draft / create / edit ----
238
239/// `GET …/-/releases/new` — the draft form.
240pub(crate) async fn new_form(
241 state: &AppState,
242 viewer: Option<User>,
243 jar: CookieJar,
244 uri: &Uri,
245 ctx: RepoCtx,
246) -> AppResult<Response> {
247 if !ctx.repo.releases_enabled || viewer.is_none() || ctx.access < auth::Access::Write {
248 return Err(AppError::NotFound);
249 }
250 let branches = branch_names(state, &ctx.repo.id).await;
251 let tags: Vec<String> = git_read(state, &ctx.repo.id, git::Repo::tags)
252 .await
253 .unwrap_or_default()
254 .into_iter()
255 .map(|t| t.name)
256 .collect();
257 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
258 let body = html! {
259 (repo_header(state, &ctx, Tab::Releases, &ctx.repo.default_branch, &branches))
260 (release_form(
261 &format!("/release-new/{}", ctx.repo.id),
262 &chrome.csrf,
263 None,
264 &branches,
265 &tags,
266 ))
267 };
268 Ok((jar, page(&chrome, "New release", body)).into_response())
269}
270
271/// `GET /release/{id}/edit` — edit an existing release (a focused form page).
272pub async fn edit_form(
273 State(state): State<AppState>,
274 MaybeUser(viewer): MaybeUser,
275 jar: CookieJar,
276 uri: Uri,
277 Path(id): Path<String>,
278) -> AppResult<Response> {
279 let (release, repo, user) = writer_release(&state, viewer.as_ref(), &id).await?;
280 let owner = state
281 .store
282 .user_by_id(&repo.owner_id)
283 .await?
284 .map_or(repo.owner_id.clone(), |u| u.username);
285 let releases = format!("/{owner}/{}/-/releases", repo.path);
286 let back = format!("/{owner}/{}/-/releases/{}", repo.path, release.tag);
287 let crumb = if release.name.is_empty() {
288 &release.tag
289 } else {
290 &release.name
291 };
292 let assets = state.store.list_release_assets(&release.id).await?;
293 let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string());
294 let body = html! {
295 nav class="crumbs" aria-label="Breadcrumb" {
296 a href=(releases) { "Releases" }
297 span class="slug-sep" { "/" }
298 a href=(back) { (crumb) }
299 span class="slug-sep" { "/" }
300 span class="crumb-current" { "Edit" }
301 }
302 (release_form(&format!("/release/{}/edit", release.id), &chrome.csrf, Some(&release), &[], &[]))
303 (assets_editor(&release.id, &chrome.csrf, &assets))
304 };
305 Ok((jar, page(&chrome, "Edit release", body)).into_response())
306}
307
308/// The create/edit form.
309fn release_form(
310 action: &str,
311 csrf: &str,
312 existing: Option<&store::Release>,
313 branches: &[String],
314 tags: &[String],
315) -> Markup {
316 html! {
317 h2 { @if existing.is_some() { "Edit release" } @else { "New release" } }
318 div class="card" {
319 form method="post" action=(action) {
320 input type="hidden" name="_csrf" value=(csrf);
321 @if let Some(r) = existing {
322 label { "Tag" }
323 p class="muted" { (r.tag) }
324 } @else {
325 label for="rl-tag" { "Tag" }
326 input type="text" id="rl-tag" name="tag" list="rl-tags"
327 placeholder="v1.0.0" required autocomplete="off";
328 datalist id="rl-tags" { @for t in tags { option value=(t) {} } }
329 label for="rl-target" { "Target (for a new tag)" }
330 select id="rl-target" name="target" {
331 @for b in branches { option value=(b) { (b) } }
332 }
333 p class="muted field-hint" { "An existing tag is used as-is; a new tag is created from the target branch." }
334 }
335 label for="rl-title" { "Release title" }
336 input type="text" id="rl-title" name="name" value=(existing.map_or("", |r| r.name.as_str()));
337 label for="rl-body" { "Release notes" }
338 textarea id="rl-body" name="body" rows="10" placeholder="Describe this release (markdown supported)" {
339 (existing.and_then(|r| r.body.as_deref()).unwrap_or(""))
340 }
341 div class="release-form-actions" {
342 button class="btn btn-primary inline-btn" type="submit" name="action" value="publish" {
343 @if existing.is_some() { "Update release" } @else { "Publish release" }
344 }
345 @if existing.is_none_or(|r| r.is_draft) {
346 button class="btn inline-btn" type="submit" name="action" value="draft" { "Save draft" }
347 }
348 label class="checkbox" {
349 input type="checkbox" name="prerelease" value="1"
350 checked[existing.is_some_and(|r| r.is_prerelease)];
351 " Pre-release"
352 }
353 }
354 }
355 }
356 }
357}
358
359/// The asset-management card shown on the edit page: existing assets with delete
360/// buttons, then the upload form. Only reachable once a release exists.
361fn assets_editor(release_id: &str, csrf: &str, assets: &[store::ReleaseAsset]) -> Markup {
362 html! {
363 section class="listing release-assets-editor" {
364 h2 { "Assets" }
365 div class="card" {
366 @if assets.is_empty() {
367 p class="muted empty-note" { "No uploaded assets yet." }
368 } @else {
369 ul class="asset-list" {
370 @for a in assets {
371 li class="asset-row" {
372 (icon(Icon::File))
373 a class="asset-name" href=(format!("/release-asset/{}", a.id)) { (a.name) }
374 span class="asset-size muted" { (fmt_bytes(a.size)) }
375 form method="post" action=(format!("/release-asset/{}/delete", a.id)) class="inline-form" {
376 input type="hidden" name="_csrf" value=(csrf);
377 button class="icon-btn" type="submit" aria-label="Delete asset" title="Delete asset" { (icon(Icon::X)) }
378 }
379 }
380 }
381 }
382 }
383 form method="post" action=(format!("/release/{release_id}/asset")) enctype="multipart/form-data" class="asset-upload" {
384 input type="hidden" name="_csrf" value=(csrf);
385 label class="dropzone" {
386 input type="file" name="asset" class="dropzone-input" required;
387 (icon(Icon::Download))
388 span { "Attach a file by dropping it here or selecting it." }
389 }
390 // Hidden by JS, which uploads on selection; the button is the
391 // no-JS fallback.
392 button class="btn inline-btn" type="submit" { "Upload asset" }
393 }
394 }
395 }
396 }
397}
398
399/// Create/edit form body.
400#[derive(Debug, Deserialize)]
401pub struct ReleaseForm {
402 #[serde(default)]
403 tag: String,
404 #[serde(default)]
405 target: String,
406 #[serde(default)]
407 name: String,
408 #[serde(default)]
409 body: String,
410 #[serde(default)]
411 prerelease: Option<String>,
412 #[serde(default)]
413 action: String,
414 #[serde(rename = "_csrf")]
415 csrf: String,
416}
417
418/// `POST /release-new/{repo_id}` — create a release (and its tag if new).
419pub async fn create(
420 State(state): State<AppState>,
421 MaybeUser(viewer): MaybeUser,
422 jar: CookieJar,
423 headers: HeaderMap,
424 Path(repo_id): Path<String>,
425 axum::Form(form): axum::Form<ReleaseForm>,
426) -> Response {
427 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
428 return AppError::Forbidden.into_response();
429 }
430 let result = async {
431 let (repo, user) = writer_repo(&state, viewer.as_ref(), &repo_id).await?;
432 if !repo.releases_enabled {
433 return Err(AppError::NotFound);
434 }
435 let tag = form.tag.trim();
436 if tag.is_empty() {
437 return Err(AppError::BadRequest("A tag is required.".to_string()));
438 }
439 // Use the tag if it exists; otherwise create it from the target branch.
440 let exists = git_read(&state, &repo.id, {
441 let tag = tag.to_string();
442 move |g| Ok(g.resolve_ref(&tag).is_ok())
443 })
444 .await
445 .unwrap_or(false);
446 if !exists {
447 let target = if form.target.trim().is_empty() {
448 repo.default_branch.clone()
449 } else {
450 form.target.trim().to_string()
451 };
452 let tag_owned = tag.to_string();
453 git_read(&state, &repo.id, move |g| {
454 let rev = g.resolve_ref(&target)?;
455 g.create_tag(&tag_owned, &rev)
456 })
457 .await
458 .map_err(|_| {
459 AppError::BadRequest("Could not create the tag from that target.".to_string())
460 })?;
461 }
462 let name = if form.name.trim().is_empty() {
463 tag.to_string()
464 } else {
465 form.name.trim().to_string()
466 };
467 let body = form.body.trim();
468 state
469 .store
470 .create_release(store::NewRelease {
471 repo_id: repo.id.clone(),
472 tag: tag.to_string(),
473 name,
474 body: (!body.is_empty()).then(|| body.to_string()),
475 is_prerelease: form.prerelease.is_some(),
476 is_draft: form.action == "draft",
477 author_id: user.id.clone(),
478 })
479 .await
480 .map_err(|e| match e {
481 store::StoreError::Conflict { .. } => {
482 AppError::BadRequest("A release already exists for that tag.".to_string())
483 }
484 other => AppError::from(other),
485 })?;
486 Ok::<String, AppError>(format!("/{}/{}/-/releases/{tag}", user.username, repo.path))
487 }
488 .await;
489 crate::issues::respond_redirect(result)
490}
491
492/// `POST /release/{id}/edit` — update a release's editable fields.
493pub async fn edit(
494 State(state): State<AppState>,
495 MaybeUser(viewer): MaybeUser,
496 jar: CookieJar,
497 headers: HeaderMap,
498 Path(id): Path<String>,
499 axum::Form(form): axum::Form<ReleaseForm>,
500) -> Response {
501 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
502 return AppError::Forbidden.into_response();
503 }
504 let result = async {
505 let (release, repo, user) = writer_release(&state, viewer.as_ref(), &id).await?;
506 let name = if form.name.trim().is_empty() {
507 release.tag.clone()
508 } else {
509 form.name.trim().to_string()
510 };
511 let body = form.body.trim();
512 state
513 .store
514 .update_release(
515 &release.id,
516 &name,
517 (!body.is_empty()).then_some(body),
518 form.prerelease.is_some(),
519 release.is_draft && form.action == "draft",
520 )
521 .await?;
522 Ok::<String, AppError>(format!(
523 "/{}/{}/-/releases/{}",
524 user.username, repo.path, release.tag
525 ))
526 }
527 .await;
528 crate::issues::respond_redirect(result)
529}
530
531/// The CSRF-only form for asset/release deletion.
532#[derive(Debug, Deserialize)]
533pub struct BareForm {
534 #[serde(rename = "_csrf")]
535 csrf: String,
536}
537
538/// `POST /release/{id}/delete` — delete a release and its asset files.
539pub async fn delete(
540 State(state): State<AppState>,
541 MaybeUser(viewer): MaybeUser,
542 jar: CookieJar,
543 headers: HeaderMap,
544 Path(id): Path<String>,
545 axum::Form(form): axum::Form<BareForm>,
546) -> Response {
547 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
548 return AppError::Forbidden.into_response();
549 }
550 let result = async {
551 let (release, repo, user) = writer_release(&state, viewer.as_ref(), &id).await?;
552 for a in state.store.list_release_assets(&release.id).await? {
553 let _ = state.blobs.delete(blob::Namespace::Releases, &a.id).await;
554 }
555 state.store.delete_release(&release.id).await?;
556 Ok::<String, AppError>(format!("/{}/{}/-/releases", user.username, repo.path))
557 }
558 .await;
559 crate::issues::respond_redirect(result)
560}
561
562// ---- Assets ----
563
564/// Per-process counter for unique upload temp-file names.
565static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
566
567/// `POST /release/{id}/asset` — upload one asset (streamed to disk).
568pub async fn asset_upload(
569 State(state): State<AppState>,
570 MaybeUser(viewer): MaybeUser,
571 jar: CookieJar,
572 Path(id): Path<String>,
573 mut multipart: Multipart,
574) -> Response {
575 let mut csrf: Option<String> = None;
576 let mut override_name: Option<String> = None;
577 let mut saved: Option<(String, String, i64, String)> = None; // (tmp_name, name, size, ctype)
578 while let Ok(Some(mut field)) = multipart.next_field().await {
579 match field.name() {
580 Some("_csrf") => csrf = field.text().await.ok(),
581 Some("name") => override_name = field.text().await.ok(),
582 Some("asset") => {
583 let name = field
584 .file_name()
585 .map(str::to_string)
586 .filter(|n| !n.is_empty());
587 let ctype = field
588 .content_type()
589 .map_or_else(|| "application/octet-stream".to_string(), str::to_string);
590 if let Some(name) = name {
591 let tmp_name = format!(
592 "tmp-{}-{}",
593 std::process::id(),
594 TMP_SEQ.fetch_add(1, Ordering::Relaxed)
595 );
596 let tmp_dir = assets_dir(&state).join("tmp");
597 if tokio::fs::create_dir_all(&tmp_dir).await.is_err() {
598 return AppError::internal("storage error").into_response();
599 }
600 let tmp_path = tmp_dir.join(&tmp_name);
601 let Ok(size) = stream_field(&mut field, &tmp_path).await else {
602 let _ = tokio::fs::remove_file(&tmp_path).await;
603 return AppError::internal("upload failed").into_response();
604 };
605 saved = Some((tmp_name, name, size, ctype));
606 }
607 }
608 _ => {}
609 }
610 }
611 if crate::session::verify_csrf(&jar, &HeaderMap::new(), csrf.as_deref()).is_err() {
612 return AppError::Forbidden.into_response();
613 }
614 // An edited name from the form overrides the uploaded file's own name; keep
615 // only the basename so it can't smuggle a path.
616 if let Some(name) = &mut saved
617 && let Some(ov) = override_name
618 .as_deref()
619 .map(str::trim)
620 .and_then(|s| s.rsplit(['/', '\\']).next())
621 .filter(|s| !s.is_empty())
622 {
623 name.1 = ov.to_string();
624 }
625 let result = async {
626 let (release, _repo, _user) = writer_release(&state, viewer.as_ref(), &id).await?;
627 if let Some((tmp_name, name, size, ctype)) = saved {
628 let asset = state
629 .store
630 .add_release_asset(&release.id, &name, size, &ctype)
631 .await?;
632 // Move the streamed temp file into the blob store (local rename or
633 // S3 upload); a failure rolls the row back.
634 let tmp = assets_dir(&state).join("tmp").join(&tmp_name);
635 if state
636 .blobs
637 .put_file(blob::Namespace::Releases, &asset.id, &tmp, &ctype)
638 .await
639 .is_err()
640 {
641 let _ = tokio::fs::remove_file(&tmp).await;
642 let _ = state.store.delete_release_asset(&asset.id).await;
643 return Err(AppError::internal("storage error"));
644 }
645 }
646 Ok::<String, AppError>(format!("/release/{}/edit", release.id))
647 }
648 .await;
649 match result {
650 Ok(dest) => Redirect::to(&dest).into_response(),
651 Err(e) => e.into_response(),
652 }
653}
654
655/// `GET /release-asset/{id}` — download an asset.
656pub async fn asset_download(
657 State(state): State<AppState>,
658 MaybeUser(viewer): MaybeUser,
659 Path(id): Path<String>,
660) -> AppResult<Response> {
661 let asset = state
662 .store
663 .release_asset_by_id(&id)
664 .await?
665 .ok_or(AppError::NotFound)?;
666 let release = state
667 .store
668 .release_by_id(&asset.release_id)
669 .await?
670 .ok_or(AppError::NotFound)?;
671 // The viewer must be able to read the repo the release belongs to.
672 let _ = require_readable_repo(&state, viewer.as_ref(), &release.repo_id).await?;
673 let Some(read) = state
674 .blobs
675 .open(blob::Namespace::Releases, &asset.id)
676 .await
677 .map_err(AppError::internal)?
678 else {
679 return Err(AppError::NotFound);
680 };
681 let _ = state.store.bump_asset_download(&asset.id).await;
682 let disposition = format!("attachment; filename=\"{}\"", asset.name.replace('"', ""));
683 Ok((
684 [
685 (header::CONTENT_TYPE, asset.content_type.clone()),
686 (header::CONTENT_DISPOSITION, disposition),
687 (header::CONTENT_LENGTH, read.size.to_string()),
688 ],
689 Body::from_stream(ReaderStream::new(read.reader)),
690 )
691 .into_response())
692}
693
694/// `POST /release-asset/{id}/delete` — delete one asset.
695pub async fn asset_delete(
696 State(state): State<AppState>,
697 MaybeUser(viewer): MaybeUser,
698 jar: CookieJar,
699 headers: HeaderMap,
700 Path(id): Path<String>,
701 axum::Form(form): axum::Form<BareForm>,
702) -> Response {
703 if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() {
704 return AppError::Forbidden.into_response();
705 }
706 let result = async {
707 let asset = state
708 .store
709 .release_asset_by_id(&id)
710 .await?
711 .ok_or(AppError::NotFound)?;
712 let (release, _repo, _user) =
713 writer_release(&state, viewer.as_ref(), &asset.release_id).await?;
714 let _ = state
715 .blobs
716 .delete(blob::Namespace::Releases, &asset.id)
717 .await;
718 state.store.delete_release_asset(&asset.id).await?;
719 Ok::<String, AppError>(format!("/release/{}/edit", release.id))
720 }
721 .await;
722 crate::issues::respond_redirect(result)
723}
724
725// ---- Source archives ----
726
727/// `GET …/-/archive/{filename}` — stream a source archive (`{rev}.zip` /
728/// `{rev}.tar.gz`).
729pub(crate) async fn archive(state: &AppState, ctx: RepoCtx, filename: &str) -> Response {
730 let (rev, format) = if let Some(rev) = filename.strip_suffix(".tar.gz") {
731 (rev, "tar.gz")
732 } else if let Some(rev) = filename.strip_suffix(".zip") {
733 (rev, "zip")
734 } else {
735 return StatusCode::NOT_FOUND.into_response();
736 };
737 let rev_owned = rev.to_string();
738 if git_read(state, &ctx.repo.id, move |g| g.resolve_ref(&rev_owned))
739 .await
740 .is_err()
741 {
742 return StatusCode::NOT_FOUND.into_response();
743 }
744 let Ok(repo_dir) = git::repo_path(&state.config.storage.repo_dir, &ctx.repo.id) else {
745 return StatusCode::INTERNAL_SERVER_ERROR.into_response();
746 };
747 let home = state.config.storage.data_dir.join("tmp");
748 let leaf = ctx.repo.path.rsplit('/').next().unwrap_or(&ctx.repo.path);
749 let prefix = format!("{leaf}-{rev}");
750 let Ok(mut child) = git::pack::archive(
751 &state.config.git.binary,
752 &repo_dir,
753 format,
754 rev,
755 &prefix,
756 &home,
757 ) else {
758 return StatusCode::INTERNAL_SERVER_ERROR.into_response();
759 };
760 let Some(stdout) = child.stdout.take() else {
761 return StatusCode::INTERNAL_SERVER_ERROR.into_response();
762 };
763 tokio::spawn(async move {
764 let _ = child.wait().await;
765 });
766 let ctype = if format == "zip" {
767 "application/zip"
768 } else {
769 "application/gzip"
770 };
771 let disposition = format!("attachment; filename=\"{prefix}.{format}\"");
772 (
773 [
774 (header::CONTENT_TYPE, ctype.to_string()),
775 (header::CONTENT_DISPOSITION, disposition),
776 ],
777 Body::from_stream(ReaderStream::new(stdout)),
778 )
779 .into_response()
780}
781
782// ---- Helpers ----
783
784/// Stream a multipart field to `path`, returning the byte count.
785async fn stream_field(
786 field: &mut axum::extract::multipart::Field<'_>,
787 path: &FsPath,
788) -> std::io::Result<i64> {
789 let mut file = tokio::fs::File::create(path).await?;
790 let mut size: i64 = 0;
791 while let Some(chunk) = field.try_next().await.map_err(std::io::Error::other)? {
792 file.write_all(&chunk).await?;
793 size += i64::try_from(chunk.len()).unwrap_or(0);
794 }
795 file.flush().await?;
796 Ok(size)
797}
798
799/// Resolve a repo by id and require the viewer to have write access.
800async fn writer_repo(
801 state: &AppState,
802 viewer: Option<&User>,
803 repo_id: &str,
804) -> Result<(model::Repo, User), AppError> {
805 let user = viewer.cloned().ok_or(AppError::Forbidden)?;
806 let repo = require_readable_repo(state, Some(&user), repo_id).await?;
807 if crate::issues::access_for(state, Some(&user), &repo).await? < auth::Access::Write {
808 return Err(AppError::NotFound);
809 }
810 Ok((repo, user))
811}
812
813/// Load a release by id and require write access on its repo.
814async fn writer_release(
815 state: &AppState,
816 viewer: Option<&User>,
817 release_id: &str,
818) -> Result<(store::Release, model::Repo, User), AppError> {
819 let release = state
820 .store
821 .release_by_id(release_id)
822 .await?
823 .ok_or(AppError::NotFound)?;
824 let (repo, user) = writer_repo(state, viewer, &release.repo_id).await?;
825 Ok((release, repo, user))
826}
827
828/// Human-readable byte size.
829fn fmt_bytes(bytes: i64) -> String {
830 const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
831 #[allow(clippy::cast_precision_loss)]
832 let mut v = bytes.max(0) as f64;
833 let mut u = 0;
834 while v >= 1024.0 && u < UNITS.len() - 1 {
835 v /= 1024.0;
836 u += 1;
837 }
838 if u == 0 {
839 format!("{bytes} B")
840 } else {
841 format!("{v:.1} {}", UNITS[u])
842 }
843}