Major refactor

This commit is contained in:
2026-04-03 23:04:51 -04:00
parent e7f337c735
commit 35d07e8df1
124 changed files with 4929 additions and 2429 deletions

View File

@@ -0,0 +1,619 @@
pub mod model;
use crate::{
AppState,
auth::{OptionalAuth, Session, csprng, middleware::check_bearer_auth},
error::{Error, Result},
};
use axum::{
Json,
Router,
extract::{
Path,
Query,
State,
WebSocketUpgrade,
ws::{Message, WebSocket},
},
http::StatusCode,
response::IntoResponse,
routing::{delete, get, post, put},
};
use futures_util::{SinkExt, StreamExt};
use model::{
ClientMessage,
CreateMapPayload,
GridCell,
GridMap,
GridToken,
MapPermission,
MapRole,
MapState,
ServerMessage,
UpdatePermissionPayload,
};
use serde::Deserialize;
use std::sync::Arc;
use tokio::sync::broadcast;
pub fn get_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/maps", get(list_maps))
.route("/maps", post(create_map))
.route("/maps/{id}", get(get_map))
.route("/maps/{id}", delete(delete_map))
.route("/maps/{id}/permissions", get(list_permissions))
.route("/maps/{id}/permissions", put(update_permission))
.route("/maps/{id}/ws", get(ws_handler))
}
// ---------------------------------------------------------------------------
// Permission helpers
// ---------------------------------------------------------------------------
/// 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: i64) -> crate::error::Result<Option<MapRole>> {
let pool = siren_core::data::pool();
let perm: Option<MapPermission> = sqlx::query_as(
"SELECT map_id, user_id, role FROM map_permissions WHERE map_id = $1 AND user_id = $2",
)
.bind(map_id)
.bind(user_id)
.fetch_optional(pool)
.await?;
Ok(perm.map(|p| p.role))
}
/// Returns whether the caller can view the map:
/// - Public maps: always true.
/// - Private maps: true only if the user has any role.
async fn can_view(map: &GridMap, session: &Option<Session>) -> bool {
if map.is_public {
return true;
}
let Some(s) = session else { return false };
let user_id = s.user_id as i64;
get_user_role(&map.id, user_id)
.await
.ok()
.flatten()
.is_some()
}
/// Returns whether the caller can edit the map (editor or owner role).
async fn can_edit(map: &GridMap, session: &Option<Session>) -> bool {
let Some(s) = session else { return false };
let user_id = s.user_id as i64;
get_user_role(&map.id, 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<Session>) -> bool {
let Some(s) = session else { return false };
let user_id = s.user_id as i64;
get_user_role(&map.id, user_id)
.await
.ok()
.flatten()
.map(|r| r.is_owner())
.unwrap_or(false)
}
// ---------------------------------------------------------------------------
// REST handlers
// ---------------------------------------------------------------------------
pub async fn list_maps(OptionalAuth(session): OptionalAuth) -> Result<Json<Vec<GridMap>>> {
let pool = siren_core::data::pool();
let maps: Vec<GridMap> = match &session {
Some(s) => {
let user_id = s.user_id as i64;
sqlx::query_as(
"SELECT DISTINCT gm.*
FROM grid_maps gm
LEFT JOIN map_permissions mp ON mp.map_id = gm.id AND mp.user_id = $1
WHERE gm.is_public = TRUE OR mp.user_id IS NOT NULL
ORDER BY gm.created_at DESC",
)
.bind(user_id)
.fetch_all(pool)
.await?
}
None => {
sqlx::query_as("SELECT * FROM grid_maps WHERE is_public = TRUE ORDER BY created_at DESC")
.fetch_all(pool)
.await?
}
};
Ok(Json(maps))
}
pub async fn create_map(
OptionalAuth(session): OptionalAuth,
Json(payload): Json<CreateMapPayload>,
) -> Result<(StatusCode, Json<GridMap>)> {
let session = session.ok_or_else(|| Error::from(StatusCode::UNAUTHORIZED))?;
let user_id = session.user_id as i64;
let map_id = csprng(32);
let pool = siren_core::data::pool();
let map: GridMap = sqlx::query_as(
"INSERT INTO grid_maps (id, name, is_public, owner_id)
VALUES ($1, $2, $3, $4)
RETURNING *",
)
.bind(&map_id)
.bind(&payload.name)
.bind(payload.is_public)
.bind(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(user_id)
.execute(pool)
.await?;
Ok((StatusCode::CREATED, Json(map)))
}
pub async fn get_map(
OptionalAuth(session): OptionalAuth,
Path(id): Path<String>,
) -> Result<Json<MapState>> {
let pool = siren_core::data::pool();
let map: Option<GridMap> = 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<GridCell> = sqlx::query_as("SELECT * FROM grid_cells WHERE map_id = $1")
.bind(&id)
.fetch_all(pool)
.await?;
let tokens: Vec<GridToken> = 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 delete_map(
OptionalAuth(session): OptionalAuth,
Path(id): Path<String>,
) -> Result<StatusCode> {
let pool = siren_core::data::pool();
let map: Option<GridMap> = 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)
}
// ---------------------------------------------------------------------------
// Permission management
// ---------------------------------------------------------------------------
pub async fn list_permissions(
OptionalAuth(session): OptionalAuth,
Path(id): Path<String>,
) -> Result<Json<Vec<MapPermission>>> {
let pool = siren_core::data::pool();
let map: Option<GridMap> = 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<MapPermission> =
sqlx::query_as("SELECT map_id, user_id, role FROM map_permissions WHERE map_id = $1")
.bind(&id)
.fetch_all(pool)
.await?;
Ok(Json(perms))
}
pub async fn update_permission(
OptionalAuth(session): OptionalAuth,
Path(id): Path<String>,
Json(payload): Json<UpdatePermissionPayload>,
) -> Result<StatusCode> {
let pool = siren_core::data::pool();
let map: Option<GridMap> = 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());
}
// Prevent the owner from removing their own owner record
let caller_id = session.as_ref().map(|s| s.user_id as i64).unwrap_or(0);
if payload.user_id == caller_id && payload.role.as_ref().map(|r| r.is_owner()) == Some(false) {
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(payload.user_id)
.bind(role)
.execute(pool)
.await?;
}
None => {
sqlx::query("DELETE FROM map_permissions WHERE map_id = $1 AND user_id = $2")
.bind(&id)
.bind(payload.user_id)
.execute(pool)
.await?;
}
}
Ok(StatusCode::NO_CONTENT)
}
// ---------------------------------------------------------------------------
// WebSocket handler
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
pub struct WsQuery {
/// Optional Bearer token passed as a query parameter for WS auth.
token: Option<String>,
}
pub async fn ws_handler(
ws: WebSocketUpgrade,
State(state): State<Arc<AppState>>,
Path(map_id): Path<String>,
Query(query): Query<WsQuery>,
) -> impl IntoResponse {
// Resolve the session from query param (WS can't easily send headers)
let session: Option<Session> = match query.token {
Some(ref tok) => check_bearer_auth(tok).await.ok(),
None => None,
};
ws.on_upgrade(move |socket| handle_socket(socket, state, map_id, session))
}
async fn handle_socket(
socket: WebSocket,
state: Arc<AppState>,
map_id: String,
session: Option<Session>,
) {
// Load the map and verify the caller can view it
let map_state = match fetch_map_state(&map_id).await {
Ok(ms) => ms,
Err(_) => return, // map doesn't exist
};
if !can_view(&map_state.map, &session).await {
// Refuse the connection silently (upgrade already happened; just close)
return;
}
let editor = can_edit(&map_state.map, &session).await;
// Get or create a broadcast channel for this map
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();
// Send the current full map state to the newly connected client
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;
}
// Task 1: forward broadcast messages to this socket
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;
}
}
});
// Task 2: receive messages from this client, persist, and broadcast
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<MapState> {
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<GridCell> = sqlx::query_as("SELECT * FROM grid_cells WHERE map_id = $1")
.bind(map_id)
.fetch_all(pool)
.await?;
let tokens: Vec<GridToken> = 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<String>,
) {
let client_msg: ClientMessage = match serde_json::from_str(raw) {
Ok(m) => m,
Err(e) => {
log::warn!("Invalid WS message: {e}");
return;
}
};
// All mutating messages require editor or owner role
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<ServerMessage> = 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<GridToken> = 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);
}
}
}