From 1a741c6420179ac73b788ccc6f41c7535755d035 Mon Sep 17 00:00:00 2001 From: aprilthepink Date: Sun, 21 Jul 2024 19:37:08 +0200 Subject: [PATCH] feat: add missing fields on AP users --- migration/src/lib.rs | 2 + .../src/m20240719_235452_user_ap_column.rs | 35 ++++ src/entities/user.rs | 1 + src/lysand/conversion.rs | 167 +++++++++++++++++- src/lysand/http.rs | 24 +-- src/lysand/objects.rs | 51 +++++- src/objects/person.rs | 58 ++++-- src/objects/post.rs | 1 + 8 files changed, 300 insertions(+), 39 deletions(-) create mode 100644 migration/src/m20240719_235452_user_ap_column.rs diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 08b1e65..7e79646 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -5,6 +5,7 @@ mod m20240417_230111_user_table; mod m20240417_233430_post_user_keys; mod m20240505_002524_user_follow_relation; mod m20240626_030922_store_ap_json_in_posts; +mod m20240719_235452_user_ap_column; pub struct Migrator; @@ -17,6 +18,7 @@ impl MigratorTrait for Migrator { Box::new(m20240417_233430_post_user_keys::Migration), Box::new(m20240505_002524_user_follow_relation::Migration), Box::new(m20240626_030922_store_ap_json_in_posts::Migration), + Box::new(m20240719_235452_user_ap_column::Migration), ] } } diff --git a/migration/src/m20240719_235452_user_ap_column.rs b/migration/src/m20240719_235452_user_ap_column.rs new file mode 100644 index 0000000..802e291 --- /dev/null +++ b/migration/src/m20240719_235452_user_ap_column.rs @@ -0,0 +1,35 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(User::Table) + .add_column_if_not_exists(ColumnDef::new(User::ApJson).string()) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(User::Table) + .drop_column(User::ApJson) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +pub enum User { + Table, + ApJson, +} diff --git a/src/entities/user.rs b/src/entities/user.rs index 2575ef0..04958e4 100644 --- a/src/entities/user.rs +++ b/src/entities/user.rs @@ -26,6 +26,7 @@ pub struct Model { pub following: Option, pub followers: Option, pub inbox: String, + pub ap_json: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/src/lysand/conversion.rs b/src/lysand/conversion.rs index e46fda8..909a505 100644 --- a/src/lysand/conversion.rs +++ b/src/lysand/conversion.rs @@ -3,6 +3,7 @@ use activitystreams_kinds::public; use anyhow::{anyhow, Ok}; use async_recursion::async_recursion; use chrono::{DateTime, TimeZone, Utc}; +use reqwest::header; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; @@ -11,9 +12,13 @@ use url::Url; use crate::{ database::State, entities::{self, post, prelude, user}, - objects::post::Mention, + objects::{ + self, + person::{AttachmentType, EndpointType, IconType, Person, TagType}, + post::Mention, + }, utils::{generate_lysand_post_url, generate_object_id, generate_user_id}, - API_DOMAIN, DB, FEDERATION_CONFIG, LYSAND_DOMAIN, + API_DOMAIN, DB, FEDERATION_CONFIG, LOCAL_USER_NAME, LYSAND_DOMAIN, USERNAME, }; use super::{ @@ -80,6 +85,8 @@ pub async fn lysand_user_from_db( user: entities::user::Model, ) -> anyhow::Result { let url = Url::parse(&user.url)?; + let ap = user.ap_json.unwrap(); + let serialized_ap: crate::objects::person::Person = serde_json::from_str(&ap)?; let inbox_url = Url::parse("https://ap.lysand.org/apbridge/lysand/inbox")?; let outbox_url = Url::parse( ("https://ap.lysand.org/apbridge/lysand/outbox/".to_string() + &user.id).as_str(), @@ -113,6 +120,59 @@ pub async fn lysand_user_from_db( "text/html".to_string(), ContentEntry::from_string(user.summary.unwrap_or_default()), ); + let avatar = match serialized_ap.icon { + Some(icon) => { + let mut content_format = ContentFormat::default(); + let content_entry = ContentEntry::from_string(icon.url.to_string()); + content_format.x.insert(icon.type_, content_entry); + Some(content_format) + } + None => None, + }; + let header = match serialized_ap.image { + Some(image) => { + let mut content_format = ContentFormat::default(); + let content_entry = ContentEntry::from_string(image.url.to_string()); + content_format.x.insert(image.type_, content_entry); + Some(content_format) + } + None => None, + }; + let mut fields = Vec::new(); + if let Some(attachments) = serialized_ap.attachment { + for attachment in attachments { + let mut key = ContentFormat::default(); + let mut value = ContentFormat::default(); + key.x.insert( + "text/html".to_string(), + ContentEntry::from_string(attachment.name), + ); + value.x.insert( + "text/html".to_string(), + ContentEntry::from_string(attachment.value), + ); + fields.push(super::objects::FieldKV { key, value }); + } + } + let emojis = match serialized_ap.tag { + Some(tags) => { + let mut emojis = Vec::new(); + for tag in tags { + let mut content_format = ContentFormat::default(); + let content_entry = ContentEntry::from_string(tag.id.to_string()); + content_format.x.insert(tag.type_, content_entry); + emojis.push(super::objects::CustomEmoji { + name: tag.name, + url: content_format, + }); + } + Some(super::objects::CustomEmojis { emojis }) + } + None => None, + }; + let extensions = super::objects::ExtensionSpecs { + custom_emojis: emojis, + }; let user = super::objects::User { rtype: super::objects::LysandType::User, id: uuid::Uuid::try_parse(&user.id)?, @@ -127,9 +187,9 @@ pub async fn lysand_user_from_db( likes: likes_url, dislikes: dislikes_url, bio: Some(bio), - avatar: None, - header: None, - fields: None, + avatar, + header, + fields: Some(fields), indexable: false, created_at: OffsetDateTime::from_unix_timestamp(user.created_at.timestamp()).unwrap(), public_key: PublicKey { @@ -137,6 +197,7 @@ pub async fn lysand_user_from_db( public_key: "AAAAC3NzaC1lZDI1NTE5AAAAIMxsX+lEWkHZt9NOvn9yYFP0Z++186LY4b97C4mwj/f2" .to_string(), // dummy key }, + extensions: Some(extensions), }; Ok(user) } @@ -211,6 +272,101 @@ pub async fn db_user_from_url(url: Url) -> anyhow::Result } else { let ls_user = fetch_user_from_url(url).await?; let keypair = generate_actor_keypair()?; + let bridge_user_url = generate_user_id(&API_DOMAIN, &ls_user.id.to_string())?; + let inbox = Url::parse(&format!( + "https://{}/{}/inbox", + API_DOMAIN.to_string(), + ls_user.username.clone() + ))?; + let icon = if let Some(avatar) = ls_user.avatar { + let avatar_url = avatar.select_rich_img_touple().await?; + Some(IconType { + type_: "Image".to_string(), + media_type: avatar_url.0, + url: Url::parse(&avatar_url.1).unwrap(), + }) + } else { + None + }; + let image = if let Some(header) = ls_user.header { + let header_url = header.select_rich_img_touple().await?; + Some(IconType { + type_: "Image".to_string(), + media_type: header_url.0, + url: Url::parse(&header_url.1).unwrap(), + }) + } else { + None + }; + let mut attachments: Vec = Vec::new(); + if let Some(fields) = ls_user.fields { + for attachment in fields { + attachments.push(AttachmentType { + type_: "PropertyValue".to_string(), + name: attachment.key.select_rich_text().await?, + value: attachment.value.select_rich_text().await?, + }); + } + } + let mut tags: Vec = Vec::new(); + if let Some(extensions) = ls_user.extensions { + if let Some(custom_emojis) = extensions.custom_emojis { + for emoji in custom_emojis.emojis { + let touple = emoji.url.select_rich_img_touple().await?; + tags.push(TagType { + id: Url::parse(&touple.1).unwrap(), + name: emoji.name, + type_: "Emoji".to_string(), + updated: Utc::now(), + icon: IconType { + type_: "Image".to_string(), + media_type: touple.0, + url: Url::parse(&touple.1).unwrap(), + }, + }); + } + } + } + let ap_json = Person { + kind: Default::default(), + id: bridge_user_url.clone().into(), + preferred_username: ls_user.username.clone(), + inbox, + public_key: activitypub_federation::protocol::public_key::PublicKey { + owner: bridge_user_url.clone(), + public_key_pem: keypair.public_key.clone(), + id: format!("{}#main-key", bridge_user_url.clone()), + }, + name: ls_user + .display_name + .clone() + .unwrap_or(ls_user.username.clone()), + summary: option_content_format_text(ls_user.bio.clone()).await, + url: ls_user.uri.clone(), + indexable: Some(ls_user.indexable), + discoverable: Some(true), + manually_approves_followers: Some(false), + followers: None, + following: None, + featured: None, + featured_tags: None, + outbox: None, + endpoints: Some(EndpointType { + shared_inbox: Url::parse( + &format!( + "https://{}/{}/inbox", + API_DOMAIN.to_string(), + &USERNAME.to_string() + ) + .as_str(), + ) + .unwrap(), + }), + icon, + image, + attachment: Some(attachments), + tag: Some(tags), + }; let user = entities::user::ActiveModel { id: Set(ls_user.id.to_string()), username: Set(ls_user.username.clone()), @@ -230,6 +386,7 @@ pub async fn db_user_from_url(url: Url) -> anyhow::Result updated_at: Set(Some(Utc::now())), followers: Set(Some(ls_user.followers.to_string())), following: Set(Some(ls_user.following.to_string())), + ap_json: Set(Some(serde_json::to_string(&ap_json).unwrap())), ..Default::default() }; let db = DB.get().unwrap(); diff --git a/src/lysand/http.rs b/src/lysand/http.rs index 65b8f49..9e2eb1b 100644 --- a/src/lysand/http.rs +++ b/src/lysand/http.rs @@ -17,7 +17,7 @@ use crate::{ }, error, lysand::conversion::{lysand_post_from_db, lysand_user_from_db}, - objects, + objects::{self, person::Person}, utils::{base_url_decode, generate_create_id, generate_user_id}, Response, API_DOMAIN, DB, FEDERATION_CONFIG, }; @@ -134,29 +134,11 @@ async fn fetch_user( None => return Ok(HttpResponse::NotFound().finish()), }; - let bridge_user_url = generate_user_id(&API_DOMAIN, &user.id)?; - let inbox = Url::parse(&format!( - "https://{}/{}/inbox", - API_DOMAIN.to_string(), - &user.username.clone() - ))?; + let deserialized_user: Person = serde_json::from_str(user.ap_json.as_ref().unwrap().as_str())?; Ok(HttpResponse::Ok() .content_type(FEDERATION_CONTENT_TYPE) - .json(WithContext::new_default(crate::objects::person::Person { - kind: Default::default(), - id: bridge_user_url.clone().into(), - preferred_username: user.username.clone(), - name: user.name.clone(), - summary: user.summary.clone(), - url: Url::parse(user.url.as_str()).unwrap(), - inbox, - public_key: PublicKey { - owner: bridge_user_url.clone(), - public_key_pem: user.public_key, - id: format!("{}#main-key", bridge_user_url.clone()), - }, - }))) + .json(WithContext::new_default(deserialized_user))) } #[get("/apbridge/lysand/object/{post}")] diff --git a/src/lysand/objects.rs b/src/lysand/objects.rs index ba2f460..f8c84e4 100644 --- a/src/lysand/objects.rs +++ b/src/lysand/objects.rs @@ -156,6 +156,34 @@ impl ContentFormat { Ok(self.x.clone().values().next().unwrap().content.clone()) } + + pub async fn select_rich_img_touple(&self) -> anyhow::Result<(String, String)> { + if let Some(entry) = self.x.get("image/webp") { + return Ok(("image/webp".to_string(), entry.content.clone())); + } + if let Some(entry) = self.x.get("image/png") { + return Ok(("image/png".to_string(), entry.content.clone())); + } + if let Some(entry) = self.x.get("image/avif") { + return Ok(("image/avif".to_string(), entry.content.clone())); + } + if let Some(entry) = self.x.get("image/jxl") { + return Ok(("image/jxl".to_string(), entry.content.clone())); + } + if let Some(entry) = self.x.get("image/jpeg") { + return Ok(("image/jpeg".to_string(), entry.content.clone())); + } + if let Some(entry) = self.x.get("image/gif") { + return Ok(("image/gif".to_string(), entry.content.clone())); + } + if let Some(entry) = self.x.get("image/bmp") { + return Ok(("image/bmp".to_string(), entry.content.clone())); + } + + let touple = self.x.iter().next().unwrap(); + + Ok((touple.0.clone(), touple.1.content.clone())) + } } impl Serialize for ContentFormat { @@ -182,8 +210,8 @@ impl<'de> Deserialize<'de> for ContentFormat { #[derive(Debug, Serialize, Deserialize, Clone)] pub struct FieldKV { - key: ContentFormat, - value: ContentFormat, + pub key: ContentFormat, + pub value: ContentFormat, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -224,7 +252,6 @@ pub struct User { #[serde(with = "iso_lysand")] pub created_at: OffsetDateTime, pub display_name: Option, - // TODO bio: Option, pub inbox: Url, pub outbox: Url, pub featured: Url, @@ -238,6 +265,24 @@ pub struct User { pub header: Option, pub fields: Option>, pub indexable: bool, + pub extensions: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ExtensionSpecs { + #[serde(rename = "org.lysand:custom_emojis")] + pub custom_emojis: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CustomEmojis { + pub emojis: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CustomEmoji { + pub name: String, + pub url: ContentFormat, } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/src/objects/person.rs b/src/objects/person.rs index 47dc821..499ccd2 100644 --- a/src/objects/person.rs +++ b/src/objects/person.rs @@ -79,6 +79,50 @@ pub struct Person { pub id: ObjectId, pub inbox: Url, pub public_key: PublicKey, + pub indexable: Option, + pub discoverable: Option, + pub manually_approves_followers: Option, + pub followers: Option, + pub following: Option, + pub featured: Option, + pub endpoints: Option, + pub outbox: Option, + pub featured_tags: Option, + pub tag: Option>, + pub icon: Option, + pub image: Option, + pub attachment: Option>, +} +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TagType { + pub id: Url, + pub name: String, + #[serde(rename = "type")] + pub type_: String, + pub updated: DateTime, + pub icon: IconType, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EndpointType { + pub shared_inbox: Url, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct IconType { + #[serde(rename = "type")] + pub type_: String, //Always "Image" + pub media_type: String, + pub url: Url, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AttachmentType { + #[serde(rename = "type")] + pub type_: String, //Always "PropertyValue" + pub name: String, + pub value: String, } #[async_trait::async_trait] @@ -103,16 +147,8 @@ impl Object for user::Model { } async fn into_json(self, _data: &Data) -> Result { - Ok(Person { - preferred_username: self.username.clone(), - kind: Default::default(), - id: Url::parse(&self.id).unwrap().into(), - inbox: Url::parse(&self.inbox).unwrap(), - public_key: self.public_key(), - name: self.name.clone(), - summary: self.summary.clone(), - url: Url::parse(&self.url).unwrap(), - }) + let serialized = serde_json::from_str(self.ap_json.as_ref().unwrap().as_str())?; + Ok(serialized) } async fn verify( @@ -135,6 +171,7 @@ impl Object for user::Model { if let Some(user) = query { return Ok(user); } + let copied_json = json.clone(); let model = user::ActiveModel { id: Set(Uuid::now_v7().to_string()), username: Set(json.preferred_username), @@ -148,6 +185,7 @@ impl Object for user::Model { following_count: Set(0), created_at: Set(Utc::now()), last_refreshed_at: Set(Utc::now()), + ap_json: Set(Some(serde_json::to_string(&copied_json).unwrap())), ..Default::default() }; let model = model.insert(_data.database_connection.as_ref()).await; diff --git a/src/objects/post.rs b/src/objects/post.rs index 543e949..6734a0a 100644 --- a/src/objects/post.rs +++ b/src/objects/post.rs @@ -128,6 +128,7 @@ impl Object for post::Model { visibility: Set("public".to_string()), // TODO: make this use the real visibility sensitive: Set(json.sensitive.clone().unwrap_or_default()), url: Set(json.id.clone().to_string()), + ap_json: Set(Some(serde_json::to_string(&json).unwrap())), ..Default::default() }; let post = post