867 lines
23 KiB
Rust
867 lines
23 KiB
Rust
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<Arc<AppState>> {
|
|
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<Option<MapRole>> {
|
|
let pool = siren_core::data::pool();
|
|
let role: Option<String> =
|
|
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<Session>) -> 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<Session>) -> 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<Session>) -> 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<Json<Vec<ListedMap>>> {
|
|
let pool = siren_core::data::pool();
|
|
let maps: Vec<ListedMap> = 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<CreateMapPayload>,
|
|
) -> Result<(StatusCode, Json<GridMap>)> {
|
|
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<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 update_map(
|
|
SessionAuthorization(session): SessionAuthorization,
|
|
Path(id): Path<String>,
|
|
Json(payload): Json<UpdateMapPayload>,
|
|
) -> Result<Json<GridMap>> {
|
|
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());
|
|
}
|
|
|
|
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<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)
|
|
}
|
|
|
|
pub async fn list_permissions(
|
|
SessionAuthorization(session): SessionAuthorization,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<Vec<PermissionWithUser>>> {
|
|
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<PermissionWithUser> = 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<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());
|
|
}
|
|
|
|
// Resolve username → user_id
|
|
let target_id: Option<Uuid> = 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<String>,
|
|
) -> Result<StatusCode> {
|
|
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<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, &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<String>,
|
|
) -> Result<StatusCode> {
|
|
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<String>,
|
|
Json(payload): Json<CreateAccessRequestPayload>,
|
|
) -> Result<StatusCode> {
|
|
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<GridMap> = 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<String>,
|
|
) -> Result<Json<Vec<AccessRequestWithUser>>> {
|
|
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 requests: Vec<AccessRequestWithUser> = 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<ResolveAccessRequestPayload>,
|
|
) -> Result<StatusCode> {
|
|
let pool = siren_core::data::pool();
|
|
|
|
let map: Option<GridMap> = 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<Arc<AppState>>,
|
|
Path(map_id): Path<String>,
|
|
headers: HeaderMap,
|
|
) -> impl IntoResponse {
|
|
let session: Option<Session> = {
|
|
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<AppState>,
|
|
map_id: String,
|
|
session: Option<Session>,
|
|
) {
|
|
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<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;
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|