diff --git a/Cargo.lock b/Cargo.lock index 152b77a..3f22d91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2581,7 +2581,7 @@ dependencies = [ [[package]] name = "rustpad" -version = "0.1.18" +version = "0.1.19" dependencies = [ "argon2", "aws-config", diff --git a/Cargo.toml b/Cargo.toml index 6b8b4d1..8633a12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.1.18" +version = "0.1.19" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 582bfc8..329d1fe 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -116,6 +116,63 @@ pub struct ResourceItem { shared_by: String, } +#[derive(Debug)] +struct SharingUserRow { + email: String, + nickname: String, + permission: String, +} + +impl<'r> sqlx::FromRow<'r, AnyRow> for SharingUserRow { + fn from_row(row: &'r AnyRow) -> Result { + Ok(Self { + email: crate::row_decode::text(row, 0)?, + nickname: crate::row_decode::text(row, 1)?, + permission: crate::row_decode::text(row, 2)?, + }) + } +} + +#[derive(Debug)] +struct SharingLinkRow { + token_hash: String, + token: Option, + permission: String, + expires_at: Option, + created_at: String, +} + +impl<'r> sqlx::FromRow<'r, AnyRow> for SharingLinkRow { + fn from_row(row: &'r AnyRow) -> Result { + Ok(Self { + token_hash: crate::row_decode::text(row, 0)?, + token: crate::row_decode::optional_text(row, 1)?, + permission: crate::row_decode::text(row, 2)?, + expires_at: crate::row_decode::optional_text(row, 3)?, + created_at: crate::row_decode::text(row, 4)?, + }) + } +} + +#[derive(Debug)] +struct PendingShareRow { + email: String, + nickname: String, + permission: String, + expires_at: Option, +} + +impl<'r> sqlx::FromRow<'r, AnyRow> for PendingShareRow { + fn from_row(row: &'r AnyRow) -> Result { + Ok(Self { + email: crate::row_decode::text(row, 0)?, + nickname: crate::row_decode::text(row, 1)?, + permission: crate::row_decode::text(row, 2)?, + expires_at: crate::row_decode::optional_text(row, 3)?, + }) + } +} + impl<'r> sqlx::FromRow<'r, AnyRow> for User { fn from_row(row: &'r AnyRow) -> Result { Ok(Self { @@ -1413,7 +1470,7 @@ pub async fn resource_sharing( .get("slug") .ok_or_else(|| AuthError::bad_request("Missing slug."))?; ensure_owner(&state, owner.id, kind, slug).await?; - let users: Vec<(String, String, String)> = sqlx::query_as(queries::get( + let users: Vec = sqlx::query_as(queries::get( state.db.kind(), queries::RESOURCE_SHARING_USERS, )) @@ -1422,15 +1479,16 @@ pub async fn resource_sharing( .fetch_all(state.db.pool()) .await .map_err(AuthError::database)?; - let links: Vec<(String, Option, String, Option, String)> = sqlx::query_as( - queries::get(state.db.kind(), queries::RESOURCE_SHARING_LINKS), - ) + let links: Vec = sqlx::query_as(queries::get( + state.db.kind(), + queries::RESOURCE_SHARING_LINKS, + )) .bind(kind) .bind(slug) .fetch_all(state.db.pool()) .await .map_err(AuthError::database)?; - let pending: Vec<(String, String, String, Option)> = sqlx::query_as(queries::get( + let pending: Vec = sqlx::query_as(queries::get( state.db.kind(), queries::RESOURCE_SHARING_PENDING, )) @@ -1440,7 +1498,7 @@ pub async fn resource_sharing( .await .map_err(AuthError::database)?; Ok(Json( - serde_json::json!({"users":users.into_iter().map(|(email,nickname,permission)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission})).collect::>(), "pending":pending.into_iter().map(|(email,nickname,permission,expires_at)|serde_json::json!({"email":email,"nickname":nickname,"permission":permission,"expires_at":expires_at})).collect::>(), "links":links.into_iter().map(|(token_hash,token,permission,expires_at,created_at)|serde_json::json!({"token_hash":token_hash,"token":token,"permission":permission,"expires_at":expires_at,"created_at":created_at})).collect::>() }), + serde_json::json!({"users":users.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission})).collect::>(), "pending":pending.into_iter().map(|row|serde_json::json!({"email":row.email,"nickname":row.nickname,"permission":row.permission,"expires_at":row.expires_at})).collect::>(), "links":links.into_iter().map(|row|serde_json::json!({"token_hash":row.token_hash,"token":row.token,"permission":row.permission,"expires_at":row.expires_at,"created_at":row.created_at})).collect::>() }), )) } diff --git a/src/queries/mysql.rs b/src/queries/mysql.rs index c421fea..80c81b6 100644 --- a/src/queries/mysql.rs +++ b/src/queries/mysql.rs @@ -49,7 +49,7 @@ pub fn get(query: Query) -> &'static str { Query::AUTH_INSERT_SESSION => r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"#, Query::AUTH_USER_BY_NICKNAME => r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users WHERE nickname_key = ?"#, Query::AUTH_USER_BY_EMAIL => r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users WHERE email_key = ?"#, - Query::AUTH_USER_BY_SHARE_IDENTIFIER => r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users JOIN (SELECT ? AS identifier) lookup ON 1 = 1 WHERE is_active = 1 AND (email_key = lookup.identifier OR nickname_key = lookup.identifier OR LOWER(external_id) = lookup.identifier) LIMIT 1"#, + Query::AUTH_USER_BY_SHARE_IDENTIFIER => r#"SELECT id, nickname, email, CAST(password_hash AS CHAR CHARACTER SET utf8mb4) AS password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS SIGNED) AS is_active FROM users JOIN (SELECT ? AS identifier) lookup ON 1 = 1 WHERE is_active = 1 AND (email_key = lookup.identifier OR LOWER(external_id) = lookup.identifier) LIMIT 1"#, Query::USER_ATTACH_WORKSPACE => r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"#, Query::USER_ATTACH_PAD => r#"INSERT INTO user_pads (user_id, pad_id) SELECT ?, id FROM pads WHERE slug = ?"#, Query::USER_LIST_WORKSPACES => r#"SELECT w.slug, CAST(w.title AS CHAR CHARACTER SET utf8mb4) AS title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? UNION SELECT w.slug, CAST(w.title AS CHAR CHARACTER SET utf8mb4) AS title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_workspaces owner_uw JOIN users u ON u.id = owner_uw.user_id WHERE owner_uw.workspace_id = w.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN workspaces w ON w.slug = rp.resource_slug WHERE rp.resource_kind = 'workspace' AND rp.user_id = ? ORDER BY updated_at DESC"#, diff --git a/src/queries/postgres.rs b/src/queries/postgres.rs index a1c5ddf..6f38325 100644 --- a/src/queries/postgres.rs +++ b/src/queries/postgres.rs @@ -49,7 +49,7 @@ pub fn get(query: Query) -> &'static str { Query::AUTH_INSERT_SESSION => r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES ($1, $2, $3)"#, Query::AUTH_USER_BY_NICKNAME => r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE nickname_key = $1"#, Query::AUTH_USER_BY_EMAIL => r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE email_key = $1"#, - Query::AUTH_USER_BY_SHARE_IDENTIFIER => r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE is_active = TRUE AND (email_key = $1 OR nickname_key = $1 OR LOWER(external_id) = $1) LIMIT 1"#, + Query::AUTH_USER_BY_SHARE_IDENTIFIER => r#"SELECT id, nickname, email, password_hash, confirmed_at, CAST(CASE WHEN is_active THEN 1 ELSE 0 END AS BIGINT) AS is_active FROM users WHERE is_active = TRUE AND (email_key = $1 OR LOWER(external_id) = $1) LIMIT 1"#, Query::USER_ATTACH_WORKSPACE => r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT $1, id FROM workspaces WHERE slug = $2"#, Query::USER_ATTACH_PAD => r#"INSERT INTO user_pads (user_id, pad_id) SELECT $1, id FROM pads WHERE slug = $2"#, Query::USER_LIST_WORKSPACES => r#"SELECT w.slug, w.title, CAST(CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT) AS protected, w.updated_at, CAST(CASE WHEN w.is_private THEN 1 ELSE 0 END AS BIGINT) AS private, CAST(1 AS BIGINT) AS owned, 'rw' AS permission, '' AS shared_by FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = $1 UNION SELECT w.slug, w.title, CAST(CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS BIGINT), w.updated_at, CAST(CASE WHEN w.is_private THEN 1 ELSE 0 END AS BIGINT), CAST(0 AS BIGINT), rp.permission, COALESCE((SELECT u.nickname FROM user_workspaces owner_uw JOIN users u ON u.id = owner_uw.user_id WHERE owner_uw.workspace_id = w.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN workspaces w ON w.slug = rp.resource_slug WHERE rp.resource_kind = 'workspace' AND rp.user_id = $2 ORDER BY updated_at DESC"#, diff --git a/src/queries/sqlite.rs b/src/queries/sqlite.rs index dc4df48..359876a 100644 --- a/src/queries/sqlite.rs +++ b/src/queries/sqlite.rs @@ -49,7 +49,7 @@ pub fn get(query: Query) -> &'static str { Query::AUTH_INSERT_SESSION => r#"INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)"#, Query::AUTH_USER_BY_NICKNAME => r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active FROM users WHERE nickname_key = ?"#, Query::AUTH_USER_BY_EMAIL => r#"SELECT id, nickname, email, password_hash, confirmed_at, is_active FROM users WHERE email_key = ?"#, - Query::AUTH_USER_BY_SHARE_IDENTIFIER => r#"SELECT id, nickname, email, password_hash, confirmed_at, CASE WHEN is_active THEN 1 ELSE 0 END AS is_active FROM users WHERE is_active = 1 AND (email_key = ?1 OR nickname_key = ?1 OR LOWER(external_id) = ?1) LIMIT 1"#, + Query::AUTH_USER_BY_SHARE_IDENTIFIER => r#"SELECT id, nickname, email, password_hash, confirmed_at, CASE WHEN is_active THEN 1 ELSE 0 END AS is_active FROM users WHERE is_active = 1 AND (email_key = ?1 OR LOWER(external_id) = ?1) LIMIT 1"#, Query::USER_ATTACH_WORKSPACE => r#"INSERT INTO user_workspaces (user_id, workspace_id) SELECT ?, id FROM workspaces WHERE slug = ?"#, Query::USER_ATTACH_PAD => r#"INSERT INTO user_pads (user_id, pad_id) SELECT ?, id FROM pads WHERE slug = ?"#, Query::USER_LIST_WORKSPACES => r#"SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END AS protected, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END AS private, 1 AS owned, 'rw' AS permission, '' AS shared_by FROM user_workspaces uw JOIN workspaces w ON w.id = uw.workspace_id WHERE uw.user_id = ? UNION SELECT w.slug, w.title, CASE WHEN w.password_hash IS NULL THEN 0 ELSE 1 END, w.updated_at, CASE WHEN w.is_private THEN 1 ELSE 0 END, 0, rp.permission, COALESCE((SELECT u.nickname FROM user_workspaces owner_uw JOIN users u ON u.id = owner_uw.user_id WHERE owner_uw.workspace_id = w.id LIMIT 1), 'Unknown user') AS shared_by FROM resource_permissions rp JOIN workspaces w ON w.slug = rp.resource_slug WHERE rp.resource_kind = 'workspace' AND rp.user_id = ? ORDER BY updated_at DESC"#, diff --git a/static/js/home.js b/static/js/home.js index 35de305..f21ac30 100644 --- a/static/js/home.js +++ b/static/js/home.js @@ -136,8 +136,8 @@ async function loadResources() { dialog.innerHTML = `