From dc5da48bff62a86437828bd97cd190843836e096 Mon Sep 17 00:00:00 2001 From: Jacob Kiers Date: Wed, 28 Dec 2022 22:48:20 +0100 Subject: [PATCH] feat: Add Gitea / Forgejo support This adds support for gitea and Forgejo, a friendly fork of Gitea. --- CHANGELOG.md | 1 + src/backends/gitea.rs | 608 ++++++++++++++++++++++++++++++++++++++++++ src/backends/mod.rs | 1 + 3 files changed, 610 insertions(+) create mode 100644 src/backends/gitea.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d8b7123..c2b6467 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## [unreleased] ### Added +- Support for Gitea / Forgejo ### Changed ### Removed diff --git a/src/backends/gitea.rs b/src/backends/gitea.rs new file mode 100644 index 0000000..dd61abc --- /dev/null +++ b/src/backends/gitea.rs @@ -0,0 +1,608 @@ +/*! +gitea releases +*/ +use std::env::{self, consts::EXE_SUFFIX}; +use std::path::{Path, PathBuf}; + +use reqwest::{self, header}; + +use crate::backends::find_rel_next_link; +use crate::{ + errors::*, + get_target, + update::{Release, ReleaseAsset, ReleaseUpdate}, + DEFAULT_PROGRESS_CHARS, DEFAULT_PROGRESS_TEMPLATE, +}; + +impl ReleaseAsset { + /// Parse a release-asset json object + /// + /// Errors: + /// * Missing required name & download-url keys + fn from_asset_gitea(asset: &serde_json::Value) -> Result { + let download_url = asset["browser_download_url"] + .as_str() + .ok_or_else(|| format_err!(Error::Release, "Asset missing `browser_download_url`"))?; + let name = asset["name"] + .as_str() + .ok_or_else(|| format_err!(Error::Release, "Asset missing `name`"))?; + Ok(ReleaseAsset { + download_url: download_url.to_owned(), + name: name.to_owned(), + }) + } +} + +impl Release { + fn from_release_gitea(release: &serde_json::Value) -> Result { + let tag = release["tag_name"] + .as_str() + .ok_or_else(|| format_err!(Error::Release, "Release missing `tag_name`"))?; + let date = release["created_at"] + .as_str() + .ok_or_else(|| format_err!(Error::Release, "Release missing `created_at`"))?; + let name = release["name"].as_str().unwrap_or(tag); + let assets = release["assets"] + .as_array() + .ok_or_else(|| format_err!(Error::Release, "No assets found"))?; + let body = release["body"].as_str().map(String::from); + let assets = assets + .iter() + .map(ReleaseAsset::from_asset_gitea) + .collect::>>()?; + Ok(Release { + name: name.to_owned(), + version: tag.trim_start_matches('v').to_owned(), + date: date.to_owned(), + body, + assets, + }) + } +} + +/// `ReleaseList` Builder +#[derive(Clone, Debug)] +pub struct ReleaseListBuilder { + host: Option, + repo_owner: Option, + repo_name: Option, + target: Option, + auth_token: Option, +} +impl ReleaseListBuilder { + /// Set the gitea `host` url + pub fn with_host(&mut self, host: &str) -> &mut Self { + self.host = Some(host.to_owned()); + self + } + + /// Set the repo owner, used to build a gitea api url + pub fn repo_owner(&mut self, owner: &str) -> &mut Self { + self.repo_owner = Some(owner.to_owned()); + self + } + + /// Set the repo name, used to build a gitea api url + pub fn repo_name(&mut self, name: &str) -> &mut Self { + self.repo_name = Some(name.to_owned()); + self + } + + /// Set the optional arch `target` name, used to filter available releases + pub fn with_target(&mut self, target: &str) -> &mut Self { + self.target = Some(target.to_owned()); + self + } + + /// Set the authorization token, used in requests to the gitea api url + /// + /// This is to support private repos where you need a gitea auth token. + /// **Make sure not to bake the token into your app**; it is recommended + /// you obtain it via another mechanism, such as environment variables + /// or prompting the user for input + pub fn auth_token(&mut self, auth_token: &str) -> &mut Self { + self.auth_token = Some(auth_token.to_owned()); + self + } + + /// Verify builder args, returning a `ReleaseList` + pub fn build(&self) -> Result { + Ok(ReleaseList { + host: if let Some(ref host) = self.host { + host.to_owned() + } else { + bail!(Error::Config, "`host` required") + }, + repo_owner: if let Some(ref owner) = self.repo_owner { + owner.to_owned() + } else { + bail!(Error::Config, "`repo_owner` required") + }, + repo_name: if let Some(ref name) = self.repo_name { + name.to_owned() + } else { + bail!(Error::Config, "`repo_name` required") + }, + target: self.target.clone(), + auth_token: self.auth_token.clone(), + }) + } +} + +/// `ReleaseList` provides a builder api for querying a gitea repo, +/// returning a `Vec` of available `Release`s +#[derive(Clone, Debug)] +pub struct ReleaseList { + host: String, + repo_owner: String, + repo_name: String, + target: Option, + auth_token: Option, +} +impl ReleaseList { + /// Initialize a ReleaseListBuilder + pub fn configure() -> ReleaseListBuilder { + ReleaseListBuilder { + host: None, + repo_owner: None, + repo_name: None, + target: None, + auth_token: None, + } + } + + /// Retrieve a list of `Release`s. + /// If specified, filter for those containing a specified `target` + pub fn fetch(self) -> Result> { + set_ssl_vars!(); + let api_url = format!( + "{}/api/v1/repos/{}/{}/releases", + self.host, self.repo_owner, self.repo_name + ); + + let releases = self.fetch_releases(&api_url)?; + let releases = match self.target { + None => releases, + Some(ref target) => releases + .into_iter() + .filter(|r| r.has_target_asset(target)) + .collect::>(), + }; + Ok(releases) + } + + fn fetch_releases(&self, url: &str) -> Result> { + let resp = reqwest::blocking::Client::new() + .get(url) + .headers(api_headers(&self.auth_token)?) + .send()?; + if !resp.status().is_success() { + bail!( + Error::Network, + "api request failed with status: {:?} - for: {:?}", + resp.status(), + url + ) + } + let headers = resp.headers().clone(); + + let releases = resp.json::()?; + let releases = releases + .as_array() + .ok_or_else(|| format_err!(Error::Release, "No releases found"))?; + let mut releases = releases + .iter() + .map(Release::from_release_gitea) + .collect::>>()?; + + // handle paged responses containing `Link` header: + // `Link: ; rel="next"` + let links = headers.get_all(reqwest::header::LINK); + + let next_link = links + .iter() + .filter_map(|link| { + if let Ok(link) = link.to_str() { + find_rel_next_link(link) + } else { + None + } + }) + .next(); + + Ok(match next_link { + None => releases, + Some(link) => { + releases.extend(self.fetch_releases(link)?); + releases + } + }) + } +} + +/// `gitea::Update` builder +/// +/// Configure download and installation from +/// `https:///api/v1/repos///releases` +#[derive(Debug)] +pub struct UpdateBuilder { + host: Option, + repo_owner: Option, + repo_name: Option, + target: Option, + bin_name: Option, + bin_install_path: Option, + bin_path_in_archive: Option, + show_download_progress: bool, + show_output: bool, + no_confirm: bool, + current_version: Option, + target_version: Option, + progress_template: String, + progress_chars: String, + auth_token: Option, +} + +impl UpdateBuilder { + /// Initialize a new builder + pub fn new() -> Self { + Default::default() + } + + /// Set the gitea `host` url + pub fn with_host(&mut self, host: &str) -> &mut Self { + self.host = Some(host.to_owned()); + self + } + + /// Set the repo owner, used to build a gitea api url + pub fn repo_owner(&mut self, owner: &str) -> &mut Self { + self.repo_owner = Some(owner.to_owned()); + self + } + + /// Set the repo name, used to build a gitea api url + pub fn repo_name(&mut self, name: &str) -> &mut Self { + self.repo_name = Some(name.to_owned()); + self + } + + /// Set the current app version, used to compare against the latest available version. + /// The `cargo_crate_version!` macro can be used to pull the version from your `Cargo.toml` + pub fn current_version(&mut self, ver: &str) -> &mut Self { + self.current_version = Some(ver.to_owned()); + self + } + + /// Set the target version tag to update to. This will be used to search for a release + /// by tag name: + /// `/repos/:owner%2F:repo/releases/:tag` + /// + /// If not specified, the latest available release is used. + pub fn target_version_tag(&mut self, ver: &str) -> &mut Self { + self.target_version = Some(ver.to_owned()); + self + } + + /// Set the target triple that will be downloaded, e.g. `x86_64-unknown-linux-gnu`. + /// + /// If unspecified, the build target of the crate will be used + pub fn target(&mut self, target: &str) -> &mut Self { + self.target = Some(target.to_owned()); + self + } + + /// Set the exe's name. Also sets `bin_path_in_archive` if it hasn't already been set. + /// + /// This method will append the platform specific executable file suffix + /// (see `std::env::consts::EXE_SUFFIX`) to the name if it's missing. + pub fn bin_name(&mut self, name: &str) -> &mut Self { + let raw_bin_name = format!("{}{}", name.trim_end_matches(EXE_SUFFIX), EXE_SUFFIX); + self.bin_name = Some(raw_bin_name.clone()); + if self.bin_path_in_archive.is_none() { + self.bin_path_in_archive = Some(PathBuf::from(raw_bin_name)); + } + self + } + + /// Set the installation path for the new exe, defaults to the current + /// executable's path + pub fn bin_install_path>(&mut self, bin_install_path: A) -> &mut Self { + self.bin_install_path = Some(PathBuf::from(bin_install_path.as_ref())); + self + } + + /// Set the path of the exe inside the release tarball. This is the location + /// of the executable relative to the base of the tar'd directory and is the + /// path that will be copied to the `bin_install_path`. If not specified, this + /// will default to the value of `bin_name`. This only needs to be specified if + /// the path to the binary (from the root of the tarball) is not equal to just + /// the `bin_name`. + /// + /// # Example + /// + /// For a tarball `myapp.tar.gz` with the contents: + /// + /// ```shell + /// myapp.tar/ + /// |------- bin/ + /// | |--- myapp # <-- executable + /// ``` + /// + /// The path provided should be: + /// + /// ``` + /// # use self_update::backends::gitea::Update; + /// # fn run() -> Result<(), Box< dyn ::std::error::Error>> { + /// Update::configure() + /// .bin_path_in_archive("bin/myapp") + /// # .build()?; + /// # Ok(()) + /// # } + /// ``` + pub fn bin_path_in_archive(&mut self, bin_path: &str) -> &mut Self { + self.bin_path_in_archive = Some(PathBuf::from(bin_path)); + self + } + + /// Toggle download progress bar, defaults to `off`. + pub fn show_download_progress(&mut self, show: bool) -> &mut Self { + self.show_download_progress = show; + self + } + + /// Set download progress style. + pub fn set_progress_style( + &mut self, + progress_template: String, + progress_chars: String, + ) -> &mut Self { + self.progress_template = progress_template; + self.progress_chars = progress_chars; + self + } + + /// Toggle update output information, defaults to `true`. + pub fn show_output(&mut self, show: bool) -> &mut Self { + self.show_output = show; + self + } + + /// Toggle download confirmation. Defaults to `false`. + pub fn no_confirm(&mut self, no_confirm: bool) -> &mut Self { + self.no_confirm = no_confirm; + self + } + + /// Set the authorization token, used in requests to the gitea api url + /// + /// This is to support private repos where you need a gitea auth token. + /// **Make sure not to bake the token into your app**; it is recommended + /// you obtain it via another mechanism, such as environment variables + /// or prompting the user for input + pub fn auth_token(&mut self, auth_token: &str) -> &mut Self { + self.auth_token = Some(auth_token.to_owned()); + self + } + + /// Confirm config and create a ready-to-use `Update` + /// + /// * Errors: + /// * Config - Invalid `Update` configuration + pub fn build(&self) -> Result> { + let bin_install_path = if let Some(v) = &self.bin_install_path { + v.clone() + } else { + env::current_exe()? + }; + + Ok(Box::new(Update { + host: if let Some(ref host) = self.host { + host.to_owned() + } else { + bail!(Error::Config, "`host` required") + }, + repo_owner: if let Some(ref owner) = self.repo_owner { + owner.to_owned() + } else { + bail!(Error::Config, "`repo_owner` required") + }, + repo_name: if let Some(ref name) = self.repo_name { + name.to_owned() + } else { + bail!(Error::Config, "`repo_name` required") + }, + target: self + .target + .as_ref() + .map(|t| t.to_owned()) + .unwrap_or_else(|| get_target().to_owned()), + bin_name: if let Some(ref name) = self.bin_name { + name.to_owned() + } else { + bail!(Error::Config, "`bin_name` required") + }, + bin_install_path, + bin_path_in_archive: if let Some(ref path) = self.bin_path_in_archive { + path.to_owned() + } else { + bail!(Error::Config, "`bin_path_in_archive` required") + }, + current_version: if let Some(ref ver) = self.current_version { + ver.to_owned() + } else { + bail!(Error::Config, "`current_version` required") + }, + target_version: self.target_version.as_ref().map(|v| v.to_owned()), + show_download_progress: self.show_download_progress, + progress_template: self.progress_template.clone(), + progress_chars: self.progress_chars.clone(), + show_output: self.show_output, + no_confirm: self.no_confirm, + auth_token: self.auth_token.clone(), + })) + } +} + +/// Updates to a specified or latest release distributed via gitea +#[derive(Debug)] +pub struct Update { + host: String, + repo_owner: String, + repo_name: String, + target: String, + current_version: String, + target_version: Option, + bin_name: String, + bin_install_path: PathBuf, + bin_path_in_archive: PathBuf, + show_download_progress: bool, + show_output: bool, + no_confirm: bool, + progress_template: String, + progress_chars: String, + auth_token: Option, +} +impl Update { + /// Initialize a new `Update` builder + pub fn configure() -> UpdateBuilder { + UpdateBuilder::new() + } +} + +impl ReleaseUpdate for Update { + fn get_latest_release(&self) -> Result { + set_ssl_vars!(); + let api_url = format!( + "{}/api/v1/repos/{}/{}/releases", + self.host, self.repo_owner, self.repo_name + ); + let resp = reqwest::blocking::Client::new() + .get(&api_url) + .headers(api_headers(&self.auth_token)?) + .send()?; + if !resp.status().is_success() { + bail!( + Error::Network, + "api request failed with status: {:?} - for: {:?}", + resp.status(), + api_url + ) + } + let json = resp.json::()?; + Release::from_release_gitea(&json[0]) + } + + fn get_release_version(&self, ver: &str) -> Result { + set_ssl_vars!(); + let api_url = format!( + "{}/api/v1/repos/{}/{}/releases/{}", + self.host, self.repo_owner, self.repo_name, ver + ); + let resp = reqwest::blocking::Client::new() + .get(&api_url) + .headers(api_headers(&self.auth_token)?) + .send()?; + if !resp.status().is_success() { + bail!( + Error::Network, + "api request failed with status: {:?} - for: {:?}", + resp.status(), + api_url + ) + } + let json = resp.json::()?; + Release::from_release_gitea(&json) + } + + fn current_version(&self) -> String { + self.current_version.to_owned() + } + + fn target(&self) -> String { + self.target.clone() + } + + fn target_version(&self) -> Option { + self.target_version.clone() + } + + fn bin_name(&self) -> String { + self.bin_name.clone() + } + + fn bin_install_path(&self) -> PathBuf { + self.bin_install_path.clone() + } + + fn bin_path_in_archive(&self) -> PathBuf { + self.bin_path_in_archive.clone() + } + + fn show_download_progress(&self) -> bool { + self.show_download_progress + } + + fn show_output(&self) -> bool { + self.show_output + } + + fn no_confirm(&self) -> bool { + self.no_confirm + } + + fn progress_template(&self) -> String { + self.progress_template.to_owned() + } + + fn progress_chars(&self) -> String { + self.progress_chars.to_owned() + } + + fn auth_token(&self) -> Option { + self.auth_token.clone() + } +} + +impl Default for UpdateBuilder { + fn default() -> Self { + Self { + host: None, + repo_owner: None, + repo_name: None, + target: None, + bin_name: None, + bin_install_path: None, + bin_path_in_archive: None, + show_download_progress: false, + show_output: true, + no_confirm: false, + current_version: None, + target_version: None, + progress_template: DEFAULT_PROGRESS_TEMPLATE.to_string(), + progress_chars: DEFAULT_PROGRESS_CHARS.to_string(), + auth_token: None, + } + } +} + +fn api_headers(auth_token: &Option) -> Result { + let mut headers = header::HeaderMap::new(); + headers.insert( + header::USER_AGENT, + "rust-reqwest/self-update" + .parse() + .expect("gitea invalid user-agent"), + ); + + if let Some(token) = auth_token { + headers.insert( + header::AUTHORIZATION, + format!("token {}", token) + .parse() + .map_err(|err| Error::Config(format!("Failed to parse auth token: {}", err)))?, + ); + }; + + Ok(headers) +} diff --git a/src/backends/mod.rs b/src/backends/mod.rs index 9462595..d331259 100644 --- a/src/backends/mod.rs +++ b/src/backends/mod.rs @@ -2,6 +2,7 @@ Collection of modules supporting various release distribution backends */ +pub mod gitea; pub mod github; pub mod gitlab; pub mod s3;