//! 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};
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
}
)
})
}
/// 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)
}
}
)
})
}
/// 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)
}