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:
2026-05-13 22:21:41 +02:00
parent 0a97fe5544
commit 815178e6a4
56 changed files with 1388 additions and 246 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();

View File

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

View File

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

View File

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

View File

@@ -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/")

View File

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

View File

@@ -31,6 +31,7 @@ pub async fn wire(
review_store: std::sync::Arc<dyn RemoteReviewRepository>,
remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
profile_fields_repo: std::sync::Arc<dyn domain::ports::UserProfileFieldsRepository>,
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
@@ -52,15 +53,26 @@ pub async fn wire(
watchlist: watchlist_handler,
});
let federation_debug = std::env::var("FEDERATION_DEBUG")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
if federation_debug {
tracing::warn!(
"federation running in DEBUG mode — PermissiveVerifier active, \
no URL/signature validation. Do NOT use in production."
);
}
let concrete = std::sync::Arc::new(
ActivityPubService::new(
federation_repo,
std::sync::Arc::new(DomainUserRepoAdapter::new(user_repo, base_url.clone())),
std::sync::Arc::new(DomainUserRepoAdapter::new(user_repo, profile_fields_repo, base_url.clone())),
composite,
base_url.clone(),
allow_registration,
"movies-diary".to_string(),
cfg!(debug_assertions),
federation_debug,
Some(event_publisher),
)
.await?,

View File

@@ -1,3 +1,4 @@
use activitypub_base::AS_PUBLIC;
use activitypub_federation::kinds::object::NoteType;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
@@ -36,6 +37,10 @@ pub struct ReviewObject {
pub(crate) watched_at: DateTime<Utc>,
#[serde(default)]
pub(crate) tag: Vec<ApHashtag>,
#[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>,
}
/// Serialize a local Review into a ReviewObject for AP delivery.
@@ -84,7 +89,7 @@ pub fn review_to_ap_object(
ReviewObject {
kind: NoteType::default(),
id: ap_id,
attributed_to: actor_url,
attributed_to: actor_url.clone(),
content,
published: DateTime::from_naive_utc_and_offset(*review.created_at(), Utc),
movie_title,
@@ -94,6 +99,8 @@ pub fn review_to_ap_object(
comment: comment_text,
watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc),
tag,
to: vec![AS_PUBLIC.to_string()],
cc: vec![format!("{}/followers", actor_url)],
}
}
@@ -119,6 +126,10 @@ pub struct WatchlistObject {
/// Non-Movies-Diary apps ignore unknown fields.
#[serde(default)]
pub(crate) watchlist_entry: bool,
#[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>,
}
pub fn watchlist_to_ap_object(
@@ -156,7 +167,7 @@ pub fn watchlist_to_ap_object(
WatchlistObject {
kind: NoteType::default(),
id: ap_id,
attributed_to: actor_url,
attributed_to: actor_url.clone(),
content,
published: added_at,
movie_title,
@@ -165,6 +176,8 @@ pub fn watchlist_to_ap_object(
poster_url,
tag,
watchlist_entry: true,
to: vec![AS_PUBLIC.to_string()],
cc: vec![format!("{}/followers", actor_url)],
}
}

View File

@@ -39,3 +39,52 @@ fn review_to_ap_object_includes_two_hashtags() {
assert!(names.contains(&"#MoviesDiary"));
assert!(names.contains(&"#Dune"));
}
#[test]
fn review_to_ap_object_has_public_addressing() {
use chrono::NaiveDateTime;
use domain::{
models::{Review, ReviewSource},
value_objects::{MovieId, Rating, ReviewId, UserId},
};
let review = Review::from_persistence(
ReviewId::generate(),
MovieId::from_uuid(uuid::Uuid::new_v4()),
UserId::from_uuid(uuid::Uuid::new_v4()),
Rating::new(3).unwrap(),
None,
NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
NaiveDateTime::parse_from_str("2024-06-01 00:00:00", "%Y-%m-%d %H:%M:%S").unwrap(),
ReviewSource::Local,
);
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
let obj = review_to_ap_object(
&review,
"https://example.com/reviews/1".parse().unwrap(),
actor_url.clone(),
"Dune".to_string(),
2021,
None,
"https://example.com",
);
assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]);
assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]);
}
#[test]
fn watchlist_to_ap_object_has_public_addressing() {
let actor_url: url::Url = "https://example.com/users/abc".parse().unwrap();
let obj = watchlist_to_ap_object(
"https://example.com/watchlist/1".parse().unwrap(),
actor_url.clone(),
"Alien".to_string(),
1979,
None,
None,
chrono::Utc::now(),
"https://example.com",
);
assert_eq!(obj.to, vec!["https://www.w3.org/ns/activitystreams#Public"]);
assert_eq!(obj.cc, vec!["https://example.com/users/abc/followers"]);
}

View File

@@ -2,30 +2,45 @@ use std::sync::Arc;
use activitypub_base::{ApUser, ApUserRepository};
use async_trait::async_trait;
use domain::{ports::UserRepository, value_objects::UserId};
use domain::{
models::ProfileField,
ports::{UserProfileFieldsRepository, UserRepository},
value_objects::UserId,
};
use url::Url;
pub struct DomainUserRepoAdapter {
pub repo: Arc<dyn UserRepository>,
pub fields_repo: Arc<dyn UserProfileFieldsRepository>,
pub base_url: String,
}
impl DomainUserRepoAdapter {
pub fn new(repo: Arc<dyn UserRepository>, base_url: String) -> Self {
Self { repo, base_url }
pub fn new(
repo: Arc<dyn UserRepository>,
fields_repo: Arc<dyn UserProfileFieldsRepository>,
base_url: String,
) -> Self {
Self { repo, fields_repo, base_url }
}
fn build_user(&self, u: &domain::models::User) -> ApUser {
fn build_user(&self, u: &domain::models::User, fields: Vec<ProfileField>) -> ApUser {
let avatar_url = u.avatar_path().and_then(|p| {
Url::parse(&format!("{}/images/{}", self.base_url, p)).ok()
});
let banner_url = u.banner_path().and_then(|p| {
Url::parse(&format!("{}/images/{}", self.base_url, p)).ok()
});
let profile_url = Url::parse(&format!("{}/u/{}", self.base_url, u.username().value())).ok();
ApUser {
id: u.id().value(),
username: u.username().value().to_string(),
bio: u.bio().map(|s| s.to_string()),
avatar_url,
banner_url,
also_known_as: u.also_known_as().map(|s| s.to_string()),
profile_url,
attachment: fields,
}
}
}
@@ -34,13 +49,23 @@ impl DomainUserRepoAdapter {
impl ApUserRepository for DomainUserRepoAdapter {
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> {
let user_id = UserId::from_uuid(id);
Ok(self.repo.find_by_id(&user_id).await?.as_ref().map(|u| self.build_user(u)))
let user = match self.repo.find_by_id(&user_id).await? {
Some(u) => u,
None => return Ok(None),
};
let fields = self.fields_repo.get_fields(&user_id).await.unwrap_or_default();
Ok(Some(self.build_user(&user, fields)))
}
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> {
use domain::value_objects::Username;
let uname = Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?;
Ok(self.repo.find_by_username(&uname).await?.as_ref().map(|u| self.build_user(u)))
let user = match self.repo.find_by_username(&uname).await? {
Some(u) => u,
None => return Ok(None),
};
let fields = self.fields_repo.get_fields(user.id()).await.unwrap_or_default();
Ok(Some(self.build_user(&user, fields)))
}
async fn count_users(&self) -> anyhow::Result<usize> {

View File

@@ -127,6 +127,60 @@ impl FederationRepository for PostgresFederationRepository {
}).collect())
}
async fn get_followers_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<Follower>> {
let uid = local_user_id.to_string();
let limit_i64 = limit as i64;
let offset_i64 = offset as i64;
let rows = sqlx::query(
"SELECT f.remote_actor_url, f.status,
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
FROM ap_followers f
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $1 AND f.status = 'accepted'
ORDER BY f.created_at ASC
LIMIT $2 OFFSET $3",
)
.bind(&uid)
.bind(limit_i64)
.bind(offset_i64)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(|row| {
let url: String = row.get("remote_actor_url");
let status_str: String = row.get("status");
let handle: String = row.try_get("handle").unwrap_or_default();
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
let shared_inbox_url: Option<String> = row.try_get("shared_inbox_url").ok().flatten();
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
Follower {
actor: RemoteActor {
url, handle, inbox_url, shared_inbox_url, display_name, avatar_url,
outbox_url: row.try_get("outbox_url").ok().flatten(),
},
status: str_to_status(&status_str),
}
}).collect())
}
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
let uid = local_user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = $1 AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await?;
Ok(count as usize)
}
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
@@ -232,6 +286,41 @@ impl FederationRepository for PostgresFederationRepository {
Ok(count as usize)
}
async fn get_following_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let limit_i64 = limit as i64;
let offset_i64 = offset as i64;
let rows = sqlx::query(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
FROM ap_following f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = $1 AND f.status = 'accepted'
ORDER BY f.created_at ASC
LIMIT $2 OFFSET $3",
)
.bind(&uid)
.bind(limit_i64)
.bind(offset_i64)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(|row| RemoteActor {
url: row.get("url"),
handle: row.get("handle"),
inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
outbox_url: row.try_get("outbox_url").ok().flatten(),
}).collect())
}
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
let now = Utc::now().naive_utc();
let fetched_at = datetime_to_str(&now);

View File

@@ -0,0 +1,13 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS banner_path TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS also_known_as TEXT;
CREATE TABLE IF NOT EXISTS user_profile_fields (
id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
value TEXT NOT NULL,
position INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_profile_fields_user_id
ON user_profile_fields(user_id);

View File

@@ -18,6 +18,7 @@ mod import_session;
mod models;
mod persons;
mod profile;
mod profile_fields;
mod users;
mod watchlist;
@@ -31,6 +32,7 @@ pub use import_profile::PostgresImportProfileRepository;
pub use import_session::PostgresImportSessionRepository;
pub use persons::{PostgresPersonAdapter, create_person_adapter};
pub use profile::PostgresMovieProfileRepository;
pub use profile_fields::PostgresProfileFieldsRepository;
pub use users::PostgresUserRepository;
pub use watchlist::PostgresWatchlistRepository;
@@ -931,6 +933,12 @@ impl StatsRepository for PostgresRepository {
}
}
pub fn create_profile_fields_repo(
pool: sqlx::PgPool,
) -> std::sync::Arc<dyn domain::ports::UserProfileFieldsRepository> {
std::sync::Arc::new(profile_fields::PostgresProfileFieldsRepository::new(pool))
}
pub async fn wire(database_url: &str) -> anyhow::Result<(
sqlx::PgPool,
std::sync::Arc<dyn domain::ports::MovieRepository>,

View File

@@ -0,0 +1,76 @@
use async_trait::async_trait;
use sqlx::PgPool;
use domain::{
errors::DomainError,
models::ProfileField,
ports::UserProfileFieldsRepository,
value_objects::UserId,
};
pub struct PostgresProfileFieldsRepository {
pool: PgPool,
}
impl PostgresProfileFieldsRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl UserProfileFieldsRepository for PostgresProfileFieldsRepository {
async fn get_fields(&self, user_id: &UserId) -> Result<Vec<ProfileField>, DomainError> {
let id_str = user_id.value().to_string();
#[derive(sqlx::FromRow)]
struct Row {
name: String,
value: String,
}
let rows = sqlx::query_as::<_, Row>(
"SELECT name, value FROM user_profile_fields WHERE user_id = $1 ORDER BY position ASC",
)
.bind(&id_str)
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(rows
.into_iter()
.map(|r| ProfileField {
name: r.name,
value: r.value,
})
.collect())
}
async fn set_fields(
&self,
user_id: &UserId,
fields: Vec<ProfileField>,
) -> Result<(), DomainError> {
let id_str = user_id.value().to_string();
sqlx::query("DELETE FROM user_profile_fields WHERE user_id = $1")
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
for (i, field) in fields.into_iter().enumerate() {
let id = uuid::Uuid::new_v4().to_string();
let position = i as i64;
sqlx::query(
"INSERT INTO user_profile_fields (id, user_id, name, value, position) VALUES ($1, $2, $3, $4, $5)",
)
.bind(&id)
.bind(&id_str)
.bind(&field.name)
.bind(&field.value)
.bind(position)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
}
Ok(())
}
}

View File

@@ -40,6 +40,8 @@ impl PostgresUserRepository {
role: UserRole,
bio: Option<String>,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
) -> Result<User, DomainError> {
let id = uuid::Uuid::parse_str(&id_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
@@ -57,6 +59,8 @@ impl PostgresUserRepository {
role,
bio,
avatar_path,
banner_path,
also_known_as,
))
}
}
@@ -74,9 +78,11 @@ impl UserRepository for PostgresUserRepository {
role: String,
bio: Option<String>,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
}
let row = sqlx::query_as::<_, Row>(
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE email = $1",
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE email = $1",
)
.bind(email_str)
.fetch_optional(&self.pool)
@@ -91,6 +97,8 @@ impl UserRepository for PostgresUserRepository {
Self::parse_role(&r.role),
r.bio,
r.avatar_path,
r.banner_path,
r.also_known_as,
)
})
.transpose()
@@ -107,9 +115,11 @@ impl UserRepository for PostgresUserRepository {
role: String,
bio: Option<String>,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
}
let row = sqlx::query_as::<_, Row>(
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE username = $1",
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE username = $1",
)
.bind(username_str)
.fetch_optional(&self.pool)
@@ -124,6 +134,8 @@ impl UserRepository for PostgresUserRepository {
Self::parse_role(&r.role),
r.bio,
r.avatar_path,
r.banner_path,
r.also_known_as,
)
})
.transpose()
@@ -178,9 +190,11 @@ impl UserRepository for PostgresUserRepository {
role: String,
bio: Option<String>,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
}
let row = sqlx::query_as::<_, Row>(
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE id = $1",
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE id = $1",
)
.bind(&id_str)
.fetch_optional(&self.pool)
@@ -195,6 +209,8 @@ impl UserRepository for PostgresUserRepository {
Self::parse_role(&r.role),
r.bio,
r.avatar_path,
r.banner_path,
r.also_known_as,
)
})
.transpose()
@@ -205,15 +221,21 @@ impl UserRepository for PostgresUserRepository {
user_id: &UserId,
bio: Option<String>,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
) -> Result<(), DomainError> {
let id_str = user_id.value().to_string();
sqlx::query("UPDATE users SET bio = $1, avatar_path = $2 WHERE id = $3")
.bind(&bio)
.bind(&avatar_path)
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
sqlx::query(
"UPDATE users SET bio = $1, avatar_path = $2, banner_path = $3, also_known_as = $4 WHERE id = $5",
)
.bind(&bio)
.bind(&avatar_path)
.bind(&banner_path)
.bind(&also_known_as)
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}

View File

@@ -146,6 +146,68 @@ impl FederationRepository for SqliteFederationRepository {
Ok(followers)
}
async fn get_followers_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<Follower>> {
let uid = local_user_id.to_string();
let limit_i64 = limit as i64;
let offset_i64 = offset as i64;
let rows = sqlx::query(
"SELECT f.remote_actor_url, f.status,
a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
FROM ap_followers f
LEFT JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted'
ORDER BY f.created_at ASC
LIMIT ? OFFSET ?",
)
.bind(&uid)
.bind(limit_i64)
.bind(offset_i64)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|row| {
let url: String = row.get("remote_actor_url");
let status_str: String = row.get("status");
let handle: String = row.try_get("handle").unwrap_or_default();
let inbox_url: String = row.try_get("inbox_url").unwrap_or_default();
let shared_inbox_url: Option<String> = row.try_get("shared_inbox_url").ok().flatten();
let display_name: Option<String> = row.try_get("display_name").ok().flatten();
let avatar_url: Option<String> = row.try_get("avatar_url").ok().flatten();
Follower {
actor: RemoteActor {
url,
handle,
inbox_url,
shared_inbox_url,
display_name,
avatar_url,
outbox_url: row.try_get("outbox_url").ok().flatten(),
},
status: str_to_status(&status_str),
}
})
.collect())
}
async fn count_followers(&self, local_user_id: uuid::Uuid) -> Result<usize> {
let uid = local_user_id.to_string();
let count: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM ap_followers WHERE local_user_id = ? AND status = 'accepted'",
)
.bind(&uid)
.fetch_one(&self.pool)
.await?;
Ok(count as usize)
}
async fn update_follower_status(
&self,
local_user_id: uuid::Uuid,
@@ -261,6 +323,44 @@ impl FederationRepository for SqliteFederationRepository {
Ok(count as usize)
}
async fn get_following_page(
&self,
local_user_id: uuid::Uuid,
offset: u32,
limit: usize,
) -> Result<Vec<RemoteActor>> {
let uid = local_user_id.to_string();
let limit_i64 = limit as i64;
let offset_i64 = offset as i64;
let rows = sqlx::query(
"SELECT a.url, a.handle, a.inbox_url, a.shared_inbox_url, a.display_name, a.avatar_url
FROM ap_following f
INNER JOIN ap_remote_actors a ON a.url = f.remote_actor_url
WHERE f.local_user_id = ? AND f.status = 'accepted'
ORDER BY f.created_at ASC
LIMIT ? OFFSET ?",
)
.bind(&uid)
.bind(limit_i64)
.bind(offset_i64)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|row| RemoteActor {
url: row.get("url"),
handle: row.get("handle"),
inbox_url: row.get("inbox_url"),
shared_inbox_url: row.try_get("shared_inbox_url").ok().flatten(),
display_name: row.try_get("display_name").ok().flatten(),
avatar_url: row.try_get("avatar_url").ok().flatten(),
outbox_url: row.try_get("outbox_url").ok().flatten(),
})
.collect())
}
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
let now = Utc::now().naive_utc();
let fetched_at = datetime_to_str(&now);

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM user_profile_fields WHERE user_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "11f7dd8da277aaf950e2a428f8e072cde8d806ca5b4007bbc882aada5c46ae63"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE id = ?",
"query": "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE username = ?",
"describe": {
"columns": [
{
@@ -37,6 +37,16 @@
"name": "avatar_path",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "banner_path",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "also_known_as",
"ordinal": 8,
"type_info": "Text"
}
],
"parameters": {
@@ -49,8 +59,10 @@
false,
false,
true,
true,
true,
true
]
},
"hash": "d6c6b579a18fb106e62148f5f85b8071fceefea51909ace939ae1d09c4597c43"
"hash": "1dd3efb043635e638f1c3d72923a4ccfb9c9810baee06cfac5ad4af5749e4c6e"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO user_profile_fields (id, user_id, name, value, position) VALUES (?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "5bde1c64a1dec54f348058c9d93842676aa3149bdfc4012f3f3318677a56336d"
}

View File

@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC",
"describe": {
"columns": [
{
"name": "name",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "value",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "5e447e9558515934d8f0c08e91342c0df0b29101223f370a126fb0ee76e3b9bd"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE email = ?",
"query": "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE email = ?",
"describe": {
"columns": [
{
@@ -37,6 +37,16 @@
"name": "avatar_path",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "banner_path",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "also_known_as",
"ordinal": 8,
"type_info": "Text"
}
],
"parameters": {
@@ -49,8 +59,10 @@
false,
false,
true,
true,
true,
true
]
},
"hash": "1edf77b936b825139735e4f92bc472031e3231235ca5fe40732d7bdfddc4cbba"
"hash": "7cb37c7e3df2a945859e12a186a479b9f9f431691d5f0e4ee460cd559f5412b4"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE username = ?",
"query": "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE id = ?",
"describe": {
"columns": [
{
@@ -37,6 +37,16 @@
"name": "avatar_path",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "banner_path",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "also_known_as",
"ordinal": 8,
"type_info": "Text"
}
],
"parameters": {
@@ -49,8 +59,10 @@
false,
false,
true,
true,
true,
true
]
},
"hash": "1417a8a295bc966637eb7e68e088148a7bef09fb1a3c3ea44d25da32c3908472"
"hash": "e6413dcabae4a72628a2abf33a8b65da6f95b7c3c015f2633fcf00c045b9f08b"
}

View File

@@ -0,0 +1,13 @@
ALTER TABLE users ADD COLUMN banner_path TEXT;
ALTER TABLE users ADD COLUMN also_known_as TEXT;
CREATE TABLE IF NOT EXISTS user_profile_fields (
id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
value TEXT NOT NULL,
position INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_profile_fields_user_id
ON user_profile_fields(user_id);

View File

@@ -19,6 +19,7 @@ mod migrations;
mod models;
mod persons;
mod profile;
mod profile_fields;
mod users;
mod watchlist;
@@ -32,9 +33,16 @@ pub use import_profile::SqliteImportProfileRepository;
pub use import_session::SqliteImportSessionRepository;
pub use persons::{SqlitePersonAdapter, create_person_adapter};
pub use profile::SqliteMovieProfileRepository;
pub use profile_fields::SqliteProfileFieldsRepository;
pub use users::SqliteUserRepository;
pub use watchlist::SqliteWatchlistRepository;
pub fn create_profile_fields_repo(
pool: sqlx::SqlitePool,
) -> std::sync::Arc<dyn domain::ports::UserProfileFieldsRepository> {
std::sync::Arc::new(SqliteProfileFieldsRepository::new(pool))
}
fn format_year_month(ym: &str) -> String {
let parts: Vec<&str> = ym.splitn(2, '-').collect();
if parts.len() != 2 {

View File

@@ -0,0 +1,58 @@
use async_trait::async_trait;
use sqlx::SqlitePool;
use domain::{
errors::DomainError,
models::ProfileField,
ports::UserProfileFieldsRepository,
value_objects::UserId,
};
pub struct SqliteProfileFieldsRepository {
pool: SqlitePool,
}
impl SqliteProfileFieldsRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[async_trait]
impl UserProfileFieldsRepository for SqliteProfileFieldsRepository {
async fn get_fields(&self, user_id: &UserId) -> Result<Vec<ProfileField>, DomainError> {
let id_str = user_id.value().to_string();
let rows = sqlx::query!(
"SELECT name, value FROM user_profile_fields WHERE user_id = ? ORDER BY position ASC",
id_str
)
.fetch_all(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(rows.into_iter().map(|r| ProfileField { name: r.name, value: r.value }).collect())
}
async fn set_fields(&self, user_id: &UserId, fields: Vec<ProfileField>) -> Result<(), DomainError> {
let id_str = user_id.value().to_string();
sqlx::query!("DELETE FROM user_profile_fields WHERE user_id = ?", id_str)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
for (i, field) in fields.into_iter().enumerate() {
let id = uuid::Uuid::new_v4().to_string();
let position = i as i64;
sqlx::query!(
"INSERT INTO user_profile_fields (id, user_id, name, value, position) VALUES (?, ?, ?, ?, ?)",
id, id_str, field.name, field.value, position
)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
}
Ok(())
}
}

View File

@@ -6,7 +6,7 @@ use sqlx::SqlitePool;
async fn setup() -> (SqlitePool, SqliteUserRepository) {
let pool = SqlitePool::connect(":memory:").await.unwrap();
sqlx::query(
"CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard', bio TEXT, avatar_path TEXT)"
"CREATE TABLE users (id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'standard', bio TEXT, avatar_path TEXT, banner_path TEXT, also_known_as TEXT)"
)
.execute(&pool)
.await
@@ -61,6 +61,8 @@ async fn update_profile_persists_bio_and_avatar() {
user.id(),
Some("My biography".to_string()),
Some("avatars/user1".to_string()),
None,
None,
)
.await
.unwrap();
@@ -80,10 +82,10 @@ async fn update_profile_clears_fields_with_none() {
UserRole::Standard,
);
repo.save(&user).await.unwrap();
repo.update_profile(user.id(), Some("bio".to_string()), Some("path".to_string()))
repo.update_profile(user.id(), Some("bio".to_string()), Some("path".to_string()), None, None)
.await
.unwrap();
repo.update_profile(user.id(), None, None).await.unwrap();
repo.update_profile(user.id(), None, None, None, None).await.unwrap();
let found = repo.find_by_id(user.id()).await.unwrap().unwrap();
assert_eq!(found.bio(), None);

View File

@@ -39,6 +39,8 @@ impl SqliteUserRepository {
role: UserRole,
bio: Option<String>,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
) -> Result<User, DomainError> {
let id = uuid::Uuid::parse_str(&id_str)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
@@ -56,6 +58,8 @@ impl SqliteUserRepository {
role,
bio,
avatar_path,
banner_path,
also_known_as,
))
}
}
@@ -65,7 +69,7 @@ impl UserRepository for SqliteUserRepository {
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
let email_str = email.value();
let row = sqlx::query!(
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE email = ?",
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE email = ?",
email_str
)
.fetch_optional(&self.pool)
@@ -81,6 +85,8 @@ impl UserRepository for SqliteUserRepository {
Self::parse_role(&r.role),
r.bio,
r.avatar_path,
r.banner_path,
r.also_known_as,
)
})
.transpose()
@@ -89,7 +95,7 @@ impl UserRepository for SqliteUserRepository {
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
let username_str = username.value();
let row = sqlx::query!(
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE username = ?",
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE username = ?",
username_str
)
.fetch_optional(&self.pool)
@@ -105,6 +111,8 @@ impl UserRepository for SqliteUserRepository {
Self::parse_role(&r.role),
r.bio,
r.avatar_path,
r.banner_path,
r.also_known_as,
)
})
.transpose()
@@ -148,7 +156,7 @@ impl UserRepository for SqliteUserRepository {
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
let id_str = id.value().to_string();
let row = sqlx::query!(
"SELECT id, email, username, password_hash, role, bio, avatar_path FROM users WHERE id = ?",
"SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE id = ?",
id_str
)
.fetch_optional(&self.pool)
@@ -164,6 +172,8 @@ impl UserRepository for SqliteUserRepository {
Self::parse_role(&r.role),
r.bio,
r.avatar_path,
r.banner_path,
r.also_known_as,
)
})
.transpose()
@@ -174,15 +184,21 @@ impl UserRepository for SqliteUserRepository {
user_id: &UserId,
bio: Option<String>,
avatar_path: Option<String>,
banner_path: Option<String>,
also_known_as: Option<String>,
) -> Result<(), DomainError> {
let id_str = user_id.value().to_string();
sqlx::query("UPDATE users SET bio = ?, avatar_path = ? WHERE id = ?")
.bind(&bio)
.bind(&avatar_path)
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
sqlx::query(
"UPDATE users SET bio = ?, avatar_path = ?, banner_path = ?, also_known_as = ? WHERE id = ?",
)
.bind(&bio)
.bind(&avatar_path)
.bind(&banner_path)
.bind(&also_known_as)
.bind(&id_str)
.execute(&self.pool)
.await
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(())
}

View File

@@ -342,6 +342,9 @@ struct ProfileSettingsTemplate<'a> {
ctx: &'a HtmlPageContext,
bio: Option<&'a str>,
avatar_url: Option<&'a str>,
banner_url: Option<&'a str>,
also_known_as: Option<&'a str>,
profile_fields: &'a [(String, String)],
saved: bool,
}
@@ -703,6 +706,9 @@ impl HtmlRenderer for AskamaHtmlRenderer {
ctx: &data.ctx,
bio: data.bio.as_deref(),
avatar_url: data.avatar_url.as_deref(),
banner_url: data.banner_url.as_deref(),
also_known_as: data.also_known_as.as_deref(),
profile_fields: &data.profile_fields,
saved: data.saved,
}
.render()

View File

@@ -6,10 +6,17 @@
{% endif %}
<form method="post" action="/settings/profile" enctype="multipart/form-data">
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
<label>
Bio<br>
<textarea name="bio">{% if let Some(b) = bio %}{{ b }}{% endif %}</textarea>
</label>
<label>
Also known as (actor URL for account migration)<br>
<input type="text" name="also_known_as" value="{% if let Some(v) = also_known_as %}{{ v }}{% endif %}">
</label>
{% if let Some(url) = avatar_url %}
<div>
<p>Current avatar:</p>
@@ -20,6 +27,30 @@
Avatar image<br>
<input type="file" name="avatar" accept="image/jpeg,image/png,image/webp">
</label>
{% if let Some(url) = banner_url %}
<div>
<p>Current banner:</p>
<img src="{{ url }}" alt="Current banner" style="max-width:600px;max-height:200px;">
</div>
{% endif %}
<label>
Banner image<br>
<input type="file" name="banner" accept="image/jpeg,image/png,image/webp">
</label>
<fieldset>
<legend>Profile fields (max 4)</legend>
{% for i in 0..4usize %}
<div>
<input type="text" name="field_name_{{ i }}" placeholder="Label"
value="{% if let Some((n, _)) = profile_fields.get(*i) %}{{ n }}{% endif %}">
<input type="text" name="field_value_{{ i }}" placeholder="Value"
value="{% if let Some((_, v)) = profile_fields.get(*i) %}{{ v }}{% endif %}">
</div>
{% endfor %}
</fieldset>
<button type="submit">Save</button>
</form>
{% endblock %}