feat: mask comment and reaction's email if profile name is not set

This commit is contained in:
khorshuheng 2025-04-17 11:21:57 +08:00
parent 041df0549c
commit 82242e339f
7 changed files with 113 additions and 36 deletions

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n avr.comment_id,\n avr.reaction_type,\n ARRAY_AGG((au.uuid, au.name, au.metadata ->> 'icon_url')) AS \"react_users!: Vec<AFWebUserColumn>\"\n FROM af_published_view_reaction avr\n INNER JOIN af_user au ON avr.created_by = au.uid\n WHERE view_id = $1\n GROUP BY comment_id, reaction_type\n ORDER BY MIN(avr.created_at)\n ",
"query": "\n SELECT\n avr.comment_id,\n avr.reaction_type,\n ARRAY_AGG((au.uuid, au.name, au.email, au.metadata ->> 'icon_url')) AS \"react_users!: Vec<AFWebUserWithEmailColumn>\"\n FROM af_published_view_reaction avr\n INNER JOIN af_user au ON avr.created_by = au.uid\n WHERE view_id = $1\n GROUP BY comment_id, reaction_type\n ORDER BY MIN(avr.created_at)\n ",
"describe": {
"columns": [
{
@ -15,7 +15,7 @@
},
{
"ordinal": 2,
"name": "react_users!: Vec<AFWebUserColumn>",
"name": "react_users!: Vec<AFWebUserWithEmailColumn>",
"type_info": "RecordArray"
}
],
@ -30,5 +30,5 @@
null
]
},
"hash": "056174448a2ff0744b5943ba6d303b180ca9016cd26d284686f445c060cec4c5"
"hash": "53d87db17bb9c1d002adc82ba9f2c07ff33ea987a1157d7f6fd2344091b98deb"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n avr.reaction_type,\n ARRAY_AGG((au.uuid, au.name, au.metadata ->> 'icon_url')) AS \"react_users!: Vec<AFWebUserColumn>\",\n avr.comment_id\n FROM af_published_view_reaction avr\n INNER JOIN af_user au ON avr.created_by = au.uid\n WHERE comment_id = $1\n GROUP BY comment_id, reaction_type\n ORDER BY MIN(avr.created_at)\n ",
"query": "\n SELECT\n avr.reaction_type,\n ARRAY_AGG((au.uuid, au.name, au.email, au.metadata ->> 'icon_url')) AS \"react_users!: Vec<AFWebUserWithEmailColumn>\",\n avr.comment_id\n FROM af_published_view_reaction avr\n INNER JOIN af_user au ON avr.created_by = au.uid\n WHERE comment_id = $1\n GROUP BY comment_id, reaction_type\n ORDER BY MIN(avr.created_at)\n ",
"describe": {
"columns": [
{
@ -10,7 +10,7 @@
},
{
"ordinal": 1,
"name": "react_users!: Vec<AFWebUserColumn>",
"name": "react_users!: Vec<AFWebUserWithEmailColumn>",
"type_info": "RecordArray"
},
{
@ -30,5 +30,5 @@
false
]
},
"hash": "b58432fffcf04a9485a7db5908c1801b34f51e51f3b06f679dc62e068e1cc721"
"hash": "63f0871525ed70bd980223de574d241c0b738cfb7b0ea1fc808f02c0e05b9a2f"
}

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n avc.comment_id,\n avc.created_at,\n avc.updated_at AS last_updated_at,\n avc.content,\n avc.reply_comment_id,\n avc.is_deleted,\n (au.uuid, au.name, au.metadata ->> 'icon_url') AS \"user: AFWebUserColumn\",\n (NOT avc.is_deleted AND ($2 OR au.uuid = $3)) AS \"can_be_deleted!\"\n FROM af_published_view_comment avc\n LEFT OUTER JOIN af_user au ON avc.created_by = au.uid\n WHERE view_id = $1\n ORDER BY avc.created_at DESC\n ",
"query": "\n SELECT\n avc.comment_id,\n avc.created_at,\n avc.updated_at AS last_updated_at,\n avc.content,\n avc.reply_comment_id,\n avc.is_deleted,\n (au.uuid, au.name, au.email, au.metadata ->> 'icon_url') AS \"user: AFWebUserWithEmailColumn\",\n (NOT avc.is_deleted AND ($2 OR au.uuid = $3)) AS \"can_be_deleted!\"\n FROM af_published_view_comment avc\n LEFT OUTER JOIN af_user au ON avc.created_by = au.uid\n WHERE view_id = $1\n ORDER BY avc.created_at DESC\n ",
"describe": {
"columns": [
{
@ -35,7 +35,7 @@
},
{
"ordinal": 6,
"name": "user: AFWebUserColumn",
"name": "user: AFWebUserWithEmailColumn",
"type_info": "Record"
},
{
@ -62,5 +62,5 @@
null
]
},
"hash": "c5c72869f44067d90c3224a17ec0e32b10cdf9378947e2c7a8409e48423377eb"
"hash": "95c00cd1ce7cdb8f5c8f45d5262d371b1b3c3f903f4eab9c0070d9916e3f8c12"
}

View file

@ -854,9 +854,16 @@ pub struct AFWebUser {
pub avatar_url: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AFWebUserWithObfuscatedName {
pub uuid: Uuid,
pub name: String,
pub avatar_url: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct GlobalComment {
pub user: Option<AFWebUser>,
pub user: Option<AFWebUserWithObfuscatedName>,
pub created_at: DateTime<Utc>,
pub last_updated_at: DateTime<Utc>,
pub content: String,
@ -885,7 +892,7 @@ pub struct Reactions {
#[derive(Serialize, Deserialize, Debug)]
pub struct Reaction {
pub reaction_type: String,
pub react_users: Vec<AFWebUser>,
pub react_users: Vec<AFWebUserWithObfuscatedName>,
pub comment_id: Uuid,
}

View file

@ -3,9 +3,9 @@ use app_error::AppError;
use chrono::{DateTime, Utc};
use database_entity::dto::{
AFAccessLevel, AFRole, AFUserProfile, AFWebUser, AFWorkspace, AFWorkspaceInvitationStatus,
AccessRequestMinimal, AccessRequestStatus, AccessRequestWithViewId, AccessRequesterInfo,
AccountLink, GlobalComment, QuickNote, Reaction, Template, TemplateCategory,
AFAccessLevel, AFRole, AFUserProfile, AFWebUser, AFWebUserWithObfuscatedName, AFWorkspace,
AFWorkspaceInvitationStatus, AccessRequestMinimal, AccessRequestStatus, AccessRequestWithViewId,
AccessRequesterInfo, AccountLink, GlobalComment, QuickNote, Reaction, Template, TemplateCategory,
TemplateCategoryMinimal, TemplateCategoryType, TemplateCreator, TemplateCreatorMinimal,
TemplateGroup, TemplateMinimal,
};
@ -330,8 +330,40 @@ impl From<AFWebUserColumn> for AFWebUser {
}
}
#[derive(sqlx::Type, Serialize, Debug)]
pub struct AFWebUserWithEmailColumn {
uuid: Uuid,
name: String,
email: String,
avatar_url: Option<String>,
}
fn mask_web_user_email(email: &str) -> String {
email
.split('@')
.next()
.map(|part| part.chars().take(6).collect())
.unwrap_or_default()
}
impl From<AFWebUserWithEmailColumn> for AFWebUserWithObfuscatedName {
fn from(val: AFWebUserWithEmailColumn) -> Self {
let obfuscated_name = if val.name == val.email {
mask_web_user_email(&val.email)
} else {
val.name.clone()
};
AFWebUserWithObfuscatedName {
uuid: val.uuid,
name: obfuscated_name,
avatar_url: val.avatar_url,
}
}
}
pub struct AFGlobalCommentRow {
pub user: Option<AFWebUserColumn>,
pub user: Option<AFWebUserWithEmailColumn>,
pub created_at: DateTime<Utc>,
pub last_updated_at: DateTime<Utc>,
pub content: String,
@ -358,7 +390,7 @@ impl From<AFGlobalCommentRow> for GlobalComment {
pub struct AFReactionRow {
pub reaction_type: String,
pub react_users: Vec<AFWebUserColumn>,
pub react_users: Vec<AFWebUserWithEmailColumn>,
pub comment_id: Uuid,
}
@ -720,3 +752,23 @@ pub struct AFPublishViewWithPublishInfo {
pub comments_enabled: bool,
pub duplicate_enabled: bool,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mask_web_user_email() {
let name = "";
let masked = mask_web_user_email(name);
assert_eq!(masked, "");
let name = "john@domain.com";
let masked = mask_web_user_email(name);
assert_eq!(masked, "john");
let name = "jonathan@domain.com";
let masked = mask_web_user_email(name);
assert_eq!(masked, "jonath");
}
}

View file

@ -11,8 +11,8 @@ use uuid::Uuid;
use crate::pg_row::{
AFGlobalCommentRow, AFImportTask, AFPermissionRow, AFReactionRow, AFUserProfileRow,
AFWebUserColumn, AFWorkspaceInvitationMinimal, AFWorkspaceMemberPermRow, AFWorkspaceMemberRow,
AFWorkspaceRow,
AFWebUserWithEmailColumn, AFWorkspaceInvitationMinimal, AFWorkspaceMemberPermRow,
AFWorkspaceMemberRow, AFWorkspaceRow,
};
use crate::user::select_uid_from_email;
use app_error::AppError;
@ -1102,7 +1102,7 @@ pub async fn select_comments_for_published_view_ordered_by_recency<
avc.content,
avc.reply_comment_id,
avc.is_deleted,
(au.uuid, au.name, au.metadata ->> 'icon_url') AS "user: AFWebUserColumn",
(au.uuid, au.name, au.email, au.metadata ->> 'icon_url') AS "user: AFWebUserWithEmailColumn",
(NOT avc.is_deleted AND ($2 OR au.uuid = $3)) AS "can_be_deleted!"
FROM af_published_view_comment avc
LEFT OUTER JOIN af_user au ON avc.created_by = au.uid
@ -1188,7 +1188,7 @@ pub async fn select_reactions_for_published_view_ordered_by_reaction_type_creati
SELECT
avr.comment_id,
avr.reaction_type,
ARRAY_AGG((au.uuid, au.name, au.metadata ->> 'icon_url')) AS "react_users!: Vec<AFWebUserColumn>"
ARRAY_AGG((au.uuid, au.name, au.email, au.metadata ->> 'icon_url')) AS "react_users!: Vec<AFWebUserWithEmailColumn>"
FROM af_published_view_reaction avr
INNER JOIN af_user au ON avr.created_by = au.uid
WHERE view_id = $1
@ -1216,7 +1216,7 @@ pub async fn select_reactions_for_comment_ordered_by_reaction_type_creation_time
r#"
SELECT
avr.reaction_type,
ARRAY_AGG((au.uuid, au.name, au.metadata ->> 'icon_url')) AS "react_users!: Vec<AFWebUserColumn>",
ARRAY_AGG((au.uuid, au.name, au.email, au.metadata ->> 'icon_url')) AS "react_users!: Vec<AFWebUserWithEmailColumn>",
avr.comment_id
FROM af_published_view_reaction avr
INNER JOIN af_user au ON avr.created_by = au.uid

View file

@ -18,6 +18,7 @@ use collab_entity::CollabType;
use collab_folder::{CollabOrigin, Folder, UserId};
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use shared_entity::dto::auth_dto::UpdateUserParams;
use shared_entity::dto::publish_dto::PublishDatabaseData;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
@ -471,13 +472,22 @@ async fn test_publish_doc() {
#[tokio::test]
async fn test_publish_comments() {
let (page_owner_client, page_owner) = generate_unique_registered_user_client().await;
let (page_owner_client, _) = generate_unique_registered_user_client().await;
let workspace_id = get_first_workspace(&page_owner_client).await;
let published_view_namespace = Uuid::new_v4().to_string();
page_owner_client
.set_workspace_publish_namespace(&workspace_id, published_view_namespace)
.await
.unwrap();
page_owner_client
.update_user(UpdateUserParams {
name: Some("PageOwner".to_string()),
password: None,
email: None,
metadata: None,
})
.await
.unwrap();
let publish_name = "published-view";
let view_id = Uuid::new_v4();
@ -516,7 +526,7 @@ async fn test_publish_comments() {
assert_eq!(comments[0].content, page_owner_comment_content);
}
let (first_user_client, first_user) = generate_unique_registered_user_client().await;
let (first_user_client, _) = generate_unique_registered_user_client().await;
let first_user_comment_content = "comment from first authenticated user";
// This is to ensure that the second comment creation timestamp is later than the first one
sleep(Duration::from_millis(1));
@ -524,6 +534,15 @@ async fn test_publish_comments() {
.create_comment_on_published_view(&view_id, first_user_comment_content, &None)
.await
.unwrap();
first_user_client
.update_user(UpdateUserParams {
name: Some("User1".to_string()),
password: None,
email: None,
metadata: None,
})
.await
.unwrap();
let guest_client = localhost_client();
let result = guest_client
.create_comment_on_published_view(&view_id, "comment from anonymous", &None)
@ -571,10 +590,7 @@ async fn test_publish_comments() {
.unwrap_or("".to_string())
})
.collect_vec();
assert_eq!(
comment_creators,
vec![first_user.email.clone(), page_owner.email.clone()]
);
assert_eq!(comment_creators, vec!["User1", "PageOwner"]);
let comment_content = published_view_comments
.iter()
.map(|c| c.content.clone())
@ -586,7 +602,16 @@ async fn test_publish_comments() {
// Test if it's possible to reply to another user's comment
let second_user_comment_content = "comment from second authenticated user";
let (second_user_client, second_user) = generate_unique_registered_user_client().await;
let (second_user_client, _) = generate_unique_registered_user_client().await;
second_user_client
.update_user(UpdateUserParams {
name: Some("User2".to_string()),
password: None,
email: None,
metadata: None,
})
.await
.unwrap();
{
let published_view_comments: Vec<GlobalComment> = guest_client
.get_published_view_comments(&view_id)
@ -626,14 +651,7 @@ async fn test_publish_comments() {
.unwrap_or("".to_string())
})
.collect_vec();
assert_eq!(
comment_creators,
vec![
second_user.email.clone(),
first_user.email.clone(),
page_owner.email.clone()
]
);
assert_eq!(comment_creators, vec!["User2", "User1", "PageOwner"]);
assert_eq!(
published_view_comments[0].reply_comment_id,
Some(published_view_comments[1].comment_id)