// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Releases: tag-based release notes with uploaded assets and source archives. //! //! A release is cut from a git tag (existing, or newly created from a target //! branch), carries markdown notes and an optional pre-release / draft label, //! and any number of uploaded asset files (stored under `data_dir/releases`, //! keyed by asset id). The list and detail views mirror GitHub's releases page. use std::path::{Path as FsPath, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use axum::body::Body; use axum::extract::{Multipart, Path, State}; use axum::http::{HeaderMap, StatusCode, Uri, header}; use axum::response::{IntoResponse, Redirect, Response}; use axum_extra::extract::cookie::CookieJar; use futures_util::TryStreamExt as _; use maud::{Markup, html}; use model::User; use serde::Deserialize; use tokio::io::AsyncWriteExt as _; use tokio_util::io::ReaderStream; use crate::AppState; use crate::error::{AppError, AppResult}; use crate::icons::{Icon, icon}; use crate::layout::page; use crate::markdown; use crate::pages::build_chrome; use crate::repo::{ Pagination, RepoCtx, Tab, branch_names, git_read, page_slice, pagination_nav, repo_header, require_readable_repo, }; use crate::session::{MaybeUser, verify_csrf}; /// The on-disk directory holding release asset files. fn assets_dir(state: &AppState) -> PathBuf { state.config.storage.data_dir.join("releases") } /// The release that should carry the "Latest" badge: the newest published, /// non-pre-release. `releases` is newest-first. fn latest_release_id(releases: &[store::Release]) -> Option { releases .iter() .find(|r| !r.is_draft && !r.is_prerelease) .map(|r| r.id.clone()) } /// `GET …/-/releases` — the release list. pub(crate) async fn list( state: &AppState, viewer: Option, jar: CookieJar, uri: &Uri, ctx: RepoCtx, ) -> AppResult { if !ctx.repo.releases_enabled { return Err(AppError::NotFound); } let can_write = viewer.is_some() && ctx.access >= auth::Access::Write; let mut releases = state.store.list_releases(&ctx.repo.id).await?; if !can_write { releases.retain(|r| !r.is_draft); } let latest = latest_release_id(&releases); let paging = Pagination::from_query(uri.query()); let (shown, has_next) = page_slice(&releases, paging); let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); let branches = branch_names(state, &ctx.repo.id).await; let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); let mut cards = Vec::with_capacity(shown.len()); for r in &shown { cards.push( release_card( state, &base, r, latest.as_deref() == Some(&r.id), can_write, &chrome.csrf, ) .await, ); } let body = html! { (repo_header(state, &ctx, Tab::Releases, &ctx.repo.default_branch, &branches)) div class="releases-head" { h2 { "Releases" } @if can_write { a class="btn btn-primary" href=(format!("{base}/-/releases/new")) { (icon(Icon::Plus)) span { "Draft a new release" } } } } @if cards.is_empty() { div class="card" { p class="muted" { "No releases yet." } } } @else { @for card in &cards { (card) } @if has_next || paging.page > 1 { (pagination_nav(uri.path(), uri.query(), paging, has_next)) } } }; let title = format!("{}/{}: releases", ctx.owner.username, ctx.repo.path); Ok((jar, page(&chrome, &title, body)).into_response()) } /// `GET …/-/releases/{tag}` — a single release. pub(crate) async fn view( state: &AppState, viewer: Option, jar: CookieJar, uri: &Uri, ctx: RepoCtx, tag: &str, ) -> AppResult { if !ctx.repo.releases_enabled { return Err(AppError::NotFound); } let can_write = viewer.is_some() && ctx.access >= auth::Access::Write; let release = state .store .release_by_tag(&ctx.repo.id, tag) .await? .ok_or(AppError::NotFound)?; if release.is_draft && !can_write { return Err(AppError::NotFound); } let all = state.store.list_releases(&ctx.repo.id).await?; let latest = latest_release_id(&all); let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); let branches = branch_names(state, &ctx.repo.id).await; let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); let card = release_card( state, &base, &release, latest.as_deref() == Some(&release.id), can_write, &chrome.csrf, ) .await; let body = html! { (repo_header(state, &ctx, Tab::Releases, &ctx.repo.default_branch, &branches)) nav class="crumbs" aria-label="Breadcrumb" { a href=(format!("{base}/-/releases")) { "Releases" } span class="slug-sep" { "/" } span class="crumb-current" { (if release.name.is_empty() { &release.tag } else { &release.name }) } } (card) }; let title = format!( "{} · {}/{}", release.name, ctx.owner.username, ctx.repo.path ); Ok((jar, page(&chrome, &title, body)).into_response()) } /// Render one release: header (title, badges, tag/date/author), notes, assets, /// and source archives. async fn release_card( state: &AppState, base: &str, r: &store::Release, is_latest: bool, can_write: bool, csrf: &str, ) -> Markup { let author = match &r.author_id { Some(id) => crate::issues::username(state, id).await, None => "unknown".to_string(), }; let assets = state .store .list_release_assets(&r.id) .await .unwrap_or_default(); html! { section class="release card" { div class="release-head" { div { h2 class="release-title" { a href=(format!("{base}/-/releases/{}", r.tag)) { (r.name) } @if is_latest { " " span class="badge badge-verified" { "Latest" } } @if r.is_prerelease { " " span class="badge badge-archived" { "pre-release" } } @if r.is_draft { " " span class="badge badge-private" { "draft" } } } div class="release-meta muted" { (icon(Icon::Box)) " " (r.tag) " · " (author) " · " (crate::repo::fmt_date(r.created_at)) } } @if can_write { div class="release-actions" { a class="btn" href=(format!("/release/{}/edit", r.id)) { "Edit" } form method="post" action=(format!("/release/{}/delete", r.id)) class="inline-form" { input type="hidden" name="_csrf" value=(csrf); button class="btn btn-danger" type="submit" { "Delete" } } } } } @if let Some(body) = &r.body { @if !body.trim().is_empty() { div class="release-notes markdown" { (markdown::render_with_refs(body, base)) } } } div class="release-assets" { h3 { "Assets" } ul class="asset-list" { @for a in &assets { li class="asset-row" { (icon(Icon::File)) a class="asset-name" href=(format!("/release-asset/{}", a.id)) { (a.name) } span class="asset-size muted" { (fmt_bytes(a.size)) } } } li class="asset-row source" { (icon(Icon::Download)) a class="asset-name" href=(format!("{base}/-/archive/{}.zip", r.tag)) { "Source code (zip)" } } li class="asset-row source" { (icon(Icon::Download)) a class="asset-name" href=(format!("{base}/-/archive/{}.tar.gz", r.tag)) { "Source code (tar.gz)" } } } } } } } // ---- Draft / create / edit ---- /// `GET …/-/releases/new` — the draft form. pub(crate) async fn new_form( state: &AppState, viewer: Option, jar: CookieJar, uri: &Uri, ctx: RepoCtx, ) -> AppResult { if !ctx.repo.releases_enabled || viewer.is_none() || ctx.access < auth::Access::Write { return Err(AppError::NotFound); } let branches = branch_names(state, &ctx.repo.id).await; let tags: Vec = git_read(state, &ctx.repo.id, git::Repo::tags) .await .unwrap_or_default() .into_iter() .map(|t| t.name) .collect(); let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); let body = html! { (repo_header(state, &ctx, Tab::Releases, &ctx.repo.default_branch, &branches)) (release_form( &format!("/release-new/{}", ctx.repo.id), &chrome.csrf, None, &branches, &tags, )) }; Ok((jar, page(&chrome, "New release", body)).into_response()) } /// `GET /release/{id}/edit` — edit an existing release (a focused form page). pub async fn edit_form( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, uri: Uri, Path(id): Path, ) -> AppResult { let (release, repo, user) = writer_release(&state, viewer.as_ref(), &id).await?; let owner = state .store .user_by_id(&repo.owner_id) .await? .map_or(repo.owner_id.clone(), |u| u.username); let releases = format!("/{owner}/{}/-/releases", repo.path); let back = format!("/{owner}/{}/-/releases/{}", repo.path, release.tag); let crumb = if release.name.is_empty() { &release.tag } else { &release.name }; let assets = state.store.list_release_assets(&release.id).await?; let (jar, chrome) = build_chrome(&state, jar, Some(user), uri.path().to_string()); let body = html! { nav class="crumbs" aria-label="Breadcrumb" { a href=(releases) { "Releases" } span class="slug-sep" { "/" } a href=(back) { (crumb) } span class="slug-sep" { "/" } span class="crumb-current" { "Edit" } } (release_form(&format!("/release/{}/edit", release.id), &chrome.csrf, Some(&release), &[], &[])) (assets_editor(&release.id, &chrome.csrf, &assets)) }; Ok((jar, page(&chrome, "Edit release", body)).into_response()) } /// The create/edit form. fn release_form( action: &str, csrf: &str, existing: Option<&store::Release>, branches: &[String], tags: &[String], ) -> Markup { html! { h2 { @if existing.is_some() { "Edit release" } @else { "New release" } } div class="card" { form method="post" action=(action) { input type="hidden" name="_csrf" value=(csrf); @if let Some(r) = existing { label { "Tag" } p class="muted" { (r.tag) } } @else { label for="rl-tag" { "Tag" } input type="text" id="rl-tag" name="tag" list="rl-tags" placeholder="v1.0.0" required autocomplete="off"; datalist id="rl-tags" { @for t in tags { option value=(t) {} } } label for="rl-target" { "Target (for a new tag)" } select id="rl-target" name="target" { @for b in branches { option value=(b) { (b) } } } p class="muted field-hint" { "An existing tag is used as-is; a new tag is created from the target branch." } } label for="rl-title" { "Release title" } input type="text" id="rl-title" name="name" value=(existing.map_or("", |r| r.name.as_str())); label for="rl-body" { "Release notes" } textarea id="rl-body" name="body" rows="10" placeholder="Describe this release (markdown supported)" { (existing.and_then(|r| r.body.as_deref()).unwrap_or("")) } div class="release-form-actions" { button class="btn btn-primary inline-btn" type="submit" name="action" value="publish" { @if existing.is_some() { "Update release" } @else { "Publish release" } } @if existing.is_none_or(|r| r.is_draft) { button class="btn inline-btn" type="submit" name="action" value="draft" { "Save draft" } } label class="checkbox" { input type="checkbox" name="prerelease" value="1" checked[existing.is_some_and(|r| r.is_prerelease)]; " Pre-release" } } } } } } /// The asset-management card shown on the edit page: existing assets with delete /// buttons, then the upload form. Only reachable once a release exists. fn assets_editor(release_id: &str, csrf: &str, assets: &[store::ReleaseAsset]) -> Markup { html! { section class="listing release-assets-editor" { h2 { "Assets" } div class="card" { @if assets.is_empty() { p class="muted empty-note" { "No uploaded assets yet." } } @else { ul class="asset-list" { @for a in assets { li class="asset-row" { (icon(Icon::File)) a class="asset-name" href=(format!("/release-asset/{}", a.id)) { (a.name) } span class="asset-size muted" { (fmt_bytes(a.size)) } form method="post" action=(format!("/release-asset/{}/delete", a.id)) class="inline-form" { input type="hidden" name="_csrf" value=(csrf); button class="icon-btn" type="submit" aria-label="Delete asset" title="Delete asset" { (icon(Icon::X)) } } } } } } form method="post" action=(format!("/release/{release_id}/asset")) enctype="multipart/form-data" class="asset-upload" { input type="hidden" name="_csrf" value=(csrf); label class="dropzone" { input type="file" name="asset" class="dropzone-input" required; (icon(Icon::Download)) span { "Attach a file by dropping it here or selecting it." } } // Hidden by JS, which uploads on selection; the button is the // no-JS fallback. button class="btn inline-btn" type="submit" { "Upload asset" } } } } } } /// Create/edit form body. #[derive(Debug, Deserialize)] pub struct ReleaseForm { #[serde(default)] tag: String, #[serde(default)] target: String, #[serde(default)] name: String, #[serde(default)] body: String, #[serde(default)] prerelease: Option, #[serde(default)] action: String, #[serde(rename = "_csrf")] csrf: String, } /// `POST /release-new/{repo_id}` — create a release (and its tag if new). pub async fn create( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(repo_id): Path, axum::Form(form): axum::Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let (repo, user) = writer_repo(&state, viewer.as_ref(), &repo_id).await?; if !repo.releases_enabled { return Err(AppError::NotFound); } let tag = form.tag.trim(); if tag.is_empty() { return Err(AppError::BadRequest("A tag is required.".to_string())); } // Use the tag if it exists; otherwise create it from the target branch. let exists = git_read(&state, &repo.id, { let tag = tag.to_string(); move |g| Ok(g.resolve_ref(&tag).is_ok()) }) .await .unwrap_or(false); if !exists { let target = if form.target.trim().is_empty() { repo.default_branch.clone() } else { form.target.trim().to_string() }; let tag_owned = tag.to_string(); git_read(&state, &repo.id, move |g| { let rev = g.resolve_ref(&target)?; g.create_tag(&tag_owned, &rev) }) .await .map_err(|_| { AppError::BadRequest("Could not create the tag from that target.".to_string()) })?; } let name = if form.name.trim().is_empty() { tag.to_string() } else { form.name.trim().to_string() }; let body = form.body.trim(); state .store .create_release(store::NewRelease { repo_id: repo.id.clone(), tag: tag.to_string(), name, body: (!body.is_empty()).then(|| body.to_string()), is_prerelease: form.prerelease.is_some(), is_draft: form.action == "draft", author_id: user.id.clone(), }) .await .map_err(|e| match e { store::StoreError::Conflict { .. } => { AppError::BadRequest("A release already exists for that tag.".to_string()) } other => AppError::from(other), })?; Ok::(format!("/{}/{}/-/releases/{tag}", user.username, repo.path)) } .await; crate::issues::respond_redirect(result) } /// `POST /release/{id}/edit` — update a release's editable fields. pub async fn edit( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, axum::Form(form): axum::Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let (release, repo, user) = writer_release(&state, viewer.as_ref(), &id).await?; let name = if form.name.trim().is_empty() { release.tag.clone() } else { form.name.trim().to_string() }; let body = form.body.trim(); state .store .update_release( &release.id, &name, (!body.is_empty()).then_some(body), form.prerelease.is_some(), release.is_draft && form.action == "draft", ) .await?; Ok::(format!( "/{}/{}/-/releases/{}", user.username, repo.path, release.tag )) } .await; crate::issues::respond_redirect(result) } /// The CSRF-only form for asset/release deletion. #[derive(Debug, Deserialize)] pub struct BareForm { #[serde(rename = "_csrf")] csrf: String, } /// `POST /release/{id}/delete` — delete a release and its asset files. pub async fn delete( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, axum::Form(form): axum::Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let (release, repo, user) = writer_release(&state, viewer.as_ref(), &id).await?; for a in state.store.list_release_assets(&release.id).await? { let _ = state.blobs.delete(blob::Namespace::Releases, &a.id).await; } state.store.delete_release(&release.id).await?; Ok::(format!("/{}/{}/-/releases", user.username, repo.path)) } .await; crate::issues::respond_redirect(result) } // ---- Assets ---- /// Per-process counter for unique upload temp-file names. static TMP_SEQ: AtomicU64 = AtomicU64::new(0); /// `POST /release/{id}/asset` — upload one asset (streamed to disk). pub async fn asset_upload( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, Path(id): Path, mut multipart: Multipart, ) -> Response { let mut csrf: Option = None; let mut override_name: Option = None; let mut saved: Option<(String, String, i64, String)> = None; // (tmp_name, name, size, ctype) while let Ok(Some(mut field)) = multipart.next_field().await { match field.name() { Some("_csrf") => csrf = field.text().await.ok(), Some("name") => override_name = field.text().await.ok(), Some("asset") => { let name = field .file_name() .map(str::to_string) .filter(|n| !n.is_empty()); let ctype = field .content_type() .map_or_else(|| "application/octet-stream".to_string(), str::to_string); if let Some(name) = name { let tmp_name = format!( "tmp-{}-{}", std::process::id(), TMP_SEQ.fetch_add(1, Ordering::Relaxed) ); let tmp_dir = assets_dir(&state).join("tmp"); if tokio::fs::create_dir_all(&tmp_dir).await.is_err() { return AppError::internal("storage error").into_response(); } let tmp_path = tmp_dir.join(&tmp_name); let Ok(size) = stream_field(&mut field, &tmp_path).await else { let _ = tokio::fs::remove_file(&tmp_path).await; return AppError::internal("upload failed").into_response(); }; saved = Some((tmp_name, name, size, ctype)); } } _ => {} } } if crate::session::verify_csrf(&jar, &HeaderMap::new(), csrf.as_deref()).is_err() { return AppError::Forbidden.into_response(); } // An edited name from the form overrides the uploaded file's own name; keep // only the basename so it can't smuggle a path. if let Some(name) = &mut saved && let Some(ov) = override_name .as_deref() .map(str::trim) .and_then(|s| s.rsplit(['/', '\\']).next()) .filter(|s| !s.is_empty()) { name.1 = ov.to_string(); } let result = async { let (release, _repo, _user) = writer_release(&state, viewer.as_ref(), &id).await?; if let Some((tmp_name, name, size, ctype)) = saved { let asset = state .store .add_release_asset(&release.id, &name, size, &ctype) .await?; // Move the streamed temp file into the blob store (local rename or // S3 upload); a failure rolls the row back. let tmp = assets_dir(&state).join("tmp").join(&tmp_name); if state .blobs .put_file(blob::Namespace::Releases, &asset.id, &tmp, &ctype) .await .is_err() { let _ = tokio::fs::remove_file(&tmp).await; let _ = state.store.delete_release_asset(&asset.id).await; return Err(AppError::internal("storage error")); } } Ok::(format!("/release/{}/edit", release.id)) } .await; match result { Ok(dest) => Redirect::to(&dest).into_response(), Err(e) => e.into_response(), } } /// `GET /release-asset/{id}` — download an asset. pub async fn asset_download( State(state): State, MaybeUser(viewer): MaybeUser, Path(id): Path, ) -> AppResult { let asset = state .store .release_asset_by_id(&id) .await? .ok_or(AppError::NotFound)?; let release = state .store .release_by_id(&asset.release_id) .await? .ok_or(AppError::NotFound)?; // The viewer must be able to read the repo the release belongs to. let _ = require_readable_repo(&state, viewer.as_ref(), &release.repo_id).await?; let Some(read) = state .blobs .open(blob::Namespace::Releases, &asset.id) .await .map_err(AppError::internal)? else { return Err(AppError::NotFound); }; let _ = state.store.bump_asset_download(&asset.id).await; let disposition = format!("attachment; filename=\"{}\"", asset.name.replace('"', "")); Ok(( [ (header::CONTENT_TYPE, asset.content_type.clone()), (header::CONTENT_DISPOSITION, disposition), (header::CONTENT_LENGTH, read.size.to_string()), ], Body::from_stream(ReaderStream::new(read.reader)), ) .into_response()) } /// `POST /release-asset/{id}/delete` — delete one asset. pub async fn asset_delete( State(state): State, MaybeUser(viewer): MaybeUser, jar: CookieJar, headers: HeaderMap, Path(id): Path, axum::Form(form): axum::Form, ) -> Response { if verify_csrf(&jar, &headers, Some(&form.csrf)).is_err() { return AppError::Forbidden.into_response(); } let result = async { let asset = state .store .release_asset_by_id(&id) .await? .ok_or(AppError::NotFound)?; let (release, _repo, _user) = writer_release(&state, viewer.as_ref(), &asset.release_id).await?; let _ = state .blobs .delete(blob::Namespace::Releases, &asset.id) .await; state.store.delete_release_asset(&asset.id).await?; Ok::(format!("/release/{}/edit", release.id)) } .await; crate::issues::respond_redirect(result) } // ---- Source archives ---- /// `GET …/-/archive/{filename}` — stream a source archive (`{rev}.zip` / /// `{rev}.tar.gz`). pub(crate) async fn archive(state: &AppState, ctx: RepoCtx, filename: &str) -> Response { let (rev, format) = if let Some(rev) = filename.strip_suffix(".tar.gz") { (rev, "tar.gz") } else if let Some(rev) = filename.strip_suffix(".zip") { (rev, "zip") } else { return StatusCode::NOT_FOUND.into_response(); }; let rev_owned = rev.to_string(); if git_read(state, &ctx.repo.id, move |g| g.resolve_ref(&rev_owned)) .await .is_err() { return StatusCode::NOT_FOUND.into_response(); } let Ok(repo_dir) = git::repo_path(&state.config.storage.repo_dir, &ctx.repo.id) else { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); }; let home = state.config.storage.data_dir.join("tmp"); let leaf = ctx.repo.path.rsplit('/').next().unwrap_or(&ctx.repo.path); let prefix = format!("{leaf}-{rev}"); let Ok(mut child) = git::pack::archive( &state.config.git.binary, &repo_dir, format, rev, &prefix, &home, ) else { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); }; let Some(stdout) = child.stdout.take() else { return StatusCode::INTERNAL_SERVER_ERROR.into_response(); }; tokio::spawn(async move { let _ = child.wait().await; }); let ctype = if format == "zip" { "application/zip" } else { "application/gzip" }; let disposition = format!("attachment; filename=\"{prefix}.{format}\""); ( [ (header::CONTENT_TYPE, ctype.to_string()), (header::CONTENT_DISPOSITION, disposition), ], Body::from_stream(ReaderStream::new(stdout)), ) .into_response() } // ---- Helpers ---- /// Stream a multipart field to `path`, returning the byte count. async fn stream_field( field: &mut axum::extract::multipart::Field<'_>, path: &FsPath, ) -> std::io::Result { let mut file = tokio::fs::File::create(path).await?; let mut size: i64 = 0; while let Some(chunk) = field.try_next().await.map_err(std::io::Error::other)? { file.write_all(&chunk).await?; size += i64::try_from(chunk.len()).unwrap_or(0); } file.flush().await?; Ok(size) } /// Resolve a repo by id and require the viewer to have write access. async fn writer_repo( state: &AppState, viewer: Option<&User>, repo_id: &str, ) -> Result<(model::Repo, User), AppError> { let user = viewer.cloned().ok_or(AppError::Forbidden)?; let repo = require_readable_repo(state, Some(&user), repo_id).await?; if crate::issues::access_for(state, Some(&user), &repo).await? < auth::Access::Write { return Err(AppError::NotFound); } Ok((repo, user)) } /// Load a release by id and require write access on its repo. async fn writer_release( state: &AppState, viewer: Option<&User>, release_id: &str, ) -> Result<(store::Release, model::Repo, User), AppError> { let release = state .store .release_by_id(release_id) .await? .ok_or(AppError::NotFound)?; let (repo, user) = writer_repo(state, viewer, &release.repo_id).await?; Ok((release, repo, user)) } /// Human-readable byte size. fn fmt_bytes(bytes: i64) -> String { const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; #[allow(clippy::cast_precision_loss)] let mut v = bytes.max(0) as f64; let mut u = 0; while v >= 1024.0 && u < UNITS.len() - 1 { v /= 1024.0; u += 1; } if u == 0 { format!("{bytes} B") } else { format!("{v:.1} {}", UNITS[u]) } }