diff --git a/docs/openapi.yaml b/docs/openapi.yaml index d5fce62..bb60f47 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -664,6 +664,16 @@ components: description: The location of the user's profile page. type: string example: https://example.com/@user + identity_proofs: + description: Identity proofs. + type: array + items: + $ref: '#/components/schemas/Field' + fields: + description: Additional metadata attached to a profile as name-value pairs. + type: array + items: + $ref: '#/components/schemas/Field' wallet_address: description: Ethereum wallet address. type: string @@ -685,6 +695,19 @@ components: url: description: The location of the original full-size attachment. type: string + Field: + type: object + properties: + name: + description: The key of a given field's key-value pair. + type: string + value: + description: The value associated with the name key. + type: string + verified_at: + description: Timestamp of when the server verified the field value. + type: string + format: dateTime Instance: type: object properties: diff --git a/migrations/V0023__actor_profile__add_identity_proofs.sql b/migrations/V0023__actor_profile__add_identity_proofs.sql new file mode 100644 index 0000000..bed0822 --- /dev/null +++ b/migrations/V0023__actor_profile__add_identity_proofs.sql @@ -0,0 +1 @@ +ALTER TABLE actor_profile ADD COLUMN identity_proofs JSONB NOT NULL DEFAULT '[]'; diff --git a/migrations/schema.sql b/migrations/schema.sql index d082be3..0f2ec4c 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), + identity_proofs 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/fetchers.rs b/src/activitypub/fetcher/fetchers.rs index b9f06ed..ec3c199 100644 --- a/src/activitypub/fetcher/fetchers.rs +++ b/src/activitypub/fetcher/fetchers.rs @@ -159,6 +159,7 @@ pub async fn fetch_profile_by_actor_id( bio: actor.summary.clone(), avatar, banner, + identity_proofs: vec![], extra_fields, actor_json: Some(actor), }; diff --git a/src/activitypub/receiver.rs b/src/activitypub/receiver.rs index a9982fb..a8dade4 100644 --- a/src/activitypub/receiver.rs +++ b/src/activitypub/receiver.rs @@ -720,6 +720,7 @@ pub async fn receive_activity( bio_source: actor.summary.clone(), avatar, banner, + identity_proofs: vec![], extra_fields, actor_json: Some(actor), }; diff --git a/src/mastodon_api/accounts/types.rs b/src/mastodon_api/accounts/types.rs index 8f9823d..c8396db 100644 --- a/src/mastodon_api/accounts/types.rs +++ b/src/mastodon_api/accounts/types.rs @@ -5,9 +5,11 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::errors::ValidationError; +use crate::models::profiles::currencies::get_identity_proof_field_name; use crate::models::profiles::types::{ DbActorProfile, ExtraField, + IdentityProof, ProfileUpdateData, }; use crate::models::profiles::validators::validate_username; @@ -17,10 +19,12 @@ use crate::models::users::types::{ }; use crate::utils::files::{FileError, save_validated_b64_file, get_file_url}; +/// https://docs.joinmastodon.org/entities/field/ #[derive(Serialize)] pub struct AccountField { pub name: String, pub value: String, + verified_at: Option>, } /// https://docs.joinmastodon.org/entities/source/ @@ -42,6 +46,7 @@ pub struct Account { pub note: Option, pub avatar: Option, pub header: Option, + pub identity_proofs: Vec, pub fields: Vec, pub followers_count: i32, pub following_count: i32, @@ -59,9 +64,30 @@ impl Account { .map(|name| get_file_url(instance_url, name)); let header_url = profile.banner_file_name.as_ref() .map(|name| get_file_url(instance_url, name)); - let fields = profile.extra_fields.into_inner().into_iter() - .map(|field| AccountField { name: field.name, value: field.value }) - .collect(); + + let mut identity_proofs = vec![]; + for proof in profile.identity_proofs.into_inner() { + // Skip proof if it doesn't map to field name + if let Some(field_name) = get_identity_proof_field_name(&proof.proof_type) { + let field = AccountField { + name: field_name, + value: proof.issuer.address, + // Use current time because DID proofs are always valid + verified_at: Some(Utc::now()), + }; + identity_proofs.push(field); + }; + }; + let mut extra_fields = vec![]; + for extra_field in profile.extra_fields.into_inner() { + let field = AccountField { + name: extra_field.name, + value: extra_field.value, + verified_at: None, + }; + extra_fields.push(field); + }; + Self { id: profile.id, username: profile.username, @@ -72,7 +98,8 @@ impl Account { note: profile.bio, avatar: avatar_url, header: header_url, - fields, + identity_proofs, + fields: extra_fields, followers_count: profile.follower_count, following_count: profile.following_count, statuses_count: profile.post_count, @@ -87,6 +114,7 @@ impl Account { .map(|field| AccountField { name: field.name, value: field.value_source.unwrap_or(field.value), + verified_at: None, }) .collect(); let source = Source { @@ -163,6 +191,7 @@ impl AccountUpdateData { self, current_avatar: &Option, current_banner: &Option, + current_identity_proofs: &[IdentityProof], media_dir: &Path, ) -> Result { let avatar = process_b64_image_field_value( @@ -171,6 +200,7 @@ impl AccountUpdateData { let banner = process_b64_image_field_value( self.header, current_banner.clone(), media_dir, )?; + let identity_proofs = current_identity_proofs.to_vec(); let extra_fields = self.fields_attributes.unwrap_or(vec![]); let profile_data = ProfileUpdateData { display_name: self.display_name, @@ -178,8 +208,9 @@ impl AccountUpdateData { bio_source: self.note_source, avatar, banner, + identity_proofs, extra_fields, - actor_json: None, + actor_json: None, // always None for local profiles }; Ok(profile_data) } diff --git a/src/mastodon_api/accounts/views.rs b/src/mastodon_api/accounts/views.rs index 77fb877..bab1572 100644 --- a/src/mastodon_api/accounts/views.rs +++ b/src/mastodon_api/accounts/views.rs @@ -15,6 +15,7 @@ use crate::errors::{DatabaseError, HttpError, ValidationError}; use crate::ethereum::eip4361::verify_eip4361_signature; use crate::ethereum::gate::is_allowed_user; use crate::ethereum::identity::{ + ETHEREUM_EIP191_PROOF, DidPkh, create_identity_claim, verify_identity_proof, @@ -32,6 +33,7 @@ use crate::models::profiles::queries::{ get_wallet_address, update_profile, }; +use crate::models::profiles::types::{IdentityProof, ProfileUpdateData}; use crate::models::relationships::queries::{ create_follow_request, follow, @@ -184,6 +186,7 @@ async fn update_credentials( .into_profile_data( ¤t_user.profile.avatar_file_name, ¤t_user.profile.banner_file_name, + ¤t_user.profile.identity_proofs.into_inner(), &config.media_dir(), ) .map_err(|err| { @@ -246,7 +249,7 @@ async fn create_identity_proof( proof_data: web::Json, ) -> Result { let db_client = &**get_database_client(&db_pool).await?; - let current_user = get_current_user(db_client, auth.token()).await?; + let mut current_user = get_current_user(db_client, auth.token()).await?; let actor_id = current_user.profile.actor_id(&config.instance_url()); let wallet_address = current_user.wallet_address.as_ref() .ok_or(HttpError::PermissionError)?; @@ -256,6 +259,29 @@ async fn create_identity_proof( &did, &proof_data.signature, )?; + let proof = IdentityProof { + issuer: did, + proof_type: ETHEREUM_EIP191_PROOF.to_string(), + value: proof_data.signature.clone(), + }; + let mut profile_data = ProfileUpdateData::from(¤t_user.profile); + match profile_data.identity_proofs.iter_mut() + .find(|item| item.issuer == proof.issuer) { + Some(mut item) => { + // Replace + item.proof_type = proof.proof_type; + item.value = proof.value; + }, + None => { + // Add new proof + profile_data.identity_proofs.push(proof); + }, + }; + current_user.profile = update_profile( + db_client, + ¤t_user.id, + profile_data, + ).await?; let account = Account::from_user(current_user, &config.instance_url()); Ok(HttpResponse::Ok().json(account)) } diff --git a/src/models/profiles/currencies.rs b/src/models/profiles/currencies.rs new file mode 100644 index 0000000..de94a92 --- /dev/null +++ b/src/models/profiles/currencies.rs @@ -0,0 +1,13 @@ +use crate::ethereum::identity::ETHEREUM_EIP191_PROOF; + +pub fn get_currency_field_name(currency_code: &str) -> String { + format!("${}", currency_code.to_uppercase()) +} + +pub fn get_identity_proof_field_name(proof_type: &str) -> Option { + let field_name = match proof_type { + ETHEREUM_EIP191_PROOF => "$ETH".to_string(), + _ => return None, + }; + Some(field_name) +} diff --git a/src/models/profiles/mod.rs b/src/models/profiles/mod.rs index a35947b..5a9002e 100644 --- a/src/models/profiles/mod.rs +++ b/src/models/profiles/mod.rs @@ -1,3 +1,4 @@ +pub mod currencies; pub mod queries; pub mod types; pub mod validators; diff --git a/src/models/profiles/queries.rs b/src/models/profiles/queries.rs index 4f94b7e..9dca240 100644 --- a/src/models/profiles/queries.rs +++ b/src/models/profiles/queries.rs @@ -10,10 +10,11 @@ use crate::models::cleanup::{ }; use crate::models::relationships::types::RelationshipType; use crate::utils::id::new_uuid; +use super::currencies::get_currency_field_name; use super::types::{ - get_currency_field_name, - ExtraFields, DbActorProfile, + ExtraFields, + IdentityProofs, ProfileCreateData, ProfileUpdateData, }; @@ -24,15 +25,15 @@ pub async fn create_profile( profile_data: ProfileCreateData, ) -> Result { let profile_id = new_uuid(); - let extra_fields = ExtraFields(profile_data.extra_fields.clone()); let row = db_client.query_one( " INSERT INTO actor_profile ( id, username, display_name, acct, bio, bio_source, - avatar_file_name, banner_file_name, extra_fields, + avatar_file_name, banner_file_name, + identity_proofs, extra_fields, actor_json ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING actor_profile ", &[ @@ -44,7 +45,8 @@ pub async fn create_profile( &profile_data.bio, &profile_data.avatar, &profile_data.banner, - &extra_fields, + &IdentityProofs(profile_data.identity_proofs), + &ExtraFields(profile_data.extra_fields), &profile_data.actor_json, ], ).await.map_err(catch_unique_violation("profile"))?; @@ -66,9 +68,10 @@ pub async fn update_profile( bio_source = $3, avatar_file_name = $4, banner_file_name = $5, - extra_fields = $6, - actor_json = $7 - WHERE id = $8 + identity_proofs = $6, + extra_fields = $7, + actor_json = $8 + WHERE id = $9 RETURNING actor_profile ", &[ @@ -77,6 +80,7 @@ pub async fn update_profile( &data.bio_source, &data.avatar, &data.banner, + &IdentityProofs(data.identity_proofs), &ExtraFields(data.extra_fields), &data.actor_json, &profile_id, @@ -490,6 +494,8 @@ mod tests { let db_client = create_test_database().await; let profile = create_profile(&db_client, profile_data).await.unwrap(); assert_eq!(profile.username, "test"); + assert_eq!(profile.identity_proofs.into_inner().len(), 0); + assert_eq!(profile.extra_fields.into_inner().len(), 0); } #[tokio::test] diff --git a/src/models/profiles/types.rs b/src/models/profiles/types.rs index 2157b0e..5eff4da 100644 --- a/src/models/profiles/types.rs +++ b/src/models/profiles/types.rs @@ -52,10 +52,6 @@ impl ExtraFields { } } -pub fn get_currency_field_name(currency_code: &str) -> String { - format!("${}", currency_code.to_uppercase()) -} - json_from_sql!(ExtraFields); json_to_sql!(ExtraFields); @@ -73,6 +69,7 @@ pub struct DbActorProfile { pub bio_source: Option, // plaintext or markdown pub avatar_file_name: Option, pub banner_file_name: Option, + pub identity_proofs: IdentityProofs, pub extra_fields: ExtraFields, pub follower_count: i32, pub following_count: i32, @@ -128,6 +125,7 @@ impl Default for DbActorProfile { bio_source: None, avatar_file_name: None, banner_file_name: None, + identity_proofs: IdentityProofs(vec![]), extra_fields: ExtraFields(vec![]), follower_count: 0, following_count: 0, @@ -147,6 +145,7 @@ pub struct ProfileCreateData { pub bio: Option, pub avatar: Option, pub banner: Option, + pub identity_proofs: Vec, pub extra_fields: Vec, pub actor_json: Option, } @@ -172,6 +171,7 @@ pub struct ProfileUpdateData { pub bio_source: Option, pub avatar: Option, pub banner: Option, + pub identity_proofs: Vec, pub extra_fields: Vec, pub actor_json: Option, } @@ -192,6 +192,22 @@ impl ProfileUpdateData { } } +impl From<&DbActorProfile> for ProfileUpdateData { + fn from(profile: &DbActorProfile) -> Self { + let profile = profile.clone(); + Self { + display_name: profile.display_name, + bio: profile.bio, + bio_source: profile.bio_source, + avatar: profile.avatar_file_name, + banner: profile.banner_file_name, + identity_proofs: profile.identity_proofs.into_inner(), + extra_fields: profile.extra_fields.into_inner(), + actor_json: profile.actor_json, + } + } +} + #[cfg(test)] mod tests { use crate::activitypub::actor::Actor; diff --git a/src/models/users/queries.rs b/src/models/users/queries.rs index fef20bd..eea89f4 100644 --- a/src/models/users/queries.rs +++ b/src/models/users/queries.rs @@ -80,6 +80,7 @@ pub async fn create_user( bio: None, avatar: None, banner: None, + identity_proofs: vec![], extra_fields: vec![], actor_json: None, };