diff --git a/Cargo.lock b/Cargo.lock index 9af2edcf..72603407 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2684,6 +2684,7 @@ dependencies = [ "rustfft", "serde", "serde_json", + "sha2 0.10.9", "symphonia", "testcontainers", "tokio", @@ -2710,6 +2711,7 @@ dependencies = [ "dartboard-editor", "dartboard-local", "dartboard-tui", + "deadpool-postgres", "emojis", "futures-util", "getrandom 0.4.2", diff --git a/Cargo.toml b/Cargo.toml index 9906676e..93916a20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,7 @@ pulldown-cmark = { version = "0.13", default-features = false, features = ["html qrcodegen = "1.8.0" rand = "0.8" rand_core = "0.6" +sha2 = "0.10" ratatui = "0.30" ringbuf = "0.4.8" rstest = "0.26.1" diff --git a/late-core/Cargo.toml b/late-core/Cargo.toml index db78d7e5..d37298ef 100644 --- a/late-core/Cargo.toml +++ b/late-core/Cargo.toml @@ -38,6 +38,7 @@ rustfft.workspace = true symphonia = { workspace = true, features = ["mp3"] } image.workspace = true axum.workspace = true +sha2.workspace = true # Telemetry (optional, heavy deps — gated behind "otel" feature) opentelemetry = { workspace = true, optional = true } diff --git a/late-core/migrations/045_create_native_tokens.sql b/late-core/migrations/045_create_native_tokens.sql new file mode 100644 index 00000000..14f13ccc --- /dev/null +++ b/late-core/migrations/045_create_native_tokens.sql @@ -0,0 +1,9 @@ +CREATE TABLE native_tokens ( + token TEXT NOT NULL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX native_tokens_user_id_idx ON native_tokens (user_id); +CREATE INDEX native_tokens_expires_at_idx ON native_tokens (expires_at); diff --git a/late-core/migrations/046_native_token_metadata.sql b/late-core/migrations/046_native_token_metadata.sql new file mode 100644 index 00000000..4d111f93 --- /dev/null +++ b/late-core/migrations/046_native_token_metadata.sql @@ -0,0 +1,8 @@ +-- Clear pre-hash tokens; stored values are raw tokens and cannot authenticate +-- under the new SHA-256 scheme. +TRUNCATE native_tokens; + +ALTER TABLE native_tokens + ADD COLUMN last_used_at TIMESTAMPTZ, + ADD COLUMN user_agent TEXT, + ADD COLUMN created_ip TEXT; diff --git a/late-core/src/models/mod.rs b/late-core/src/models/mod.rs index 8f492457..97d27613 100644 --- a/late-core/src/models/mod.rs +++ b/late-core/src/models/mod.rs @@ -14,6 +14,7 @@ pub mod leaderboard; pub mod mention_feed_read; pub mod minesweeper; pub mod moderation_audit_log; +pub mod native_token; pub mod nonogram; pub mod notification; pub mod profile; diff --git a/late-core/src/models/native_token.rs b/late-core/src/models/native_token.rs new file mode 100644 index 00000000..49c16a84 --- /dev/null +++ b/late-core/src/models/native_token.rs @@ -0,0 +1,99 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use sha2::{Digest, Sha256}; +use std::fmt::Write as _; +use tokio_postgres::{Client, Row}; +use uuid::Uuid; + +pub struct NativeToken { + /// SHA-256 hex hash of the raw bearer token. Raw token is never stored. + pub token_hash: String, + pub user_id: Uuid, + pub created_at: DateTime, + pub expires_at: DateTime, + pub last_used_at: Option>, + pub user_agent: Option, + pub created_ip: Option, +} + +impl From for NativeToken { + fn from(row: Row) -> Self { + Self { + token_hash: row.get("token"), + user_id: row.get("user_id"), + created_at: row.get("created_at"), + expires_at: row.get("expires_at"), + last_used_at: row.get("last_used_at"), + user_agent: row.get("user_agent"), + created_ip: row.get("created_ip"), + } + } +} + +fn hash_token(raw: &str) -> String { + let hash = Sha256::digest(raw.as_bytes()); + hash.iter().fold(String::with_capacity(64), |mut s, b| { + write!(s, "{b:02x}").unwrap(); + s + }) +} + +impl NativeToken { + pub async fn create( + client: &Client, + raw_token: &str, + user_id: Uuid, + expires_at: DateTime, + user_agent: Option<&str>, + created_ip: Option<&str>, + ) -> Result { + let token_hash = hash_token(raw_token); + let row = client + .query_one( + "INSERT INTO native_tokens (token, user_id, expires_at, user_agent, created_ip) + VALUES ($1, $2, $3, $4, $5) + RETURNING *", + &[&token_hash, &user_id, &expires_at, &user_agent, &created_ip], + ) + .await?; + Ok(Self::from(row)) + } + + /// Returns `(user_id, username)` if the token exists and has not expired. + /// Also updates `last_used_at` atomically. + pub async fn find_user_by_token( + client: &Client, + raw_token: &str, + ) -> Result> { + let token_hash = hash_token(raw_token); + let row = client + .query_opt( + "WITH updated AS ( + UPDATE native_tokens SET last_used_at = NOW() + WHERE token = $1 AND expires_at > NOW() + RETURNING user_id + ) + SELECT u.id, u.username + FROM updated + JOIN users u ON u.id = updated.user_id", + &[&token_hash], + ) + .await?; + Ok(row.map(|r| (r.get("id"), r.get("username")))) + } + + pub async fn delete(client: &Client, raw_token: &str) -> Result<()> { + let token_hash = hash_token(raw_token); + client + .execute("DELETE FROM native_tokens WHERE token = $1", &[&token_hash]) + .await?; + Ok(()) + } + + pub async fn purge_expired(client: &Client) -> Result { + let n = client + .execute("DELETE FROM native_tokens WHERE expires_at <= NOW()", &[]) + .await?; + Ok(n) + } +} diff --git a/late-ssh/Cargo.toml b/late-ssh/Cargo.toml index 840e6235..dc454223 100644 --- a/late-ssh/Cargo.toml +++ b/late-ssh/Cargo.toml @@ -29,6 +29,7 @@ futures-util = { workspace = true } tikv-jemallocator = "0.6" # Database +deadpool-postgres = { workspace = true } tokio-postgres = { workspace = true } crossterm.workspace = true dartboard-core.workspace = true diff --git a/late-ssh/src/api.rs b/late-ssh/src/api.rs index fa8fcf0d..8ea4312d 100644 --- a/late-ssh/src/api.rs +++ b/late-ssh/src/api.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; use axum::{ Json, Router, extract::{ - ConnectInfo, Query, State as AxumState, WebSocketUpgrade, + ConnectInfo, DefaultBodyLimit, Query, State as AxumState, WebSocketUpgrade, ws::{Message, WebSocket}, }, http::StatusCode, @@ -91,8 +91,10 @@ pub async fn run_api_server_with_listener( .route("/api/ws/pair", get(ws_handler)) .route("/api/ws/tunnel", get(crate::web_tunnel::ws_handler)) .route("/api/ws/chat", get(crate::web::ws_chat_handler)) + .merge(crate::native_api::router()) .layer(cors) .layer(middleware::from_fn(http_telemetry_middleware)) + .layer(DefaultBodyLimit::max(64 * 1024)) .with_state(state); let shutdown = shutdown.unwrap_or_default(); @@ -333,7 +335,7 @@ fn token_hint(token: &str) -> String { format!("{prefix}..({})", token.len()) } -fn effective_client_ip(headers: &HeaderMap, peer_addr: SocketAddr, state: &State) -> IpAddr { +pub(crate) fn effective_client_ip(headers: &HeaderMap, peer_addr: SocketAddr, state: &State) -> IpAddr { if is_trusted_proxy_peer(peer_addr.ip(), &state.config.ssh_proxy_trusted_cidrs) && let Some(ip) = forwarded_for_ip(headers) { diff --git a/late-ssh/src/lib.rs b/late-ssh/src/lib.rs index 96da4c04..73d63711 100644 --- a/late-ssh/src/lib.rs +++ b/late-ssh/src/lib.rs @@ -5,6 +5,7 @@ pub mod config; pub mod dartboard; pub mod metrics; pub mod moderation; +pub mod native_api; pub mod session; pub mod session_bootstrap; pub mod ssh; diff --git a/late-ssh/src/main.rs b/late-ssh/src/main.rs index e672f88e..43bb3c77 100644 --- a/late-ssh/src/main.rs +++ b/late-ssh/src/main.rs @@ -9,7 +9,11 @@ static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; use anyhow::Context; use late_core::{ - api_types::NowPlaying, db::Db, icecast, models::chat_room::ChatRoom, rate_limit::IpRateLimiter, + api_types::NowPlaying, + db::Db, + icecast, + models::{chat_room::ChatRoom, native_token::NativeToken}, + rate_limit::IpRateLimiter, shutdown::CancellationToken, }; use late_ssh::{ @@ -271,6 +275,11 @@ async fn main() -> anyhow::Result<()> { web_chat_registry, ssh_attempt_limiter, ws_pair_limiter, + native_challenges: late_ssh::state::NativeChallengeStore::new(), + native_ws_tickets: late_ssh::state::NativeWsTicketStore::new(), + native_challenge_limiter: IpRateLimiter::new(20, 60), + native_token_limiter: IpRateLimiter::new(10, 60), + native_ws_limiter: IpRateLimiter::new(10, 60), is_draining: Arc::new(std::sync::atomic::AtomicBool::new(false)), }; @@ -292,6 +301,21 @@ async fn main() -> anyhow::Result<()> { Ok(()) }); + let purge_db = state.db.clone(); + tasks.spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60 * 60)); + loop { + interval.tick().await; + if let Ok(client) = purge_db.get().await { + match NativeToken::purge_expired(&client).await { + Ok(n) if n > 0 => tracing::info!(n, "purged expired native tokens"), + Err(e) => tracing::warn!("native token purge failed: {e}"), + _ => {} + } + } + } + }); + let ssh_shutdown = accept_shutdown.clone(); let ssh_state = state.clone(); let mut ssh_task = tokio::spawn(async move { diff --git a/late-ssh/src/native_api/artboard.rs b/late-ssh/src/native_api/artboard.rs new file mode 100644 index 00000000..0fb4765d --- /dev/null +++ b/late-ssh/src/native_api/artboard.rs @@ -0,0 +1,45 @@ +use axum::{ + Json, Router, + extract::State as AxumState, + http::StatusCode, + routing::{get, post}, +}; +use dartboard_core::CanvasOp; +use rand_core::{OsRng, RngCore}; +use serde::Serialize; + +use crate::state::State; + +use super::{ApiError, NativeAuthUser}; + +pub fn router() -> Router { + Router::new() + .route("/api/native/artboard", get(get_artboard)) + .route("/api/native/artboard/ops", post(post_artboard_op)) +} + +#[derive(Serialize)] +struct ArtboardResponse { + canvas: serde_json::Value, +} + +async fn get_artboard( + _auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result, ApiError> { + let canvas = state.dartboard_server.canvas_snapshot(); + let canvas_json = + serde_json::to_value(&canvas).map_err(|_| ApiError::Db)?; + Ok(Json(ArtboardResponse { canvas: canvas_json })) +} + +async fn post_artboard_op( + auth: NativeAuthUser, + AxumState(state): AxumState, + Json(op): Json, +) -> Result { + let user_id_u64 = auth.user_id.as_u64_pair().1; + let client_op_id: u64 = OsRng.next_u64(); + state.dartboard_server.submit_op_for(user_id_u64, client_op_id, op); + Ok(StatusCode::ACCEPTED) +} diff --git a/late-ssh/src/native_api/articles.rs b/late-ssh/src/native_api/articles.rs new file mode 100644 index 00000000..9a0f30cc --- /dev/null +++ b/late-ssh/src/native_api/articles.rs @@ -0,0 +1,53 @@ +use axum::{ + Json, Router, + extract::{Query, State as AxumState}, + routing::get, +}; +use late_core::models::article::Article; +use serde::{Deserialize, Serialize}; + +use crate::state::State; + +use super::{ApiError, NativeAuthUser}; + +pub fn router() -> Router { + Router::new().route("/api/native/articles", get(get_articles)) +} + +#[derive(Deserialize)] +struct ArticlesParams { + limit: Option, +} + +#[derive(Serialize)] +struct ArticleItem { + id: String, + url: String, + title: String, + summary: String, + ascii_art: String, + created: String, +} + +async fn get_articles( + _auth: NativeAuthUser, + Query(params): Query, + AxumState(state): AxumState, +) -> Result>, ApiError> { + let limit = params.limit.unwrap_or(20).clamp(1, 100); + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let articles = Article::list_recent(&client, limit).await.map_err(|_| ApiError::Db)?; + Ok(Json( + articles + .into_iter() + .map(|a| ArticleItem { + id: a.id.to_string(), + url: a.url, + title: a.title, + summary: a.summary, + ascii_art: a.ascii_art, + created: a.created.to_rfc3339(), + }) + .collect(), + )) +} diff --git a/late-ssh/src/native_api/auth.rs b/late-ssh/src/native_api/auth.rs new file mode 100644 index 00000000..a26a1de7 --- /dev/null +++ b/late-ssh/src/native_api/auth.rs @@ -0,0 +1,175 @@ +use axum::{ + Json, Router, + extract::{ConnectInfo, State as AxumState}, + http::{HeaderMap, StatusCode}, + response::IntoResponse, + routing::{delete, get, post}, +}; +use chrono::{Duration, Utc}; +use late_core::models::{native_token::NativeToken, user::User}; +use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; + +use super::{ApiError, NativeAuthUser}; +use crate::state::State; + +const TOKEN_DAYS: i64 = 30; + +pub fn router() -> Router { + Router::new() + .route("/api/native/challenge", get(get_challenge)) + .route("/api/native/token", post(post_token)) + .route("/api/native/logout", delete(delete_token)) + .route("/api/native/ws-ticket", get(get_ws_ticket)) +} + +// ── Challenge ───────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct ChallengeResponse { + nonce: String, + expires_in: u32, +} + +async fn get_challenge( + ConnectInfo(peer_addr): ConnectInfo, + headers: HeaderMap, + AxumState(state): AxumState, +) -> impl IntoResponse { + let client_ip = crate::api::effective_client_ip(&headers, peer_addr, &state); + if !state.native_challenge_limiter.allow(client_ip) { + return ApiError::TooManyRequests.into_response(); + } + let nonce = crate::session::new_session_token(); + state.native_challenges.issue(nonce.clone()); + Json(ChallengeResponse { nonce, expires_in: 60 }).into_response() +} + +// ── Token ───────────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct TokenRequest { + /// SHA-256 fingerprint in `SHA256:xxxx` format (e.g. from `ssh-keygen -lf`). + public_key_fingerprint: String, + /// OpenSSH public key string, e.g. `"ssh-ed25519 AAAA... comment"`. + public_key: String, + /// Nonce from `GET /api/native/challenge`. + nonce: String, + /// Full PEM text of the SSH signature produced by `ssh-keygen -Y sign -n late.sh`. + signature_pem: String, +} + +#[derive(Serialize)] +struct TokenResponse { + token: String, + expires_at: String, +} + +async fn post_token( + ConnectInfo(peer_addr): ConnectInfo, + headers: HeaderMap, + AxumState(state): AxumState, + Json(body): Json, +) -> Result, ApiError> { + let client_ip = crate::api::effective_client_ip(&headers, peer_addr, &state); + if !state.native_token_limiter.allow(client_ip) { + return Err(ApiError::TooManyRequests); + } + + if !state.native_challenges.consume(&body.nonce) { + return Err(ApiError::Unauthorized("nonce invalid or expired")); + } + + verify_ssh_sig(&body.public_key, &body.public_key_fingerprint, &body.nonce, &body.signature_pem) + .map_err(|_| ApiError::Unauthorized("signature verification failed"))?; + + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + + let user = User::find_by_fingerprint(&client, &body.public_key_fingerprint) + .await + .map_err(|_| ApiError::Db)? + .ok_or(ApiError::Unauthorized("no user with that fingerprint"))?; + + let raw_token = crate::session::new_session_token(); + let expires_at = Utc::now() + Duration::days(TOKEN_DAYS); + let user_agent = headers + .get(axum::http::header::USER_AGENT) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let created_ip = client_ip.to_string(); + + NativeToken::create( + &client, + &raw_token, + user.id, + expires_at, + user_agent.as_deref(), + Some(&created_ip), + ) + .await + .map_err(|_| ApiError::Db)?; + + Ok(Json(TokenResponse { token: raw_token, expires_at: expires_at.to_rfc3339() })) +} + +// ── Logout ──────────────────────────────────────────────────────────────────── + +async fn delete_token( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + NativeToken::delete(&client, &auth.raw_token).await.map_err(|_| ApiError::Db)?; + Ok(StatusCode::NO_CONTENT) +} + +// ── WS ticket ───────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct WsTicketResponse { + ticket: String, + expires_in: u32, +} + +async fn get_ws_ticket( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Json { + let ticket = state.native_ws_tickets.mint(auth.user_id, auth.username); + Json(WsTicketResponse { ticket, expires_in: 30 }) +} + +// ── SSH sig verification ────────────────────────────────────────────────────── + +/// Verify an SSH signature produced by `ssh-keygen -Y sign -n late.sh`. +/// +/// Checks: +/// 1. Public key parses and its SHA-256 fingerprint matches `expected_fingerprint`. +/// 2. The PEM signature is valid over `nonce` bytes with namespace `"late.sh"`. +fn verify_ssh_sig( + public_key_openssh: &str, + expected_fingerprint: &str, + nonce: &str, + signature_pem: &str, +) -> anyhow::Result<()> { + use russh::keys::{ + PublicKey, + ssh_key::{HashAlg, SshSig}, + }; + + let pk = PublicKey::from_openssh(public_key_openssh) + .map_err(|e| anyhow::anyhow!("invalid public key: {e}"))?; + + let computed_fp = pk.fingerprint(HashAlg::Sha256).to_string(); + if computed_fp != expected_fingerprint { + anyhow::bail!("fingerprint mismatch: expected {expected_fingerprint}, got {computed_fp}"); + } + + let sig = SshSig::from_pem(signature_pem) + .map_err(|e| anyhow::anyhow!("invalid SSH signature: {e}"))?; + + pk.verify("late.sh", nonce.as_bytes(), &sig) + .map_err(|e| anyhow::anyhow!("signature verification failed: {e}"))?; + + Ok(()) +} diff --git a/late-ssh/src/native_api/bonsai.rs b/late-ssh/src/native_api/bonsai.rs new file mode 100644 index 00000000..df6aaa91 --- /dev/null +++ b/late-ssh/src/native_api/bonsai.rs @@ -0,0 +1,65 @@ +use axum::{ + Json, Router, + extract::State as AxumState, + routing::{get, post}, +}; +use late_core::models::bonsai::Tree; +use rand_core::{OsRng, RngCore}; +use serde::Serialize; + +use crate::app::bonsai::{state::stage_for, ui::tree_ascii}; +use crate::state::State; + +use super::{ApiError, NativeAuthUser}; + +pub fn router() -> Router { + Router::new() + .route("/api/native/bonsai", get(get_bonsai)) + .route("/api/native/bonsai/water", post(post_bonsai_water)) +} + +#[derive(Serialize)] +struct BonsaiResponse { + growth_points: i32, + is_alive: bool, + last_watered: Option, + art: Vec, +} + +async fn get_bonsai( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result, ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let tree = Tree::ensure(&client, auth.user_id, rand_seed()) + .await + .map_err(|_| ApiError::Db)?; + Ok(Json(build_response(tree))) +} + +async fn post_bonsai_water( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result, ApiError> { + state.bonsai_service.water_task(auth.user_id, false); + + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let tree = Tree::ensure(&client, auth.user_id, rand_seed()) + .await + .map_err(|_| ApiError::Db)?; + Ok(Json(build_response(tree))) +} + +fn build_response(tree: Tree) -> BonsaiResponse { + let art = tree_ascii(stage_for(tree.is_alive, tree.growth_points), tree.seed, false); + BonsaiResponse { + growth_points: tree.growth_points, + is_alive: tree.is_alive, + last_watered: tree.last_watered.map(|d| d.to_string()), + art, + } +} + +fn rand_seed() -> i64 { + OsRng.next_u64() as i64 +} diff --git a/late-ssh/src/native_api/chat.rs b/late-ssh/src/native_api/chat.rs new file mode 100644 index 00000000..256ff188 --- /dev/null +++ b/late-ssh/src/native_api/chat.rs @@ -0,0 +1,413 @@ +use axum::{ + Json, Router, + extract::{Path, Query, State as AxumState}, + http::StatusCode, + routing::{get, post}, +}; +use late_core::models::{ + chat_message::ChatMessage, + chat_message_reaction::ChatMessageReaction, + chat_room::ChatRoom, + chat_room_member::ChatRoomMember, + user::User, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::{ApiError, NativeAuthUser}; +use crate::state::State; + +pub fn router() -> Router { + Router::new() + .route("/api/native/rooms", get(get_rooms)) + .route("/api/native/rooms/{room}", get(get_room)) + .route("/api/native/rooms/{room}/history", get(get_room_history)) + .route("/api/native/rooms/{room}/messages", post(post_room_message)) + .route("/api/native/rooms/{room}/messages/{id}/react", post(post_message_react)) + .route("/api/native/rooms/{room}/members", get(get_room_members)) + .route("/api/native/rooms/{room}/read", post(post_room_read)) + .route("/api/native/dms", post(post_create_dm)) +} + +// ── Response types ──────────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct RoomInfo { + pub id: String, + pub name: String, + pub slug: String, + pub kind: String, + pub unread_count: i64, +} + +#[derive(Serialize)] +pub struct MessageItem { + pub id: String, + pub user_id: String, + pub username: String, + pub body: String, + pub timestamp: String, + pub reactions: Vec, +} + +#[derive(Serialize)] +pub struct ReactionItem { + pub emoji: String, + pub count: i64, +} + +#[derive(Serialize)] +struct MemberItem { + user_id: String, + username: String, +} + +// ── Handlers ────────────────────────────────────────────────────────────────── + +async fn get_rooms( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result>, ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let rooms = ChatRoom::list_for_user(&client, auth.user_id) + .await + .map_err(|_| ApiError::Db)?; + let unread_map = ChatRoomMember::unread_counts_for_user(&client, auth.user_id) + .await + .unwrap_or_default(); + + let items = rooms + .into_iter() + .map(|r| RoomInfo { + id: r.id.to_string(), + name: room_display_name(r.slug.as_deref()), + slug: r.slug.clone().unwrap_or_default(), + kind: r.kind.clone(), + unread_count: unread_map.get(&r.id).copied().unwrap_or(0), + }) + .collect(); + + Ok(Json(items)) +} + +#[derive(Deserialize)] +struct HistoryParams { + limit: Option, +} + +async fn get_room_history( + auth: NativeAuthUser, + Path(room): Path, + Query(params): Query, + AxumState(state): AxumState, +) -> Result>, ApiError> { + let limit = params.limit.unwrap_or(50).clamp(1, 200); + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let room_id = resolve_room_id(&client, &room).await?; + + let is_member = ChatRoomMember::is_member(&client, room_id, auth.user_id) + .await + .map_err(|_| ApiError::Db)?; + if !is_member { + return Err(ApiError::Forbidden("not a member of this room")); + } + + let messages = ChatMessage::list_recent(&client, room_id, limit) + .await + .map_err(|_| ApiError::Db)?; + + Ok(Json(build_message_items(&client, messages).await)) +} + +#[derive(Deserialize)] +struct SendMessageBody { + body: String, + reply_to: Option, +} + +async fn post_room_message( + auth: NativeAuthUser, + Path(room): Path, + AxumState(state): AxumState, + Json(body): Json, +) -> Result { + let trimmed = body.body.trim(); + if trimmed.is_empty() { + return Err(ApiError::BadRequest("message body is empty")); + } + if trimmed.len() > 4000 { + return Err(ApiError::BadRequest("message body exceeds 4000 characters")); + } + let reply_to = body + .reply_to + .as_deref() + .map(|s| Uuid::parse_str(s).map_err(|_| ApiError::BadRequest("invalid reply_to uuid"))) + .transpose()?; + + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let room_id = resolve_room_id(&client, &room).await?; + + // Need room slug for announcements guard in chat service + let chat_room = ChatRoom::get(&client, room_id) + .await + .map_err(|_| ApiError::Db)? + .ok_or(ApiError::NotFound("room not found"))?; + let room_slug = chat_room.slug.clone(); + drop(client); + + state.chat_service.send_message_with_reply_task( + crate::app::chat::svc::SendMessageTask { + user_id: auth.user_id, + room_id, + room_slug, + body: trimmed.to_string(), + reply_to_message_id: reply_to, + request_id: Uuid::now_v7(), + is_admin: false, + }, + ); + + Ok(StatusCode::ACCEPTED) +} + +async fn get_room_members( + auth: NativeAuthUser, + Path(room): Path, + AxumState(state): AxumState, +) -> Result>, ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let room_id = resolve_room_id(&client, &room).await?; + + let is_member = ChatRoomMember::is_member(&client, room_id, auth.user_id) + .await + .map_err(|_| ApiError::Db)?; + if !is_member { + return Err(ApiError::Forbidden("not a member of this room")); + } + + let user_ids = ChatRoomMember::list_user_ids(&client, room_id) + .await + .map_err(|_| ApiError::Db)?; + let usernames = User::list_usernames_by_ids(&client, &user_ids) + .await + .unwrap_or_default(); + + let items = user_ids + .iter() + .map(|id| MemberItem { + user_id: id.to_string(), + username: usernames.get(id).cloned().unwrap_or_default(), + }) + .collect(); + + Ok(Json(items)) +} + +async fn get_room( + auth: NativeAuthUser, + Path(room): Path, + AxumState(state): AxumState, +) -> Result, ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let room_id = resolve_room_id(&client, &room).await?; + + let chat_room = ChatRoom::get(&client, room_id) + .await + .map_err(|_| ApiError::Db)? + .ok_or(ApiError::NotFound("room not found"))?; + + let is_member = ChatRoomMember::is_member(&client, room_id, auth.user_id) + .await + .map_err(|_| ApiError::Db)?; + if !is_member { + return Err(ApiError::Forbidden("not a member of this room")); + } + + let unread = ChatRoomMember::unread_counts_for_user(&client, auth.user_id) + .await + .unwrap_or_default() + .get(&room_id) + .copied() + .unwrap_or(0); + + Ok(Json(RoomInfo { + id: chat_room.id.to_string(), + name: room_display_name(chat_room.slug.as_deref()), + slug: chat_room.slug.clone().unwrap_or_default(), + kind: chat_room.kind.clone(), + unread_count: unread, + })) +} + +#[derive(Deserialize)] +struct CreateDmBody { + username: String, +} + +#[derive(Serialize)] +struct DmResponse { + room_id: String, + slug: String, +} + +async fn post_create_dm( + auth: NativeAuthUser, + AxumState(state): AxumState, + Json(body): Json, +) -> Result, ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let target = User::find_by_username(&client, body.username.trim()) + .await + .map_err(|_| ApiError::Db)? + .ok_or(ApiError::NotFound("user not found"))?; + + if target.id == auth.user_id { + return Err(ApiError::BadRequest("cannot DM yourself")); + } + + let room = ChatRoom::get_or_create_dm(&client, auth.user_id, target.id) + .await + .map_err(|_| ApiError::Db)?; + + // Ensure both parties are room members + ChatRoomMember::join(&client, room.id, auth.user_id).await.ok(); + ChatRoomMember::join(&client, room.id, target.id).await.ok(); + + Ok(Json(DmResponse { + room_id: room.id.to_string(), + slug: room.slug.clone().unwrap_or_default(), + })) +} + +#[derive(Deserialize)] +struct ReactBody { + kind: i16, +} + +async fn post_message_react( + auth: NativeAuthUser, + Path((room, message_id)): Path<(String, String)>, + AxumState(state): AxumState, + Json(body): Json, +) -> Result { + if !(1..=8).contains(&body.kind) { + return Err(ApiError::BadRequest("reaction kind must be 1–8")); + } + let msg_id = Uuid::parse_str(&message_id) + .map_err(|_| ApiError::BadRequest("invalid message id"))?; + + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let room_id = resolve_room_id(&client, &room).await?; + + let is_member = ChatRoomMember::is_member(&client, room_id, auth.user_id) + .await + .map_err(|_| ApiError::Db)?; + if !is_member { + return Err(ApiError::Forbidden("not a member of this room")); + } + + ChatMessageReaction::toggle(&client, msg_id, auth.user_id, body.kind) + .await + .map_err(|_| ApiError::Db)?; + + Ok(StatusCode::NO_CONTENT) +} + +async fn post_room_read( + auth: NativeAuthUser, + Path(room): Path, + AxumState(state): AxumState, +) -> Result { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let room_id = resolve_room_id(&client, &room).await?; + + ChatRoomMember::mark_read_now(&client, room_id, auth.user_id) + .await + .map_err(|_| ApiError::Db)?; + + Ok(StatusCode::NO_CONTENT) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Resolve a room path segment to a room UUID. +/// Accepts: UUID, "general", or any room slug. +pub(crate) async fn resolve_room_id( + client: &deadpool_postgres::Client, + room: &str, +) -> Result { + if let Ok(id) = Uuid::parse_str(room) { + return Ok(id); + } + // Named lookup: "general" is a common shorthand; all other slugs go via DB. + ChatRoom::find_non_dm_by_slug(client, room) + .await + .map_err(|_| ApiError::Db)? + .map(|r| r.id) + .ok_or(ApiError::NotFound("room not found")) +} + +pub(crate) async fn build_message_items( + client: &deadpool_postgres::Client, + messages: Vec, +) -> Vec { + let author_ids: Vec = messages.iter().map(|m| m.user_id).collect(); + let usernames = User::list_usernames_by_ids(client, &author_ids) + .await + .unwrap_or_default(); + + let message_ids: Vec = messages.iter().map(|m| m.id).collect(); + let reactions_map = ChatMessageReaction::list_summaries_for_messages(client, &message_ids) + .await + .unwrap_or_default(); + + messages + .iter() + .rev() + .map(|m| MessageItem { + id: m.id.to_string(), + user_id: m.user_id.to_string(), + username: usernames.get(&m.user_id).cloned().unwrap_or_default(), + body: m.body.clone(), + timestamp: m.created.to_rfc3339(), + reactions: reactions_map + .get(&m.id) + .map(|rs| { + rs.iter() + .map(|r| ReactionItem { + emoji: reaction_emoji(r.kind).to_string(), + count: r.count, + }) + .collect() + }) + .unwrap_or_default(), + }) + .collect() +} + +pub(crate) fn reaction_emoji(kind: i16) -> &'static str { + match kind { + 1 => "👍", + 2 => "🧡", + 3 => "😂", + 4 => "👀", + 5 => "🔥", + 6 => "🙌", + 7 => "🚀", + 8 => "🤔", + _ => "?", + } +} + +fn room_display_name(slug: Option<&str>) -> String { + match slug { + None | Some("") => "Room".to_string(), + Some(s) => { + let mut c = s.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } + } + } +} diff --git a/late-ssh/src/native_api/chips.rs b/late-ssh/src/native_api/chips.rs new file mode 100644 index 00000000..c148a6f8 --- /dev/null +++ b/late-ssh/src/native_api/chips.rs @@ -0,0 +1,36 @@ +use axum::{ + Json, Router, + extract::State as AxumState, + routing::get, +}; +use late_core::models::chips::UserChips; +use serde::Serialize; + +use crate::state::State; + +use super::{ApiError, NativeAuthUser}; + +pub fn router() -> Router { + Router::new().route("/api/native/chips", get(get_chips)) +} + +#[derive(Serialize)] +struct ChipsResponse { + balance: i64, + last_stipend_date: Option, +} + +async fn get_chips( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result, ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let chips = UserChips::ensure(&client, auth.user_id) + .await + .map_err(|_| ApiError::Db)?; + + Ok(Json(ChipsResponse { + balance: chips.balance, + last_stipend_date: chips.last_stipend_date.map(|d| d.to_string()), + })) +} diff --git a/late-ssh/src/native_api/games.rs b/late-ssh/src/native_api/games.rs new file mode 100644 index 00000000..54d13554 --- /dev/null +++ b/late-ssh/src/native_api/games.rs @@ -0,0 +1,701 @@ +use axum::{ + Json, Router, + extract::{Query, State as AxumState}, + http::StatusCode, + routing::{get, put}, +}; +use chrono::Utc; +use late_core::models::{ + leaderboard::{BadgeTier, fetch_leaderboard_data}, + minesweeper, nonogram, solitaire, sudoku, tetris, twenty_forty_eight, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::state::State; + +use super::{ApiError, NativeAuthUser}; + +pub fn router() -> Router { + Router::new() + .route("/api/native/games/leaderboard", get(get_leaderboard)) + // Tetris + .route("/api/native/games/tetris", get(get_tetris)) + .route("/api/native/games/tetris", put(put_tetris)) + // 2048 + .route("/api/native/games/twenty-forty-eight", get(get_2048)) + .route("/api/native/games/twenty-forty-eight", put(put_2048)) + // Minesweeper + .route("/api/native/games/minesweeper", get(get_minesweeper)) + .route("/api/native/games/minesweeper", put(put_minesweeper)) + .route("/api/native/games/minesweeper/won-today", get(get_minesweeper_won_today)) + // Sudoku + .route("/api/native/games/sudoku", get(get_sudoku)) + .route("/api/native/games/sudoku", put(put_sudoku)) + .route("/api/native/games/sudoku/won-today", get(get_sudoku_won_today)) + // Nonogram + .route("/api/native/games/nonogram", get(get_nonogram)) + .route("/api/native/games/nonogram", put(put_nonogram)) + .route("/api/native/games/nonogram/won-today", get(get_nonogram_won_today)) + // Solitaire + .route("/api/native/games/solitaire", get(get_solitaire)) + .route("/api/native/games/solitaire", put(put_solitaire)) + .route("/api/native/games/solitaire/won-today", get(get_solitaire_won_today)) +} + +// ── Leaderboard ─────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct LeaderboardResponse { + today_champions: Vec, + streak_leaders: Vec, + high_scores: Vec, + chip_leaders: Vec, +} + +#[derive(Serialize)] +struct PlayerEntry { + user_id: String, + username: String, + count: u32, + badge: Option, +} + +#[derive(Serialize)] +struct HighScoreItem { + game: String, + user_id: String, + username: String, + score: i32, +} + +#[derive(Serialize)] +struct ChipItem { + user_id: String, + username: String, + balance: i64, +} + +async fn get_leaderboard( + _auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result, ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let data = fetch_leaderboard_data(&client).await.map_err(|_| ApiError::Db)?; + + let badges = data.badges(); + + let today_champions = data + .today_champions + .iter() + .map(|e| PlayerEntry { + user_id: e.user_id.to_string(), + username: e.username.clone(), + count: e.count, + badge: badges.get(&e.user_id).map(badge_label), + }) + .collect(); + + let streak_leaders = data + .streak_leaders + .iter() + .map(|e| PlayerEntry { + user_id: e.user_id.to_string(), + username: e.username.clone(), + count: e.count, + badge: badges.get(&e.user_id).map(badge_label), + }) + .collect(); + + let high_scores = data + .high_scores + .iter() + .map(|e| HighScoreItem { + game: e.game.to_string(), + user_id: e.user_id.to_string(), + username: e.username.clone(), + score: e.score, + }) + .collect(); + + let chip_leaders = data + .chip_leaders + .iter() + .map(|e| ChipItem { + user_id: e.user_id.to_string(), + username: e.username.clone(), + balance: e.balance, + }) + .collect(); + + Ok(Json(LeaderboardResponse { today_champions, streak_leaders, high_scores, chip_leaders })) +} + +fn badge_label(tier: &BadgeTier) -> String { + tier.tier_name().to_string() +} + +// ── Tetris ──────────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct TetrisState { + score: i32, + lines: i32, + level: i32, + board: Value, + current_kind: String, + current_rotation: i32, + current_row: i32, + current_col: i32, + next_kind: String, + is_game_over: bool, +} + +#[derive(Deserialize)] +struct PutTetrisBody { + score: i32, + lines: i32, + level: i32, + board: Value, + current_kind: String, + current_rotation: i32, + current_row: i32, + current_col: i32, + next_kind: String, + is_game_over: bool, +} + +async fn get_tetris( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result>, ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let games = tetris::Game::list_by_user_id(&client, auth.user_id) + .await + .map_err(|_| ApiError::Db)?; + Ok(Json(games.into_iter().next().map(|g| TetrisState { + score: g.score, + lines: g.lines, + level: g.level, + board: g.board, + current_kind: g.current_kind, + current_rotation: g.current_rotation, + current_row: g.current_row, + current_col: g.current_col, + next_kind: g.next_kind, + is_game_over: g.is_game_over, + }))) +} + +async fn put_tetris( + auth: NativeAuthUser, + AxumState(state): AxumState, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let g = tetris::Game::upsert( + &client, + tetris::GameParams { + user_id: auth.user_id, + score: body.score, + lines: body.lines, + level: body.level, + board: body.board, + current_kind: body.current_kind, + current_rotation: body.current_rotation, + current_row: body.current_row, + current_col: body.current_col, + next_kind: body.next_kind, + is_game_over: body.is_game_over, + }, + ) + .await + .map_err(|_| ApiError::Db)?; + + if g.is_game_over { + tetris::HighScore::update_score_if_higher(&client, auth.user_id, g.score) + .await + .ok(); + } + + Ok(( + StatusCode::OK, + Json(TetrisState { + score: g.score, + lines: g.lines, + level: g.level, + board: g.board, + current_kind: g.current_kind, + current_rotation: g.current_rotation, + current_row: g.current_row, + current_col: g.current_col, + next_kind: g.next_kind, + is_game_over: g.is_game_over, + }), + )) +} + +// ── 2048 ────────────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct TwentyFortyEightState { + score: i32, + grid: Value, + is_game_over: bool, +} + +#[derive(Deserialize)] +struct PutTwentyFortyEightBody { + score: i32, + grid: Value, + is_game_over: bool, +} + +async fn get_2048( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result>, ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let games = twenty_forty_eight::Game::list_by_user_id(&client, auth.user_id) + .await + .map_err(|_| ApiError::Db)?; + Ok(Json(games.into_iter().next().map(|g| TwentyFortyEightState { + score: g.score, + grid: g.grid, + is_game_over: g.is_game_over, + }))) +} + +async fn put_2048( + auth: NativeAuthUser, + AxumState(state): AxumState, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let g = twenty_forty_eight::Game::upsert( + &client, + auth.user_id, + body.score, + body.grid, + body.is_game_over, + ) + .await + .map_err(|_| ApiError::Db)?; + + if g.is_game_over { + twenty_forty_eight::HighScore::update_score_if_higher(&client, auth.user_id, g.score) + .await + .ok(); + } + + Ok(( + StatusCode::OK, + Json(TwentyFortyEightState { score: g.score, grid: g.grid, is_game_over: g.is_game_over }), + )) +} + +// ── Minesweeper ─────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct MinesweeperState { + mode: String, + difficulty_key: String, + puzzle_date: Option, + puzzle_seed: i64, + mine_map: Value, + player_grid: Value, + lives: i32, + is_game_over: bool, + score: i32, +} + +#[derive(Deserialize)] +struct PutMinesweeperBody { + mode: String, + difficulty_key: String, + puzzle_date: Option, + puzzle_seed: i64, + mine_map: Value, + player_grid: Value, + lives: i32, + is_game_over: bool, + score: i32, +} + +#[derive(Deserialize)] +struct WonTodayParams { + difficulty_key: String, +} + +async fn get_minesweeper( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result>, ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let games = minesweeper::Game::list_by_user_id(&client, auth.user_id) + .await + .map_err(|_| ApiError::Db)?; + Ok(Json(games.into_iter().map(minesweeper_state).collect())) +} + +async fn put_minesweeper( + auth: NativeAuthUser, + AxumState(state): AxumState, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + let puzzle_date = body + .puzzle_date + .as_deref() + .map(|s| s.parse::().map_err(|_| ApiError::BadRequest("invalid puzzle_date"))) + .transpose()?; + + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let g = minesweeper::Game::upsert( + &client, + minesweeper::GameParams { + user_id: auth.user_id, + mode: body.mode, + difficulty_key: body.difficulty_key, + puzzle_date, + puzzle_seed: body.puzzle_seed, + mine_map: body.mine_map, + player_grid: body.player_grid, + lives: body.lives, + is_game_over: body.is_game_over, + score: body.score, + }, + ) + .await + .map_err(|_| ApiError::Db)?; + + Ok((StatusCode::OK, Json(minesweeper_state(g)))) +} + +async fn get_minesweeper_won_today( + auth: NativeAuthUser, + Query(params): Query, + AxumState(state): AxumState, +) -> Result, ApiError> { + let today = Utc::now().date_naive(); + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let won = minesweeper::DailyWin::has_won_today(&client, auth.user_id, ¶ms.difficulty_key, today) + .await + .map_err(|_| ApiError::Db)?; + Ok(Json(WonTodayResponse { won, date: today.to_string() })) +} + +fn minesweeper_state(g: minesweeper::Game) -> MinesweeperState { + MinesweeperState { + mode: g.mode, + difficulty_key: g.difficulty_key, + puzzle_date: g.puzzle_date.map(|d| d.to_string()), + puzzle_seed: g.puzzle_seed, + mine_map: g.mine_map, + player_grid: g.player_grid, + lives: g.lives, + is_game_over: g.is_game_over, + score: g.score, + } +} + +// ── Sudoku ──────────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct SudokuState { + mode: String, + difficulty_key: String, + puzzle_date: Option, + puzzle_seed: i64, + grid: Value, + fixed_mask: Value, + is_game_over: bool, + score: i32, +} + +#[derive(Deserialize)] +struct PutSudokuBody { + mode: String, + difficulty_key: String, + puzzle_date: Option, + puzzle_seed: i64, + grid: Value, + fixed_mask: Value, + is_game_over: bool, + score: i32, +} + +async fn get_sudoku( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result>, ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let games = sudoku::Game::list_by_user_id(&client, auth.user_id) + .await + .map_err(|_| ApiError::Db)?; + Ok(Json(games.into_iter().map(sudoku_state).collect())) +} + +async fn put_sudoku( + auth: NativeAuthUser, + AxumState(state): AxumState, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + let puzzle_date = body + .puzzle_date + .as_deref() + .map(|s| s.parse::().map_err(|_| ApiError::BadRequest("invalid puzzle_date"))) + .transpose()?; + + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let g = sudoku::Game::upsert( + &client, + sudoku::GameParams { + user_id: auth.user_id, + mode: body.mode, + difficulty_key: body.difficulty_key, + puzzle_date, + puzzle_seed: body.puzzle_seed, + grid: body.grid, + fixed_mask: body.fixed_mask, + is_game_over: body.is_game_over, + score: body.score, + }, + ) + .await + .map_err(|_| ApiError::Db)?; + + Ok((StatusCode::OK, Json(sudoku_state(g)))) +} + +async fn get_sudoku_won_today( + auth: NativeAuthUser, + Query(params): Query, + AxumState(state): AxumState, +) -> Result, ApiError> { + let today = Utc::now().date_naive(); + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let won = sudoku::DailyWin::has_won_today(&client, auth.user_id, ¶ms.difficulty_key, today) + .await + .map_err(|_| ApiError::Db)?; + Ok(Json(WonTodayResponse { won, date: today.to_string() })) +} + +fn sudoku_state(g: sudoku::Game) -> SudokuState { + SudokuState { + mode: g.mode, + difficulty_key: g.difficulty_key, + puzzle_date: g.puzzle_date.map(|d| d.to_string()), + puzzle_seed: g.puzzle_seed, + grid: g.grid, + fixed_mask: g.fixed_mask, + is_game_over: g.is_game_over, + score: g.score, + } +} + +// ── Nonogram ────────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct NonogramState { + mode: String, + size_key: String, + puzzle_date: Option, + puzzle_id: String, + player_grid: Value, + is_game_over: bool, + score: i32, +} + +#[derive(Deserialize)] +struct PutNonogramBody { + mode: String, + size_key: String, + puzzle_date: Option, + puzzle_id: String, + player_grid: Value, + is_game_over: bool, + score: i32, +} + +#[derive(Deserialize)] +struct NonogramWonTodayParams { + size_key: String, +} + +async fn get_nonogram( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result>, ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let games = nonogram::Game::list_by_user_id(&client, auth.user_id) + .await + .map_err(|_| ApiError::Db)?; + Ok(Json(games.into_iter().map(nonogram_state).collect())) +} + +async fn put_nonogram( + auth: NativeAuthUser, + AxumState(state): AxumState, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + let puzzle_date = body + .puzzle_date + .as_deref() + .map(|s| s.parse::().map_err(|_| ApiError::BadRequest("invalid puzzle_date"))) + .transpose()?; + + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let g = nonogram::Game::upsert( + &client, + nonogram::GameParams { + user_id: auth.user_id, + mode: body.mode, + size_key: body.size_key, + puzzle_date, + puzzle_id: body.puzzle_id, + player_grid: body.player_grid, + is_game_over: body.is_game_over, + score: body.score, + }, + ) + .await + .map_err(|_| ApiError::Db)?; + + Ok((StatusCode::OK, Json(nonogram_state(g)))) +} + +async fn get_nonogram_won_today( + auth: NativeAuthUser, + Query(params): Query, + AxumState(state): AxumState, +) -> Result, ApiError> { + let today = Utc::now().date_naive(); + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let won = nonogram::DailyWin::has_won_today(&client, auth.user_id, ¶ms.size_key, today) + .await + .map_err(|_| ApiError::Db)?; + Ok(Json(WonTodayResponse { won, date: today.to_string() })) +} + +fn nonogram_state(g: nonogram::Game) -> NonogramState { + NonogramState { + mode: g.mode, + size_key: g.size_key, + puzzle_date: g.puzzle_date.map(|d| d.to_string()), + puzzle_id: g.puzzle_id, + player_grid: g.player_grid, + is_game_over: g.is_game_over, + score: g.score, + } +} + +// ── Solitaire ───────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct SolitaireState { + mode: String, + difficulty_key: String, + puzzle_date: Option, + puzzle_seed: i64, + stock: Value, + waste: Value, + foundations: Value, + tableau: Value, + is_game_over: bool, + score: i32, +} + +#[derive(Deserialize)] +struct PutSolitaireBody { + mode: String, + difficulty_key: String, + puzzle_date: Option, + puzzle_seed: i64, + stock: Value, + waste: Value, + foundations: Value, + tableau: Value, + is_game_over: bool, + score: i32, +} + +async fn get_solitaire( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result>, ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let games = solitaire::Game::list_by_user_id(&client, auth.user_id) + .await + .map_err(|_| ApiError::Db)?; + Ok(Json(games.into_iter().map(solitaire_state).collect())) +} + +async fn put_solitaire( + auth: NativeAuthUser, + AxumState(state): AxumState, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + let puzzle_date = body + .puzzle_date + .as_deref() + .map(|s| s.parse::().map_err(|_| ApiError::BadRequest("invalid puzzle_date"))) + .transpose()?; + + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let g = solitaire::Game::upsert( + &client, + solitaire::GameParams { + user_id: auth.user_id, + mode: body.mode, + difficulty_key: body.difficulty_key, + puzzle_date, + puzzle_seed: body.puzzle_seed, + stock: body.stock, + waste: body.waste, + foundations: body.foundations, + tableau: body.tableau, + is_game_over: body.is_game_over, + score: body.score, + }, + ) + .await + .map_err(|_| ApiError::Db)?; + + Ok((StatusCode::OK, Json(solitaire_state(g)))) +} + +async fn get_solitaire_won_today( + auth: NativeAuthUser, + Query(params): Query, + AxumState(state): AxumState, +) -> Result, ApiError> { + let today = Utc::now().date_naive(); + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let won = solitaire::DailyWin::has_won_today(&client, auth.user_id, ¶ms.difficulty_key, today) + .await + .map_err(|_| ApiError::Db)?; + Ok(Json(WonTodayResponse { won, date: today.to_string() })) +} + +fn solitaire_state(g: solitaire::Game) -> SolitaireState { + SolitaireState { + mode: g.mode, + difficulty_key: g.difficulty_key, + puzzle_date: g.puzzle_date.map(|d| d.to_string()), + puzzle_seed: g.puzzle_seed, + stock: g.stock, + waste: g.waste, + foundations: g.foundations, + tableau: g.tableau, + is_game_over: g.is_game_over, + score: g.score, + } +} + +// ── Shared ──────────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct WonTodayResponse { + won: bool, + date: String, +} diff --git a/late-ssh/src/native_api/media.rs b/late-ssh/src/native_api/media.rs new file mode 100644 index 00000000..21811882 --- /dev/null +++ b/late-ssh/src/native_api/media.rs @@ -0,0 +1,136 @@ +use axum::{ + Json, Router, + extract::{State as AxumState}, + routing::{get, post}, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +use crate::app::vote::svc::{Genre, VoteSnapshot}; +use crate::state::State; + +use super::{ApiError, NativeAuthUser}; + +pub fn router() -> Router { + Router::new() + .route("/api/native/now-playing", get(get_now_playing)) + .route("/api/native/status", get(get_native_status)) + .route("/api/native/vote", post(post_vote)) +} + +// ── Response types ──────────────────────────────────────────────────────────── + +#[derive(Serialize, Clone)] +pub struct NowPlayingResponse { + pub track: String, + pub artist: String, + pub album: String, + pub progress_sec: u64, + pub duration_sec: u64, + pub volume_pct: u32, +} + +#[derive(Serialize, Clone)] +pub struct VotesResponse { + pub lofi: i64, + pub ambient: i64, + pub classic: i64, + pub jazz: i64, + pub next_vote_at: String, +} + +#[derive(Serialize)] +struct NativeStatusResponse { + connected: bool, + online_users: usize, + now_playing: NowPlayingResponse, + votes: VotesResponse, +} + +// ── Handlers ────────────────────────────────────────────────────────────────── + +async fn get_now_playing( + _auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Json { + Json(build_now_playing_response(&state)) +} + +async fn get_native_status( + _auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Json { + let online_users = state.active_users.lock().unwrap_or_else(|e| e.into_inner()).len(); + Json(NativeStatusResponse { + connected: true, + online_users, + now_playing: build_now_playing_response(&state), + votes: build_votes_response(&state), + }) +} + +#[derive(Deserialize)] +struct VoteBody { + genre: String, +} + +async fn post_vote( + auth: NativeAuthUser, + AxumState(state): AxumState, + Json(body): Json, +) -> Result, ApiError> { + let genre = Genre::try_from(body.genre.as_str()) + .map_err(|_| ApiError::BadRequest("unknown genre"))?; + state.vote_service.cast_vote_task(auth.user_id, genre); + Ok(Json(build_votes_response(&state))) +} + +// ── Builder helpers (used by ws.rs) ─────────────────────────────────────────── + +pub(crate) fn build_now_playing_response(state: &State) -> NowPlayingResponse { + build_now_playing_from_value(&state.now_playing_rx.borrow().clone()) +} + +pub(crate) fn build_now_playing_from_value( + np: &Option, +) -> NowPlayingResponse { + match np { + Some(np) => { + let elapsed = np.started_at.elapsed().as_secs(); + let duration = np.track.duration_seconds.unwrap_or(1); + NowPlayingResponse { + track: np.track.title.clone(), + artist: np.track.artist.clone().unwrap_or_default(), + album: String::new(), + progress_sec: elapsed, + duration_sec: duration, + volume_pct: 0, + } + } + None => NowPlayingResponse { + track: String::new(), + artist: String::new(), + album: String::new(), + progress_sec: 0, + duration_sec: 1, + volume_pct: 0, + }, + } +} + +pub(crate) fn build_votes_response(state: &State) -> VotesResponse { + build_votes_response_from_snapshot(&state.vote_service.subscribe_state().borrow().clone()) +} + +pub(crate) fn build_votes_response_from_snapshot(snap: &VoteSnapshot) -> VotesResponse { + let next_vote_at = + (Utc::now() + chrono::Duration::from_std(snap.next_switch_in).unwrap_or_default()) + .to_rfc3339(); + VotesResponse { + lofi: snap.counts.lofi, + ambient: snap.counts.ambient, + classic: snap.counts.classic, + jazz: snap.counts.jazz, + next_vote_at, + } +} diff --git a/late-ssh/src/native_api/mod.rs b/late-ssh/src/native_api/mod.rs new file mode 100644 index 00000000..f3c4fe1c --- /dev/null +++ b/late-ssh/src/native_api/mod.rs @@ -0,0 +1,111 @@ +pub mod articles; +pub mod artboard; +pub mod auth; +pub mod bonsai; +pub mod chat; +pub mod chips; +pub mod games; +pub mod media; +pub mod notifications; +pub mod profile; +pub mod rss; +pub mod showcase; +pub mod users; +pub mod work_profiles; +pub mod ws; + +use axum::{ + Json, Router, + extract::FromRequestParts, + http::{StatusCode, header::AUTHORIZATION, request::Parts}, + response::IntoResponse, +}; +use late_core::models::native_token::NativeToken; +use serde::Serialize; +use uuid::Uuid; + +use crate::state::State; + +// ── Typed API error ─────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub enum ApiError { + Unauthorized(&'static str), + Forbidden(&'static str), + NotFound(&'static str), + BadRequest(&'static str), + TooManyRequests, + Db, +} + +#[derive(Serialize)] +struct ErrorBody { + error: &'static str, +} + +impl IntoResponse for ApiError { + fn into_response(self) -> axum::response::Response { + let (status, msg) = match self { + Self::Unauthorized(m) => (StatusCode::UNAUTHORIZED, m), + Self::Forbidden(m) => (StatusCode::FORBIDDEN, m), + Self::NotFound(m) => (StatusCode::NOT_FOUND, m), + Self::BadRequest(m) => (StatusCode::BAD_REQUEST, m), + Self::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded"), + Self::Db => (StatusCode::INTERNAL_SERVER_ERROR, "db error"), + }; + (status, Json(ErrorBody { error: msg })).into_response() + } +} + +// ── Auth extractor ──────────────────────────────────────────────────────────── + +pub struct NativeAuthUser { + pub user_id: Uuid, + pub username: String, + pub raw_token: String, +} + +impl FromRequestParts for NativeAuthUser { + type Rejection = ApiError; + + async fn from_request_parts(parts: &mut Parts, state: &State) -> Result { + let token = parts + .headers + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(str::trim) + .ok_or(ApiError::Unauthorized("missing bearer token"))? + .to_owned(); + + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + + let (user_id, username) = NativeToken::find_user_by_token(&client, &token) + .await + .map_err(|_| ApiError::Db)? + .ok_or(ApiError::Unauthorized("invalid or expired token"))?; + + Ok(NativeAuthUser { user_id, username, raw_token: token }) + } +} + +// ── Router ──────────────────────────────────────────────────────────────────── + +pub fn router() -> Router { + Router::new() + .merge(articles::router()) + .merge(artboard::router()) + .merge(auth::router()) + .merge(bonsai::router()) + .merge(chat::router()) + .merge(chips::router()) + .merge(games::router()) + .merge(media::router()) + .merge(notifications::router()) + .merge(profile::router()) + .merge(rss::router()) + .merge(showcase::router()) + .merge(users::router()) + .merge(work_profiles::router()) + .merge(ws::router()) +} diff --git a/late-ssh/src/native_api/notifications.rs b/late-ssh/src/native_api/notifications.rs new file mode 100644 index 00000000..ee16c7ff --- /dev/null +++ b/late-ssh/src/native_api/notifications.rs @@ -0,0 +1,85 @@ +use axum::{ + Json, Router, + extract::{Query, State as AxumState}, + http::StatusCode, + routing::{get, post}, +}; +use late_core::models::notification::Notification; +use serde::{Deserialize, Serialize}; + +use crate::state::State; + +use super::{ApiError, NativeAuthUser}; + +pub fn router() -> Router { + Router::new() + .route("/api/native/notifications", get(get_notifications)) + .route("/api/native/notifications/read", post(post_notifications_read)) + .route("/api/native/notifications/unread", get(get_unread_count)) +} + +#[derive(Deserialize)] +struct NotifParams { + limit: Option, +} + +#[derive(Serialize)] +struct NotificationItem { + id: String, + actor_username: String, + room_slug: Option, + message_preview: String, + timestamp: String, +} + +#[derive(Serialize)] +struct UnreadCountResponse { + unread: i64, +} + +async fn get_notifications( + auth: NativeAuthUser, + Query(params): Query, + AxumState(state): AxumState, +) -> Result>, ApiError> { + let limit = params.limit.unwrap_or(50).clamp(1, 200); + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let views = Notification::list_for_user(&client, auth.user_id, limit) + .await + .map_err(|_| ApiError::Db)?; + + let items = views + .into_iter() + .map(|n| NotificationItem { + id: n.id.to_string(), + actor_username: n.actor_username, + room_slug: n.room_slug, + message_preview: n.message_preview, + timestamp: n.created.to_rfc3339(), + }) + .collect(); + + Ok(Json(items)) +} + +async fn post_notifications_read( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + Notification::mark_all_read(&client, auth.user_id) + .await + .map_err(|_| ApiError::Db)?; + Ok(StatusCode::NO_CONTENT) +} + +async fn get_unread_count( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result, ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let unread = Notification::unread_count(&client, auth.user_id) + .await + .map_err(|_| ApiError::Db)?; + Ok(Json(UnreadCountResponse { unread })) +} diff --git a/late-ssh/src/native_api/profile.rs b/late-ssh/src/native_api/profile.rs new file mode 100644 index 00000000..507363d5 --- /dev/null +++ b/late-ssh/src/native_api/profile.rs @@ -0,0 +1,201 @@ +use axum::{ + Json, Router, + extract::State as AxumState, + routing::{get, put}, +}; +use late_core::models::{ + profile::{Profile, ProfileParams}, + user::User, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::state::State; + +use super::{ApiError, NativeAuthUser}; + +pub fn router() -> Router { + Router::new() + .route("/api/native/profile", get(get_profile)) + .route("/api/native/profile", put(put_profile)) +} + +#[derive(Serialize)] +struct ProfileResponse { + username: String, + bio: String, + country: Option, + timezone: Option, + ide: Option, + terminal: Option, + os: Option, + langs: Vec, + notify_kinds: Vec, + notify_bell: bool, + notify_cooldown_mins: i32, + notify_format: Option, + theme_id: Option, + enable_background_color: bool, + show_dashboard_header: bool, + show_right_sidebar: bool, + show_games_sidebar: bool, + show_settings_on_connect: bool, + favorite_room_ids: Vec, + member_since: Option, +} + +async fn get_profile( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result, ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let profile = Profile::load(&client, auth.user_id).await.map_err(|_| ApiError::Db)?; + Ok(Json(profile_to_response(profile))) +} + +#[derive(Deserialize)] +struct UpdateProfileBody { + username: Option, + bio: Option, + country: Option, + timezone: Option, + ide: Option, + terminal: Option, + os: Option, + langs: Option>, + notify_kinds: Option>, + notify_bell: Option, + notify_cooldown_mins: Option, + notify_format: Option, + theme_id: Option, + enable_background_color: Option, + show_dashboard_header: Option, + show_right_sidebar: Option, + show_games_sidebar: Option, + show_settings_on_connect: Option, + favorite_room_ids: Option>, +} + +async fn put_profile( + auth: NativeAuthUser, + AxumState(state): AxumState, + Json(body): Json, +) -> Result, ApiError> { + // Validate + if let Some(ref u) = body.username { + let trimmed = u.trim(); + if trimmed.is_empty() || trimmed.len() > 32 { + return Err(ApiError::BadRequest("username must be 1–32 characters")); + } + } + if let Some(ref b) = body.bio { + if b.len() > 500 { + return Err(ApiError::BadRequest("bio exceeds 500 characters")); + } + } + if let Some(ref fmt) = body.notify_format { + if !matches!(fmt.as_str(), "both" | "osc777" | "osc9") { + return Err(ApiError::BadRequest( + "notify_format must be one of: both, osc777, osc9", + )); + } + } + if let Some(ref ids) = body.favorite_room_ids { + if ids.len() > 20 { + return Err(ApiError::BadRequest("too many favorite rooms (max 20)")); + } + for id in ids { + Uuid::parse_str(id).map_err(|_| ApiError::BadRequest("invalid uuid in favorite_room_ids"))?; + } + } + + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + + // Load current profile to merge partial updates + let current = Profile::load(&client, auth.user_id).await.map_err(|_| ApiError::Db)?; + + let favorite_room_ids = body + .favorite_room_ids + .as_deref() + .map(|ids| { + ids.iter() + .map(|id| Uuid::parse_str(id).unwrap()) // already validated above + .collect::>() + }) + .unwrap_or(current.favorite_room_ids); + + // Validate username uniqueness if changing + let new_username = body + .username + .as_deref() + .map(str::trim) + .filter(|u| !u.is_empty()) + .unwrap_or(¤t.username) + .to_string(); + + if new_username != current.username { + let existing = User::find_by_username(&client, &new_username) + .await + .map_err(|_| ApiError::Db)?; + if existing.is_some() { + return Err(ApiError::BadRequest("username already taken")); + } + } + + let params = ProfileParams { + username: new_username, + bio: body.bio.unwrap_or(current.bio), + country: body.country.or(current.country), + timezone: body.timezone.or(current.timezone), + ide: body.ide.or(current.ide), + terminal: body.terminal.or(current.terminal), + os: body.os.or(current.os), + langs: body.langs.unwrap_or(current.langs), + notify_kinds: body.notify_kinds.unwrap_or(current.notify_kinds), + notify_bell: body.notify_bell.unwrap_or(current.notify_bell), + notify_cooldown_mins: body.notify_cooldown_mins.unwrap_or(current.notify_cooldown_mins), + notify_format: body.notify_format.or(current.notify_format), + theme_id: body.theme_id.or(current.theme_id), + enable_background_color: body + .enable_background_color + .unwrap_or(current.enable_background_color), + show_dashboard_header: body.show_dashboard_header.unwrap_or(current.show_dashboard_header), + show_right_sidebar: body.show_right_sidebar.unwrap_or(current.show_right_sidebar), + show_games_sidebar: body.show_games_sidebar.unwrap_or(current.show_games_sidebar), + show_settings_on_connect: body + .show_settings_on_connect + .unwrap_or(current.show_settings_on_connect), + favorite_room_ids, + }; + + let updated = Profile::update(&client, auth.user_id, params) + .await + .map_err(|_| ApiError::Db)?; + + Ok(Json(profile_to_response(updated))) +} + +fn profile_to_response(p: Profile) -> ProfileResponse { + ProfileResponse { + username: p.username, + bio: p.bio, + country: p.country, + timezone: p.timezone, + ide: p.ide, + terminal: p.terminal, + os: p.os, + langs: p.langs, + notify_kinds: p.notify_kinds, + notify_bell: p.notify_bell, + notify_cooldown_mins: p.notify_cooldown_mins, + notify_format: p.notify_format, + theme_id: p.theme_id, + enable_background_color: p.enable_background_color, + show_dashboard_header: p.show_dashboard_header, + show_right_sidebar: p.show_right_sidebar, + show_games_sidebar: p.show_games_sidebar, + show_settings_on_connect: p.show_settings_on_connect, + favorite_room_ids: p.favorite_room_ids.iter().map(Uuid::to_string).collect(), + member_since: p.created_at.map(|d| d.to_rfc3339()), + } +} diff --git a/late-ssh/src/native_api/rss.rs b/late-ssh/src/native_api/rss.rs new file mode 100644 index 00000000..7534db36 --- /dev/null +++ b/late-ssh/src/native_api/rss.rs @@ -0,0 +1,178 @@ +use axum::{ + Json, Router, + extract::{Path, Query, State as AxumState}, + http::StatusCode, + routing::{delete, get, post}, +}; +use late_core::models::{rss_entry::RssEntry, rss_feed::RssFeed}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::state::State; + +use super::{ApiError, NativeAuthUser}; + +pub fn router() -> Router { + Router::new() + .route("/api/native/rss/feeds", get(get_feeds)) + .route("/api/native/rss/feeds", post(post_feed)) + .route("/api/native/rss/feeds/{id}", delete(delete_feed)) + .route("/api/native/rss/entries", get(get_entries)) + .route("/api/native/rss/entries/{id}/dismiss", post(post_dismiss_entry)) + .route("/api/native/rss/unread", get(get_unread_count)) +} + +// ── Feeds ───────────────────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct FeedItem { + id: String, + url: String, + title: String, + active: bool, + last_checked_at: Option, + last_success_at: Option, + last_error: Option, +} + +#[derive(Deserialize)] +struct AddFeedBody { + url: String, +} + +async fn get_feeds( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result>, ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let feeds = RssFeed::list_for_user(&client, auth.user_id) + .await + .map_err(|_| ApiError::Db)?; + Ok(Json(feeds.into_iter().map(feed_to_item).collect())) +} + +async fn post_feed( + auth: NativeAuthUser, + AxumState(state): AxumState, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + let url = body.url.trim(); + if !url.starts_with("http://") && !url.starts_with("https://") { + return Err(ApiError::BadRequest("url must start with http:// or https://")); + } + if url.len() > 1000 { + return Err(ApiError::BadRequest("url exceeds 1000 characters")); + } + + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let feed = RssFeed::create_for_user(&client, auth.user_id, url) + .await + .map_err(|_| ApiError::Db)?; + + Ok((StatusCode::CREATED, Json(feed_to_item(feed)))) +} + +async fn delete_feed( + auth: NativeAuthUser, + Path(id): Path, + AxumState(state): AxumState, +) -> Result { + let feed_id = Uuid::parse_str(&id).map_err(|_| ApiError::BadRequest("invalid id"))?; + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let deleted = RssFeed::delete_for_user(&client, auth.user_id, feed_id) + .await + .map_err(|_| ApiError::Db)?; + if deleted == 0 { + return Err(ApiError::NotFound("feed not found")); + } + Ok(StatusCode::NO_CONTENT) +} + +// ── Entries ─────────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct EntriesParams { + limit: Option, +} + +#[derive(Serialize)] +struct EntryItem { + id: String, + feed_id: String, + feed_title: String, + feed_url: String, + url: String, + title: String, + summary: String, + published_at: Option, +} + +#[derive(Serialize)] +struct UnreadCountResponse { + unread: i64, +} + +async fn get_entries( + auth: NativeAuthUser, + Query(params): Query, + AxumState(state): AxumState, +) -> Result>, ApiError> { + let limit = params.limit.unwrap_or(50).clamp(1, 200); + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let views = RssEntry::list_visible_for_user(&client, auth.user_id, limit) + .await + .map_err(|_| ApiError::Db)?; + Ok(Json( + views + .into_iter() + .map(|v| EntryItem { + id: v.entry.id.to_string(), + feed_id: v.entry.feed_id.to_string(), + feed_title: v.feed_title, + feed_url: v.feed_url, + url: v.entry.url, + title: v.entry.title, + summary: v.entry.summary, + published_at: v.entry.published_at.map(|d| d.to_rfc3339()), + }) + .collect(), + )) +} + +async fn post_dismiss_entry( + auth: NativeAuthUser, + Path(id): Path, + AxumState(state): AxumState, +) -> Result { + let entry_id = Uuid::parse_str(&id).map_err(|_| ApiError::BadRequest("invalid id"))?; + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + RssEntry::dismiss(&client, auth.user_id, entry_id) + .await + .map_err(|_| ApiError::Db)?; + Ok(StatusCode::NO_CONTENT) +} + +async fn get_unread_count( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result, ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let unread = RssEntry::unread_count_for_user(&client, auth.user_id) + .await + .map_err(|_| ApiError::Db)?; + Ok(Json(UnreadCountResponse { unread })) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn feed_to_item(f: RssFeed) -> FeedItem { + FeedItem { + id: f.id.to_string(), + url: f.url, + title: f.title, + active: f.active, + last_checked_at: f.last_checked_at.map(|d| d.to_rfc3339()), + last_success_at: f.last_success_at.map(|d| d.to_rfc3339()), + last_error: f.last_error, + } +} diff --git a/late-ssh/src/native_api/showcase.rs b/late-ssh/src/native_api/showcase.rs new file mode 100644 index 00000000..52ac96f1 --- /dev/null +++ b/late-ssh/src/native_api/showcase.rs @@ -0,0 +1,176 @@ +use axum::{ + Json, Router, + extract::{Path, Query, State as AxumState}, + http::StatusCode, + routing::{get, post}, +}; +use late_core::models::showcase::{Showcase, ShowcaseParams}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::state::State; + +use super::{ApiError, NativeAuthUser}; + +pub fn router() -> Router { + Router::new() + .route("/api/native/showcase", get(get_showcase)) + .route("/api/native/showcase", post(post_showcase)) + .route("/api/native/showcase/mine", get(get_my_showcase)) + .route("/api/native/showcase/{id}", axum::routing::delete(delete_showcase)) +} + +#[derive(Deserialize)] +struct ShowcaseParams_ { + limit: Option, +} + +#[derive(Serialize)] +struct ShowcaseItem { + id: String, + user_id: String, + title: String, + url: String, + description: String, + tags: Vec, + created: String, +} + +#[derive(Deserialize)] +struct CreateShowcaseBody { + title: String, + url: String, + description: String, + #[serde(default)] + tags: Vec, +} + +async fn get_showcase( + _auth: NativeAuthUser, + Query(params): Query, + AxumState(state): AxumState, +) -> Result>, ApiError> { + let limit = params.limit.unwrap_or(50).clamp(1, 200); + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let items = Showcase::list_recent(&client, limit) + .await + .map_err(|_| ApiError::Db)?; + + Ok(Json( + items + .into_iter() + .map(|s| ShowcaseItem { + id: s.id.to_string(), + user_id: s.user_id.to_string(), + title: s.title, + url: s.url, + description: s.description, + tags: s.tags, + created: s.created.to_rfc3339(), + }) + .collect(), + )) +} + +async fn post_showcase( + auth: NativeAuthUser, + AxumState(state): AxumState, + Json(body): Json, +) -> Result<(StatusCode, Json), ApiError> { + let title = body.title.trim(); + if title.is_empty() { + return Err(ApiError::BadRequest("title is required")); + } + if title.len() > 100 { + return Err(ApiError::BadRequest("title exceeds 100 characters")); + } + + let url = body.url.trim(); + if !url.starts_with("http://") && !url.starts_with("https://") { + return Err(ApiError::BadRequest("url must start with http:// or https://")); + } + if url.len() > 500 { + return Err(ApiError::BadRequest("url exceeds 500 characters")); + } + + let description = body.description.trim(); + if description.len() > 500 { + return Err(ApiError::BadRequest("description exceeds 500 characters")); + } + + let tags: Vec = body + .tags + .iter() + .map(|t| t.trim().to_ascii_lowercase()) + .filter(|t| !t.is_empty() && t.len() <= 30) + .take(10) + .collect(); + + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let item = Showcase::create_by_user_id( + &client, + auth.user_id, + ShowcaseParams { + user_id: Uuid::nil(), + title: title.to_string(), + url: url.to_string(), + description: description.to_string(), + tags, + }, + ) + .await + .map_err(|_| ApiError::Db)?; + + Ok(( + StatusCode::CREATED, + Json(ShowcaseItem { + id: item.id.to_string(), + user_id: item.user_id.to_string(), + title: item.title, + url: item.url, + description: item.description, + tags: item.tags, + created: item.created.to_rfc3339(), + }), + )) +} + +async fn get_my_showcase( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result>, ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let items = Showcase::list_by_user_id(&client, auth.user_id) + .await + .map_err(|_| ApiError::Db)?; + Ok(Json( + items + .into_iter() + .map(|s| ShowcaseItem { + id: s.id.to_string(), + user_id: s.user_id.to_string(), + title: s.title, + url: s.url, + description: s.description, + tags: s.tags, + created: s.created.to_rfc3339(), + }) + .collect(), + )) +} + +async fn delete_showcase( + auth: NativeAuthUser, + Path(id): Path, + AxumState(state): AxumState, +) -> Result { + let item_id = Uuid::parse_str(&id).map_err(|_| ApiError::BadRequest("invalid id"))?; + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let deleted = Showcase::delete_by_user_id(&client, auth.user_id, item_id) + .await + .map_err(|_| ApiError::Db)?; + if deleted == 0 { + return Err(ApiError::NotFound("showcase item not found")); + } + Ok(StatusCode::NO_CONTENT) +} diff --git a/late-ssh/src/native_api/users.rs b/late-ssh/src/native_api/users.rs new file mode 100644 index 00000000..9d58094b --- /dev/null +++ b/late-ssh/src/native_api/users.rs @@ -0,0 +1,91 @@ +use axum::{ + Json, Router, + extract::{Path, State as AxumState}, + routing::get, +}; +use late_core::models::{profile::Profile, user::User}; +use serde::Serialize; + +use crate::state::State; + +use super::{ApiError, NativeAuthUser}; + +pub fn router() -> Router { + Router::new() + .route("/api/native/me", get(get_me)) + .route("/api/native/users/online", get(get_online_users)) + .route("/api/native/users/{username}", get(get_user_profile)) +} + +#[derive(Serialize)] +struct MeResponse { + user_id: String, + username: String, +} + +async fn get_me(auth: NativeAuthUser) -> Json { + Json(MeResponse { + user_id: auth.user_id.to_string(), + username: auth.username, + }) +} + +#[derive(Serialize)] +struct OnlineUser { + user_id: String, + username: String, +} + +async fn get_online_users( + _auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Json> { + let users = state.active_users.lock().unwrap_or_else(|e| e.into_inner()); + let list = users + .iter() + .map(|(id, u)| OnlineUser { + user_id: id.to_string(), + username: u.username.clone(), + }) + .collect(); + Json(list) +} + +#[derive(Serialize)] +struct PublicProfile { + username: String, + bio: String, + country: Option, + timezone: Option, + ide: Option, + terminal: Option, + os: Option, + langs: Vec, + member_since: Option, +} + +async fn get_user_profile( + _auth: NativeAuthUser, + Path(username): Path, + AxumState(state): AxumState, +) -> Result, ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let user = User::find_by_username(&client, &username) + .await + .map_err(|_| ApiError::Db)? + .ok_or(ApiError::NotFound("user not found"))?; + + let profile = Profile::load(&client, user.id).await.map_err(|_| ApiError::Db)?; + + Ok(Json(PublicProfile { + username: profile.username, + bio: profile.bio, + country: profile.country, + timezone: profile.timezone, + ide: profile.ide, + terminal: profile.terminal, + os: profile.os, + langs: profile.langs, + member_since: profile.created_at.map(|d| d.to_rfc3339()), + })) +} diff --git a/late-ssh/src/native_api/work_profiles.rs b/late-ssh/src/native_api/work_profiles.rs new file mode 100644 index 00000000..eafbcf46 --- /dev/null +++ b/late-ssh/src/native_api/work_profiles.rs @@ -0,0 +1,181 @@ +use axum::{ + Json, Router, + extract::{Path, Query, State as AxumState}, + http::StatusCode, + routing::{delete, get, put}, +}; +use late_core::models::work_profile::{WorkProfile, WorkProfileParams}; +use serde::{Deserialize, Serialize}; +use crate::state::State; + +use super::{ApiError, NativeAuthUser}; + +pub fn router() -> Router { + Router::new() + .route("/api/native/work-profiles", get(get_work_profiles)) + .route("/api/native/work-profiles/{slug}", get(get_work_profile_by_slug)) + .route("/api/native/work-profile", put(put_work_profile)) + .route("/api/native/work-profile", delete(delete_work_profile)) +} + +#[derive(Deserialize)] +struct ListParams { + limit: Option, +} + +#[derive(Serialize)] +struct WorkProfileItem { + id: String, + user_id: String, + slug: String, + headline: String, + status: String, + work_type: String, + location: String, + contact: String, + links: Vec, + skills: Vec, + summary: String, +} + +#[derive(Deserialize)] +struct UpsertWorkProfileBody { + headline: String, + status: String, + work_type: String, + location: String, + contact: String, + #[serde(default)] + links: Vec, + #[serde(default)] + skills: Vec, + summary: String, +} + +async fn get_work_profiles( + _auth: NativeAuthUser, + Query(params): Query, + AxumState(state): AxumState, +) -> Result>, ApiError> { + let limit = params.limit.unwrap_or(50).clamp(1, 200); + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let profiles = WorkProfile::list_recent(&client, limit).await.map_err(|_| ApiError::Db)?; + Ok(Json(profiles.into_iter().map(to_item).collect())) +} + +async fn get_work_profile_by_slug( + _auth: NativeAuthUser, + Path(slug): Path, + AxumState(state): AxumState, +) -> Result, ApiError> { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let profile = WorkProfile::find_by_slug(&client, &slug) + .await + .map_err(|_| ApiError::Db)? + .ok_or(ApiError::NotFound("work profile not found"))?; + Ok(Json(to_item(profile))) +} + +async fn put_work_profile( + auth: NativeAuthUser, + AxumState(state): AxumState, + Json(body): Json, +) -> Result, ApiError> { + if body.headline.trim().is_empty() { + return Err(ApiError::BadRequest("headline is required")); + } + if body.headline.len() > 120 { + return Err(ApiError::BadRequest("headline exceeds 120 characters")); + } + if body.summary.len() > 2000 { + return Err(ApiError::BadRequest("summary exceeds 2000 characters")); + } + if body.links.len() > 10 { + return Err(ApiError::BadRequest("too many links (max 10)")); + } + if body.skills.len() > 20 { + return Err(ApiError::BadRequest("too many skills (max 20)")); + } + for link in &body.links { + if !link.starts_with("http://") && !link.starts_with("https://") { + return Err(ApiError::BadRequest("each link must start with http:// or https://")); + } + } + + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + + // Derive slug from the user's username + let slug = client + .query_opt("SELECT username FROM users WHERE id = $1", &[&auth.user_id]) + .await + .map_err(|_| ApiError::Db)? + .and_then(|row| { + let u: String = row.get("username"); + if u.is_empty() { None } else { Some(u) } + }) + .ok_or(ApiError::NotFound("user not found"))?; + + // Check for existing profile and update, or create + let existing = WorkProfile::list_by_user_id(&client, auth.user_id) + .await + .map_err(|_| ApiError::Db)?; + + let params = WorkProfileParams { + user_id: auth.user_id, + slug: slug.clone(), + headline: body.headline.trim().to_string(), + status: body.status.trim().to_string(), + work_type: body.work_type.trim().to_string(), + location: body.location.trim().to_string(), + contact: body.contact.trim().to_string(), + links: body.links, + skills: body.skills, + summary: body.summary.trim().to_string(), + }; + + let profile = if let Some(existing) = existing.into_iter().next() { + WorkProfile::update_by_user_id(&client, auth.user_id, existing.id, params) + .await + .map_err(|_| ApiError::Db)? + .ok_or(ApiError::NotFound("work profile not found"))? + } else { + WorkProfile::create_by_user_id(&client, auth.user_id, params) + .await + .map_err(|_| ApiError::Db)? + }; + + Ok(Json(to_item(profile))) +} + +async fn delete_work_profile( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result { + let client = state.db.get().await.map_err(|_| ApiError::Db)?; + let existing = WorkProfile::list_by_user_id(&client, auth.user_id) + .await + .map_err(|_| ApiError::Db)?; + let Some(profile) = existing.into_iter().next() else { + return Err(ApiError::NotFound("no work profile found")); + }; + WorkProfile::delete_by_user_id(&client, auth.user_id, profile.id) + .await + .map_err(|_| ApiError::Db)?; + Ok(StatusCode::NO_CONTENT) +} + +fn to_item(p: WorkProfile) -> WorkProfileItem { + WorkProfileItem { + id: p.id.to_string(), + user_id: p.user_id.to_string(), + slug: p.slug, + headline: p.headline, + status: p.status, + work_type: p.work_type, + location: p.location, + contact: p.contact, + links: p.links, + skills: p.skills, + summary: p.summary, + } +} diff --git a/late-ssh/src/native_api/ws.rs b/late-ssh/src/native_api/ws.rs new file mode 100644 index 00000000..cc47b951 --- /dev/null +++ b/late-ssh/src/native_api/ws.rs @@ -0,0 +1,276 @@ +use axum::{ + Router, + extract::{ConnectInfo, Query, State as AxumState, WebSocketUpgrade, ws::{Message, WebSocket}}, + http::{HeaderMap, StatusCode, header::AUTHORIZATION}, + response::IntoResponse, + routing::get, +}; +use late_core::models::{ + chat_message::ChatMessage, + chat_room::ChatRoom, + chat_room_member::ChatRoomMember, + native_token::NativeToken, + user::User, +}; +use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; +use uuid::Uuid; + +use crate::app::{chat::svc::ChatEvent, vote::svc::Genre}; +use crate::state::State; + +use super::chat::{MessageItem, build_message_items}; +use super::media::{ + NowPlayingResponse, VotesResponse, build_now_playing_from_value, build_now_playing_response, + build_votes_response, build_votes_response_from_snapshot, +}; + +pub fn router() -> Router { + Router::new().route("/api/ws/native", get(ws_native_handler)) +} + +// ── Auth params ─────────────────────────────────────────────────────────────── + +#[derive(Deserialize, Default)] +struct WsNativeParams { + /// Short-lived one-time ticket from `GET /api/native/ws-ticket` (preferred). + ticket: Option, + /// Long-lived bearer token fallback for clients that cannot set headers. + token: Option, +} + +// ── Outbound message types ──────────────────────────────────────────────────── + +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum WsOut { + Init { + rooms: Vec, + online_users: Vec, + now_playing: NowPlayingResponse, + votes: VotesResponse, + messages: Vec, + }, + Message { + room_id: String, + msg: MessageItem, + }, + #[allow(dead_code)] + Presence { + event: String, + username: String, + }, + NowPlaying(NowPlayingResponse), + Votes(VotesResponse), + #[allow(dead_code)] + Ping, +} + +#[derive(Serialize)] +struct WsRoom { + id: String, + name: String, +} + +#[derive(Serialize)] +struct WsUser { + username: String, +} + +// ── Inbound message types ───────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct WsInAny { + #[serde(rename = "type")] + kind: String, + body: Option, + genre: Option, + #[allow(dead_code)] + room_id: Option, +} + +// ── Handler ─────────────────────────────────────────────────────────────────── + +async fn ws_native_handler( + ws: WebSocketUpgrade, + headers: HeaderMap, + Query(params): Query, + ConnectInfo(peer_addr): ConnectInfo, + AxumState(state): AxumState, +) -> impl IntoResponse { + let client_ip = crate::api::effective_client_ip(&headers, peer_addr, &state); + if !state.native_ws_limiter.allow(client_ip) { + return StatusCode::TOO_MANY_REQUESTS.into_response(); + } + + // Auth priority: short-lived ticket → Authorization header → token query param. + let identity: Option<(Uuid, String)> = if let Some(ticket) = params.ticket { + state.native_ws_tickets.consume(&ticket) + } else { + let raw_token = headers + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(|s| s.trim().to_owned()) + .or(params.token); + + if let Some(raw_token) = raw_token { + let Ok(client) = state.db.get().await else { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }; + NativeToken::find_user_by_token(&client, &raw_token).await.ok().flatten() + } else { + None + } + }; + + let Some((user_id, username)) = identity else { + return StatusCode::UNAUTHORIZED.into_response(); + }; + + ws.on_upgrade(move |socket| handle_native_socket(socket, user_id, username, state)) +} + +// ── Socket loop ─────────────────────────────────────────────────────────────── + +async fn handle_native_socket(mut socket: WebSocket, user_id: Uuid, _username: String, state: State) { + let Ok(client) = state.db.get().await else { return }; + let Some(room) = ChatRoom::find_general(&client).await.ok().flatten() else { return }; + let room_id = room.id; + + let messages = ChatMessage::list_recent(&client, room_id, 50).await.unwrap_or_default(); + // Seed the username cache from the initial message batch before consuming it. + let init_author_ids: Vec = messages.iter().map(|m| m.user_id).collect(); + let mut username_cache = User::list_usernames_by_ids(&client, &init_author_ids) + .await + .unwrap_or_default(); + let msg_items = build_message_items(&client, messages).await; + drop(client); + + let online = { + let users = state.active_users.lock().unwrap_or_else(|e| e.into_inner()); + users.values().map(|u| WsUser { username: u.username.clone() }).collect::>() + }; + + let init = WsOut::Init { + rooms: vec![WsRoom { id: room_id.to_string(), name: "General".to_string() }], + online_users: online, + now_playing: build_now_playing_response(&state), + votes: build_votes_response(&state), + messages: msg_items, + }; + if send_json(&mut socket, &init).await.is_err() { + return; + } + + let mut chat_rx = state.chat_service.subscribe_events(); + let mut vote_rx = state.vote_service.subscribe_state(); + let mut np_rx = state.now_playing_rx.clone(); + let mut active_room_id = room_id; + + loop { + tokio::select! { + maybe_msg = socket.recv() => { + let Some(Ok(Message::Text(text))) = maybe_msg else { break }; + let Ok(payload) = serde_json::from_str::(&text) else { continue }; + match payload.kind.as_str() { + "send" => { + if let Some(body) = payload.body.as_deref().map(str::trim).filter(|b| !b.is_empty()) { + if body.len() <= 4000 { + let slug = if active_room_id == room_id { + Some("general".to_string()) + } else { + // Look up the slug for the current room for permission checks + if let Ok(c) = state.db.get().await { + ChatRoom::get(&c, active_room_id).await.ok().flatten().and_then(|r| r.slug) + } else { + None + } + }; + state.chat_service.send_message_task( + user_id, + active_room_id, + slug, + body.to_string(), + Uuid::now_v7(), + false, + ); + } + } + } + "subscribe" => { + if let Some(new_id) = payload.room_id.as_ref().and_then(|v| v.as_str()).and_then(|s| Uuid::parse_str(s).ok()) { + if let Ok(client) = state.db.get().await { + if ChatRoomMember::is_member(&client, new_id, user_id).await.unwrap_or(false) { + active_room_id = new_id; + } + } + } + } + "vote" => { + if let Some(genre_str) = &payload.genre { + if let Ok(genre) = Genre::try_from(genre_str.as_str()) { + state.vote_service.cast_vote_task(user_id, genre); + } + } + } + "pong" => {} + _ => {} + } + } + Ok(event) = chat_rx.recv() => { + match event { + ChatEvent::MessageCreated { message, author_username, .. } + if message.room_id == active_room_id => + { + let author = if let Some(name) = author_username { + username_cache.insert(message.user_id, name.clone()); + name + } else if let Some(name) = username_cache.get(&message.user_id).cloned() { + name + } else if let Ok(c) = state.db.get().await { + let names = User::list_usernames_by_ids(&c, &[message.user_id]) + .await + .unwrap_or_default(); + let name = names.get(&message.user_id).cloned().unwrap_or_default(); + username_cache.insert(message.user_id, name.clone()); + name + } else { + String::new() + }; + let out = WsOut::Message { + room_id: active_room_id.to_string(), + msg: MessageItem { + id: message.id.to_string(), + user_id: message.user_id.to_string(), + username: author, + body: message.body.clone(), + timestamp: message.created.to_rfc3339(), + reactions: vec![], + }, + }; + if send_json(&mut socket, &out).await.is_err() { + break; + } + } + _ => {} + } + } + Ok(()) = vote_rx.changed() => { + let out = WsOut::Votes(build_votes_response_from_snapshot(&vote_rx.borrow_and_update())); + if send_json(&mut socket, &out).await.is_err() { break; } + } + Ok(()) = np_rx.changed() => { + let out = WsOut::NowPlaying(build_now_playing_from_value(&np_rx.borrow_and_update())); + if send_json(&mut socket, &out).await.is_err() { break; } + } + } + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async fn send_json(socket: &mut WebSocket, val: &T) -> Result<(), ()> { + let json = serde_json::to_string(val).map_err(|_| ())?; + socket.send(Message::Text(json.into())).await.map_err(|_| ()) +} diff --git a/late-ssh/src/state.rs b/late-ssh/src/state.rs index dfe1b81f..7e419efb 100644 --- a/late-ssh/src/state.rs +++ b/late-ssh/src/state.rs @@ -29,7 +29,7 @@ use std::{ collections::HashMap, net::IpAddr, sync::{Arc, Mutex}, - time::Instant, + time::{Duration, Instant}, }; use tokio::sync::{Semaphore, broadcast, watch}; use uuid::Uuid; @@ -53,6 +53,71 @@ pub struct ActiveUser { pub type ActiveUsers = Arc>>; +const CHALLENGE_TTL: Duration = Duration::from_secs(60); +const WS_TICKET_TTL: Duration = Duration::from_secs(30); + +/// In-memory store for short-lived auth nonces issued by `GET /api/native/challenge`. +#[derive(Clone, Default)] +pub struct NativeChallengeStore { + inner: Arc>>, +} + +impl NativeChallengeStore { + pub fn new() -> Self { + Self::default() + } + + /// Mint a new nonce, storing it with a 60-second TTL. Returns the nonce. + pub fn issue(&self, nonce: String) -> String { + let expiry = Instant::now() + CHALLENGE_TTL; + let mut map = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + map.retain(|_, exp| *exp > Instant::now()); + map.insert(nonce.clone(), expiry); + nonce + } + + /// Remove and return whether the nonce was valid (present and not expired). + pub fn consume(&self, nonce: &str) -> bool { + let mut map = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + match map.remove(nonce) { + Some(exp) => exp > Instant::now(), + None => false, + } + } +} + +/// One-time short-lived tickets for WebSocket authentication. +/// Minted by `GET /api/native/ws-ticket` (requires bearer auth), consumed on WS connect. +#[derive(Clone, Default)] +pub struct NativeWsTicketStore { + inner: Arc>>, +} + +impl NativeWsTicketStore { + pub fn new() -> Self { + Self::default() + } + + /// Mint a ticket valid for `WS_TICKET_TTL`. Returns the ticket string. + pub fn mint(&self, user_id: Uuid, username: String) -> String { + let ticket = crate::session::new_session_token(); + let expiry = Instant::now() + WS_TICKET_TTL; + let mut map = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + map.retain(|_, (_, _, exp)| *exp > Instant::now()); + map.insert(ticket.clone(), (user_id, username, expiry)); + ticket + } + + /// Consume and validate a ticket. Returns `(user_id, username)` if valid. + pub fn consume(&self, ticket: &str) -> Option<(Uuid, String)> { + let mut map = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + match map.remove(ticket) { + Some((user_id, username, exp)) if exp > Instant::now() => Some((user_id, username)), + _ => None, + } + } +} + #[derive(Clone, Debug)] pub struct ActivityEvent { pub username: String, @@ -98,5 +163,10 @@ pub struct State { pub web_chat_registry: WebChatRegistry, pub ssh_attempt_limiter: IpRateLimiter, pub ws_pair_limiter: IpRateLimiter, + pub native_challenges: NativeChallengeStore, + pub native_ws_tickets: NativeWsTicketStore, + pub native_challenge_limiter: IpRateLimiter, + pub native_token_limiter: IpRateLimiter, + pub native_ws_limiter: IpRateLimiter, pub is_draining: Arc, } diff --git a/late-ssh/tests/helpers/mod.rs b/late-ssh/tests/helpers/mod.rs index b77a4862..67845a1b 100644 --- a/late-ssh/tests/helpers/mod.rs +++ b/late-ssh/tests/helpers/mod.rs @@ -193,6 +193,11 @@ pub fn test_app_state(db: Db, config: Config) -> State { web_chat_registry: late_ssh::web::WebChatRegistry::new(), ssh_attempt_limiter, ws_pair_limiter, + native_challenges: late_ssh::state::NativeChallengeStore::new(), + native_ws_tickets: late_ssh::state::NativeWsTicketStore::new(), + native_challenge_limiter: IpRateLimiter::new(0, 60), + native_token_limiter: IpRateLimiter::new(0, 60), + native_ws_limiter: IpRateLimiter::new(0, 60), is_draining: Arc::new(std::sync::atomic::AtomicBool::new(false)), } } diff --git a/pr.md b/pr.md new file mode 100644 index 00000000..3a08df4d --- /dev/null +++ b/pr.md @@ -0,0 +1,69 @@ +# Native API hardening follow-up + +## What changed + +This PR hardens the native token + websocket path that shipped in the initial native API work. + +- Store only SHA-256 token hashes in `native_tokens` (raw bearer tokens are never persisted). +- Add token metadata (`last_used_at`, `user_agent`, `created_ip`) and update `last_used_at` atomically on successful auth. +- Add migration `046_native_token_metadata.sql` to append metadata columns and invalidate pre-hash tokens (`TRUNCATE native_tokens`). +- Add periodic cleanup task in `late-ssh` startup to purge expired native tokens every hour. +- Add per-IP rate limiting for native challenge, token issuance, and websocket connect paths. +- Add one-time short-lived websocket tickets (`GET /api/native/ws-ticket`) and support ticket-first auth in `/api/ws/native`. +- Add native logout endpoint (`DELETE /api/native/logout`) to revoke current bearer token. +- Enforce room membership checks for native history reads and websocket room subscription changes. + +## Hardening details + +### Token storage and lifecycle + +Native tokens are now write-only secrets from client perspective: + +1. Server generates raw token and returns it once. +2. Server hashes token with SHA-256 and stores only hex digest. +3. Auth lookups hash incoming token before DB query. +4. Successful auth updates `last_used_at`. +5. Expired tokens are removed by scheduled purge job. + +This reduces impact of DB leaks and improves auditability for active token usage. + +### Abuse resistance + +Native endpoints now use dedicated IP limiters: + +- challenge issuance (`/api/native/challenge`) +- token minting (`/api/native/token`) +- websocket connect (`/api/ws/native`) + +Client IP derivation reuses existing trusted-proxy logic so limits apply to real client IP when requests pass through approved proxies. + +### WebSocket auth hardening + +`/api/ws/native` now prefers ephemeral one-time tickets over long-lived bearer token query params: + +1. Authenticated client requests ticket from `GET /api/native/ws-ticket`. +2. Server mints ticket valid for 30 seconds, single-use. +3. WS connect consumes ticket; replay fails. +4. Bearer token auth remains as fallback via `Authorization` header or query param for compatibility. + +This limits token exposure in logs/URLs for clients that adopt ticket flow. + +### Authorization fixes + +- `GET /api/native/rooms/{room}/history` now returns `403` unless caller is room member. +- Native websocket `subscribe` now switches rooms only if caller is member of requested room. + +These checks close cross-room data access gaps. + +## Operational impact + +- Existing rows in `native_tokens` are intentionally invalidated by migration (`TRUNCATE native_tokens`) because old records contain unhashed raw token values that cannot match new lookup semantics. +- Clients must re-authenticate and receive fresh tokens after deploy. + +## Why + +Initial native API implementation proved feature path. This follow-up focuses on production hardening: secret-at-rest protection, abuse throttling, revocation support, reduced token leakage during websocket auth, stronger room-level authorization, and better token observability. + +## Validation + +- Ran `cargo check -p late-ssh`.