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