1#![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
37pub 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
56pub 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(¶ms.host),
91 owner: params.owner.clone(),
92 repo: params.repo.clone(),
93 token,
94 }))
95}
96
97pub 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#[distributed_slice(RELEASE_PROVIDERS)]
113fn __register_gitea() -> Box<dyn ProviderRegistration> {
114 Box::new(RegisteredProvider { source_type: "gitea", factory: factory as ProviderFactory })
115}
116
117#[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); 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#[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}