diff --git a/src/api.rs b/src/api.rs index d3b5f78..7316e5f 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1132,7 +1132,7 @@ pub async fn create_resource_access_token( let token = hex::encode(bytes); let expires_at = (Utc::now() + Duration::days(state.anonymous_access_token_ttl_days)).to_rfc3339(); - sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)")) + sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_INSERT)) .bind(hash_access_token(&token)) .bind(kind) .bind(slug) @@ -1161,7 +1161,7 @@ pub async fn verify_resource_access_token( { return Ok(true); } - let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), "SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND expires_at > ?")) + let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), queries::RESOURCE_ACCESS_TOKENS_VALID_COUNT)) .bind(hash_access_token(token)) .bind(kind) .bind(slug) diff --git a/src/auth.rs b/src/auth.rs index 095a446..fb73275 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -466,7 +466,7 @@ pub async fn update_resource( .map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), - "DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?", + queries::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE, )) .bind(req.kind.as_str()) .bind(req.slug.trim()) @@ -490,7 +490,7 @@ pub async fn delete_resource( }; sqlx::query(queries::get( state.db.kind(), - "DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?", + queries::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE, )) .bind(req.kind.as_str()) .bind(req.slug.trim()) @@ -553,14 +553,12 @@ pub async fn set_resource_privacy( ) -> Result, AuthError> { let user = require_user(&state, &headers).await?; ensure_owner(&state, user.id, &req.kind, &req.slug).await?; - let table = match req.kind.as_str() { - "workspace" => "workspaces", - "pad" => "pads", + let query = match req.kind.as_str() { + "workspace" => queries::USER_SET_WORKSPACE_PRIVACY, + "pad" => queries::USER_SET_PAD_PRIVACY, _ => return Err(AuthError::bad_request("Unknown resource type.")), }; - let query = - format!("UPDATE {table} SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"); - sqlx::query(&query) + sqlx::query(queries::get(state.db.kind(), query)) .bind(req.private) .bind(req.slug.trim()) .execute(state.db.pool()) @@ -604,16 +602,16 @@ pub async fn share_resource_users( continue; } - sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) + sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER)) .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_share_invitations WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) + sqlx::query(queries::get(state.db.kind(), queries::SHARE_INVITATION_DELETE_USER)) .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; if state.share_confirmation_required { let token = random_token(); let token_hash = hash_token(&token); let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339(); - sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_share_invitations (token_hash, resource_kind, resource_slug, user_id, permission, created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)")) + sqlx::query(queries::get(state.db.kind(), queries::SHARE_INVITATION_INSERT)) .bind(&token_hash).bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).bind(owner.id).bind(&expires_at) .execute(state.db.pool()).await.map_err(AuthError::database)?; if let Err(error) = send_share_invitation( @@ -629,7 +627,7 @@ pub async fn share_resource_users( { let _ = sqlx::query(queries::get( state.db.kind(), - "DELETE FROM resource_share_invitations WHERE token_hash = ?", + queries::SHARE_INVITATION_DELETE_TOKEN, )) .bind(&token_hash) .execute(state.db.pool()) @@ -637,7 +635,7 @@ pub async fn share_resource_users( return Err(error); } } else { - sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)")) + sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_INSERT)) .bind(&req.kind).bind(req.slug.trim()).bind(user.id).bind(permission).execute(state.db.pool()).await.map_err(AuthError::database)?; } } @@ -657,7 +655,7 @@ pub async fn accept_share_invitation( AxumPath(token): AxumPath, ) -> Result { let token_hash = hash_token(token.trim()); - let row: Option<(String, String, i64, String, String, Option)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT resource_kind, resource_slug, user_id, permission, expires_at, accepted_at FROM resource_share_invitations WHERE token_hash = ?")) + let row: Option<(String, String, i64, String, String, Option)> = sqlx::query_as(queries::get(state.db.kind(), queries::SHARE_INVITATION_FIND_TOKEN)) .bind(&token_hash).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?; let (kind, slug, user_id, permission, expires_at, accepted_at) = row.ok_or_else(|| { AuthError::bad_request("The sharing invitation is invalid or has expired.") @@ -672,13 +670,13 @@ pub async fn accept_share_invitation( )); } let mut tx = state.db.pool().begin().await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) + sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER)) .bind(&kind).bind(&slug).bind(user_id).execute(&mut *tx).await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)")) + sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_INSERT)) .bind(&kind).bind(&slug).bind(user_id).bind(&permission).execute(&mut *tx).await.map_err(AuthError::database)?; sqlx::query(queries::get( state.db.kind(), - "UPDATE resource_share_invitations SET accepted_at = ? WHERE token_hash = ?", + queries::SHARE_INVITATION_ACCEPT, )) .bind(Utc::now().to_rfc3339()) .bind(&token_hash) @@ -704,9 +702,9 @@ pub async fn remove_resource_user( ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; let email = normalize(&req.email); if let Some(user) = find_user_by_email(&state, &email).await? { - sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) + sqlx::query(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_DELETE_USER)) .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; - sqlx::query(queries::get(state.db.kind(), "DELETE FROM resource_share_invitations WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) + sqlx::query(queries::get(state.db.kind(), queries::SHARE_INVITATION_DELETE_USER)) .bind(&req.kind).bind(req.slug.trim()).bind(user.id).execute(state.db.pool()).await.map_err(AuthError::database)?; } Ok(Json(serde_json::json!({"ok":true}))) @@ -725,11 +723,11 @@ 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(state.db.kind(), "SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email")) + let users: Vec<(String,String,String)> = sqlx::query_as(queries::get(state.db.kind(), queries::RESOURCE_SHARING_USERS)) .bind(kind).bind(slug).fetch_all(state.db.pool()).await.map_err(AuthError::database)?; - let links: Vec<(String,String,Option,String)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT token_hash, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC")) + let links: Vec<(String,String,Option,String)> = 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,String)> = sqlx::query_as(queries::get(state.db.kind(), "SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email")) + let pending: Vec<(String,String,String,String)> = sqlx::query_as(queries::get(state.db.kind(), queries::RESOURCE_SHARING_PENDING)) .bind(kind).bind(slug).fetch_all(state.db.pool()).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,permission,expires_at,created_at)|serde_json::json!({"token":token,"permission":permission,"expires_at":expires_at,"created_at":created_at})).collect::>() }), @@ -747,7 +745,7 @@ pub async fn create_share_link( validate_share_expiration(req.expires_at.as_deref())?; let token = random_token(); let token_hash = hash_token(&token); - sqlx::query(queries::get(state.db.kind(), "INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)")) + sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT)) .bind(token_hash).bind(&req.kind).bind(req.slug.trim()).bind(permission).bind(&req.expires_at).bind(owner.id).execute(state.db.pool()).await.map_err(AuthError::database)?; let base = if req.kind == "workspace" { format!("/w/{}", req.slug.trim()) @@ -768,7 +766,7 @@ pub async fn update_share_link( ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; let permission = validate_permission(&req.permission)?; validate_share_expiration(req.expires_at.as_deref())?; - let result = sqlx::query(queries::get(state.db.kind(), "UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL")) + let result = sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_UPDATE)) .bind(permission).bind(&req.expires_at).bind(req.token.trim()).bind(&req.kind).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; if result.rows_affected() == 0 { return Err(AuthError::bad_request( @@ -787,7 +785,7 @@ pub async fn revoke_share_link( ) -> Result, AuthError> { let owner = require_user(&state, &headers).await?; ensure_owner(&state, owner.id, &req.kind, &req.slug).await?; - sqlx::query(queries::get(state.db.kind(), "UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?")) + sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_REVOKE)) .bind(Utc::now().to_rfc3339()).bind(req.token.trim()).bind(&req.kind).bind(req.slug.trim()).execute(state.db.pool()).await.map_err(AuthError::database)?; Ok(Json(serde_json::json!({"ok":true}))) } @@ -826,12 +824,12 @@ pub async fn resource_permission( if owns { return Ok(Some("rw".into())); } - let permission: Option = sqlx::query_scalar(queries::get(state.db.kind(), "SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?")) + let permission: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::RESOURCE_PERMISSION_BY_USER)) .bind(kind).bind(slug).bind(user.id).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?; return Ok(permission); } let now = Utc::now().to_rfc3339(); - let permission: Option = sqlx::query_scalar(queries::get(state.db.kind(), "SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)")) + let permission: Option = sqlx::query_scalar(queries::get(state.db.kind(), queries::SHARE_LINK_PERMISSION)) .bind(hash_token(token)).bind(kind).bind(slug).bind(now).fetch_optional(state.db.pool()).await.map_err(AuthError::database)?; Ok(permission) } diff --git a/src/queries.rs b/src/queries.rs index c23eb9b..c44dc11 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -61,6 +61,46 @@ pub const USER_SET_WORKSPACE_PASSWORD: &str = "UPDATE workspaces SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"; pub const USER_SET_PAD_PASSWORD: &str = "UPDATE pads SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"; +pub const USER_SET_WORKSPACE_PRIVACY: &str = + "UPDATE workspaces SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"; +pub const USER_SET_PAD_PRIVACY: &str = + "UPDATE pads SET is_private = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?"; +pub const RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE: &str = + "DELETE FROM resource_access_tokens WHERE resource_kind = ? AND resource_slug = ?"; +pub const RESOURCE_ACCESS_TOKENS_INSERT: &str = + "INSERT INTO resource_access_tokens (token_hash, resource_kind, resource_slug, expires_at) VALUES (?, ?, ?, ?)"; +pub const RESOURCE_ACCESS_TOKENS_VALID_COUNT: &str = + "SELECT COUNT(*) FROM resource_access_tokens WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND expires_at > ?"; +pub const RESOURCE_PERMISSION_DELETE_USER: &str = + "DELETE FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"; +pub const RESOURCE_PERMISSION_INSERT: &str = + "INSERT INTO resource_permissions (resource_kind, resource_slug, user_id, permission) VALUES (?, ?, ?, ?)"; +pub const SHARE_INVITATION_DELETE_USER: &str = + "DELETE FROM resource_share_invitations WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"; +pub const SHARE_INVITATION_INSERT: &str = + "INSERT INTO resource_share_invitations (token_hash, resource_kind, resource_slug, user_id, permission, created_by, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)"; +pub const SHARE_INVITATION_DELETE_TOKEN: &str = + "DELETE FROM resource_share_invitations WHERE token_hash = ?"; +pub const SHARE_INVITATION_FIND_TOKEN: &str = + "SELECT resource_kind, resource_slug, user_id, permission, expires_at, accepted_at FROM resource_share_invitations WHERE token_hash = ?"; +pub const SHARE_INVITATION_ACCEPT: &str = + "UPDATE resource_share_invitations SET accepted_at = ? WHERE token_hash = ?"; +pub const RESOURCE_SHARING_USERS: &str = + "SELECT u.email, u.nickname, rp.permission FROM resource_permissions rp JOIN users u ON u.id = rp.user_id WHERE rp.resource_kind = ? AND rp.resource_slug = ? ORDER BY u.email"; +pub const RESOURCE_SHARING_LINKS: &str = + "SELECT token_hash, permission, expires_at, created_at FROM resource_share_links WHERE resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL ORDER BY created_at DESC"; +pub const RESOURCE_SHARING_PENDING: &str = + "SELECT u.email, u.nickname, i.permission, i.expires_at FROM resource_share_invitations i JOIN users u ON u.id = i.user_id WHERE i.resource_kind = ? AND i.resource_slug = ? AND i.accepted_at IS NULL ORDER BY u.email"; +pub const SHARE_LINK_INSERT: &str = + "INSERT INTO resource_share_links (token_hash, resource_kind, resource_slug, permission, expires_at, created_by) VALUES (?, ?, ?, ?, ?, ?)"; +pub const SHARE_LINK_UPDATE: &str = + "UPDATE resource_share_links SET permission = ?, expires_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL"; +pub const SHARE_LINK_REVOKE: &str = + "UPDATE resource_share_links SET revoked_at = ? WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ?"; +pub const RESOURCE_PERMISSION_BY_USER: &str = + "SELECT permission FROM resource_permissions WHERE resource_kind = ? AND resource_slug = ? AND user_id = ?"; +pub const SHARE_LINK_PERMISSION: &str = + "SELECT permission FROM resource_share_links WHERE token_hash = ? AND resource_kind = ? AND resource_slug = ? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > ?)"; pub const Q001: &str = "SELECT id, slug, title, password_hash, created_at, updated_at, CAST(CASE WHEN is_private THEN 1 ELSE 0 END AS BIGINT) AS is_private FROM workspaces WHERE slug = ?"; pub const Q002: &str = "INSERT INTO workspaces (slug, title, password_hash) VALUES (?, ?, ?)";