pub mod model; use crate::{ AppState, auth::{Session, SessionAuthorization, middleware::check_cookie_from_header_str}, error::{Error, Result}, }; use axum::{ Json, Router, extract::{ Path, State, WebSocketUpgrade, ws::{Message, WebSocket}, }, http::{HeaderMap, StatusCode}, response::IntoResponse, routing::{delete, get, post, put}, }; use futures_util::{SinkExt, StreamExt}; use model::{ AccessRequestWithUser, ClientMessage, CreateAccessRequestPayload, CreateMapPayload, GridCell, GridMap, GridToken, ListedMap, MapRole, MapState, PermissionWithUser, ResolveAccessRequestPayload, ServerMessage, UpdateMapPayload, UpdatePermissionPayload, }; use siren_core::utils::csprng; use std::sync::Arc; use tokio::sync::broadcast; use uuid::Uuid; pub fn get_routes() -> Router> { Router::new() .route("/maps", get(list_maps)) .route("/maps", post(create_map)) .route("/maps/{id}", get(get_map)) .route("/maps/{id}", put(update_map)) .route("/maps/{id}", delete(delete_map)) .route("/maps/{id}/permissions", get(list_permissions)) .route("/maps/{id}/permissions", put(update_permission)) .route("/maps/{id}/favorite", post(favorite_map)) .route("/maps/{id}/favorite", delete(unfavorite_map)) .route("/maps/{id}/access-requests", post(create_access_request)) .route("/maps/{id}/access-requests", get(list_access_requests)) .route( "/maps/{id}/access-requests/{request_id}", put(resolve_access_request), ) .route("/maps/{id}/ws", get(ws_handler)) } /// Fetch the role of `user_id` on `map_id`, or `None` if no record exists. async fn get_user_role(map_id: &str, user_id: Uuid) -> Result> { let pool = siren_core::data::pool(); let role: Option = sqlx::query_scalar("SELECT role FROM map_permissions WHERE map_id = $1 AND user_id = $2") .bind(map_id) .bind(user_id) .fetch_optional(pool) .await?; Ok(role.and_then(|r| match r.as_str() { "owner" => Some(MapRole::Owner), "editor" => Some(MapRole::Editor), "viewer" => Some(MapRole::Viewer), _ => None, })) } /// Returns whether the caller can view the map. async fn can_view(map: &GridMap, session: &Option) -> bool { if map.public_access == "public_view" || map.public_access == "public_edit" { return true; } let Some(s) = session else { return false }; get_user_role(&map.id, s.user_id) .await .ok() .flatten() .is_some() } /// Returns whether the caller can edit the map (editor or owner role, or public_edit). async fn can_edit(map: &GridMap, session: &Option) -> bool { if map.public_access == "public_edit" { return true; } let Some(s) = session else { return false }; get_user_role(&map.id, s.user_id) .await .ok() .flatten() .map(|r| r.can_edit()) .unwrap_or(false) } /// Returns whether the caller is the owner. async fn is_owner(map: &GridMap, session: &Option) -> bool { let Some(s) = session else { return false }; get_user_role(&map.id, s.user_id) .await .ok() .flatten() .map(|r| r.is_owner()) .unwrap_or(false) } pub async fn list_maps( SessionAuthorization(session): SessionAuthorization, ) -> Result>> { let pool = siren_core::data::pool(); let maps: Vec = match &session { Some(s) => { sqlx::query_as( "SELECT gm.id, gm.name, gm.public_access, gm.owner_id, u.username AS owner_username, gm.colors, gm.created_at, gm.updated_at, mp.role AS user_role, (mf.user_id IS NOT NULL) AS is_favorited FROM grid_maps gm JOIN users u ON u.id = gm.owner_id LEFT JOIN map_permissions mp ON mp.map_id = gm.id AND mp.user_id = $1 LEFT JOIN map_favorites mf ON mf.map_id = gm.id AND mf.user_id = $1 WHERE mp.user_id IS NOT NULL OR mf.user_id IS NOT NULL ORDER BY gm.updated_at DESC", ) .bind(s.user_id) .fetch_all(pool) .await? } None => vec![], }; Ok(Json(maps)) } pub async fn create_map( SessionAuthorization(session): SessionAuthorization, Json(payload): Json, ) -> Result<(StatusCode, Json)> { let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; let public_access = payload.public_access.as_str(); if !matches!(public_access, "private" | "public_view" | "public_edit") { return Err(Error::new(422, "Invalid public_access value".into())); } let map_id = csprng(32); let pool = siren_core::data::pool(); let map: GridMap = sqlx::query_as( "INSERT INTO grid_maps (id, name, public_access, owner_id) VALUES ($1, $2, $3, $4) RETURNING *", ) .bind(&map_id) .bind(&payload.name) .bind(&payload.public_access) .bind(session.user_id) .fetch_one(pool) .await?; // Auto-assign the creator as owner in map_permissions sqlx::query("INSERT INTO map_permissions (map_id, user_id, role) VALUES ($1, $2, 'owner')") .bind(&map_id) .bind(session.user_id) .execute(pool) .await?; Ok((StatusCode::CREATED, Json(map))) } pub async fn get_map( SessionAuthorization(session): SessionAuthorization, Path(id): Path, ) -> Result> { let pool = siren_core::data::pool(); let map: Option = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1") .bind(&id) .fetch_optional(pool) .await?; let map = map.ok_or_else(|| Error::not_found("Map not found".into()))?; if !can_view(&map, &session).await { return Err(StatusCode::FORBIDDEN.into()); } let cells: Vec = sqlx::query_as("SELECT * FROM grid_cells WHERE map_id = $1") .bind(&id) .fetch_all(pool) .await?; let tokens: Vec = sqlx::query_as("SELECT * FROM grid_tokens WHERE map_id = $1") .bind(&id) .fetch_all(pool) .await?; Ok(Json(MapState { map, cells, tokens })) } pub async fn update_map( SessionAuthorization(session): SessionAuthorization, Path(id): Path, Json(payload): Json, ) -> Result> { let pool = siren_core::data::pool(); let map: Option = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1") .bind(&id) .fetch_optional(pool) .await?; let map = map.ok_or_else(|| Error::not_found("Map not found".into()))?; if !is_owner(&map, &session).await { return Err(StatusCode::FORBIDDEN.into()); } if let Some(ref pa) = payload.public_access { if !matches!(pa.as_str(), "private" | "public_view" | "public_edit") { return Err(Error::new(422, "Invalid public_access value".into())); } } let new_name = payload.name.as_deref().unwrap_or(&map.name); let new_pa = payload .public_access .as_deref() .unwrap_or(&map.public_access); let updated: GridMap = sqlx::query_as( "UPDATE grid_maps SET name = $1, public_access = $2, updated_at = NOW() WHERE id = $3 RETURNING *", ) .bind(new_name) .bind(new_pa) .bind(&id) .fetch_one(pool) .await?; Ok(Json(updated)) } pub async fn delete_map( SessionAuthorization(session): SessionAuthorization, Path(id): Path, ) -> Result { let pool = siren_core::data::pool(); let map: Option = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1") .bind(&id) .fetch_optional(pool) .await?; let map = map.ok_or_else(|| Error::not_found("Map not found".into()))?; if !is_owner(&map, &session).await { return Err(StatusCode::FORBIDDEN.into()); } sqlx::query("DELETE FROM grid_maps WHERE id = $1") .bind(&id) .execute(pool) .await?; Ok(StatusCode::NO_CONTENT) } pub async fn list_permissions( SessionAuthorization(session): SessionAuthorization, Path(id): Path, ) -> Result>> { let pool = siren_core::data::pool(); let map: Option = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1") .bind(&id) .fetch_optional(pool) .await?; let map = map.ok_or_else(|| Error::not_found("Map not found".into()))?; if !is_owner(&map, &session).await { return Err(StatusCode::FORBIDDEN.into()); } let perms: Vec = sqlx::query_as( "SELECT mp.map_id, mp.user_id, u.username, mp.role FROM map_permissions mp JOIN users u ON u.id = mp.user_id WHERE mp.map_id = $1 ORDER BY mp.role, u.username", ) .bind(&id) .fetch_all(pool) .await?; Ok(Json(perms)) } pub async fn update_permission( SessionAuthorization(session): SessionAuthorization, Path(id): Path, Json(payload): Json, ) -> Result { let pool = siren_core::data::pool(); let map: Option = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1") .bind(&id) .fetch_optional(pool) .await?; let map = map.ok_or_else(|| Error::not_found("Map not found".into()))?; if !is_owner(&map, &session).await { return Err(StatusCode::FORBIDDEN.into()); } // Resolve username → user_id let target_id: Option = sqlx::query_scalar("SELECT id FROM users WHERE username = $1") .bind(&payload.username) .fetch_optional(pool) .await?; let target_id = target_id.ok_or_else(|| Error::not_found("User not found".into()))?; // Prevent the owner from stripping their own owner record if let Some(ref s) = session { if target_id == s.user_id { if let Some(ref role) = payload.role { if !role.is_owner() { return Err(Error::from(StatusCode::UNPROCESSABLE_ENTITY)); } } } } match payload.role { Some(role) => { sqlx::query( "INSERT INTO map_permissions (map_id, user_id, role) VALUES ($1, $2, $3) ON CONFLICT (map_id, user_id) DO UPDATE SET role = EXCLUDED.role", ) .bind(&id) .bind(target_id) .bind(role) .execute(pool) .await?; } None => { sqlx::query("DELETE FROM map_permissions WHERE map_id = $1 AND user_id = $2") .bind(&id) .bind(target_id) .execute(pool) .await?; } } Ok(StatusCode::NO_CONTENT) } pub async fn favorite_map( SessionAuthorization(session): SessionAuthorization, Path(id): Path, ) -> Result { let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; let pool = siren_core::data::pool(); // Verify the map exists and is viewable let map: Option = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1") .bind(&id) .fetch_optional(pool) .await?; let map = map.ok_or_else(|| Error::not_found("Map not found".into()))?; if !can_view(&map, &Some(session.clone())).await { return Err(StatusCode::FORBIDDEN.into()); } sqlx::query( "INSERT INTO map_favorites (user_id, map_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", ) .bind(session.user_id) .bind(&id) .execute(pool) .await?; Ok(StatusCode::NO_CONTENT) } pub async fn unfavorite_map( SessionAuthorization(session): SessionAuthorization, Path(id): Path, ) -> Result { let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; let pool = siren_core::data::pool(); sqlx::query("DELETE FROM map_favorites WHERE user_id = $1 AND map_id = $2") .bind(session.user_id) .bind(&id) .execute(pool) .await?; Ok(StatusCode::NO_CONTENT) } pub async fn create_access_request( SessionAuthorization(session): SessionAuthorization, Path(id): Path, Json(payload): Json, ) -> Result { let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?; // Only editor and viewer roles can be requested if matches!(payload.role, MapRole::Owner) { return Err(Error::new(422, "Cannot request owner role".into())); } let pool = siren_core::data::pool(); let map: Option = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1") .bind(&id) .fetch_optional(pool) .await?; map.ok_or_else(|| Error::not_found("Map not found".into()))?; // Check if user already has a direct permission let existing_role = get_user_role(&id, session.user_id).await?; if existing_role.is_some() { return Err(Error::new( 409, "You already have access to this map".into(), )); } // Upsert the request (update role if they change their mind) sqlx::query( "INSERT INTO map_access_requests (map_id, user_id, requested_role, status, updated_at) VALUES ($1, $2, $3, 'pending', NOW()) ON CONFLICT (map_id, user_id) DO UPDATE SET requested_role = EXCLUDED.requested_role, status = 'pending', updated_at = NOW()", ) .bind(&id) .bind(session.user_id) .bind(&payload.role) .execute(pool) .await?; Ok(StatusCode::CREATED) } pub async fn list_access_requests( SessionAuthorization(session): SessionAuthorization, Path(id): Path, ) -> Result>> { let pool = siren_core::data::pool(); let map: Option = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1") .bind(&id) .fetch_optional(pool) .await?; let map = map.ok_or_else(|| Error::not_found("Map not found".into()))?; if !is_owner(&map, &session).await { return Err(StatusCode::FORBIDDEN.into()); } let requests: Vec = sqlx::query_as( "SELECT mar.id, mar.map_id, mar.user_id, u.username, mar.requested_role, mar.status, mar.created_at, mar.updated_at FROM map_access_requests mar JOIN users u ON u.id = mar.user_id WHERE mar.map_id = $1 AND mar.status = 'pending' ORDER BY mar.created_at ASC", ) .bind(&id) .fetch_all(pool) .await?; Ok(Json(requests)) } pub async fn resolve_access_request( SessionAuthorization(session): SessionAuthorization, Path((map_id, request_id)): Path<(String, Uuid)>, Json(payload): Json, ) -> Result { let pool = siren_core::data::pool(); let map: Option = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1") .bind(&map_id) .fetch_optional(pool) .await?; let map = map.ok_or_else(|| Error::not_found("Map not found".into()))?; if !is_owner(&map, &session).await { return Err(StatusCode::FORBIDDEN.into()); } if !matches!(payload.action.as_str(), "approve" | "deny") { return Err(Error::new(422, "action must be 'approve' or 'deny'".into())); } // Fetch the request let req: Option<(Uuid, String)> = sqlx::query_as( "SELECT user_id, requested_role FROM map_access_requests WHERE id = $1 AND map_id = $2", ) .bind(request_id) .bind(&map_id) .fetch_optional(pool) .await?; let (user_id, role) = req.ok_or_else(|| Error::not_found("Access request not found".into()))?; if payload.action == "approve" { // Grant the requested role sqlx::query( "INSERT INTO map_permissions (map_id, user_id, role) VALUES ($1, $2, $3) ON CONFLICT (map_id, user_id) DO UPDATE SET role = EXCLUDED.role", ) .bind(&map_id) .bind(user_id) .bind(&role) .execute(pool) .await?; } // Mark request as resolved let new_status = if payload.action == "approve" { "approved" } else { "denied" }; sqlx::query("UPDATE map_access_requests SET status = $1, updated_at = NOW() WHERE id = $2") .bind(new_status) .bind(request_id) .execute(pool) .await?; Ok(StatusCode::NO_CONTENT) } pub async fn ws_handler( ws: WebSocketUpgrade, State(state): State>, Path(map_id): Path, headers: HeaderMap, ) -> impl IntoResponse { let session: Option = { let ip = crate::auth::middleware::extract_ip(&headers); let user_agent = headers .get("user-agent") .and_then(|h| h.to_str().ok()) .unwrap_or("unknown"); if let Some(cookie_header) = headers.get("cookie").and_then(|h| h.to_str().ok()) { check_cookie_from_header_str(cookie_header, &ip, user_agent).await } else { None } }; ws.on_upgrade(move |socket| handle_socket(socket, state, map_id, session)) } async fn handle_socket( socket: WebSocket, state: Arc, map_id: String, session: Option, ) { let map_state = match fetch_map_state(&map_id).await { Ok(ms) => ms, Err(_) => return, }; if !can_view(&map_state.map, &session).await { return; } let editor = can_edit(&map_state.map, &session).await; let tx = state .map_rooms .entry(map_id.clone()) .or_insert_with(|| { let (tx, _) = broadcast::channel(256); tx }) .clone(); let mut rx = tx.subscribe(); let (mut ws_tx, mut ws_rx) = socket.split(); let init_msg = ServerMessage::State { cells: map_state.cells, tokens: map_state.tokens, colors: map_state.map.colors, }; if let Ok(json) = serde_json::to_string(&init_msg) { let _ = ws_tx.send(Message::Text(json.into())).await; } let mut send_task = tokio::spawn(async move { while let Ok(json) = rx.recv().await { if ws_tx.send(Message::Text(json.into())).await.is_err() { break; } } }); let tx_clone = tx.clone(); let mut recv_task = tokio::spawn(async move { while let Some(Ok(msg)) = ws_rx.next().await { match msg { Message::Text(text) => { handle_client_message(&text, &map_id, editor, &tx_clone).await; } Message::Close(_) => break, _ => {} } } }); tokio::select! { _ = &mut send_task => recv_task.abort(), _ = &mut recv_task => send_task.abort(), } } async fn fetch_map_state(map_id: &str) -> crate::error::Result { let pool = siren_core::data::pool(); let map: GridMap = sqlx::query_as("SELECT * FROM grid_maps WHERE id = $1") .bind(map_id) .fetch_one(pool) .await?; let cells: Vec = sqlx::query_as("SELECT * FROM grid_cells WHERE map_id = $1") .bind(map_id) .fetch_all(pool) .await?; let tokens: Vec = sqlx::query_as("SELECT * FROM grid_tokens WHERE map_id = $1") .bind(map_id) .fetch_all(pool) .await?; Ok(MapState { map, cells, tokens }) } async fn handle_client_message( raw: &str, map_id: &str, can_edit: bool, tx: &broadcast::Sender, ) { let client_msg: ClientMessage = match serde_json::from_str(raw) { Ok(m) => m, Err(e) => { log::warn!("Invalid WS message: {e}"); return; } }; if !can_edit { let err = ServerMessage::Error { message: "You do not have permission to edit this map.".into(), }; if let Ok(json) = serde_json::to_string(&err) { let _ = tx.send(json); } return; } let pool = siren_core::data::pool(); let server_msg: Option = match client_msg { ClientMessage::PaintCell { x, y, color } => { let result = sqlx::query( "INSERT INTO grid_cells (map_id, x, y, color) VALUES ($1, $2, $3, $4) ON CONFLICT (map_id, x, y) DO UPDATE SET color = EXCLUDED.color", ) .bind(map_id) .bind(x) .bind(y) .bind(&color) .execute(pool) .await; match result { Ok(_) => Some(ServerMessage::CellPainted { x, y, color }), Err(e) => { log::error!("DB error painting cell: {e}"); None } } } ClientMessage::PaintCells { cells } => { let mut tx_db = match pool.begin().await { Ok(t) => t, Err(e) => { log::error!("DB error starting transaction for batch paint: {e}"); return; } }; let mut ok = true; for cell in &cells { let res = sqlx::query( "INSERT INTO grid_cells (map_id, x, y, color) VALUES ($1, $2, $3, $4) ON CONFLICT (map_id, x, y) DO UPDATE SET color = EXCLUDED.color", ) .bind(map_id) .bind(cell.x) .bind(cell.y) .bind(&cell.color) .execute(&mut *tx_db) .await; if let Err(e) = res { log::error!("DB error in batch paint cell ({},{}): {e}", cell.x, cell.y); ok = false; break; } } if ok { if let Err(e) = tx_db.commit().await { log::error!("DB error committing batch paint: {e}"); None } else { Some(ServerMessage::CellsBatchPainted { cells }) } } else { let _ = tx_db.rollback().await; None } } ClientMessage::EraseCell { x, y } => { let result = sqlx::query("DELETE FROM grid_cells WHERE map_id = $1 AND x = $2 AND y = $3") .bind(map_id) .bind(x) .bind(y) .execute(pool) .await; match result { Ok(_) => Some(ServerMessage::CellErased { x, y }), Err(e) => { log::error!("DB error erasing cell: {e}"); None } } } ClientMessage::AddToken { x, y, label, color } => { let token_id = csprng(16); let result: sqlx::Result = sqlx::query_as( "INSERT INTO grid_tokens (id, map_id, x, y, label, color) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *", ) .bind(&token_id) .bind(map_id) .bind(x) .bind(y) .bind(&label) .bind(&color) .fetch_one(pool) .await; match result { Ok(token) => Some(ServerMessage::TokenAdded { id: token.id, x: token.x, y: token.y, label: token.label, color: token.color, }), Err(e) => { log::error!("DB error adding token: {e}"); None } } } ClientMessage::MoveToken { id, x, y } => { let result = sqlx::query("UPDATE grid_tokens SET x = $1, y = $2 WHERE id = $3 AND map_id = $4") .bind(x) .bind(y) .bind(&id) .bind(map_id) .execute(pool) .await; match result { Ok(r) if r.rows_affected() > 0 => Some(ServerMessage::TokenMoved { id, x, y }), Ok(_) => None, Err(e) => { log::error!("DB error moving token: {e}"); None } } } ClientMessage::DeleteToken { id } => { let result = sqlx::query("DELETE FROM grid_tokens WHERE id = $1 AND map_id = $2") .bind(&id) .bind(map_id) .execute(pool) .await; match result { Ok(r) if r.rows_affected() > 0 => Some(ServerMessage::TokenDeleted { id }), Ok(_) => None, Err(e) => { log::error!("DB error deleting token: {e}"); None } } } ClientMessage::UpdateColors { colors } => { let result = sqlx::query("UPDATE grid_maps SET colors = $1, updated_at = NOW() WHERE id = $2") .bind(&colors) .bind(map_id) .execute(pool) .await; match result { Ok(_) => Some(ServerMessage::ColorsUpdated { colors }), Err(e) => { log::error!("DB error updating colors: {e}"); None } } } }; if let Some(msg) = server_msg { if let Ok(json) = serde_json::to_string(&msg) { let _ = tx.send(json); } } }