diff --git a/Cargo.lock b/Cargo.lock index e40c45f..fabf2b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -619,7 +619,7 @@ dependencies = [ [[package]] name = "frangipane" -version = "1.0.2" +version = "1.0.3" dependencies = [ "anyhow", "argon2", diff --git a/Cargo.toml b/Cargo.toml index be87638..eda061e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "frangipane" -version = "1.0.2" +version = "1.0.3" edition = "2024" [dependencies] diff --git a/src/routes/rooms.rs b/src/routes/rooms.rs index 7395320..284b181 100644 --- a/src/routes/rooms.rs +++ b/src/routes/rooms.rs @@ -2,13 +2,16 @@ use axum::{ Extension, Json, Router, extract::Path, http::{HeaderMap, StatusCode}, - routing::{get, post}, + routing::{delete, get, post}, }; use sqlx::{PgPool, Pool, Postgres}; use uuid::Uuid; -use crate::db::{id_from_username, room_name_from_uuid, user_id_from_uuid, username_from_id}; use crate::{auth::verify_jwt, db::room_id_from_uuid}; +use crate::{ + db::{id_from_username, room_name_from_uuid, user_id_from_uuid, username_from_id}, + routes::users::UserProfile, +}; #[derive(sqlx::FromRow, serde::Serialize)] pub struct Room { @@ -46,15 +49,25 @@ pub struct AcceptRoomInvitePayload { pub sender_uuid: Uuid, } +#[derive(serde::Deserialize)] +pub struct TransferOwnershipPayload { + pub room_uuid: Uuid, + pub new_owner_uuid: Option, +} + pub fn routes() -> Router { Router::new() .route("/rooms", get(list_rooms)) .route("/rooms", post(create_room)) - .route("/rooms/{room_id}", get(get_room)) + .route("/rooms/{room_uuid}", get(get_room)) + .route("/rooms/{room-uuid}/members", get(list_members)) .route("/rooms/invites", get(list_invites)) .route("/rooms/invite", post(send_invite)) .route("/rooms/join", post(accept_request)) .route("/rooms/decline", post(decline_request)) + .route("/rooms/{room_uuid}/leave", post(leave_room)) + .route("/rooms/{room_uuid}/delete", delete(delete_room)) + .route("/rooms/transfer-ownership", post(transfer_ownership)) } pub async fn is_member(user_id: i32, room_id: i32, db: &Pool) -> bool { @@ -138,7 +151,8 @@ async fn create_room( .bind(room_uuid) .bind(user_id) .bind(&payload.name) - .bind(&payload.global) + // .bind(&payload.global) + .bind(false) // We do not allow global rooms .execute(&db) .await .map_err(|_| (StatusCode::BAD_REQUEST, format!("Could not create room")))?; @@ -465,3 +479,356 @@ async fn decline_request( Ok(StatusCode::CREATED) } + +async fn leave_room( + headers: HeaderMap, + Path(room_uuid): Path, + Extension(db): Extension, +) -> Result { + let claims = verify_jwt(headers)?; + + let user_id = user_id_from_uuid(&db, claims.sub).await?; + let room_id = room_id_from_uuid(&db, room_uuid).await?; + + if !is_member(user_id, room_id, &db).await { + return Err(( + StatusCode::FORBIDDEN, + "You are not a member of this room.".into(), + )); + } + + let mut tx = db + .begin() + .await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "DB error".into()))?; + + let owner: i32 = sqlx::query_scalar(r#"SELECT owner FROM room_ WHERE id = $1"#) + .bind(room_id) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Failed to get room owner: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to get room owner".into(), + ) + })?; + + if owner == user_id { + return Err(( + StatusCode::FORBIDDEN, + "You cannot leave a room that you own".into(), + )); + // let member_count: i64 = + // sqlx::query_scalar(r#"SELECT count(*) FROM membership_ WHERE room = $1"#) + // .bind(room_id) + // .fetch_one(&mut *tx) + // .await + // .map_err(|e| { + // tracing::error!("Failed to get member count: {e}"); + // ( + // StatusCode::INTERNAL_SERVER_ERROR, + // "Failed to get member count".into(), + // ) + // })?; + // + // if member_count > 0 { + // if let Some(new_owner) = payload.new_owner_uuid { + // let exists: bool = + // sqlx::query_scalar(r#"SELECT EXISTS (SELECT 1 FROM user_ WHERE uuid = $1)"#) + // .bind(new_owner) + // .fetch_one(&mut *tx) + // .await + // .map_err(|e| { + // tracing::error!("Failed to check user existence: {e}"); + // ( + // StatusCode::INTERNAL_SERVER_ERROR, + // "Failed to check user existence".into(), + // ) + // })?; + // + // if !exists { + // tracing::debug!( + // "User {user_id} tried to leave a room without transfering ownership" + // ); + // return Err(( + // StatusCode::FORBIDDEN, + // "Tried to transfer ownership to nonexistant user".into(), + // )); + // } + // + // sqlx::query("UPDATE room_ SET owner = $1 WHERE id = $2") + // .bind(new_owner) + // .bind(room_id) + // .execute(&mut *tx) + // .await + // .map_err(|e| { + // tracing::error!("Failed to set new owner: {e}"); + // ( + // StatusCode::INTERNAL_SERVER_ERROR, + // "Failed to set new owner".into(), + // ) + // })?; + // } else { + // return Err(( + // StatusCode::BAD_REQUEST, + // "Please provide a new owner for a non-empty room".into(), + // )); + // } + // } else { + // sqlx::query("DELETE FROM room_ WHERE id = $1") + // .bind(room_id) + // .execute(&mut *tx) + // .await + // .map_err(|e| { + // tracing::error!("Failed to delete room: {e}"); + // ( + // StatusCode::INTERNAL_SERVER_ERROR, + // "Failed to delete room".into(), + // ) + // })?; + // } + } + + sqlx::query( + r#" + DELETE FROM membership_ + WHERE user_id = $1 AND room = $2 + "#, + ) + .bind(user_id) + .bind(room_id) + .execute(&mut *tx) + .await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "DB error".into()))?; + + tx.commit().await.map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Could not accept room invite".into(), + ) + })?; + + Ok(StatusCode::OK) +} + +async fn transfer_ownership( + headers: HeaderMap, + Extension(db): Extension, + Json(payload): Json, +) -> Result { + let claims = verify_jwt(headers)?; + + let user_id = user_id_from_uuid(&db, claims.sub).await?; + let room_id = room_id_from_uuid(&db, payload.room_uuid).await?; + + if !is_member(user_id, room_id, &db).await { + return Err(( + StatusCode::FORBIDDEN, + "You are not a member of this room.".into(), + )); + } + + let owner: i32 = sqlx::query_scalar(r#"SELECT owner FROM room_ WHERE id = $1"#) + .bind(room_id) + .fetch_one(&db) + .await + .map_err(|e| { + tracing::error!("Failed to get owner for room {room_id}: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to get room owner".into(), + ) + })?; + + if owner != user_id { + return Err(( + StatusCode::FORBIDDEN, + "You are not a member of this room.".into(), + )); + } + + let exists: bool = sqlx::query_scalar(r#"SELECT EXISTS (SELECT 1 FROM user_ WHERE uuid = $1)"#) + .bind(payload.new_owner_uuid) + .fetch_one(&db) + .await + .map_err(|e| { + tracing::error!("Failed to check user existence: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to check user existence".into(), + ) + })?; + + if !exists { + tracing::debug!( + "User {user_id} tried to leave room {room_id} without transfering ownership" + ); + return Err(( + StatusCode::FORBIDDEN, + "Tried to transfer ownership to nonexistant user".into(), + )); + } + + sqlx::query("UPDATE room_ SET owner = $1 WHERE id = $2") + .bind(payload.new_owner_uuid) + .bind(room_id) + .execute(&db) + .await + .map_err(|e| { + tracing::error!("Failed to set new owner for room {room_id}: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to set new owner".into(), + ) + })?; + + Ok(StatusCode::OK) +} + +async fn list_members( + headers: HeaderMap, + Path(room_uuid): Path, + Extension(db): Extension, +) -> Result<(StatusCode, Json>), (StatusCode, String)> { + let claims = verify_jwt(headers)?; + + let user_id = user_id_from_uuid(&db, claims.sub).await?; + let room_id = room_id_from_uuid(&db, room_uuid).await?; + + if !is_member(user_id, room_id, &db).await { + return Err(( + StatusCode::FORBIDDEN, + "You are not a member of this room.".into(), + )); + } + + let is_global: bool = sqlx::query_scalar("SELECT global FROM room_ WHERE id = $1") + .bind(room_id) + .fetch_one(&db) + .await + .map_err(|e| { + tracing::error!("Failed to get global boolean {room_id}: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to fetch room".into(), + ) + })?; + + if is_global { + return Err(( + StatusCode::FORBIDDEN, + "Cannot get member list for global rooms".into(), + )); + } + + let members = sqlx::query_as::<_, UserProfile>( + r#" + SELECT u.uuid, u.username + FROM user_ u + JOIN membership_ m + ON m.room = $1 + AND m.user_id = u.id + "#, + ) + .bind(room_id) + .fetch_all(&db) + .await + .map_err(|e| { + tracing::error!("Failed to get member list for room {room_id}: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to get member list".into(), + ) + })?; + + Ok((StatusCode::OK, Json(members))) +} + +async fn delete_room( + headers: HeaderMap, + Path(room_uuid): Path, + Extension(db): Extension, +) -> Result { + let claims = verify_jwt(headers)?; + + let user_id = user_id_from_uuid(&db, claims.sub).await?; + let room_id = room_id_from_uuid(&db, room_uuid).await?; + + if !is_member(user_id, room_id, &db).await { + return Err(( + StatusCode::FORBIDDEN, + "You are not a member of this room.".into(), + )); + } + + let owner: i32 = sqlx::query_scalar(r#"SELECT owner FROM room_ WHERE id = $1"#) + .bind(room_id) + .fetch_one(&db) + .await + .map_err(|e| { + tracing::error!("Failed to get owner for room {room_id}: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to get room owner".into(), + ) + })?; + + if owner != user_id { + return Err(( + StatusCode::FORBIDDEN, + "You are not a member of this room.".into(), + )); + } + + let mut tx = db + .begin() + .await + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "DB error".into()))?; + + sqlx::query(r#"DELETE FROM message_ WHERE room = $1"#) + .bind(room_id) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Failed to delete messages on room {room_id}: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to delete messages".into(), + ) + })?; + + sqlx::query(r#"DELETE FROM membership_ WHERE room = $1"#) + .bind(room_id) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Failed to delete room memberships {room_id}: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to delete room memberships".into(), + ) + })?; + + sqlx::query(r#"DELETE FROM room_ WHERE id = $1"#) + .bind(room_id) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Failed to delete room {room_id}: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to delete room".into(), + ) + })?; + + tx.commit().await.map_err(|e| { + tracing::error!("Failed to delete room {room_id}: {e}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to delete room".into(), + ) + })?; + + Ok(StatusCode::OK) +} diff --git a/src/routes/users.rs b/src/routes/users.rs index 61d61dd..65c96c9 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -27,6 +27,12 @@ pub struct User { pub email: String, } +#[derive(sqlx::FromRow, serde::Serialize)] +pub struct UserProfile { + pub uuid: Uuid, + pub username: String, +} + #[derive(serde::Deserialize)] pub struct LoginPayload { pub email: String,