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:
12
.sqlx/query-11f7dd8da277aaf950e2a428f8e072cde8d806ca5b4007bbc882aada5c46ae63.json
generated
Normal file
12
.sqlx/query-11f7dd8da277aaf950e2a428f8e072cde8d806ca5b4007bbc882aada5c46ae63.json
generated
Normal 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"
|
||||||
|
}
|
||||||
68
.sqlx/query-1dd3efb043635e638f1c3d72923a4ccfb9c9810baee06cfac5ad4af5749e4c6e.json
generated
Normal file
68
.sqlx/query-1dd3efb043635e638f1c3d72923a4ccfb9c9810baee06cfac5ad4af5749e4c6e.json
generated
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE username = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "password_hash",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "role",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bio",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "1dd3efb043635e638f1c3d72923a4ccfb9c9810baee06cfac5ad4af5749e4c6e"
|
||||||
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "SELECT id, email, username, password_hash, role FROM users WHERE username = ?",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"name": "id",
|
|
||||||
"ordinal": 0,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "email",
|
|
||||||
"ordinal": 1,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "username",
|
|
||||||
"ordinal": 2,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "password_hash",
|
|
||||||
"ordinal": 3,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "role",
|
|
||||||
"ordinal": 4,
|
|
||||||
"type_info": "Text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 1
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "319b5d09824809a971f6c9546dde6389a0aca5a732531bb9ca9b99675d0a89f4"
|
|
||||||
}
|
|
||||||
12
.sqlx/query-5bde1c64a1dec54f348058c9d93842676aa3149bdfc4012f3f3318677a56336d.json
generated
Normal file
12
.sqlx/query-5bde1c64a1dec54f348058c9d93842676aa3149bdfc4012f3f3318677a56336d.json
generated
Normal 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"
|
||||||
|
}
|
||||||
26
.sqlx/query-5e447e9558515934d8f0c08e91342c0df0b29101223f370a126fb0ee76e3b9bd.json
generated
Normal file
26
.sqlx/query-5e447e9558515934d8f0c08e91342c0df0b29101223f370a126fb0ee76e3b9bd.json
generated
Normal 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"
|
||||||
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "SELECT id, email, username, password_hash, role FROM users WHERE id = ?",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"name": "id",
|
|
||||||
"ordinal": 0,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "email",
|
|
||||||
"ordinal": 1,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "username",
|
|
||||||
"ordinal": 2,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "password_hash",
|
|
||||||
"ordinal": 3,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "role",
|
|
||||||
"ordinal": 4,
|
|
||||||
"type_info": "Text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 1
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "79af0324db4d0b8e4bd66e12816583598865630241deffbb8b4175fa7a755099"
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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 email = ?",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -37,6 +37,16 @@
|
|||||||
"name": "avatar_path",
|
"name": "avatar_path",
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "banner_path",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "also_known_as",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -49,8 +59,10 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "1417a8a295bc966637eb7e68e088148a7bef09fb1a3c3ea44d25da32c3908472"
|
"hash": "7cb37c7e3df2a945859e12a186a479b9f9f431691d5f0e4ee460cd559f5412b4"
|
||||||
}
|
}
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
"db_name": "SQLite",
|
|
||||||
"query": "SELECT id, email, username, password_hash, role FROM users WHERE email = ?",
|
|
||||||
"describe": {
|
|
||||||
"columns": [
|
|
||||||
{
|
|
||||||
"name": "id",
|
|
||||||
"ordinal": 0,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "email",
|
|
||||||
"ordinal": 1,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "username",
|
|
||||||
"ordinal": 2,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "password_hash",
|
|
||||||
"ordinal": 3,
|
|
||||||
"type_info": "Text"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "role",
|
|
||||||
"ordinal": 4,
|
|
||||||
"type_info": "Text"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"parameters": {
|
|
||||||
"Right": 1
|
|
||||||
},
|
|
||||||
"nullable": [
|
|
||||||
true,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"hash": "c43249558d535343e387586fa00499fe813b18b9e7d8d241462f0689969dc64f"
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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 id = ?",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -37,6 +37,16 @@
|
|||||||
"name": "avatar_path",
|
"name": "avatar_path",
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "banner_path",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "also_known_as",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -49,8 +59,10 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "1edf77b936b825139735e4f92bc472031e3231235ca5fe40732d7bdfddc4cbba"
|
"hash": "e6413dcabae4a72628a2abf33a8b65da6f95b7c3c015f2633fcf00c045b9f08b"
|
||||||
}
|
}
|
||||||
@@ -301,6 +301,10 @@ pub struct CreateActivity {
|
|||||||
pub(crate) kind: CreateType,
|
pub(crate) kind: CreateType,
|
||||||
pub(crate) actor: ObjectId<DbActor>,
|
pub(crate) actor: ObjectId<DbActor>,
|
||||||
pub(crate) object: serde_json::Value,
|
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]
|
#[async_trait::async_trait]
|
||||||
@@ -347,6 +351,10 @@ pub struct DeleteActivity {
|
|||||||
pub(crate) kind: DeleteType,
|
pub(crate) kind: DeleteType,
|
||||||
pub(crate) actor: ObjectId<DbActor>,
|
pub(crate) actor: ObjectId<DbActor>,
|
||||||
pub(crate) object: Url,
|
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]
|
#[async_trait::async_trait]
|
||||||
@@ -392,6 +400,10 @@ pub struct UpdateActivity {
|
|||||||
pub(crate) kind: UpdateType,
|
pub(crate) kind: UpdateType,
|
||||||
pub(crate) actor: ObjectId<DbActor>,
|
pub(crate) actor: ObjectId<DbActor>,
|
||||||
pub(crate) object: serde_json::Value,
|
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]
|
#[async_trait::async_trait]
|
||||||
@@ -495,6 +507,10 @@ pub struct AddActivity {
|
|||||||
pub(crate) kind: AddType,
|
pub(crate) kind: AddType,
|
||||||
pub(crate) actor: ObjectId<DbActor>,
|
pub(crate) actor: ObjectId<DbActor>,
|
||||||
pub(crate) object: serde_json::Value,
|
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]
|
#[async_trait::async_trait]
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ pub struct DbActor {
|
|||||||
pub last_refreshed_at: DateTime<Utc>,
|
pub last_refreshed_at: DateTime<Utc>,
|
||||||
pub bio: Option<String>,
|
pub bio: Option<String>,
|
||||||
pub avatar_url: Option<Url>,
|
pub avatar_url: Option<Url>,
|
||||||
|
pub banner_url: Option<Url>,
|
||||||
|
pub also_known_as: Option<String>,
|
||||||
pub profile_url: Option<Url>,
|
pub profile_url: Option<Url>,
|
||||||
|
pub attachment: Vec<domain::models::ProfileField>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
@@ -38,6 +41,20 @@ pub struct ApImageObject {
|
|||||||
pub url: Url,
|
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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Person {
|
pub struct Person {
|
||||||
@@ -60,6 +77,16 @@ pub struct Person {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
discoverable: Option<bool>,
|
discoverable: Option<bool>,
|
||||||
manually_approves_followers: 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(
|
pub async fn get_local_actor(
|
||||||
@@ -107,7 +134,10 @@ pub async fn get_local_actor(
|
|||||||
last_refreshed_at: Utc::now(),
|
last_refreshed_at: Utc::now(),
|
||||||
bio: user.bio,
|
bio: user.bio,
|
||||||
avatar_url: user.avatar_url,
|
avatar_url: user.avatar_url,
|
||||||
|
banner_url: user.banner_url,
|
||||||
|
also_known_as: user.also_known_as,
|
||||||
profile_url: user.profile_url,
|
profile_url: user.profile_url,
|
||||||
|
attachment: user.attachment,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,11 +197,14 @@ impl Object for DbActor {
|
|||||||
last_refreshed_at: Utc::now(),
|
last_refreshed_at: Utc::now(),
|
||||||
bio: None,
|
bio: None,
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
|
banner_url: None,
|
||||||
|
also_known_as: None,
|
||||||
profile_url: 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 {
|
let public_key = PublicKey {
|
||||||
id: format!("{}#main-key", &self.ap_id),
|
id: format!("{}#main-key", &self.ap_id),
|
||||||
owner: self.ap_id.clone(),
|
owner: self.ap_id.clone(),
|
||||||
@@ -182,7 +215,20 @@ impl Object for DbActor {
|
|||||||
kind: "Image".to_string(),
|
kind: "Image".to_string(),
|
||||||
url,
|
url,
|
||||||
});
|
});
|
||||||
|
let image = self.banner_url.map(|url| ApImageObject {
|
||||||
|
kind: "Image".to_string(),
|
||||||
|
url,
|
||||||
|
});
|
||||||
let profile_url = self.profile_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 {
|
Ok(Person {
|
||||||
kind: Default::default(),
|
kind: Default::default(),
|
||||||
@@ -198,7 +244,12 @@ impl Object for DbActor {
|
|||||||
icon,
|
icon,
|
||||||
url: profile_url,
|
url: profile_url,
|
||||||
discoverable: Some(true),
|
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(),
|
last_refreshed_at: Utc::now(),
|
||||||
bio: None,
|
bio: None,
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
|
banner_url: None,
|
||||||
|
also_known_as: None,
|
||||||
profile_url: None,
|
profile_url: None,
|
||||||
|
attachment: vec![],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
use activitypub_federation::{axum::json::FederationJson, config::Data};
|
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 serde_json::json;
|
||||||
|
|
||||||
use crate::data::FederationData;
|
use crate::data::FederationData;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::repository::FollowerStatus;
|
|
||||||
|
|
||||||
fn ordered_collection(id: String, total: usize, items: Vec<String>) -> serde_json::Value {
|
const PAGE_SIZE: usize = 20;
|
||||||
json!({
|
|
||||||
"@context": "https://www.w3.org/ns/activitystreams",
|
#[derive(Deserialize)]
|
||||||
"type": "OrderedCollection",
|
pub struct PageQuery {
|
||||||
"id": id,
|
page: Option<u32>,
|
||||||
"totalItems": total,
|
|
||||||
"orderedItems": items,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn followers_handler(
|
pub async fn followers_handler(
|
||||||
Path(user_id_str): Path<String>,
|
Path(user_id_str): Path<String>,
|
||||||
|
Query(query): Query<PageQuery>,
|
||||||
data: Data<FederationData>,
|
data: Data<FederationData>,
|
||||||
) -> Result<FederationJson<serde_json::Value>, Error> {
|
) -> Result<FederationJson<serde_json::Value>, Error> {
|
||||||
let user_id = uuid::Uuid::parse_str(&user_id_str)
|
let user_id = uuid::Uuid::parse_str(&user_id_str)
|
||||||
@@ -29,24 +27,53 @@ pub async fn followers_handler(
|
|||||||
.map_err(Error::from)?
|
.map_err(Error::from)?
|
||||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
.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
|
.federation_repo
|
||||||
.get_followers(user_id)
|
.count_followers(user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(Error::from)?;
|
.map_err(Error::from)?;
|
||||||
|
|
||||||
let items: Vec<String> = followers
|
if let Some(page) = query.page {
|
||||||
.into_iter()
|
let page = page.max(1);
|
||||||
.filter(|f| f.status == FollowerStatus::Accepted)
|
let offset = (page.saturating_sub(1) as usize) * PAGE_SIZE;
|
||||||
.map(|f| f.actor.url)
|
let followers = data
|
||||||
.collect();
|
.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);
|
let has_next = offset + followers.len() < total;
|
||||||
Ok(FederationJson(ordered_collection(id, items.len(), items)))
|
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(
|
pub async fn following_handler(
|
||||||
Path(user_id_str): Path<String>,
|
Path(user_id_str): Path<String>,
|
||||||
|
Query(query): Query<PageQuery>,
|
||||||
data: Data<FederationData>,
|
data: Data<FederationData>,
|
||||||
) -> Result<FederationJson<serde_json::Value>, Error> {
|
) -> Result<FederationJson<serde_json::Value>, Error> {
|
||||||
let user_id = uuid::Uuid::parse_str(&user_id_str)
|
let user_id = uuid::Uuid::parse_str(&user_id_str)
|
||||||
@@ -58,14 +85,46 @@ pub async fn following_handler(
|
|||||||
.map_err(Error::from)?
|
.map_err(Error::from)?
|
||||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found")))?;
|
.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
|
.federation_repo
|
||||||
.get_following(user_id)
|
.count_following(user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(Error::from)?;
|
.map_err(Error::from)?;
|
||||||
|
|
||||||
|
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 has_next = offset + following.len() < total;
|
||||||
let items: Vec<String> = following.into_iter().map(|a| a.url).collect();
|
let items: Vec<String> = following.into_iter().map(|a| a.url).collect();
|
||||||
|
|
||||||
let id = format!("{}/users/{}/following", data.base_url, user_id_str);
|
let mut obj = json!({
|
||||||
Ok(FederationJson(ordered_collection(id, items.len(), items)))
|
"@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 repository;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub(crate) mod urls;
|
pub(crate) mod urls;
|
||||||
|
pub use urls::AS_PUBLIC;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
pub mod webfinger;
|
pub mod webfinger;
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use url::Url;
|
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};
|
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 has_more = items.len() == PAGE_SIZE;
|
||||||
let oldest_ts = items.last().map(|(_, _, ts)| *ts);
|
let oldest_ts = items.last().map(|(_, _, ts)| *ts);
|
||||||
|
|
||||||
|
let followers_url = format!("{}/followers", actor_url);
|
||||||
let ordered_items: Vec<serde_json::Value> = items
|
let ordered_items: Vec<serde_json::Value> = items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(ap_id, object, _)| {
|
.map(|(ap_id, object, _)| {
|
||||||
let create_id =
|
let create_id =
|
||||||
Url::parse(&format!("{}/activity", ap_id)).expect("valid url");
|
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,
|
id: create_id,
|
||||||
kind: CreateType::default(),
|
kind: CreateType::default(),
|
||||||
actor: ObjectId::from(actor_url.clone()),
|
actor: ObjectId::from(actor_url.clone()),
|
||||||
object,
|
object,
|
||||||
})
|
to: vec![crate::urls::AS_PUBLIC.to_string()],
|
||||||
|
cc: vec![followers_url.clone()],
|
||||||
|
}))
|
||||||
.expect("serializable")
|
.expect("serializable")
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -58,6 +58,19 @@ pub trait FederationRepository: Send + Sync {
|
|||||||
remote_actor_url: &str,
|
remote_actor_url: &str,
|
||||||
) -> Result<()>;
|
) -> Result<()>;
|
||||||
async fn get_followers(&self, local_user_id: uuid::Uuid) -> Result<Vec<Follower>>;
|
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(
|
async fn update_follower_status(
|
||||||
&self,
|
&self,
|
||||||
local_user_id: uuid::Uuid,
|
local_user_id: uuid::Uuid,
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ impl ActivityPubService {
|
|||||||
.route("/.well-known/nodeinfo", get(nodeinfo_well_known_handler))
|
.route("/.well-known/nodeinfo", get(nodeinfo_well_known_handler))
|
||||||
.route("/nodeinfo/2.0", get(nodeinfo_handler))
|
.route("/nodeinfo/2.0", get(nodeinfo_handler))
|
||||||
.route("/.well-known/webfinger", get(webfinger_handler))
|
.route("/.well-known/webfinger", get(webfinger_handler))
|
||||||
|
.route("/inbox", post(inbox_handler))
|
||||||
.route("/users/{id}/inbox", post(inbox_handler))
|
.route("/users/{id}/inbox", post(inbox_handler))
|
||||||
.route("/users/{id}/outbox", get(outbox_handler))
|
.route("/users/{id}/outbox", get(outbox_handler))
|
||||||
.route("/users/{id}/followers", get(followers_handler))
|
.route("/users/{id}/followers", get(followers_handler))
|
||||||
@@ -487,6 +488,8 @@ impl ActivityPubService {
|
|||||||
kind: Default::default(),
|
kind: Default::default(),
|
||||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||||
object,
|
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);
|
let create_with_ctx = WithContext::new_default(create);
|
||||||
|
|
||||||
@@ -554,6 +557,8 @@ impl ActivityPubService {
|
|||||||
kind: Default::default(),
|
kind: Default::default(),
|
||||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||||
object: ap_id,
|
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 delete_with_ctx = WithContext::new_default(delete);
|
||||||
let inboxes = collect_inboxes(&accepted);
|
let inboxes = collect_inboxes(&accepted);
|
||||||
@@ -617,6 +622,8 @@ impl ActivityPubService {
|
|||||||
kind: Default::default(),
|
kind: Default::default(),
|
||||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||||
object,
|
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 add_with_ctx = WithContext::new_default(add);
|
||||||
let inboxes = collect_inboxes(&accepted);
|
let inboxes = collect_inboxes(&accepted);
|
||||||
@@ -746,6 +753,8 @@ impl ActivityPubService {
|
|||||||
kind: Default::default(),
|
kind: Default::default(),
|
||||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||||
object,
|
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 update_with_ctx = WithContext::new_default(update);
|
||||||
let inboxes = collect_inboxes(&accepted);
|
let inboxes = collect_inboxes(&accepted);
|
||||||
@@ -771,7 +780,8 @@ impl ActivityPubService {
|
|||||||
|
|
||||||
let person = local_actor.clone().into_json(&data).await
|
let person = local_actor.clone().into_json(&data).await
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
.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!(
|
let update_id = Url::parse(&format!(
|
||||||
"{}/activities/update/{}",
|
"{}/activities/update/{}",
|
||||||
@@ -784,6 +794,8 @@ impl ActivityPubService {
|
|||||||
kind: Default::default(),
|
kind: Default::default(),
|
||||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||||
object: person_json,
|
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?;
|
let followers = data.federation_repo.get_followers(user_id).await?;
|
||||||
@@ -793,10 +805,19 @@ impl ActivityPubService {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if accepted.is_empty() {
|
if accepted.is_empty() {
|
||||||
|
tracing::info!(user_id = %user_id, "no accepted followers, skipping actor update broadcast");
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let inboxes = collect_inboxes(&accepted);
|
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(
|
let sends = SendActivityTask::prepare(
|
||||||
&WithContext::new_default(update),
|
&WithContext::new_default(update),
|
||||||
&local_actor,
|
&local_actor,
|
||||||
@@ -807,8 +828,13 @@ impl ActivityPubService {
|
|||||||
|
|
||||||
let failures = send_with_retry(sends, &data).await;
|
let failures = send_with_retry(sends, &data).await;
|
||||||
if !failures.is_empty() {
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1115,6 +1141,8 @@ impl ActivityPubService {
|
|||||||
kind: Default::default(),
|
kind: Default::default(),
|
||||||
actor: ObjectId::from(local_actor.ap_id.clone()),
|
actor: ObjectId::from(local_actor.ap_id.clone()),
|
||||||
object: object_json.clone(),
|
object: object_json.clone(),
|
||||||
|
to: vec![],
|
||||||
|
cc: vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
let sends = SendActivityTask::prepare(
|
let sends = SendActivityTask::prepare(
|
||||||
|
|||||||
@@ -23,11 +23,21 @@ fn person_serializes_with_enriched_fields() {
|
|||||||
}),
|
}),
|
||||||
url: Some("https://example.com/u/alice".parse().unwrap()),
|
url: Some("https://example.com/u/alice".parse().unwrap()),
|
||||||
discoverable: Some(true),
|
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();
|
let json = serde_json::to_value(&person).unwrap();
|
||||||
assert_eq!(json["discoverable"], true);
|
assert_eq!(json["discoverable"], true);
|
||||||
assert_eq!(json["summary"], "Bio text");
|
assert_eq!(json["summary"], "Bio text");
|
||||||
assert_eq!(json["icon"]["type"], "Image");
|
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;
|
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> {
|
pub fn extract_user_id_from_url(url: &Url) -> Option<uuid::Uuid> {
|
||||||
let path = url.path();
|
let path = url.path();
|
||||||
path.strip_prefix("/users/")
|
path.strip_prefix("/users/")
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ pub struct ApUser {
|
|||||||
pub username: String,
|
pub username: String,
|
||||||
pub bio: Option<String>,
|
pub bio: Option<String>,
|
||||||
pub avatar_url: Option<Url>,
|
pub avatar_url: Option<Url>,
|
||||||
|
pub banner_url: Option<Url>,
|
||||||
|
pub also_known_as: Option<String>,
|
||||||
pub profile_url: Option<Url>,
|
pub profile_url: Option<Url>,
|
||||||
|
pub attachment: Vec<domain::models::ProfileField>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ pub async fn wire(
|
|||||||
review_store: std::sync::Arc<dyn RemoteReviewRepository>,
|
review_store: std::sync::Arc<dyn RemoteReviewRepository>,
|
||||||
remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
remote_watchlist_repo: std::sync::Arc<dyn domain::ports::RemoteWatchlistRepository>,
|
||||||
user_repo: std::sync::Arc<dyn domain::ports::UserRepository>,
|
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>,
|
movie_repo: std::sync::Arc<dyn domain::ports::MovieRepository>,
|
||||||
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
|
review_repo: std::sync::Arc<dyn domain::ports::ReviewRepository>,
|
||||||
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
|
diary_repo: std::sync::Arc<dyn domain::ports::DiaryRepository>,
|
||||||
@@ -52,15 +53,26 @@ pub async fn wire(
|
|||||||
watchlist: watchlist_handler,
|
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(
|
let concrete = std::sync::Arc::new(
|
||||||
ActivityPubService::new(
|
ActivityPubService::new(
|
||||||
federation_repo,
|
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,
|
composite,
|
||||||
base_url.clone(),
|
base_url.clone(),
|
||||||
allow_registration,
|
allow_registration,
|
||||||
"movies-diary".to_string(),
|
"movies-diary".to_string(),
|
||||||
cfg!(debug_assertions),
|
federation_debug,
|
||||||
Some(event_publisher),
|
Some(event_publisher),
|
||||||
)
|
)
|
||||||
.await?,
|
.await?,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use activitypub_base::AS_PUBLIC;
|
||||||
use activitypub_federation::kinds::object::NoteType;
|
use activitypub_federation::kinds::object::NoteType;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -36,6 +37,10 @@ pub struct ReviewObject {
|
|||||||
pub(crate) watched_at: DateTime<Utc>,
|
pub(crate) watched_at: DateTime<Utc>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) tag: Vec<ApHashtag>,
|
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.
|
/// Serialize a local Review into a ReviewObject for AP delivery.
|
||||||
@@ -84,7 +89,7 @@ pub fn review_to_ap_object(
|
|||||||
ReviewObject {
|
ReviewObject {
|
||||||
kind: NoteType::default(),
|
kind: NoteType::default(),
|
||||||
id: ap_id,
|
id: ap_id,
|
||||||
attributed_to: actor_url,
|
attributed_to: actor_url.clone(),
|
||||||
content,
|
content,
|
||||||
published: DateTime::from_naive_utc_and_offset(*review.created_at(), Utc),
|
published: DateTime::from_naive_utc_and_offset(*review.created_at(), Utc),
|
||||||
movie_title,
|
movie_title,
|
||||||
@@ -94,6 +99,8 @@ pub fn review_to_ap_object(
|
|||||||
comment: comment_text,
|
comment: comment_text,
|
||||||
watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc),
|
watched_at: DateTime::from_naive_utc_and_offset(*review.watched_at(), Utc),
|
||||||
tag,
|
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.
|
/// Non-Movies-Diary apps ignore unknown fields.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub(crate) watchlist_entry: bool,
|
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(
|
pub fn watchlist_to_ap_object(
|
||||||
@@ -156,7 +167,7 @@ pub fn watchlist_to_ap_object(
|
|||||||
WatchlistObject {
|
WatchlistObject {
|
||||||
kind: NoteType::default(),
|
kind: NoteType::default(),
|
||||||
id: ap_id,
|
id: ap_id,
|
||||||
attributed_to: actor_url,
|
attributed_to: actor_url.clone(),
|
||||||
content,
|
content,
|
||||||
published: added_at,
|
published: added_at,
|
||||||
movie_title,
|
movie_title,
|
||||||
@@ -165,6 +176,8 @@ pub fn watchlist_to_ap_object(
|
|||||||
poster_url,
|
poster_url,
|
||||||
tag,
|
tag,
|
||||||
watchlist_entry: true,
|
watchlist_entry: true,
|
||||||
|
to: vec![AS_PUBLIC.to_string()],
|
||||||
|
cc: vec![format!("{}/followers", actor_url)],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,3 +39,52 @@ fn review_to_ap_object_includes_two_hashtags() {
|
|||||||
assert!(names.contains(&"#MoviesDiary"));
|
assert!(names.contains(&"#MoviesDiary"));
|
||||||
assert!(names.contains(&"#Dune"));
|
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"]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,30 +2,45 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use activitypub_base::{ApUser, ApUserRepository};
|
use activitypub_base::{ApUser, ApUserRepository};
|
||||||
use async_trait::async_trait;
|
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;
|
use url::Url;
|
||||||
|
|
||||||
pub struct DomainUserRepoAdapter {
|
pub struct DomainUserRepoAdapter {
|
||||||
pub repo: Arc<dyn UserRepository>,
|
pub repo: Arc<dyn UserRepository>,
|
||||||
|
pub fields_repo: Arc<dyn UserProfileFieldsRepository>,
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DomainUserRepoAdapter {
|
impl DomainUserRepoAdapter {
|
||||||
pub fn new(repo: Arc<dyn UserRepository>, base_url: String) -> Self {
|
pub fn new(
|
||||||
Self { repo, base_url }
|
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| {
|
let avatar_url = u.avatar_path().and_then(|p| {
|
||||||
Url::parse(&format!("{}/images/{}", self.base_url, p)).ok()
|
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();
|
let profile_url = Url::parse(&format!("{}/u/{}", self.base_url, u.username().value())).ok();
|
||||||
ApUser {
|
ApUser {
|
||||||
id: u.id().value(),
|
id: u.id().value(),
|
||||||
username: u.username().value().to_string(),
|
username: u.username().value().to_string(),
|
||||||
bio: u.bio().map(|s| s.to_string()),
|
bio: u.bio().map(|s| s.to_string()),
|
||||||
avatar_url,
|
avatar_url,
|
||||||
|
banner_url,
|
||||||
|
also_known_as: u.also_known_as().map(|s| s.to_string()),
|
||||||
profile_url,
|
profile_url,
|
||||||
|
attachment: fields,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,13 +49,23 @@ impl DomainUserRepoAdapter {
|
|||||||
impl ApUserRepository for DomainUserRepoAdapter {
|
impl ApUserRepository for DomainUserRepoAdapter {
|
||||||
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> {
|
async fn find_by_id(&self, id: uuid::Uuid) -> anyhow::Result<Option<ApUser>> {
|
||||||
let user_id = UserId::from_uuid(id);
|
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>> {
|
async fn find_by_username(&self, username: &str) -> anyhow::Result<Option<ApUser>> {
|
||||||
use domain::value_objects::Username;
|
use domain::value_objects::Username;
|
||||||
let uname = Username::new(username.to_string()).map_err(|e| anyhow::anyhow!(e.to_string()))?;
|
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> {
|
async fn count_users(&self) -> anyhow::Result<usize> {
|
||||||
|
|||||||
@@ -127,6 +127,60 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
}).collect())
|
}).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(
|
async fn update_follower_status(
|
||||||
&self,
|
&self,
|
||||||
local_user_id: uuid::Uuid,
|
local_user_id: uuid::Uuid,
|
||||||
@@ -232,6 +286,41 @@ impl FederationRepository for PostgresFederationRepository {
|
|||||||
Ok(count as usize)
|
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<()> {
|
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
let fetched_at = datetime_to_str(&now);
|
let fetched_at = datetime_to_str(&now);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -18,6 +18,7 @@ mod import_session;
|
|||||||
mod models;
|
mod models;
|
||||||
mod persons;
|
mod persons;
|
||||||
mod profile;
|
mod profile;
|
||||||
|
mod profile_fields;
|
||||||
mod users;
|
mod users;
|
||||||
mod watchlist;
|
mod watchlist;
|
||||||
|
|
||||||
@@ -31,6 +32,7 @@ pub use import_profile::PostgresImportProfileRepository;
|
|||||||
pub use import_session::PostgresImportSessionRepository;
|
pub use import_session::PostgresImportSessionRepository;
|
||||||
pub use persons::{PostgresPersonAdapter, create_person_adapter};
|
pub use persons::{PostgresPersonAdapter, create_person_adapter};
|
||||||
pub use profile::PostgresMovieProfileRepository;
|
pub use profile::PostgresMovieProfileRepository;
|
||||||
|
pub use profile_fields::PostgresProfileFieldsRepository;
|
||||||
pub use users::PostgresUserRepository;
|
pub use users::PostgresUserRepository;
|
||||||
pub use watchlist::PostgresWatchlistRepository;
|
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<(
|
pub async fn wire(database_url: &str) -> anyhow::Result<(
|
||||||
sqlx::PgPool,
|
sqlx::PgPool,
|
||||||
std::sync::Arc<dyn domain::ports::MovieRepository>,
|
std::sync::Arc<dyn domain::ports::MovieRepository>,
|
||||||
|
|||||||
76
crates/adapters/postgres/src/profile_fields.rs
Normal file
76
crates/adapters/postgres/src/profile_fields.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,8 @@ impl PostgresUserRepository {
|
|||||||
role: UserRole,
|
role: UserRole,
|
||||||
bio: Option<String>,
|
bio: Option<String>,
|
||||||
avatar_path: Option<String>,
|
avatar_path: Option<String>,
|
||||||
|
banner_path: Option<String>,
|
||||||
|
also_known_as: Option<String>,
|
||||||
) -> Result<User, DomainError> {
|
) -> Result<User, DomainError> {
|
||||||
let id = uuid::Uuid::parse_str(&id_str)
|
let id = uuid::Uuid::parse_str(&id_str)
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
@@ -57,6 +59,8 @@ impl PostgresUserRepository {
|
|||||||
role,
|
role,
|
||||||
bio,
|
bio,
|
||||||
avatar_path,
|
avatar_path,
|
||||||
|
banner_path,
|
||||||
|
also_known_as,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,9 +78,11 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
role: String,
|
role: String,
|
||||||
bio: Option<String>,
|
bio: Option<String>,
|
||||||
avatar_path: Option<String>,
|
avatar_path: Option<String>,
|
||||||
|
banner_path: Option<String>,
|
||||||
|
also_known_as: Option<String>,
|
||||||
}
|
}
|
||||||
let row = sqlx::query_as::<_, Row>(
|
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)
|
.bind(email_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -91,6 +97,8 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
Self::parse_role(&r.role),
|
Self::parse_role(&r.role),
|
||||||
r.bio,
|
r.bio,
|
||||||
r.avatar_path,
|
r.avatar_path,
|
||||||
|
r.banner_path,
|
||||||
|
r.also_known_as,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.transpose()
|
.transpose()
|
||||||
@@ -107,9 +115,11 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
role: String,
|
role: String,
|
||||||
bio: Option<String>,
|
bio: Option<String>,
|
||||||
avatar_path: Option<String>,
|
avatar_path: Option<String>,
|
||||||
|
banner_path: Option<String>,
|
||||||
|
also_known_as: Option<String>,
|
||||||
}
|
}
|
||||||
let row = sqlx::query_as::<_, Row>(
|
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)
|
.bind(username_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -124,6 +134,8 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
Self::parse_role(&r.role),
|
Self::parse_role(&r.role),
|
||||||
r.bio,
|
r.bio,
|
||||||
r.avatar_path,
|
r.avatar_path,
|
||||||
|
r.banner_path,
|
||||||
|
r.also_known_as,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.transpose()
|
.transpose()
|
||||||
@@ -178,9 +190,11 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
role: String,
|
role: String,
|
||||||
bio: Option<String>,
|
bio: Option<String>,
|
||||||
avatar_path: Option<String>,
|
avatar_path: Option<String>,
|
||||||
|
banner_path: Option<String>,
|
||||||
|
also_known_as: Option<String>,
|
||||||
}
|
}
|
||||||
let row = sqlx::query_as::<_, Row>(
|
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)
|
.bind(&id_str)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -195,6 +209,8 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
Self::parse_role(&r.role),
|
Self::parse_role(&r.role),
|
||||||
r.bio,
|
r.bio,
|
||||||
r.avatar_path,
|
r.avatar_path,
|
||||||
|
r.banner_path,
|
||||||
|
r.also_known_as,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.transpose()
|
.transpose()
|
||||||
@@ -205,11 +221,17 @@ impl UserRepository for PostgresUserRepository {
|
|||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
bio: Option<String>,
|
bio: Option<String>,
|
||||||
avatar_path: Option<String>,
|
avatar_path: Option<String>,
|
||||||
|
banner_path: Option<String>,
|
||||||
|
also_known_as: Option<String>,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let id_str = user_id.value().to_string();
|
let id_str = user_id.value().to_string();
|
||||||
sqlx::query("UPDATE users SET bio = $1, avatar_path = $2 WHERE id = $3")
|
sqlx::query(
|
||||||
|
"UPDATE users SET bio = $1, avatar_path = $2, banner_path = $3, also_known_as = $4 WHERE id = $5",
|
||||||
|
)
|
||||||
.bind(&bio)
|
.bind(&bio)
|
||||||
.bind(&avatar_path)
|
.bind(&avatar_path)
|
||||||
|
.bind(&banner_path)
|
||||||
|
.bind(&also_known_as)
|
||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -146,6 +146,68 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
Ok(followers)
|
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(
|
async fn update_follower_status(
|
||||||
&self,
|
&self,
|
||||||
local_user_id: uuid::Uuid,
|
local_user_id: uuid::Uuid,
|
||||||
@@ -261,6 +323,44 @@ impl FederationRepository for SqliteFederationRepository {
|
|||||||
Ok(count as usize)
|
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<()> {
|
async fn upsert_remote_actor(&self, actor: RemoteActor) -> Result<()> {
|
||||||
let now = Utc::now().naive_utc();
|
let now = Utc::now().naive_utc();
|
||||||
let fetched_at = datetime_to_str(&now);
|
let fetched_at = datetime_to_str(&now);
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "DELETE FROM user_profile_fields WHERE user_id = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "11f7dd8da277aaf950e2a428f8e072cde8d806ca5b4007bbc882aada5c46ae63"
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE username = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "password_hash",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "role",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bio",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "1dd3efb043635e638f1c3d72923a4ccfb9c9810baee06cfac5ad4af5749e4c6e"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"db_name": "SQLite",
|
||||||
|
"query": "SELECT id, email, username, password_hash, role, bio, avatar_path, banner_path, also_known_as FROM users WHERE email = ?",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"ordinal": 0,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"ordinal": 1,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"ordinal": 2,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "password_hash",
|
||||||
|
"ordinal": 3,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "role",
|
||||||
|
"ordinal": 4,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bio",
|
||||||
|
"ordinal": 5,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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": {
|
||||||
|
"Right": 1
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "7cb37c7e3df2a945859e12a186a479b9f9f431691d5f0e4ee460cd559f5412b4"
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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 id = ?",
|
||||||
"describe": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -37,6 +37,16 @@
|
|||||||
"name": "avatar_path",
|
"name": "avatar_path",
|
||||||
"ordinal": 6,
|
"ordinal": 6,
|
||||||
"type_info": "Text"
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "banner_path",
|
||||||
|
"ordinal": 7,
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "also_known_as",
|
||||||
|
"ordinal": 8,
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -49,8 +59,10 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "d6c6b579a18fb106e62148f5f85b8071fceefea51909ace939ae1d09c4597c43"
|
"hash": "e6413dcabae4a72628a2abf33a8b65da6f95b7c3c015f2633fcf00c045b9f08b"
|
||||||
}
|
}
|
||||||
13
crates/adapters/sqlite/migrations/0019_profile_extended.sql
Normal file
13
crates/adapters/sqlite/migrations/0019_profile_extended.sql
Normal 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);
|
||||||
@@ -19,6 +19,7 @@ mod migrations;
|
|||||||
mod models;
|
mod models;
|
||||||
mod persons;
|
mod persons;
|
||||||
mod profile;
|
mod profile;
|
||||||
|
mod profile_fields;
|
||||||
mod users;
|
mod users;
|
||||||
mod watchlist;
|
mod watchlist;
|
||||||
|
|
||||||
@@ -32,9 +33,16 @@ pub use import_profile::SqliteImportProfileRepository;
|
|||||||
pub use import_session::SqliteImportSessionRepository;
|
pub use import_session::SqliteImportSessionRepository;
|
||||||
pub use persons::{SqlitePersonAdapter, create_person_adapter};
|
pub use persons::{SqlitePersonAdapter, create_person_adapter};
|
||||||
pub use profile::SqliteMovieProfileRepository;
|
pub use profile::SqliteMovieProfileRepository;
|
||||||
|
pub use profile_fields::SqliteProfileFieldsRepository;
|
||||||
pub use users::SqliteUserRepository;
|
pub use users::SqliteUserRepository;
|
||||||
pub use watchlist::SqliteWatchlistRepository;
|
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 {
|
fn format_year_month(ym: &str) -> String {
|
||||||
let parts: Vec<&str> = ym.splitn(2, '-').collect();
|
let parts: Vec<&str> = ym.splitn(2, '-').collect();
|
||||||
if parts.len() != 2 {
|
if parts.len() != 2 {
|
||||||
|
|||||||
58
crates/adapters/sqlite/src/profile_fields.rs
Normal file
58
crates/adapters/sqlite/src/profile_fields.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ use sqlx::SqlitePool;
|
|||||||
async fn setup() -> (SqlitePool, SqliteUserRepository) {
|
async fn setup() -> (SqlitePool, SqliteUserRepository) {
|
||||||
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
let pool = SqlitePool::connect(":memory:").await.unwrap();
|
||||||
sqlx::query(
|
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)
|
.execute(&pool)
|
||||||
.await
|
.await
|
||||||
@@ -61,6 +61,8 @@ async fn update_profile_persists_bio_and_avatar() {
|
|||||||
user.id(),
|
user.id(),
|
||||||
Some("My biography".to_string()),
|
Some("My biography".to_string()),
|
||||||
Some("avatars/user1".to_string()),
|
Some("avatars/user1".to_string()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -80,10 +82,10 @@ async fn update_profile_clears_fields_with_none() {
|
|||||||
UserRole::Standard,
|
UserRole::Standard,
|
||||||
);
|
);
|
||||||
repo.save(&user).await.unwrap();
|
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
|
.await
|
||||||
.unwrap();
|
.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();
|
let found = repo.find_by_id(user.id()).await.unwrap().unwrap();
|
||||||
assert_eq!(found.bio(), None);
|
assert_eq!(found.bio(), None);
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ impl SqliteUserRepository {
|
|||||||
role: UserRole,
|
role: UserRole,
|
||||||
bio: Option<String>,
|
bio: Option<String>,
|
||||||
avatar_path: Option<String>,
|
avatar_path: Option<String>,
|
||||||
|
banner_path: Option<String>,
|
||||||
|
also_known_as: Option<String>,
|
||||||
) -> Result<User, DomainError> {
|
) -> Result<User, DomainError> {
|
||||||
let id = uuid::Uuid::parse_str(&id_str)
|
let id = uuid::Uuid::parse_str(&id_str)
|
||||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
|
||||||
@@ -56,6 +58,8 @@ impl SqliteUserRepository {
|
|||||||
role,
|
role,
|
||||||
bio,
|
bio,
|
||||||
avatar_path,
|
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> {
|
async fn find_by_email(&self, email: &Email) -> Result<Option<User>, DomainError> {
|
||||||
let email_str = email.value();
|
let email_str = email.value();
|
||||||
let row = sqlx::query!(
|
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
|
email_str
|
||||||
)
|
)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -81,6 +85,8 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
Self::parse_role(&r.role),
|
Self::parse_role(&r.role),
|
||||||
r.bio,
|
r.bio,
|
||||||
r.avatar_path,
|
r.avatar_path,
|
||||||
|
r.banner_path,
|
||||||
|
r.also_known_as,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.transpose()
|
.transpose()
|
||||||
@@ -89,7 +95,7 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
async fn find_by_username(&self, username: &Username) -> Result<Option<User>, DomainError> {
|
||||||
let username_str = username.value();
|
let username_str = username.value();
|
||||||
let row = sqlx::query!(
|
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
|
username_str
|
||||||
)
|
)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -105,6 +111,8 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
Self::parse_role(&r.role),
|
Self::parse_role(&r.role),
|
||||||
r.bio,
|
r.bio,
|
||||||
r.avatar_path,
|
r.avatar_path,
|
||||||
|
r.banner_path,
|
||||||
|
r.also_known_as,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.transpose()
|
.transpose()
|
||||||
@@ -148,7 +156,7 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
async fn find_by_id(&self, id: &UserId) -> Result<Option<User>, DomainError> {
|
||||||
let id_str = id.value().to_string();
|
let id_str = id.value().to_string();
|
||||||
let row = sqlx::query!(
|
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
|
id_str
|
||||||
)
|
)
|
||||||
.fetch_optional(&self.pool)
|
.fetch_optional(&self.pool)
|
||||||
@@ -164,6 +172,8 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
Self::parse_role(&r.role),
|
Self::parse_role(&r.role),
|
||||||
r.bio,
|
r.bio,
|
||||||
r.avatar_path,
|
r.avatar_path,
|
||||||
|
r.banner_path,
|
||||||
|
r.also_known_as,
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.transpose()
|
.transpose()
|
||||||
@@ -174,11 +184,17 @@ impl UserRepository for SqliteUserRepository {
|
|||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
bio: Option<String>,
|
bio: Option<String>,
|
||||||
avatar_path: Option<String>,
|
avatar_path: Option<String>,
|
||||||
|
banner_path: Option<String>,
|
||||||
|
also_known_as: Option<String>,
|
||||||
) -> Result<(), DomainError> {
|
) -> Result<(), DomainError> {
|
||||||
let id_str = user_id.value().to_string();
|
let id_str = user_id.value().to_string();
|
||||||
sqlx::query("UPDATE users SET bio = ?, avatar_path = ? WHERE id = ?")
|
sqlx::query(
|
||||||
|
"UPDATE users SET bio = ?, avatar_path = ?, banner_path = ?, also_known_as = ? WHERE id = ?",
|
||||||
|
)
|
||||||
.bind(&bio)
|
.bind(&bio)
|
||||||
.bind(&avatar_path)
|
.bind(&avatar_path)
|
||||||
|
.bind(&banner_path)
|
||||||
|
.bind(&also_known_as)
|
||||||
.bind(&id_str)
|
.bind(&id_str)
|
||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -342,6 +342,9 @@ struct ProfileSettingsTemplate<'a> {
|
|||||||
ctx: &'a HtmlPageContext,
|
ctx: &'a HtmlPageContext,
|
||||||
bio: Option<&'a str>,
|
bio: Option<&'a str>,
|
||||||
avatar_url: 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,
|
saved: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -703,6 +706,9 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
|||||||
ctx: &data.ctx,
|
ctx: &data.ctx,
|
||||||
bio: data.bio.as_deref(),
|
bio: data.bio.as_deref(),
|
||||||
avatar_url: data.avatar_url.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,
|
saved: data.saved,
|
||||||
}
|
}
|
||||||
.render()
|
.render()
|
||||||
|
|||||||
@@ -6,10 +6,17 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<form method="post" action="/settings/profile" enctype="multipart/form-data">
|
<form method="post" action="/settings/profile" enctype="multipart/form-data">
|
||||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Bio<br>
|
Bio<br>
|
||||||
<textarea name="bio">{% if let Some(b) = bio %}{{ b }}{% endif %}</textarea>
|
<textarea name="bio">{% if let Some(b) = bio %}{{ b }}{% endif %}</textarea>
|
||||||
</label>
|
</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 %}
|
{% if let Some(url) = avatar_url %}
|
||||||
<div>
|
<div>
|
||||||
<p>Current avatar:</p>
|
<p>Current avatar:</p>
|
||||||
@@ -20,6 +27,30 @@
|
|||||||
Avatar image<br>
|
Avatar image<br>
|
||||||
<input type="file" name="avatar" accept="image/jpeg,image/png,image/webp">
|
<input type="file" name="avatar" accept="image/jpeg,image/png,image/webp">
|
||||||
</label>
|
</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>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -78,6 +78,14 @@ pub struct UpdateProfileCommand {
|
|||||||
pub bio: Option<String>,
|
pub bio: Option<String>,
|
||||||
pub avatar_bytes: Option<Vec<u8>>,
|
pub avatar_bytes: Option<Vec<u8>>,
|
||||||
pub avatar_content_type: Option<String>,
|
pub avatar_content_type: Option<String>,
|
||||||
|
pub banner_bytes: Option<Vec<u8>>,
|
||||||
|
pub banner_content_type: Option<String>,
|
||||||
|
pub also_known_as: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UpdateProfileFieldsCommand {
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub fields: Vec<domain::models::ProfileField>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct EnrichMovieCommand {
|
pub struct EnrichMovieCommand {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use domain::ports::{
|
|||||||
ImportProfileRepository, ImportSessionRepository,
|
ImportProfileRepository, ImportSessionRepository,
|
||||||
MetadataClient, MovieProfileRepository, MovieRepository, PasswordHasher, PosterFetcherClient,
|
MetadataClient, MovieProfileRepository, MovieRepository, PasswordHasher, PosterFetcherClient,
|
||||||
PersonCommand, PersonQuery, SearchCommand, SearchPort,
|
PersonCommand, PersonQuery, SearchCommand, SearchPort,
|
||||||
ReviewRepository, StatsRepository, UserRepository,
|
ReviewRepository, StatsRepository, UserProfileFieldsRepository, UserRepository,
|
||||||
WatchlistRepository,
|
WatchlistRepository,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "federation")]
|
#[cfg(feature = "federation")]
|
||||||
@@ -37,6 +37,7 @@ pub struct AppContext {
|
|||||||
pub search_port: Arc<dyn SearchPort>,
|
pub search_port: Arc<dyn SearchPort>,
|
||||||
pub search_command: Arc<dyn SearchCommand>,
|
pub search_command: Arc<dyn SearchCommand>,
|
||||||
pub watchlist_repository: Arc<dyn WatchlistRepository>,
|
pub watchlist_repository: Arc<dyn WatchlistRepository>,
|
||||||
|
pub profile_fields_repository: Arc<dyn UserProfileFieldsRepository>,
|
||||||
#[cfg(feature = "federation")]
|
#[cfg(feature = "federation")]
|
||||||
pub remote_watchlist_repository: Arc<dyn RemoteWatchlistRepository>,
|
pub remote_watchlist_repository: Arc<dyn RemoteWatchlistRepository>,
|
||||||
pub config: AppConfig,
|
pub config: AppConfig,
|
||||||
|
|||||||
@@ -177,6 +177,9 @@ pub struct ProfileSettingsPageData {
|
|||||||
pub ctx: HtmlPageContext,
|
pub ctx: HtmlPageContext,
|
||||||
pub bio: Option<String>,
|
pub bio: Option<String>,
|
||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
|
pub banner_url: Option<String>,
|
||||||
|
pub also_known_as: Option<String>,
|
||||||
|
pub profile_fields: Vec<(String, String)>,
|
||||||
pub saved: bool,
|
pub saved: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ pub mod register;
|
|||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod sync_poster;
|
pub mod sync_poster;
|
||||||
pub mod update_profile;
|
pub mod update_profile;
|
||||||
|
pub mod update_profile_fields;
|
||||||
pub mod add_to_watchlist;
|
pub mod add_to_watchlist;
|
||||||
pub mod remove_from_watchlist;
|
pub mod remove_from_watchlist;
|
||||||
pub mod get_watchlist;
|
pub mod get_watchlist;
|
||||||
|
|||||||
@@ -15,33 +15,46 @@ pub async fn execute(ctx: &AppContext, cmd: UpdateProfileCommand) -> Result<(),
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| DomainError::NotFound("User not found".into()))?;
|
.ok_or_else(|| DomainError::NotFound("User not found".into()))?;
|
||||||
|
|
||||||
|
// Handle avatar
|
||||||
let new_avatar_path = if let Some(bytes) = cmd.avatar_bytes {
|
let new_avatar_path = if let Some(bytes) = cmd.avatar_bytes {
|
||||||
let content_type = cmd.avatar_content_type.as_deref().unwrap_or("");
|
let content_type = cmd.avatar_content_type.as_deref().unwrap_or("");
|
||||||
if !["image/jpeg", "image/png", "image/webp"].contains(&content_type) {
|
if !["image/jpeg", "image/png", "image/webp"].contains(&content_type) {
|
||||||
return Err(DomainError::ValidationError(
|
return Err(DomainError::ValidationError("Avatar must be jpeg, png, or webp".into()));
|
||||||
"Avatar must be jpeg, png, or webp".into(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
if let Some(old_path) = user.avatar_path() {
|
if let Some(old_path) = user.avatar_path() {
|
||||||
let _ = ctx.image_storage.delete(old_path).await;
|
let _ = ctx.image_storage.delete(old_path).await;
|
||||||
}
|
}
|
||||||
let key = format!("avatars/{}", user_id.value());
|
let key = format!("avatars/{}", user_id.value());
|
||||||
let stored = ctx.image_storage.store(&key, &bytes).await?;
|
let stored = ctx.image_storage.store(&key, &bytes).await?;
|
||||||
|
if let Err(e) = ctx.event_publisher.publish(&DomainEvent::ImageStored { key: stored.clone() }).await {
|
||||||
if let Err(e) = ctx.event_publisher
|
tracing::warn!("failed to emit ImageStored for avatar {stored}: {e}");
|
||||||
.publish(&DomainEvent::ImageStored { key: stored.clone() })
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!("failed to emit ImageStored for {stored}: {e}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(stored)
|
Some(stored)
|
||||||
} else {
|
} else {
|
||||||
user.avatar_path().map(|s| s.to_string())
|
user.avatar_path().map(|s| s.to_string())
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle banner
|
||||||
|
let new_banner_path = if let Some(bytes) = cmd.banner_bytes {
|
||||||
|
let content_type = cmd.banner_content_type.as_deref().unwrap_or("");
|
||||||
|
if !["image/jpeg", "image/png", "image/webp"].contains(&content_type) {
|
||||||
|
return Err(DomainError::ValidationError("Banner must be jpeg, png, or webp".into()));
|
||||||
|
}
|
||||||
|
if let Some(old_path) = user.banner_path() {
|
||||||
|
let _ = ctx.image_storage.delete(old_path).await;
|
||||||
|
}
|
||||||
|
let key = format!("banners/{}", user_id.value());
|
||||||
|
let stored = ctx.image_storage.store(&key, &bytes).await?;
|
||||||
|
if let Err(e) = ctx.event_publisher.publish(&DomainEvent::ImageStored { key: stored.clone() }).await {
|
||||||
|
tracing::warn!("failed to emit ImageStored for banner {stored}: {e}");
|
||||||
|
}
|
||||||
|
Some(stored)
|
||||||
|
} else {
|
||||||
|
user.banner_path().map(|s| s.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
ctx.user_repository
|
ctx.user_repository
|
||||||
.update_profile(&user_id, cmd.bio, new_avatar_path)
|
.update_profile(&user_id, cmd.bio, new_avatar_path, new_banner_path, cmd.also_known_as)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
ctx.event_publisher
|
ctx.event_publisher
|
||||||
|
|||||||
17
crates/application/src/use_cases/update_profile_fields.rs
Normal file
17
crates/application/src/use_cases/update_profile_fields.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
events::DomainEvent,
|
||||||
|
value_objects::UserId,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{commands::UpdateProfileFieldsCommand, context::AppContext};
|
||||||
|
|
||||||
|
pub async fn execute(ctx: &AppContext, cmd: UpdateProfileFieldsCommand) -> Result<(), DomainError> {
|
||||||
|
if cmd.fields.len() > 4 {
|
||||||
|
return Err(DomainError::ValidationError("Maximum 4 profile fields allowed".into()));
|
||||||
|
}
|
||||||
|
let user_id = UserId::from_uuid(cmd.user_id);
|
||||||
|
ctx.profile_fields_repository.set_fields(&user_id, cmd.fields).await?;
|
||||||
|
ctx.event_publisher.publish(&DomainEvent::UserUpdated { user_id }).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -306,6 +306,12 @@ pub enum UserRole {
|
|||||||
Admin,
|
Admin,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ProfileField {
|
||||||
|
pub name: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
id: UserId,
|
id: UserId,
|
||||||
@@ -315,6 +321,8 @@ pub struct User {
|
|||||||
role: UserRole,
|
role: UserRole,
|
||||||
bio: Option<String>,
|
bio: Option<String>,
|
||||||
avatar_path: Option<String>,
|
avatar_path: Option<String>,
|
||||||
|
banner_path: Option<String>,
|
||||||
|
also_known_as: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
@@ -332,6 +340,8 @@ impl User {
|
|||||||
role,
|
role,
|
||||||
bio: None,
|
bio: None,
|
||||||
avatar_path: None,
|
avatar_path: None,
|
||||||
|
banner_path: None,
|
||||||
|
also_known_as: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +353,8 @@ impl User {
|
|||||||
role: UserRole,
|
role: UserRole,
|
||||||
bio: Option<String>,
|
bio: Option<String>,
|
||||||
avatar_path: Option<String>,
|
avatar_path: Option<String>,
|
||||||
|
banner_path: Option<String>,
|
||||||
|
also_known_as: Option<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
@@ -352,6 +364,8 @@ impl User {
|
|||||||
role,
|
role,
|
||||||
bio,
|
bio,
|
||||||
avatar_path,
|
avatar_path,
|
||||||
|
banner_path,
|
||||||
|
also_known_as,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,9 +373,11 @@ impl User {
|
|||||||
self.password_hash = new_hash;
|
self.password_hash = new_hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_profile(&mut self, bio: Option<String>, avatar_path: Option<String>) {
|
pub fn update_profile(&mut self, bio: Option<String>, avatar_path: Option<String>, banner_path: Option<String>, also_known_as: Option<String>) {
|
||||||
self.bio = bio;
|
self.bio = bio;
|
||||||
self.avatar_path = avatar_path;
|
self.avatar_path = avatar_path;
|
||||||
|
self.banner_path = banner_path;
|
||||||
|
self.also_known_as = also_known_as;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn email(&self) -> &Email {
|
pub fn email(&self) -> &Email {
|
||||||
@@ -386,6 +402,14 @@ impl User {
|
|||||||
pub fn avatar_path(&self) -> Option<&str> {
|
pub fn avatar_path(&self) -> Option<&str> {
|
||||||
self.avatar_path.as_deref()
|
self.avatar_path.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn banner_path(&self) -> Option<&str> {
|
||||||
|
self.banner_path.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn also_known_as(&self) -> Option<&str> {
|
||||||
|
self.also_known_as.as_deref()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
|
|||||||
@@ -188,9 +188,17 @@ pub trait UserRepository: Send + Sync {
|
|||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
bio: Option<String>,
|
bio: Option<String>,
|
||||||
avatar_path: Option<String>,
|
avatar_path: Option<String>,
|
||||||
|
banner_path: Option<String>,
|
||||||
|
also_known_as: Option<String>,
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserProfileFieldsRepository: Send + Sync {
|
||||||
|
async fn get_fields(&self, user_id: &UserId) -> Result<Vec<crate::models::ProfileField>, DomainError>;
|
||||||
|
async fn set_fields(&self, user_id: &UserId, fields: Vec<crate::models::ProfileField>) -> Result<(), DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait EventPublisher: Send + Sync {
|
pub trait EventPublisher: Send + Sync {
|
||||||
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>;
|
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError>;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use application::{
|
|||||||
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
|
delete_review, export_diary as export_diary_uc, get_activity_feed as get_feed_uc,
|
||||||
get_diary, get_movie_social_page, get_movies, get_review_history,
|
get_diary, get_movie_social_page, get_movies, get_review_history,
|
||||||
get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc,
|
get_user_profile as get_user_profile_uc, get_users, log_review, login as login_uc,
|
||||||
register as register_uc, sync_poster, update_profile,
|
register as register_uc, sync_poster, update_profile, update_profile_fields,
|
||||||
search as search_uc, get_person, get_person_credits,
|
search as search_uc, get_person, get_person_credits,
|
||||||
add_to_watchlist, remove_from_watchlist, get_watchlist, is_on_watchlist,
|
add_to_watchlist, remove_from_watchlist, get_watchlist, is_on_watchlist,
|
||||||
},
|
},
|
||||||
@@ -433,21 +433,29 @@ pub async fn update_profile_handler(
|
|||||||
let mut bio: Option<String> = None;
|
let mut bio: Option<String> = None;
|
||||||
let mut avatar_bytes: Option<Vec<u8>> = None;
|
let mut avatar_bytes: Option<Vec<u8>> = None;
|
||||||
let mut avatar_content_type: Option<String> = None;
|
let mut avatar_content_type: Option<String> = None;
|
||||||
|
let mut banner_bytes: Option<Vec<u8>> = None;
|
||||||
|
let mut banner_content_type: Option<String> = None;
|
||||||
|
let mut also_known_as: Option<String> = None;
|
||||||
|
|
||||||
while let Ok(Some(field)) = multipart.next_field().await {
|
while let Ok(Some(field)) = multipart.next_field().await {
|
||||||
let name = field.name().unwrap_or("").to_string();
|
let name = field.name().unwrap_or("").to_string();
|
||||||
match name.as_str() {
|
match name.as_str() {
|
||||||
"bio" => {
|
"bio" => { if let Ok(text) = field.text().await { bio = Some(text); } }
|
||||||
|
"also_known_as" => {
|
||||||
if let Ok(text) = field.text().await {
|
if let Ok(text) = field.text().await {
|
||||||
bio = Some(text);
|
also_known_as = Some(text).filter(|s| !s.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"avatar" => {
|
"avatar" => {
|
||||||
let content_type = field.content_type().map(|s| s.to_string());
|
let ct = field.content_type().map(|s| s.to_string());
|
||||||
if let Ok(bytes) = field.bytes().await
|
if let Ok(bytes) = field.bytes().await {
|
||||||
&& !bytes.is_empty() {
|
if !bytes.is_empty() { avatar_bytes = Some(bytes.to_vec()); avatar_content_type = ct; }
|
||||||
avatar_bytes = Some(bytes.to_vec());
|
}
|
||||||
avatar_content_type = content_type;
|
}
|
||||||
|
"banner" => {
|
||||||
|
let ct = field.content_type().map(|s| s.to_string());
|
||||||
|
if let Ok(bytes) = field.bytes().await {
|
||||||
|
if !bytes.is_empty() { banner_bytes = Some(bytes.to_vec()); banner_content_type = ct; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -459,6 +467,9 @@ pub async fn update_profile_handler(
|
|||||||
bio,
|
bio,
|
||||||
avatar_bytes,
|
avatar_bytes,
|
||||||
avatar_content_type,
|
avatar_content_type,
|
||||||
|
banner_bytes,
|
||||||
|
banner_content_type,
|
||||||
|
also_known_as,
|
||||||
};
|
};
|
||||||
|
|
||||||
match update_profile::execute(&state.app_ctx, cmd).await {
|
match update_profile::execute(&state.app_ctx, cmd).await {
|
||||||
@@ -474,6 +485,42 @@ pub async fn update_profile_handler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_profile_fields_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AuthenticatedUser(user_id): AuthenticatedUser,
|
||||||
|
axum::Json(body): axum::Json<serde_json::Value>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let raw_fields = match body.get("fields").and_then(|f| f.as_array()) {
|
||||||
|
Some(arr) => arr.clone(),
|
||||||
|
None => return StatusCode::BAD_REQUEST.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let fields: Vec<domain::models::ProfileField> = raw_fields
|
||||||
|
.iter()
|
||||||
|
.filter_map(|f| {
|
||||||
|
let name = f.get("name").and_then(|n| n.as_str())?.to_string();
|
||||||
|
let value = f.get("value").and_then(|v| v.as_str())?.to_string();
|
||||||
|
Some(domain::models::ProfileField { name, value })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let cmd = application::commands::UpdateProfileFieldsCommand {
|
||||||
|
user_id: user_id.value(),
|
||||||
|
fields,
|
||||||
|
};
|
||||||
|
|
||||||
|
match update_profile_fields::execute(&state.app_ctx, cmd).await {
|
||||||
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
|
Err(domain::errors::DomainError::ValidationError(msg)) => {
|
||||||
|
(StatusCode::BAD_REQUEST, msg).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("update_profile_fields error: {:?}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn movie_to_dto(movie: &Movie) -> MovieDto {
|
fn movie_to_dto(movie: &Movie) -> MovieDto {
|
||||||
MovieDto {
|
MovieDto {
|
||||||
id: movie.id().value(),
|
id: movie.id().value(),
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ use application::{
|
|||||||
use_cases::{
|
use_cases::{
|
||||||
add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page,
|
add_to_watchlist, delete_review, export_diary as export_diary_uc, get_movie_social_page,
|
||||||
get_watchlist, is_on_watchlist, log_review, login as login_uc, register as register_uc,
|
get_watchlist, is_on_watchlist, log_review, login as login_uc, register as register_uc,
|
||||||
remove_from_watchlist, update_profile,
|
remove_from_watchlist, update_profile, update_profile_fields,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use domain::models::ExportFormat;
|
use domain::models::ExportFormat;
|
||||||
@@ -1249,6 +1249,17 @@ pub async fn get_profile_settings(
|
|||||||
let avatar_url = user
|
let avatar_url = user
|
||||||
.avatar_path()
|
.avatar_path()
|
||||||
.map(|path| format!("{}/images/{}", base_url, path));
|
.map(|path| format!("{}/images/{}", base_url, path));
|
||||||
|
let banner_url = user
|
||||||
|
.banner_path()
|
||||||
|
.map(|path| format!("{}/images/{}", base_url, path));
|
||||||
|
|
||||||
|
let profile_fields = state.app_ctx.profile_fields_repository
|
||||||
|
.get_fields(&user_id)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|f| (f.name, f.value))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let saved = params.saved.as_deref() == Some("1");
|
let saved = params.saved.as_deref() == Some("1");
|
||||||
|
|
||||||
@@ -1256,6 +1267,9 @@ pub async fn get_profile_settings(
|
|||||||
ctx,
|
ctx,
|
||||||
bio: user.bio().map(|s| s.to_string()),
|
bio: user.bio().map(|s| s.to_string()),
|
||||||
avatar_url,
|
avatar_url,
|
||||||
|
banner_url,
|
||||||
|
also_known_as: user.also_known_as().map(|s| s.to_string()),
|
||||||
|
profile_fields,
|
||||||
saved,
|
saved,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1430,21 +1444,45 @@ pub async fn post_profile_settings(
|
|||||||
let mut bio: Option<String> = None;
|
let mut bio: Option<String> = None;
|
||||||
let mut avatar_bytes: Option<Vec<u8>> = None;
|
let mut avatar_bytes: Option<Vec<u8>> = None;
|
||||||
let mut avatar_content_type: Option<String> = None;
|
let mut avatar_content_type: Option<String> = None;
|
||||||
|
let mut banner_bytes: Option<Vec<u8>> = None;
|
||||||
|
let mut banner_content_type: Option<String> = None;
|
||||||
|
let mut also_known_as: Option<String> = None;
|
||||||
|
let mut field_names: std::collections::HashMap<usize, String> = std::collections::HashMap::new();
|
||||||
|
let mut field_values: std::collections::HashMap<usize, String> = std::collections::HashMap::new();
|
||||||
|
|
||||||
while let Ok(Some(field)) = multipart.next_field().await {
|
while let Ok(Some(field)) = multipart.next_field().await {
|
||||||
let name = field.name().unwrap_or("").to_string();
|
let name = field.name().unwrap_or("").to_string();
|
||||||
match name.as_str() {
|
match name.as_str() {
|
||||||
"bio" => {
|
"bio" => { if let Ok(text) = field.text().await { bio = Some(text); } }
|
||||||
|
"also_known_as" => {
|
||||||
if let Ok(text) = field.text().await {
|
if let Ok(text) = field.text().await {
|
||||||
bio = Some(text);
|
also_known_as = Some(text).filter(|s| !s.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"avatar" => {
|
"avatar" => {
|
||||||
let content_type = field.content_type().map(|s| s.to_string());
|
let ct = field.content_type().map(|s| s.to_string());
|
||||||
if let Ok(bytes) = field.bytes().await
|
if let Ok(bytes) = field.bytes().await {
|
||||||
&& !bytes.is_empty() {
|
if !bytes.is_empty() { avatar_bytes = Some(bytes.to_vec()); avatar_content_type = ct; }
|
||||||
avatar_bytes = Some(bytes.to_vec());
|
}
|
||||||
avatar_content_type = content_type;
|
}
|
||||||
|
"banner" => {
|
||||||
|
let ct = field.content_type().map(|s| s.to_string());
|
||||||
|
if let Ok(bytes) = field.bytes().await {
|
||||||
|
if !bytes.is_empty() { banner_bytes = Some(bytes.to_vec()); banner_content_type = ct; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n if n.starts_with("field_name_") => {
|
||||||
|
if let Ok(idx) = n["field_name_".len()..].parse::<usize>() {
|
||||||
|
if let Ok(text) = field.text().await {
|
||||||
|
if !text.is_empty() { field_names.insert(idx, text); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n if n.starts_with("field_value_") => {
|
||||||
|
if let Ok(idx) = n["field_value_".len()..].parse::<usize>() {
|
||||||
|
if let Ok(text) = field.text().await {
|
||||||
|
if !text.is_empty() { field_values.insert(idx, text); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -1456,9 +1494,26 @@ pub async fn post_profile_settings(
|
|||||||
bio,
|
bio,
|
||||||
avatar_bytes,
|
avatar_bytes,
|
||||||
avatar_content_type,
|
avatar_content_type,
|
||||||
|
banner_bytes,
|
||||||
|
banner_content_type,
|
||||||
|
also_known_as,
|
||||||
};
|
};
|
||||||
|
|
||||||
let _ = update_profile::execute(&state.app_ctx, cmd).await;
|
let _ = update_profile::execute(&state.app_ctx, cmd).await;
|
||||||
|
|
||||||
|
let fields: Vec<domain::models::ProfileField> = (0..4)
|
||||||
|
.filter_map(|i| {
|
||||||
|
field_names.get(&i).map(|name| domain::models::ProfileField {
|
||||||
|
name: name.clone(),
|
||||||
|
value: field_values.get(&i).cloned().unwrap_or_default(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let fields_cmd = application::commands::UpdateProfileFieldsCommand {
|
||||||
|
user_id: user_id.value(),
|
||||||
|
fields,
|
||||||
|
};
|
||||||
|
let _ = update_profile_fields::execute(&state.app_ctx, fields_cmd).await;
|
||||||
|
|
||||||
Redirect::to("/settings/profile?saved=1").into_response()
|
Redirect::to("/settings/profile?saved=1").into_response()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,15 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
|||||||
_ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build (sqlite feature is not enabled)"),
|
_ => anyhow::bail!("DATABASE_BACKEND={backend} is not supported by this build (sqlite feature is not enabled)"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let profile_fields_repo = match &db_pool {
|
||||||
|
#[cfg(feature = "postgres")]
|
||||||
|
DbPool::Postgres(pool) => postgres::create_profile_fields_repo(pool.clone()),
|
||||||
|
#[cfg(feature = "sqlite")]
|
||||||
|
DbPool::Sqlite(pool) => sqlite::create_profile_fields_repo(pool.clone()),
|
||||||
|
#[cfg(not(feature = "sqlite"))]
|
||||||
|
_ => anyhow::bail!("no profile fields repo for this backend"),
|
||||||
|
};
|
||||||
|
|
||||||
// Wire up event channel, federation service, and ap_router
|
// Wire up event channel, federation service, and ap_router
|
||||||
let event_bus = EventBusBackend::from_env()?;
|
let event_bus = EventBusBackend::from_env()?;
|
||||||
|
|
||||||
@@ -114,6 +123,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
|||||||
review_store,
|
review_store,
|
||||||
remote_watchlist_repo.clone(),
|
remote_watchlist_repo.clone(),
|
||||||
Arc::clone(&user_repository),
|
Arc::clone(&user_repository),
|
||||||
|
Arc::clone(&profile_fields_repo),
|
||||||
Arc::clone(&movie_repository),
|
Arc::clone(&movie_repository),
|
||||||
Arc::clone(&review_repository),
|
Arc::clone(&review_repository),
|
||||||
Arc::clone(&diary_repository),
|
Arc::clone(&diary_repository),
|
||||||
@@ -173,6 +183,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
|
|||||||
import_profile_repository: import_profile_repository as Arc<dyn ImportProfileRepository>,
|
import_profile_repository: import_profile_repository as Arc<dyn ImportProfileRepository>,
|
||||||
movie_profile_repository,
|
movie_profile_repository,
|
||||||
watchlist_repository,
|
watchlist_repository,
|
||||||
|
profile_fields_repository: profile_fields_repo,
|
||||||
#[cfg(feature = "federation")]
|
#[cfg(feature = "federation")]
|
||||||
remote_watchlist_repository: remote_watchlist_repo,
|
remote_watchlist_repository: remote_watchlist_repo,
|
||||||
person_command,
|
person_command,
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
|
|||||||
.route("/import/profiles", routing::get(handlers::import::api_get_profiles).post(handlers::import::api_post_profile))
|
.route("/import/profiles", routing::get(handlers::import::api_get_profiles).post(handlers::import::api_post_profile))
|
||||||
.route("/import/profiles/{id}", routing::delete(handlers::import::api_delete_profile))
|
.route("/import/profiles/{id}", routing::delete(handlers::import::api_delete_profile))
|
||||||
.route("/profile", routing::get(handlers::api::get_profile).put(handlers::api::update_profile_handler))
|
.route("/profile", routing::get(handlers::api::get_profile).put(handlers::api::update_profile_handler))
|
||||||
|
.route("/profile/fields", routing::put(handlers::api::update_profile_fields_handler))
|
||||||
.route("/search", routing::get(handlers::api::get_search))
|
.route("/search", routing::get(handlers::api::get_search))
|
||||||
.route("/people/{id}", routing::get(handlers::api::get_person_handler))
|
.route("/people/{id}", routing::get(handlers::api::get_person_handler))
|
||||||
.route("/people/{id}/credits", routing::get(handlers::api::get_person_credits_handler))
|
.route("/people/{id}/credits", routing::get(handlers::api::get_person_credits_handler))
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ use anyhow::Context;
|
|||||||
use domain::ports::{
|
use domain::ports::{
|
||||||
DiaryRepository, ImageRefCommand, ImageRefQuery, ImportProfileRepository,
|
DiaryRepository, ImageRefCommand, ImageRefQuery, ImportProfileRepository,
|
||||||
ImportSessionRepository, MovieProfileRepository, MovieRepository, PersonCommand, PersonQuery,
|
ImportSessionRepository, MovieProfileRepository, MovieRepository, PersonCommand, PersonQuery,
|
||||||
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserRepository, WatchlistRepository,
|
ReviewRepository, SearchCommand, SearchPort, StatsRepository, UserProfileFieldsRepository,
|
||||||
|
UserRepository, WatchlistRepository,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub enum DbPool {
|
pub enum DbPool {
|
||||||
@@ -30,6 +31,7 @@ pub struct Repos {
|
|||||||
pub person_query: Arc<dyn PersonQuery>,
|
pub person_query: Arc<dyn PersonQuery>,
|
||||||
pub search_command: Arc<dyn SearchCommand>,
|
pub search_command: Arc<dyn SearchCommand>,
|
||||||
pub search_port: Arc<dyn SearchPort>,
|
pub search_port: Arc<dyn SearchPort>,
|
||||||
|
pub profile_fields: Arc<dyn UserProfileFieldsRepository>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<(Repos, DbPool)> {
|
pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<(Repos, DbPool)> {
|
||||||
@@ -41,10 +43,12 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<(Repos
|
|||||||
let (image_ref_command, image_ref_query) = postgres::create_image_ref(pool.clone());
|
let (image_ref_command, image_ref_query) = postgres::create_image_ref(pool.clone());
|
||||||
let (person_command, person_query) = postgres::create_person_adapter(pool.clone());
|
let (person_command, person_query) = postgres::create_person_adapter(pool.clone());
|
||||||
let (search_command, search_port) = postgres_search::create_search_adapter(pool.clone());
|
let (search_command, search_port) = postgres_search::create_search_adapter(pool.clone());
|
||||||
|
let pf = postgres::create_profile_fields_repo(pool.clone());
|
||||||
Ok((Repos { movie: m, review: r, diary: d, stats: s, user: u,
|
Ok((Repos { movie: m, review: r, diary: d, stats: s, user: u,
|
||||||
import_session: is, import_profile: ip, movie_profile: mp, watchlist: wl,
|
import_session: is, import_profile: ip, movie_profile: mp, watchlist: wl,
|
||||||
image_ref_command, image_ref_query,
|
image_ref_command, image_ref_query,
|
||||||
person_command, person_query, search_command, search_port },
|
person_command, person_query, search_command, search_port,
|
||||||
|
profile_fields: pf },
|
||||||
DbPool::Postgres(pool)))
|
DbPool::Postgres(pool)))
|
||||||
}
|
}
|
||||||
#[cfg(feature = "sqlite")]
|
#[cfg(feature = "sqlite")]
|
||||||
@@ -54,10 +58,12 @@ pub async fn connect(database_url: &str, backend: &str) -> anyhow::Result<(Repos
|
|||||||
let (image_ref_command, image_ref_query) = sqlite::create_image_ref(pool.clone());
|
let (image_ref_command, image_ref_query) = sqlite::create_image_ref(pool.clone());
|
||||||
let (person_command, person_query) = sqlite::create_person_adapter(pool.clone());
|
let (person_command, person_query) = sqlite::create_person_adapter(pool.clone());
|
||||||
let (search_command, search_port) = sqlite_search::create_search_adapter(pool.clone());
|
let (search_command, search_port) = sqlite_search::create_search_adapter(pool.clone());
|
||||||
|
let pf = sqlite::create_profile_fields_repo(pool.clone());
|
||||||
Ok((Repos { movie: m, review: r, diary: d, stats: s, user: u,
|
Ok((Repos { movie: m, review: r, diary: d, stats: s, user: u,
|
||||||
import_session: is, import_profile: ip, movie_profile: mp, watchlist: wl,
|
import_session: is, import_profile: ip, movie_profile: mp, watchlist: wl,
|
||||||
image_ref_command, image_ref_query,
|
image_ref_command, image_ref_query,
|
||||||
person_command, person_query, search_command, search_port },
|
person_command, person_query, search_command, search_port,
|
||||||
|
profile_fields: pf },
|
||||||
DbPool::Sqlite(pool)))
|
DbPool::Sqlite(pool)))
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "sqlite"))]
|
#[cfg(not(feature = "sqlite"))]
|
||||||
|
|||||||
@@ -38,14 +38,16 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let person_query = Arc::clone(&repos.person_query);
|
let person_query = Arc::clone(&repos.person_query);
|
||||||
let search_command = Arc::clone(&repos.search_command);
|
let search_command = Arc::clone(&repos.search_command);
|
||||||
let search_port = Arc::clone(&repos.search_port);
|
let search_port = Arc::clone(&repos.search_port);
|
||||||
|
let profile_fields_repo = Arc::clone(&repos.profile_fields);
|
||||||
|
|
||||||
// Clone refs federation handler needs before ctx consumes them.
|
// Clone refs federation handler needs before ctx consumes them.
|
||||||
#[cfg(feature = "federation")]
|
#[cfg(feature = "federation")]
|
||||||
let (fed_movie_repo, fed_review_repo, fed_diary_repo, fed_user_repo, base_url, allow_registration) = (
|
let (fed_movie_repo, fed_review_repo, fed_diary_repo, fed_user_repo, fed_profile_fields_repo, base_url, allow_registration) = (
|
||||||
Arc::clone(&repos.movie),
|
Arc::clone(&repos.movie),
|
||||||
Arc::clone(&repos.review),
|
Arc::clone(&repos.review),
|
||||||
Arc::clone(&repos.diary),
|
Arc::clone(&repos.diary),
|
||||||
Arc::clone(&repos.user),
|
Arc::clone(&repos.user),
|
||||||
|
Arc::clone(&repos.profile_fields),
|
||||||
app_config.base_url.clone(),
|
app_config.base_url.clone(),
|
||||||
app_config.allow_registration,
|
app_config.allow_registration,
|
||||||
);
|
);
|
||||||
@@ -76,6 +78,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
import_profile_repository: repos.import_profile,
|
import_profile_repository: repos.import_profile,
|
||||||
movie_profile_repository: repos.movie_profile,
|
movie_profile_repository: repos.movie_profile,
|
||||||
watchlist_repository: repos.watchlist,
|
watchlist_repository: repos.watchlist,
|
||||||
|
profile_fields_repository: Arc::clone(&profile_fields_repo),
|
||||||
#[cfg(feature = "federation")]
|
#[cfg(feature = "federation")]
|
||||||
remote_watchlist_repository: fed_remote_watchlist_repo.clone(),
|
remote_watchlist_repository: fed_remote_watchlist_repo.clone(),
|
||||||
person_command: Arc::clone(&person_command),
|
person_command: Arc::clone(&person_command),
|
||||||
@@ -172,6 +175,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
fed_review_store,
|
fed_review_store,
|
||||||
fed_remote_watchlist_repo,
|
fed_remote_watchlist_repo,
|
||||||
fed_user_repo,
|
fed_user_repo,
|
||||||
|
fed_profile_fields_repo,
|
||||||
fed_movie_repo,
|
fed_movie_repo,
|
||||||
fed_review_repo,
|
fed_review_repo,
|
||||||
fed_diary_repo,
|
fed_diary_repo,
|
||||||
@@ -207,7 +211,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
fn init_tracing() {
|
fn init_tracing() {
|
||||||
let filter = std::env::var("RUST_LOG")
|
let filter = std::env::var("RUST_LOG")
|
||||||
.unwrap_or_else(|_| "worker=info,application=info".to_string());
|
.unwrap_or_else(|_| "worker=info,application=info,activitypub_base=info".to_string());
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
.with(tracing_subscriber::EnvFilter::new(filter))
|
.with(tracing_subscriber::EnvFilter::new(filter))
|
||||||
.with(tracing_subscriber::fmt::layer())
|
.with(tracing_subscriber::fmt::layer())
|
||||||
|
|||||||
Reference in New Issue
Block a user