use reqwest::{Client, Request, StatusCode}; use soft_assert::*; use url::Url; use zeroize::Zeroize; pub struct Forgejo { url: Url, client: Client, } mod generated; pub use generated::structs; #[derive(thiserror::Error, Debug)] pub enum ForgejoError { #[error("url must have a host")] HostRequired, #[error("scheme must be http or https")] HttpRequired, #[error(transparent)] ReqwestError(#[from] reqwest::Error), #[error("API key should be ascii")] KeyNotAscii, #[error("the response from forgejo was not properly structured")] BadStructure(#[source] serde_json::Error, String), #[error("unexpected status code {} {}", .0.as_u16(), .0.canonical_reason().unwrap_or(""))] UnexpectedStatusCode(StatusCode), #[error("{} {}{}", .0.as_u16(), .0.canonical_reason().unwrap_or(""), .1.as_ref().map(|s| format!(": {s}")).unwrap_or_default())] ApiError(StatusCode, Option), #[error("the provided authorization was too long to accept")] AuthTooLong, } /// Method of authentication to connect to the Forgejo host with. pub enum Auth<'a> { /// Application Access Token. Grants access to scope enabled for the /// provided token, which may include full access. /// /// To learn how to create a token, see /// [the Codeberg docs on the subject](https://docs.codeberg.org/advanced/access-token/). /// /// To learn about token scope, see /// [the official Forgejo docs](https://forgejo.org/docs/latest/user/token-scope/). Token(&'a str), /// Username, password, and 2-factor auth code (if enabled). Grants full /// access to the user's account. Password { username: &'a str, password: &'a str, mfa: Option<&'a str>, }, /// No authentication. Only grants access to access public endpoints. None, } impl Forgejo { pub fn new(auth: Auth, url: Url) -> Result { Self::with_user_agent(auth, url, "forgejo-api-rs") } pub fn with_user_agent(auth: Auth, url: Url, user_agent: &str) -> Result { soft_assert!( matches!(url.scheme(), "http" | "https"), Err(ForgejoError::HttpRequired) ); let mut headers = reqwest::header::HeaderMap::new(); match auth { Auth::Token(token) => { let mut header: reqwest::header::HeaderValue = format!("token {token}") .try_into() .map_err(|_| ForgejoError::KeyNotAscii)?; header.set_sensitive(true); headers.insert("Authorization", header); } Auth::Password { username, password, mfa, } => { let len = (((username.len() + password.len() + 1) .checked_mul(4) .ok_or(ForgejoError::AuthTooLong)?) / 3) + 1; let mut bytes = vec![0; len]; // panic safety: len cannot be zero let mut encoder = base64ct::Encoder::::new(&mut bytes).unwrap(); // panic safety: len will always be enough encoder.encode(username.as_bytes()).unwrap(); encoder.encode(b":").unwrap(); encoder.encode(password.as_bytes()).unwrap(); let b64 = encoder.finish().unwrap(); let mut header: reqwest::header::HeaderValue = format!("Basic {b64}").try_into().unwrap(); // panic safety: base64 is always ascii header.set_sensitive(true); headers.insert("Authorization", header); bytes.zeroize(); if let Some(mfa) = mfa { let mut key_header: reqwest::header::HeaderValue = mfa.try_into().map_err(|_| ForgejoError::KeyNotAscii)?; key_header.set_sensitive(true); headers.insert("X-FORGEJO-OTP", key_header); } } Auth::None => (), } let client = Client::builder() .user_agent(user_agent) .default_headers(headers) .build()?; Ok(Self { url, client }) } pub async fn download_release_attachment( &self, owner: &str, repo: &str, release: u64, attach: u64, ) -> Result { let release = self .repo_get_release_attachment(owner, repo, release, attach) .await?; let request = self .client .get(format!("/attachments/{}", release.uuid.unwrap())) .build()?; Ok(self.execute(request).await?.bytes().await?) } fn get(&self, path: &str) -> reqwest::RequestBuilder { let url = self.url.join("api/v1/").unwrap().join(path).unwrap(); self.client.get(url) } fn put(&self, path: &str) -> reqwest::RequestBuilder { let url = self.url.join("api/v1/").unwrap().join(path).unwrap(); self.client.put(url) } fn post(&self, path: &str) -> reqwest::RequestBuilder { let url = self.url.join("api/v1/").unwrap().join(path).unwrap(); self.client.post(url) } fn delete(&self, path: &str) -> reqwest::RequestBuilder { let url = self.url.join("api/v1/").unwrap().join(path).unwrap(); self.client.delete(url) } fn patch(&self, path: &str) -> reqwest::RequestBuilder { let url = self.url.join("api/v1/").unwrap().join(path).unwrap(); self.client.patch(url) } async fn execute(&self, request: Request) -> Result { let response = self.client.execute(request).await?; match response.status() { status if status.is_success() => Ok(response), status if status.is_client_error() => { Err(ForgejoError::ApiError(status, maybe_err(response).await)) } status => Err(ForgejoError::UnexpectedStatusCode(status)), } } } async fn maybe_err(res: reqwest::Response) -> Option { res.json::().await.ok().map(|e| e.message) } #[derive(serde::Deserialize)] struct ErrorMessage { message: String, // intentionally ignored, no need for now // url: Url } // Forgejo can return blank strings for URLs. This handles that by deserializing // that as `None` fn none_if_blank_url<'de, D: serde::Deserializer<'de>>( deserializer: D, ) -> Result, D::Error> { use serde::de::{Error, Unexpected, Visitor}; use std::fmt; struct EmptyUrlVisitor; impl<'de> Visitor<'de> for EmptyUrlVisitor { type Value = Option; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { formatter.write_str("option") } #[inline] fn visit_unit(self) -> Result where E: Error, { Ok(None) } #[inline] fn visit_none(self) -> Result where E: Error, { Ok(None) } #[inline] fn visit_str(self, s: &str) -> Result where E: Error, { if s.is_empty() { return Ok(None); } Url::parse(s) .map_err(|err| { let err_s = format!("{}", err); Error::invalid_value(Unexpected::Str(s), &err_s.as_str()) }) .map(Some) } } deserializer.deserialize_str(EmptyUrlVisitor) }