//! Pages: releases (`/{owner}/{repo}/releases`). Anyone who can read the repo sees its published //! releases and downloads their assets. Drafts, and every change (create, edit, delete, asset //! upload and delete), need Write; anyone else gets a 404 for drafts and a refusal for changes. //! //! A release names a git tag. When the tag does not exist yet it is created as a lightweight tag //! at the head of the chosen branch, through `after_ref_update`. Deleting a release keeps the tag. //! Asset bytes are streamed to and from the blob store (see `blobs.rs`). Downloads are served as //! attachments with `nosniff`, and each completed start counts one download. use forge_core::releases::{ MAX_BODY_CHARS, MAX_TITLE_CHARS, NewAsset, NewRelease, Release, ReleaseAsset, ReleaseEdit, clean_content_type, valid_asset_name, }; use forge_core::webhooks::WebhookEvent; use forge_core::{Action, Denied, Repo as CoreRepo, User, authorize}; use forge_git::browse::RefKind; use forge_git::tags::{create_lightweight_tag, valid_tag_name}; use futures_util::TryStreamExt; use http::header::{CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_TYPE}; use http_body::Frame; use http_body_util::StreamBody; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, forbidden, not_found, see_other}; use topcoat::router::request::headers; use topcoat::router::response::Response; use topcoat::router::{Body, HeaderName, HeaderValue, StatusCode, page, path_param, route}; use topcoat::view::{Unescaped, View, component, view}; use crate::auth::require_csrf; use crate::blobs::{BlobWriter, asset_key}; use crate::i18n::{ self, Key, Lang, releases_ci::{self as g, K}, }; use crate::pages::refs::load_refs; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo}; use crate::pages::repo_mirrors::stamp_in_tz; use crate::post_update::after_ref_update; use crate::render::markdown::render_markdown; use crate::state::AppState; use crate::views::{alert, enc, shell, user_facing_message, user_link}; use crate::webhooks::emit::{Sender, emit}; path_param!(owner); path_param!(repo); path_param!(release_id); path_param!(asset_id); path_param!(tag); path_param!(name); /// Multipart framing allowed on top of the file itself when checking `Content-Length`. const MULTIPART_OVERHEAD: u64 = 64 * 1024; /// The all-zero object id that means "no ref yet" in ref updates. fn zero_oid(len: usize) -> String { "0".repeat(len) } /// Notice words (the only text a `?notice=` query can show), their text and whether they are errors. const NOTICES: [(&str, K, bool); 11] = [ ("saved", K::RelSaved, false), ("deleted", K::RelDeleted, false), ("asset-uploaded", K::RelAssetUploaded, false), ("asset-deleted", K::RelAssetDeleted, false), ("asset-too-large", K::RelAssetTooLarge, true), ("asset-exists", K::RelAssetExists, true), ("asset-name", K::RelAssetName, true), ("asset-invalid", K::RelAssetInvalid, true), ("confirm", K::RelConfirm, true), ("invalid", K::RelInvalid, true), ("asset-empty", K::RelAssetEmpty, true), ]; /// The viewer, if signed in. fn viewer_user(ctx: &RepoCtx) -> Option<&User> { ctx.viewer.as_ref().map(|(user, _)| user) } /// Whether the viewer may change releases of this repo. Archived repos refuse. fn can_write(ctx: &RepoCtx) -> bool { authorize( viewer_user(ctx), &ctx.repo, ctx.role, ctx.org_role, Action::Write, ) .is_ok() } /// Write access, or the refusal that matches why it is missing. fn require_write(ctx: &RepoCtx) -> Result<()> { match authorize( viewer_user(ctx), &ctx.repo, ctx.role, ctx.org_role, Action::Write, ) { Ok(()) => Ok(()), Err(Denied::NotFound) => Err(not_found().into()), Err(Denied::Forbidden) => Err(forbidden().into()), } } fn title_of(ctx: &RepoCtx) -> String { format!("{}/{}", ctx.repo.owner_name, ctx.repo.name) } fn releases_href(title: &str) -> String { format!("/{title}/releases") } fn release_edit_href(title: &str, id: &str) -> String { format!("/{title}/releases/{}/edit", enc(id)) } fn tag_href(title: &str, tag: &str) -> String { format!("/{title}/releases/tag/{}", enc(tag)) } fn download_href(title: &str, tag: &str, name: &str) -> String { format!("/{title}/releases/download/{}/{}", enc(tag), enc(name)) } /// The notice from `?notice=`. Anything else is ignored. fn notice_from_query(cx: &Cx, lang: Lang) -> Option<(&'static str, bool)> { let query = topcoat::router::request::uri(cx).query()?; let word = form_urlencoded::parse(query.as_bytes()) .find(|(key, _)| key == "notice") .map(|(_, value)| value.into_owned())?; NOTICES .iter() .find(|(name, _, _)| *name == word) .map(|(_, key, is_error)| (g::t(lang, *key), *is_error)) } fn notice_redirect(href: &str, word: &str) -> SeeOther { see_other(format!("{href}?notice={word}")) } /// A byte count for people: "512 B", "1.5 KiB", "3.2 MiB". pub fn size_text(bytes: u64) -> String { const KIB: f64 = 1024.0; let b = bytes as f64; if bytes < 1024 { format!("{bytes} B") } else if b < KIB * KIB { format!("{:.1} KiB", b / KIB) } else if b < KIB * KIB * KIB { format!("{:.1} MiB", b / (KIB * KIB)) } else { format!("{:.1} GiB", b / (KIB * KIB * KIB)) } } /// One asset as the pages show it. pub struct AssetLink { pub name: String, pub href: String, pub size: String, pub downloads: u64, pub sha256: String, } fn asset_link(title: &str, tag: &str, asset: &ReleaseAsset) -> AssetLink { AssetLink { name: asset.name.clone(), href: download_href(title, tag, &asset.name), size: size_text(asset.size), downloads: asset.download_count, sha256: asset.sha256.clone(), } } /// One release card on the list page. pub struct ReleaseCard { pub title: String, pub tag: String, pub href: String, pub edit_href: String, pub body: String, pub when: String, pub author: String, pub draft: bool, pub prerelease: bool, pub latest: bool, pub assets: Vec, } /// The "latest release" summary shown on the repo home page. pub struct LatestRelease { pub title: String, pub tag: String, pub href: String, pub when: String, pub assets: Vec, } fn card_of( ctx: &RepoCtx, release: &Release, assets: &[ReleaseAsset], latest_id: Option<&str>, ) -> ReleaseCard { let title = title_of(ctx); let when = release .published_at .as_deref() .unwrap_or(&release.created_at); ReleaseCard { title: release.title.clone(), tag: release.tag.clone(), href: tag_href(&title, &release.tag), edit_href: release_edit_href(&title, &release.id), body: release.body.clone(), when: stamp_in_tz(when, ctx.viewer_tz()), author: release.author_name.clone(), draft: release.draft, prerelease: release.prerelease, latest: latest_id == Some(release.id.as_str()), assets: assets .iter() .map(|asset| asset_link(&title, &release.tag, asset)) .collect(), } } /// The data behind the repo home page's "Latest release" card, or `None` when there is none. pub async fn latest_release_for_home( state: &AppState, ctx: &RepoCtx, ) -> Result> { let Some(release) = state.store.latest_release(&ctx.repo.id).await? else { return Ok(None); }; let title = title_of(ctx); let assets = state.store.list_release_assets(&release.id).await?; let when = release .published_at .as_deref() .unwrap_or(&release.created_at); Ok(Some(LatestRelease { title: release.title.clone(), tag: release.tag.clone(), href: tag_href(&title, &release.tag), when: stamp_in_tz(when, ctx.viewer_tz()), assets: assets .iter() .map(|asset| asset_link(&title, &release.tag, asset)) .collect(), })) } /// Values a release form shows, and its error. #[derive(Default)] struct FormDraft { tag: String, target: String, title: String, body: String, prerelease: bool, draft: bool, error: Option, } /// The new-release form fields. #[derive(Deserialize)] struct CreateForm { #[serde(default)] csrf: String, #[serde(default)] tag: String, #[serde(default)] target: String, #[serde(default)] title: String, #[serde(default)] body: String, #[serde(default)] prerelease: Option, #[serde(default)] draft: Option, } /// The edit-release form fields. #[derive(Deserialize)] struct EditForm { #[serde(default)] csrf: String, #[serde(default)] title: String, #[serde(default)] body: String, #[serde(default)] prerelease: Option, #[serde(default)] draft: Option, } #[derive(Deserialize)] struct CsrfForm { #[serde(default)] csrf: String, #[serde(default)] confirm: Option, } /// The branch names of the repo, default branch first, for the target selector. fn branch_choices(refs: &[forge_git::browse::RefInfo], default_branch: &str) -> Vec { let mut names: Vec = refs .iter() .filter(|r| r.kind == RefKind::Branch) .map(|r| r.name.clone()) .collect(); names.sort(); if let Some(pos) = names.iter().position(|n| n == default_branch) { let default = names.remove(pos); names.insert(0, default); } names } /// Checks the typed fields of a release. The message is shown to the user. fn check_title_body(lang: Lang, title: &str, body: &str) -> std::result::Result<(), String> { if title.is_empty() || title.chars().count() > MAX_TITLE_CHARS { return Err(g::title_too_long(lang, MAX_TITLE_CHARS)); } if body.chars().count() > MAX_BODY_CHARS { return Err(g::body_too_long(lang, MAX_BODY_CHARS)); } Ok(()) } /// Release-form list of branches and the form itself, shared by the new and edit pages. #[component] async fn release_form( action: String, csrf: String, lang: Lang, is_new: bool, draft: FormDraft, branches: Vec, submit: &'static str, ) -> Result { let tag_hint = g::t(lang, K::TagHint); Ok(view! {
if let Some(message) = draft.error { alert(message: message.as_str()) } if is_new {

(tag_hint)

} else {

(g::t(lang, K::Tag)) ": " (draft.tag.as_str())

}
}) } /// The assets table on the edit page. `delete_actions` holds one form action per asset. #[component] async fn asset_rows( assets: Vec, delete_actions: Vec, csrf: String, lang: Lang, ) -> Result { Ok(view! { if assets.is_empty() {

(g::t(lang, K::NoAssetsYet))

} else {
for (asset, action) in assets.iter().zip(delete_actions.iter()) { }
(g::t(lang, K::File)) (g::t(lang, K::Size)) (g::t(lang, K::Downloads)) "SHA-256"
(asset.name.as_str()) (asset.size.as_str()) (asset.downloads) (asset.sha256.as_str())
} }) } /// The "Latest release" card on the repo home page. #[component] pub async fn latest_card(release: LatestRelease, lang: Lang) -> Result { Ok(view! {

(g::t(lang, K::LatestReleaseHeading))

(release.title.as_str()) " \u{b7} " (release.tag.as_str())

(release.when.as_str())

if !release.assets.is_empty() { }
}) } /// The list page body. #[component] async fn list_body( head: crate::pages::repo_ctx::RepoHead, lang: Lang, notice: Option<(&'static str, bool)>, new_href: Option, cards: Vec, ) -> Result { Ok(view! { repo_head(head: head) if let Some((text, is_error)) = notice { if is_error { alert(message: text) } else {
(text)
} }

(g::t(lang, K::Releases))

if let Some(href) = new_href { (g::t(lang, K::NewRelease)) }
if cards.is_empty() {

(g::t(lang, K::NoReleasesYet))

} for card in cards.iter() {

(card.title.as_str()) if card.latest { " " (g::t(lang, K::Latest)) } if card.prerelease { " " (g::t(lang, K::PreRelease)) } if card.draft { " " (g::t(lang, K::Draft)) }

(card.tag.as_str()) " \u{b7} " (card.when.as_str()) if !card.author.is_empty() { " \u{b7} " user_link(shown: card.author.as_str(), login: card.author.as_str()) }

if !card.body.is_empty() {
(Unescaped::new_unchecked(render_markdown(&card.body, None)))
} if !card.assets.is_empty() {
    for asset in card.assets.iter() {
  • (asset.name.as_str()) " (" (asset.size.as_str()) ", " (g::download_count(lang, asset.downloads)) ")"
  • }
}

(g::t(lang, K::Edit))

}
}) } /// Lists the repo's releases, newest first. Drafts appear only for writers. #[page("/{owner}/{repo}/releases")] async fn releases_list_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let lang = i18n::lang(cx, viewer_user(&ctx)); let state = app_context::(cx); let writer = can_write(&ctx); let latest_id = state .store .latest_release(&ctx.repo.id) .await? .map(|release| release.id); let mut cards = Vec::new(); for release in state.store.list_releases(&ctx.repo.id).await? { if release.draft && !writer { continue; } let assets = state.store.list_release_assets(&release.id).await?; cards.push(card_of(&ctx, &release, &assets, latest_id.as_deref())); } let title = format!("{} \u{b7} {}", title_of(&ctx), g::t(lang, K::Releases)); let new_href = writer.then(|| format!("{}/new", releases_href(&title_of(&ctx)))); let notice = notice_from_query(cx, lang); let head = ctx.head(state, "releases").await?; Ok(view! { shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), list_body(head: head, lang: lang, notice: notice, new_href: new_href, cards: cards)) }) } /// The branch choices and frame data of the new-release form. async fn new_form_data(state: &AppState, ctx: &RepoCtx) -> Result<(RepoHead, Vec)> { let refs = load_refs(ctx.path.clone()).await?; let branches = branch_choices(&refs, &ctx.repo.default_branch); let head = ctx.head(state, "releases").await?; Ok((head, branches)) } /// The new-release form body, below the repo header. #[component] async fn new_form_body( head: RepoHead, lang: Lang, action: String, csrf: String, draft: FormDraft, branches: Vec, ) -> Result { Ok(view! { repo_head(head: head)

(g::t(lang, K::NewRelease))

release_form(action: action, csrf: csrf, lang: lang, is_new: true, draft: draft, branches: branches, submit: g::t(lang, K::SaveRelease))
}) } /// The new-release form. #[page("/{owner}/{repo}/releases/new")] async fn release_new_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; require_write(&ctx)?; let lang = i18n::lang(cx, viewer_user(&ctx)); let state = app_context::(cx); let csrf = match crate::auth::current_user(cx).await? { Some((_, session)) => session.csrf_token.clone(), None => String::new(), }; let draft = FormDraft { target: ctx.repo.default_branch.clone(), draft: false, ..FormDraft::default() }; let (head, branches) = new_form_data(state, &ctx).await?; let title = format!("{} \u{b7} {}", title_of(&ctx), g::t(lang, K::NewRelease)); let action = format!("{}/new", releases_href(&title_of(&ctx))); Ok(view! { shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), new_form_body(head: head, lang: lang, action: action, csrf: csrf, draft: draft, branches: branches)) }) } /// Creates a release. The tag is created at the target branch's head when it does not exist. #[page(POST "/{owner}/{repo}/releases/new")] async fn release_create(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, session) = require_csrf(cx, &form.csrf).await?; require_write(&ctx)?; let lang = i18n::lang(cx, viewer_user(&ctx)); let state = app_context::(cx); let draft = FormDraft { tag: form.tag.trim().to_owned(), target: form.target.trim().to_owned(), title: form.title.trim().to_owned(), body: form.body.trim_end().to_owned(), prerelease: form.prerelease.is_some(), draft: form.draft.is_some(), error: None, }; match create_checked(state, &ctx, &user, &draft, lang).await? { Ok(release) => { if !release.draft { emit_release(state, &ctx.repo, &user, "published", &release).await; } let title = title_of(&ctx); Err(see_other(tag_href(&title, &release.tag)).into()) } Err(message) => { let csrf = session.csrf_token.clone(); let draft = FormDraft { error: Some(message), ..draft }; let (head, branches) = new_form_data(state, &ctx).await?; let title = format!("{} \u{b7} {}", title_of(&ctx), g::t(lang, K::NewRelease)); let action = format!("{}/new", releases_href(&title_of(&ctx))); Ok(view! { shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), new_form_body(head: head, lang: lang, action: action, csrf: csrf, draft: draft, branches: branches)) }) } } } /// Checks the new-release fields, makes the tag when it is missing, and stores the release. A /// refusal the form shows is `Err(message)`; storage failures are errors. async fn create_checked( state: &AppState, ctx: &RepoCtx, user: &User, draft: &FormDraft, lang: Lang, ) -> Result> { let tag_name = draft.tag.as_str(); if !valid_tag_name(tag_name) { return Ok(Err(g::t(lang, K::RelErrTagName).to_owned())); } if let Err(message) = check_title_body(lang, &draft.title, &draft.body) { return Ok(Err(message)); } if state .store .release_by_tag(&ctx.repo.id, tag_name) .await? .is_some() { return Ok(Err(g::t(lang, K::RelErrReleaseExists).to_owned())); } let refs = load_refs(ctx.path.clone()).await?; let existing = refs .iter() .find(|r| r.kind == RefKind::Tag && r.name == tag_name) .map(|r| r.target.clone()); let target_oid = match existing { Some(oid) => oid, None => { let branch_oid = refs .iter() .find(|r| r.kind == RefKind::Branch && r.name == draft.target) .map(|r| r.target.clone()); let Some(branch_oid) = branch_oid else { return Ok(Err(g::t(lang, K::RelErrChooseBranch).to_owned())); }; create_lightweight_tag(&state.git, &ctx.path, tag_name, &branch_oid).await?; let refname = format!("refs/tags/{tag_name}"); let zero = zero_oid(branch_oid.len()); if let Err(e) = after_ref_update(state, &ctx.repo, &user.id, &refname, &zero, &branch_oid).await { tracing::error!(error = %e, repo_id = %ctx.repo.id, "releases: post-update for new tag failed"); } branch_oid } }; let created = state .store .create_release(&NewRelease { repo_id: &ctx.repo.id, tag: tag_name, target_oid: &target_oid, title: &draft.title, body: &draft.body, draft: draft.draft, prerelease: draft.prerelease, author_id: &user.id, }) .await; match created { Ok(release) => Ok(Ok(release)), Err(e) => match user_facing_message(&e) { Some(message) => Ok(Err(message)), None => Err(e.into()), }, } } /// The webhook payload of a release event, sent to every hook that subscribes to `release`. async fn emit_release( state: &AppState, repo: &CoreRepo, user: &User, action: &str, release: &Release, ) { let payload = serde_json::json!({ "release": { "id": release.id, "tag": release.tag, "target_oid": release.target_oid, "name": release.title, "draft": release.draft, "prerelease": release.prerelease, "published_at": release.published_at, } }); emit( state, repo, &Sender::of(user), WebhookEvent::Release, action, payload, ) .await; } /// The public view of one release: notes, assets and the source archive links. #[page("/{owner}/{repo}/releases/tag/{tag}")] async fn release_tag_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let tag_param = path_param::(cx).to_owned(); let ctx = load_repo(cx, owner, name).await?; let lang = i18n::lang(cx, viewer_user(&ctx)); let state = app_context::(cx); let writer = can_write(&ctx); let Some(release) = state.store.release_by_tag(&ctx.repo.id, &tag_param).await? else { return Err(not_found().into()); }; if release.draft && !writer { return Err(not_found().into()); } let title = title_of(&ctx); let assets = state.store.list_release_assets(&release.id).await?; let links: Vec = assets .iter() .map(|asset| asset_link(&title, &release.tag, asset)) .collect(); let when = release .published_at .as_deref() .unwrap_or(&release.created_at); let when = stamp_in_tz(when, ctx.viewer_tz()); let body_html = render_markdown(&release.body, None); let page_title = format!("{title} \u{b7} {}", release.title); let archive_tar = format!("/{title}/archive/{}.tar.gz", enc(&release.tag)); let archive_zip = format!("/{title}/archive/{}.zip", enc(&release.tag)); let edit_href = release_edit_href(&title, &release.id); let all_href = releases_href(&title); let card = ReleaseCard { title: release.title.clone(), tag: release.tag.clone(), href: tag_href(&title, &release.tag), edit_href: edit_href.clone(), body: String::new(), when, author: release.author_name.clone(), draft: release.draft, prerelease: release.prerelease, latest: false, assets: links, }; let head = ctx.head(state, "releases").await?; let notice = notice_from_query(cx, lang); let delete_href = format!("/{title}/releases/{}/delete", enc(&release.id)); let csrf = match crate::auth::current_user(cx).await? { Some((_, session)) => session.csrf_token.clone(), None => String::new(), }; Ok(view! { shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: head) if let Some((text, is_error)) = notice { if is_error { alert(message: text) } else {
(text)
} }

(g::t(lang, K::AllReleases))

(card.title.as_str()) if card.prerelease { " " (g::t(lang, K::PreRelease)) } if card.draft { " " (g::t(lang, K::Draft)) }

(card.tag.as_str()) " \u{b7} " (card.when.as_str()) if !card.author.is_empty() { " \u{b7} " user_link(shown: card.author.as_str(), login: card.author.as_str()) }

if !body_html.is_empty() {
(Unescaped::new_unchecked(body_html))
}

(g::t(lang, K::Assets))

if card.assets.is_empty() {

(g::t(lang, K::NoAssets))

} else {
    for asset in card.assets.iter() {
  • (asset.name.as_str()) " (" (asset.size.as_str()) ", " (g::download_count(lang, asset.downloads)) ")"
    (asset.sha256.as_str())
  • }
}

(g::t(lang, K::SourceCode))

if writer {

(g::t(lang, K::Edit))

}
) }) } /// The edit page: the release fields, its assets, and the upload form. #[page("/{owner}/{repo}/releases/{release_id}/edit")] async fn release_edit_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let id = path_param::(cx).to_owned(); let ctx = load_repo(cx, owner, name).await?; require_write(&ctx)?; let lang = i18n::lang(cx, viewer_user(&ctx)); let state = app_context::(cx); let Some(release) = state.store.release(&id).await? else { return Err(not_found().into()); }; if release.repo_id != ctx.repo.id { return Err(not_found().into()); } let title = title_of(&ctx); let assets = state.store.list_release_assets(&release.id).await?; let delete_actions: Vec = assets .iter() .map(|a| { format!( "/{}/releases/{}/assets/{}/delete", title_of(&ctx), enc(&release.id), enc(&a.id) ) }) .collect(); let links: Vec = assets .iter() .map(|asset| asset_link(&title, &release.tag, asset)) .collect(); let csrf = match crate::auth::current_user(cx).await? { Some((_, session)) => session.csrf_token.clone(), None => String::new(), }; let draft = FormDraft { tag: release.tag.clone(), title: release.title.clone(), body: release.body.clone(), prerelease: release.prerelease, draft: release.draft, ..FormDraft::default() }; let refs = load_refs(ctx.path.clone()).await?; let branches = branch_choices(&refs, &ctx.repo.default_branch); let head = ctx.head(state, "releases").await?; let page_title = format!("{title} \u{b7} {}", g::edit_title(lang, &release.tag)); let notice = notice_from_query(cx, lang); let action = release_edit_href(&title, &release.id); let assets_base = format!("/{title}/releases/{}/assets", enc(&release.id)); let upload_action = assets_base.clone(); let upload_hint = g::upload_hint(lang, state.config.releases.max_asset_mb); let delete_href = format!("/{title}/releases/{}/delete", enc(&release.id)); let view_tag = tag_href(&title, &release.tag); Ok(view! { shell(title: page_title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: head) if let Some((text, is_error)) = notice { if is_error { alert(message: text) } else {
(text)
} }

(g::t(lang, K::BackToRelease))

(g::t(lang, K::EditRelease))

release_form(action: action.clone(), csrf: csrf.clone(), lang: lang, is_new: false, draft: draft, branches: branches, submit: g::t(lang, K::SaveRelease))

(g::t(lang, K::Assets))

(upload_hint.as_str())

asset_rows(assets: links, delete_actions: delete_actions, csrf: csrf.clone(), lang: lang)

(g::t(lang, K::DeleteRelease))

(g::t(lang, K::DeleteReleaseHelp))

) }) } /// Saves the edited fields. A draft that becomes published is announced as `published`; any other /// change to a published release is `updated`. #[route(POST "/{owner}/{repo}/releases/{release_id}/edit")] async fn release_update(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let id = path_param::(cx).to_owned(); let ctx = load_repo(cx, owner, name).await?; let (user, _) = require_csrf(cx, &form.csrf).await?; require_write(&ctx)?; let state = app_context::(cx); let Some(before) = state.store.release(&id).await? else { return Err(not_found().into()); }; if before.repo_id != ctx.repo.id { return Err(not_found().into()); } let title_text = form.title.trim().to_owned(); let body = form.body.trim_end().to_owned(); let lang = i18n::lang(cx, viewer_user(&ctx)); if check_title_body(lang, &title_text, &body).is_err() { return Err(notice_redirect(&release_edit_href(&title_of(&ctx), &id), "invalid").into()); } let edit = ReleaseEdit { title: &title_text, body: &body, draft: form.draft.is_some(), prerelease: form.prerelease.is_some(), }; if !state.store.update_release(&id, &edit).await? { return Err(not_found().into()); } let Some(after) = state.store.release(&id).await? else { return Err(not_found().into()); }; let action = match (before.draft, after.draft) { (true, false) => Some("published"), (true, true) => None, (false, _) => Some("updated"), }; if let Some(action) = action { emit_release(state, &ctx.repo, &user, action, &after).await; } let href = release_edit_href(&title_of(&ctx), &id); Err(notice_redirect(&href, "saved").into()) } /// Deletes a release and its assets, then their blobs. The git tag is kept. Requires the confirm box. #[route(POST "/{owner}/{repo}/releases/{release_id}/delete")] async fn release_delete(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let id = path_param::(cx).to_owned(); let ctx = load_repo(cx, owner, name).await?; let (user, _) = require_csrf(cx, &form.csrf).await?; require_write(&ctx)?; let title = title_of(&ctx); if form.confirm.is_none() { let href = release_edit_href(&title, &id); return Err(notice_redirect(&href, "confirm").into()); } let state = app_context::(cx); let Some(release) = state.store.release(&id).await? else { return Err(not_found().into()); }; if release.repo_id != ctx.repo.id { return Err(not_found().into()); } let Some(asset_ids) = state.store.delete_release(&id).await? else { return Err(not_found().into()); }; for asset_id in &asset_ids { if let Err(e) = state.blobs.delete(&asset_key(asset_id)).await { tracing::error!(error = %e, asset_id, "releases: could not delete an asset blob"); } } if !release.draft { emit_release(state, &ctx.repo, &user, "deleted", &release).await; } Err(notice_redirect(&releases_href(&title), "deleted").into()) } /// Deletes one asset row and its blob. #[route(POST "/{owner}/{repo}/releases/{release_id}/assets/{asset_id}/delete")] async fn asset_delete(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let release = path_param::(cx).to_owned(); let asset = path_param::(cx).to_owned(); let ctx = load_repo(cx, owner, name).await?; require_csrf(cx, &form.csrf).await?; require_write(&ctx)?; let state = app_context::(cx); let Some(row) = state.store.release_asset_by_id(&asset).await? else { return Err(not_found().into()); }; if row.release_id != release { return Err(not_found().into()); } let href = release_edit_href(&title_of(&ctx), &release); state.store.delete_release_asset(&row.id).await?; if let Err(e) = state.blobs.delete(&asset_key(&row.id)).await { tracing::error!(error = %e, asset_id = %row.id, "releases: could not delete an asset blob"); } Err(notice_redirect(&href, "asset-deleted").into()) } /// Why a streamed upload stopped early. enum UploadFailure { TooLarge, Unreadable, Storage(forge_core::Error), } /// Copies one multipart field into the blob writer, enforcing the writer's size limit. async fn copy_field( field: &mut multer::Field<'_>, writer: &mut BlobWriter, ) -> std::result::Result<(), UploadFailure> { loop { match field.chunk().await { Ok(Some(chunk)) => match writer.push(&chunk).await { Ok(()) => {} Err(forge_core::Error::Invalid(_)) => return Err(UploadFailure::TooLarge), Err(e) => return Err(UploadFailure::Storage(e)), }, Ok(None) => return Ok(()), Err(_) => return Err(UploadFailure::Unreadable), } } } /// Uploads one asset. The body is read as a stream: the `csrf` field must come first, then the /// `file` field. The file goes to a temporary blob while its sha256 is computed, and is /// published under `assets/` only when it is complete and the row is stored. #[route(POST "/{owner}/{repo}/releases/{release_id}/assets")] async fn asset_upload(cx: &Cx, body: Body) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let id = path_param::(cx).to_owned(); let ctx = load_repo(cx, owner, name).await?; let state = app_context::(cx); let href = release_edit_href(&title_of(&ctx), &id); let max_bytes = state .config .releases .max_asset_mb .saturating_mul(1024 * 1024); let declared = headers(cx) .get(CONTENT_LENGTH) .and_then(|v| v.to_str().ok()) .and_then(|v| v.parse::().ok()); if declared.is_some_and(|len| len > max_bytes.saturating_add(MULTIPART_OVERHEAD)) { return Err(notice_redirect(&href, "asset-too-large").into()); } let content_type = headers(cx) .get(CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or("") .to_owned(); let Ok(boundary) = multer::parse_boundary(&content_type) else { return Err(notice_redirect(&href, "asset-invalid").into()); }; let release = match state.store.release(&id).await? { Some(release) if release.repo_id == ctx.repo.id => release, _ => return Err(not_found().into()), }; let mut multipart = multer::Multipart::new(body.into_data_stream(), boundary); let mut uploader: Option = None; let mut outcome: &'static str = "asset-empty"; loop { let mut field = match multipart.next_field().await { Ok(Some(field)) => field, Ok(None) => break, Err(_) => { outcome = "asset-invalid"; break; } }; match field.name() { Some("csrf") => { let token = field.text().await.unwrap_or_default(); let (user, _) = require_csrf(cx, &token).await?; require_write(&ctx)?; uploader = Some(user); } Some("file") => { let Some(user) = uploader.as_ref() else { return Err(forbidden().into()); }; outcome = store_upload(state, &release.id, user, &mut field, max_bytes).await?; break; } _ => { let _ = field.bytes().await; } } } Err(notice_redirect(&href, outcome).into()) } /// Streams one `file` field into a blob and adds its asset row. Returns the notice word for the /// outcome. Storage failures are errors; everything the client can cause is a notice. async fn store_upload( state: &AppState, release_id: &str, user: &User, field: &mut multer::Field<'_>, max_bytes: u64, ) -> Result<&'static str> { let file_name = field.file_name().map(str::to_owned).unwrap_or_default(); if file_name.is_empty() { return Ok("asset-empty"); } if !valid_asset_name(&file_name) { return Ok("asset-name"); } if state .store .release_asset(release_id, &file_name) .await? .is_some() { return Ok("asset-exists"); } let mime = field.content_type().map(|m| m.to_string()); let content_type = clean_content_type(mime.as_deref().and_then(|m| m.split(';').next())); let asset_id = ulid::Ulid::generate().to_string(); let key = asset_key(&asset_id); let mut writer = state.blobs.begin(&key, max_bytes).await?; match copy_field(field, &mut writer).await { Ok(()) => {} Err(UploadFailure::TooLarge) => { writer.abort().await; return Ok("asset-too-large"); } Err(UploadFailure::Unreadable) => { writer.abort().await; return Ok("asset-invalid"); } Err(UploadFailure::Storage(e)) => { writer.abort().await; return Err(e.into()); } } let (size, sha256) = writer.finish().await?; let added = state .store .add_asset(&NewAsset { id: &asset_id, release_id, name: &file_name, size, sha256: &sha256, content_type: &content_type, uploader_id: &user.id, }) .await; match added { Ok(_) => Ok("asset-uploaded"), Err(e) => { if let Err(del) = state.blobs.delete(&key).await { tracing::error!(error = %del, "releases: could not remove an unlinked blob"); } match user_facing_message(&e) { Some(_) => Ok("asset-exists"), None => Err(e.into()), } } } } /// Streams a release asset as an attachment and counts the download. Drafts are visible only to /// people who can write; anyone else gets a 404. #[route(GET "/{owner}/{repo}/releases/download/{tag}/{name}")] async fn asset_download(cx: &Cx) -> Result { let owner = path_param::(cx); let repo_name = path_param::(cx); let tag_param = path_param::(cx).to_owned(); let file_name = path_param::(cx).to_owned(); let ctx = load_repo(cx, owner, repo_name).await?; let state = app_context::(cx); let Some(release) = state.store.release_by_tag(&ctx.repo.id, &tag_param).await? else { return Err(not_found().into()); }; if release.draft && !can_write(&ctx) { return Err(not_found().into()); } let Some(asset) = state.store.release_asset(&release.id, &file_name).await? else { return Err(not_found().into()); }; let Some(read) = state.blobs.read(&asset_key(&asset.id)).await? else { tracing::error!(asset_id = %asset.id, "releases: asset row without a blob"); return Err(not_found().into()); }; if let Err(e) = state.store.count_asset_download(&asset.id).await { tracing::error!(error = %e, asset_id = %asset.id, "releases: could not count a download"); } let chunks = read.stream.map_ok(Frame::data); let mut res = Response::new(Body::new(StreamBody::new(chunks))); *res.status_mut() = StatusCode::OK; let headers = res.headers_mut(); if let Ok(value) = HeaderValue::from_str(&asset.content_type) { headers.insert(CONTENT_TYPE, value); } headers.insert(CONTENT_LENGTH, HeaderValue::from(read.size)); if let Ok(value) = HeaderValue::from_str(&format!("attachment; filename=\"{}\"", asset.name)) { headers.insert(CONTENT_DISPOSITION, value); } headers.insert( HeaderName::from_static("x-content-type-options"), HeaderValue::from_static("nosniff"), ); headers.insert( HeaderName::from_static("cache-control"), HeaderValue::from_static("private, no-cache"), ); Ok(res) }