//! Releases: a tag with a title, notes and optional downloadable assets. The stored shape is in //! migrations/0020_releases.sql; the store functions live in store/releases.rs. //! //! A release names a git tag. The tag itself lives in git, so deleting a release keeps it. //! Asset bytes live in the blob store under `assets/`; only metadata is stored here. use crate::model::Id; /// Longest release title, in characters. pub const MAX_TITLE_CHARS: usize = 200; /// Longest release body, in characters. pub const MAX_BODY_CHARS: usize = 65_536; /// Longest asset file name, in bytes. Names are ASCII, so bytes and characters agree. pub const MAX_ASSET_NAME_LEN: usize = 200; /// A release. `published_at` is set once it is first published and stays set; `draft` decides /// visibility. Drafts are shown only to people with Write on the repo. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Release { pub id: Id, pub repo_id: Id, pub tag: String, /// The commit the tag names when the release was made. pub target_oid: String, pub title: String, pub body: String, pub draft: bool, pub prerelease: bool, pub author_id: Id, pub author_name: String, pub created_at: String, pub published_at: Option, } /// A file attached to a release. `download_count` counts completed starts of downloads. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReleaseAsset { pub id: Id, pub release_id: Id, pub name: String, pub size: u64, pub sha256: String, pub content_type: String, pub download_count: u64, pub uploader_id: Id, pub created_at: String, } /// Input for `Store::create_release`. #[derive(Debug, Clone, Copy)] pub struct NewRelease<'a> { pub repo_id: &'a str, pub tag: &'a str, pub target_oid: &'a str, pub title: &'a str, pub body: &'a str, pub draft: bool, pub prerelease: bool, pub author_id: &'a str, } /// The editable fields of a release. The tag and its target never change. #[derive(Debug, Clone, Copy)] pub struct ReleaseEdit<'a> { pub title: &'a str, pub body: &'a str, pub draft: bool, pub prerelease: bool, } /// Input for `Store::add_asset`. The blob is already written under `assets/`. #[derive(Debug, Clone, Copy)] pub struct NewAsset<'a> { pub id: &'a str, pub release_id: &'a str, pub name: &'a str, pub size: u64, pub sha256: &'a str, pub content_type: &'a str, pub uploader_id: &'a str, } /// True for a file name that is safe in a `Content-Disposition` header and a URL segment: 1 to /// [`MAX_ASSET_NAME_LEN`] of ASCII letters, digits, `.`, `-`, `_` or `+`, not starting with a dot. pub fn valid_asset_name(name: &str) -> bool { !name.is_empty() && name.len() <= MAX_ASSET_NAME_LEN && !name.starts_with('.') && name .bytes() .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_' | b'+')) } /// The content type stored for an upload: a `type/subtype` token pair, or /// `application/octet-stream` when the client sent nothing usable. pub fn clean_content_type(raw: Option<&str>) -> String { let fallback = "application/octet-stream"; let Some(raw) = raw.map(str::trim) else { return fallback.to_owned(); }; let ok_token = |s: &str| { !s.is_empty() && s.bytes() .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+' | b'_')) }; match raw.split_once('/') { Some((main, sub)) if ok_token(main) && ok_token(sub) => { format!("{}/{}", main.to_ascii_lowercase(), sub.to_ascii_lowercase()) } _ => fallback.to_owned(), } }