Skip to main content

rtb_vcs/
gitea.rs

1//! Gitea backend — self-hosted Gitea instances.
2//!
3//! Codeberg piggybacks on this module (see [`crate::codeberg`]) by
4//! pinning `host` to `codeberg.org`.
5//!
6//! # Authentication
7//!
8//! Personal Access Token via `Authorization: token <token>` header
9//! (Gitea's documented convention; unlike GitHub, Gitea rejects
10//! `Bearer` for PATs).
11//!
12//! # URL shape
13//!
14//! REST API under `<host>/api/v1`. The backend appends that path
15//! automatically; users supply the bare host.
16//!
17//! # Lint exception
18//!
19//! Same as the other REST backends.
20
21#![allow(unsafe_code)]
22
23use std::sync::Arc;
24
25use async_trait::async_trait;
26use linkme::distributed_slice;
27use secrecy::{ExposeSecret, SecretString};
28use tokio::io::AsyncRead;
29
30use crate::config::{GiteaParams, ReleaseSourceConfig};
31use crate::http;
32use crate::release::{
33    ProviderError, ProviderFactory, ProviderRegistration, RegisteredProvider, Release,
34    ReleaseAsset, ReleaseProvider, RELEASE_PROVIDERS,
35};
36
37/// Gitea-specific release provider. Codeberg uses the same impl via
38/// [`crate::codeberg`]'s factory, which constructs a `GiteaParams`
39/// with `host = "codeberg.org"`.
40pub struct GiteaProvider {
41    client: reqwest::Client,
42    scheme: &'static str,
43    host: String,
44    owner: String,
45    repo: String,
46    token: Option<SecretString>,
47}
48
49fn normalise_host(raw: &str) -> String {
50    raw.trim_end_matches('/')
51        .trim_start_matches("https://")
52        .trim_start_matches("http://")
53        .to_string()
54}
55
56// ---------------------------------------------------------------------
57// Factory + registration
58// ---------------------------------------------------------------------
59
60/// Shared constructor. [`crate::codeberg`] calls this directly with
61/// Codeberg's hard-coded host.
62///
63/// # Errors
64///
65/// [`ProviderError::InvalidConfig`] for empty or http-prefixed hosts
66/// and for missing owner/repo.
67pub fn build_provider(
68    params: &GiteaParams,
69    token: Option<SecretString>,
70) -> Result<Arc<dyn ReleaseProvider>, ProviderError> {
71    if params.host.trim().is_empty() {
72        return Err(ProviderError::InvalidConfig("gitea host must not be empty".into()));
73    }
74    if !params.allow_insecure_base_url && params.host.starts_with("http://") {
75        return Err(ProviderError::InvalidConfig(format!(
76            "gitea host must be https; got {}",
77            params.host
78        )));
79    }
80    if params.owner.trim().is_empty() || params.repo.trim().is_empty() {
81        return Err(ProviderError::InvalidConfig("gitea owner and repo must not be empty".into()));
82    }
83
84    let client = http::build_client(params.timeout_seconds, params.allow_insecure_base_url)?;
85    let scheme = http::scheme_for(params.allow_insecure_base_url);
86
87    Ok(Arc::new(GiteaProvider {
88        client,
89        scheme,
90        host: normalise_host(&params.host),
91        owner: params.owner.clone(),
92        repo: params.repo.clone(),
93        token,
94    }))
95}
96
97/// Factory for the `gitea` source type.
98pub fn factory(
99    cfg: &ReleaseSourceConfig,
100    token: Option<SecretString>,
101) -> Result<Arc<dyn ReleaseProvider>, ProviderError> {
102    let ReleaseSourceConfig::Gitea(params) = cfg else {
103        return Err(ProviderError::InvalidConfig(format!(
104            "gitea factory called with non-gitea config: source_type={}",
105            cfg.source_type()
106        )));
107    };
108    build_provider(params, token)
109}
110
111/// Link-time registration entry.
112#[distributed_slice(RELEASE_PROVIDERS)]
113fn __register_gitea() -> Box<dyn ProviderRegistration> {
114    Box::new(RegisteredProvider { source_type: "gitea", factory: factory as ProviderFactory })
115}
116
117// ---------------------------------------------------------------------
118// Trait impl
119// ---------------------------------------------------------------------
120
121#[async_trait]
122impl ReleaseProvider for GiteaProvider {
123    async fn latest_release(&self) -> Result<Release, ProviderError> {
124        let url = format!(
125            "{scheme}://{host}/api/v1/repos/{owner}/{repo}/releases/latest",
126            scheme = self.scheme,
127            host = self.host,
128            owner = self.owner,
129            repo = self.repo,
130        );
131        let dto: ApiRelease = self.get_json(&url).await?;
132        Ok(dto.into_release())
133    }
134
135    async fn release_by_tag(&self, tag: &str) -> Result<Release, ProviderError> {
136        let url = format!(
137            "{scheme}://{host}/api/v1/repos/{owner}/{repo}/releases/tags/{tag}",
138            scheme = self.scheme,
139            host = self.host,
140            owner = self.owner,
141            repo = self.repo,
142            tag = http::urlencode(tag),
143        );
144        let dto: ApiRelease = self.get_json(&url).await?;
145        Ok(dto.into_release())
146    }
147
148    async fn list_releases(&self, limit: usize) -> Result<Vec<Release>, ProviderError> {
149        let per_page = limit.clamp(1, 50); // Gitea default cap is 50
150        let url = format!(
151            "{scheme}://{host}/api/v1/repos/{owner}/{repo}/releases?limit={per_page}",
152            scheme = self.scheme,
153            host = self.host,
154            owner = self.owner,
155            repo = self.repo,
156        );
157        let list: Vec<ApiRelease> = self.get_json(&url).await?;
158        Ok(list.into_iter().take(limit).map(ApiRelease::into_release).collect())
159    }
160
161    async fn download_asset(
162        &self,
163        asset: &ReleaseAsset,
164    ) -> Result<(Box<dyn AsyncRead + Send + Unpin>, u64), ProviderError> {
165        let mut req = self.client.get(&asset.download_url);
166        if let Some(tok) = &self.token {
167            req = req.header("Authorization", format!("token {}", tok.expose_secret()));
168        }
169        let resp = req.send().await.map_err(|e| ProviderError::Transport(e.to_string()))?;
170        http::map_status_to_error(&resp, &self.host, false)?;
171        Ok(http::stream_body(resp))
172    }
173}
174
175impl GiteaProvider {
176    async fn get_json<T: serde::de::DeserializeOwned>(
177        &self,
178        url: &str,
179    ) -> Result<T, ProviderError> {
180        let mut req = self.client.get(url).header("Accept", "application/json");
181        if let Some(tok) = &self.token {
182            req = req.header("Authorization", format!("token {}", tok.expose_secret()));
183        }
184        let resp = req.send().await.map_err(|e| ProviderError::Transport(e.to_string()))?;
185        http::map_status_to_error(&resp, &self.host, false)?;
186        http::parse_json::<T>(resp).await
187    }
188}
189
190// ---------------------------------------------------------------------
191// DTOs
192// ---------------------------------------------------------------------
193
194#[derive(Debug, serde::Deserialize)]
195struct ApiRelease {
196    #[serde(default)]
197    name: Option<String>,
198    tag_name: String,
199    #[serde(default)]
200    body: Option<String>,
201    #[serde(default)]
202    draft: bool,
203    #[serde(default)]
204    prerelease: bool,
205    created_at: String,
206    #[serde(default)]
207    published_at: Option<String>,
208    #[serde(default)]
209    assets: Vec<ApiAsset>,
210}
211
212#[derive(Debug, serde::Deserialize)]
213struct ApiAsset {
214    id: u64,
215    name: String,
216    #[serde(default)]
217    size: u64,
218    #[serde(default)]
219    browser_download_url: String,
220}
221
222impl ApiRelease {
223    fn into_release(self) -> Release {
224        let created_at =
225            parse_iso8601(&self.created_at).unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
226        let published_at = self.published_at.as_deref().and_then(parse_iso8601);
227        let name = self.name.unwrap_or_else(|| self.tag_name.clone());
228        let body = self.body.unwrap_or_default();
229        let mut release = Release::new(name, self.tag_name.clone(), created_at);
230        release.body = body;
231        release.draft = self.draft;
232        release.prerelease = self.prerelease;
233        release.published_at = published_at;
234        release.assets = self
235            .assets
236            .into_iter()
237            .map(|a| {
238                let mut asset = ReleaseAsset::new(a.id.to_string(), a.name, a.browser_download_url);
239                asset.size = a.size;
240                asset
241            })
242            .collect();
243        release
244    }
245}
246
247fn parse_iso8601(s: &str) -> Option<time::OffsetDateTime> {
248    time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()
249}