diff --git a/migrations/V0026__actor_profile__add_payment_options.sql b/migrations/V0026__actor_profile__add_payment_options.sql new file mode 100644 index 0000000..e460808 --- /dev/null +++ b/migrations/V0026__actor_profile__add_payment_options.sql @@ -0,0 +1 @@ +ALTER TABLE actor_profile ADD COLUMN payment_options JSONB NOT NULL DEFAULT '[]'; diff --git a/migrations/schema.sql b/migrations/schema.sql index 9f3b78f..ec9ad0d 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -8,6 +8,7 @@ CREATE TABLE actor_profile ( avatar_file_name VARCHAR(100), banner_file_name VARCHAR(100), identity_proofs JSONB NOT NULL DEFAULT '[]', + payment_options JSONB NOT NULL DEFAULT '[]', 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, diff --git a/src/activitypub/fetcher/helpers.rs b/src/activitypub/fetcher/helpers.rs index 4852979..47e98c9 100644 --- a/src/activitypub/fetcher/helpers.rs +++ b/src/activitypub/fetcher/helpers.rs @@ -78,6 +78,7 @@ async fn prepare_remote_profile_data( avatar, banner, identity_proofs, + payment_options: vec![], extra_fields, actor_json: Some(actor), }; diff --git a/src/activitypub/handlers/update_person.rs b/src/activitypub/handlers/update_person.rs index 9b90128..6827301 100644 --- a/src/activitypub/handlers/update_person.rs +++ b/src/activitypub/handlers/update_person.rs @@ -64,6 +64,7 @@ pub async fn update_remote_profile( avatar, banner, identity_proofs, + payment_options: vec![], extra_fields, actor_json: Some(actor), }; diff --git a/src/mastodon_api/accounts/types.rs b/src/mastodon_api/accounts/types.rs index d5fee6e..6e8e8d6 100644 --- a/src/mastodon_api/accounts/types.rs +++ b/src/mastodon_api/accounts/types.rs @@ -10,6 +10,7 @@ use crate::models::profiles::types::{ DbActorProfile, ExtraField, IdentityProof, + PaymentOption, ProfileUpdateData, }; use crate::models::profiles::validators::validate_username; @@ -194,6 +195,7 @@ impl AccountUpdateData { current_avatar: &Option, current_banner: &Option, current_identity_proofs: &[IdentityProof], + current_payment_options: &[PaymentOption], media_dir: &Path, ) -> Result { let avatar = process_b64_image_field_value( @@ -203,6 +205,7 @@ impl AccountUpdateData { self.header, current_banner.clone(), media_dir, )?; let identity_proofs = current_identity_proofs.to_vec(); + let payment_options = current_payment_options.to_vec(); let extra_fields = self.fields_attributes.unwrap_or(vec![]); let profile_data = ProfileUpdateData { display_name: self.display_name, @@ -211,6 +214,7 @@ impl AccountUpdateData { avatar, banner, identity_proofs, + payment_options, extra_fields, actor_json: None, // always None for local profiles }; diff --git a/src/mastodon_api/accounts/views.rs b/src/mastodon_api/accounts/views.rs index f4b418b..000cb72 100644 --- a/src/mastodon_api/accounts/views.rs +++ b/src/mastodon_api/accounts/views.rs @@ -28,7 +28,10 @@ use crate::models::profiles::queries::{ get_profile_by_id, update_profile, }; -use crate::models::profiles::types::{IdentityProof, ProfileUpdateData}; +use crate::models::profiles::types::{ + IdentityProof, + ProfileUpdateData, +}; use crate::models::relationships::queries::{ create_follow_request, follow, @@ -184,6 +187,7 @@ async fn update_credentials( ¤t_user.profile.avatar_file_name, ¤t_user.profile.banner_file_name, ¤t_user.profile.identity_proofs.into_inner(), + ¤t_user.profile.payment_options.into_inner(), &config.media_dir(), ) .map_err(|err| { diff --git a/src/models/profiles/queries.rs b/src/models/profiles/queries.rs index 681e7e2..32d3e62 100644 --- a/src/models/profiles/queries.rs +++ b/src/models/profiles/queries.rs @@ -16,6 +16,7 @@ use super::types::{ DbActorProfile, ExtraFields, IdentityProofs, + PaymentOptions, ProfileCreateData, ProfileUpdateData, }; @@ -31,10 +32,10 @@ pub async fn create_profile( INSERT INTO actor_profile ( id, username, display_name, acct, bio, bio_source, avatar_file_name, banner_file_name, - identity_proofs, extra_fields, + identity_proofs, payment_options, extra_fields, actor_json ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING actor_profile ", &[ @@ -47,6 +48,7 @@ pub async fn create_profile( &profile_data.avatar, &profile_data.banner, &IdentityProofs(profile_data.identity_proofs), + &PaymentOptions(profile_data.payment_options), &ExtraFields(profile_data.extra_fields), &profile_data.actor_json, ], @@ -70,10 +72,11 @@ pub async fn update_profile( avatar_file_name = $4, banner_file_name = $5, identity_proofs = $6, - extra_fields = $7, - actor_json = $8, + payment_options = $7, + extra_fields = $8, + actor_json = $9, updated_at = CURRENT_TIMESTAMP - WHERE id = $9 + WHERE id = $10 RETURNING actor_profile ", &[ @@ -83,6 +86,7 @@ pub async fn update_profile( &data.avatar, &data.banner, &IdentityProofs(data.identity_proofs), + &PaymentOptions(data.payment_options), &ExtraFields(data.extra_fields), &data.actor_json, &profile_id, diff --git a/src/models/profiles/types.rs b/src/models/profiles/types.rs index 6430717..5753f2f 100644 --- a/src/models/profiles/types.rs +++ b/src/models/profiles/types.rs @@ -1,16 +1,22 @@ +use std::convert::TryFrom; + use chrono::{DateTime, Duration, Utc}; use postgres_types::FromSql; -use serde::{Deserialize, Serialize}; +use serde::{ + Deserialize, Deserializer, Serialize, Serializer, + de::Error as DeserializerError, +}; use uuid::Uuid; use crate::activitypub::actors::types::Actor; use crate::activitypub::identifiers::local_actor_id; use crate::database::json_macro::{json_from_sql, json_to_sql}; -use crate::errors::ValidationError; +use crate::errors::{ConversionError, ValidationError}; use crate::ethereum::identity::DidPkh; use super::validators::{ validate_username, validate_display_name, + validate_payment_options, clean_bio, clean_extra_fields, }; @@ -35,6 +41,72 @@ impl IdentityProofs { json_from_sql!(IdentityProofs); json_to_sql!(IdentityProofs); +#[derive(Clone, Debug)] +pub enum PaymentType { + Link, + EthereumSubscription, +} + +impl From<&PaymentType> for i16 { + fn from(payment_type: &PaymentType) -> i16 { + match payment_type { + PaymentType::Link => 1, + PaymentType::EthereumSubscription => 2, + } + } +} + +impl TryFrom for PaymentType { + type Error = ConversionError; + + fn try_from(value: i16) -> Result { + let payment_type = match value { + 1 => Self::Link, + 2 => Self::EthereumSubscription, + _ => return Err(ConversionError), + }; + Ok(payment_type) + } +} + +impl Serialize for PaymentType { + fn serialize(&self, serializer: S) -> Result + where S: Serializer + { + let value: i16 = self.into(); + serializer.serialize_i16(value) + } +} + +impl<'de> Deserialize<'de> for PaymentType { + fn deserialize(deserializer: D) -> Result + where D: Deserializer<'de> + { + let value: i16 = Deserialize::deserialize(deserializer)?; + Self::try_from(value).map_err(DeserializerError::custom) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PaymentOption { + pub payment_type: PaymentType, + pub name: Option, + pub href: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct PaymentOptions(pub Vec); + +impl PaymentOptions { + pub fn into_inner(self) -> Vec { + let Self(payment_options) = self; + payment_options + } +} + +json_from_sql!(PaymentOptions); +json_to_sql!(PaymentOptions); + #[derive(Clone, Debug, Deserialize, Serialize)] pub struct ExtraField { pub name: String, @@ -70,6 +142,7 @@ pub struct DbActorProfile { pub avatar_file_name: Option, pub banner_file_name: Option, pub identity_proofs: IdentityProofs, + pub payment_options: PaymentOptions, pub extra_fields: ExtraFields, pub follower_count: i32, pub following_count: i32, @@ -143,6 +216,7 @@ impl Default for DbActorProfile { avatar_file_name: None, banner_file_name: None, identity_proofs: IdentityProofs(vec![]), + payment_options: PaymentOptions(vec![]), extra_fields: ExtraFields(vec![]), follower_count: 0, following_count: 0, @@ -164,6 +238,7 @@ pub struct ProfileCreateData { pub avatar: Option, pub banner: Option, pub identity_proofs: Vec, + pub payment_options: Vec, pub extra_fields: Vec, pub actor_json: Option, } @@ -178,6 +253,7 @@ impl ProfileCreateData { let cleaned_bio = clean_bio(bio, self.actor_json.is_some())?; self.bio = Some(cleaned_bio); }; + validate_payment_options(&self.payment_options)?; self.extra_fields = clean_extra_fields(&self.extra_fields)?; Ok(()) } @@ -190,6 +266,7 @@ pub struct ProfileUpdateData { pub avatar: Option, pub banner: Option, pub identity_proofs: Vec, + pub payment_options: Vec, pub extra_fields: Vec, pub actor_json: Option, } @@ -204,7 +281,7 @@ impl ProfileUpdateData { let cleaned_bio = clean_bio(bio, self.actor_json.is_some())?; self.bio = Some(cleaned_bio); }; - // Clean extra fields and remove fields with empty labels + validate_payment_options(&self.payment_options)?; self.extra_fields = clean_extra_fields(&self.extra_fields)?; Ok(()) } @@ -220,6 +297,7 @@ impl From<&DbActorProfile> for ProfileUpdateData { avatar: profile.avatar_file_name, banner: profile.banner_file_name, identity_proofs: profile.identity_proofs.into_inner(), + payment_options: profile.payment_options.into_inner(), extra_fields: profile.extra_fields.into_inner(), actor_json: profile.actor_json, } @@ -242,6 +320,18 @@ mod tests { assert_eq!(serialized, json_data); } + #[test] + fn test_payment_option_serialization() { + let json_data = r#"{"payment_type":2,"name":null,"href":null}"#; + let payment_option: PaymentOption = serde_json::from_str(json_data).unwrap(); + assert!(matches!( + payment_option.payment_type, + PaymentType::EthereumSubscription, + )); + let serialized = serde_json::to_string(&payment_option).unwrap(); + assert_eq!(serialized, json_data); + } + #[test] fn test_local_actor_address() { let local_profile = DbActorProfile { diff --git a/src/models/profiles/validators.rs b/src/models/profiles/validators.rs index e36b4f2..8975956 100644 --- a/src/models/profiles/validators.rs +++ b/src/models/profiles/validators.rs @@ -1,7 +1,7 @@ use regex::Regex; use crate::errors::ValidationError; use crate::utils::html::{clean_html, clean_html_strict}; -use super::types::ExtraField; +use super::types::{ExtraField, PaymentOption, PaymentType}; const USERNAME_RE: &str = r"^[a-zA-Z0-9_\.-]+$"; @@ -46,6 +46,26 @@ pub fn clean_bio(bio: &str, is_remote: bool) -> Result Ok(cleaned_bio) } +pub fn validate_payment_options(payment_options: &[PaymentOption]) + -> Result<(), ValidationError> +{ + for option in payment_options { + match option.payment_type { + PaymentType::Link => { + if option.name.is_none() || option.href.is_none() { + return Err(ValidationError("invalid payment option")); + }; + }, + PaymentType::EthereumSubscription => { + if option.name.is_some() || option.href.is_some() { + return Err(ValidationError("invalid payment option")); + }; + }, + }; + }; + Ok(()) +} + const FIELD_NAME_MAX_SIZE: usize = 500; const FIELD_VALUE_MAX_SIZE: usize = 5000; diff --git a/src/models/users/queries.rs b/src/models/users/queries.rs index 80f890e..6237e9d 100644 --- a/src/models/users/queries.rs +++ b/src/models/users/queries.rs @@ -81,6 +81,7 @@ pub async fn create_user( avatar: None, banner: None, identity_proofs: vec![], + payment_options: vec![], extra_fields: vec![], actor_json: None, };