forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
3.6 KiB
1//! Releases: a tag with a title, notes and optional downloadable assets. The stored shape is in
2//! migrations/0020_releases.sql; the store functions live in store/releases.rs.
3//!
4//! A release names a git tag. The tag itself lives in git, so deleting a release keeps it.
5//! Asset bytes live in the blob store under `assets/<asset id>`; only metadata is stored here.
6
7use crate::model::Id;
8
9/// Longest release title, in characters.
10pub const MAX_TITLE_CHARS: usize = 200;
11/// Longest release body, in characters.
12pub const MAX_BODY_CHARS: usize = 65_536;
13/// Longest asset file name, in bytes. Names are ASCII, so bytes and characters agree.
14pub const MAX_ASSET_NAME_LEN: usize = 200;
15
16/// A release. `published_at` is set once it is first published and stays set; `draft` decides
17/// visibility. Drafts are shown only to people with Write on the repo.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Release {
20 pub id: Id,
21 pub repo_id: Id,
22 pub tag: String,
23 /// The commit the tag names when the release was made.
24 pub target_oid: String,
25 pub title: String,
26 pub body: String,
27 pub draft: bool,
28 pub prerelease: bool,
29 pub author_id: Id,
30 pub author_name: String,
31 pub created_at: String,
32 pub published_at: Option<String>,
33}
34
35/// A file attached to a release. `download_count` counts completed starts of downloads.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ReleaseAsset {
38 pub id: Id,
39 pub release_id: Id,
40 pub name: String,
41 pub size: u64,
42 pub sha256: String,
43 pub content_type: String,
44 pub download_count: u64,
45 pub uploader_id: Id,
46 pub created_at: String,
47}
48
49/// Input for `Store::create_release`.
50#[derive(Debug, Clone, Copy)]
51pub struct NewRelease<'a> {
52 pub repo_id: &'a str,
53 pub tag: &'a str,
54 pub target_oid: &'a str,
55 pub title: &'a str,
56 pub body: &'a str,
57 pub draft: bool,
58 pub prerelease: bool,
59 pub author_id: &'a str,
60}
61
62/// The editable fields of a release. The tag and its target never change.
63#[derive(Debug, Clone, Copy)]
64pub struct ReleaseEdit<'a> {
65 pub title: &'a str,
66 pub body: &'a str,
67 pub draft: bool,
68 pub prerelease: bool,
69}
70
71/// Input for `Store::add_asset`. The blob is already written under `assets/<id>`.
72#[derive(Debug, Clone, Copy)]
73pub struct NewAsset<'a> {
74 pub id: &'a str,
75 pub release_id: &'a str,
76 pub name: &'a str,
77 pub size: u64,
78 pub sha256: &'a str,
79 pub content_type: &'a str,
80 pub uploader_id: &'a str,
81}
82
83/// True for a file name that is safe in a `Content-Disposition` header and a URL segment: 1 to
84/// [`MAX_ASSET_NAME_LEN`] of ASCII letters, digits, `.`, `-`, `_` or `+`, not starting with a dot.
85pub fn valid_asset_name(name: &str) -> bool {
86 !name.is_empty()
87 && name.len() <= MAX_ASSET_NAME_LEN
88 && !name.starts_with('.')
89 && name
90 .bytes()
91 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_' | b'+'))
92}
93
94/// The content type stored for an upload: a `type/subtype` token pair, or
95/// `application/octet-stream` when the client sent nothing usable.
96pub fn clean_content_type(raw: Option<&str>) -> String {
97 let fallback = "application/octet-stream";
98 let Some(raw) = raw.map(str::trim) else {
99 return fallback.to_owned();
100 };
101 let ok_token = |s: &str| {
102 !s.is_empty()
103 && s.bytes()
104 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+' | b'_'))
105 };
106 match raw.split_once('/') {
107 Some((main, sub)) if ok_token(main) && ok_token(sub) => {
108 format!("{}/{}", main.to_ascii_lowercase(), sub.to_ascii_lowercase())
109 }
110 _ => fallback.to_owned(),
111 }
112}