From 805bd9534f73ab2caccb9e3c29610354505b4770 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Fri, 29 May 2026 13:54:25 +0200 Subject: [PATCH] feat: add profile fields for local users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB→domain→API→AP→frontend end-to-end. Fields stored as JSONB, exposed via PATCH /users/me, serialized as AP PropertyValue attachment. Editor in federation settings, display on profile card. --- Cargo.lock | 1 + .../adapters/postgres-federation/Cargo.toml | 1 + .../adapters/postgres-federation/src/lib.rs | 25 ++++- crates/adapters/postgres-search/src/lib.rs | 1 + .../migrations/020_user_profile_fields.sql | 1 + crates/adapters/postgres/src/feed/mod.rs | 1 + crates/adapters/postgres/src/follow/mod.rs | 6 +- .../adapters/postgres/src/top_friend/mod.rs | 2 +- crates/adapters/postgres/src/user/mod.rs | 50 ++++++++-- crates/api-types/src/requests.rs | 1 + crates/api-types/src/responses.rs | 5 +- crates/application/src/testing.rs | 1 + crates/domain/src/models/user.rs | 3 + crates/presentation/src/handlers/auth.rs | 10 +- crates/presentation/src/handlers/users/mod.rs | 4 + .../app/settings/federation/page.tsx | 5 + .../app/users/[username]/page.tsx | 21 ++++ .../components/profile-fields-editor.tsx | 99 +++++++++++++++++++ thoughts-frontend/lib/api.ts | 14 +-- 19 files changed, 224 insertions(+), 27 deletions(-) create mode 100644 crates/adapters/postgres/migrations/020_user_profile_fields.sql create mode 100644 thoughts-frontend/components/profile-fields-editor.tsx diff --git a/Cargo.lock b/Cargo.lock index 2d58288..b363034 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2554,6 +2554,7 @@ dependencies = [ "async-trait", "chrono", "k-ap", + "serde_json", "sqlx", "tokio", "tracing", diff --git a/crates/adapters/postgres-federation/Cargo.toml b/crates/adapters/postgres-federation/Cargo.toml index 7a260eb..cf91c97 100644 --- a/crates/adapters/postgres-federation/Cargo.toml +++ b/crates/adapters/postgres-federation/Cargo.toml @@ -12,6 +12,7 @@ tracing = { workspace = true } async-trait = { workspace = true } anyhow = { workspace = true } url = { workspace = true } +serde_json = { workspace = true } [dev-dependencies] tokio = { workspace = true, features = ["full"] } diff --git a/crates/adapters/postgres-federation/src/lib.rs b/crates/adapters/postgres-federation/src/lib.rs index 77d3a46..5143bef 100644 --- a/crates/adapters/postgres-federation/src/lib.rs +++ b/crates/adapters/postgres-federation/src/lib.rs @@ -13,8 +13,9 @@ impl IntoAnyhow for std::result::Result { } use k_ap::{ - ActivityRepository, ActorRepository, ApActorType, ApUser, ApUserRepository, BlockedDomain, - BlocklistRepository, FollowRepository, Follower, FollowerStatus, FollowingStatus, RemoteActor, + ActivityRepository, ActorRepository, ApActorType, ApProfileField, ApUser, ApUserRepository, + BlockedDomain, BlocklistRepository, FollowRepository, Follower, FollowerStatus, + FollowingStatus, RemoteActor, }; // ── PostgresFederationRepository ───────────────────────────────────────────── @@ -751,6 +752,7 @@ struct UserRow { avatar_url: Option, header_url: Option, also_known_as: Option, + profile_fields: Option, } pub struct PgApUserRepository { @@ -767,6 +769,19 @@ impl PgApUserRepository { let profile_url = url::Url::parse(&format!("{}/users/{}", self.base_url, r.id)).ok(); let avatar_url = r.avatar_url.and_then(|u| url::Url::parse(&u).ok()); let banner_url = r.header_url.and_then(|u| url::Url::parse(&u).ok()); + let attachment = r + .profile_fields + .and_then(|v| v.as_array().cloned()) + .map(|arr| { + arr.into_iter() + .filter_map(|item| { + let name = item.get("name")?.as_str()?.to_string(); + let value = item.get("value")?.as_str()?.to_string(); + Some(ApProfileField { name, value }) + }) + .collect() + }) + .unwrap_or_default(); ApUser { id: r.id, username: r.username, @@ -776,7 +791,7 @@ impl PgApUserRepository { banner_url, also_known_as: r.also_known_as.into_iter().collect(), profile_url, - attachment: vec![], + attachment, manually_approves_followers: true, discoverable: true, actor_type: ApActorType::default(), @@ -789,7 +804,7 @@ impl PgApUserRepository { impl ApUserRepository for PgApUserRepository { async fn find_by_id(&self, id: uuid::Uuid) -> Result> { let row = sqlx::query_as::<_, UserRow>( - "SELECT id,username,display_name,bio,avatar_url,header_url,also_known_as FROM users WHERE id=$1 AND local=true", + "SELECT id,username,display_name,bio,avatar_url,header_url,also_known_as,profile_fields FROM users WHERE id=$1 AND local=true", ) .bind(id) .fetch_optional(&self.pool) @@ -800,7 +815,7 @@ impl ApUserRepository for PgApUserRepository { async fn find_by_username(&self, username: &str) -> Result> { let row = sqlx::query_as::<_, UserRow>( - "SELECT id,username,display_name,bio,avatar_url,header_url,also_known_as FROM users WHERE username=$1 AND local=true", + "SELECT id,username,display_name,bio,avatar_url,header_url,also_known_as,profile_fields FROM users WHERE username=$1 AND local=true", ) .bind(username) .fetch_optional(&self.pool) diff --git a/crates/adapters/postgres-search/src/lib.rs b/crates/adapters/postgres-search/src/lib.rs index 5e3febb..229c355 100644 --- a/crates/adapters/postgres-search/src/lib.rs +++ b/crates/adapters/postgres-search/src/lib.rs @@ -105,6 +105,7 @@ fn row_to_entry(r: FeedRow, viewer: Option) -> Result) -> Result( - "SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.created_at,u.updated_at + "SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.local,u.created_at,u.updated_at FROM users u JOIN follows f ON f.follower_id=u.id WHERE f.following_id=$1 AND f.state='accepted' ORDER BY f.created_at DESC LIMIT $2 OFFSET $3" @@ -154,7 +154,7 @@ impl FollowRepository for PgFollowRepository { .into_domain()?; let rows = sqlx::query_as::<_, crate::user::UserRow>( - "SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.local,u.ap_id,u.inbox_url,u.created_at,u.updated_at + "SELECT u.id,u.username,u.email,u.password_hash,u.display_name,u.bio,u.avatar_url,u.header_url,u.custom_css,u.profile_fields,u.local,u.created_at,u.updated_at FROM users u JOIN follows f ON f.following_id=u.id WHERE f.follower_id=$1 AND f.state='accepted' ORDER BY f.created_at DESC LIMIT $2 OFFSET $3" @@ -210,7 +210,7 @@ impl FollowRepository for PgFollowRepository { let rows = sqlx::query_as::<_, crate::user::UserRow>( "SELECT u.id, u.username, u.email, u.password_hash, u.display_name, u.bio, - u.avatar_url, u.header_url, u.custom_css, u.local, + u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.local, u.created_at, u.updated_at FROM users u JOIN follows f1 diff --git a/crates/adapters/postgres/src/top_friend/mod.rs b/crates/adapters/postgres/src/top_friend/mod.rs index 313fd1e..062fa03 100644 --- a/crates/adapters/postgres/src/top_friend/mod.rs +++ b/crates/adapters/postgres/src/top_friend/mod.rs @@ -54,7 +54,7 @@ impl TopFriendRepository for PgTopFriendRepository { let rows = sqlx::query_as::<_, TopFriendRow>( "SELECT tf.user_id AS tf_user_id, tf.friend_id, tf.position, u.id, u.username, u.email, u.password_hash, u.display_name, u.bio, - u.avatar_url, u.header_url, u.custom_css, u.local, + u.avatar_url, u.header_url, u.custom_css, u.profile_fields, u.local, u.created_at, u.updated_at FROM top_friends tf JOIN users u ON u.id=tf.friend_id WHERE tf.user_id=$1 ORDER BY tf.position", diff --git a/crates/adapters/postgres/src/user/mod.rs b/crates/adapters/postgres/src/user/mod.rs index 67b1d64..77ea0e8 100644 --- a/crates/adapters/postgres/src/user/mod.rs +++ b/crates/adapters/postgres/src/user/mod.rs @@ -31,11 +31,26 @@ pub struct UserRow { pub avatar_url: Option, pub header_url: Option, pub custom_css: Option, + pub profile_fields: Option, pub local: bool, pub created_at: DateTime, pub updated_at: DateTime, } +fn parse_profile_fields(v: Option) -> Vec<(String, String)> { + v.and_then(|v| v.as_array().cloned()) + .map(|arr| { + arr.into_iter() + .filter_map(|item| { + let name = item.get("name")?.as_str()?.to_string(); + let value = item.get("value")?.as_str()?.to_string(); + Some((name, value)) + }) + .collect() + }) + .unwrap_or_default() +} + impl From for User { fn from(r: UserRow) -> Self { User { @@ -48,6 +63,7 @@ impl From for User { avatar_url: r.avatar_url, header_url: r.header_url, custom_css: r.custom_css, + profile_fields: parse_profile_fields(r.profile_fields), local: r.local, created_at: r.created_at, updated_at: r.updated_at, @@ -57,7 +73,7 @@ impl From for User { pub const USER_SELECT: &str = "SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,\ - custom_css,local,created_at,updated_at FROM users"; + custom_css,profile_fields,local,created_at,updated_at FROM users"; #[async_trait] impl UserReader for PgUserRepository { @@ -222,14 +238,20 @@ impl UserReader for PgUserRepository { #[async_trait] impl UserWriter for PgUserRepository { async fn save(&self, user: &User) -> Result<(), DomainError> { + let profile_fields_json: serde_json::Value = user + .profile_fields + .iter() + .map(|(n, v)| serde_json::json!({"name": n, "value": v})) + .collect(); sqlx::query( - "INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,created_at,updated_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + "INSERT INTO users (id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,profile_fields,local,created_at,updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT(id) DO UPDATE SET username=EXCLUDED.username, email=EXCLUDED.email, password_hash=EXCLUDED.password_hash, display_name=EXCLUDED.display_name, bio=EXCLUDED.bio, avatar_url=EXCLUDED.avatar_url, header_url=EXCLUDED.header_url, custom_css=EXCLUDED.custom_css, + profile_fields=EXCLUDED.profile_fields, local=EXCLUDED.local, updated_at=NOW()" ) @@ -242,6 +264,7 @@ impl UserWriter for PgUserRepository { .bind(&user.avatar_url) .bind(&user.header_url) .bind(&user.custom_css) + .bind(&profile_fields_json) .bind(user.local) .bind(user.created_at) .bind(user.updated_at) @@ -267,14 +290,22 @@ impl UserWriter for PgUserRepository { user_id: &UserId, input: UpdateProfileInput, ) -> Result<(), DomainError> { + let profile_fields_json: Option = + input.profile_fields.as_ref().map(|fields| { + fields + .iter() + .map(|(n, v)| serde_json::json!({"name": n, "value": v})) + .collect() + }); sqlx::query( "UPDATE users SET \ - display_name = COALESCE($2, display_name), \ - bio = COALESCE($3, bio), \ - avatar_url = COALESCE($4, avatar_url), \ - header_url = COALESCE($5, header_url), \ - custom_css = COALESCE($6, custom_css), \ - updated_at = NOW() \ + display_name = COALESCE($2, display_name), \ + bio = COALESCE($3, bio), \ + avatar_url = COALESCE($4, avatar_url), \ + header_url = COALESCE($5, header_url), \ + custom_css = COALESCE($6, custom_css), \ + profile_fields = COALESCE($7, profile_fields), \ + updated_at = NOW() \ WHERE id = $1", ) .bind(user_id.as_uuid()) @@ -283,6 +314,7 @@ impl UserWriter for PgUserRepository { .bind(input.avatar_url) .bind(input.header_url) .bind(input.custom_css) + .bind(profile_fields_json) .execute(&self.pool) .await .into_domain() diff --git a/crates/api-types/src/requests.rs b/crates/api-types/src/requests.rs index c24f231..895e6d7 100644 --- a/crates/api-types/src/requests.rs +++ b/crates/api-types/src/requests.rs @@ -47,6 +47,7 @@ pub struct UpdateProfileRequest { pub avatar_url: Option, pub header_url: Option, pub custom_css: Option, + pub profile_fields: Option>, } #[derive(Deserialize, utoipa::ToSchema)] diff --git a/crates/api-types/src/responses.rs b/crates/api-types/src/responses.rs index db64a95..fb81fdf 100644 --- a/crates/api-types/src/responses.rs +++ b/crates/api-types/src/responses.rs @@ -1,5 +1,5 @@ use chrono::{DateTime, Utc}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use uuid::Uuid; #[derive(Serialize, utoipa::ToSchema)] @@ -19,6 +19,7 @@ pub struct UserResponse { pub avatar_url: Option, pub header_url: Option, pub custom_css: Option, + pub profile_fields: Vec, pub local: bool, pub is_followed_by_viewer: bool, #[serde(rename = "joinedAt")] @@ -105,7 +106,7 @@ pub struct CreatedApiKeyResponse { pub key: String, } -#[derive(Serialize, Clone, utoipa::ToSchema)] +#[derive(Serialize, Deserialize, Clone, utoipa::ToSchema)] #[serde(rename_all = "camelCase")] pub struct ProfileField { pub name: String, diff --git a/crates/application/src/testing.rs b/crates/application/src/testing.rs index 83a42f4..5e21a78 100644 --- a/crates/application/src/testing.rs +++ b/crates/application/src/testing.rs @@ -73,6 +73,7 @@ impl ActivityPubRepository for TestApRepo { avatar_url: None, header_url: None, custom_css: None, + profile_fields: vec![], local: false, created_at: chrono::Utc::now(), updated_at: chrono::Utc::now(), diff --git a/crates/domain/src/models/user.rs b/crates/domain/src/models/user.rs index f303aba..bcf5f51 100644 --- a/crates/domain/src/models/user.rs +++ b/crates/domain/src/models/user.rs @@ -8,6 +8,7 @@ pub struct UpdateProfileInput { pub avatar_url: Option, pub header_url: Option, pub custom_css: Option, + pub profile_fields: Option>, } #[derive(Debug, Clone)] @@ -21,6 +22,7 @@ pub struct User { pub avatar_url: Option, pub header_url: Option, pub custom_css: Option, + pub profile_fields: Vec<(String, String)>, pub local: bool, pub created_at: DateTime, pub updated_at: DateTime, @@ -44,6 +46,7 @@ impl User { avatar_url: None, header_url: None, custom_css: None, + profile_fields: vec![], local: true, created_at: now, updated_at: now, diff --git a/crates/presentation/src/handlers/auth.rs b/crates/presentation/src/handlers/auth.rs index f61ee74..9500ff5 100644 --- a/crates/presentation/src/handlers/auth.rs +++ b/crates/presentation/src/handlers/auth.rs @@ -1,7 +1,7 @@ use crate::{deps_struct, errors::ApiError, extractors::Deps}; use api_types::{ requests::{LoginRequest, RegisterRequest}, - responses::{AuthResponse, ErrorResponse, UserResponse}, + responses::{AuthResponse, ErrorResponse, ProfileField, UserResponse}, }; use application::use_cases::auth::{login, register, LoginInput, RegisterInput}; use axum::{http::StatusCode, response::IntoResponse, Json}; @@ -23,6 +23,14 @@ pub fn to_user_response(u: &domain::models::user::User) -> UserResponse { avatar_url: u.avatar_url.clone(), header_url: u.header_url.clone(), custom_css: u.custom_css.clone(), + profile_fields: u + .profile_fields + .iter() + .map(|(n, v)| ProfileField { + name: n.clone(), + value: v.clone(), + }) + .collect(), local: u.local, is_followed_by_viewer: false, created_at: u.created_at, diff --git a/crates/presentation/src/handlers/users/mod.rs b/crates/presentation/src/handlers/users/mod.rs index 75e575a..ee4d37b 100644 --- a/crates/presentation/src/handlers/users/mod.rs +++ b/crates/presentation/src/handlers/users/mod.rs @@ -114,6 +114,9 @@ pub async fn patch_profile( avatar_url: body.avatar_url, header_url: body.header_url, custom_css: body.custom_css, + profile_fields: body + .profile_fields + .map(|f| f.into_iter().map(|pf| (pf.name, pf.value)).collect()), }, ) .await?; @@ -208,6 +211,7 @@ pub async fn get_users( avatar_url: u.avatar_url.clone(), header_url: None, custom_css: None, + profile_fields: vec![], local: true, is_followed_by_viewer: false, created_at: chrono::Utc::now(), diff --git a/thoughts-frontend/app/settings/federation/page.tsx b/thoughts-frontend/app/settings/federation/page.tsx index 9b0cbdd..1802e50 100644 --- a/thoughts-frontend/app/settings/federation/page.tsx +++ b/thoughts-frontend/app/settings/federation/page.tsx @@ -1,7 +1,9 @@ import { cookies } from "next/headers"; import { redirect } from "next/navigation"; +import { getMe } from "@/lib/api"; import { FederationPanel } from "@/components/federation/federation-panel"; import { MigrationSettings } from "@/components/federation/migration-settings"; +import { ProfileFieldsEditor } from "@/components/profile-fields-editor"; export default async function FederationSettingsPage() { const token = (await cookies()).get("auth_token")?.value; @@ -9,6 +11,8 @@ export default async function FederationSettingsPage() { redirect("/login"); } + const me = await getMe(token); + return (
@@ -18,6 +22,7 @@ export default async function FederationSettingsPage() { other instances.

+
diff --git a/thoughts-frontend/app/users/[username]/page.tsx b/thoughts-frontend/app/users/[username]/page.tsx index fae875b..e698943 100644 --- a/thoughts-frontend/app/users/[username]/page.tsx +++ b/thoughts-frontend/app/users/[username]/page.tsx @@ -224,6 +224,27 @@ export default async function ProfilePage({ params }: ProfilePageProps) { {user.bio}

+ {user.profileFields.length > 0 && ( +
+ {user.profileFields.map((field) => ( +
+ + {field.name} + + + {field.value} + +
+ ))} +
+ )} + {isOwnProfile && (
(initial); + const [saving, setSaving] = useState(false); + + const update = (i: number, key: "name" | "value", val: string) => { + setFields((prev) => prev.map((f, j) => (j === i ? { ...f, [key]: val } : f))); + }; + + const add = () => { + if (fields.length >= MAX_FIELDS) return; + setFields((prev) => [...prev, { name: "", value: "" }]); + }; + + const remove = (i: number) => { + setFields((prev) => prev.filter((_, j) => j !== i)); + }; + + const save = async () => { + if (!token) return; + const clean = fields.filter((f) => f.name.trim() || f.value.trim()); + setSaving(true); + try { + await updateProfile({ profileFields: clean }, token); + setFields(clean); + toast.success("Profile fields saved."); + } catch { + toast.error("Failed to save profile fields."); + } finally { + setSaving(false); + } + }; + + return ( +
+
+

Profile fields

+

+ Add up to {MAX_FIELDS} custom fields visible on your profile and + across the fediverse (e.g. Website, Pronouns). +

+
+ +
+ {fields.map((f, i) => ( +
+ update(i, "name", e.target.value)} + placeholder="Label" + className="max-w-[8rem] text-sm" + /> + update(i, "value", e.target.value)} + placeholder="Value" + className="text-sm" + /> + +
+ ))} +
+ +
+ {fields.length < MAX_FIELDS && ( + + )} + +
+
+ ); +} diff --git a/thoughts-frontend/lib/api.ts b/thoughts-frontend/lib/api.ts index ad0612e..e61e9bd 100644 --- a/thoughts-frontend/lib/api.ts +++ b/thoughts-frontend/lib/api.ts @@ -1,6 +1,12 @@ import { cache } from "react"; import { z } from "zod"; +export const ProfileFieldSchema = z.object({ + name: z.string(), + value: z.string(), +}); +export type ProfileField = z.infer; + export const UserSchema = z.object({ id: z.string().uuid(), username: z.string(), @@ -9,6 +15,7 @@ export const UserSchema = z.object({ avatarUrl: z.string().nullable(), headerUrl: z.string().nullable(), customCss: z.string().nullable(), + profileFields: z.array(ProfileFieldSchema).default([]), local: z.boolean(), isFollowedByViewer: z.boolean(), joinedAt: z.coerce.date().nullable(), @@ -16,12 +23,6 @@ export const UserSchema = z.object({ export const MeSchema = UserSchema; -export const ProfileFieldSchema = z.object({ - name: z.string(), - value: z.string(), -}); -export type ProfileField = z.infer; - export const RemoteActorSchema = z.object({ handle: z.string(), displayName: z.string().nullable(), @@ -77,6 +78,7 @@ export const UpdateProfileSchema = z.object({ displayName: z.string().max(50).optional(), bio: z.string().max(4000).optional(), customCss: z.string().optional(), + profileFields: z.array(ProfileFieldSchema).max(4).optional(), }); export const SearchResultsSchema = z.object({