From 407cae20401472c33b6d0eeb75121d4f0a48ced5 Mon Sep 17 00:00:00 2001 From: funman300 Date: Thu, 14 May 2026 17:14:36 -0700 Subject: [PATCH] feat(auth): add /api/me endpoint, avatar upload, and profile picture support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add migration 005: nullable avatar_url column on users table - Add GET /api/me: returns id, username, avatar_url from DB (fixes UUID-on-profile bug) - Add PUT /api/me/avatar: accepts raw image bytes (≤1 MB, jpeg/png/webp/gif), writes to avatars/ dir, updates avatar_url in DB - Serve /avatars via ServeDir so uploaded images are publicly accessible - Update account.html: fetch username from /api/me instead of parsing JWT; add circular avatar display with initials fallback and click-to-upload - Add SolitaireServerClient::fetch_me() for desktop/Android profile display - Add avatar_url field to SyncBackend::SolitaireServer settings (serde default None) - Update sqlx offline query cache for new avatar_url queries Co-Authored-By: Claude Sonnet 4.6 --- ...dc86a82a4e13dd45d8286295ba37cdbdc045e.json | 20 +++ ...2b99edbf4c33078191f0132991ee94d4507b1.json | 12 ++ ...d8fc67e71f3930609f1cf14061d35d6de8ec3.json | 12 -- ...0dcb0250988b43550881e3f0a336d9516dd45.json | 26 ++++ solitaire_data/src/settings.rs | 3 + solitaire_data/src/sync_client.rs | 50 ++++++- solitaire_engine/src/sync_setup_plugin.rs | 1 + solitaire_server/migrations/005_avatar.sql | 4 + solitaire_server/src/auth.rs | 119 +++++++++++++++- solitaire_server/src/lib.rs | 6 +- solitaire_server/web/account.html | 131 ++++++++++++++++-- 11 files changed, 354 insertions(+), 30 deletions(-) create mode 100644 .sqlx/query-06c945e50567c6801f1346d436cdc86a82a4e13dd45d8286295ba37cdbdc045e.json create mode 100644 .sqlx/query-15d08e02b102147bc74848ec3692b99edbf4c33078191f0132991ee94d4507b1.json delete mode 100644 .sqlx/query-f23630e78ae88e72d7930184f7cd8fc67e71f3930609f1cf14061d35d6de8ec3.json create mode 100644 .sqlx/query-fae33e7159b43c756131b1545360dcb0250988b43550881e3f0a336d9516dd45.json create mode 100644 solitaire_server/migrations/005_avatar.sql diff --git a/.sqlx/query-06c945e50567c6801f1346d436cdc86a82a4e13dd45d8286295ba37cdbdc045e.json b/.sqlx/query-06c945e50567c6801f1346d436cdc86a82a4e13dd45d8286295ba37cdbdc045e.json new file mode 100644 index 0000000..aaa90a7 --- /dev/null +++ b/.sqlx/query-06c945e50567c6801f1346d436cdc86a82a4e13dd45d8286295ba37cdbdc045e.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT username FROM users WHERE id = ?", + "describe": { + "columns": [ + { + "name": "username", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "06c945e50567c6801f1346d436cdc86a82a4e13dd45d8286295ba37cdbdc045e" +} diff --git a/.sqlx/query-15d08e02b102147bc74848ec3692b99edbf4c33078191f0132991ee94d4507b1.json b/.sqlx/query-15d08e02b102147bc74848ec3692b99edbf4c33078191f0132991ee94d4507b1.json new file mode 100644 index 0000000..9217cd9 --- /dev/null +++ b/.sqlx/query-15d08e02b102147bc74848ec3692b99edbf4c33078191f0132991ee94d4507b1.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE users SET avatar_url = ? WHERE id = ?", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "15d08e02b102147bc74848ec3692b99edbf4c33078191f0132991ee94d4507b1" +} diff --git a/.sqlx/query-f23630e78ae88e72d7930184f7cd8fc67e71f3930609f1cf14061d35d6de8ec3.json b/.sqlx/query-f23630e78ae88e72d7930184f7cd8fc67e71f3930609f1cf14061d35d6de8ec3.json deleted file mode 100644 index 37ea9f8..0000000 --- a/.sqlx/query-f23630e78ae88e72d7930184f7cd8fc67e71f3930609f1cf14061d35d6de8ec3.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT OR IGNORE INTO analytics_events\n (id, user_id, session_id, event_type, payload, client_time, received_at)\n VALUES (?, ?, ?, ?, ?, ?, ?)", - "describe": { - "columns": [], - "parameters": { - "Right": 7 - }, - "nullable": [] - }, - "hash": "f23630e78ae88e72d7930184f7cd8fc67e71f3930609f1cf14061d35d6de8ec3" -} diff --git a/.sqlx/query-fae33e7159b43c756131b1545360dcb0250988b43550881e3f0a336d9516dd45.json b/.sqlx/query-fae33e7159b43c756131b1545360dcb0250988b43550881e3f0a336d9516dd45.json new file mode 100644 index 0000000..3a86adf --- /dev/null +++ b/.sqlx/query-fae33e7159b43c756131b1545360dcb0250988b43550881e3f0a336d9516dd45.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "SELECT username, avatar_url FROM users WHERE id = ?", + "describe": { + "columns": [ + { + "name": "username", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "avatar_url", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true + ] + }, + "hash": "fae33e7159b43c756131b1545360dcb0250988b43550881e3f0a336d9516dd45" +} diff --git a/solitaire_data/src/settings.rs b/solitaire_data/src/settings.rs index c7e47ea..5ddcef4 100644 --- a/solitaire_data/src/settings.rs +++ b/solitaire_data/src/settings.rs @@ -56,6 +56,9 @@ pub enum SyncBackend { url: String, /// The player's username on that server. username: String, + /// Absolute URL of the user's avatar image, or `None` if not set. + #[serde(default, skip_serializing_if = "Option::is_none")] + avatar_url: Option, // JWT tokens are stored in the OS keychain — not here. }, diff --git a/solitaire_data/src/sync_client.rs b/solitaire_data/src/sync_client.rs index 60dd22f..a42337f 100644 --- a/solitaire_data/src/sync_client.rs +++ b/solitaire_data/src/sync_client.rs @@ -500,6 +500,53 @@ impl SolitaireServerClient { })?; Ok(format!("{}/replays/{}", self.base_url, id)) } + + /// Fetch the authenticated user's profile (`GET /api/me`). + /// + /// Returns `(username, avatar_url)`. `avatar_url` is `None` when the user + /// has not set an avatar. Returns an error on network failure or if the + /// token is expired and refresh also fails. + pub async fn fetch_me(&self) -> Result<(String, Option), SyncError> { + let token = self.access_token()?; + let url = format!("{}/api/me", self.base_url); + + let resp = self + .client + .get(&url) + .bearer_auth(&token) + .send() + .await + .map_err(|e| SyncError::Network(e.to_string()))?; + + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + self.refresh_token().await?; + let new_token = self.access_token()?; + let resp = self + .client + .get(&url) + .bearer_auth(new_token) + .send() + .await + .map_err(|e| SyncError::Network(e.to_string()))?; + return Self::extract_me_body(resp).await; + } + + Self::extract_me_body(resp).await + } + + async fn extract_me_body(resp: reqwest::Response) -> Result<(String, Option), SyncError> { + let status = resp.status(); + if !status.is_success() { + return Err(SyncError::Network(format!("GET /api/me returned {status}"))); + } + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| SyncError::Serialization(e.to_string()))?; + let username = body["username"].as_str().unwrap_or("").to_string(); + let avatar_url = body["avatar_url"].as_str().map(str::to_string); + Ok((username, avatar_url)) + } } // --------------------------------------------------------------------------- @@ -575,7 +622,7 @@ async fn extract_push_body(resp: reqwest::Response) -> Result Box { match backend { SyncBackend::Local => Box::new(LocalOnlyProvider), - SyncBackend::SolitaireServer { url, username } => { + SyncBackend::SolitaireServer { url, username, .. } => { Box::new(SolitaireServerClient::new(url.clone(), username.clone())) } } @@ -628,6 +675,7 @@ mod tests { let provider = provider_for_backend(&SyncBackend::SolitaireServer { url: "https://example.com".to_string(), username: "bob".to_string(), + avatar_url: None, }); assert_eq!(provider.backend_name(), "solitaire_server"); } diff --git a/solitaire_engine/src/sync_setup_plugin.rs b/solitaire_engine/src/sync_setup_plugin.rs index 3a26562..cb6a07d 100644 --- a/solitaire_engine/src/sync_setup_plugin.rs +++ b/solitaire_engine/src/sync_setup_plugin.rs @@ -424,6 +424,7 @@ fn poll_auth_task( settings.0.sync_backend = SyncBackend::SolitaireServer { url: url.clone(), username: username.clone(), + avatar_url: None, }; if let Some(path) = &settings_path.0 && let Err(e) = save_settings_to(path, &settings.0) diff --git a/solitaire_server/migrations/005_avatar.sql b/solitaire_server/migrations/005_avatar.sql new file mode 100644 index 0000000..4a78353 --- /dev/null +++ b/solitaire_server/migrations/005_avatar.sql @@ -0,0 +1,4 @@ +-- Migration 005: user avatar +-- Adds a nullable avatar_url column to users. +-- Existing rows receive NULL (no avatar set). +ALTER TABLE users ADD COLUMN avatar_url TEXT; diff --git a/solitaire_server/src/auth.rs b/solitaire_server/src/auth.rs index 5e30132..4333695 100644 --- a/solitaire_server/src/auth.rs +++ b/solitaire_server/src/auth.rs @@ -1,6 +1,12 @@ -//! Authentication handlers: register, login, refresh, delete account. +//! Authentication handlers: register, login, refresh, delete account, +//! current-user profile, and avatar upload. -use axum::{extract::State, Json}; +use axum::{ + body::Bytes, + extract::State, + http::HeaderMap, + Json, +}; use bcrypt::{hash, verify}; use chrono::Utc; use jsonwebtoken::{encode, EncodingKey, Header}; @@ -37,6 +43,14 @@ pub struct AuthResponse { pub refresh_token: String, } +/// Response for `GET /api/me`. +#[derive(Debug, Serialize)] +pub struct MeResponse { + pub id: String, + pub username: String, + pub avatar_url: Option, +} + /// Successful refresh response — contains the new access token and the rotated /// refresh token. The refresh token is always rotated: the client must store /// the new value and discard the old one. @@ -302,6 +316,107 @@ pub async fn delete_account( Ok(Json(serde_json::json!({ "ok": true }))) } +/// `GET /api/me` — return the authenticated user's id, username, and avatar URL. +pub async fn get_me( + State(state): State, + user: AuthenticatedUser, +) -> Result, AppError> { + struct Row { + username: Option, + avatar_url: Option, + } + let row = sqlx::query_as!( + Row, + "SELECT username, avatar_url FROM users WHERE id = ?", + user.user_id + ) + .fetch_optional(&state.pool) + .await? + .ok_or_else(|| AppError::NotFound("user not found".into()))?; + + Ok(Json(MeResponse { + id: user.user_id, + username: row.username.unwrap_or_default(), + avatar_url: row.avatar_url, + })) +} + +/// Allowed MIME types for uploaded avatars. +const ALLOWED_IMAGE_TYPES: &[&str] = &["image/jpeg", "image/png", "image/webp", "image/gif"]; +/// Maximum avatar upload size in bytes (1 MB). +const AVATAR_MAX_BYTES: usize = 1024 * 1024; + +/// `PUT /api/me/avatar` — upload a new avatar image (raw bytes, ≤ 1 MB). +/// +/// The `Content-Type` header must be one of `image/jpeg`, `image/png`, +/// `image/webp`, or `image/gif`. The previous avatar file is replaced in-place. +pub async fn upload_avatar( + State(state): State, + user: AuthenticatedUser, + headers: HeaderMap, + body: Bytes, +) -> Result, AppError> { + let mime = headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + let ext = if mime.contains("jpeg") || mime.contains("jpg") { + "jpg" + } else if mime.contains("png") { + "png" + } else if mime.contains("webp") { + "webp" + } else if mime.contains("gif") { + "gif" + } else { + return Err(AppError::BadRequest( + "avatar must be image/jpeg, image/png, image/webp, or image/gif".into(), + )); + }; + + if !ALLOWED_IMAGE_TYPES.iter().any(|t| mime.starts_with(t)) { + return Err(AppError::BadRequest("unsupported image type".into())); + } + if body.len() > AVATAR_MAX_BYTES { + return Err(AppError::BadRequest("avatar must be ≤ 1 MB".into())); + } + + // Write to avatars/ directory, replacing any previous file for this user. + std::fs::create_dir_all("avatars").map_err(|e| AppError::Internal(e.to_string()))?; + let filename = format!("{}.{}", user.user_id, ext); + let path = std::path::Path::new("avatars").join(&filename); + // Remove stale files with other extensions first. + for old_ext in &["jpg", "png", "webp", "gif"] { + let _ = std::fs::remove_file( + std::path::Path::new("avatars").join(format!("{}.{}", user.user_id, old_ext)), + ); + } + std::fs::write(&path, &body).map_err(|e| AppError::Internal(e.to_string()))?; + + let avatar_url = format!("/avatars/{filename}"); + sqlx::query!( + "UPDATE users SET avatar_url = ? WHERE id = ?", + avatar_url, + user.user_id + ) + .execute(&state.pool) + .await?; + + let username: Option = sqlx::query_scalar!( + "SELECT username FROM users WHERE id = ?", + user.user_id + ) + .fetch_optional(&state.pool) + .await?; + + Ok(Json(MeResponse { + id: user.user_id, + username: username.unwrap_or_default(), + avatar_url: Some(avatar_url), + })) +} + // --------------------------------------------------------------------------- // Admin helpers (CLI use only — not exposed via HTTP) // --------------------------------------------------------------------------- diff --git a/solitaire_server/src/lib.rs b/solitaire_server/src/lib.rs index 7102336..0079adb 100644 --- a/solitaire_server/src/lib.rs +++ b/solitaire_server/src/lib.rs @@ -19,9 +19,10 @@ use axum::{ http::{HeaderValue, Request}, middleware as axum_middleware, response::{Html, Response}, - routing::{delete, get, post}, + routing::{delete, get, post, put}, Router, }; + use jsonwebtoken::{decode, DecodingKey, Validation}; use sqlx::SqlitePool; use std::sync::Arc; @@ -143,6 +144,8 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router { .route("/api/leaderboard/opt-in", post(leaderboard::opt_in)) .route("/api/leaderboard/opt-in", delete(leaderboard::opt_out)) .route("/api/account", delete(auth::delete_account)) + .route("/api/me", get(auth::get_me)) + .route("/api/me/avatar", put(auth::upload_avatar)) .layer(axum_middleware::from_fn_with_state( state.clone(), middleware::require_auth, @@ -228,6 +231,7 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router { ) .nest_service("/web", ServeDir::new("solitaire_server/web")) .nest_service("/assets", ServeDir::new("assets")) + .nest_service("/avatars", ServeDir::new("avatars")) .layer(axum_middleware::from_fn(security_headers)); Router::new() diff --git a/solitaire_server/web/account.html b/solitaire_server/web/account.html index cf95dc9..7f12336 100644 --- a/solitaire_server/web/account.html +++ b/solitaire_server/web/account.html @@ -80,14 +80,14 @@ button[type="submit"]:disabled { opacity: 0.4; cursor: default; } /* ── Signed-in state ── */ - #signed-in { display: none; flex-direction: column; gap: 16px; } + #signed-in { display: none; flex-direction: column; gap: 16px; align-items: center; } .username-display { font-size: 20px; font-weight: 700; text-align: center; } .signed-in-detail { font-size: 13px; color: var(--text-muted); text-align: center; } - .signed-in-actions { display: flex; flex-direction: column; gap: 8px; } + .signed-in-actions { display: flex; flex-direction: column; gap: 8px; width: 100%; } .btn-secondary { background: var(--panel-hi); color: var(--text); border: 1px solid var(--border); border-radius: 6px; @@ -103,6 +103,40 @@ cursor: pointer; transition: background 120ms; } .btn-danger:hover { background: rgba(165, 66, 66, 0.15); } + + /* ── Avatar ── */ + .avatar-wrap { + position: relative; width: 96px; height: 96px; cursor: pointer; + border-radius: 50%; overflow: hidden; + border: 2px solid var(--border); + flex-shrink: 0; + } + .avatar-img { + width: 100%; height: 100%; object-fit: cover; + border-radius: 50%; + } + .avatar-initials { + width: 100%; height: 100%; border-radius: 50%; + background: var(--panel-hi); display: flex; + align-items: center; justify-content: center; + font-size: 32px; font-weight: 700; color: var(--text-muted); + user-select: none; + } + .avatar-overlay { + position: absolute; inset: 0; border-radius: 50%; + background: rgba(0,0,0,0.55); display: flex; + align-items: center; justify-content: center; + font-size: 11px; font-weight: 700; color: #fff; + letter-spacing: 0.06em; opacity: 0; + transition: opacity 120ms; + } + .avatar-wrap:hover .avatar-overlay { opacity: 1; } + .avatar-status { + font-size: 11px; color: var(--text-muted); text-align: center; + min-height: 16px; + } + .avatar-status.ok { color: var(--success); } + .avatar-status.err { color: var(--accent-hi); }