From b9a49afb1b0db84e3f1921edc9261eb6f96a0eb8 Mon Sep 17 00:00:00 2001 From: stacknode-lambda Date: Thu, 7 May 2026 14:22:26 +0100 Subject: [PATCH 1/4] Add native API token auth and websocket routes --- Cargo.lock | 1 + .../migrations/045_create_native_tokens.sql | 9 + late-core/src/models/mod.rs | 1 + late-core/src/models/native_token.rs | 72 ++ late-ssh/Cargo.toml | 1 + late-ssh/src/api.rs | 1 + late-ssh/src/lib.rs | 1 + late-ssh/src/main.rs | 1 + late-ssh/src/native_api.rs | 792 ++++++++++++++++++ late-ssh/src/state.rs | 35 +- 10 files changed, 913 insertions(+), 1 deletion(-) create mode 100644 late-core/migrations/045_create_native_tokens.sql create mode 100644 late-core/src/models/native_token.rs create mode 100644 late-ssh/src/native_api.rs diff --git a/Cargo.lock b/Cargo.lock index 9af2edcf..7109a932 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2710,6 +2710,7 @@ dependencies = [ "dartboard-editor", "dartboard-local", "dartboard-tui", + "deadpool-postgres", "emojis", "futures-util", "getrandom 0.4.2", 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/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..30505e80 --- /dev/null +++ b/late-core/src/models/native_token.rs @@ -0,0 +1,72 @@ +use anyhow::Result; +use chrono::{DateTime, Utc}; +use tokio_postgres::{Client, Row}; +use uuid::Uuid; + +pub struct NativeToken { + pub token: String, + pub user_id: Uuid, + pub created_at: DateTime, + pub expires_at: DateTime, +} + +impl From for NativeToken { + fn from(row: Row) -> Self { + Self { + token: row.get("token"), + user_id: row.get("user_id"), + created_at: row.get("created_at"), + expires_at: row.get("expires_at"), + } + } +} + +impl NativeToken { + pub async fn create( + client: &Client, + token: &str, + user_id: Uuid, + expires_at: DateTime, + ) -> Result { + let row = client + .query_one( + "INSERT INTO native_tokens (token, user_id, expires_at) + VALUES ($1, $2, $3) + RETURNING *", + &[&token, &user_id, &expires_at], + ) + .await?; + Ok(Self::from(row)) + } + + /// Returns `(user_id, username)` if the token exists and has not expired. + pub async fn find_user_by_token( + client: &Client, + token: &str, + ) -> Result> { + let row = client + .query_opt( + "SELECT u.id, u.username + FROM native_tokens t + JOIN users u ON u.id = t.user_id + WHERE t.token = $1 AND t.expires_at > NOW()", + &[&token], + ) + .await?; + Ok(row.map(|r| (r.get("id"), r.get("username")))) + } + + pub async fn delete(client: &Client, token: &str) -> Result<()> { + client + .execute("DELETE FROM native_tokens WHERE token = $1", &[&token]) + .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..58236870 100644 --- a/late-ssh/src/api.rs +++ b/late-ssh/src/api.rs @@ -91,6 +91,7 @@ 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)) .with_state(state); 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 d6362b2a..b3896ad2 100644 --- a/late-ssh/src/main.rs +++ b/late-ssh/src/main.rs @@ -269,6 +269,7 @@ async fn main() -> anyhow::Result<()> { web_chat_registry, ssh_attempt_limiter, ws_pair_limiter, + native_challenges: late_ssh::state::NativeChallengeStore::new(), is_draining: Arc::new(std::sync::atomic::AtomicBool::new(false)), }; diff --git a/late-ssh/src/native_api.rs b/late-ssh/src/native_api.rs new file mode 100644 index 00000000..46b6f195 --- /dev/null +++ b/late-ssh/src/native_api.rs @@ -0,0 +1,792 @@ +use axum::{ + Json, Router, + extract::{ + FromRequestParts, Path, Query, State as AxumState, WebSocketUpgrade, + ws::{Message, WebSocket}, + }, + http::{StatusCode, header::AUTHORIZATION, request::Parts}, + response::IntoResponse, + routing::{get, post}, +}; +use chrono::{Duration, Utc}; +use late_core::models::{ + bonsai::Tree, + chat_message::ChatMessage, + chat_room::ChatRoom, + native_token::NativeToken, + user::User, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + app::{ + bonsai::{state::stage_for, ui::tree_ascii}, + chat::svc::ChatEvent, + vote::svc::{Genre, VoteSnapshot}, + }, + state::{ActiveUsers, State}, +}; + +// ── Token lifetime ──────────────────────────────────────────────────────────── + +const TOKEN_DAYS: i64 = 30; + +// ── Auth extractor ──────────────────────────────────────────────────────────── + +pub struct NativeAuthUser { + pub user_id: Uuid, + pub username: String, +} + +fn api_error(status: StatusCode, msg: &'static str) -> (StatusCode, Json) { + (status, Json(serde_json::json!({ "error": msg }))) +} + +impl FromRequestParts for NativeAuthUser { + type Rejection = (StatusCode, Json); + + 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_else(|| api_error(StatusCode::UNAUTHORIZED, "missing bearer token"))? + .to_owned(); + + let client = state + .db + .get() + .await + .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; + + let (user_id, username) = NativeToken::find_user_by_token(&client, &token) + .await + .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))? + .ok_or_else(|| api_error(StatusCode::UNAUTHORIZED, "invalid or expired token"))?; + + Ok(NativeAuthUser { user_id, username }) + } +} + +// ── Route builder ──────────────────────────────────────────────────────────── + +pub fn router() -> Router { + Router::new() + .route("/api/native/challenge", get(get_challenge)) + .route("/api/native/token", post(post_token)) + .route("/api/native/me", get(get_me)) + .route("/api/native/rooms", get(get_rooms)) + .route("/api/native/rooms/{room}/history", get(get_room_history)) + .route("/api/native/users/online", get(get_online_users)) + .route("/api/native/now-playing", get(get_now_playing)) + .route("/api/native/status", get(get_native_status)) + .route("/api/native/vote", post(post_vote)) + .route("/api/native/bonsai", get(get_bonsai)) + .route("/api/native/bonsai/water", post(post_bonsai_water)) + .route("/api/ws/native", get(ws_native_handler)) +} + +// ── Challenge / token ───────────────────────────────────────────────────────── + +#[derive(Serialize)] +struct ChallengeResponse { + nonce: String, + expires_in: u32, +} + +async fn get_challenge(AxumState(state): AxumState) -> Json { + let nonce = crate::session::new_session_token(); + state.native_challenges.issue(nonce.clone()); + Json(ChallengeResponse { nonce, expires_in: 60 }) +} + +#[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( + AxumState(state): AxumState, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + if !state.native_challenges.consume(&body.nonce) { + return Err(api_error(StatusCode::UNAUTHORIZED, "nonce invalid or expired")); + } + + // Verify the SSH signature before touching the DB. + verify_ssh_sig(&body.public_key, &body.public_key_fingerprint, &body.nonce, &body.signature_pem) + .map_err(|_| api_error(StatusCode::UNAUTHORIZED, "signature verification failed"))?; + + let client = state + .db + .get() + .await + .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; + + let user = User::find_by_fingerprint(&client, &body.public_key_fingerprint) + .await + .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))? + .ok_or_else(|| api_error(StatusCode::UNAUTHORIZED, "no user with that fingerprint"))?; + + let token = crate::session::new_session_token(); + let expires_at = Utc::now() + Duration::days(TOKEN_DAYS); + NativeToken::create(&client, &token, user.id, expires_at) + .await + .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "failed to create token"))?; + + Ok(Json(TokenResponse { token, expires_at: expires_at.to_rfc3339() })) +} + +// ── REST handlers ───────────────────────────────────────────────────────────── + +#[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 RoomInfo { + id: String, + name: String, + slug: String, + member_count: i64, +} + +async fn get_rooms( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result>, (StatusCode, Json)> { + let _ = auth; + let client = state + .db + .get() + .await + .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; + + let Some(room) = ChatRoom::find_general(&client) + .await + .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))? + else { + return Ok(Json(vec![])); + }; + + let count = online_user_count(&state.active_users) as i64; + Ok(Json(vec![RoomInfo { + id: room.id.to_string(), + name: "General".to_string(), + slug: room.slug.unwrap_or_else(|| "general".to_string()), + member_count: count, + }])) +} + +#[derive(Deserialize)] +struct HistoryParams { + limit: Option, +} + +#[derive(Serialize)] +struct MessageItem { + id: String, + user_id: String, + username: String, + body: String, + timestamp: String, + reactions: Vec, +} + +#[derive(Serialize)] +struct ReactionItem { + emoji: String, + count: i64, +} + +async fn get_room_history( + auth: NativeAuthUser, + Path(room): Path, + Query(params): Query, + AxumState(state): AxumState, +) -> Result>, (StatusCode, Json)> { + let _ = auth; + let limit = params.limit.unwrap_or(50).min(200); + let client = state + .db + .get() + .await + .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; + + let room_id = resolve_room_id(&client, &room).await?; + let messages = ChatMessage::list_recent(&client, room_id, limit) + .await + .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; + + 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 = late_core::models::chat_message_reaction::ChatMessageReaction::list_summaries_for_messages(&client, &message_ids) + .await + .unwrap_or_default(); + + let items: Vec = 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(); + + Ok(Json(items)) +} + +#[derive(Serialize)] +struct OnlineUser { + user_id: String, + username: String, +} + +async fn get_online_users( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Json> { + let _ = auth; + 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 NowPlayingResponse { + track: String, + artist: String, + album: String, + progress_sec: u64, + duration_sec: u64, + volume_pct: u32, +} + +async fn get_now_playing( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Json { + let _ = auth; + Json(build_now_playing_response(&state)) +} + +#[derive(Serialize)] +struct NativeStatusResponse { + connected: bool, + online_users: usize, + now_playing: NowPlayingResponse, + votes: VotesResponse, +} + +async fn get_native_status( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Json { + let _ = auth; + let online_users = online_user_count(&state.active_users); + let now_playing = build_now_playing_response(&state); + let votes = build_votes_response(&state); + Json(NativeStatusResponse { + connected: true, + online_users, + now_playing, + votes, + }) +} + +#[derive(Deserialize)] +struct VoteBody { + genre: String, +} + +async fn post_vote( + auth: NativeAuthUser, + AxumState(state): AxumState, + Json(body): Json, +) -> Result, (StatusCode, Json)> { + let genre = Genre::try_from(body.genre.as_str()) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "unknown genre"))?; + state.vote_service.cast_vote_task(auth.user_id, genre); + Ok(Json(build_votes_response(&state))) +} + +#[derive(Serialize)] +struct BonsaiResponse { + growth_points: i32, + is_alive: bool, + last_watered: Option, + /// ASCII art lines for the bonsai at its current growth stage. + art: Vec, +} + +async fn get_bonsai( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result, (StatusCode, Json)> { + let client = state + .db + .get() + .await + .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; + + let tree = Tree::ensure(&client, auth.user_id, rand_seed()) + .await + .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; + + let art = tree_ascii(stage_for(tree.is_alive, tree.growth_points), tree.seed, false); + Ok(Json(BonsaiResponse { + growth_points: tree.growth_points, + is_alive: tree.is_alive, + last_watered: tree.last_watered.map(|d| d.to_string()), + art, + })) +} + +async fn post_bonsai_water( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result, (StatusCode, Json)> { + state.bonsai_service.water_task(auth.user_id, false); + + let client = state + .db + .get() + .await + .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; + + let tree = Tree::ensure(&client, auth.user_id, rand_seed()) + .await + .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; + + let art = tree_ascii(stage_for(tree.is_alive, tree.growth_points), tree.seed, false); + Ok(Json(BonsaiResponse { + growth_points: tree.growth_points, + is_alive: tree.is_alive, + last_watered: tree.last_watered.map(|d| d.to_string()), + art, + })) +} + +// ── WebSocket ───────────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct WsNativeParams { + token: String, +} + +async fn ws_native_handler( + ws: WebSocketUpgrade, + Query(params): Query, + AxumState(state): AxumState, +) -> impl IntoResponse { + let Ok(client) = state.db.get().await else { + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + }; + let Ok(Some((user_id, username))) = + NativeToken::find_user_by_token(&client, ¶ms.token).await + else { + return StatusCode::UNAUTHORIZED.into_response(); + }; + drop(client); + ws.on_upgrade(move |socket| handle_native_socket(socket, user_id, username, state)) +} + +// ── Outbound WS types ───────────────────────────────────────────────────────── + +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +#[allow(dead_code)] +enum WsOut { + Init { + rooms: Vec, + online_users: Vec, + now_playing: NowPlayingResponse, + votes: VotesResponse, + messages: Vec, + }, + Message { + room_id: String, + msg: MessageItem, + }, + Presence { + event: String, + username: String, + }, + NowPlaying(NowPlayingResponse), + Votes(VotesResponse), + Ping, +} + +#[derive(Serialize)] +struct WsRoom { + id: String, + name: String, +} + +#[derive(Serialize)] +struct WsUser { + username: String, +} + +// ── Inbound WS types ───────────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct WsInAny { + #[serde(rename = "type")] + kind: String, + body: Option, + genre: Option, + #[allow(dead_code)] + room_id: Option, +} + +// ── 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(); + let author_ids: Vec = messages.iter().map(|m| m.user_id).collect(); + let mut usernames = User::list_usernames_by_ids(&client, &author_ids) + .await + .unwrap_or_default(); + let msg_ids: Vec = messages.iter().map(|m| m.id).collect(); + let reactions_map = + late_core::models::chat_message_reaction::ChatMessageReaction::list_summaries_for_messages( + &client, &msg_ids, + ) + .await + .unwrap_or_default(); + drop(client); + + let msg_items: Vec = 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(); + + 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()) { + // Resolve the slug for DM/non-general rooms if needed. + let slug = if active_room_id == room_id { Some("general".to_string()) } 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()) { + 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 { + usernames.insert(message.user_id, name.clone()); + name + } else if let Some(name) = usernames.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(); + usernames.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(|_| ()) +} + +/// Resolve a room id from a path segment that is either a UUID or "general". +async fn resolve_room_id( + client: &deadpool_postgres::Client, + room: &str, +) -> Result)> { + if room == "general" { + return ChatRoom::find_general(client) + .await + .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))? + .map(|r| r.id) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "room not found")); + } + Uuid::parse_str(room).map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid room id")) +} + +fn online_user_count(active_users: &ActiveUsers) -> usize { + active_users.lock().unwrap_or_else(|e| e.into_inner()).len() +} + +#[derive(Serialize)] +pub struct VotesResponse { + lofi: i64, + ambient: i64, + classic: i64, + jazz: i64, + next_vote_at: String, +} + +fn build_votes_response(state: &State) -> VotesResponse { + build_votes_response_from_snapshot(&state.vote_service.subscribe_state().borrow().clone()) +} + +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, + } +} + +fn build_now_playing_response(state: &State) -> NowPlayingResponse { + build_now_playing_from_value(&state.now_playing_rx.borrow().clone()) +} + +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, + }, + } +} + +fn reaction_emoji(kind: i16) -> &'static str { + match kind { + 1 => "👍", + 2 => "🧡", + 3 => "😂", + 4 => "👀", + 5 => "🔥", + 6 => "🙌", + 7 => "🚀", + 8 => "🤔", + _ => "?", + } +} + +fn rand_seed() -> i64 { + use rand_core::{OsRng, RngCore}; + OsRng.next_u64() as i64 +} + +/// Verify an SSH signature produced by `ssh-keygen -Y sign -n late.sh`. +/// +/// Checks that: +/// 1. The provided 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/state.rs b/late-ssh/src/state.rs index dfe1b81f..cacf75d3 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,38 @@ pub struct ActiveUser { pub type ActiveUsers = Arc>>; +const CHALLENGE_TTL: Duration = Duration::from_secs(60); + +/// 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, + } + } +} + #[derive(Clone, Debug)] pub struct ActivityEvent { pub username: String, @@ -98,5 +130,6 @@ pub struct State { pub web_chat_registry: WebChatRegistry, pub ssh_attempt_limiter: IpRateLimiter, pub ws_pair_limiter: IpRateLimiter, + pub native_challenges: NativeChallengeStore, pub is_draining: Arc, } From 1409d0f67347c0cc7c088c6f1d4b07eb797f40a8 Mon Sep 17 00:00:00 2001 From: stacknode-lambda Date: Thu, 7 May 2026 14:29:53 +0100 Subject: [PATCH 2/4] pr.md --- pr.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 pr.md diff --git a/pr.md b/pr.md new file mode 100644 index 00000000..2b7c8e04 --- /dev/null +++ b/pr.md @@ -0,0 +1,42 @@ +# Native API token auth and websocket routes + +## What changed + +This change adds a native-client API surface to `late-ssh` and a matching token model in `late-core`. + +- Added a `native_tokens` table and `NativeToken` model for persistent bearer tokens with expiry. +- Added an in-memory `NativeChallengeStore` for short-lived nonces issued by `GET /api/native/challenge`. +- Added `late-ssh/src/native_api.rs` and mounted its routes under `/api/native/*` plus `/api/ws/native`. +- Wired the new module and shared state into the existing API server and application bootstrap. +- Added `deadpool-postgres` where needed for the new DB-backed native endpoints. + +## Auth flow + +The native auth flow is challenge-based: + +1. A client requests a nonce from `GET /api/native/challenge`. +2. The client signs that nonce with its SSH key. +3. `POST /api/native/token` verifies the SSH signature and fingerprint. +4. If the fingerprint maps to a known user, the server issues a time-limited bearer token and stores it in `native_tokens`. + +This keeps the native API aligned with the existing SSH identity model instead of introducing a separate password-based auth path. + +## API surface + +The new native API exposes endpoints for: + +- current user identity +- room listing and room history +- online user presence +- now playing and voting status +- submitting votes +- bonsai state and watering +- websocket updates for chat, votes, and now-playing changes + +## Why + +The repo already had the core app state and websocket/event infrastructure, but it did not expose a dedicated native-client API with a reusable bearer-token auth flow. This change fills that gap so a native app can authenticate with an SSH-backed identity and consume the same real-time app data without going through the browser-oriented routes. + +## Validation + +- Ran `cargo check -p late-ssh` From d8aa3ec53cdf000619fe6c47b172b950e2f1ce4c Mon Sep 17 00:00:00 2001 From: stacknode-lambda Date: Fri, 8 May 2026 12:47:02 +0100 Subject: [PATCH 3/4] Hardening --- Cargo.lock | 1 + Cargo.toml | 1 + late-core/Cargo.toml | 1 + .../migrations/046_native_token_metadata.sql | 8 + late-core/src/models/native_token.rs | 55 +++++-- late-ssh/src/api.rs | 2 +- late-ssh/src/main.rs | 25 ++- late-ssh/src/native_api.rs | 146 +++++++++++++++--- late-ssh/src/state.rs | 37 +++++ late-ssh/tests/helpers/mod.rs | 5 + pr.md | 77 ++++++--- 11 files changed, 293 insertions(+), 65 deletions(-) create mode 100644 late-core/migrations/046_native_token_metadata.sql diff --git a/Cargo.lock b/Cargo.lock index 7109a932..72603407 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2684,6 +2684,7 @@ dependencies = [ "rustfft", "serde", "serde_json", + "sha2 0.10.9", "symphonia", "testcontainers", "tokio", 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/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/native_token.rs b/late-core/src/models/native_token.rs index 30505e80..49c16a84 100644 --- a/late-core/src/models/native_token.rs +++ b/late-core/src/models/native_token.rs @@ -1,64 +1,91 @@ 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 { - pub token: String, + /// 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: row.get("token"), + 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, - token: &str, + 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) - VALUES ($1, $2, $3) + "INSERT INTO native_tokens (token, user_id, expires_at, user_agent, created_ip) + VALUES ($1, $2, $3, $4, $5) RETURNING *", - &[&token, &user_id, &expires_at], + &[&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, - token: &str, + raw_token: &str, ) -> Result> { + let token_hash = hash_token(raw_token); let row = client .query_opt( - "SELECT u.id, u.username - FROM native_tokens t - JOIN users u ON u.id = t.user_id - WHERE t.token = $1 AND t.expires_at > NOW()", - &[&token], + "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, token: &str) -> Result<()> { + 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]) + .execute("DELETE FROM native_tokens WHERE token = $1", &[&token_hash]) .await?; Ok(()) } diff --git a/late-ssh/src/api.rs b/late-ssh/src/api.rs index 58236870..16cd7036 100644 --- a/late-ssh/src/api.rs +++ b/late-ssh/src/api.rs @@ -334,7 +334,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/main.rs b/late-ssh/src/main.rs index 5116ab49..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::{ @@ -272,6 +276,10 @@ async fn main() -> anyhow::Result<()> { 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)), }; @@ -293,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.rs b/late-ssh/src/native_api.rs index 46b6f195..af1b81ca 100644 --- a/late-ssh/src/native_api.rs +++ b/late-ssh/src/native_api.rs @@ -1,22 +1,24 @@ use axum::{ Json, Router, extract::{ - FromRequestParts, Path, Query, State as AxumState, WebSocketUpgrade, + ConnectInfo, FromRequestParts, Path, Query, State as AxumState, WebSocketUpgrade, ws::{Message, WebSocket}, }, - http::{StatusCode, header::AUTHORIZATION, request::Parts}, + http::{HeaderMap, StatusCode, header::AUTHORIZATION, request::Parts}, response::IntoResponse, - routing::{get, post}, + routing::{delete, get, post}, }; use chrono::{Duration, Utc}; use late_core::models::{ bonsai::Tree, 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::{ @@ -37,6 +39,7 @@ const TOKEN_DAYS: i64 = 30; pub struct NativeAuthUser { pub user_id: Uuid, pub username: String, + pub raw_token: String, } fn api_error(status: StatusCode, msg: &'static str) -> (StatusCode, Json) { @@ -67,7 +70,7 @@ impl FromRequestParts for NativeAuthUser { .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))? .ok_or_else(|| api_error(StatusCode::UNAUTHORIZED, "invalid or expired token"))?; - Ok(NativeAuthUser { user_id, username }) + Ok(NativeAuthUser { user_id, username, raw_token: token }) } } @@ -77,6 +80,8 @@ 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)) .route("/api/native/me", get(get_me)) .route("/api/native/rooms", get(get_rooms)) .route("/api/native/rooms/{room}/history", get(get_room_history)) @@ -97,10 +102,18 @@ struct ChallengeResponse { expires_in: u32, } -async fn get_challenge(AxumState(state): AxumState) -> Json { +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 api_error(StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded").into_response(); + } let nonce = crate::session::new_session_token(); state.native_challenges.issue(nonce.clone()); - Json(ChallengeResponse { nonce, expires_in: 60 }) + Json(ChallengeResponse { nonce, expires_in: 60 }).into_response() } #[derive(Deserialize)] @@ -122,14 +135,20 @@ struct TokenResponse { } async fn post_token( + ConnectInfo(peer_addr): ConnectInfo, + headers: HeaderMap, AxumState(state): AxumState, Json(body): Json, ) -> Result, (StatusCode, Json)> { + let client_ip = crate::api::effective_client_ip(&headers, peer_addr, &state); + if !state.native_token_limiter.allow(client_ip) { + return Err(api_error(StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded")); + } + if !state.native_challenges.consume(&body.nonce) { return Err(api_error(StatusCode::UNAUTHORIZED, "nonce invalid or expired")); } - // Verify the SSH signature before touching the DB. verify_ssh_sig(&body.public_key, &body.public_key_fingerprint, &body.nonce, &body.signature_pem) .map_err(|_| api_error(StatusCode::UNAUTHORIZED, "signature verification failed"))?; @@ -144,13 +163,26 @@ async fn post_token( .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))? .ok_or_else(|| api_error(StatusCode::UNAUTHORIZED, "no user with that fingerprint"))?; - let token = crate::session::new_session_token(); + let raw_token = crate::session::new_session_token(); let expires_at = Utc::now() + Duration::days(TOKEN_DAYS); - NativeToken::create(&client, &token, user.id, expires_at) - .await - .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "failed to create token"))?; - - Ok(Json(TokenResponse { token, expires_at: expires_at.to_rfc3339() })) + let user_agent = headers + .get(axum::http::header::USER_AGENT) + .and_then(|v| v.to_str().ok()) + .map(|s| s.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(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "failed to create token"))?; + + Ok(Json(TokenResponse { token: raw_token, expires_at: expires_at.to_rfc3339() })) } // ── REST handlers ───────────────────────────────────────────────────────────── @@ -168,6 +200,35 @@ async fn get_me(auth: NativeAuthUser) -> Json { }) } +#[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 }) +} + +async fn delete_token( + auth: NativeAuthUser, + AxumState(state): AxumState, +) -> Result)> { + let client = state + .db + .get() + .await + .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; + NativeToken::delete(&client, &auth.raw_token) + .await + .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; + Ok(StatusCode::NO_CONTENT) +} + #[derive(Serialize)] struct RoomInfo { id: String, @@ -230,7 +291,6 @@ async fn get_room_history( Query(params): Query, AxumState(state): AxumState, ) -> Result>, (StatusCode, Json)> { - let _ = auth; let limit = params.limit.unwrap_or(50).min(200); let client = state .db @@ -239,6 +299,14 @@ async fn get_room_history( .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; let room_id = resolve_room_id(&client, &room).await?; + + let is_member = ChatRoomMember::is_member(&client, room_id, auth.user_id) + .await + .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; + if !is_member { + return Err(api_error(StatusCode::FORBIDDEN, "not a member of this room")); + } + let messages = ChatMessage::list_recent(&client, room_id, limit) .await .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; @@ -418,25 +486,52 @@ async fn post_bonsai_water( // ── WebSocket ───────────────────────────────────────────────────────────────── -#[derive(Deserialize)] +#[derive(Deserialize, Default)] struct WsNativeParams { - token: String, + /// Short-lived one-time ticket from `GET /api/native/ws-ticket` (preferred). + ticket: Option, + /// Long-lived bearer token fallback for clients that cannot use tickets. + token: Option, } async fn ws_native_handler( ws: WebSocketUpgrade, + headers: HeaderMap, Query(params): Query, + ConnectInfo(peer_addr): ConnectInfo, AxumState(state): AxumState, ) -> impl IntoResponse { - let Ok(client) = state.db.get().await else { - return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + 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 → raw token query param. + let identity: Option<(uuid::Uuid, String)> = if let Some(ticket) = params.ticket { + state.native_ws_tickets.consume(&ticket) + } else { + // Header or raw token — both require a DB lookup. + 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 Ok(Some((user_id, username))) = - NativeToken::find_user_by_token(&client, ¶ms.token).await - else { + + let Some((user_id, username)) = identity else { return StatusCode::UNAUTHORIZED.into_response(); }; - drop(client); + ws.on_upgrade(move |socket| handle_native_socket(socket, user_id, username, state)) } @@ -573,7 +668,6 @@ async fn handle_native_socket( match payload.kind.as_str() { "send" => { if let Some(body) = payload.body.as_deref().map(str::trim).filter(|b| !b.is_empty()) { - // Resolve the slug for DM/non-general rooms if needed. let slug = if active_room_id == room_id { Some("general".to_string()) } else { None }; state.chat_service.send_message_task( user_id, @@ -587,7 +681,11 @@ async fn handle_native_socket( } "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()) { - active_room_id = new_id; + 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" => { diff --git a/late-ssh/src/state.rs b/late-ssh/src/state.rs index cacf75d3..7e419efb 100644 --- a/late-ssh/src/state.rs +++ b/late-ssh/src/state.rs @@ -54,6 +54,7 @@ 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)] @@ -85,6 +86,38 @@ impl NativeChallengeStore { } } +/// 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, @@ -131,5 +164,9 @@ pub struct State { 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 index 2b7c8e04..3a08df4d 100644 --- a/pr.md +++ b/pr.md @@ -1,42 +1,69 @@ -# Native API token auth and websocket routes +# Native API hardening follow-up ## What changed -This change adds a native-client API surface to `late-ssh` and a matching token model in `late-core`. +This PR hardens the native token + websocket path that shipped in the initial native API work. -- Added a `native_tokens` table and `NativeToken` model for persistent bearer tokens with expiry. -- Added an in-memory `NativeChallengeStore` for short-lived nonces issued by `GET /api/native/challenge`. -- Added `late-ssh/src/native_api.rs` and mounted its routes under `/api/native/*` plus `/api/ws/native`. -- Wired the new module and shared state into the existing API server and application bootstrap. -- Added `deadpool-postgres` where needed for the new DB-backed native endpoints. +- 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. -## Auth flow +## Hardening details -The native auth flow is challenge-based: +### Token storage and lifecycle -1. A client requests a nonce from `GET /api/native/challenge`. -2. The client signs that nonce with its SSH key. -3. `POST /api/native/token` verifies the SSH signature and fingerprint. -4. If the fingerprint maps to a known user, the server issues a time-limited bearer token and stores it in `native_tokens`. +Native tokens are now write-only secrets from client perspective: -This keeps the native API aligned with the existing SSH identity model instead of introducing a separate password-based auth path. +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. -## API surface +This reduces impact of DB leaks and improves auditability for active token usage. -The new native API exposes endpoints for: +### Abuse resistance -- current user identity -- room listing and room history -- online user presence -- now playing and voting status -- submitting votes -- bonsai state and watering -- websocket updates for chat, votes, and now-playing changes +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 -The repo already had the core app state and websocket/event infrastructure, but it did not expose a dedicated native-client API with a reusable bearer-token auth flow. This change fills that gap so a native app can authenticate with an SSH-backed identity and consume the same real-time app data without going through the browser-oriented routes. +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` +- Ran `cargo check -p late-ssh`. From 80b85390b41ca8b83857dd4e6fa5076757974e25 Mon Sep 17 00:00:00 2001 From: stacknode-lambda Date: Mon, 11 May 2026 02:00:43 +0100 Subject: [PATCH 4/4] refactor(late-ssh): native_api module layout Co-authored-by: Cursor --- late-ssh/src/api.rs | 3 +- late-ssh/src/native_api.rs | 890 ----------------------- late-ssh/src/native_api/artboard.rs | 45 ++ late-ssh/src/native_api/articles.rs | 53 ++ late-ssh/src/native_api/auth.rs | 175 +++++ late-ssh/src/native_api/bonsai.rs | 65 ++ late-ssh/src/native_api/chat.rs | 413 +++++++++++ late-ssh/src/native_api/chips.rs | 36 + late-ssh/src/native_api/games.rs | 701 ++++++++++++++++++ late-ssh/src/native_api/media.rs | 136 ++++ late-ssh/src/native_api/mod.rs | 111 +++ late-ssh/src/native_api/notifications.rs | 85 +++ late-ssh/src/native_api/profile.rs | 201 +++++ late-ssh/src/native_api/rss.rs | 178 +++++ late-ssh/src/native_api/showcase.rs | 176 +++++ late-ssh/src/native_api/users.rs | 91 +++ late-ssh/src/native_api/work_profiles.rs | 181 +++++ late-ssh/src/native_api/ws.rs | 276 +++++++ 18 files changed, 2925 insertions(+), 891 deletions(-) delete mode 100644 late-ssh/src/native_api.rs create mode 100644 late-ssh/src/native_api/artboard.rs create mode 100644 late-ssh/src/native_api/articles.rs create mode 100644 late-ssh/src/native_api/auth.rs create mode 100644 late-ssh/src/native_api/bonsai.rs create mode 100644 late-ssh/src/native_api/chat.rs create mode 100644 late-ssh/src/native_api/chips.rs create mode 100644 late-ssh/src/native_api/games.rs create mode 100644 late-ssh/src/native_api/media.rs create mode 100644 late-ssh/src/native_api/mod.rs create mode 100644 late-ssh/src/native_api/notifications.rs create mode 100644 late-ssh/src/native_api/profile.rs create mode 100644 late-ssh/src/native_api/rss.rs create mode 100644 late-ssh/src/native_api/showcase.rs create mode 100644 late-ssh/src/native_api/users.rs create mode 100644 late-ssh/src/native_api/work_profiles.rs create mode 100644 late-ssh/src/native_api/ws.rs diff --git a/late-ssh/src/api.rs b/late-ssh/src/api.rs index 16cd7036..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, @@ -94,6 +94,7 @@ pub async fn run_api_server_with_listener( .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(); diff --git a/late-ssh/src/native_api.rs b/late-ssh/src/native_api.rs deleted file mode 100644 index af1b81ca..00000000 --- a/late-ssh/src/native_api.rs +++ /dev/null @@ -1,890 +0,0 @@ -use axum::{ - Json, Router, - extract::{ - ConnectInfo, FromRequestParts, Path, Query, State as AxumState, WebSocketUpgrade, - ws::{Message, WebSocket}, - }, - http::{HeaderMap, StatusCode, header::AUTHORIZATION, request::Parts}, - response::IntoResponse, - routing::{delete, get, post}, -}; -use chrono::{Duration, Utc}; -use late_core::models::{ - bonsai::Tree, - 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::{ - bonsai::{state::stage_for, ui::tree_ascii}, - chat::svc::ChatEvent, - vote::svc::{Genre, VoteSnapshot}, - }, - state::{ActiveUsers, State}, -}; - -// ── Token lifetime ──────────────────────────────────────────────────────────── - -const TOKEN_DAYS: i64 = 30; - -// ── Auth extractor ──────────────────────────────────────────────────────────── - -pub struct NativeAuthUser { - pub user_id: Uuid, - pub username: String, - pub raw_token: String, -} - -fn api_error(status: StatusCode, msg: &'static str) -> (StatusCode, Json) { - (status, Json(serde_json::json!({ "error": msg }))) -} - -impl FromRequestParts for NativeAuthUser { - type Rejection = (StatusCode, Json); - - 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_else(|| api_error(StatusCode::UNAUTHORIZED, "missing bearer token"))? - .to_owned(); - - let client = state - .db - .get() - .await - .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; - - let (user_id, username) = NativeToken::find_user_by_token(&client, &token) - .await - .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))? - .ok_or_else(|| api_error(StatusCode::UNAUTHORIZED, "invalid or expired token"))?; - - Ok(NativeAuthUser { user_id, username, raw_token: token }) - } -} - -// ── Route builder ──────────────────────────────────────────────────────────── - -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)) - .route("/api/native/me", get(get_me)) - .route("/api/native/rooms", get(get_rooms)) - .route("/api/native/rooms/{room}/history", get(get_room_history)) - .route("/api/native/users/online", get(get_online_users)) - .route("/api/native/now-playing", get(get_now_playing)) - .route("/api/native/status", get(get_native_status)) - .route("/api/native/vote", post(post_vote)) - .route("/api/native/bonsai", get(get_bonsai)) - .route("/api/native/bonsai/water", post(post_bonsai_water)) - .route("/api/ws/native", get(ws_native_handler)) -} - -// ── Challenge / token ───────────────────────────────────────────────────────── - -#[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 api_error(StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded").into_response(); - } - let nonce = crate::session::new_session_token(); - state.native_challenges.issue(nonce.clone()); - Json(ChallengeResponse { nonce, expires_in: 60 }).into_response() -} - -#[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, (StatusCode, Json)> { - let client_ip = crate::api::effective_client_ip(&headers, peer_addr, &state); - if !state.native_token_limiter.allow(client_ip) { - return Err(api_error(StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded")); - } - - if !state.native_challenges.consume(&body.nonce) { - return Err(api_error(StatusCode::UNAUTHORIZED, "nonce invalid or expired")); - } - - verify_ssh_sig(&body.public_key, &body.public_key_fingerprint, &body.nonce, &body.signature_pem) - .map_err(|_| api_error(StatusCode::UNAUTHORIZED, "signature verification failed"))?; - - let client = state - .db - .get() - .await - .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; - - let user = User::find_by_fingerprint(&client, &body.public_key_fingerprint) - .await - .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))? - .ok_or_else(|| api_error(StatusCode::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(|s| s.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(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "failed to create token"))?; - - Ok(Json(TokenResponse { token: raw_token, expires_at: expires_at.to_rfc3339() })) -} - -// ── REST handlers ───────────────────────────────────────────────────────────── - -#[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 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 }) -} - -async fn delete_token( - auth: NativeAuthUser, - AxumState(state): AxumState, -) -> Result)> { - let client = state - .db - .get() - .await - .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; - NativeToken::delete(&client, &auth.raw_token) - .await - .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; - Ok(StatusCode::NO_CONTENT) -} - -#[derive(Serialize)] -struct RoomInfo { - id: String, - name: String, - slug: String, - member_count: i64, -} - -async fn get_rooms( - auth: NativeAuthUser, - AxumState(state): AxumState, -) -> Result>, (StatusCode, Json)> { - let _ = auth; - let client = state - .db - .get() - .await - .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; - - let Some(room) = ChatRoom::find_general(&client) - .await - .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))? - else { - return Ok(Json(vec![])); - }; - - let count = online_user_count(&state.active_users) as i64; - Ok(Json(vec![RoomInfo { - id: room.id.to_string(), - name: "General".to_string(), - slug: room.slug.unwrap_or_else(|| "general".to_string()), - member_count: count, - }])) -} - -#[derive(Deserialize)] -struct HistoryParams { - limit: Option, -} - -#[derive(Serialize)] -struct MessageItem { - id: String, - user_id: String, - username: String, - body: String, - timestamp: String, - reactions: Vec, -} - -#[derive(Serialize)] -struct ReactionItem { - emoji: String, - count: i64, -} - -async fn get_room_history( - auth: NativeAuthUser, - Path(room): Path, - Query(params): Query, - AxumState(state): AxumState, -) -> Result>, (StatusCode, Json)> { - let limit = params.limit.unwrap_or(50).min(200); - let client = state - .db - .get() - .await - .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; - - let room_id = resolve_room_id(&client, &room).await?; - - let is_member = ChatRoomMember::is_member(&client, room_id, auth.user_id) - .await - .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; - if !is_member { - return Err(api_error(StatusCode::FORBIDDEN, "not a member of this room")); - } - - let messages = ChatMessage::list_recent(&client, room_id, limit) - .await - .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; - - 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 = late_core::models::chat_message_reaction::ChatMessageReaction::list_summaries_for_messages(&client, &message_ids) - .await - .unwrap_or_default(); - - let items: Vec = 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(); - - Ok(Json(items)) -} - -#[derive(Serialize)] -struct OnlineUser { - user_id: String, - username: String, -} - -async fn get_online_users( - auth: NativeAuthUser, - AxumState(state): AxumState, -) -> Json> { - let _ = auth; - 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 NowPlayingResponse { - track: String, - artist: String, - album: String, - progress_sec: u64, - duration_sec: u64, - volume_pct: u32, -} - -async fn get_now_playing( - auth: NativeAuthUser, - AxumState(state): AxumState, -) -> Json { - let _ = auth; - Json(build_now_playing_response(&state)) -} - -#[derive(Serialize)] -struct NativeStatusResponse { - connected: bool, - online_users: usize, - now_playing: NowPlayingResponse, - votes: VotesResponse, -} - -async fn get_native_status( - auth: NativeAuthUser, - AxumState(state): AxumState, -) -> Json { - let _ = auth; - let online_users = online_user_count(&state.active_users); - let now_playing = build_now_playing_response(&state); - let votes = build_votes_response(&state); - Json(NativeStatusResponse { - connected: true, - online_users, - now_playing, - votes, - }) -} - -#[derive(Deserialize)] -struct VoteBody { - genre: String, -} - -async fn post_vote( - auth: NativeAuthUser, - AxumState(state): AxumState, - Json(body): Json, -) -> Result, (StatusCode, Json)> { - let genre = Genre::try_from(body.genre.as_str()) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "unknown genre"))?; - state.vote_service.cast_vote_task(auth.user_id, genre); - Ok(Json(build_votes_response(&state))) -} - -#[derive(Serialize)] -struct BonsaiResponse { - growth_points: i32, - is_alive: bool, - last_watered: Option, - /// ASCII art lines for the bonsai at its current growth stage. - art: Vec, -} - -async fn get_bonsai( - auth: NativeAuthUser, - AxumState(state): AxumState, -) -> Result, (StatusCode, Json)> { - let client = state - .db - .get() - .await - .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; - - let tree = Tree::ensure(&client, auth.user_id, rand_seed()) - .await - .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; - - let art = tree_ascii(stage_for(tree.is_alive, tree.growth_points), tree.seed, false); - Ok(Json(BonsaiResponse { - growth_points: tree.growth_points, - is_alive: tree.is_alive, - last_watered: tree.last_watered.map(|d| d.to_string()), - art, - })) -} - -async fn post_bonsai_water( - auth: NativeAuthUser, - AxumState(state): AxumState, -) -> Result, (StatusCode, Json)> { - state.bonsai_service.water_task(auth.user_id, false); - - let client = state - .db - .get() - .await - .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; - - let tree = Tree::ensure(&client, auth.user_id, rand_seed()) - .await - .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))?; - - let art = tree_ascii(stage_for(tree.is_alive, tree.growth_points), tree.seed, false); - Ok(Json(BonsaiResponse { - growth_points: tree.growth_points, - is_alive: tree.is_alive, - last_watered: tree.last_watered.map(|d| d.to_string()), - art, - })) -} - -// ── WebSocket ───────────────────────────────────────────────────────────────── - -#[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 use tickets. - token: Option, -} - -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 → raw token query param. - let identity: Option<(uuid::Uuid, String)> = if let Some(ticket) = params.ticket { - state.native_ws_tickets.consume(&ticket) - } else { - // Header or raw token — both require a DB lookup. - 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)) -} - -// ── Outbound WS types ───────────────────────────────────────────────────────── - -#[derive(Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -#[allow(dead_code)] -enum WsOut { - Init { - rooms: Vec, - online_users: Vec, - now_playing: NowPlayingResponse, - votes: VotesResponse, - messages: Vec, - }, - Message { - room_id: String, - msg: MessageItem, - }, - Presence { - event: String, - username: String, - }, - NowPlaying(NowPlayingResponse), - Votes(VotesResponse), - Ping, -} - -#[derive(Serialize)] -struct WsRoom { - id: String, - name: String, -} - -#[derive(Serialize)] -struct WsUser { - username: String, -} - -// ── Inbound WS types ───────────────────────────────────────────────────────── - -#[derive(Deserialize)] -struct WsInAny { - #[serde(rename = "type")] - kind: String, - body: Option, - genre: Option, - #[allow(dead_code)] - room_id: Option, -} - -// ── 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(); - let author_ids: Vec = messages.iter().map(|m| m.user_id).collect(); - let mut usernames = User::list_usernames_by_ids(&client, &author_ids) - .await - .unwrap_or_default(); - let msg_ids: Vec = messages.iter().map(|m| m.id).collect(); - let reactions_map = - late_core::models::chat_message_reaction::ChatMessageReaction::list_summaries_for_messages( - &client, &msg_ids, - ) - .await - .unwrap_or_default(); - drop(client); - - let msg_items: Vec = 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(); - - 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()) { - let slug = if active_room_id == room_id { Some("general".to_string()) } 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 { - usernames.insert(message.user_id, name.clone()); - name - } else if let Some(name) = usernames.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(); - usernames.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(|_| ()) -} - -/// Resolve a room id from a path segment that is either a UUID or "general". -async fn resolve_room_id( - client: &deadpool_postgres::Client, - room: &str, -) -> Result)> { - if room == "general" { - return ChatRoom::find_general(client) - .await - .map_err(|_| api_error(StatusCode::INTERNAL_SERVER_ERROR, "db error"))? - .map(|r| r.id) - .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "room not found")); - } - Uuid::parse_str(room).map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid room id")) -} - -fn online_user_count(active_users: &ActiveUsers) -> usize { - active_users.lock().unwrap_or_else(|e| e.into_inner()).len() -} - -#[derive(Serialize)] -pub struct VotesResponse { - lofi: i64, - ambient: i64, - classic: i64, - jazz: i64, - next_vote_at: String, -} - -fn build_votes_response(state: &State) -> VotesResponse { - build_votes_response_from_snapshot(&state.vote_service.subscribe_state().borrow().clone()) -} - -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, - } -} - -fn build_now_playing_response(state: &State) -> NowPlayingResponse { - build_now_playing_from_value(&state.now_playing_rx.borrow().clone()) -} - -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, - }, - } -} - -fn reaction_emoji(kind: i16) -> &'static str { - match kind { - 1 => "👍", - 2 => "🧡", - 3 => "😂", - 4 => "👀", - 5 => "🔥", - 6 => "🙌", - 7 => "🚀", - 8 => "🤔", - _ => "?", - } -} - -fn rand_seed() -> i64 { - use rand_core::{OsRng, RngCore}; - OsRng.next_u64() as i64 -} - -/// Verify an SSH signature produced by `ssh-keygen -Y sign -n late.sh`. -/// -/// Checks that: -/// 1. The provided 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/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(|_| ()) +}