fabrica

hanna/fabrica

feat(web): releases — list, detail, drafts, assets, source archives

16a2dd1 · hanna committed on 2026-07-26

Build the releases UI on the existing store foundation:

- A Releases repo tab (behind the per-repo releases_enabled toggle, added
  to repo settings alongside issues/PRs) and /-/releases list + detail,
  GitHub-style: title, Latest/pre-release/draft badges, author, date,
  markdown notes, assets, and source-code zip/tar.gz downloads.
- Draft/create a release from an existing tag or a new tag cut from a
  target branch (git::Repo::create_tag); edit and delete; publish vs save
  draft. Drafts are hidden from non-writers.
- Asset upload (multipart, streamed to disk under data_dir/releases),
  download (with a download counter), and delete.
- Source archives via `git archive` (git::pack::archive), streamed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
14 files changed · +1031 −1UnifiedSplit
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}
114114
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.
123pub 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 frame146/// 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 }
242242
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 the261 /// 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}
858858
859#[tokio::test]859#[tokio::test]
860async 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]
860async fn mirrors_create_list_due_and_delete() {941async fn mirrors_create_list_due_and_delete() {
861 for fx in sqlite_fixtures().await {942 for fx in sqlite_fixtures().await {
862 let owner = fx943 let owner = fx
crates/web/src/lib.rs +10 −0
@@ -27,6 +27,7 @@ mod markdown;
27mod notifications;27mod notifications;
28mod pages;28mod pages;
29mod pulls;29mod pulls;
30mod releases;
30mod repo;31mod repo;
31mod search;32mod 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
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 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.
162async 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.
249pub(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).
281pub 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.
304fn 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)]
356pub 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).
374pub 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.
448pub 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)]
488pub struct BareForm {
489 #[serde(rename = "_csrf")]
490 csrf: String,
491}
492
493/// `POST /release/{id}/delete` — delete a release and its asset files.
494pub 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.
520static TMP_SEQ: AtomicU64 = AtomicU64::new(0);
521
522/// `POST /release/{id}/asset` — upload one asset (streamed to disk).
523pub 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.
595pub 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.
629pub 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`).
663pub(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.
719async 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.
734async 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.
748async 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.
763fn 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}
18251836
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`.
1827async fn require_readable_repo(1838pub(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 .store2162 .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 = state2169 let owner = state
2152 .store2170 .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.