Game sync and live services for independent game developers (targeting itch.io)
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 245 lines 7.2 kB view raw
1use anyhow::{anyhow, Result}; 2use chrono::{DateTime, Utc}; 3use reqwest::Client; 4use serde::{Deserialize, Serialize}; 5use sqlx::{Row, SqlitePool}; 6use std::sync::Arc; 7use tokio::sync::RwLock; 8use uuid::Uuid; 9 10use crate::config::AuthConfig; 11 12/// Itch.io OAuth device code response 13#[derive(Debug, Deserialize, Serialize)] 14pub struct DeviceCodeResponse { 15 pub device_code: String, 16 pub user_code: String, 17 pub verification_url: String, 18 pub expires_in: u64, 19 pub interval: u64, 20} 21 22/// Itch.io token response 23#[derive(Debug, Deserialize, Serialize)] 24pub struct TokenResponse { 25 pub access_token: String, 26 pub token_type: String, 27 pub expires_in: Option<u64>, 28 pub refresh_token: Option<String>, 29 pub scope: Option<String>, 30} 31 32/// Itch.io user info response 33#[derive(Debug, Deserialize, Serialize)] 34pub struct ItchUser { 35 pub id: i64, 36 pub username: String, 37 pub display_name: Option<String>, 38 pub url: String, 39} 40 41/// Device code session stored in memory 42pub struct DeviceSession { 43 pub device_code: String, 44 pub user_code: String, 45 pub expires_at: DateTime<Utc>, 46 pub interval: u64, 47 pub poll_count: u32, 48} 49 50/// OAuth state managed in memory 51pub struct OAuthState { 52 pub device_session: Option<DeviceSession>, 53 pub pending_user_id: Option<i64>, 54 pub access_token: Option<String>, 55} 56 57impl OAuthState { 58 pub fn new() -> Self { 59 Self { 60 device_session: None, 61 pending_user_id: None, 62 access_token: None, 63 } 64 } 65} 66 67/// Auth service for itch.io OAuth 68pub struct AuthService { 69 client: Client, 70 config: AuthConfig, 71 pool: SqlitePool, 72 state: Arc<RwLock<OAuthState>>, 73} 74 75impl AuthService { 76 pub fn new(config: AuthConfig, pool: SqlitePool) -> Self { 77 Self { 78 client: Client::new(), 79 config, 80 pool, 81 state: Arc::new(RwLock::new(OAuthState::new())), 82 } 83 } 84 85 /// Start the device code flow - return user code and verification URL 86 pub async fn start_device_flow(&self) -> Result<DeviceCodeResponse> { 87 let params = [ 88 ("client_id", self.config.itchio_client_id.as_str()), 89 ("scope", "profile:me"), 90 ]; 91 92 let response = self 93 .client 94 .post("https://itch.io/api-integrations/request-token") 95 .form(&params) 96 .send() 97 .await? 98 .json::<DeviceCodeResponse>() 99 .await?; 100 101 // Store the device session 102 let session = DeviceSession { 103 device_code: response.device_code.clone(), 104 user_code: response.user_code.clone(), 105 expires_at: Utc::now() + chrono::Duration::seconds(response.expires_in as i64), 106 interval: response.interval, 107 poll_count: 0, 108 }; 109 110 let mut state = self.state.write().await; 111 state.device_session = Some(session); 112 113 Ok(response) 114 } 115 116 /// Poll for token completion 117 pub async fn poll_for_token(&self) -> Result<TokenResponse> { 118 let device_code = { 119 let state = self.state.read().await; 120 state 121 .device_session 122 .as_ref() 123 .map(|s| s.device_code.clone()) 124 .ok_or_else(|| anyhow!("No device code session active"))? 125 }; 126 127 let params = [ 128 ("client_id", self.config.itchio_client_id.as_str()), 129 ("client_secret", &self.config.itchio_client_id), // itch.io uses client_id as secret in some flows 130 ("code", &device_code), 131 ("grant_type", "device_token"), 132 ]; 133 134 let response = self 135 .client 136 .post("https://itch.io/api-integrations/request-token") 137 .form(&params) 138 .send() 139 .await? 140 .json::<TokenResponse>() 141 .await?; 142 143 // Store access token 144 let mut state = self.state.write().await; 145 state.access_token = Some(response.access_token.clone()); 146 state.device_session = None; 147 148 Ok(response) 149 } 150 151 /// Get user info from itch.io 152 pub async fn get_itch_user(&self, access_token: &str) -> Result<ItchUser> { 153 let response = self 154 .client 155 .get("https://itch.io/api-integrations/me") 156 .header("Authorization", format!("Bearer {}", access_token)) 157 .send() 158 .await? 159 .json::<ItchUser>() 160 .await?; 161 162 Ok(response) 163 } 164 165 /// Exchange device code for final token and register/update user 166 pub async fn complete_device_flow(&self) -> Result<GamerRecord> { 167 let token_response = self.poll_for_token().await?; 168 let itch_user = self.get_itch_user(&token_response.access_token).await?; 169 170 // Upsert gamer in database 171 let gamer_id = self.upsert_gamer(&itch_user).await?; 172 173 Ok(GamerRecord { 174 id: gamer_id, 175 itch_user_id: itch_user.id, 176 username: itch_user.username, 177 access_token: token_response.access_token, 178 }) 179 } 180 181 /// Register or update a gamer in the database 182 async fn upsert_gamer(&self, itch_user: &ItchUser) -> Result<String> { 183 let id = Uuid::new_v4().to_string(); 184 185 sqlx::query( 186 r#" 187 INSERT INTO gamers (id, itch_user_id, username) 188 VALUES (?, ?, ?) 189 ON CONFLICT (itch_user_id) DO UPDATE SET 190 username = excluded.username, 191 updated_at = CURRENT_TIMESTAMP 192 "#, 193 ) 194 .bind(&id) 195 .bind(itch_user.id) 196 .bind(&itch_user.username) 197 .execute(&self.pool) 198 .await?; 199 200 Ok(id) 201 } 202 203 /// Verify an access token and return the gamer record 204 pub async fn verify_token(&self, access_token: &str) -> Result<Option<GamerRecord>> { 205 let itch_user = self.get_itch_user(access_token).await.ok(); 206 207 match itch_user { 208 Some(user) => { 209 let gamer = self.get_gamer_by_itch_id(user.id).await?; 210 Ok(Some(GamerRecord { 211 id: gamer.id, 212 itch_user_id: user.id, 213 username: user.username, 214 access_token: access_token.to_string(), 215 })) 216 } 217 None => Ok(None), 218 } 219 } 220 221 /// Get gamer by itch.io user ID 222 async fn get_gamer_by_itch_id(&self, itch_user_id: i64) -> Result<GamerRecord> { 223 let row = sqlx::query("SELECT id, itch_user_id, username FROM gamers WHERE itch_user_id = ?") 224 .bind(itch_user_id) 225 .fetch_optional(&self.pool) 226 .await? 227 .ok_or_else(|| anyhow!("Gamer not found"))?; 228 229 Ok(GamerRecord { 230 id: row.try_get("id")?, 231 itch_user_id: row.try_get("itch_user_id")?, 232 username: row.try_get("username").unwrap_or_default(), 233 access_token: String::new(), 234 }) 235 } 236} 237 238/// Record of a gamer in the system 239#[derive(poem_openapi::Object)] 240pub struct GamerRecord { 241 pub id: String, 242 pub itch_user_id: i64, 243 pub username: String, 244 pub access_token: String, 245}