use std::{error, fmt, str::FromStr}; use reqwest::{ Client as HttpClient, header::{ACCEPT, AUTHORIZATION, HeaderMap, HeaderValue}, }; pub use reqwest::{Method, RequestBuilder, Response, StatusCode, Url}; use serde::{Deserialize, Serialize}; use gitea_openapi::apis; pub use gitea_openapi::models; pub use models::{Repository, ServerVersion, User}; pub mod activity; mod config; pub mod diff; mod domain; mod issues; mod milestones; mod pulls; mod repositories; pub use activity::ActivityFilter; pub use config::{Config, Selection, ServerProfile, TuiPreferences, server_url}; pub use domain::{ CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId, api_date, civil_from_days, days_from_civil, parse_api_date, }; pub use issues::comment_can_edit; pub use pulls::{PullFileSource, pull_file_source, pull_state}; pub type Result = std::result::Result; #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "lowercase")] pub enum Provider { #[default] Gitea, Forgejo, } impl fmt::Display for Provider { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter.write_str(match self { Self::Gitea => "gitea", Self::Forgejo => "forgejo", }) } } impl FromStr for Provider { type Err = Error; fn from_str(value: &str) -> Result { match value.to_ascii_lowercase().as_str() { "gitea" => Ok(Self::Gitea), "forgejo" => Ok(Self::Forgejo), _ => Err(Error::InvalidInput( "provider must be gitea or forgejo".into(), )), } } } pub(crate) fn validate_page(page: i32, limit: i32) -> Result<()> { if page < 1 || limit < 1 { return Err(Error::InvalidInput( "page and limit must be positive".into(), )); } Ok(()) } pub(crate) fn positive(value: i64, name: &str) -> Result<()> { if value < 1 { return Err(Error::InvalidInput(format!( "{name} must be a positive integer" ))); } Ok(()) } #[derive(Debug)] pub enum Error { Configuration(String), InvalidInput(String), Forbidden(String), Transport(reqwest::Error), Generated(String), Api { status: StatusCode, message: String }, } impl fmt::Display for Error { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Configuration(message) => formatter.write_str(message), Self::InvalidInput(message) => formatter.write_str(message), Self::Forbidden(message) => formatter.write_str(message), Self::Transport(error) => error.fmt(formatter), Self::Generated(message) => formatter.write_str(message), Self::Api { status, message } => { write!(formatter, "Server returned {status}: {message}") } } } } impl error::Error for Error { fn source(&self) -> Option<&(dyn error::Error + 'static)> { match self { Self::Transport(error) => Some(error), _ => None, } } } impl From for Error { fn from(error: reqwest::Error) -> Self { Self::Transport(error) } } impl Error { pub(crate) fn generated(error: impl fmt::Display) -> Self { Self::Generated(error.to_string()) } } #[derive(Clone, Debug)] pub struct Client { api_url: Url, forgejo_api_url: Url, http: HttpClient, provider: Provider, } impl Client { pub fn new(instance_url: &str, token: Option<&str>) -> Result { Self::with_provider(instance_url, token, Provider::Gitea) } pub fn with_provider( instance_url: &str, token: Option<&str>, provider: Provider, ) -> Result { let mut instance_url = Url::parse(instance_url) .map_err(|error| Error::Configuration(format!("invalid server URL: {error}")))?; if !matches!(instance_url.scheme(), "http" | "https") { return Err(Error::Configuration( "Server URL must use http or https".into(), )); } if !instance_url.username().is_empty() || instance_url.password().is_some() { return Err(Error::Configuration( "Server URL must not contain credentials".into(), )); } if !instance_url.path().ends_with('/') { instance_url.set_path(&format!("{}/", instance_url.path())); } let api_url = instance_url .join("api/v1/") .map_err(|error| Error::Configuration(format!("invalid server URL: {error}")))?; let forgejo_api_url = instance_url .join("api/forgejo/v1/") .map_err(|error| Error::Configuration(format!("invalid server URL: {error}")))?; let mut headers = HeaderMap::new(); headers.insert(ACCEPT, HeaderValue::from_static("application/json")); if let Some(token) = token { let value = HeaderValue::from_str(&format!("token {token}")) .map_err(|_| Error::Configuration("invalid server token".into()))?; headers.insert(AUTHORIZATION, value); } Ok(Self { api_url, forgejo_api_url, http: HttpClient::builder().default_headers(headers).build()?, provider, }) } pub async fn discover( instance_url: &str, token: Option<&str>, fallback: Provider, ) -> Result { let mut client = Self::with_provider(instance_url, token, fallback)?; let version = client.gitea_version().await?; if is_forgejo_version(&version) { client.provider = Provider::Forgejo; return Ok(client); } match client.forgejo_version().await { Ok(_) => client.provider = Provider::Forgejo, Err(Error::Api { status, .. }) if status == StatusCode::NOT_FOUND => { if fallback == Provider::Forgejo { return Err(Error::Configuration( "Server does not expose the Forgejo API".into(), )); } client.provider = Provider::Gitea; } Err(error) => return Err(error), } Ok(client) } pub fn api_url(&self) -> &Url { &self.api_url } pub fn provider(&self) -> Provider { self.provider } /// Returns an authenticated configuration for every generated typed API. pub(crate) fn configuration(&self) -> apis::configuration::Configuration { apis::configuration::Configuration { base_path: self.api_url.as_str().trim_end_matches('/').into(), client: self.http.clone(), user_agent: Some(format!("gotcha_gitea/{}", env!("CARGO_PKG_VERSION"))), ..Default::default() } } /// Builds an authenticated request to an API-relative endpoint. /// /// The returned reqwest builder supports JSON, forms, multipart uploads, /// query parameters, and byte streams without duplicating those APIs here. pub fn request(&self, method: Method, endpoint: &str) -> Result { if endpoint.starts_with('/') || Url::parse(endpoint).is_ok() || endpoint.split('/').any(|segment| segment == "..") { return Err(Error::Configuration( "endpoint must be a relative path below /api/v1".into(), )); } let url = self .api_url .join(endpoint) .map_err(|error| Error::Configuration(format!("invalid endpoint: {error}")))?; Ok(self.http.request(method, url)) } pub async fn execute(&self, request: RequestBuilder) -> Result { let response = request.send().await?; let status = response.status(); if status.is_success() { return Ok(response); } let body = response.text().await?; let message = serde_json::from_str::(&body) .ok() .map(|error| error.message) .filter(|message| !message.is_empty()) .unwrap_or(body); Err(Error::Api { status, message }) } pub async fn version(&self) -> Result { match self.provider { Provider::Gitea => self.gitea_version().await, Provider::Forgejo => self.forgejo_version().await, } } async fn gitea_version(&self) -> Result { apis::miscellaneous_api::get_version(&self.configuration()) .await .map_err(|error| Error::Generated(error.to_string())) } async fn forgejo_version(&self) -> Result { let request = self.http .get(self.forgejo_api_url.join("version").map_err(|error| { Error::Configuration(format!("invalid Forgejo endpoint: {error}")) })?); Ok(self.execute(request).await?.json().await?) } pub async fn current_user(&self) -> Result { apis::user_api::user_get_current(&self.configuration()) .await .map_err(|error| Error::Generated(error.to_string())) } pub async fn current_user_repositories(&self) -> Result> { apis::user_api::user_current_list_repos(&self.configuration(), None, None) .await .map_err(|error| Error::Generated(error.to_string())) } pub async fn repository(&self, owner: &str, repository: &str) -> Result { if owner.is_empty() || repository.is_empty() { return Err(Error::Configuration( "repository owner and name must not be empty".into(), )); } apis::repository_api::repo_get(&self.configuration(), owner, repository) .await .map_err(|error| Error::Generated(error.to_string())) } } fn is_forgejo_version(version: &models::ServerVersion) -> bool { version .version .as_deref() .is_some_and(|version| version.contains("+gitea-")) } #[derive(Debug, Deserialize)] struct ApiError { #[serde(default)] message: String, } #[cfg(test)] mod tests { use super::*; #[test] fn keeps_instance_subpaths_and_rejects_endpoint_escapes() { let client = Client::with_provider( "https://example.com/gitea", Some("secret"), Provider::Forgejo, ) .unwrap(); assert_eq!( client.api_url().as_str(), "https://example.com/gitea/api/v1/" ); assert!(client.request(Method::GET, "repos/octo/demo").is_ok()); assert!(client.request(Method::GET, "../admin").is_err()); assert!( client .request(Method::GET, "https://example.net/user") .is_err() ); assert_eq!( client.configuration().base_path, "https://example.com/gitea/api/v1" ); assert_eq!(client.provider(), Provider::Forgejo); assert_eq!( client.forgejo_api_url.as_str(), "https://example.com/gitea/api/forgejo/v1/" ); assert_eq!("GITEA".parse::().unwrap(), Provider::Gitea); assert_eq!(Provider::Forgejo.to_string(), "forgejo"); assert!("github".parse::().is_err()); assert!(is_forgejo_version(&models::ServerVersion { version: Some("16.0.1+gitea-1.24.6".into()), })); assert!(!is_forgejo_version(&models::ServerVersion { version: Some("1.25.2".into()), })); let _typed_model = models::Issue::default(); } }