feat(ap): ActivityPub spec compliance and profile completeness
Phase 1 — spec compliance: - Add AS_PUBLIC constant; add to/cc fields to CreateActivity, DeleteActivity, UpdateActivity, AddActivity; populate on all broadcast call sites - Add @context to outbox CreateActivity items - Set manuallyApprovesFollowers: true to match actual Pending follow flow - Gate PermissiveVerifier behind FEDERATION_DEBUG env var - Add updated timestamp to Person actor JSON - Improve actor update delivery logging Phase 2a Batch 1 — AP layer: - Add /inbox shared inbox route; add endpoints.sharedInbox to Person - Paginate followers and following collections (20/page, OrderedCollectionPage) Phase 2a Batch 2 — profile completeness: - DB migrations: banner_path, also_known_as columns; user_profile_fields table - ProfileField value object; UserProfileFieldsRepository port - Banner image upload (stored via image-converter, surfaced as image in Person) - alsoKnownAs field in Person (account migration support) - Custom profile fields (up to 4 PropertyValue attachments in Person) - Profile settings UI: banner preview/upload, alsoKnownAs input, fields form - PUT /api/v1/profile/fields API endpoint
This commit is contained in:
@@ -301,6 +301,10 @@ pub struct CreateActivity {
|
||||
pub(crate) kind: CreateType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub(crate) to: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub(crate) cc: Vec<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -347,6 +351,10 @@ pub struct DeleteActivity {
|
||||
pub(crate) kind: DeleteType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: Url,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub(crate) to: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub(crate) cc: Vec<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -392,6 +400,10 @@ pub struct UpdateActivity {
|
||||
pub(crate) kind: UpdateType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub(crate) to: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub(crate) cc: Vec<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -495,6 +507,10 @@ pub struct AddActivity {
|
||||
pub(crate) kind: AddType,
|
||||
pub(crate) actor: ObjectId<DbActor>,
|
||||
pub(crate) object: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub(crate) to: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
pub(crate) cc: Vec<String>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
|
||||
@@ -28,7 +28,10 @@ pub struct DbActor {
|
||||
pub last_refreshed_at: DateTime<Utc>,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_url: Option<Url>,
|
||||
pub banner_url: Option<Url>,
|
||||
pub also_known_as: Option<String>,
|
||||
pub profile_url: Option<Url>,
|
||||
pub attachment: Vec<domain::models::ProfileField>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -38,6 +41,20 @@ pub struct ApImageObject {
|
||||
pub url: Url,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Endpoints {
|
||||
pub shared_inbox: Url,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileFieldObject {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Person {
|
||||
@@ -60,6 +77,16 @@ pub struct Person {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
discoverable: Option<bool>,
|
||||
manually_approves_followers: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none", default)]
|
||||
updated: Option<DateTime<Utc>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
endpoints: Option<Endpoints>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
image: Option<ApImageObject>,
|
||||
#[serde(rename = "alsoKnownAs", skip_serializing_if = "Vec::is_empty", default)]
|
||||
also_known_as: Vec<String>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty", default)]
|
||||
attachment: Vec<ProfileFieldObject>,
|
||||
}
|
||||
|
||||
pub async fn get_local_actor(
|
||||
@@ -107,7 +134,10 @@ pub async fn get_local_actor(
|
||||
last_refreshed_at: Utc::now(),
|
||||
bio: user.bio,
|
||||
avatar_url: user.avatar_url,
|
||||
banner_url: user.banner_url,
|
||||
also_known_as: user.also_known_as,
|
||||
profile_url: user.profile_url,
|
||||
attachment: user.attachment,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -167,11 +197,14 @@ impl Object for DbActor {
|
||||
last_refreshed_at: Utc::now(),
|
||||
bio: None,
|
||||
avatar_url: None,
|
||||
banner_url: None,
|
||||
also_known_as: None,
|
||||
profile_url: None,
|
||||
attachment: vec![],
|
||||
}))
|
||||
}
|
||||
|
||||
async fn into_json(self, _data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
|
||||
async fn into_json(self, data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
|
||||
let public_key = PublicKey {
|
||||
id: format!("{}#main-key", &self.ap_id),
|
||||
owner: self.ap_id.clone(),
|
||||
@@ -182,7 +215,20 @@ impl Object for DbActor {
|
||||
kind: "Image".to_string(),
|
||||
url,
|
||||
});
|
||||
let image = self.banner_url.map(|url| ApImageObject {
|
||||
kind: "Image".to_string(),
|
||||
url,
|
||||
});
|
||||
let profile_url = self.profile_url;
|
||||
let also_known_as: Vec<String> = self.also_known_as.into_iter().collect();
|
||||
let attachment: Vec<ProfileFieldObject> = self.attachment.into_iter().map(|f| ProfileFieldObject {
|
||||
kind: "PropertyValue".to_string(),
|
||||
name: f.name,
|
||||
value: f.value,
|
||||
}).collect();
|
||||
|
||||
let shared_inbox = Url::parse(&format!("{}/inbox", data.base_url))
|
||||
.expect("base_url is always valid");
|
||||
|
||||
Ok(Person {
|
||||
kind: Default::default(),
|
||||
@@ -198,7 +244,12 @@ impl Object for DbActor {
|
||||
icon,
|
||||
url: profile_url,
|
||||
discoverable: Some(true),
|
||||
manually_approves_followers: false,
|
||||
manually_approves_followers: true,
|
||||
updated: Some(self.last_refreshed_at),
|
||||
endpoints: Some(Endpoints { shared_inbox }),
|
||||
image,
|
||||
also_known_as,
|
||||
attachment,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -244,7 +295,10 @@ impl Object for DbActor {
|
||||
last_refreshed_at: Utc::now(),
|
||||
bio: None,
|
||||
avatar_url: None,
|
||||
banner_url: None,
|
||||
also_known_as: None,
|
||||
profile_url: None,
|
||||
attachment: vec![],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
use activitypub_federation::{axum::json::FederationJson, config::Data};
|
||||
use axum::extract::Path;
|
||||
use axum::extract::{Path, Query};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::data::FederationData;
|
||||
use crate::error::Error;
|
||||
use crate::repository::FollowerStatus;
|
||||
|
||||
fn ordered_collection(id: String, total: usize, items: Vec<String>) -> serde_json::Value {
|
||||
json!({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"type": "OrderedCollection",
|
||||
"id": id,
|
||||
"totalItems": total,
|
||||
"orderedItems": items,
|
||||
})
|
||||
const PAGE_SIZE: usize = 20;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PageQuery {
|
||||
page: Option<u32>,
|
||||
}
|
||||
|
||||
pub async fn followers_handler(
|
||||
Path(user_id_str): Path<String>,
|
||||
Query(query): Query<PageQuery>,
|
||||
data: Data<FederationData>,
|
||||
) -> Result<FederationJson<serde_json::Value>, Error> {
|
||||
let user_id = uuid::Uuid::parse_str(&user_id_str)
|
||||
@@ -29,24 +27,53 @@ pub async fn followers_handler(
|
||||
.map_err(Error::from)?
|
||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
||||
|
||||
let followers = data
|
||||
let collection_id = format!("{}/users/{}/followers", data.base_url, user_id_str);
|
||||
let total = data
|
||||
.federation_repo
|
||||
.get_followers(user_id)
|
||||
.count_followers(user_id)
|
||||
.await
|
||||
.map_err(Error::from)?;
|
||||
|
||||
let items: Vec<String> = followers
|
||||
.into_iter()
|
||||
.filter(|f| f.status == FollowerStatus::Accepted)
|
||||
.map(|f| f.actor.url)
|
||||
.collect();
|
||||
if let Some(page) = query.page {
|
||||
let page = page.max(1);
|
||||
let offset = (page.saturating_sub(1) as usize) * PAGE_SIZE;
|
||||
let followers = data
|
||||
.federation_repo
|
||||
.get_followers_page(user_id, offset as u32, PAGE_SIZE)
|
||||
.await
|
||||
.map_err(Error::from)?;
|
||||
|
||||
let id = format!("{}/users/{}/followers", data.base_url, user_id_str);
|
||||
Ok(FederationJson(ordered_collection(id, items.len(), items)))
|
||||
let has_next = offset + followers.len() < total;
|
||||
let items: Vec<String> = followers.into_iter().map(|f| f.actor.url).collect();
|
||||
|
||||
let mut obj = json!({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"type": "OrderedCollectionPage",
|
||||
"id": format!("{}?page={}", collection_id, page),
|
||||
"partOf": collection_id,
|
||||
"totalItems": total,
|
||||
"orderedItems": items,
|
||||
});
|
||||
|
||||
if has_next {
|
||||
obj["next"] = json!(format!("{}?page={}", collection_id, page + 1));
|
||||
}
|
||||
|
||||
Ok(FederationJson(obj))
|
||||
} else {
|
||||
Ok(FederationJson(json!({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"type": "OrderedCollection",
|
||||
"id": collection_id,
|
||||
"totalItems": total,
|
||||
"first": format!("{}?page=1", collection_id),
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn following_handler(
|
||||
Path(user_id_str): Path<String>,
|
||||
Query(query): Query<PageQuery>,
|
||||
data: Data<FederationData>,
|
||||
) -> Result<FederationJson<serde_json::Value>, Error> {
|
||||
let user_id = uuid::Uuid::parse_str(&user_id_str)
|
||||
@@ -58,14 +85,46 @@ pub async fn following_handler(
|
||||
.map_err(Error::from)?
|
||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
||||
|
||||
let following = data
|
||||
let collection_id = format!("{}/users/{}/following", data.base_url, user_id_str);
|
||||
let total = data
|
||||
.federation_repo
|
||||
.get_following(user_id)
|
||||
.count_following(user_id)
|
||||
.await
|
||||
.map_err(Error::from)?;
|
||||
|
||||
let items: Vec<String> = following.into_iter().map(|a| a.url).collect();
|
||||
if let Some(page) = query.page {
|
||||
let page = page.max(1);
|
||||
let offset = (page.saturating_sub(1) as usize) * PAGE_SIZE;
|
||||
let following = data
|
||||
.federation_repo
|
||||
.get_following_page(user_id, offset as u32, PAGE_SIZE)
|
||||
.await
|
||||
.map_err(Error::from)?;
|
||||
|
||||
let id = format!("{}/users/{}/following", data.base_url, user_id_str);
|
||||
Ok(FederationJson(ordered_collection(id, items.len(), items)))
|
||||
let has_next = offset + following.len() < total;
|
||||
let items: Vec<String> = following.into_iter().map(|a| a.url).collect();
|
||||
|
||||
let mut obj = json!({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"type": "OrderedCollectionPage",
|
||||
"id": format!("{}?page={}", collection_id, page),
|
||||
"partOf": collection_id,
|
||||
"totalItems": total,
|
||||
"orderedItems": items,
|
||||
});
|
||||
|
||||
if has_next {
|
||||
obj["next"] = json!(format!("{}?page={}", collection_id, page + 1));
|
||||
}
|
||||
|
||||
Ok(FederationJson(obj))
|
||||
} else {
|
||||
Ok(FederationJson(json!({
|
||||
"@context": "https://www.w3.org/ns/activitystreams",
|
||||
"type": "OrderedCollection",
|
||||
"id": collection_id,
|
||||
"totalItems": total,
|
||||
"first": format!("{}?page=1", collection_id),
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod outbox;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
pub(crate) mod urls;
|
||||
pub use urls::AS_PUBLIC;
|
||||
pub mod user;
|
||||
pub mod webfinger;
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use activitypub_federation::{config::Data, fetch::object_id::ObjectId, kinds::activity::CreateType};
|
||||
use activitypub_federation::{
|
||||
config::Data,
|
||||
fetch::object_id::ObjectId,
|
||||
kinds::activity::CreateType,
|
||||
protocol::context::WithContext,
|
||||
};
|
||||
|
||||
use crate::{activities::CreateActivity, data::FederationData, error::Error};
|
||||
|
||||
@@ -74,17 +79,20 @@ pub async fn outbox_handler(
|
||||
let has_more = items.len() == PAGE_SIZE;
|
||||
let oldest_ts = items.last().map(|(_, _, ts)| *ts);
|
||||
|
||||
let followers_url = format!("{}/followers", actor_url);
|
||||
let ordered_items: Vec<serde_json::Value> = items
|
||||
.into_iter()
|
||||
.map(|(ap_id, object, _)| {
|
||||
let create_id =
|
||||
Url::parse(&format!("{}/activity", ap_id)).expect("valid url");
|
||||
serde_json::to_value(CreateActivity {
|
||||
serde_json::to_value(WithContext::new_default(CreateActivity {
|
||||
id: create_id,
|
||||
kind: CreateType::default(),
|
||||
actor: ObjectId::from(actor_url.clone()),
|
||||
object,
|
||||
})
|
||||
to: vec![crate::urls::AS_PUBLIC.to_string()],
|
||||
cc: vec![followers_url.clone()],
|
||||
}))
|
||||
.expect("serializable")
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -58,6 +58,19 @@ pub trait FederationRepository: Send + Sync {
|
||||
remote_actor_url: &str,
|
||||
) -> Result<()>;
|
||||
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>>;
|
||||
async fn get_followers_page(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
offset: u32,
|
||||
limit: usize,
|
||||
) -> Result<Vec<Follower>>;
|
||||
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize>;
|
||||
async fn get_following_page(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
offset: u32,
|
||||
limit: usize,
|
||||
) -> Result<Vec<RemoteActor>>;
|
||||
async fn update_follower_status(
|
||||
&self,
|
||||
local_user_id: uuid::Uuid,
|
||||
|
||||
@@ -121,6 +121,7 @@ impl ActivityPubService {
|
||||
.route("/.well-known/nodeinfo", get(nodeinfo_well_known_handler))
|
||||
.route("/nodeinfo/2.0", get(nodeinfo_handler))
|
||||
.route("/.well-known/webfinger", get(webfinger_handler))
|
||||
.route("/inbox", post(inbox_handler))
|
||||
.route("/users/{id}/inbox", post(inbox_handler))
|
||||
.route("/users/{id}/outbox", get(outbox_handler))
|
||||
.route("/users/{id}/followers", get(followers_handler))
|
||||
@@ -487,6 +488,8 @@ impl ActivityPubService {
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object,
|
||||
to: vec![crate::urls::AS_PUBLIC.to_string()],
|
||||
cc: vec![local_actor.followers_url.to_string()],
|
||||
};
|
||||
let create_with_ctx = WithContext::new_default(create);
|
||||
|
||||
@@ -554,6 +557,8 @@ impl ActivityPubService {
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object: ap_id,
|
||||
to: vec![crate::urls::AS_PUBLIC.to_string()],
|
||||
cc: vec![local_actor.followers_url.to_string()],
|
||||
};
|
||||
let delete_with_ctx = WithContext::new_default(delete);
|
||||
let inboxes = collect_inboxes(&accepted);
|
||||
@@ -617,6 +622,8 @@ impl ActivityPubService {
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object,
|
||||
to: vec![crate::urls::AS_PUBLIC.to_string()],
|
||||
cc: vec![local_actor.followers_url.to_string()],
|
||||
};
|
||||
let add_with_ctx = WithContext::new_default(add);
|
||||
let inboxes = collect_inboxes(&accepted);
|
||||
@@ -746,6 +753,8 @@ impl ActivityPubService {
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object,
|
||||
to: vec![crate::urls::AS_PUBLIC.to_string()],
|
||||
cc: vec![local_actor.followers_url.to_string()],
|
||||
};
|
||||
let update_with_ctx = WithContext::new_default(update);
|
||||
let inboxes = collect_inboxes(&accepted);
|
||||
@@ -771,7 +780,8 @@ impl ActivityPubService {
|
||||
|
||||
let person = local_actor.clone().into_json(&data).await
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||
let person_json = serde_json::to_value(&person)?;
|
||||
// Wrap with @context so Mastodon's JSON-LD processor can resolve field names.
|
||||
let person_json = serde_json::to_value(&WithContext::new_default(person))?;
|
||||
|
||||
let update_id = Url::parse(&format!(
|
||||
"{}/activities/update/{}",
|
||||
@@ -784,6 +794,8 @@ impl ActivityPubService {
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object: person_json,
|
||||
to: vec![crate::urls::AS_PUBLIC.to_string()],
|
||||
cc: vec![local_actor.followers_url.to_string()],
|
||||
};
|
||||
|
||||
let followers = data.federation_repo.get_followers(user_id).await?;
|
||||
@@ -793,10 +805,19 @@ impl ActivityPubService {
|
||||
.collect();
|
||||
|
||||
if accepted.is_empty() {
|
||||
tracing::info!(user_id = %user_id, "no accepted followers, skipping actor update broadcast");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let inboxes = collect_inboxes(&accepted);
|
||||
tracing::info!(
|
||||
user_id = %user_id,
|
||||
follower_count = accepted.len(),
|
||||
inbox_count = inboxes.len(),
|
||||
inboxes = ?inboxes,
|
||||
"broadcasting actor update"
|
||||
);
|
||||
|
||||
let sends = SendActivityTask::prepare(
|
||||
&WithContext::new_default(update),
|
||||
&local_actor,
|
||||
@@ -807,8 +828,13 @@ impl ActivityPubService {
|
||||
|
||||
let failures = send_with_retry(sends, &data).await;
|
||||
if !failures.is_empty() {
|
||||
tracing::warn!(count = failures.len(), "actor update delivery failures");
|
||||
return Err(anyhow::anyhow!(
|
||||
"actor update delivery failed for {} inbox(es): {}",
|
||||
failures.len(),
|
||||
failures.iter().map(|e| e.to_string()).collect::<Vec<_>>().join("; ")
|
||||
));
|
||||
}
|
||||
tracing::info!(user_id = %user_id, "actor update broadcast complete");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1115,6 +1141,8 @@ impl ActivityPubService {
|
||||
kind: Default::default(),
|
||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||
object: object_json.clone(),
|
||||
to: vec![],
|
||||
cc: vec![],
|
||||
};
|
||||
|
||||
let sends = SendActivityTask::prepare(
|
||||
|
||||
@@ -23,11 +23,21 @@ fn person_serializes_with_enriched_fields() {
|
||||
}),
|
||||
url: Some("https://example.com/u/alice".parse().unwrap()),
|
||||
discoverable: Some(true),
|
||||
manually_approves_followers: false,
|
||||
manually_approves_followers: true,
|
||||
updated: Some(Utc::now()),
|
||||
endpoints: Some(Endpoints {
|
||||
shared_inbox: "https://example.com/inbox".parse().unwrap(),
|
||||
}),
|
||||
image: None,
|
||||
also_known_as: vec![],
|
||||
attachment: vec![],
|
||||
};
|
||||
let json = serde_json::to_value(&person).unwrap();
|
||||
assert_eq!(json["discoverable"], true);
|
||||
assert_eq!(json["summary"], "Bio text");
|
||||
assert_eq!(json["icon"]["type"], "Image");
|
||||
assert!(json.get("manuallyApprovesFollowers").is_some());
|
||||
assert_eq!(json["manuallyApprovesFollowers"], true);
|
||||
assert!(json.get("updated").is_some());
|
||||
assert!(json.get("endpoints").is_some());
|
||||
assert_eq!(json["endpoints"]["sharedInbox"], "https://example.com/inbox");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ use url::Url;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
pub const AS_PUBLIC: &str = "https://www.w3.org/ns/activitystreams#Public";
|
||||
|
||||
pub fn extract_user_id_from_url(url: &Url) -> Option<uuid::Uuid> {
|
||||
let path = url.path();
|
||||
path.strip_prefix("/users/")
|
||||
|
||||
@@ -7,7 +7,10 @@ pub struct ApUser {
|
||||
pub username: String,
|
||||
pub bio: Option<String>,
|
||||
pub avatar_url: Option<Url>,
|
||||
pub banner_url: Option<Url>,
|
||||
pub also_known_as: Option<String>,
|
||||
pub profile_url: Option<Url>,
|
||||
pub attachment: Vec<domain::models::ProfileField>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
Reference in New Issue
Block a user