Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
crates/model/src/entity.rs +15 −0
| @@ -65,6 +65,21 @@ pub struct User { | |||
| 65 | 65 | pub email: String, | |
| 66 | 66 | /// Optional human-friendly display name. | |
| 67 | 67 | pub display_name: Option<String>, | |
| 68 | + | /// Optional pronouns, shown on the profile (e.g. `they/them`). | |
| 69 | + | pub pronouns: Option<String>, | |
| 70 | + | /// Optional free-text bio shown on the profile. | |
| 71 | + | pub bio: Option<String>, | |
| 72 | + | /// Optional personal website URL. | |
| 73 | + | pub website: Option<String>, | |
| 74 | + | /// Optional location string. | |
| 75 | + | pub location: Option<String>, | |
| 76 | + | /// Optional `GitHub` handle (username only, no `@`). | |
| 77 | + | pub social_github: Option<String>, | |
| 78 | + | /// Optional Mastodon handle (e.g. `@user@instance`). | |
| 79 | + | pub social_mastodon: Option<String>, | |
| 80 | + | /// Content type of the uploaded avatar, or `None` to use a generated | |
| 81 | + | /// identicon. The bytes live on disk, not in this row. | |
| 82 | + | pub avatar_mime: Option<String>, | |
| 68 | 83 | /// Argon2id PHC hash, or `None` until an invite is completed. | |
| 69 | 84 | pub password_hash: Option<String>, | |
| 70 | 85 | /// Whether the user must set a new password on next login. | |
crates/store/src/lib.rs +1 −1
| @@ -46,7 +46,7 @@ pub use crate::keys::NewKey; | |||
| 46 | 46 | pub use crate::repos::NewRepo; | |
| 47 | 47 | pub use crate::sessions::NewSession; | |
| 48 | 48 | pub use crate::tokens::NewToken; | |
| 49 | - | pub use crate::users::NewUser; | |
| 49 | + | pub use crate::users::{NewUser, ProfileUpdate}; | |
| 50 | 50 | ||
| 51 | 51 | /// Migrations for the SQLite dialect, embedded at build time. | |
| 52 | 52 | static SQLITE_MIGRATOR: Migrator = sqlx::migrate!("../../migrations/sqlite"); | |
crates/store/src/users.rs +90 −1
| @@ -12,9 +12,30 @@ use crate::{Store, StoreError, map_conflict, new_id, now_ms}; | |||
| 12 | 12 | ||
| 13 | 13 | /// The columns of `users` in a fixed order, shared by every `SELECT` so the | |
| 14 | 14 | /// row-mapping in [`Store::map_user`] can rely on names. | |
| 15 | - | pub(crate) const USER_COLUMNS: &str = "id, username, email, display_name, password_hash, \ | |
| 15 | + | pub(crate) const USER_COLUMNS: &str = "id, username, email, display_name, pronouns, bio, \ | |
| 16 | + | website, location, social_github, social_mastodon, avatar_mime, password_hash, \ | |
| 16 | 17 | must_change_password, is_admin, disabled_at, created_at, updated_at"; | |
| 17 | 18 | ||
| 19 | + | /// The editable profile fields, set together by the settings page. Each `None` | |
| 20 | + | /// clears the column. | |
| 21 | + | #[derive(Debug, Clone, Default)] | |
| 22 | + | pub struct ProfileUpdate { | |
| 23 | + | /// Display name. | |
| 24 | + | pub display_name: Option<String>, | |
| 25 | + | /// Pronouns. | |
| 26 | + | pub pronouns: Option<String>, | |
| 27 | + | /// Free-text bio. | |
| 28 | + | pub bio: Option<String>, | |
| 29 | + | /// Website URL. | |
| 30 | + | pub website: Option<String>, | |
| 31 | + | /// Location. | |
| 32 | + | pub location: Option<String>, | |
| 33 | + | /// `GitHub` handle. | |
| 34 | + | pub social_github: Option<String>, | |
| 35 | + | /// Mastodon handle. | |
| 36 | + | pub social_mastodon: Option<String>, | |
| 37 | + | } | |
| 38 | + | ||
| 18 | 39 | /// Fields needed to create a user. The server fills in the id, timestamps, and | |
| 19 | 40 | /// the normalized `*_lower` uniqueness keys. | |
| 20 | 41 | #[derive(Debug, Clone)] | |
| @@ -52,6 +73,13 @@ impl Store { | |||
| 52 | 73 | username: new.username, | |
| 53 | 74 | email: new.email, | |
| 54 | 75 | display_name: new.display_name, | |
| 76 | + | pronouns: None, | |
| 77 | + | bio: None, | |
| 78 | + | website: None, | |
| 79 | + | location: None, | |
| 80 | + | social_github: None, | |
| 81 | + | social_mastodon: None, | |
| 82 | + | avatar_mime: None, | |
| 55 | 83 | password_hash: new.password_hash, | |
| 56 | 84 | must_change_password: new.must_change_password, | |
| 57 | 85 | is_admin: new.is_admin, | |
| @@ -220,6 +248,60 @@ impl Store { | |||
| 220 | 248 | Ok(deleted > 0) | |
| 221 | 249 | } | |
| 222 | 250 | ||
| 251 | + | /// Overwrite a user's profile fields (each `None` clears the column). | |
| 252 | + | /// | |
| 253 | + | /// Returns `true` if a user with `user_id` existed and was updated. | |
| 254 | + | /// | |
| 255 | + | /// # Errors | |
| 256 | + | /// | |
| 257 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 258 | + | pub async fn update_profile( | |
| 259 | + | &self, | |
| 260 | + | user_id: &str, | |
| 261 | + | profile: ProfileUpdate, | |
| 262 | + | ) -> Result<bool, StoreError> { | |
| 263 | + | let sql = "UPDATE users SET display_name = $1, pronouns = $2, bio = $3, website = $4, \ | |
| 264 | + | location = $5, social_github = $6, social_mastodon = $7, updated_at = $8 WHERE id = $9"; | |
| 265 | + | let updated = sqlx::query(sql) | |
| 266 | + | .bind(profile.display_name) | |
| 267 | + | .bind(profile.pronouns) | |
| 268 | + | .bind(profile.bio) | |
| 269 | + | .bind(profile.website) | |
| 270 | + | .bind(profile.location) | |
| 271 | + | .bind(profile.social_github) | |
| 272 | + | .bind(profile.social_mastodon) | |
| 273 | + | .bind(now_ms()) | |
| 274 | + | .bind(user_id) | |
| 275 | + | .execute(&self.pool) | |
| 276 | + | .await? | |
| 277 | + | .rows_affected(); | |
| 278 | + | Ok(updated > 0) | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | /// Set (or clear) the content type of a user's uploaded avatar. `None` | |
| 282 | + | /// returns the user to the generated-identicon fallback. | |
| 283 | + | /// | |
| 284 | + | /// Returns `true` if a user with `user_id` existed and was updated. | |
| 285 | + | /// | |
| 286 | + | /// # Errors | |
| 287 | + | /// | |
| 288 | + | /// Returns [`StoreError::Query`] on a database failure. | |
| 289 | + | pub async fn set_avatar_mime( | |
| 290 | + | &self, | |
| 291 | + | user_id: &str, | |
| 292 | + | mime: Option<&str>, | |
| 293 | + | ) -> Result<bool, StoreError> { | |
| 294 | + | let updated = | |
| 295 | + | sqlx::query("UPDATE users SET avatar_mime = $1, updated_at = $2 WHERE id = $3") | |
| 296 | + | .bind(mime) | |
| 297 | + | .bind(now_ms()) | |
| 298 | + | .bind(user_id) | |
| 299 | + | .execute(&self.pool) | |
| 300 | + | .await? | |
| 301 | + | .rows_affected(); | |
| 302 | + | Ok(updated > 0) | |
| 303 | + | } | |
| 304 | + | ||
| 223 | 305 | /// Map a `users` row (selected via [`USER_COLUMNS`]) to a [`User`]. | |
| 224 | 306 | pub(crate) fn map_user(&self, row: &AnyRow) -> Result<User, sqlx::Error> { | |
| 225 | 307 | Ok(User { | |
| @@ -227,6 +309,13 @@ impl Store { | |||
| 227 | 309 | username: row.try_get("username")?, | |
| 228 | 310 | email: row.try_get("email")?, | |
| 229 | 311 | display_name: row.try_get("display_name")?, | |
| 312 | + | pronouns: row.try_get("pronouns")?, | |
| 313 | + | bio: row.try_get("bio")?, | |
| 314 | + | website: row.try_get("website")?, | |
| 315 | + | location: row.try_get("location")?, | |
| 316 | + | social_github: row.try_get("social_github")?, | |
| 317 | + | social_mastodon: row.try_get("social_mastodon")?, | |
| 318 | + | avatar_mime: row.try_get("avatar_mime")?, | |
| 230 | 319 | password_hash: row.try_get("password_hash")?, | |
| 231 | 320 | must_change_password: self.get_bool(row, "must_change_password")?, | |
| 232 | 321 | is_admin: self.get_bool(row, "is_admin")?, | |
migrations/postgres/0002_user_profile.sql +12 −0
| @@ -0,0 +1,12 @@ | |||
| 1 | + | -- Profile fields shown on the user page: pronouns, a bio, a website and | |
| 2 | + | -- location, and named social handles. `avatar_mime` records an uploaded | |
| 3 | + | -- avatar's content type; NULL means fall back to a generated identicon. The | |
| 4 | + | -- avatar bytes live on disk under `{storage.data_dir}/avatars/{id}`, keeping | |
| 5 | + | -- the schema portable (no BLOB/BYTEA divergence). | |
| 6 | + | ALTER TABLE users ADD COLUMN pronouns TEXT; | |
| 7 | + | ALTER TABLE users ADD COLUMN bio TEXT; | |
| 8 | + | ALTER TABLE users ADD COLUMN website TEXT; | |
| 9 | + | ALTER TABLE users ADD COLUMN location TEXT; | |
| 10 | + | ALTER TABLE users ADD COLUMN social_github TEXT; | |
| 11 | + | ALTER TABLE users ADD COLUMN social_mastodon TEXT; | |
| 12 | + | ALTER TABLE users ADD COLUMN avatar_mime TEXT; | |
migrations/sqlite/0002_user_profile.sql +12 −0
| @@ -0,0 +1,12 @@ | |||
| 1 | + | -- Profile fields shown on the user page: pronouns, a bio, a website and | |
| 2 | + | -- location, and named social handles. `avatar_mime` records an uploaded | |
| 3 | + | -- avatar's content type; NULL means fall back to a generated identicon. The | |
| 4 | + | -- avatar bytes live on disk under `{storage.data_dir}/avatars/{id}`, keeping | |
| 5 | + | -- the schema portable (no BLOB/BYTEA divergence). | |
| 6 | + | ALTER TABLE users ADD COLUMN pronouns TEXT; | |
| 7 | + | ALTER TABLE users ADD COLUMN bio TEXT; | |
| 8 | + | ALTER TABLE users ADD COLUMN website TEXT; | |
| 9 | + | ALTER TABLE users ADD COLUMN location TEXT; | |
| 10 | + | ALTER TABLE users ADD COLUMN social_github TEXT; | |
| 11 | + | ALTER TABLE users ADD COLUMN social_mastodon TEXT; | |
| 12 | + | ALTER TABLE users ADD COLUMN avatar_mime TEXT; | |