diff --git a/migrations/V0002__actor_profile__add_extra_fields.sql b/migrations/V0002__actor_profile__add_extra_fields.sql new file mode 100644 index 0000000..43458f4 --- /dev/null +++ b/migrations/V0002__actor_profile__add_extra_fields.sql @@ -0,0 +1 @@ +ALTER TABLE actor_profile ADD COLUMN extra_fields JSONB NOT NULL DEFAULT '[]'; diff --git a/migrations/schema.sql b/migrations/schema.sql index 66ac352..1b00fe0 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -7,6 +7,7 @@ CREATE TABLE actor_profile ( bio_source TEXT, avatar_file_name VARCHAR(100), banner_file_name VARCHAR(100), + extra_fields JSONB NOT NULL DEFAULT '[]', follower_count INTEGER NOT NULL CHECK (follower_count >= 0) DEFAULT 0, following_count INTEGER NOT NULL CHECK (following_count >= 0) DEFAULT 0, post_count INTEGER NOT NULL CHECK (post_count >= 0) DEFAULT 0, diff --git a/src/activitypub/receiver.rs b/src/activitypub/receiver.rs index 3809b98..7cc3492 100644 --- a/src/activitypub/receiver.rs +++ b/src/activitypub/receiver.rs @@ -152,6 +152,7 @@ pub async fn receive_activity( bio_source: actor.summary, avatar, banner, + extra_fields: vec![], }; profile_data.clean()?; update_profile(db_client, &profile.id, profile_data).await?; diff --git a/src/mastodon_api/accounts/types.rs b/src/mastodon_api/accounts/types.rs index f96ee36..0b139d5 100644 --- a/src/mastodon_api/accounts/types.rs +++ b/src/mastodon_api/accounts/types.rs @@ -4,13 +4,18 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::models::profiles::types::{DbActorProfile, ProfileUpdateData}; +use crate::models::profiles::types::{ + DbActorProfile, + ExtraField, + ProfileUpdateData, +}; use crate::utils::files::{FileError, save_validated_b64_file, get_file_url}; /// https://docs.joinmastodon.org/entities/source/ #[derive(Serialize)] pub struct Source { pub note: Option, + pub fields: Vec, } /// https://docs.joinmastodon.org/entities/account/ @@ -24,6 +29,7 @@ pub struct Account { pub note: Option, pub avatar: Option, pub header: Option, + pub fields: Vec, pub followers_count: i32, pub following_count: i32, pub statuses_count: i32, @@ -39,7 +45,10 @@ impl Account { // Remote actor None } else { - let source = Source { note: profile.bio_source }; + let source = Source { + note: profile.bio_source, + fields: profile.extra_fields.clone().unpack(), + }; Some(source) }; Self { @@ -51,6 +60,7 @@ impl Account { note: profile.bio, avatar: avatar_url, header: header_url, + fields: profile.extra_fields.unpack(), followers_count: profile.follower_count, following_count: profile.following_count, statuses_count: profile.post_count, @@ -67,6 +77,7 @@ pub struct AccountUpdateData { pub note_source: Option, pub avatar: Option, pub header: Option, + pub fields_attributes: Option>, } fn process_b64_image_field_value( @@ -106,12 +117,14 @@ impl AccountUpdateData { let banner = process_b64_image_field_value( self.header, current_banner.clone(), media_dir, )?; + let extra_fields = self.fields_attributes.unwrap_or(vec![]); let profile_data = ProfileUpdateData { display_name: self.display_name, bio: self.note, bio_source: self.note_source, avatar, banner, + extra_fields, }; Ok(profile_data) } diff --git a/src/models/profiles/queries.rs b/src/models/profiles/queries.rs index 459e9ff..a6fdbc7 100644 --- a/src/models/profiles/queries.rs +++ b/src/models/profiles/queries.rs @@ -2,7 +2,12 @@ use tokio_postgres::GenericClient; use uuid::Uuid; use crate::errors::DatabaseError; -use super::types::{DbActorProfile, ProfileCreateData, ProfileUpdateData}; +use super::types::{ + ExtraFields, + DbActorProfile, + ProfileCreateData, + ProfileUpdateData, +}; /// Create new profile using given Client or Transaction. pub async fn create_profile( @@ -56,8 +61,9 @@ pub async fn update_profile( bio = $2, bio_source = $3, avatar_file_name = $4, - banner_file_name = $5 - WHERE id = $6 + banner_file_name = $5, + extra_fields = $6 + WHERE id = $7 RETURNING actor_profile ", &[ @@ -66,6 +72,7 @@ pub async fn update_profile( &data.bio_source, &data.avatar, &data.banner, + &ExtraFields(data.extra_fields), &profile_id, ], ).await?; diff --git a/src/models/profiles/types.rs b/src/models/profiles/types.rs index c6565f7..6b37006 100644 --- a/src/models/profiles/types.rs +++ b/src/models/profiles/types.rs @@ -1,11 +1,53 @@ use chrono::{DateTime, Utc}; -use postgres_types::FromSql; +use postgres_types::{ + FromSql, ToSql, IsNull, Type, Json, + accepts, to_sql_checked, + private::BytesMut, +}; +use serde::{Deserialize, Serialize}; use serde_json::Value; use uuid::Uuid; use crate::errors::ValidationError; use crate::utils::html::clean_html; +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ExtraField { + pub name: String, + pub value: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct ExtraFields(pub Vec); + +impl ExtraFields { + pub fn unpack(self) -> Vec { + let Self(extra_fields) = self; + extra_fields + } +} + +type SqlError = Box; + +impl<'a> FromSql<'a> for ExtraFields { + fn from_sql(ty: &Type, raw: &'a [u8]) -> Result { + let Json(json_value) = Json::::from_sql(ty, raw)?; + let fields: Self = serde_json::from_value(json_value)?; + Ok(fields) + } + accepts!(JSON,JSONB); +} + +impl ToSql for ExtraFields { + fn to_sql(&self, ty: &Type, out: &mut BytesMut) -> Result { + let value = serde_json::to_value(self)?; + Json(value).to_sql(ty, out) + } + + accepts!(JSON, JSONB); + to_sql_checked!(); +} + #[derive(Clone, FromSql)] #[postgres(name = "actor_profile")] pub struct DbActorProfile { @@ -17,6 +59,7 @@ pub struct DbActorProfile { pub bio_source: Option, // plaintext or markdown pub avatar_file_name: Option, pub banner_file_name: Option, + pub extra_fields: ExtraFields, pub follower_count: i32, pub following_count: i32, pub post_count: i32, @@ -40,12 +83,28 @@ pub struct ProfileUpdateData { pub bio_source: Option, pub avatar: Option, pub banner: Option, + pub extra_fields: Vec, } impl ProfileUpdateData { - /// Validate and clean bio. pub fn clean(&mut self) -> Result<(), ValidationError> { + // Validate and clean bio self.bio = self.bio.as_ref().map(|val| clean_html(val)); + // Remove fields with empty labels + self.extra_fields = self.extra_fields.iter().cloned() + .filter(|field| field.name.trim().len() > 0) + .collect(); + // Validate extra fields + if self.extra_fields.len() >= 10 { + return Err(ValidationError("at most 10 fields are allowed")); + } + let mut unique_labels: Vec = self.extra_fields.iter() + .map(|field| field.name.clone()).collect(); + unique_labels.sort(); + unique_labels.dedup(); + if unique_labels.len() < self.extra_fields.len() { + return Err(ValidationError("duplicate labels")); + } Ok(()) } }