Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
crates/model/src/entity.rs +15 −0
| @@ -65,6 +65,21 @@ pub struct User { | |||
| 65 | pub email: String, | 65 | pub email: String, |
| 66 | /// Optional human-friendly display name. | 66 | /// Optional human-friendly display name. |
| 67 | pub display_name: Option<String>, | 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 | /// Argon2id PHC hash, or `None` until an invite is completed. | 83 | /// Argon2id PHC hash, or `None` until an invite is completed. |
| 69 | pub password_hash: Option<String>, | 84 | pub password_hash: Option<String>, |
| 70 | /// Whether the user must set a new password on next login. | 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 | pub use crate::repos::NewRepo; | 46 | pub use crate::repos::NewRepo; |
| 47 | pub use crate::sessions::NewSession; | 47 | pub use crate::sessions::NewSession; |
| 48 | pub use crate::tokens::NewToken; | 48 | pub use crate::tokens::NewToken; |
| 49 | pub use crate::users::NewUser; | 49 | pub use crate::users::{NewUser, ProfileUpdate}; |
| 50 | 50 | ||
| 51 | /// Migrations for the SQLite dialect, embedded at build time. | 51 | /// Migrations for the SQLite dialect, embedded at build time. |
| 52 | static SQLITE_MIGRATOR: Migrator = sqlx::migrate!("../../migrations/sqlite"); | 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 | /// The columns of `users` in a fixed order, shared by every `SELECT` so the | 13 | /// The columns of `users` in a fixed order, shared by every `SELECT` so the |
| 14 | /// row-mapping in [`Store::map_user`] can rely on names. | 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 | must_change_password, is_admin, disabled_at, created_at, updated_at"; | 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 | /// Fields needed to create a user. The server fills in the id, timestamps, and | 39 | /// Fields needed to create a user. The server fills in the id, timestamps, and |
| 19 | /// the normalized `*_lower` uniqueness keys. | 40 | /// the normalized `*_lower` uniqueness keys. |
| 20 | #[derive(Debug, Clone)] | 41 | #[derive(Debug, Clone)] |
| @@ -52,6 +73,13 @@ impl Store { | |||
| 52 | username: new.username, | 73 | username: new.username, |
| 53 | email: new.email, | 74 | email: new.email, |
| 54 | display_name: new.display_name, | 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 | password_hash: new.password_hash, | 83 | password_hash: new.password_hash, |
| 56 | must_change_password: new.must_change_password, | 84 | must_change_password: new.must_change_password, |
| 57 | is_admin: new.is_admin, | 85 | is_admin: new.is_admin, |
| @@ -220,6 +248,60 @@ impl Store { | |||
| 220 | Ok(deleted > 0) | 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 | /// Map a `users` row (selected via [`USER_COLUMNS`]) to a [`User`]. | 305 | /// Map a `users` row (selected via [`USER_COLUMNS`]) to a [`User`]. |
| 224 | pub(crate) fn map_user(&self, row: &AnyRow) -> Result<User, sqlx::Error> { | 306 | pub(crate) fn map_user(&self, row: &AnyRow) -> Result<User, sqlx::Error> { |
| 225 | Ok(User { | 307 | Ok(User { |
| @@ -227,6 +309,13 @@ impl Store { | |||
| 227 | username: row.try_get("username")?, | 309 | username: row.try_get("username")?, |
| 228 | email: row.try_get("email")?, | 310 | email: row.try_get("email")?, |
| 229 | display_name: row.try_get("display_name")?, | 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 | password_hash: row.try_get("password_hash")?, | 319 | password_hash: row.try_get("password_hash")?, |
| 231 | must_change_password: self.get_bool(row, "must_change_password")?, | 320 | must_change_password: self.get_bool(row, "must_change_password")?, |
| 232 | is_admin: self.get_bool(row, "is_admin")?, | 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; | ||