580 lines
18 KiB
Rust
580 lines
18 KiB
Rust
use std::fmt;
|
|
use std::str::FromStr;
|
|
|
|
use chrono::{DateTime, Duration, Utc};
|
|
use postgres_types::FromSql;
|
|
use serde::{
|
|
Deserialize, Deserializer, Serialize, Serializer,
|
|
de::Error as DeserializerError,
|
|
ser::SerializeMap,
|
|
__private::ser::FlatMapSerializer,
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
use crate::activitypub::actors::types::{Actor, ActorAddress};
|
|
use crate::activitypub::identifiers::local_actor_id;
|
|
use crate::database::{
|
|
json_macro::{json_from_sql, json_to_sql},
|
|
DatabaseTypeError,
|
|
};
|
|
use crate::errors::{ConversionError, ValidationError};
|
|
use crate::identity::{
|
|
did::Did,
|
|
signatures::{PROOF_TYPE_ID_EIP191, PROOF_TYPE_ID_MINISIGN},
|
|
};
|
|
use crate::utils::caip2::ChainId;
|
|
use super::validators::{
|
|
validate_username,
|
|
validate_display_name,
|
|
clean_bio,
|
|
clean_extra_fields,
|
|
};
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum ProofType {
|
|
LegacyEip191IdentityProof,
|
|
LegacyMinisignIdentityProof,
|
|
}
|
|
|
|
impl FromStr for ProofType {
|
|
type Err = ConversionError;
|
|
|
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
|
let proof_type = match value {
|
|
PROOF_TYPE_ID_EIP191 => Self::LegacyEip191IdentityProof,
|
|
PROOF_TYPE_ID_MINISIGN => Self::LegacyMinisignIdentityProof,
|
|
_ => return Err(ConversionError),
|
|
};
|
|
Ok(proof_type)
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for ProofType {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
let proof_type_str = match self {
|
|
Self::LegacyEip191IdentityProof => PROOF_TYPE_ID_EIP191,
|
|
Self::LegacyMinisignIdentityProof => PROOF_TYPE_ID_MINISIGN,
|
|
};
|
|
write!(formatter, "{}", proof_type_str)
|
|
}
|
|
}
|
|
|
|
impl<'de> Deserialize<'de> for ProofType {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where D: Deserializer<'de>
|
|
{
|
|
String::deserialize(deserializer)?
|
|
.parse().map_err(DeserializerError::custom)
|
|
}
|
|
}
|
|
|
|
impl Serialize for ProofType {
|
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where S: Serializer
|
|
{
|
|
serializer.serialize_str(&self.to_string())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
pub struct IdentityProof {
|
|
pub issuer: Did,
|
|
pub proof_type: ProofType,
|
|
pub value: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
pub struct IdentityProofs(pub Vec<IdentityProof>);
|
|
|
|
impl IdentityProofs {
|
|
pub fn inner(&self) -> &[IdentityProof] {
|
|
let Self(identity_proofs) = self;
|
|
identity_proofs
|
|
}
|
|
|
|
pub fn into_inner(self) -> Vec<IdentityProof> {
|
|
let Self(identity_proofs) = self;
|
|
identity_proofs
|
|
}
|
|
|
|
/// Returns true if identity proof list contains at least one proof
|
|
/// created by a given DID.
|
|
pub fn any(&self, issuer: &Did) -> bool {
|
|
let Self(identity_proofs) = self;
|
|
identity_proofs.iter().any(|proof| proof.issuer == *issuer)
|
|
}
|
|
}
|
|
|
|
json_from_sql!(IdentityProofs);
|
|
json_to_sql!(IdentityProofs);
|
|
|
|
#[derive(PartialEq)]
|
|
pub enum PaymentType {
|
|
Link,
|
|
EthereumSubscription,
|
|
MoneroSubscription,
|
|
}
|
|
|
|
impl From<&PaymentType> for i16 {
|
|
fn from(payment_type: &PaymentType) -> i16 {
|
|
match payment_type {
|
|
PaymentType::Link => 1,
|
|
PaymentType::EthereumSubscription => 2,
|
|
PaymentType::MoneroSubscription => 3,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TryFrom<i16> for PaymentType {
|
|
type Error = DatabaseTypeError;
|
|
|
|
fn try_from(value: i16) -> Result<Self, Self::Error> {
|
|
let payment_type = match value {
|
|
1 => Self::Link,
|
|
2 => Self::EthereumSubscription,
|
|
3 => Self::MoneroSubscription,
|
|
_ => return Err(DatabaseTypeError),
|
|
};
|
|
Ok(payment_type)
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
pub struct PaymentLink {
|
|
pub name: String,
|
|
pub href: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
pub struct EthereumSubscription {
|
|
chain_id: ChainId,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
pub struct MoneroSubscription {
|
|
pub chain_id: ChainId,
|
|
pub price: u64, // piconeros per second
|
|
pub payout_address: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum PaymentOption {
|
|
Link(PaymentLink),
|
|
EthereumSubscription(EthereumSubscription),
|
|
MoneroSubscription(MoneroSubscription),
|
|
}
|
|
|
|
impl PaymentOption {
|
|
pub fn ethereum_subscription(chain_id: ChainId) -> Self {
|
|
Self::EthereumSubscription(EthereumSubscription { chain_id })
|
|
}
|
|
|
|
fn payment_type(&self) -> PaymentType {
|
|
match self {
|
|
Self::Link(_) => PaymentType::Link,
|
|
Self::EthereumSubscription(_) => PaymentType::EthereumSubscription,
|
|
Self::MoneroSubscription(_) => PaymentType::MoneroSubscription,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Integer tags are not supported https://github.com/serde-rs/serde/issues/745
|
|
// Workaround: https://stackoverflow.com/a/65576570
|
|
impl<'de> Deserialize<'de> for PaymentOption {
|
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
|
where D: Deserializer<'de>
|
|
{
|
|
let value = serde_json::Value::deserialize(deserializer)?;
|
|
let payment_type = value.get("payment_type")
|
|
.and_then(serde_json::Value::as_u64)
|
|
.and_then(|val| i16::try_from(val).ok())
|
|
.and_then(|val| PaymentType::try_from(val).ok())
|
|
.ok_or(DeserializerError::custom("invalid payment type"))?;
|
|
let payment_option = match payment_type {
|
|
PaymentType::Link => {
|
|
let link = PaymentLink::deserialize(value)
|
|
.map_err(DeserializerError::custom)?;
|
|
Self::Link(link)
|
|
},
|
|
PaymentType::EthereumSubscription => {
|
|
let payment_info = EthereumSubscription::deserialize(value)
|
|
.map_err(DeserializerError::custom)?;
|
|
Self::EthereumSubscription(payment_info)
|
|
},
|
|
PaymentType::MoneroSubscription => {
|
|
let payment_info = MoneroSubscription::deserialize(value)
|
|
.map_err(DeserializerError::custom)?;
|
|
Self::MoneroSubscription(payment_info)
|
|
},
|
|
};
|
|
Ok(payment_option)
|
|
}
|
|
}
|
|
|
|
impl Serialize for PaymentOption {
|
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where S: Serializer,
|
|
{
|
|
let mut map = serializer.serialize_map(None)?;
|
|
let payment_type = self.payment_type();
|
|
map.serialize_entry("payment_type", &i16::from(&payment_type))?;
|
|
|
|
match self {
|
|
Self::Link(link) => link.serialize(FlatMapSerializer(&mut map))?,
|
|
Self::EthereumSubscription(payment_info) => {
|
|
payment_info.serialize(FlatMapSerializer(&mut map))?
|
|
},
|
|
Self::MoneroSubscription(payment_info) => {
|
|
payment_info.serialize(FlatMapSerializer(&mut map))?
|
|
},
|
|
};
|
|
map.end()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
pub struct PaymentOptions(pub Vec<PaymentOption>);
|
|
|
|
impl PaymentOptions {
|
|
pub fn into_inner(self) -> Vec<PaymentOption> {
|
|
let Self(payment_options) = self;
|
|
payment_options
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
let Self(payment_options) = self;
|
|
payment_options.is_empty()
|
|
}
|
|
|
|
/// Returns true if payment option list contains at least one option
|
|
/// of the given type.
|
|
pub fn any(&self, payment_type: PaymentType) -> bool {
|
|
let Self(payment_options) = self;
|
|
payment_options.iter()
|
|
.any(|option| option.payment_type() == payment_type)
|
|
}
|
|
}
|
|
|
|
json_from_sql!(PaymentOptions);
|
|
json_to_sql!(PaymentOptions);
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
pub struct ExtraField {
|
|
pub name: String,
|
|
pub value: String,
|
|
pub value_source: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
pub struct ExtraFields(pub Vec<ExtraField>);
|
|
|
|
impl ExtraFields {
|
|
pub fn into_inner(self) -> Vec<ExtraField> {
|
|
let Self(extra_fields) = self;
|
|
extra_fields
|
|
}
|
|
}
|
|
|
|
json_from_sql!(ExtraFields);
|
|
json_to_sql!(ExtraFields);
|
|
|
|
json_from_sql!(Actor);
|
|
json_to_sql!(Actor);
|
|
|
|
#[derive(Clone, FromSql)]
|
|
#[postgres(name = "actor_profile")]
|
|
pub struct DbActorProfile {
|
|
pub id: Uuid,
|
|
pub username: String,
|
|
pub hostname: Option<String>,
|
|
pub display_name: Option<String>,
|
|
pub bio: Option<String>, // html
|
|
pub bio_source: Option<String>, // plaintext or markdown
|
|
pub avatar_file_name: Option<String>,
|
|
pub banner_file_name: Option<String>,
|
|
pub identity_proofs: IdentityProofs,
|
|
pub payment_options: PaymentOptions,
|
|
pub extra_fields: ExtraFields,
|
|
pub follower_count: i32,
|
|
pub following_count: i32,
|
|
pub subscriber_count: i32,
|
|
pub post_count: i32,
|
|
pub actor_json: Option<Actor>,
|
|
pub created_at: DateTime<Utc>,
|
|
pub updated_at: DateTime<Utc>,
|
|
pub unreachable_since: Option<DateTime<Utc>>,
|
|
|
|
// auto-generated database fields
|
|
pub acct: String,
|
|
pub actor_id: Option<String>,
|
|
}
|
|
|
|
// Profile identifiers:
|
|
// id (local profile UUID): never changes
|
|
// acct (webfinger): must never change
|
|
// actor_id of remote actor: may change if acct remains the same
|
|
// actor RSA key: can be updated at any time by the instance admin
|
|
// identity proofs: TBD (likely will do "Trust on first use" (TOFU))
|
|
|
|
impl DbActorProfile {
|
|
pub fn is_local(&self) -> bool {
|
|
self.actor_json.is_none()
|
|
}
|
|
|
|
pub fn actor_id(&self, instance_url: &str) -> String {
|
|
match self.actor_json {
|
|
Some(ref actor) => actor.id.clone(),
|
|
None => local_actor_id(instance_url, &self.username),
|
|
}
|
|
}
|
|
|
|
/// Profile URL
|
|
pub fn actor_url(&self, instance_url: &str) -> String {
|
|
if let Some(ref actor) = self.actor_json {
|
|
if let Some(ref actor_url) = actor.url {
|
|
return actor_url.to_string();
|
|
};
|
|
};
|
|
self.actor_id(instance_url)
|
|
}
|
|
|
|
pub fn actor_address(&self, local_hostname: &str) -> ActorAddress {
|
|
assert_eq!(self.hostname.is_none(), self.is_local());
|
|
ActorAddress {
|
|
username: self.username.clone(),
|
|
hostname: self.hostname.as_deref()
|
|
.unwrap_or(local_hostname)
|
|
.to_string(),
|
|
}
|
|
}
|
|
|
|
pub fn possibly_outdated(&self) -> bool {
|
|
if self.is_local() {
|
|
false
|
|
} else {
|
|
self.updated_at < Utc::now() - Duration::days(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl Default for DbActorProfile {
|
|
fn default() -> Self {
|
|
let now = Utc::now();
|
|
Self {
|
|
id: Uuid::new_v4(),
|
|
username: "".to_string(),
|
|
hostname: None,
|
|
acct: "".to_string(),
|
|
display_name: None,
|
|
bio: None,
|
|
bio_source: None,
|
|
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,
|
|
subscriber_count: 0,
|
|
post_count: 0,
|
|
actor_json: None,
|
|
actor_id: None,
|
|
created_at: now,
|
|
updated_at: now,
|
|
unreachable_since: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg_attr(test, derive(Default))]
|
|
pub struct ProfileCreateData {
|
|
pub username: String,
|
|
pub hostname: Option<String>,
|
|
pub display_name: Option<String>,
|
|
pub bio: Option<String>,
|
|
pub avatar: Option<String>,
|
|
pub banner: Option<String>,
|
|
pub identity_proofs: Vec<IdentityProof>,
|
|
pub payment_options: Vec<PaymentOption>,
|
|
pub extra_fields: Vec<ExtraField>,
|
|
pub actor_json: Option<Actor>,
|
|
}
|
|
|
|
impl ProfileCreateData {
|
|
pub fn clean(&mut self) -> Result<(), ValidationError> {
|
|
validate_username(&self.username)?;
|
|
if self.hostname.is_some() != self.actor_json.is_some() {
|
|
return Err(ValidationError("hostname and actor_json field mismatch"));
|
|
};
|
|
if let Some(display_name) = &self.display_name {
|
|
validate_display_name(display_name)?;
|
|
};
|
|
let is_remote = self.actor_json.is_some();
|
|
if let Some(bio) = &self.bio {
|
|
let cleaned_bio = clean_bio(bio, is_remote)?;
|
|
self.bio = Some(cleaned_bio);
|
|
};
|
|
self.extra_fields = clean_extra_fields(&self.extra_fields, is_remote)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub struct ProfileUpdateData {
|
|
pub display_name: Option<String>,
|
|
pub bio: Option<String>,
|
|
pub bio_source: Option<String>,
|
|
pub avatar: Option<String>,
|
|
pub banner: Option<String>,
|
|
pub identity_proofs: Vec<IdentityProof>,
|
|
pub payment_options: Vec<PaymentOption>,
|
|
pub extra_fields: Vec<ExtraField>,
|
|
pub actor_json: Option<Actor>,
|
|
}
|
|
|
|
impl ProfileUpdateData {
|
|
/// Adds new identity proof
|
|
/// or replaces the existing one if it has the same issuer.
|
|
pub fn add_identity_proof(&mut self, proof: IdentityProof) -> () {
|
|
self.identity_proofs.retain(|item| item.issuer != proof.issuer);
|
|
self.identity_proofs.push(proof);
|
|
}
|
|
|
|
/// Adds new payment option
|
|
/// or replaces the existing one if it has the same type.
|
|
pub fn add_payment_option(&mut self, option: PaymentOption) -> () {
|
|
self.payment_options.retain(|item| {
|
|
item.payment_type() != option.payment_type()
|
|
});
|
|
self.payment_options.push(option);
|
|
}
|
|
|
|
pub fn clean(&mut self) -> Result<(), ValidationError> {
|
|
if let Some(display_name) = &self.display_name {
|
|
validate_display_name(display_name)?;
|
|
};
|
|
let is_remote = self.actor_json.is_some();
|
|
// Validate and clean bio
|
|
if let Some(bio) = &self.bio {
|
|
let cleaned_bio = clean_bio(bio, is_remote)?;
|
|
self.bio = Some(cleaned_bio);
|
|
};
|
|
self.extra_fields = clean_extra_fields(&self.extra_fields, is_remote)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
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(),
|
|
payment_options: profile.payment_options.into_inner(),
|
|
extra_fields: profile.extra_fields.into_inner(),
|
|
actor_json: profile.actor_json,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::activitypub::actors::types::Actor;
|
|
use super::*;
|
|
|
|
const INSTANCE_HOSTNAME: &str = "example.com";
|
|
|
|
#[test]
|
|
fn test_identity_proof_serialization() {
|
|
let json_data = r#"{"issuer":"did:pkh:eip155:1:0xb9c5714089478a327f09197987f16f9e5d936e8a","proof_type":"ethereum-eip191-00","value":"dbfe"}"#;
|
|
let proof: IdentityProof = serde_json::from_str(json_data).unwrap();
|
|
let did_pkh = match proof.issuer {
|
|
Did::Pkh(ref did_pkh) => did_pkh,
|
|
_ => panic!("unexpected did method"),
|
|
};
|
|
assert_eq!(did_pkh.address, "0xb9c5714089478a327f09197987f16f9e5d936e8a");
|
|
let serialized = serde_json::to_string(&proof).unwrap();
|
|
assert_eq!(serialized, json_data);
|
|
}
|
|
|
|
#[test]
|
|
fn test_payment_option_link_serialization() {
|
|
let json_data = r#"{"payment_type":1,"name":"test","href":"https://test.com"}"#;
|
|
let payment_option: PaymentOption = serde_json::from_str(json_data).unwrap();
|
|
let link = match payment_option {
|
|
PaymentOption::Link(ref link) => link,
|
|
_ => panic!("wrong option"),
|
|
};
|
|
assert_eq!(link.name, "test");
|
|
assert_eq!(link.href, "https://test.com");
|
|
let serialized = serde_json::to_string(&payment_option).unwrap();
|
|
assert_eq!(serialized, json_data);
|
|
}
|
|
|
|
#[test]
|
|
fn test_payment_option_ethereum_subscription_serialization() {
|
|
let json_data = r#"{"payment_type":2,"chain_id":"eip155:1","name":null}"#;
|
|
let payment_option: PaymentOption = serde_json::from_str(json_data).unwrap();
|
|
let payment_info = match payment_option {
|
|
PaymentOption::EthereumSubscription(ref payment_info) => payment_info,
|
|
_ => panic!("wrong option"),
|
|
};
|
|
assert_eq!(payment_info.chain_id, ChainId::ethereum_mainnet());
|
|
let serialized = serde_json::to_string(&payment_option).unwrap();
|
|
assert_eq!(serialized, r#"{"payment_type":2,"chain_id":"eip155:1"}"#);
|
|
}
|
|
|
|
#[test]
|
|
fn test_local_actor_address() {
|
|
let local_profile = DbActorProfile {
|
|
username: "user".to_string(),
|
|
hostname: None,
|
|
acct: "user".to_string(),
|
|
actor_json: None,
|
|
..Default::default()
|
|
};
|
|
assert_eq!(
|
|
local_profile.actor_address(INSTANCE_HOSTNAME).to_string(),
|
|
"user@example.com",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_remote_actor_address() {
|
|
let remote_profile = DbActorProfile {
|
|
username: "test".to_string(),
|
|
hostname: Some("remote.com".to_string()),
|
|
acct: "test@remote.com".to_string(),
|
|
actor_json: Some(Actor {
|
|
id: "https://test".to_string(),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(
|
|
remote_profile.actor_address(INSTANCE_HOSTNAME).to_string(),
|
|
remote_profile.acct,
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_clean_profile_create_data() {
|
|
let mut profile_data = ProfileCreateData {
|
|
username: "test".to_string(),
|
|
hostname: Some("example.org".to_string()),
|
|
display_name: Some("Test Test".to_string()),
|
|
actor_json: Some(Actor {
|
|
id: "https://example.org/test".to_string(),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
let result = profile_data.clean();
|
|
assert_eq!(result.is_ok(), true);
|
|
}
|
|
}
|