diff --git a/src/auth.rs b/src/auth.rs index 1625414..1511fe8 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -39,8 +39,13 @@ impl AuthCommand { None => crate::readline("new key: ").await?.trim().to_string(), }; if keys.hosts.get(&user).is_none() { - keys.hosts - .insert(host, crate::keys::LoginInfo::new(user, key)); + keys.hosts.insert( + host, + crate::keys::LoginInfo::Token { + name: user, + token: key, + }, + ); } else { println!("key for {} already exists", host); } @@ -57,3 +62,12 @@ impl AuthCommand { Ok(()) } } + +pub fn get_client_info_for(url: &url::Url) -> Option<(&'static str, &'static str)> { + let host = url.host_str()?; + let client_info = match (url.host_str()?, url.path()) { + ("codeberg.org", "/") => option_env!("CLIENT_INFO_CODEBERG"), + _ => None, + }; + client_info.and_then(|info| info.split_once(":")) +} diff --git a/src/issues.rs b/src/issues.rs index cf1abd7..a8e130d 100644 --- a/src/issues.rs +++ b/src/issues.rs @@ -121,10 +121,10 @@ pub enum ViewCommand { } impl IssueCommand { - pub async fn run(self, keys: &crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> { + pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> { use IssueSubcommand::*; let repo = RepoInfo::get_current(host_name, self.repo(), self.remote.as_deref())?; - let api = keys.get_api(repo.host_url())?; + let api = keys.get_api(repo.host_url()).await?; let repo = repo.name().ok_or_else(|| self.no_repo_error())?; match self.command { Create { diff --git a/src/keys.rs b/src/keys.rs index 72ab86d..90ac0dd 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -42,7 +42,7 @@ impl KeyInfo { Ok(()) } - pub fn get_login(&self, url: &Url) -> eyre::Result<&LoginInfo> { + pub fn get_login(&mut self, url: &Url) -> eyre::Result<&mut LoginInfo> { let host_str = url .host_str() .ok_or_else(|| eyre!("remote url does not have host"))?; @@ -54,32 +54,76 @@ impl KeyInfo { let login_info = self .hosts - .get(&domain) + .get_mut(&domain) .ok_or_else(|| eyre!("not signed in to {domain}"))?; Ok(login_info) } - pub fn get_api(&self, url: &Url) -> eyre::Result { - self.get_login(url)?.api_for(url).map_err(Into::into) + pub async fn get_api(&mut self, url: &Url) -> eyre::Result { + self.get_login(url)?.api_for(url).await.map_err(Into::into) } } -#[derive(serde::Serialize, serde::Deserialize, Clone, Default)] -pub struct LoginInfo { - name: String, - key: String, +#[derive(serde::Serialize, serde::Deserialize, Clone)] +#[serde(tag = "type")] +pub enum LoginInfo { + Token { + name: String, + token: String, + }, + OAuth { + name: String, + token: String, + refresh_token: String, + expires_at: time::OffsetDateTime, + }, } impl LoginInfo { - pub fn new(name: String, key: String) -> Self { - Self { name, key } - } - pub fn username(&self) -> &str { - &self.name + match self { + LoginInfo::Token { name, .. } => name, + LoginInfo::OAuth { name, .. } => name, + } } - pub fn api_for(&self, url: &Url) -> Result { - forgejo_api::Forgejo::new(forgejo_api::Auth::Token(&self.key), url.clone()) + pub async fn api_for(&mut self, url: &Url) -> eyre::Result { + match self { + LoginInfo::Token { token, .. } => { + let api = forgejo_api::Forgejo::new(forgejo_api::Auth::Token(token), url.clone())?; + Ok(api) + } + LoginInfo::OAuth { + token, + refresh_token, + expires_at, + .. + } => { + if time::OffsetDateTime::now_utc() >= *expires_at { + let api = forgejo_api::Forgejo::new(forgejo_api::Auth::None, url.clone())?; + let (client_id, client_secret) = crate::auth::get_client_info_for(url) + .ok_or_else(|| { + eyre::eyre!("Can't refresh token; no client info for {url}. How did this happen?") + })?; + let response = api + .oauth_get_access_token(forgejo_api::structs::OAuthTokenRequest::Refresh { + refresh_token, + client_id, + client_secret, + }) + .await?; + *token = response.access_token; + *refresh_token = response.refresh_token; + // A minute less, in case any weirdness happens at the exact moment it + // expires. Better to refresh slightly too soon than slightly too late. + let expires_in = std::time::Duration::from_secs( + response.expires_in.saturating_sub(60) as u64, + ); + *expires_at = time::OffsetDateTime::now_utc() + expires_in; + } + let api = forgejo_api::Forgejo::new(forgejo_api::Auth::Token(token), url.clone())?; + Ok(api) + } + } } } diff --git a/src/main.rs b/src/main.rs index 92b547c..d24f4c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,9 +50,9 @@ async fn main() -> eyre::Result<()> { let host_name = args.host.as_deref(); // let remote = repo::RepoInfo::get_current(host_name, remote_name)?; match args.command { - Command::Repo(subcommand) => subcommand.run(&keys, host_name).await?, - Command::Issue(subcommand) => subcommand.run(&keys, host_name).await?, - Command::Pr(subcommand) => subcommand.run(&keys, host_name).await?, + Command::Repo(subcommand) => subcommand.run(&mut keys, host_name).await?, + Command::Issue(subcommand) => subcommand.run(&mut keys, host_name).await?, + Command::Pr(subcommand) => subcommand.run(&mut keys, host_name).await?, Command::WhoAmI { remote } => { let url = repo::RepoInfo::get_current(host_name, None, remote.as_deref()) .wrap_err("could not find host, try specifying with --host")? diff --git a/src/prs.rs b/src/prs.rs index bd9852f..535b62e 100644 --- a/src/prs.rs +++ b/src/prs.rs @@ -249,10 +249,10 @@ pub enum ViewCommand { } impl PrCommand { - pub async fn run(self, keys: &crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> { + pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> { use PrSubcommand::*; let repo = RepoInfo::get_current(host_name, self.repo(), self.remote.as_deref())?; - let api = keys.get_api(repo.host_url())?; + let api = keys.get_api(repo.host_url()).await?; let repo = repo.name().ok_or_else(|| self.no_repo_error())?; match self.command { Create { diff --git a/src/release.rs b/src/release.rs index dd9010c..b5a7566 100644 --- a/src/release.rs +++ b/src/release.rs @@ -116,10 +116,10 @@ pub enum AssetCommand { } impl ReleaseCommand { - pub async fn run(self, keys: &KeyInfo, remote_name: Option<&str>) -> eyre::Result<()> { + pub async fn run(self, keys: &mut KeyInfo, remote_name: Option<&str>) -> eyre::Result<()> { let repo = RepoInfo::get_current(remote_name, self.repo.as_deref(), self.remote.as_deref())?; - let api = keys.get_api(&repo.host_url())?; + let api = keys.get_api(&repo.host_url()).await?; let repo = repo .name() .ok_or_eyre("couldn't get repo name, try specifying with --repo")?; diff --git a/src/repo.rs b/src/repo.rs index 16bcc0b..7520af8 100644 --- a/src/repo.rs +++ b/src/repo.rs @@ -268,7 +268,7 @@ pub enum RepoCommand { } impl RepoCommand { - pub async fn run(self, keys: &crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> { + pub async fn run(self, keys: &mut crate::KeyInfo, host_name: Option<&str>) -> eyre::Result<()> { match self { RepoCommand::Create { repo, @@ -287,7 +287,7 @@ impl RepoCommand { } } let host = RepoInfo::get_current(host_name, None, None)?; - let api = keys.get_api(host.host_url())?; + let api = keys.get_api(host.host_url()).await?; let repo_spec = CreateRepoOption { auto_init: Some(false), default_branch: Some("main".into()), @@ -343,7 +343,7 @@ impl RepoCommand { } RepoCommand::View { name, remote } => { let repo = RepoInfo::get_current(host_name, name.as_deref(), remote.as_deref())?; - let api = keys.get_api(&repo.host_url())?; + let api = keys.get_api(&repo.host_url()).await?; let repo = repo .name() .ok_or_eyre("couldn't get repo name, please specify")?; @@ -454,7 +454,7 @@ impl RepoCommand { } RepoCommand::Clone { repo, path } => { let repo = RepoInfo::get_current(host_name, Some(&repo), None)?; - let api = keys.get_api(&repo.host_url())?; + let api = keys.get_api(&repo.host_url()).await?; let name = repo.name().unwrap(); let repo_data = api.repo_get(name.owner(), name.name()).await?; @@ -544,14 +544,14 @@ impl RepoCommand { } RepoCommand::Star { repo } => { let repo = RepoInfo::get_current(host_name, Some(&repo), None)?; - let api = keys.get_api(&repo.host_url())?; + let api = keys.get_api(&repo.host_url()).await?; let name = repo.name().unwrap(); api.user_current_put_star(name.owner(), name.name()).await?; println!("Starred {}/{}!", name.owner(), name.name()); } RepoCommand::Unstar { repo } => { let repo = RepoInfo::get_current(host_name, Some(&repo), None)?; - let api = keys.get_api(&repo.host_url())?; + let api = keys.get_api(&repo.host_url()).await?; let name = repo.name().unwrap(); api.user_current_delete_star(name.owner(), name.name()) .await?;