diff --git a/Cargo.lock b/Cargo.lock index c8f43fc..e62534c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2273,10 +2273,8 @@ name = "patreon-proxy" version = "0.1.0" dependencies = [ "chrono", - "deadpool-postgres 0.10.5", "envy", "reqwest 0.11.27", - "rustls 0.21.12", "sentry 0.31.8", "sentry-tracing 0.31.8", "serde", @@ -2284,7 +2282,6 @@ dependencies = [ "serde_json", "thiserror", "tokio", - "tokio-postgres", "tracing", "tracing-subscriber 0.3.18", "url", @@ -2408,7 +2405,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d2234cdee9408b523530a9b6d2d6b373d1db34f6a8e51dc03ded1828d7fb67c" dependencies = [ "bytes 1.6.0", - "chrono", "fallible-iterator", "postgres-protocol", ] diff --git a/patreon-proxy/Cargo.toml b/patreon-proxy/Cargo.toml index b34b842..0538d71 100644 --- a/patreon-proxy/Cargo.toml +++ b/patreon-proxy/Cargo.toml @@ -6,7 +6,6 @@ edition = "2021" [dependencies] tokio = { version = "1", features = ["full"] } -tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"] } thiserror = "1" chrono = { version = "0.4", features = ["serde"] } reqwest = { version = "0.11", features = ["json", "rustls-tls"] } @@ -14,11 +13,10 @@ serde = { version = "1", features = ["derive"] } serde-enum-str = "0.4" serde_json = "1" warp = { version = "0.3", default-features = false } -rustls = "0.21" +# rustls = "0.21" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } sentry = "0.31" sentry-tracing = "0.31" envy = "0.4" -deadpool-postgres = "0.10" url = "2" \ No newline at end of file diff --git a/patreon-proxy/envvars.md b/patreon-proxy/envvars.md index c20c35f..1c6679c 100644 --- a/patreon-proxy/envvars.md +++ b/patreon-proxy/envvars.md @@ -1,6 +1,8 @@ - PATREON_CAMPAIGN_ID - PATREON_CLIENT_ID - PATREON_CLIENT_SECRET -- PATREON_REDIRECT_URI - SERVER_ADDR -- DATABASE_URI \ No newline at end of file +- RUST_LOG +- DEBUG_MODE +- JSON_LOG +- SENTRY_DSON \ No newline at end of file diff --git a/patreon-proxy/src/main.rs b/patreon-proxy/src/bin/main.rs similarity index 64% rename from patreon-proxy/src/main.rs rename to patreon-proxy/src/bin/main.rs index 7ddb8ec..7bfa2d1 100644 --- a/patreon-proxy/src/main.rs +++ b/patreon-proxy/src/bin/main.rs @@ -1,51 +1,34 @@ -mod config; -mod database; -mod error; -mod http; -mod patreon; - -use config::Config; -use database::Database; -use patreon::oauth; -use patreon::Entitlement; -use patreon::Poller; - use std::collections::HashMap; use std::str::FromStr; use std::sync::{Arc, RwLock}; use chrono::prelude::*; +use patreon_proxy::patreon::oauth::OauthClient; +use patreon_proxy::patreon::{Entitlement, Poller, Tokens}; +use patreon_proxy::{http, Config, Result}; use sentry::types::Dsn; use sentry_tracing::EventFilter; -use std::time::Duration; +use std::time::{Duration, Instant}; use tokio::time::sleep; -use crate::error::Error; -use tracing::log::{debug, error, info}; +use tracing::{debug, error, info}; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::EnvFilter; #[tokio::main] -pub async fn main() -> Result<(), Error> { - let config = Config::new().expect("Failed to load config from environment variables"); +pub async fn main() -> Result<()> { + let config = Arc::new(Config::new().expect("Failed to load config from environment variables")); let _guard = configure_observability(&config); - info!("Connecting to database..."); - let db_client = Database::connect(&config).await?; - db_client.create_schema().await?; - info!("Database connection established"); - - let mut tokens = Arc::new( - db_client - .get_tokens(config.patreon_client_id.clone()) - .await?, - ); - tokens = Arc::new(handle_refresh(&tokens, &config, &db_client).await?); + let oauth_client = OauthClient::new(Arc::clone(&config))?; + + let tokens = attempt_grant(&oauth_client, Some(10)).await?; + let expires_at = Instant::now() + Duration::from_secs(tokens.expires_in as u64); - let mut poller = Poller::new(config.patreon_campaign_id.clone(), Arc::clone(&tokens)); + let mut poller = Poller::new(config.patreon_campaign_id.clone(), tokens); let mut server_started = false; @@ -60,11 +43,11 @@ pub async fn main() -> Result<(), Error> { info!("Starting loop"); // Patreon issues tokens that last 1 month, but I have noticed some issues refreshing them close to the deadline - if (current_time_seconds() + (86400 * 3)) > tokens.expires { + if (expires_at - Instant::now()) < Duration::from_secs(86400 * 3) { info!("Needs new credentials"); - tokens = Arc::new(handle_refresh(&tokens, &config, &db_client).await?); - poller.tokens = Arc::clone(&tokens); - info!("Retrieved new credentials"); + let tokens = attempt_grant(&oauth_client, None).await?; + poller = Poller::new(config.patreon_campaign_id.clone(), tokens); + info!(?expires_at, "Retrieved new credentials"); } info!("Polling"); @@ -99,33 +82,30 @@ pub async fn main() -> Result<(), Error> { } } -fn current_time_seconds() -> i64 { - Utc::now().timestamp() -} +async fn attempt_grant(oauth_client: &OauthClient, max_retries: Option) -> Result { + let mut retries = 0usize; -async fn handle_refresh( - tokens: &database::Tokens, - config: &Config, - db_client: &Database, -) -> Result { - info!("handle_refresh called"); - let new_tokens = oauth::refresh_tokens( - tokens.refresh_token.clone(), - config.patreon_client_id.clone(), - config.patreon_client_secret.clone(), - ) - .await?; - - let tokens = database::Tokens::new( - new_tokens.access_token, - new_tokens.refresh_token, - current_time_seconds() + new_tokens.expires_in, - ); - db_client - .update_tokens(config.patreon_client_id.clone(), &tokens) - .await?; - - Ok(tokens) + loop { + info!("Attempting to refresh credentials"); + + let err = match oauth_client.grant_credentials().await { + Ok(tokens) => return Ok(tokens), + Err(e) => { + error!(error = %e, "Failed to refresh credentials, waiting 30s"); + e + } + }; + + retries += 1; + if let Some(max) = max_retries { + if retries >= max { + error!("Failed to refresh credentials after {} attempts", max); + return Err(err); + } + } + + sleep(Duration::from_secs(30)).await; + } } fn configure_observability(config: &Config) -> sentry::ClientInitGuard { diff --git a/patreon-proxy/src/config.rs b/patreon-proxy/src/config.rs index 4138331..f4cdcfc 100644 --- a/patreon-proxy/src/config.rs +++ b/patreon-proxy/src/config.rs @@ -5,9 +5,7 @@ pub struct Config { pub patreon_campaign_id: String, pub patreon_client_id: String, pub patreon_client_secret: String, - pub patreon_redirect_uri: String, pub server_addr: String, - pub database_uri: String, pub sentry_dsn: Option, #[serde(default)] pub debug_mode: bool, diff --git a/patreon-proxy/src/database/database.rs b/patreon-proxy/src/database/database.rs deleted file mode 100644 index 0daef73..0000000 --- a/patreon-proxy/src/database/database.rs +++ /dev/null @@ -1,89 +0,0 @@ -use super::Tokens; - -use crate::config::Config; -use crate::error::{Error, Result}; -use tokio_postgres::NoTls; - -use chrono::{DateTime, NaiveDateTime, Utc}; -use deadpool_postgres::{Config as ConnectionConfig, GenericClient, Pool, PoolConfig, Runtime}; -use url::Url; - -pub struct Database { - pool: Pool, -} - -impl Database { - pub async fn connect(config: &Config) -> Result { - let url = Url::parse(&config.database_uri)?; - - let pool_size = url - .query_pairs() - .find(|(k, _)| k == "max_size") - .map(|(_, v)| v.parse::()) - .unwrap_or(Ok(4))?; - - let mut pool_config = ConnectionConfig::new(); - pool_config.host = url.host_str().map(|s| s.to_owned()); - pool_config.port = url.port(); - pool_config.dbname = Some(url.path().trim_start_matches('/').to_owned()); - pool_config.user = Some(url.username().to_owned()); - pool_config.password = url.password().map(|s| s.to_owned()); - pool_config.pool = Some(PoolConfig::new(pool_size)); - - let pool = pool_config.create_pool(Some(Runtime::Tokio1), NoTls)?; - - Ok(Database { pool }) - } - - pub async fn create_schema(&self) -> Result<()> { - let query = include_str!("sql/patreon_keys/schema.sql"); - self.pool.get().await?.execute(query, &[]).await?; - - Ok(()) - } - - pub async fn get_tokens(&self, client_id: String) -> Result { - let query = include_str!("sql/patreon_keys/get_tokens.sql"); - - let conn = self.pool.get().await?; - let statement = conn.prepare_cached(query).await?; - let rows = conn.query(&statement, &[&client_id]).await?; - - if rows.is_empty() { - return Error::MissingTokens(client_id).into(); - } - - let row = &rows[0]; - - let access_token: String = row.try_get(0)?; - let refresh_token: String = row.try_get(1)?; - let expires: DateTime = row.try_get(2)?; - - Ok(Tokens::new( - access_token, - refresh_token, - expires.timestamp(), - )) - } - - pub async fn update_tokens(&self, client_id: String, tokens: &Tokens) -> Result<()> { - let query = include_str!("sql/patreon_keys/insert_tokens.sql"); - let date_time = - DateTime::::from_utc(NaiveDateTime::from_timestamp(tokens.expires, 0), Utc); - - let conn = self.pool.get().await?; - let statement = conn.prepare_cached(query).await?; - conn.execute( - &statement, - &[ - &client_id, - &tokens.access_token, - &tokens.refresh_token, - &date_time, - ], - ) - .await?; - - Ok(()) - } -} diff --git a/patreon-proxy/src/database/mod.rs b/patreon-proxy/src/database/mod.rs deleted file mode 100644 index 13d792d..0000000 --- a/patreon-proxy/src/database/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -mod database; -mod tokens; - -pub use database::Database; -pub use tokens::Tokens; diff --git a/patreon-proxy/src/database/sql/patreon_keys/get_tokens.sql b/patreon-proxy/src/database/sql/patreon_keys/get_tokens.sql deleted file mode 100644 index 495d604..0000000 --- a/patreon-proxy/src/database/sql/patreon_keys/get_tokens.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT "access_token", - "refresh_token", - "expires" -FROM patreon_keys -WHERE "client_id" = $1; \ No newline at end of file diff --git a/patreon-proxy/src/database/sql/patreon_keys/insert_tokens.sql b/patreon-proxy/src/database/sql/patreon_keys/insert_tokens.sql deleted file mode 100644 index 921be8a..0000000 --- a/patreon-proxy/src/database/sql/patreon_keys/insert_tokens.sql +++ /dev/null @@ -1,6 +0,0 @@ -INSERT INTO patreon_keys -VALUES ($1, $2, $3, $4) -ON CONFLICT ("client_id") DO UPDATE - SET "access_token" = EXCLUDED.access_token, - "refresh_token" = EXCLUDED.refresh_token, - "expires" = EXCLUDED.expires \ No newline at end of file diff --git a/patreon-proxy/src/database/sql/patreon_keys/schema.sql b/patreon-proxy/src/database/sql/patreon_keys/schema.sql deleted file mode 100644 index f4c830b..0000000 --- a/patreon-proxy/src/database/sql/patreon_keys/schema.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE IF NOT EXISTS patreon_keys ( - "client_id" VARCHAR(255) NOT NULL, - "access_token" VARCHAR(255) NOT NULL, - "refresh_token" VARCHAR(255) NOT NULL, - "expires" TIMESTAMPTZ NOT NULL, - PRIMARY KEY("client_id") -); diff --git a/patreon-proxy/src/database/tokens.rs b/patreon-proxy/src/database/tokens.rs deleted file mode 100644 index 012c5ac..0000000 --- a/patreon-proxy/src/database/tokens.rs +++ /dev/null @@ -1,15 +0,0 @@ -pub struct Tokens { - pub access_token: String, - pub refresh_token: String, - pub expires: i64, // seconds since epoch -} - -impl Tokens { - pub fn new(access_token: String, refresh_token: String, expires: i64) -> Tokens { - Tokens { - access_token, - refresh_token, - expires, - } - } -} diff --git a/patreon-proxy/src/error.rs b/patreon-proxy/src/error.rs index ec2220a..ca3308d 100644 --- a/patreon-proxy/src/error.rs +++ b/patreon-proxy/src/error.rs @@ -13,20 +13,14 @@ pub enum Error { #[error("Error while operating on JSON: {0}")] JsonError(#[from] serde_json::Error), - #[error("Error while performing database operation: {0}")] - DatabaseError(#[from] tokio_postgres::Error), + #[error("Error requesting Patreon. Status: {0}")] + PatreonError(reqwest::StatusCode), #[error("Error while parsing URL: {0}")] UrlParseError(#[from] url::ParseError), - #[error("Error while creating database pool: {0}")] - CreatePoolError(#[from] deadpool_postgres::CreatePoolError), - #[error("{0}")] ParseIntError(#[from] std::num::ParseIntError), - - #[error("Error while managing database pool: {0}")] - PoolError(#[from] deadpool_postgres::PoolError), } impl From for Result { diff --git a/patreon-proxy/src/lib.rs b/patreon-proxy/src/lib.rs new file mode 100644 index 0000000..c75b9e1 --- /dev/null +++ b/patreon-proxy/src/lib.rs @@ -0,0 +1,8 @@ +mod config; +pub use config::Config; + +mod error; +pub use error::{Error, Result}; + +pub mod http; +pub mod patreon; diff --git a/patreon-proxy/src/patreon/entitlement.rs b/patreon-proxy/src/patreon/entitlement.rs index 287b3c8..42fa007 100644 --- a/patreon-proxy/src/patreon/entitlement.rs +++ b/patreon-proxy/src/patreon/entitlement.rs @@ -1,7 +1,9 @@ use chrono::{DateTime, Months, Utc}; use serde::Serialize; -use super::{models::MemberAttributes, tier::TIERS_PREMIUM_LEGACY, tier::TIERS_WHITELABEL_LEGACY, Tier}; +use super::{ + models::MemberAttributes, tier::TIERS_PREMIUM_LEGACY, tier::TIERS_WHITELABEL_LEGACY, Tier, +}; #[derive(Debug, Clone, Serialize)] pub struct Entitlement { diff --git a/patreon-proxy/src/patreon/mod.rs b/patreon-proxy/src/patreon/mod.rs index 944c9aa..39e689e 100644 --- a/patreon-proxy/src/patreon/mod.rs +++ b/patreon-proxy/src/patreon/mod.rs @@ -5,6 +5,7 @@ mod poller; mod tier; pub use entitlement::Entitlement; +pub use models::Tokens; pub use models::PledgeResponse; pub use poller::Poller; pub use tier::Tier; diff --git a/patreon-proxy/src/patreon/models.rs b/patreon-proxy/src/patreon/models.rs index 5305e04..177fcee 100644 --- a/patreon-proxy/src/patreon/models.rs +++ b/patreon-proxy/src/patreon/models.rs @@ -6,6 +6,14 @@ use std::collections::HashMap; use super::Entitlement; +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct Tokens { + pub access_token: String, + pub refresh_token: String, + pub expires_in: i64, +} + #[derive(Debug, Deserialize)] pub struct PledgeResponse { pub data: Vec, diff --git a/patreon-proxy/src/patreon/oauth.rs b/patreon-proxy/src/patreon/oauth.rs index 5a4936b..25d1cc3 100644 --- a/patreon-proxy/src/patreon/oauth.rs +++ b/patreon-proxy/src/patreon/oauth.rs @@ -1,28 +1,56 @@ -use crate::error::Error; -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -#[allow(dead_code)] -pub struct PatreonResponse { - pub access_token: String, - pub refresh_token: String, - pub expires_in: i64, - scope: String, - token_type: String, +use std::{sync::Arc, time::Duration}; + +use crate::{Config, Error, Result}; +use tracing::error; + +use super::models::Tokens; + +pub struct OauthClient { + client: reqwest::Client, + config: Arc, } -pub async fn refresh_tokens( - refresh_token: String, - client_id: String, - client_secret: String, -) -> Result { - let uri = format!("https://www.patreon.com/api/oauth2/token?grant_type=refresh_token&refresh_token={}&client_id={}&client_secret={}", refresh_token, client_id, client_secret); +const TOKEN_URI: &'static str = "https://www.patreon.com/api/oauth2/token"; +const USER_AGENT: &'static str = + "ticketsbot.net/patreon-proxy (https://github.com/TicketsBot/tickets.rs)"; + +impl OauthClient { + pub fn new(config: Arc) -> Result { + let client = reqwest::ClientBuilder::new() + .use_rustls_tls() + .timeout(Duration::from_secs(15)) + .connect_timeout(Duration::from_secs(15)) + .build()?; + + Ok(OauthClient { client, config }) + } + + pub async fn grant_credentials(&self) -> Result { + let form_body = [ + ("grant_type", "client_credentials"), + ("client_id", self.config.patreon_client_id.as_str()), + ("client_secret", self.config.patreon_client_secret.as_str()), + ]; + + let res = self.client.post(TOKEN_URI) + .form(&form_body) + .header("User-Agent", USER_AGENT) + .send().await?; + + if !res.status().is_success() { + let status = res.status(); + let body = match res.bytes().await { + Ok(b) => String::from_utf8_lossy(&b).to_string(), + Err(e) => { + error!(error = %e, "Failed to ready response body for non-2xx status code"); + String::from("Failed to read response body") + } + }; - let client = reqwest::ClientBuilder::new() - .use_rustls_tls() - .build() - .unwrap(); - let res: PatreonResponse = client.post(&uri).send().await?.json().await?; + error!(%status, %body, "Failed to perform client_credentials exchange"); + return Error::PatreonError(status).into(); + } - Ok(res) + Ok(res.json().await?) + } } diff --git a/patreon-proxy/src/patreon/poller.rs b/patreon-proxy/src/patreon/poller.rs index b5da769..cb09382 100644 --- a/patreon-proxy/src/patreon/poller.rs +++ b/patreon-proxy/src/patreon/poller.rs @@ -1,10 +1,8 @@ +use super::models::Tokens; use super::Entitlement; use super::PledgeResponse; -use crate::database::Tokens; use std::collections::HashMap; -use std::sync::Arc; - use crate::error::Error; use std::time::Duration; use tracing::log::{debug, error}; @@ -12,11 +10,11 @@ use tracing::log::{debug, error}; pub struct Poller { client: reqwest::Client, campaign_id: String, - pub tokens: Arc, + tokens: Tokens, } impl Poller { - pub fn new(campaign_id: String, tokens: Arc) -> Poller { + pub fn new(campaign_id: String, tokens: Tokens) -> Poller { let client = reqwest::ClientBuilder::new() .use_rustls_tls() .timeout(Duration::from_secs(30))