Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +92 −0
| @@ -2130,6 +2130,98 @@ pre.code { | |||
| 2130 | margin: 0.6rem 0 0; | 2130 | margin: 0.6rem 0 0; |
| 2131 | font-size: 0.9em; | 2131 | font-size: 0.9em; |
| 2132 | } | 2132 | } |
| 2133 | /* Releases. */ | ||
| 2134 | .releases-head { | ||
| 2135 | display: flex; | ||
| 2136 | align-items: center; | ||
| 2137 | justify-content: space-between; | ||
| 2138 | gap: 1rem; | ||
| 2139 | margin-bottom: 1rem; | ||
| 2140 | } | ||
| 2141 | .back-link { | ||
| 2142 | font-size: 0.9em; | ||
| 2143 | } | ||
| 2144 | .release { | ||
| 2145 | margin-bottom: 1.25rem; | ||
| 2146 | } | ||
| 2147 | .release-head { | ||
| 2148 | display: flex; | ||
| 2149 | align-items: flex-start; | ||
| 2150 | justify-content: space-between; | ||
| 2151 | gap: 1rem; | ||
| 2152 | } | ||
| 2153 | .release-title { | ||
| 2154 | margin: 0; | ||
| 2155 | font-size: 1.4rem; | ||
| 2156 | } | ||
| 2157 | .release-title a { | ||
| 2158 | color: var(--fb-fg); | ||
| 2159 | } | ||
| 2160 | .release-meta { | ||
| 2161 | display: flex; | ||
| 2162 | align-items: center; | ||
| 2163 | gap: 0.35rem; | ||
| 2164 | flex-wrap: wrap; | ||
| 2165 | margin-top: 0.35rem; | ||
| 2166 | font-size: 0.9em; | ||
| 2167 | } | ||
| 2168 | .release-actions { | ||
| 2169 | display: flex; | ||
| 2170 | gap: 0.5rem; | ||
| 2171 | flex: none; | ||
| 2172 | } | ||
| 2173 | .release-notes { | ||
| 2174 | margin: 1rem 0; | ||
| 2175 | padding-top: 1rem; | ||
| 2176 | border-top: 1px solid var(--fb-border); | ||
| 2177 | } | ||
| 2178 | .release-assets h3 { | ||
| 2179 | margin: 1rem 0 0.5rem; | ||
| 2180 | } | ||
| 2181 | .asset-list { | ||
| 2182 | list-style: none; | ||
| 2183 | margin: 0; | ||
| 2184 | padding: 0; | ||
| 2185 | border: 1px solid var(--fb-border); | ||
| 2186 | border-radius: var(--fb-radius); | ||
| 2187 | } | ||
| 2188 | .asset-row { | ||
| 2189 | display: flex; | ||
| 2190 | align-items: center; | ||
| 2191 | gap: 0.6rem; | ||
| 2192 | padding: 0.55rem 0.85rem; | ||
| 2193 | border-bottom: 1px solid var(--fb-border); | ||
| 2194 | } | ||
| 2195 | .asset-row:last-child { | ||
| 2196 | border-bottom: none; | ||
| 2197 | } | ||
| 2198 | .asset-name { | ||
| 2199 | flex: 1; | ||
| 2200 | min-width: 0; | ||
| 2201 | overflow: hidden; | ||
| 2202 | text-overflow: ellipsis; | ||
| 2203 | white-space: nowrap; | ||
| 2204 | font-weight: 600; | ||
| 2205 | } | ||
| 2206 | .asset-row.source .asset-name { | ||
| 2207 | font-weight: 400; | ||
| 2208 | } | ||
| 2209 | .asset-size { | ||
| 2210 | flex: none; | ||
| 2211 | font-size: 0.85em; | ||
| 2212 | } | ||
| 2213 | .asset-upload { | ||
| 2214 | display: flex; | ||
| 2215 | align-items: center; | ||
| 2216 | gap: 0.6rem; | ||
| 2217 | margin-top: 0.75rem; | ||
| 2218 | flex-wrap: wrap; | ||
| 2219 | } | ||
| 2220 | .release-form-actions { | ||
| 2221 | display: flex; | ||
| 2222 | gap: 0.6rem; | ||
| 2223 | margin-top: 1rem; | ||
| 2224 | } | ||
| 2133 | /* Notifications inbox. */ | 2225 | /* Notifications inbox. */ |
| 2134 | .notif-head { | 2226 | .notif-head { |
| 2135 | display: flex; | 2227 | display: flex; |
crates/git/src/pack.rs +31 −0
| @@ -112,6 +112,37 @@ pub fn spawn( | |||
| 112 | cmd.spawn() | 112 | cmd.spawn() |
| 113 | } | 113 | } |
| 114 | 114 | ||
| 115 | /// Spawn `git archive` for a source download, streaming the archive on stdout. | ||
| 116 | /// | ||
| 117 | /// `format` is a git archive format (`zip` or `tar.gz`); `prefix` is the top-level | ||
| 118 | /// directory inside the archive (e.g. `repo-v1.0`); `rev` is the tag/branch/commit. | ||
| 119 | /// | ||
| 120 | /// # Errors | ||
| 121 | /// | ||
| 122 | /// Returns the [`std::io::Error`] from spawning if the `git` binary cannot be run. | ||
| 123 | pub fn archive( | ||
| 124 | binary: &str, | ||
| 125 | repo_path: &Path, | ||
| 126 | format: &str, | ||
| 127 | rev: &str, | ||
| 128 | prefix: &str, | ||
| 129 | home: &Path, | ||
| 130 | ) -> std::io::Result<Child> { | ||
| 131 | let mut cmd = Command::new(binary); | ||
| 132 | cmd.arg("archive"); | ||
| 133 | cmd.arg(format!("--format={format}")); | ||
| 134 | cmd.arg(format!("--prefix={prefix}/")); | ||
| 135 | cmd.arg(rev); | ||
| 136 | cmd.env("GIT_DIR", repo_path); | ||
| 137 | cmd.env("GIT_TERMINAL_PROMPT", "0"); | ||
| 138 | cmd.env("GIT_CONFIG_NOSYSTEM", "1"); | ||
| 139 | cmd.env("HOME", home); | ||
| 140 | cmd.stdout(Stdio::piped()); | ||
| 141 | cmd.stderr(Stdio::null()); | ||
| 142 | cmd.kill_on_drop(true); | ||
| 143 | cmd.spawn() | ||
| 144 | } | ||
| 145 | |||
| 115 | /// Encode `payload` as a single git pkt-line (`{len:04x}{payload}`). Used to frame | 146 | /// Encode `payload` as a single git pkt-line (`{len:04x}{payload}`). Used to frame |
| 116 | /// the smart-HTTP service advertisement. | 147 | /// the smart-HTTP service advertisement. |
| 117 | #[must_use] | 148 | #[must_use] |
crates/git/src/repo.rs +18 −0
| @@ -240,6 +240,24 @@ impl Repo { | |||
| 240 | }) | 240 | }) |
| 241 | } | 241 | } |
| 242 | 242 | ||
| 243 | /// Create a lightweight tag `name` pointing at `rev`, failing if it already | ||
| 244 | /// exists (releases must not silently move a tag). For cutting a release from | ||
| 245 | /// a branch/commit. | ||
| 246 | /// | ||
| 247 | /// # Errors | ||
| 248 | /// | ||
| 249 | /// Returns [`GitError::Libgit2`] if the tag exists or the ref cannot be written. | ||
| 250 | pub fn create_tag(&self, name: &str, rev: &Resolved) -> Result<(), GitError> { | ||
| 251 | let oid = git2::Oid::from_str(&rev.oid.to_string())?; | ||
| 252 | self.inner.reference( | ||
| 253 | &format!("refs/tags/{name}"), | ||
| 254 | oid, | ||
| 255 | false, | ||
| 256 | "fabrica release tag", | ||
| 257 | )?; | ||
| 258 | Ok(()) | ||
| 259 | } | ||
| 260 | |||
| 243 | /// The local branches, each with its position (ahead/behind) relative to the | 261 | /// The local branches, each with its position (ahead/behind) relative to the |
| 244 | /// default branch and its tip commit. | 262 | /// default branch and its tip commit. |
| 245 | /// | 263 | /// |
crates/store/src/tests.rs +81 −0
| @@ -857,6 +857,87 @@ async fn fork_parent_and_count_round_trip() { | |||
| 857 | } | 857 | } |
| 858 | 858 | ||
| 859 | #[tokio::test] | 859 | #[tokio::test] |
| 860 | async fn releases_and_assets_round_trip() { | ||
| 861 | for fx in sqlite_fixtures().await { | ||
| 862 | let owner = fx | ||
| 863 | .store | ||
| 864 | .create_user(sample_user("rel", "rel@example.com")) | ||
| 865 | .await | ||
| 866 | .unwrap(); | ||
| 867 | let repo = fx | ||
| 868 | .store | ||
| 869 | .create_repo(NewRepo { | ||
| 870 | owner_id: owner.id.clone(), | ||
| 871 | group_id: None, | ||
| 872 | name: "r".to_string(), | ||
| 873 | path: "r".to_string(), | ||
| 874 | description: None, | ||
| 875 | visibility: model::Visibility::Public, | ||
| 876 | default_branch: "main".to_string(), | ||
| 877 | }) | ||
| 878 | .await | ||
| 879 | .unwrap(); | ||
| 880 | let rel = fx | ||
| 881 | .store | ||
| 882 | .create_release(crate::NewRelease { | ||
| 883 | repo_id: repo.id.clone(), | ||
| 884 | tag: "v1.0".to_string(), | ||
| 885 | name: "First".to_string(), | ||
| 886 | body: Some("notes".to_string()), | ||
| 887 | is_prerelease: false, | ||
| 888 | is_draft: false, | ||
| 889 | author_id: owner.id.clone(), | ||
| 890 | }) | ||
| 891 | .await | ||
| 892 | .unwrap(); | ||
| 893 | // Duplicate tag conflicts. | ||
| 894 | assert!(matches!( | ||
| 895 | fx.store | ||
| 896 | .create_release(crate::NewRelease { | ||
| 897 | repo_id: repo.id.clone(), | ||
| 898 | tag: "v1.0".to_string(), | ||
| 899 | name: "dup".to_string(), | ||
| 900 | body: None, | ||
| 901 | is_prerelease: false, | ||
| 902 | is_draft: false, | ||
| 903 | author_id: owner.id.clone(), | ||
| 904 | }) | ||
| 905 | .await, | ||
| 906 | Err(StoreError::Conflict { .. }) | ||
| 907 | )); | ||
| 908 | assert_eq!(fx.store.list_releases(&repo.id).await.unwrap().len(), 1); | ||
| 909 | assert_eq!( | ||
| 910 | fx.store | ||
| 911 | .release_by_tag(&repo.id, "v1.0") | ||
| 912 | .await | ||
| 913 | .unwrap() | ||
| 914 | .unwrap() | ||
| 915 | .id, | ||
| 916 | rel.id | ||
| 917 | ); | ||
| 918 | |||
| 919 | let asset = fx | ||
| 920 | .store | ||
| 921 | .add_release_asset(&rel.id, "bin.tar.gz", 4096, "application/gzip") | ||
| 922 | .await | ||
| 923 | .unwrap(); | ||
| 924 | assert_eq!( | ||
| 925 | fx.store.list_release_assets(&rel.id).await.unwrap().len(), | ||
| 926 | 1 | ||
| 927 | ); | ||
| 928 | // Deleting the release cascades to its assets. | ||
| 929 | assert!(fx.store.delete_release(&rel.id).await.unwrap()); | ||
| 930 | assert!( | ||
| 931 | fx.store | ||
| 932 | .release_asset_by_id(&asset.id) | ||
| 933 | .await | ||
| 934 | .unwrap() | ||
| 935 | .is_none() | ||
| 936 | ); | ||
| 937 | } | ||
| 938 | } | ||
| 939 | |||
| 940 | #[tokio::test] | ||
| 860 | async fn mirrors_create_list_due_and_delete() { | 941 | async fn mirrors_create_list_due_and_delete() { |
| 861 | for fx in sqlite_fixtures().await { | 942 | for fx in sqlite_fixtures().await { |
| 862 | let owner = fx | 943 | let owner = fx |
crates/web/src/lib.rs +10 −0
| @@ -27,6 +27,7 @@ mod markdown; | |||
| 27 | mod notifications; | 27 | mod notifications; |
| 28 | mod pages; | 28 | mod pages; |
| 29 | mod pulls; | 29 | mod pulls; |
| 30 | mod releases; | ||
| 30 | mod repo; | 31 | mod repo; |
| 31 | mod search; | 32 | mod search; |
| 32 | #[path = "serve.rs"] | 33 | #[path = "serve.rs"] |
| @@ -339,6 +340,15 @@ pub fn build_router(state: AppState) -> Router { | |||
| 339 | .route("/comment/{id}/edit", post(issues::comment_edit)) | 340 | .route("/comment/{id}/edit", post(issues::comment_edit)) |
| 340 | .route("/pull-new/{repo_id}", post(pulls::create)) | 341 | .route("/pull-new/{repo_id}", post(pulls::create)) |
| 341 | .route("/pull/{id}/merge", post(pulls::merge)) | 342 | .route("/pull/{id}/merge", post(pulls::merge)) |
| 343 | .route("/release-new/{repo_id}", post(releases::create)) | ||
| 344 | .route( | ||
| 345 | "/release/{id}/edit", | ||
| 346 | get(releases::edit_form).post(releases::edit), | ||
| 347 | ) | ||
| 348 | .route("/release/{id}/delete", post(releases::delete)) | ||
| 349 | .route("/release/{id}/asset", post(releases::asset_upload)) | ||
| 350 | .route("/release-asset/{id}", get(releases::asset_download)) | ||
| 351 | .route("/release-asset/{id}/delete", post(releases::asset_delete)) | ||
| 342 | .route("/healthz", get(serve_assets::healthz)) | 352 | .route("/healthz", get(serve_assets::healthz)) |
| 343 | .route("/version", get(serve_assets::version)) | 353 | .route("/version", get(serve_assets::version)) |
| 344 | .route("/theme.css", get(serve_assets::theme_default)) | 354 | .route("/theme.css", get(serve_assets::theme_default)) |
crates/web/src/releases.rs +777 −0
| @@ -0,0 +1,777 @@ | |||
| 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 | |||
| 12 | use std::path::{Path as FsPath, PathBuf}; | ||
| 13 | use std::sync::atomic::{AtomicU64, Ordering}; | ||
| 14 | |||
| 15 | use axum::body::Body; | ||
| 16 | use axum::extract::{Multipart, Path, State}; | ||
| 17 | use axum::http::{HeaderMap, StatusCode, Uri, header}; | ||
| 18 | use axum::response::{IntoResponse, Redirect, Response}; | ||
| 19 | use axum_extra::extract::cookie::CookieJar; | ||
| 20 | use futures_util::TryStreamExt as _; | ||
| 21 | use maud::{Markup, html}; | ||
| 22 | use model::User; | ||
| 23 | use serde::Deserialize; | ||
| 24 | use tokio::io::AsyncWriteExt as _; | ||
| 25 | use tokio_util::io::ReaderStream; | ||
| 26 | |||
| 27 | use crate::AppState; | ||
| 28 | use crate::error::{AppError, AppResult}; | ||
| 29 | use crate::icons::{Icon, icon}; | ||
| 30 | use crate::layout::page; | ||
| 31 | use crate::markdown; | ||
| 32 | use crate::pages::build_chrome; | ||
| 33 | use crate::repo::{ | ||
| 34 | Pagination, RepoCtx, Tab, branch_names, git_read, page_slice, pagination_nav, repo_header, | ||
| 35 | require_readable_repo, | ||
| 36 | }; | ||
| 37 | use crate::session::{MaybeUser, verify_csrf}; | ||
| 38 | |||
| 39 | /// The on-disk directory holding release asset files. | ||
| 40 | fn 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. | ||
| 46 | fn 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. | ||
| 54 | pub(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. | ||
| 114 | pub(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 | p { a class="back-link" href=(format!("{base}/-/releases")) { "← Releases" } } | ||
| 151 | (card) | ||
| 152 | }; | ||
| 153 | let title = format!( | ||
| 154 | "{} · {}/{}", | ||
| 155 | release.name, ctx.owner.username, ctx.repo.path | ||
| 156 | ); | ||
| 157 | Ok((jar, page(&chrome, &title, body)).into_response()) | ||
| 158 | } | ||
| 159 | |||
| 160 | /// Render one release: header (title, badges, tag/date/author), notes, assets, | ||
| 161 | /// and source archives. | ||
| 162 | async fn release_card( | ||
| 163 | state: &AppState, | ||
| 164 | base: &str, | ||
| 165 | r: &store::Release, | ||
| 166 | is_latest: bool, | ||
| 167 | can_write: bool, | ||
| 168 | csrf: &str, | ||
| 169 | ) -> Markup { | ||
| 170 | let author = match &r.author_id { | ||
| 171 | Some(id) => crate::issues::username(state, id).await, | ||
| 172 | None => "unknown".to_string(), | ||
| 173 | }; | ||
| 174 | let assets = state | ||
| 175 | .store | ||
| 176 | .list_release_assets(&r.id) | ||
| 177 | .await | ||
| 178 | .unwrap_or_default(); | ||
| 179 | html! { | ||
| 180 | section class="release card" { | ||
| 181 | div class="release-head" { | ||
| 182 | div { | ||
| 183 | h2 class="release-title" { | ||
| 184 | a href=(format!("{base}/-/releases/{}", r.tag)) { (r.name) } | ||
| 185 | @if is_latest { " " span class="badge badge-verified" { "Latest" } } | ||
| 186 | @if r.is_prerelease { " " span class="badge badge-archived" { "pre-release" } } | ||
| 187 | @if r.is_draft { " " span class="badge badge-private" { "draft" } } | ||
| 188 | } | ||
| 189 | div class="release-meta muted" { | ||
| 190 | (icon(Icon::Box)) " " (r.tag) | ||
| 191 | " · " (author) " · " (crate::repo::fmt_date(r.created_at)) | ||
| 192 | } | ||
| 193 | } | ||
| 194 | @if can_write { | ||
| 195 | div class="release-actions" { | ||
| 196 | a class="btn" href=(format!("/release/{}/edit", r.id)) { "Edit" } | ||
| 197 | form method="post" action=(format!("/release/{}/delete", r.id)) class="inline-form" { | ||
| 198 | input type="hidden" name="_csrf" value=(csrf); | ||
| 199 | button class="btn btn-danger" type="submit" { "Delete" } | ||
| 200 | } | ||
| 201 | } | ||
| 202 | } | ||
| 203 | } | ||
| 204 | @if let Some(body) = &r.body { | ||
| 205 | @if !body.trim().is_empty() { | ||
| 206 | div class="release-notes markdown" { (markdown::render_with_refs(body, base)) } | ||
| 207 | } | ||
| 208 | } | ||
| 209 | div class="release-assets" { | ||
| 210 | h3 { "Assets" } | ||
| 211 | ul class="asset-list" { | ||
| 212 | @for a in &assets { | ||
| 213 | li class="asset-row" { | ||
| 214 | (icon(Icon::File)) | ||
| 215 | a class="asset-name" href=(format!("/release-asset/{}", a.id)) { (a.name) } | ||
| 216 | span class="asset-size muted" { (fmt_bytes(a.size)) } | ||
| 217 | @if can_write { | ||
| 218 | form method="post" action=(format!("/release-asset/{}/delete", a.id)) class="inline-form" { | ||
| 219 | input type="hidden" name="_csrf" value=(csrf); | ||
| 220 | button class="icon-btn" type="submit" aria-label="Delete asset" title="Delete asset" { (icon(Icon::X)) } | ||
| 221 | } | ||
| 222 | } | ||
| 223 | } | ||
| 224 | } | ||
| 225 | li class="asset-row source" { | ||
| 226 | (icon(Icon::Download)) | ||
| 227 | a class="asset-name" href=(format!("{base}/-/archive/{}.zip", r.tag)) { "Source code (zip)" } | ||
| 228 | } | ||
| 229 | li class="asset-row source" { | ||
| 230 | (icon(Icon::Download)) | ||
| 231 | a class="asset-name" href=(format!("{base}/-/archive/{}.tar.gz", r.tag)) { "Source code (tar.gz)" } | ||
| 232 | } | ||
| 233 | } | ||
| 234 | @if can_write { | ||
| 235 | form method="post" action=(format!("/release/{}/asset", r.id)) enctype="multipart/form-data" class="asset-upload" { | ||
| 236 | input type="hidden" name="_csrf" value=(csrf); | ||
| 237 | input type="file" name="asset" required; | ||
| 238 | button class="btn inline-btn" type="submit" { "Upload asset" } | ||
| 239 | } | ||
| 240 | } | ||
| 241 | } | ||
| 242 | } | ||
| 243 | } | ||
| 244 | } | ||
| 245 | |||
| 246 | // ---- Draft / create / edit ---- | ||
| 247 | |||
| 248 | /// `GET …/-/releases/new` — the draft form. | ||
| 249 | pub(crate) async fn new_form( | ||
| 250 | state: &AppState, | ||
| 251 | viewer: Option<User>, | ||
| 252 | jar: CookieJar, | ||
| 253 | uri: &Uri, | ||
| 254 | ctx: RepoCtx, | ||
| 255 | ) -> AppResult<Response> { | ||
| 256 | if !ctx.repo.releases_enabled || viewer.is_none() || ctx.access < auth::Access::Write { | ||
| 257 | return Err(AppError::NotFound); | ||
| 258 | } | ||
| 259 | let branches = branch_names(state, &ctx.repo.id).await; | ||
| 260 | let tags: Vec<String> = git_read(state, &ctx.repo.id, git::Repo::tags) | ||
| 261 | .await | ||
| 262 | .unwrap_or_default() | ||
| 263 | .into_iter() | ||
| 264 | .map(|t| t.name) | ||
| 265 | .collect(); | ||
| 266 | let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); | ||
| 267 | let body = html! { | ||
| 268 | (repo_header(state, &ctx, Tab::Releases, &ctx.repo.default_branch, &branches)) | ||
| 269 | (release_form( | ||
| 270 | &format!("/release-new/{}", ctx.repo.id), | ||
| 271 | &chrome.csrf, | ||
| 272 | None, | ||
| 273 | &branches, | ||
| 274 | &tags, | ||
| 275 | )) | ||
| 276 | }; | ||
| 277 | Ok((jar, page(&chrome, "New release", body)).into_response()) | ||
| 278 | } | ||
| 279 | |||
| 280 | /// `GET /release/{id}/edit` — edit an existing release (a focused form page). | ||
| 281 | pub async fn edit_form( | ||
| 282 | State(state): State<AppState>, | ||
| 283 | MaybeUser(viewer): MaybeUser, | ||
| 284 | jar: CookieJar, | ||
| 285 | uri: Uri, | ||
| 286 | Path(id): Path<String>, | ||
| 287 | ) -> AppResult<Response> { | ||
| 288 | let (release, repo, user) = writer_release(&state, viewer.as_ref(), &id).await?; | ||
| 289 | let owner = state | ||
| 290 | .store | ||
| 291 | .user_by_id(&repo.owner_id) | ||
| 292 | .await? | ||
| 293 | .map_or(repo.owner_id.clone(), |u| u.username); | ||
| 294 | let back = format!("/{owner}/{}/-/releases/{}", repo.path, release.tag); | ||
| 295 | let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string()); | ||
| 296 | let body = html! { | ||
| 297 | p { a class="back-link" href=(back) { "← Back to release" } } | ||
| 298 | (release_form(&format!("/release/{}/edit", release.id), &chrome.csrf, Some(&release), &[], &[])) | ||
| 299 | }; | ||
| 300 | Ok((jar, page(&chrome, "Edit release", body)).into_response()) | ||
| 301 | } | ||
| 302 | |||
| 303 | /// The create/edit form. | ||
| 304 | fn release_form( | ||
| 305 | action: &str, | ||
| 306 | csrf: &str, | ||
| 307 | existing: Option<&store::Release>, | ||
| 308 | branches: &[String], | ||
| 309 | tags: &[String], | ||
| 310 | ) -> Markup { | ||
| 311 | html! { | ||
| 312 | h2 { @if existing.is_some() { "Edit release" } @else { "New release" } } | ||
| 313 | div class="card" { | ||
| 314 | form method="post" action=(action) { | ||
| 315 | input type="hidden" name="_csrf" value=(csrf); | ||
| 316 | @if let Some(r) = existing { | ||
| 317 | label { "Tag" } | ||
| 318 | p class="muted" { (r.tag) } | ||
| 319 | } @else { | ||
| 320 | label for="rl-tag" { "Tag" } | ||
| 321 | input type="text" id="rl-tag" name="tag" list="rl-tags" | ||
| 322 | placeholder="v1.0.0" required autocomplete="off"; | ||
| 323 | datalist id="rl-tags" { @for t in tags { option value=(t) {} } } | ||
| 324 | label for="rl-target" { "Target (for a new tag)" } | ||
| 325 | select id="rl-target" name="target" { | ||
| 326 | @for b in branches { option value=(b) { (b) } } | ||
| 327 | } | ||
| 328 | p class="muted field-hint" { "An existing tag is used as-is; a new tag is created from the target branch." } | ||
| 329 | } | ||
| 330 | label for="rl-title" { "Release title" } | ||
| 331 | input type="text" id="rl-title" name="name" value=(existing.map_or("", |r| r.name.as_str())); | ||
| 332 | label for="rl-body" { "Release notes" } | ||
| 333 | textarea id="rl-body" name="body" rows="10" placeholder="Describe this release (markdown supported)" { | ||
| 334 | (existing.and_then(|r| r.body.as_deref()).unwrap_or("")) | ||
| 335 | } | ||
| 336 | label class="checkbox" { | ||
| 337 | input type="checkbox" name="prerelease" value="1" | ||
| 338 | checked[existing.is_some_and(|r| r.is_prerelease)]; | ||
| 339 | " This is a pre-release" | ||
| 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 | } | ||
| 349 | } | ||
| 350 | } | ||
| 351 | } | ||
| 352 | } | ||
| 353 | |||
| 354 | /// Create/edit form body. | ||
| 355 | #[derive(Debug, Deserialize)] | ||
| 356 | pub struct ReleaseForm { | ||
| 357 | #[serde(default)] | ||
| 358 | tag: String, | ||
| 359 | #[serde(default)] | ||
| 360 | target: String, | ||
| 361 | #[serde(default)] | ||
| 362 | name: String, | ||
| 363 | #[serde(default)] | ||
| 364 | body: String, | ||
| 365 | #[serde(default)] | ||
| 366 | prerelease: Option<String>, | ||
| 367 | #[serde(default)] | ||
| 368 | action: String, | ||
| 369 | #[serde(rename = "_csrf")] | ||
| 370 | csrf: String, | ||
| 371 | } | ||
| 372 | |||
| 373 | /// `POST /release-new/{repo_id}` — create a release (and its tag if new). | ||
| 374 | pub async fn create( | ||
| 375 | State(state): State<AppState>, | ||
| 376 | MaybeUser(viewer): MaybeUser, | ||
| 377 | jar: CookieJar, | ||
| 378 | headers: HeaderMap, | ||
| 379 | Path(repo_id): Path<String>, | ||
| 380 | axum::Form(form): axum::Form<ReleaseForm>, | ||
| 381 | ) -> Response { | ||
| 382 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | ||
| 383 | return AppError::Forbidden.into_response(); | ||
| 384 | } | ||
| 385 | let result = async { | ||
| 386 | let (repo, user) = writer_repo(&state, viewer.as_ref(), &repo_id).await?; | ||
| 387 | if !repo.releases_enabled { | ||
| 388 | return Err(AppError::NotFound); | ||
| 389 | } | ||
| 390 | let tag = form.tag.trim(); | ||
| 391 | if tag.is_empty() { | ||
| 392 | return Err(AppError::BadRequest("A tag is required.".to_string())); | ||
| 393 | } | ||
| 394 | // Use the tag if it exists; otherwise create it from the target branch. | ||
| 395 | let exists = git_read(&state, &repo.id, { | ||
| 396 | let tag = tag.to_string(); | ||
| 397 | move |g| Ok(g.resolve_ref(&tag).is_ok()) | ||
| 398 | }) | ||
| 399 | .await | ||
| 400 | .unwrap_or(false); | ||
| 401 | if !exists { | ||
| 402 | let target = if form.target.trim().is_empty() { | ||
| 403 | repo.default_branch.clone() | ||
| 404 | } else { | ||
| 405 | form.target.trim().to_string() | ||
| 406 | }; | ||
| 407 | let tag_owned = tag.to_string(); | ||
| 408 | git_read(&state, &repo.id, move |g| { | ||
| 409 | let rev = g.resolve_ref(&target)?; | ||
| 410 | g.create_tag(&tag_owned, &rev) | ||
| 411 | }) | ||
| 412 | .await | ||
| 413 | .map_err(|_| { | ||
| 414 | AppError::BadRequest("Could not create the tag from that target.".to_string()) | ||
| 415 | })?; | ||
| 416 | } | ||
| 417 | let name = if form.name.trim().is_empty() { | ||
| 418 | tag.to_string() | ||
| 419 | } else { | ||
| 420 | form.name.trim().to_string() | ||
| 421 | }; | ||
| 422 | let body = form.body.trim(); | ||
| 423 | state | ||
| 424 | .store | ||
| 425 | .create_release(store::NewRelease { | ||
| 426 | repo_id: repo.id.clone(), | ||
| 427 | tag: tag.to_string(), | ||
| 428 | name, | ||
| 429 | body: (!body.is_empty()).then(|| body.to_string()), | ||
| 430 | is_prerelease: form.prerelease.is_some(), | ||
| 431 | is_draft: form.action == "draft", | ||
| 432 | author_id: user.id.clone(), | ||
| 433 | }) | ||
| 434 | .await | ||
| 435 | .map_err(|e| match e { | ||
| 436 | store::StoreError::Conflict { .. } => { | ||
| 437 | AppError::BadRequest("A release already exists for that tag.".to_string()) | ||
| 438 | } | ||
| 439 | other => AppError::from(other), | ||
| 440 | })?; | ||
| 441 | Ok::<String, AppError>(format!("/{}/{}/-/releases/{tag}", user.username, repo.path)) | ||
| 442 | } | ||
| 443 | .await; | ||
| 444 | crate::issues::respond_redirect(result) | ||
| 445 | } | ||
| 446 | |||
| 447 | /// `POST /release/{id}/edit` — update a release's editable fields. | ||
| 448 | pub async fn edit( | ||
| 449 | State(state): State<AppState>, | ||
| 450 | MaybeUser(viewer): MaybeUser, | ||
| 451 | jar: CookieJar, | ||
| 452 | headers: HeaderMap, | ||
| 453 | Path(id): Path<String>, | ||
| 454 | axum::Form(form): axum::Form<ReleaseForm>, | ||
| 455 | ) -> Response { | ||
| 456 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | ||
| 457 | return AppError::Forbidden.into_response(); | ||
| 458 | } | ||
| 459 | let result = async { | ||
| 460 | let (release, repo, user) = writer_release(&state, viewer.as_ref(), &id).await?; | ||
| 461 | let name = if form.name.trim().is_empty() { | ||
| 462 | release.tag.clone() | ||
| 463 | } else { | ||
| 464 | form.name.trim().to_string() | ||
| 465 | }; | ||
| 466 | let body = form.body.trim(); | ||
| 467 | state | ||
| 468 | .store | ||
| 469 | .update_release( | ||
| 470 | &release.id, | ||
| 471 | &name, | ||
| 472 | (!body.is_empty()).then_some(body), | ||
| 473 | form.prerelease.is_some(), | ||
| 474 | release.is_draft && form.action == "draft", | ||
| 475 | ) | ||
| 476 | .await?; | ||
| 477 | Ok::<String, AppError>(format!( | ||
| 478 | "/{}/{}/-/releases/{}", | ||
| 479 | user.username, repo.path, release.tag | ||
| 480 | )) | ||
| 481 | } | ||
| 482 | .await; | ||
| 483 | crate::issues::respond_redirect(result) | ||
| 484 | } | ||
| 485 | |||
| 486 | /// The CSRF-only form for asset/release deletion. | ||
| 487 | #[derive(Debug, Deserialize)] | ||
| 488 | pub struct BareForm { | ||
| 489 | #[serde(rename = "_csrf")] | ||
| 490 | csrf: String, | ||
| 491 | } | ||
| 492 | |||
| 493 | /// `POST /release/{id}/delete` — delete a release and its asset files. | ||
| 494 | pub async fn delete( | ||
| 495 | State(state): State<AppState>, | ||
| 496 | MaybeUser(viewer): MaybeUser, | ||
| 497 | jar: CookieJar, | ||
| 498 | headers: HeaderMap, | ||
| 499 | Path(id): Path<String>, | ||
| 500 | axum::Form(form): axum::Form<BareForm>, | ||
| 501 | ) -> Response { | ||
| 502 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | ||
| 503 | return AppError::Forbidden.into_response(); | ||
| 504 | } | ||
| 505 | let result = async { | ||
| 506 | let (release, repo, user) = writer_release(&state, viewer.as_ref(), &id).await?; | ||
| 507 | for a in state.store.list_release_assets(&release.id).await? { | ||
| 508 | let _ = tokio::fs::remove_file(assets_dir(&state).join(&a.id)).await; | ||
| 509 | } | ||
| 510 | state.store.delete_release(&release.id).await?; | ||
| 511 | Ok::<String, AppError>(format!("/{}/{}/-/releases", user.username, repo.path)) | ||
| 512 | } | ||
| 513 | .await; | ||
| 514 | crate::issues::respond_redirect(result) | ||
| 515 | } | ||
| 516 | |||
| 517 | // ---- Assets ---- | ||
| 518 | |||
| 519 | /// Per-process counter for unique upload temp-file names. | ||
| 520 | static TMP_SEQ: AtomicU64 = AtomicU64::new(0); | ||
| 521 | |||
| 522 | /// `POST /release/{id}/asset` — upload one asset (streamed to disk). | ||
| 523 | pub async fn asset_upload( | ||
| 524 | State(state): State<AppState>, | ||
| 525 | MaybeUser(viewer): MaybeUser, | ||
| 526 | jar: CookieJar, | ||
| 527 | Path(id): Path<String>, | ||
| 528 | mut multipart: Multipart, | ||
| 529 | ) -> Response { | ||
| 530 | let mut csrf: Option<String> = None; | ||
| 531 | let mut saved: Option<(String, String, i64, String)> = None; // (tmp_name, name, size, ctype) | ||
| 532 | while let Ok(Some(mut field)) = multipart.next_field().await { | ||
| 533 | match field.name() { | ||
| 534 | Some("_csrf") => csrf = field.text().await.ok(), | ||
| 535 | Some("asset") => { | ||
| 536 | let name = field | ||
| 537 | .file_name() | ||
| 538 | .map(str::to_string) | ||
| 539 | .filter(|n| !n.is_empty()); | ||
| 540 | let ctype = field | ||
| 541 | .content_type() | ||
| 542 | .map_or_else(|| "application/octet-stream".to_string(), str::to_string); | ||
| 543 | if let Some(name) = name { | ||
| 544 | let tmp_name = format!( | ||
| 545 | "tmp-{}-{}", | ||
| 546 | std::process::id(), | ||
| 547 | TMP_SEQ.fetch_add(1, Ordering::Relaxed) | ||
| 548 | ); | ||
| 549 | let tmp_dir = assets_dir(&state).join("tmp"); | ||
| 550 | if tokio::fs::create_dir_all(&tmp_dir).await.is_err() { | ||
| 551 | return AppError::internal("storage error").into_response(); | ||
| 552 | } | ||
| 553 | let tmp_path = tmp_dir.join(&tmp_name); | ||
| 554 | let Ok(size) = stream_field(&mut field, &tmp_path).await else { | ||
| 555 | let _ = tokio::fs::remove_file(&tmp_path).await; | ||
| 556 | return AppError::internal("upload failed").into_response(); | ||
| 557 | }; | ||
| 558 | saved = Some((tmp_name, name, size, ctype)); | ||
| 559 | } | ||
| 560 | } | ||
| 561 | _ => {} | ||
| 562 | } | ||
| 563 | } | ||
| 564 | if crate::session::verify_csrf(&jar, &HeaderMap::new(), csrf.as_deref()).is_err() { | ||
| 565 | return AppError::Forbidden.into_response(); | ||
| 566 | } | ||
| 567 | let result = async { | ||
| 568 | let (release, repo, user) = writer_release(&state, viewer.as_ref(), &id).await?; | ||
| 569 | if let Some((tmp_name, name, size, ctype)) = saved { | ||
| 570 | let asset = state | ||
| 571 | .store | ||
| 572 | .add_release_asset(&release.id, &name, size, &ctype) | ||
| 573 | .await?; | ||
| 574 | let dest = assets_dir(&state).join(&asset.id); | ||
| 575 | let tmp = assets_dir(&state).join("tmp").join(&tmp_name); | ||
| 576 | if tokio::fs::rename(&tmp, &dest).await.is_err() { | ||
| 577 | let _ = tokio::fs::remove_file(&tmp).await; | ||
| 578 | let _ = state.store.delete_release_asset(&asset.id).await; | ||
| 579 | return Err(AppError::internal("storage error")); | ||
| 580 | } | ||
| 581 | } | ||
| 582 | Ok::<String, AppError>(format!( | ||
| 583 | "/{}/{}/-/releases/{}", | ||
| 584 | user.username, repo.path, release.tag | ||
| 585 | )) | ||
| 586 | } | ||
| 587 | .await; | ||
| 588 | match result { | ||
| 589 | Ok(dest) => Redirect::to(&dest).into_response(), | ||
| 590 | Err(e) => e.into_response(), | ||
| 591 | } | ||
| 592 | } | ||
| 593 | |||
| 594 | /// `GET /release-asset/{id}` — download an asset. | ||
| 595 | pub async fn asset_download( | ||
| 596 | State(state): State<AppState>, | ||
| 597 | MaybeUser(viewer): MaybeUser, | ||
| 598 | Path(id): Path<String>, | ||
| 599 | ) -> AppResult<Response> { | ||
| 600 | let asset = state | ||
| 601 | .store | ||
| 602 | .release_asset_by_id(&id) | ||
| 603 | .await? | ||
| 604 | .ok_or(AppError::NotFound)?; | ||
| 605 | let release = state | ||
| 606 | .store | ||
| 607 | .release_by_id(&asset.release_id) | ||
| 608 | .await? | ||
| 609 | .ok_or(AppError::NotFound)?; | ||
| 610 | // The viewer must be able to read the repo the release belongs to. | ||
| 611 | let _ = require_readable_repo(&state, viewer.as_ref(), &release.repo_id).await?; | ||
| 612 | let path = assets_dir(&state).join(&asset.id); | ||
| 613 | let Ok(file) = tokio::fs::File::open(&path).await else { | ||
| 614 | return Err(AppError::NotFound); | ||
| 615 | }; | ||
| 616 | let _ = state.store.bump_asset_download(&asset.id).await; | ||
| 617 | let disposition = format!("attachment; filename=\"{}\"", asset.name.replace('"', "")); | ||
| 618 | Ok(( | ||
| 619 | [ | ||
| 620 | (header::CONTENT_TYPE, asset.content_type.clone()), | ||
| 621 | (header::CONTENT_DISPOSITION, disposition), | ||
| 622 | ], | ||
| 623 | Body::from_stream(ReaderStream::new(file)), | ||
| 624 | ) | ||
| 625 | .into_response()) | ||
| 626 | } | ||
| 627 | |||
| 628 | /// `POST /release-asset/{id}/delete` — delete one asset. | ||
| 629 | pub async fn asset_delete( | ||
| 630 | State(state): State<AppState>, | ||
| 631 | MaybeUser(viewer): MaybeUser, | ||
| 632 | jar: CookieJar, | ||
| 633 | headers: HeaderMap, | ||
| 634 | Path(id): Path<String>, | ||
| 635 | axum::Form(form): axum::Form<BareForm>, | ||
| 636 | ) -> Response { | ||
| 637 | if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { | ||
| 638 | return AppError::Forbidden.into_response(); | ||
| 639 | } | ||
| 640 | let result = async { | ||
| 641 | let asset = state | ||
| 642 | .store | ||
| 643 | .release_asset_by_id(&id) | ||
| 644 | .await? | ||
| 645 | .ok_or(AppError::NotFound)?; | ||
| 646 | let (release, repo, user) = | ||
| 647 | writer_release(&state, viewer.as_ref(), &asset.release_id).await?; | ||
| 648 | let _ = tokio::fs::remove_file(assets_dir(&state).join(&asset.id)).await; | ||
| 649 | state.store.delete_release_asset(&asset.id).await?; | ||
| 650 | Ok::<String, AppError>(format!( | ||
| 651 | "/{}/{}/-/releases/{}", | ||
| 652 | user.username, repo.path, release.tag | ||
| 653 | )) | ||
| 654 | } | ||
| 655 | .await; | ||
| 656 | crate::issues::respond_redirect(result) | ||
| 657 | } | ||
| 658 | |||
| 659 | // ---- Source archives ---- | ||
| 660 | |||
| 661 | /// `GET …/-/archive/{filename}` — stream a source archive (`{rev}.zip` / | ||
| 662 | /// `{rev}.tar.gz`). | ||
| 663 | pub(crate) async fn archive(state: &AppState, ctx: RepoCtx, filename: &str) -> Response { | ||
| 664 | let (rev, format) = if let Some(rev) = filename.strip_suffix(".tar.gz") { | ||
| 665 | (rev, "tar.gz") | ||
| 666 | } else if let Some(rev) = filename.strip_suffix(".zip") { | ||
| 667 | (rev, "zip") | ||
| 668 | } else { | ||
| 669 | return StatusCode::NOT_FOUND.into_response(); | ||
| 670 | }; | ||
| 671 | let rev_owned = rev.to_string(); | ||
| 672 | if git_read(state, &ctx.repo.id, move |g| g.resolve_ref(&rev_owned)) | ||
| 673 | .await | ||
| 674 | .is_err() | ||
| 675 | { | ||
| 676 | return StatusCode::NOT_FOUND.into_response(); | ||
| 677 | } | ||
| 678 | let Ok(repo_dir) = git::repo_path(&state.config.storage.repo_dir, &ctx.repo.id) else { | ||
| 679 | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); | ||
| 680 | }; | ||
| 681 | let home = state.config.storage.data_dir.join("tmp"); | ||
| 682 | let leaf = ctx.repo.path.rsplit('/').next().unwrap_or(&ctx.repo.path); | ||
| 683 | let prefix = format!("{leaf}-{rev}"); | ||
| 684 | let Ok(mut child) = git::pack::archive( | ||
| 685 | &state.config.git.binary, | ||
| 686 | &repo_dir, | ||
| 687 | format, | ||
| 688 | rev, | ||
| 689 | &prefix, | ||
| 690 | &home, | ||
| 691 | ) else { | ||
| 692 | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); | ||
| 693 | }; | ||
| 694 | let Some(stdout) = child.stdout.take() else { | ||
| 695 | return StatusCode::INTERNAL_SERVER_ERROR.into_response(); | ||
| 696 | }; | ||
| 697 | tokio::spawn(async move { | ||
| 698 | let _ = child.wait().await; | ||
| 699 | }); | ||
| 700 | let ctype = if format == "zip" { | ||
| 701 | "application/zip" | ||
| 702 | } else { | ||
| 703 | "application/gzip" | ||
| 704 | }; | ||
| 705 | let disposition = format!("attachment; filename=\"{prefix}.{format}\""); | ||
| 706 | ( | ||
| 707 | [ | ||
| 708 | (header::CONTENT_TYPE, ctype.to_string()), | ||
| 709 | (header::CONTENT_DISPOSITION, disposition), | ||
| 710 | ], | ||
| 711 | Body::from_stream(ReaderStream::new(stdout)), | ||
| 712 | ) | ||
| 713 | .into_response() | ||
| 714 | } | ||
| 715 | |||
| 716 | // ---- Helpers ---- | ||
| 717 | |||
| 718 | /// Stream a multipart field to `path`, returning the byte count. | ||
| 719 | async fn stream_field( | ||
| 720 | field: &mut axum::extract::multipart::Field<'_>, | ||
| 721 | path: &FsPath, | ||
| 722 | ) -> std::io::Result<i64> { | ||
| 723 | let mut file = tokio::fs::File::create(path).await?; | ||
| 724 | let mut size: i64 = 0; | ||
| 725 | while let Some(chunk) = field.try_next().await.map_err(std::io::Error::other)? { | ||
| 726 | file.write_all(&chunk).await?; | ||
| 727 | size += i64::try_from(chunk.len()).unwrap_or(0); | ||
| 728 | } | ||
| 729 | file.flush().await?; | ||
| 730 | Ok(size) | ||
| 731 | } | ||
| 732 | |||
| 733 | /// Resolve a repo by id and require the viewer to have write access. | ||
| 734 | async fn writer_repo( | ||
| 735 | state: &AppState, | ||
| 736 | viewer: Option<&User>, | ||
| 737 | repo_id: &str, | ||
| 738 | ) -> Result<(model::Repo, User), AppError> { | ||
| 739 | let user = viewer.cloned().ok_or(AppError::Forbidden)?; | ||
| 740 | let repo = require_readable_repo(state, Some(&user), repo_id).await?; | ||
| 741 | if crate::issues::access_for(state, Some(&user), &repo).await? < auth::Access::Write { | ||
| 742 | return Err(AppError::NotFound); | ||
| 743 | } | ||
| 744 | Ok((repo, user)) | ||
| 745 | } | ||
| 746 | |||
| 747 | /// Load a release by id and require write access on its repo. | ||
| 748 | async fn writer_release( | ||
| 749 | state: &AppState, | ||
| 750 | viewer: Option<&User>, | ||
| 751 | release_id: &str, | ||
| 752 | ) -> Result<(store::Release, model::Repo, User), AppError> { | ||
| 753 | let release = state | ||
| 754 | .store | ||
| 755 | .release_by_id(release_id) | ||
| 756 | .await? | ||
| 757 | .ok_or(AppError::NotFound)?; | ||
| 758 | let (repo, user) = writer_repo(state, viewer, &release.repo_id).await?; | ||
| 759 | Ok((release, repo, user)) | ||
| 760 | } | ||
| 761 | |||
| 762 | /// Human-readable byte size. | ||
| 763 | fn fmt_bytes(bytes: i64) -> String { | ||
| 764 | const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; | ||
| 765 | #[allow(clippy::cast_precision_loss)] | ||
| 766 | let mut v = bytes.max(0) as f64; | ||
| 767 | let mut u = 0; | ||
| 768 | while v >= 1024.0 && u < UNITS.len() - 1 { | ||
| 769 | v /= 1024.0; | ||
| 770 | u += 1; | ||
| 771 | } | ||
| 772 | if u == 0 { | ||
| 773 | format!("{bytes} B") | ||
| 774 | } else { | ||
| 775 | format!("{v:.1} {}", UNITS[u]) | ||
| 776 | } | ||
| 777 | } | ||
crates/web/src/repo.rs +22 −1
| @@ -65,6 +65,7 @@ pub(crate) enum Tab { | |||
| 65 | Commits, | 65 | Commits, |
| 66 | Branches, | 66 | Branches, |
| 67 | Tags, | 67 | Tags, |
| 68 | Releases, | ||
| 68 | Search, | 69 | Search, |
| 69 | Settings, | 70 | Settings, |
| 70 | } | 71 | } |
| @@ -710,6 +711,12 @@ async fn dispatch_view( | |||
| 710 | }, | 711 | }, |
| 711 | } | 712 | } |
| 712 | } | 713 | } |
| 714 | "releases" => match rest { | ||
| 715 | "" => crate::releases::list(state, viewer, jar, uri, ctx).await, | ||
| 716 | "new" => crate::releases::new_form(state, viewer, jar, uri, ctx).await, | ||
| 717 | tag => crate::releases::view(state, viewer, jar, uri, ctx, tag).await, | ||
| 718 | }, | ||
| 719 | "archive" => Ok(crate::releases::archive(state, ctx, rest).await), | ||
| 713 | "settings" => repo_settings_view(state, viewer, jar, uri, ctx).await, | 720 | "settings" => repo_settings_view(state, viewer, jar, uri, ctx).await, |
| 714 | // Reserved `runs` slot returns 404 until built. | 721 | // Reserved `runs` slot returns 404 until built. |
| 715 | _ => Err(AppError::NotFound), | 722 | _ => Err(AppError::NotFound), |
| @@ -1324,6 +1331,10 @@ async fn repo_settings_view( | |||
| 1324 | input type="checkbox" name="pulls_enabled" value="1" | 1331 | input type="checkbox" name="pulls_enabled" value="1" |
| 1325 | checked[ctx.repo.pulls_enabled]; " Pull requests" | 1332 | checked[ctx.repo.pulls_enabled]; " Pull requests" |
| 1326 | } | 1333 | } |
| 1334 | label class="checkbox" { | ||
| 1335 | input type="checkbox" name="releases_enabled" value="1" | ||
| 1336 | checked[ctx.repo.releases_enabled]; " Releases" | ||
| 1337 | } | ||
| 1327 | } | 1338 | } |
| 1328 | div class="settings-actions" { | 1339 | div class="settings-actions" { |
| 1329 | button class="btn btn-primary" type="submit" form="repo-general" { "Save changes" } | 1340 | button class="btn btn-primary" type="submit" form="repo-general" { "Save changes" } |
| @@ -1824,7 +1835,7 @@ pub async fn settings_mirror_sync( | |||
| 1824 | } | 1835 | } |
| 1825 | 1836 | ||
| 1826 | /// Load a repo by id and require the viewer to be able to read it, else `404`. | 1837 | /// Load a repo by id and require the viewer to be able to read it, else `404`. |
| 1827 | async fn require_readable_repo( | 1838 | pub(crate) async fn require_readable_repo( |
| 1828 | state: &AppState, | 1839 | state: &AppState, |
| 1829 | viewer: Option<&User>, | 1840 | viewer: Option<&User>, |
| 1830 | repo_id: &str, | 1841 | repo_id: &str, |
| @@ -2100,6 +2111,9 @@ pub struct RepoGeneralForm { | |||
| 2100 | /// Enable pull requests. | 2111 | /// Enable pull requests. |
| 2101 | #[serde(default)] | 2112 | #[serde(default)] |
| 2102 | pulls_enabled: Option<String>, | 2113 | pulls_enabled: Option<String>, |
| 2114 | /// Enable releases. | ||
| 2115 | #[serde(default)] | ||
| 2116 | releases_enabled: Option<String>, | ||
| 2103 | /// CSRF token. | 2117 | /// CSRF token. |
| 2104 | #[serde(rename = "_csrf")] | 2118 | #[serde(rename = "_csrf")] |
| 2105 | csrf: String, | 2119 | csrf: String, |
| @@ -2148,6 +2162,10 @@ pub async fn settings_general( | |||
| 2148 | .store | 2162 | .store |
| 2149 | .set_feature_enabled(&repo.id, "pulls", form.pulls_enabled.is_some()) | 2163 | .set_feature_enabled(&repo.id, "pulls", form.pulls_enabled.is_some()) |
| 2150 | .await?; | 2164 | .await?; |
| 2165 | state | ||
| 2166 | .store | ||
| 2167 | .set_feature_enabled(&repo.id, "releases", form.releases_enabled.is_some()) | ||
| 2168 | .await?; | ||
| 2151 | let owner = state | 2169 | let owner = state |
| 2152 | .store | 2170 | .store |
| 2153 | .user_by_id(&repo.owner_id) | 2171 | .user_by_id(&repo.owner_id) |
| @@ -3160,6 +3178,9 @@ pub(crate) fn repo_header( | |||
| 3160 | (tab(Tab::Commits, "Commits", Icon::History, format!("{base}/-/commits/{rev}"))) | 3178 | (tab(Tab::Commits, "Commits", Icon::History, format!("{base}/-/commits/{rev}"))) |
| 3161 | (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches"))) | 3179 | (tab(Tab::Branches, "Branches", Icon::GitBranch, format!("{base}/-/branches"))) |
| 3162 | (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags"))) | 3180 | (tab(Tab::Tags, "Tags", Icon::Box, format!("{base}/-/tags"))) |
| 3181 | @if ctx.repo.releases_enabled { | ||
| 3182 | (tab(Tab::Releases, "Releases", Icon::Download, format!("{base}/-/releases"))) | ||
| 3183 | } | ||
| 3163 | @if ctx.access == auth::Access::Admin { | 3184 | @if ctx.access == auth::Access::Admin { |
| 3164 | (tab(Tab::Settings, "Settings", Icon::Settings, format!("{base}/-/settings"))) | 3185 | (tab(Tab::Settings, "Settings", Icon::Settings, format!("{base}/-/settings"))) |
| 3165 | } | 3186 | } |
data/fabrica/trash/01kydxcsngxmfpk2bzfdt0sfmx-1785056820303.git/HEAD +0 −0
No textual changes.
data/fabrica/trash/01kydxcsngxmfpk2bzfdt0sfmx-1785056820303.git/config +0 −0
No textual changes.
data/fabrica/trash/01kydxcsngxmfpk2bzfdt0sfmx-1785056820303.git/description +0 −0
No textual changes.
data/fabrica/trash/01kydxcsngxmfpk2bzfdt0sfmx-1785056820303.git/hooks/README.sample +0 −0
No textual changes.
data/fabrica/trash/01kydxcsngxmfpk2bzfdt0sfmx-1785056820303.git/hooks/post-receive +0 −0
No textual changes.
data/fabrica/trash/01kydxcsngxmfpk2bzfdt0sfmx-1785056820303.git/hooks/pre-receive +0 −0
No textual changes.
data/fabrica/trash/01kydxcsngxmfpk2bzfdt0sfmx-1785056820303.git/info/exclude +0 −0
No textual changes.