From 020a79704fd301290e6e41228cff190584d698d6 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 29 May 2026 13:59:39 +0200 Subject: [PATCH] refactor: dedup JSONB name/value helpers, add profile fields validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract parse/serialize into postgres::jsonb, used by user, remote_actor, and postgres-federation. Validate profile fields in update_profile use case (max 4, name≤64, value≤256). --- crates/adapters/postgres/src/jsonb.rs | 20 +++++++++++ crates/adapters/postgres/src/lib.rs | 1 + crates/adapters/postgres/src/remote_actor.rs | 20 ++--------- crates/adapters/postgres/src/user/mod.rs | 33 ++++--------------- .../application/src/use_cases/profile/mod.rs | 17 ++++++++++ 5 files changed, 46 insertions(+), 45 deletions(-) create mode 100644 crates/adapters/postgres/src/jsonb.rs diff --git a/crates/adapters/postgres/src/jsonb.rs b/crates/adapters/postgres/src/jsonb.rs new file mode 100644 index 0000000..30425b2 --- /dev/null +++ b/crates/adapters/postgres/src/jsonb.rs @@ -0,0 +1,20 @@ +pub fn parse_name_value(v: Option) -> Vec<(String, String)> { + v.and_then(|v| v.as_array().cloned()) + .map(|arr| { + arr.into_iter() + .filter_map(|item| { + let name = item.get("name")?.as_str()?.to_string(); + let value = item.get("value")?.as_str()?.to_string(); + Some((name, value)) + }) + .collect() + }) + .unwrap_or_default() +} + +pub fn serialize_name_value(fields: &[(String, String)]) -> serde_json::Value { + fields + .iter() + .map(|(n, v)| serde_json::json!({"name": n, "value": v})) + .collect() +} diff --git a/crates/adapters/postgres/src/lib.rs b/crates/adapters/postgres/src/lib.rs index 28087e9..61403b2 100644 --- a/crates/adapters/postgres/src/lib.rs +++ b/crates/adapters/postgres/src/lib.rs @@ -8,6 +8,7 @@ pub mod engagement; pub mod failed_event; pub mod feed; pub mod follow; +pub(crate) mod jsonb; pub mod like; pub mod notification; pub mod outbox; diff --git a/crates/adapters/postgres/src/remote_actor.rs b/crates/adapters/postgres/src/remote_actor.rs index 3987b52..0116af3 100644 --- a/crates/adapters/postgres/src/remote_actor.rs +++ b/crates/adapters/postgres/src/remote_actor.rs @@ -23,11 +23,7 @@ impl RemoteActorRepository for PgRemoteActorRepository { } else { Some(a.also_known_as.iter().map(|s| s.as_str()).collect()) }; - let attachment_json: serde_json::Value = a - .attachment - .iter() - .map(|(n, v)| serde_json::json!({"name": n, "value": v})) - .collect(); + let attachment_json = crate::jsonb::serialize_name_value(&a.attachment); sqlx::query( "INSERT INTO remote_actors(url,handle,display_name,avatar_url,last_fetched_at, bio,banner_url,outbox_url,followers_url,following_url,also_known_as,attachment) @@ -101,19 +97,7 @@ impl RemoteActorRepository for PgRemoteActorRepository { following_url: r.following_url, inbox_url: r.inbox_url, shared_inbox_url: r.shared_inbox_url, - attachment: r - .attachment - .and_then(|v| v.as_array().cloned()) - .map(|arr| { - arr.into_iter() - .filter_map(|item| { - let name = item.get("name")?.as_str()?.to_string(); - let value = item.get("value")?.as_str()?.to_string(); - Some((name, value)) - }) - .collect() - }) - .unwrap_or_default(), + attachment: crate::jsonb::parse_name_value(r.attachment), }) }) } diff --git a/crates/adapters/postgres/src/user/mod.rs b/crates/adapters/postgres/src/user/mod.rs index 77ea0e8..5757a8a 100644 --- a/crates/adapters/postgres/src/user/mod.rs +++ b/crates/adapters/postgres/src/user/mod.rs @@ -37,20 +37,6 @@ pub struct UserRow { pub updated_at: DateTime, } -fn parse_profile_fields(v: Option) -> Vec<(String, String)> { - v.and_then(|v| v.as_array().cloned()) - .map(|arr| { - arr.into_iter() - .filter_map(|item| { - let name = item.get("name")?.as_str()?.to_string(); - let value = item.get("value")?.as_str()?.to_string(); - Some((name, value)) - }) - .collect() - }) - .unwrap_or_default() -} - impl From for User { fn from(r: UserRow) -> Self { User { @@ -63,7 +49,7 @@ impl From for User { avatar_url: r.avatar_url, header_url: r.header_url, custom_css: r.custom_css, - profile_fields: parse_profile_fields(r.profile_fields), + profile_fields: crate::jsonb::parse_name_value(r.profile_fields), local: r.local, created_at: r.created_at, updated_at: r.updated_at, @@ -238,11 +224,7 @@ impl UserReader for PgUserRepository { #[async_trait] impl UserWriter for PgUserRepository { async fn save(&self, user: &User) -> Result<(), DomainError> { - let profile_fields_json: serde_json::Value = user - .profile_fields - .iter() - .map(|(n, v)| serde_json::json!({"name": n, "value": v})) - .collect(); + let profile_fields_json = crate::jsonb::serialize_name_value(&user.profile_fields); sqlx::query( "INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,profile_fields,local,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) @@ -290,13 +272,10 @@ impl UserWriter for PgUserRepository { user_id: &UserId, input: UpdateProfileInput, ) -> Result<(), DomainError> { - let profile_fields_json: Option = - input.profile_fields.as_ref().map(|fields| { - fields - .iter() - .map(|(n, v)| serde_json::json!({"name": n, "value": v})) - .collect() - }); + let profile_fields_json: Option = input + .profile_fields + .as_ref() + .map(|f| crate::jsonb::serialize_name_value(f)); sqlx::query( "UPDATE users SET \ display_name = COALESCE($2, display_name), \ diff --git a/crates/application/src/use_cases/profile/mod.rs b/crates/application/src/use_cases/profile/mod.rs index b742a79..6b92410 100644 --- a/crates/application/src/use_cases/profile/mod.rs +++ b/crates/application/src/use_cases/profile/mod.rs @@ -1,4 +1,7 @@ const MAX_TOP_FRIENDS: usize = 8; +const MAX_PROFILE_FIELDS: usize = 4; +const MAX_FIELD_NAME_LEN: usize = 64; +const MAX_FIELD_VALUE_LEN: usize = 256; use bytes::Bytes; use domain::{ @@ -55,6 +58,20 @@ pub async fn update_profile( user_id: &UserId, input: UpdateProfileInput, ) -> Result<(), DomainError> { + if let Some(ref fields) = input.profile_fields { + if fields.len() > MAX_PROFILE_FIELDS { + return Err(DomainError::InvalidInput(format!( + "profile fields: max {MAX_PROFILE_FIELDS}" + ))); + } + for (name, value) in fields { + if name.len() > MAX_FIELD_NAME_LEN || value.len() > MAX_FIELD_VALUE_LEN { + return Err(DomainError::InvalidInput( + "profile field name or value too long".into(), + )); + } + } + } users.update_profile(user_id, input).await?; events .publish(&DomainEvent::ProfileUpdated {