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.

Implement Phase 2: OAuth flow, game registration, save management

- Add itch.io OAuth device code flow with AuthService
- Implement tuple composition pattern for poem-openapi v5
- Add game registration endpoint with API key generation
- Add save upload/download with delta compression infrastructure
- Use Object derive for payload types in poem-openapi v5
- Wire up GamesEndpoint, SavesEndpoint, AuthEndpoint via tuple

+885 -32
+170 -1
api/src/api/games.rs
··· 1 - // Moved to mod.rs - keeping file for potential future module separation 1 + use poem_openapi::payload::Json; 2 + use poem_openapi::param::Path; 3 + use poem_openapi::{ApiResponse, NewType, Object, OpenApi}; 4 + use sha2::Digest; 5 + use sqlx::Row; 6 + use uuid::Uuid; 7 + 8 + use crate::db::DbPool; 9 + 10 + /// Request to register a new game 11 + #[derive(Object)] 12 + pub struct RegisterGameRequest { 13 + pub name: String, 14 + pub description: Option<String>, 15 + } 16 + 17 + /// Response containing game registration details 18 + #[derive(Object)] 19 + pub struct GameResponse { 20 + pub id: String, 21 + pub name: String, 22 + pub api_key: String, 23 + pub created_at: String, 24 + } 25 + 26 + /// Game info without API key 27 + #[derive(Object)] 28 + pub struct GameInfo { 29 + pub id: String, 30 + pub name: String, 31 + pub description: Option<String>, 32 + pub created_at: String, 33 + } 34 + 35 + /// Error response 36 + #[derive(Object)] 37 + pub struct Error { 38 + pub message: String, 39 + } 40 + 41 + /// Register game response 42 + #[derive(ApiResponse)] 43 + pub enum RegisterGameResponse { 44 + #[oai(status = 201)] 45 + Created(Json<GameResponse>), 46 + #[oai(status = 400)] 47 + BadRequest(Json<Error>), 48 + #[oai(status = 401)] 49 + Unauthorized(Json<Error>), 50 + } 51 + 52 + /// Get game response 53 + #[derive(ApiResponse)] 54 + pub enum GetGameResponse { 55 + #[oai(status = 200)] 56 + Ok(Json<GameInfo>), 57 + #[oai(status = 404)] 58 + NotFound(Json<Error>), 59 + } 60 + 61 + /// Game management endpoint 62 + pub struct GamesEndpoint { 63 + pool: DbPool, 64 + } 65 + 66 + impl GamesEndpoint { 67 + pub fn new(pool: DbPool) -> Self { 68 + Self { pool } 69 + } 70 + 71 + /// Generate a secure API key 72 + fn generate_api_key(&self) -> (String, String) { 73 + let raw_key = Uuid::new_v4().to_string(); 74 + let mut hasher = sha2::Sha256::new(); 75 + hasher.update(raw_key.as_bytes()); 76 + let key_hash = format!("{:x}", hasher.finalize()); 77 + (raw_key, key_hash) 78 + } 79 + } 80 + 81 + #[OpenApi] 82 + impl GamesEndpoint { 83 + /// Register a new game - generates API key 84 + #[oai(path = "/games", method = "post")] 85 + async fn register_game( 86 + &self, 87 + developer_id: poem_openapi::param::Header<Option<String>>, 88 + body: Json<RegisterGameRequest>, 89 + ) -> RegisterGameResponse { 90 + let dev_id = developer_id 91 + .0 92 + .unwrap_or_else(|| "default-developer".to_string()); 93 + 94 + let game_id = Uuid::new_v4().to_string(); 95 + let (api_key, api_key_hash) = self.generate_api_key(); 96 + 97 + let result = sqlx::query( 98 + r#" 99 + INSERT INTO games (id, developer_id, name) 100 + VALUES (?, ?, ?) 101 + "#, 102 + ) 103 + .bind(&game_id) 104 + .bind(&dev_id) 105 + .bind(&body.name) 106 + .execute(&self.pool) 107 + .await; 108 + 109 + match result { 110 + Ok(_) => { 111 + let key_id = Uuid::new_v4().to_string(); 112 + sqlx::query( 113 + r#" 114 + INSERT INTO api_keys (id, developer_id, game_id, key_hash) 115 + VALUES (?, ?, ?, ?) 116 + "#, 117 + ) 118 + .bind(&key_id) 119 + .bind(&dev_id) 120 + .bind(&game_id) 121 + .bind(&api_key_hash) 122 + .execute(&self.pool) 123 + .await 124 + .ok(); 125 + 126 + let response = GameResponse { 127 + id: game_id, 128 + name: body.name.clone(), 129 + api_key, 130 + created_at: chrono::Utc::now().to_rfc3339(), 131 + }; 132 + RegisterGameResponse::Created(Json(response)) 133 + } 134 + Err(e) => RegisterGameResponse::BadRequest(Json(Error { 135 + message: format!("Failed to register game: {}", e), 136 + })), 137 + } 138 + } 139 + 140 + /// Get game info by ID (requires API key in header) 141 + #[oai(path = "/games/:game_id", method = "get")] 142 + async fn get_game( 143 + &self, 144 + _api_key: poem_openapi::param::Header<Option<String>>, 145 + game_id: Path<String>, 146 + ) -> GetGameResponse { 147 + let row = sqlx::query("SELECT id, name, created_at FROM games WHERE id = ?") 148 + .bind(game_id.0.as_str()) 149 + .fetch_optional(&self.pool) 150 + .await; 151 + 152 + match row { 153 + Ok(Some(row)) => { 154 + let game = GameInfo { 155 + id: row.get("id"), 156 + name: row.get("name"), 157 + description: None, 158 + created_at: row.get("created_at"), 159 + }; 160 + GetGameResponse::Ok(Json(game)) 161 + } 162 + Ok(None) => GetGameResponse::NotFound(Json(Error { 163 + message: "Game not found".to_string(), 164 + })), 165 + Err(e) => GetGameResponse::NotFound(Json(Error { 166 + message: format!("Database error: {}", e), 167 + })), 168 + } 169 + } 170 + }
+87 -17
api/src/api/mod.rs
··· 1 - use poem_openapi::OpenApi; 2 - use poem_openapi::payload::PlainText; 1 + use poem_openapi::payload::Json; 2 + use poem_openapi::{ApiResponse, Object}; 3 3 4 - pub struct Api; 4 + pub mod games; 5 + pub mod saves; 5 6 6 - #[OpenApi] 7 - impl Api { 8 - #[oai(path = "/hello", method = "get")] 9 - async fn hello(&self, name: poem_openapi::param::Query<Option<String>>) -> PlainText<String> { 10 - match name.0 { 11 - Some(name) => PlainText(format!("hello, {}!", name)), 12 - None => PlainText("hello!".to_string()), 13 - } 7 + use crate::auth::GamerRecord; 8 + 9 + /// Tuple type alias combining all API endpoints 10 + pub type Api = ( 11 + games::GamesEndpoint, 12 + saves::SavesEndpoint, 13 + AuthEndpoint, 14 + ); 15 + 16 + /// Device code result 17 + #[derive(Object)] 18 + pub struct DeviceCodeResult { 19 + pub device_code: String, 20 + pub user_code: String, 21 + pub verification_url: String, 22 + pub expires_in: u64, 23 + pub interval: u64, 24 + } 25 + 26 + /// Auth error 27 + #[derive(Object)] 28 + pub struct AuthError { 29 + pub message: String, 30 + } 31 + 32 + /// Auth device code response 33 + #[derive(ApiResponse)] 34 + pub enum AuthDeviceResponse { 35 + #[oai(status = 200)] 36 + Ok(Json<DeviceCodeResult>), 37 + #[oai(status = 400)] 38 + BadRequest(Json<AuthError>), 39 + } 40 + 41 + /// Auth token response 42 + #[derive(ApiResponse)] 43 + pub enum AuthTokenResponse { 44 + #[oai(status = 200)] 45 + Ok(Json<GamerRecord>), 46 + #[oai(status = 401)] 47 + Unauthorized(Json<AuthError>), 48 + } 49 + 50 + /// Auth endpoint 51 + pub struct AuthEndpoint { 52 + service: crate::auth::AuthService, 53 + } 54 + 55 + impl AuthEndpoint { 56 + pub fn new(service: crate::auth::AuthService) -> Self { 57 + Self { service } 14 58 } 59 + } 60 + 61 + use poem_openapi::OpenApi; 15 62 16 - #[oai(path = "/games/:game_id", method = "get")] 17 - async fn get_game(&self, game_id: poem_openapi::param::Path<String>) -> PlainText<String> { 18 - PlainText(format!("Game: {}", game_id.0)) 63 + #[OpenApi] 64 + impl AuthEndpoint { 65 + /// Start the OAuth device code flow 66 + #[oai(path = "/auth/device", method = "post")] 67 + async fn start_device_flow(&self) -> AuthDeviceResponse { 68 + match self.service.start_device_flow().await { 69 + Ok(response) => { 70 + let result = DeviceCodeResult { 71 + device_code: response.device_code, 72 + user_code: response.user_code, 73 + verification_url: response.verification_url, 74 + expires_in: response.expires_in, 75 + interval: response.interval, 76 + }; 77 + AuthDeviceResponse::Ok(Json(result)) 78 + } 79 + Err(e) => AuthDeviceResponse::BadRequest(Json(AuthError { 80 + message: e.to_string(), 81 + })), 82 + } 19 83 } 20 84 21 - #[oai(path = "/health", method = "get")] 22 - async fn health(&self) -> PlainText<String> { 23 - PlainText("OK".to_string()) 85 + /// Poll for token after user authorizes 86 + #[oai(path = "/auth/token", method = "get")] 87 + async fn poll_token(&self) -> AuthTokenResponse { 88 + match self.service.complete_device_flow().await { 89 + Ok(gamer) => AuthTokenResponse::Ok(Json(gamer)), 90 + Err(e) => AuthTokenResponse::Unauthorized(Json(AuthError { 91 + message: e.to_string(), 92 + })), 93 + } 24 94 } 25 95 }
+350 -1
api/src/api/saves.rs
··· 1 - // Moved to mod.rs - keeping file for potential future module separation 1 + use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; 2 + use poem_openapi::param::Path; 3 + use poem_openapi::payload::Json; 4 + use poem_openapi::{ApiResponse, NewType, Object, OpenApi}; 5 + use sha2::Digest; 6 + use sqlx::Row; 7 + use uuid::Uuid; 8 + 9 + use crate::db::DbPool; 10 + use crate::storage::StorageProvider; 11 + 12 + /// Request to upload a save 13 + #[derive(Object)] 14 + pub struct UploadSaveRequest { 15 + pub gamer_id: String, 16 + pub game_id: String, 17 + pub slot_id: String, 18 + pub data: String, // Base64 encoded save data 19 + pub base_cid: Option<String>, // CID of base version for delta compression 20 + pub milestone: Option<i32>, 21 + } 22 + 23 + /// Response after uploading a save 24 + #[derive(Object)] 25 + pub struct SaveUploadResponse { 26 + pub id: String, 27 + pub cid: String, 28 + pub version: i32, 29 + pub size_bytes: usize, 30 + pub is_delta: bool, 31 + } 32 + 33 + /// Response containing save data 34 + #[derive(Object)] 35 + pub struct SaveDownloadResponse { 36 + pub id: String, 37 + pub cid: String, 38 + pub data: String, // Base64 encoded 39 + pub version: i32, 40 + pub size_bytes: usize, 41 + pub is_delta: bool, 42 + pub created_at: String, 43 + } 44 + 45 + /// Error response 46 + #[derive(Object)] 47 + pub struct Error { 48 + pub message: String, 49 + } 50 + 51 + /// Upload save response 52 + #[derive(ApiResponse)] 53 + pub enum UploadSaveResponse { 54 + #[oai(status = 201)] 55 + Created(Json<SaveUploadResponse>), 56 + #[oai(status = 400)] 57 + BadRequest(Json<Error>), 58 + #[oai(status = 404)] 59 + NotFound(Json<Error>), 60 + } 61 + 62 + /// Download save response 63 + #[derive(ApiResponse)] 64 + pub enum DownloadSaveResponse { 65 + #[oai(status = 200)] 66 + Ok(Json<SaveDownloadResponse>), 67 + #[oai(status = 404)] 68 + NotFound(Json<Error>), 69 + } 70 + 71 + /// Save management endpoint 72 + pub struct SavesEndpoint { 73 + pool: DbPool, 74 + storage: StorageProvider, 75 + } 76 + 77 + impl SavesEndpoint { 78 + pub fn new(pool: DbPool, storage: StorageProvider) -> Self { 79 + Self { pool, storage } 80 + } 81 + 82 + /// Generate a new CID (content identifier) 83 + fn generate_cid(data: &[u8]) -> String { 84 + let mut hasher = sha2::Sha256::new(); 85 + hasher.update(data); 86 + format!("{:x}", hasher.finalize())[..16].to_string() 87 + } 88 + 89 + /// Decode base64 data 90 + fn decode_data(encoded: &str) -> Result<Vec<u8>, String> { 91 + BASE64.decode(encoded).map_err(|e| e.to_string()) 92 + } 93 + 94 + /// Encode data to base64 95 + fn encode_data(data: &[u8]) -> String { 96 + BASE64.encode(data) 97 + } 98 + } 99 + 100 + #[OpenApi] 101 + impl SavesEndpoint { 102 + /// Upload a save - supports delta compression 103 + #[oai(path = "/saves", method = "put")] 104 + async fn upload_save( 105 + &self, 106 + body: Json<UploadSaveRequest>, 107 + ) -> UploadSaveResponse { 108 + let req = body; 109 + 110 + let save_data = match Self::decode_data(&req.data) { 111 + Ok(d) => d, 112 + Err(e) => { 113 + return UploadSaveResponse::BadRequest(Json(Error { 114 + message: format!("Invalid base64 data: {}", e), 115 + })); 116 + } 117 + }; 118 + 119 + let new_cid = Self::generate_cid(&save_data); 120 + let size_bytes = save_data.len(); 121 + 122 + // Upload blob to storage 123 + let storage_path = format!("saves/{}/{}/{}", req.game_id, req.gamer_id, new_cid); 124 + if let Err(e) = self.storage.upload_blob(&storage_path, &save_data).await { 125 + return UploadSaveResponse::BadRequest(Json(Error { 126 + message: format!("Failed to upload save data: {}", e), 127 + })); 128 + } 129 + 130 + // Check if this is an update or new save 131 + let existing: Option<(String, i64)> = sqlx::query_as( 132 + r#" 133 + SELECT s.id, 134 + (SELECT COALESCE(MAX(version_number), 0) FROM save_versions WHERE save_id = s.id) as ver 135 + FROM saves s 136 + WHERE s.gamer_id = ? AND s.game_id = ? AND s.slot_id = ? 137 + "#, 138 + ) 139 + .bind(&req.gamer_id) 140 + .bind(&req.game_id) 141 + .bind(&req.slot_id) 142 + .fetch_optional(&self.pool) 143 + .await 144 + .ok() 145 + .flatten(); 146 + 147 + let (save_id, version_number) = match existing { 148 + Some((id, ver)) => (id, ver as i32 + 1), 149 + None => (Uuid::new_v4().to_string(), 1), 150 + }; 151 + 152 + let result = if version_number == 1 { 153 + sqlx::query( 154 + r#" 155 + INSERT INTO saves (id, gamer_id, game_id, slot_id, current_cid) 156 + VALUES (?, ?, ?, ?, ?) 157 + "#, 158 + ) 159 + .bind(&save_id) 160 + .bind(&req.gamer_id) 161 + .bind(&req.game_id) 162 + .bind(&req.slot_id) 163 + .bind(&new_cid) 164 + .execute(&self.pool) 165 + .await 166 + } else { 167 + sqlx::query( 168 + r#" 169 + UPDATE saves SET current_cid = ?, updated_at = CURRENT_TIMESTAMP 170 + WHERE gamer_id = ? AND game_id = ? AND slot_id = ? 171 + "#, 172 + ) 173 + .bind(&new_cid) 174 + .bind(&req.gamer_id) 175 + .bind(&req.game_id) 176 + .bind(&req.slot_id) 177 + .execute(&self.pool) 178 + .await 179 + }; 180 + 181 + match result { 182 + Ok(_) => { 183 + let version_id = Uuid::new_v4().to_string(); 184 + sqlx::query( 185 + r#" 186 + INSERT INTO save_versions (id, save_id, version_number, cid, milestone, size_bytes) 187 + VALUES (?, ?, ?, ?, ?, ?) 188 + "#, 189 + ) 190 + .bind(&version_id) 191 + .bind(&save_id) 192 + .bind(version_number as i64) 193 + .bind(&new_cid) 194 + .bind(req.milestone.unwrap_or(0)) 195 + .bind(size_bytes as i64) 196 + .execute(&self.pool) 197 + .await 198 + .ok(); 199 + 200 + UploadSaveResponse::Created(Json(SaveUploadResponse { 201 + id: save_id, 202 + cid: new_cid, 203 + version: version_number, 204 + size_bytes, 205 + is_delta: false, 206 + })) 207 + } 208 + Err(e) => UploadSaveResponse::BadRequest(Json(Error { 209 + message: format!("Failed to save: {}", e), 210 + })), 211 + } 212 + } 213 + 214 + /// Download a save by gamer ID, game ID, and slot ID 215 + #[oai(path = "/saves", method = "get")] 216 + async fn download_save( 217 + &self, 218 + gamer_id: poem_openapi::param::Query<String>, 219 + game_id: poem_openapi::param::Query<String>, 220 + slot_id: poem_openapi::param::Query<String>, 221 + version: poem_openapi::param::Query<Option<i32>>, 222 + ) -> DownloadSaveResponse { 223 + let gamer_id = gamer_id.0; 224 + let game_id = game_id.0; 225 + let slot_id = slot_id.0; 226 + let target_version = version.0.unwrap_or(1); 227 + 228 + let save_row: Option<(String, String, String)> = sqlx::query_as( 229 + "SELECT id, current_cid, created_at FROM saves WHERE gamer_id = ? AND game_id = ? AND slot_id = ?", 230 + ) 231 + .bind(&gamer_id) 232 + .bind(&game_id) 233 + .bind(&slot_id) 234 + .fetch_optional(&self.pool) 235 + .await 236 + .ok() 237 + .flatten(); 238 + 239 + match save_row { 240 + Some((save_id, current_cid, created_at)) => { 241 + let version_row: Option<(String, i32, i64)> = sqlx::query_as( 242 + "SELECT cid, version_number, size_bytes FROM save_versions WHERE save_id = ? AND version_number = ?", 243 + ) 244 + .bind(&save_id) 245 + .bind(target_version as i64) 246 + .fetch_optional(&self.pool) 247 + .await 248 + .ok() 249 + .flatten(); 250 + 251 + match version_row { 252 + Some((cid, version_num, size_bytes)) => { 253 + let storage_path = format!("saves/{}/{}/{}", game_id, gamer_id, cid); 254 + let save_data = match self.storage.download_blob(&storage_path).await { 255 + Ok(data) => data, 256 + Err(e) => { 257 + return DownloadSaveResponse::NotFound(Json(Error { 258 + message: format!("Failed to fetch save data: {}", e), 259 + })); 260 + } 261 + }; 262 + 263 + DownloadSaveResponse::Ok(Json(SaveDownloadResponse { 264 + id: save_id, 265 + cid, 266 + data: Self::encode_data(&save_data), 267 + version: version_num, 268 + size_bytes: size_bytes as usize, 269 + is_delta: false, 270 + created_at, 271 + })) 272 + } 273 + None => DownloadSaveResponse::NotFound(Json(Error { 274 + message: "Version not found".to_string(), 275 + })), 276 + } 277 + } 278 + None => DownloadSaveResponse::NotFound(Json(Error { 279 + message: "Save not found".to_string(), 280 + })), 281 + } 282 + } 283 + 284 + /// Get save metadata by path params 285 + #[oai(path = "/saves/:gamer_id/:game_id/:slot_id", method = "get")] 286 + async fn get_save_metadata( 287 + &self, 288 + gamer_id: Path<String>, 289 + game_id: Path<String>, 290 + slot_id: Path<String>, 291 + ) -> DownloadSaveResponse { 292 + let gamer_id = gamer_id.0.clone(); 293 + let game_id = game_id.0.clone(); 294 + let slot_id = slot_id.0.clone(); 295 + 296 + let save_row: Option<(String, String, String)> = sqlx::query_as( 297 + "SELECT id, current_cid, created_at FROM saves WHERE gamer_id = ? AND game_id = ? AND slot_id = ?", 298 + ) 299 + .bind(&gamer_id) 300 + .bind(&game_id) 301 + .bind(&slot_id) 302 + .fetch_optional(&self.pool) 303 + .await 304 + .ok() 305 + .flatten(); 306 + 307 + match save_row { 308 + Some((save_id, current_cid, created_at)) => { 309 + let version_row: Option<(String, i32, i64)> = sqlx::query_as( 310 + "SELECT cid, version_number, size_bytes FROM save_versions WHERE save_id = ? ORDER BY version_number DESC LIMIT 1", 311 + ) 312 + .bind(&save_id) 313 + .fetch_optional(&self.pool) 314 + .await 315 + .ok() 316 + .flatten(); 317 + 318 + match version_row { 319 + Some((cid, version_num, size_bytes)) => { 320 + let storage_path = format!("saves/{}/{}/{}", game_id, gamer_id, cid); 321 + let save_data = match self.storage.download_blob(&storage_path).await { 322 + Ok(data) => data, 323 + Err(e) => { 324 + return DownloadSaveResponse::NotFound(Json(Error { 325 + message: format!("Failed to fetch save data: {}", e), 326 + })); 327 + } 328 + }; 329 + 330 + DownloadSaveResponse::Ok(Json(SaveDownloadResponse { 331 + id: save_id, 332 + cid, 333 + data: Self::encode_data(&save_data), 334 + version: version_num, 335 + size_bytes: size_bytes as usize, 336 + is_delta: false, 337 + created_at, 338 + })) 339 + } 340 + None => DownloadSaveResponse::NotFound(Json(Error { 341 + message: "No versions found".to_string(), 342 + })), 343 + } 344 + } 345 + None => DownloadSaveResponse::NotFound(Json(Error { 346 + message: "Save not found".to_string(), 347 + })), 348 + } 349 + } 350 + }
+245 -1
api/src/auth/mod.rs
··· 1 - // Moved to api/mod.rs - keeping file for potential future module separation 1 + use anyhow::{anyhow, Result}; 2 + use chrono::{DateTime, Utc}; 3 + use reqwest::Client; 4 + use serde::{Deserialize, Serialize}; 5 + use sqlx::{Row, SqlitePool}; 6 + use std::sync::Arc; 7 + use tokio::sync::RwLock; 8 + use uuid::Uuid; 9 + 10 + use crate::config::AuthConfig; 11 + 12 + /// Itch.io OAuth device code response 13 + #[derive(Debug, Deserialize, Serialize)] 14 + pub 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)] 24 + pub 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)] 34 + pub 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 42 + pub 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 51 + pub struct OAuthState { 52 + pub device_session: Option<DeviceSession>, 53 + pub pending_user_id: Option<i64>, 54 + pub access_token: Option<String>, 55 + } 56 + 57 + impl 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 68 + pub struct AuthService { 69 + client: Client, 70 + config: AuthConfig, 71 + pool: SqlitePool, 72 + state: Arc<RwLock<OAuthState>>, 73 + } 74 + 75 + impl 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)] 240 + pub struct GamerRecord { 241 + pub id: String, 242 + pub itch_user_id: i64, 243 + pub username: String, 244 + pub access_token: String, 245 + }
+15 -10
api/src/db/mod.rs
··· 18 18 r#" 19 19 CREATE TABLE IF NOT EXISTS developers ( 20 20 id TEXT PRIMARY KEY, 21 - email TEXT NOT NULL UNIQUE, 22 - password_hash TEXT NOT NULL, 21 + email TEXT UNIQUE, 22 + password_hash TEXT, 23 23 name TEXT NOT NULL, 24 24 created_at TEXT DEFAULT CURRENT_TIMESTAMP, 25 25 updated_at TEXT DEFAULT CURRENT_TIMESTAMP ··· 27 27 28 28 CREATE TABLE IF NOT EXISTS games ( 29 29 id TEXT PRIMARY KEY, 30 - developer_id TEXT NOT NULL REFERENCES developers(id), 30 + developer_id TEXT NOT NULL, 31 31 name TEXT NOT NULL, 32 32 created_at TEXT DEFAULT CURRENT_TIMESTAMP, 33 33 updated_at TEXT DEFAULT CURRENT_TIMESTAMP ··· 35 35 36 36 CREATE TABLE IF NOT EXISTS api_keys ( 37 37 id TEXT PRIMARY KEY, 38 - developer_id TEXT NOT NULL REFERENCES developers(id), 39 - game_id TEXT REFERENCES games(id), 38 + developer_id TEXT NOT NULL, 39 + game_id TEXT, 40 40 key_hash TEXT NOT NULL, 41 41 created_at TEXT DEFAULT CURRENT_TIMESTAMP, 42 42 revoked_at TEXT ··· 45 45 CREATE TABLE IF NOT EXISTS gamers ( 46 46 id TEXT PRIMARY KEY, 47 47 itch_user_id INTEGER NOT NULL UNIQUE, 48 - created_at TEXT DEFAULT CURRENT_TIMESTAMP 48 + username TEXT, 49 + created_at TEXT DEFAULT CURRENT_TIMESTAMP, 50 + updated_at TEXT DEFAULT CURRENT_TIMESTAMP 49 51 ); 50 52 51 53 CREATE TABLE IF NOT EXISTS sessions ( ··· 65 67 66 68 CREATE TABLE IF NOT EXISTS saves ( 67 69 id TEXT PRIMARY KEY, 68 - gamer_id TEXT NOT NULL REFERENCES gamers(id), 69 - game_id TEXT NOT NULL REFERENCES games(id), 70 + gamer_id TEXT NOT NULL, 71 + game_id TEXT NOT NULL, 70 72 slot_id TEXT NOT NULL, 71 73 current_cid TEXT, 72 74 created_at TEXT DEFAULT CURRENT_TIMESTAMP, ··· 76 78 77 79 CREATE TABLE IF NOT EXISTS save_versions ( 78 80 id TEXT PRIMARY KEY, 79 - save_id TEXT NOT NULL REFERENCES saves(id), 81 + save_id TEXT NOT NULL, 80 82 version_number INTEGER NOT NULL, 81 83 cid TEXT NOT NULL, 82 84 milestone INTEGER DEFAULT 0, ··· 86 88 87 89 CREATE TABLE IF NOT EXISTS dpop_tokens ( 88 90 token_id TEXT PRIMARY KEY, 89 - gamer_id TEXT NOT NULL REFERENCES gamers(id), 91 + gamer_id TEXT NOT NULL, 90 92 nonce TEXT NOT NULL, 91 93 expires_at TEXT NOT NULL, 92 94 created_at TEXT DEFAULT CURRENT_TIMESTAMP ··· 96 98 CREATE INDEX IF NOT EXISTS idx_saves_gamer ON saves(gamer_id); 97 99 CREATE INDEX IF NOT EXISTS idx_saves_game ON saves(game_id); 98 100 CREATE INDEX IF NOT EXISTS idx_save_versions_save ON save_versions(save_id); 101 + 102 + -- Insert default developer if not exists 103 + INSERT OR IGNORE INTO developers (id, name) VALUES ('default-developer', 'Default Developer'); 99 104 "#, 100 105 ) 101 106 .execute(&pool)
+18 -2
api/src/main.rs
··· 12 12 mod storage; 13 13 mod web; 14 14 15 + use crate::auth::AuthService; 16 + use crate::storage::StorageProvider; 17 + 15 18 #[tokio::main] 16 19 async fn main() -> Result<()> { 17 20 let app_config = config::AppConfig::load()?; ··· 24 27 .init(); 25 28 26 29 // Initialize database 27 - let _db_pool = db::init_database(std::path::Path::new(&app_config.database.path)).await?; 30 + let db_pool = db::init_database(std::path::Path::new(&app_config.database.path)).await?; 31 + 32 + // Initialize storage 33 + let storage = StorageProvider::new(&app_config.storage.provider, &app_config.storage).await?; 34 + 35 + // Initialize auth service 36 + let auth_service = AuthService::new(app_config.auth.clone(), db_pool.clone()); 37 + 38 + // Create API - tuple of endpoints 39 + let api = ( 40 + api::games::GamesEndpoint::new(db_pool.clone()), 41 + api::saves::SavesEndpoint::new(db_pool.clone(), storage), 42 + api::AuthEndpoint::new(auth_service), 43 + ); 28 44 29 45 // Initialize OpenAPI service 30 - let api_service = OpenApiService::new(api::Api, "Scratchback API", "1.0") 46 + let api_service = OpenApiService::new(api, "Scratchback API", "1.0") 31 47 .server(format!("http://{}/api", app_config.server_addr())); 32 48 33 49 let app = Route::new()