feat: add missing fields on AP users

This commit is contained in:
aprilthepink 2024-07-21 19:37:08 +02:00
parent 692e4bff22
commit 1a741c6420
8 changed files with 300 additions and 39 deletions

View file

@ -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),
]
}
}

View file

@ -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,
}

View file

@ -26,6 +26,7 @@ pub struct Model {
pub following: Option<String>,
pub followers: Option<String>,
pub inbox: String,
pub ap_json: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View file

@ -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<super::objects::User> {
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<entities::user::Model>
} 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<AttachmentType> = 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<TagType> = 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<entities::user::Model>
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();

View file

@ -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}")]

View file

@ -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<String>,
// TODO bio: Option<String>,
pub inbox: Url,
pub outbox: Url,
pub featured: Url,
@ -238,6 +265,24 @@ pub struct User {
pub header: Option<ContentFormat>,
pub fields: Option<Vec<FieldKV>>,
pub indexable: bool,
pub extensions: Option<ExtensionSpecs>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ExtensionSpecs {
#[serde(rename = "org.lysand:custom_emojis")]
pub custom_emojis: Option<CustomEmojis>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CustomEmojis {
pub emojis: Vec<CustomEmoji>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CustomEmoji {
pub name: String,
pub url: ContentFormat,
}
#[derive(Debug, Serialize, Deserialize, Clone)]

View file

@ -79,6 +79,50 @@ pub struct Person {
pub id: ObjectId<user::Model>,
pub inbox: Url,
pub public_key: PublicKey,
pub indexable: Option<bool>,
pub discoverable: Option<bool>,
pub manually_approves_followers: Option<bool>,
pub followers: Option<Url>,
pub following: Option<Url>,
pub featured: Option<Url>,
pub endpoints: Option<EndpointType>,
pub outbox: Option<Url>,
pub featured_tags: Option<Url>,
pub tag: Option<Vec<TagType>>,
pub icon: Option<IconType>,
pub image: Option<IconType>,
pub attachment: Option<Vec<AttachmentType>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct TagType {
pub id: Url,
pub name: String,
#[serde(rename = "type")]
pub type_: String,
pub updated: DateTime<Utc>,
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<Self::DataType>) -> Result<Self::Kind, Self::Error> {
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;

View file

@ -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