this repo has no description

Actor preferences

lewis 1801d787 cb742bf0

+28
.sqlx/query-12351a50c151a1a4b0b74dcd2604427aed5e8e3ccc067f253c9e342a9b505941.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT name, value_json FROM account_preferences WHERE user_id = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "name", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "value_json", 14 + "type_info": "Jsonb" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Uuid" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + false 25 + ] 26 + }, 27 + "hash": "12351a50c151a1a4b0b74dcd2604427aed5e8e3ccc067f253c9e342a9b505941" 28 + }
-28
.sqlx/query-40bd5b538224352dd8912e2c13a71b920ee3874f4acc12ebf4e6f62aae86c556.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT id, handle FROM users WHERE LOWER(email) = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "handle", 14 - "type_info": "Text" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Text" 20 - ] 21 - }, 22 - "nullable": [ 23 - false, 24 - false 25 - ] 26 - }, 27 - "hash": "40bd5b538224352dd8912e2c13a71b920ee3874f4acc12ebf4e6f62aae86c556" 28 - }
+16
.sqlx/query-48b80b34ff2ad6e43ed7596d4c609e8b3ec4e546c711e57d47097f617471c60d.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM account_preferences WHERE user_id = $1 AND (name = $2 OR name LIKE $3)", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Text", 10 + "Text" 11 + ] 12 + }, 13 + "nullable": [] 14 + }, 15 + "hash": "48b80b34ff2ad6e43ed7596d4c609e8b3ec4e546c711e57d47097f617471c60d" 16 + }
+16
.sqlx/query-80d4e5415cd065ee137cdcdaa69a6d7329352ca48cfb9b216a9598e3f1a5dbeb.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3)", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Text", 10 + "Jsonb" 11 + ] 12 + }, 13 + "nullable": [] 14 + }, 15 + "hash": "80d4e5415cd065ee137cdcdaa69a6d7329352ca48cfb9b216a9598e3f1a5dbeb" 16 + }
+22
.sqlx/query-94e290ff1acc15ccb8fd57fce25c7a4eea1e45c7339145d5af2741cc04348c8f.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT record_cid FROM records WHERE repo_id = $1 AND collection = 'app.bsky.actor.profile' AND rkey = 'self'", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "record_cid", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Uuid" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "94e290ff1acc15ccb8fd57fce25c7a4eea1e45c7339145d5af2741cc04348c8f" 22 + }
+46
.sqlx/query-bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT\n email,\n handle,\n preferred_notification_channel as \"channel: NotificationChannel\"\n FROM users\n WHERE id = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "email", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "handle", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "channel: NotificationChannel", 19 + "type_info": { 20 + "Custom": { 21 + "name": "notification_channel", 22 + "kind": { 23 + "Enum": [ 24 + "email", 25 + "discord", 26 + "telegram", 27 + "signal" 28 + ] 29 + } 30 + } 31 + } 32 + } 33 + ], 34 + "parameters": { 35 + "Left": [ 36 + "Uuid" 37 + ] 38 + }, 39 + "nullable": [ 40 + false, 41 + false, 42 + false 43 + ] 44 + }, 45 + "hash": "bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508" 46 + }
+22
.sqlx/query-d2a6047b9f8039025b19028b8db7935ea60bfff1698488cbaacc8785c85c94b4.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id FROM users WHERE LOWER(email) = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "d2a6047b9f8039025b19028b8db7935ea60bfff1698488cbaacc8785c85c94b4" 22 + }
+3
Cargo.toml
··· 46 46 urlencoding = "2.1" 47 47 uuid = { version = "1.19.0", features = ["v4", "fast-rng"] } 48 48 49 + [features] 50 + external-infra = [] 51 + 49 52 [dev-dependencies] 50 53 ctor = "0.6.3" 51 54 iroh-car = "0.5.1"
+54 -7
TODO.md
··· 162 162 These endpoints need to be implemented at the PDS level (not just proxied to appview). 163 163 164 164 ### Actor (`app.bsky.actor`) 165 - - [ ] Implement `app.bsky.actor.getPreferences` (user preferences storage). 166 - - [ ] Implement `app.bsky.actor.putPreferences` (update user preferences). 167 - - [ ] Implement `app.bsky.actor.getProfile` (PDS-level with proxy fallback). 168 - - [ ] Implement `app.bsky.actor.getProfiles` (PDS-level with proxy fallback). 165 + - [x] Implement `app.bsky.actor.getPreferences` (user preferences storage). 166 + - [x] Implement `app.bsky.actor.putPreferences` (update user preferences). 167 + - [x] Implement `app.bsky.actor.getProfile` (PDS-level with proxy fallback). 168 + - [x] Implement `app.bsky.actor.getProfiles` (PDS-level with proxy fallback). 169 169 170 170 ### Feed (`app.bsky.feed`) 171 171 These are implemented at PDS level to enable local-first reads: ··· 190 190 191 191 ## Preference Storage 192 192 User preferences (for app.bsky.actor.getPreferences/putPreferences): 193 - - [ ] Create preferences table for storing user app preferences. 194 - - [ ] Implement `app.bsky.actor.getPreferences` handler (read from postgres, proxy fallback). 195 - - [ ] Implement `app.bsky.actor.putPreferences` handler (write to postgres). 193 + - [x] Create preferences table for storing user app preferences. 194 + - [x] Implement `app.bsky.actor.getPreferences` handler (read from postgres, proxy fallback). 195 + - [x] Implement `app.bsky.actor.putPreferences` handler (write to postgres). 196 196 197 197 ## Infrastructure & Core Components 198 198 - [x] Sequencer (Event Log) ··· 221 221 - [ ] Telegram bot sender 222 222 - [ ] Signal bot sender 223 223 - [x] Helper functions for common notification types (welcome, password reset, email verification, etc.) 224 + - [x] Respect user's `preferred_notification_channel` setting for non-email-specific notifications 224 225 - [ ] Image Processing 225 226 - [ ] Implement image resize/formatting pipeline (for blob uploads). 226 227 - [x] IPLD & MST ··· 230 231 - [ ] DID PLC Operations (Sign rotation keys). 231 232 - [ ] Fix any remaining TODOs in the code, everywhere, full stop. 232 233 234 + ## Web Management UI 235 + A single-page web app for account management. The frontend (JS framework) calls existing ATProto XRPC endpoints - no server-side rendering or bespoke HTML form handlers. 236 + 237 + ### Architecture 238 + - [ ] Static SPA served from PDS (or separate static host) 239 + - [ ] Frontend authenticates via OAuth 2.1 flow (same as any ATProto client) 240 + - [ ] All operations use standard XRPC endpoints (existing + new PDS-specific ones below) 241 + - [ ] No server-side sessions or CSRF - pure API client 242 + 243 + ### PDS-Specific XRPC Endpoints (new) 244 + Absolutely subject to change, "bspds" isn't even the real name of this pds thus far :D 245 + Anyway... endpoints for PDS settings not covered by standard ATProto: 246 + - [ ] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels 247 + - [ ] `com.bspds.account.updateNotificationPrefs` - set preferred channel 248 + - [ ] `com.bspds.account.getNotificationHistory` - list past notifications 249 + - [ ] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal 250 + - [ ] `com.bspds.account.confirmChannelVerification` - confirm with code 251 + - [ ] `com.bspds.admin.getServerStats` - user count, storage usage, etc. 252 + 253 + ### Frontend Views 254 + Uses existing ATProto endpoints where possible: 255 + 256 + **User Dashboard** 257 + - [ ] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`) 258 + - [ ] Active sessions view (needs new endpoint or extend existing) 259 + - [ ] App passwords (uses `com.atproto.server.listAppPasswords`, `createAppPassword`, `revokeAppPassword`) 260 + - [ ] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`) 261 + 262 + **Notification Preferences** 263 + - [ ] Channel selector (uses `com.bspds.account.*` endpoints above) 264 + - [ ] Verification flows for Discord/Telegram/Signal 265 + - [ ] Notification history view 266 + 267 + **Account Settings** 268 + - [ ] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`) 269 + - [ ] Password change (uses `com.atproto.server.requestPasswordReset`, `resetPassword`) 270 + - [ ] Handle change (uses `com.atproto.identity.updateHandle`) 271 + - [ ] Account deletion (uses `com.atproto.server.requestAccountDelete`, `deleteAccount`) 272 + - [ ] Data export (uses `com.atproto.sync.getRepo`) 273 + 274 + **Admin Dashboard** (privileged users only) 275 + - [ ] User list (uses `com.atproto.admin.getAccountInfos` with pagination) 276 + - [ ] User detail/actions (uses `com.atproto.admin.*` endpoints) 277 + - [ ] Invite management (uses `com.atproto.admin.getInviteCodes`, `disableInviteCodes`) 278 + - [ ] Server stats (uses `com.bspds.admin.getServerStats`) 279 +
+12
migrations/202512211500_account_preferences.sql
··· 1 + CREATE TABLE IF NOT EXISTS account_preferences ( 2 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 3 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 4 + name TEXT NOT NULL, 5 + value_json JSONB NOT NULL, 6 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 7 + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 8 + UNIQUE(user_id, name) 9 + ); 10 + 11 + CREATE INDEX IF NOT EXISTS idx_account_preferences_user_id ON account_preferences(user_id); 12 + CREATE INDEX IF NOT EXISTS idx_account_preferences_name ON account_preferences(name);
+5
src/api/actor/mod.rs
··· 1 + mod preferences; 2 + mod profile; 3 + 4 + pub use preferences::{get_preferences, put_preferences}; 5 + pub use profile::{get_profile, get_profiles};
+233
src/api/actor/preferences.rs
··· 1 + use crate::state::AppState; 2 + use axum::{ 3 + extract::State, 4 + http::StatusCode, 5 + response::{IntoResponse, Response}, 6 + Json, 7 + }; 8 + use serde::{Deserialize, Serialize}; 9 + use serde_json::{json, Value}; 10 + 11 + const APP_BSKY_NAMESPACE: &str = "app.bsky"; 12 + 13 + #[derive(Serialize)] 14 + pub struct GetPreferencesOutput { 15 + pub preferences: Vec<Value>, 16 + } 17 + 18 + pub async fn get_preferences( 19 + State(state): State<AppState>, 20 + headers: axum::http::HeaderMap, 21 + ) -> Response { 22 + let token = match crate::auth::extract_bearer_token_from_header( 23 + headers.get("Authorization").and_then(|h| h.to_str().ok()), 24 + ) { 25 + Some(t) => t, 26 + None => { 27 + return ( 28 + StatusCode::UNAUTHORIZED, 29 + Json(json!({"error": "AuthenticationRequired"})), 30 + ) 31 + .into_response(); 32 + } 33 + }; 34 + 35 + let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { 36 + Ok(user) => user, 37 + Err(_) => { 38 + return ( 39 + StatusCode::UNAUTHORIZED, 40 + Json(json!({"error": "AuthenticationFailed"})), 41 + ) 42 + .into_response(); 43 + } 44 + }; 45 + 46 + let user_id: uuid::Uuid = 47 + match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did) 48 + .fetch_optional(&state.db) 49 + .await 50 + { 51 + Ok(Some(id)) => id, 52 + _ => { 53 + return ( 54 + StatusCode::INTERNAL_SERVER_ERROR, 55 + Json(json!({"error": "InternalError", "message": "User not found"})), 56 + ) 57 + .into_response(); 58 + } 59 + }; 60 + 61 + let prefs_result = sqlx::query!( 62 + "SELECT name, value_json FROM account_preferences WHERE user_id = $1", 63 + user_id 64 + ) 65 + .fetch_all(&state.db) 66 + .await; 67 + 68 + let prefs = match prefs_result { 69 + Ok(rows) => rows, 70 + Err(_) => { 71 + return ( 72 + StatusCode::INTERNAL_SERVER_ERROR, 73 + Json(json!({"error": "InternalError", "message": "Failed to fetch preferences"})), 74 + ) 75 + .into_response(); 76 + } 77 + }; 78 + 79 + let preferences: Vec<Value> = prefs 80 + .into_iter() 81 + .filter(|row| { 82 + row.name == APP_BSKY_NAMESPACE || row.name.starts_with(&format!("{}.", APP_BSKY_NAMESPACE)) 83 + }) 84 + .filter_map(|row| { 85 + if row.name == "app.bsky.actor.defs#declaredAgePref" { 86 + return None; 87 + } 88 + serde_json::from_value(row.value_json).ok() 89 + }) 90 + .collect(); 91 + 92 + (StatusCode::OK, Json(GetPreferencesOutput { preferences })).into_response() 93 + } 94 + 95 + #[derive(Deserialize)] 96 + pub struct PutPreferencesInput { 97 + pub preferences: Vec<Value>, 98 + } 99 + 100 + pub async fn put_preferences( 101 + State(state): State<AppState>, 102 + headers: axum::http::HeaderMap, 103 + Json(input): Json<PutPreferencesInput>, 104 + ) -> Response { 105 + let token = match crate::auth::extract_bearer_token_from_header( 106 + headers.get("Authorization").and_then(|h| h.to_str().ok()), 107 + ) { 108 + Some(t) => t, 109 + None => { 110 + return ( 111 + StatusCode::UNAUTHORIZED, 112 + Json(json!({"error": "AuthenticationRequired"})), 113 + ) 114 + .into_response(); 115 + } 116 + }; 117 + 118 + let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { 119 + Ok(user) => user, 120 + Err(_) => { 121 + return ( 122 + StatusCode::UNAUTHORIZED, 123 + Json(json!({"error": "AuthenticationFailed"})), 124 + ) 125 + .into_response(); 126 + } 127 + }; 128 + 129 + let user_id: uuid::Uuid = 130 + match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did) 131 + .fetch_optional(&state.db) 132 + .await 133 + { 134 + Ok(Some(id)) => id, 135 + _ => { 136 + return ( 137 + StatusCode::INTERNAL_SERVER_ERROR, 138 + Json(json!({"error": "InternalError", "message": "User not found"})), 139 + ) 140 + .into_response(); 141 + } 142 + }; 143 + 144 + for pref in &input.preferences { 145 + let pref_type = match pref.get("$type").and_then(|t| t.as_str()) { 146 + Some(t) => t, 147 + None => { 148 + return ( 149 + StatusCode::BAD_REQUEST, 150 + Json(json!({"error": "InvalidRequest", "message": "Preference missing $type field"})), 151 + ) 152 + .into_response(); 153 + } 154 + }; 155 + 156 + if !pref_type.starts_with(APP_BSKY_NAMESPACE) { 157 + return ( 158 + StatusCode::BAD_REQUEST, 159 + Json(json!({"error": "InvalidRequest", "message": format!("Invalid preference namespace: {}", pref_type)})), 160 + ) 161 + .into_response(); 162 + } 163 + 164 + if pref_type == "app.bsky.actor.defs#declaredAgePref" { 165 + return ( 166 + StatusCode::BAD_REQUEST, 167 + Json(json!({"error": "InvalidRequest", "message": "declaredAgePref is read-only"})), 168 + ) 169 + .into_response(); 170 + } 171 + } 172 + 173 + let mut tx = match state.db.begin().await { 174 + Ok(tx) => tx, 175 + Err(_) => { 176 + return ( 177 + StatusCode::INTERNAL_SERVER_ERROR, 178 + Json(json!({"error": "InternalError", "message": "Failed to start transaction"})), 179 + ) 180 + .into_response(); 181 + } 182 + }; 183 + 184 + let delete_result = sqlx::query!( 185 + "DELETE FROM account_preferences WHERE user_id = $1 AND (name = $2 OR name LIKE $3)", 186 + user_id, 187 + APP_BSKY_NAMESPACE, 188 + format!("{}.%", APP_BSKY_NAMESPACE) 189 + ) 190 + .execute(&mut *tx) 191 + .await; 192 + 193 + if delete_result.is_err() { 194 + let _ = tx.rollback().await; 195 + return ( 196 + StatusCode::INTERNAL_SERVER_ERROR, 197 + Json(json!({"error": "InternalError", "message": "Failed to clear preferences"})), 198 + ) 199 + .into_response(); 200 + } 201 + 202 + for pref in input.preferences { 203 + let pref_type = pref.get("$type").and_then(|t| t.as_str()).unwrap(); 204 + 205 + let insert_result = sqlx::query!( 206 + "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3)", 207 + user_id, 208 + pref_type, 209 + pref 210 + ) 211 + .execute(&mut *tx) 212 + .await; 213 + 214 + if insert_result.is_err() { 215 + let _ = tx.rollback().await; 216 + return ( 217 + StatusCode::INTERNAL_SERVER_ERROR, 218 + Json(json!({"error": "InternalError", "message": "Failed to save preference"})), 219 + ) 220 + .into_response(); 221 + } 222 + } 223 + 224 + if let Err(_) = tx.commit().await { 225 + return ( 226 + StatusCode::INTERNAL_SERVER_ERROR, 227 + Json(json!({"error": "InternalError", "message": "Failed to commit transaction"})), 228 + ) 229 + .into_response(); 230 + } 231 + 232 + StatusCode::OK.into_response() 233 + }
+206
src/api/actor/profile.rs
··· 1 + use crate::state::AppState; 2 + use axum::{ 3 + extract::{Query, State}, 4 + http::StatusCode, 5 + response::{IntoResponse, Response}, 6 + Json, 7 + }; 8 + use jacquard_repo::storage::BlockStore; 9 + use reqwest::Client; 10 + use serde::{Deserialize, Serialize}; 11 + use serde_json::{json, Value}; 12 + use std::collections::HashMap; 13 + use tracing::{error, info}; 14 + 15 + #[derive(Deserialize)] 16 + pub struct GetProfileParams { 17 + pub actor: String, 18 + } 19 + 20 + #[derive(Deserialize)] 21 + pub struct GetProfilesParams { 22 + pub actors: String, 23 + } 24 + 25 + #[derive(Serialize, Deserialize, Clone)] 26 + #[serde(rename_all = "camelCase")] 27 + pub struct ProfileViewDetailed { 28 + pub did: String, 29 + pub handle: String, 30 + #[serde(skip_serializing_if = "Option::is_none")] 31 + pub display_name: Option<String>, 32 + #[serde(skip_serializing_if = "Option::is_none")] 33 + pub description: Option<String>, 34 + #[serde(skip_serializing_if = "Option::is_none")] 35 + pub avatar: Option<String>, 36 + #[serde(skip_serializing_if = "Option::is_none")] 37 + pub banner: Option<String>, 38 + #[serde(flatten)] 39 + pub extra: HashMap<String, Value>, 40 + } 41 + 42 + #[derive(Serialize, Deserialize)] 43 + pub struct GetProfilesOutput { 44 + pub profiles: Vec<ProfileViewDetailed>, 45 + } 46 + 47 + async fn get_local_profile_record(state: &AppState, did: &str) -> Option<Value> { 48 + let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 49 + .fetch_optional(&state.db) 50 + .await 51 + .ok()??; 52 + 53 + let record_row = sqlx::query!( 54 + "SELECT record_cid FROM records WHERE repo_id = $1 AND collection = 'app.bsky.actor.profile' AND rkey = 'self'", 55 + user_id 56 + ) 57 + .fetch_optional(&state.db) 58 + .await 59 + .ok()??; 60 + 61 + let cid: cid::Cid = record_row.record_cid.parse().ok()?; 62 + let block_bytes = state.block_store.get(&cid).await.ok()??; 63 + serde_ipld_dagcbor::from_slice(&block_bytes).ok() 64 + } 65 + 66 + fn munge_profile_with_local(profile: &mut ProfileViewDetailed, local_record: &Value) { 67 + if let Some(display_name) = local_record.get("displayName").and_then(|v| v.as_str()) { 68 + profile.display_name = Some(display_name.to_string()); 69 + } 70 + if let Some(description) = local_record.get("description").and_then(|v| v.as_str()) { 71 + profile.description = Some(description.to_string()); 72 + } 73 + } 74 + 75 + async fn proxy_to_appview( 76 + method: &str, 77 + params: &HashMap<String, String>, 78 + auth_header: Option<&str>, 79 + ) -> Result<(StatusCode, Value), Response> { 80 + let appview_url = match std::env::var("APPVIEW_URL") { 81 + Ok(url) => url, 82 + Err(_) => { 83 + return Err( 84 + (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError", "message": "No upstream AppView configured"}))).into_response() 85 + ); 86 + } 87 + }; 88 + 89 + let target_url = format!("{}/xrpc/{}", appview_url, method); 90 + info!("Proxying GET request to {}", target_url); 91 + 92 + let client = Client::new(); 93 + let mut request_builder = client.get(&target_url).query(params); 94 + 95 + if let Some(auth) = auth_header { 96 + request_builder = request_builder.header("Authorization", auth); 97 + } 98 + 99 + match request_builder.send().await { 100 + Ok(resp) => { 101 + let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); 102 + match resp.json::<Value>().await { 103 + Ok(body) => Ok((status, body)), 104 + Err(e) => { 105 + error!("Error parsing proxy response: {:?}", e); 106 + Err((StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response()) 107 + } 108 + } 109 + } 110 + Err(e) => { 111 + error!("Error sending proxy request: {:?}", e); 112 + if e.is_timeout() { 113 + Err((StatusCode::GATEWAY_TIMEOUT, Json(json!({"error": "UpstreamTimeout"}))).into_response()) 114 + } else { 115 + Err((StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response()) 116 + } 117 + } 118 + } 119 + } 120 + 121 + pub async fn get_profile( 122 + State(state): State<AppState>, 123 + headers: axum::http::HeaderMap, 124 + Query(params): Query<GetProfileParams>, 125 + ) -> Response { 126 + let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok()); 127 + 128 + let auth_did = auth_header.and_then(|h| { 129 + let token = crate::auth::extract_bearer_token_from_header(Some(h))?; 130 + crate::auth::get_did_from_token(&token).ok() 131 + }); 132 + 133 + let mut query_params = HashMap::new(); 134 + query_params.insert("actor".to_string(), params.actor.clone()); 135 + 136 + let (status, body) = match proxy_to_appview("app.bsky.actor.getProfile", &query_params, auth_header).await { 137 + Ok(r) => r, 138 + Err(e) => return e, 139 + }; 140 + 141 + if !status.is_success() { 142 + return (status, Json(body)).into_response(); 143 + } 144 + 145 + let mut profile: ProfileViewDetailed = match serde_json::from_value(body) { 146 + Ok(p) => p, 147 + Err(_) => { 148 + return (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError", "message": "Invalid profile response"}))).into_response(); 149 + } 150 + }; 151 + 152 + if let Some(ref did) = auth_did { 153 + if profile.did == *did { 154 + if let Some(local_record) = get_local_profile_record(&state, did).await { 155 + munge_profile_with_local(&mut profile, &local_record); 156 + } 157 + } 158 + } 159 + 160 + (StatusCode::OK, Json(profile)).into_response() 161 + } 162 + 163 + pub async fn get_profiles( 164 + State(state): State<AppState>, 165 + headers: axum::http::HeaderMap, 166 + Query(params): Query<GetProfilesParams>, 167 + ) -> Response { 168 + let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok()); 169 + 170 + let auth_did = auth_header.and_then(|h| { 171 + let token = crate::auth::extract_bearer_token_from_header(Some(h))?; 172 + crate::auth::get_did_from_token(&token).ok() 173 + }); 174 + 175 + let mut query_params = HashMap::new(); 176 + query_params.insert("actors".to_string(), params.actors.clone()); 177 + 178 + let (status, body) = match proxy_to_appview("app.bsky.actor.getProfiles", &query_params, auth_header).await { 179 + Ok(r) => r, 180 + Err(e) => return e, 181 + }; 182 + 183 + if !status.is_success() { 184 + return (status, Json(body)).into_response(); 185 + } 186 + 187 + let mut output: GetProfilesOutput = match serde_json::from_value(body) { 188 + Ok(p) => p, 189 + Err(_) => { 190 + return (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError", "message": "Invalid profiles response"}))).into_response(); 191 + } 192 + }; 193 + 194 + if let Some(ref did) = auth_did { 195 + for profile in &mut output.profiles { 196 + if profile.did == *did { 197 + if let Some(local_record) = get_local_profile_record(&state, did).await { 198 + munge_profile_with_local(profile, &local_record); 199 + } 200 + break; 201 + } 202 + } 203 + } 204 + 205 + (StatusCode::OK, Json(output)).into_response() 206 + }
+2 -10
src/api/identity/account.rs
··· 415 415 } 416 416 417 417 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 418 - if let Err(e) = crate::notifications::enqueue_welcome_email( 419 - &state.db, 420 - user_id, 421 - &input.email, 422 - &input.handle, 423 - &hostname, 424 - ) 425 - .await 426 - { 427 - warn!("Failed to enqueue welcome email: {:?}", e); 418 + if let Err(e) = crate::notifications::enqueue_welcome(&state.db, user_id, &hostname).await { 419 + warn!("Failed to enqueue welcome notification: {:?}", e); 428 420 } 429 421 430 422 (
+1
src/api/mod.rs
··· 1 + pub mod actor; 1 2 pub mod admin; 2 3 pub mod feed; 3 4 pub mod identity;
+4 -14
src/api/server/account_status.rs
··· 247 247 } 248 248 }; 249 249 250 - let user = match sqlx::query!("SELECT id, email, handle FROM users WHERE did = $1", did) 250 + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 251 251 .fetch_optional(&state.db) 252 252 .await 253 253 { 254 - Ok(Some(row)) => row, 254 + Ok(Some(id)) => id, 255 255 _ => { 256 256 return ( 257 257 StatusCode::INTERNAL_SERVER_ERROR, ··· 260 260 .into_response(); 261 261 } 262 262 }; 263 - let user_id = user.id; 264 - let email = user.email; 265 - let handle = user.handle; 266 263 267 264 let confirmation_token = Uuid::new_v4().to_string(); 268 265 let expires_at = Utc::now() + Duration::minutes(15); ··· 286 283 } 287 284 288 285 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 289 - if let Err(e) = crate::notifications::enqueue_account_deletion( 290 - &state.db, 291 - user_id, 292 - &email, 293 - &handle, 294 - &confirmation_token, 295 - &hostname, 296 - ) 297 - .await 286 + if let Err(e) = 287 + crate::notifications::enqueue_account_deletion(&state.db, user_id, &confirmation_token, &hostname).await 298 288 { 299 289 warn!("Failed to enqueue account deletion notification: {:?}", e); 300 290 }
+7 -17
src/api/server/password.rs
··· 38 38 .into_response(); 39 39 } 40 40 41 - let user = sqlx::query!( 42 - "SELECT id, handle FROM users WHERE LOWER(email) = $1", 43 - email 44 - ) 45 - .fetch_optional(&state.db) 46 - .await; 41 + let user = sqlx::query!("SELECT id FROM users WHERE LOWER(email) = $1", email) 42 + .fetch_optional(&state.db) 43 + .await; 47 44 48 - let (user_id, handle) = match user { 49 - Ok(Some(row)) => (row.id, row.handle), 45 + let user_id = match user { 46 + Ok(Some(row)) => row.id, 50 47 Ok(None) => { 51 48 info!("Password reset requested for unknown email: {}", email); 52 49 return (StatusCode::OK, Json(json!({}))).into_response(); ··· 83 80 } 84 81 85 82 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 86 - if let Err(e) = crate::notifications::enqueue_password_reset( 87 - &state.db, 88 - user_id, 89 - &email, 90 - &handle, 91 - &code, 92 - &hostname, 93 - ) 94 - .await 83 + if let Err(e) = 84 + crate::notifications::enqueue_password_reset(&state.db, user_id, &code, &hostname).await 95 85 { 96 86 warn!("Failed to enqueue password reset notification: {:?}", e); 97 87 }
+3
src/config.rs
··· 1 + #[allow(deprecated)] 1 2 use aes_gcm::{ 2 3 Aes256Gcm, KeyInit, Nonce, 3 4 aead::Aead, ··· 127 128 128 129 let mut nonce_bytes = [0u8; 12]; 129 130 rand::thread_rng().fill_bytes(&mut nonce_bytes); 131 + #[allow(deprecated)] 130 132 let nonce = Nonce::from_slice(&nonce_bytes); 131 133 132 134 let ciphertext = cipher ··· 148 150 let cipher = Aes256Gcm::new_from_slice(&self.key_encryption_key) 149 151 .map_err(|e| format!("Failed to create cipher: {}", e))?; 150 152 153 + #[allow(deprecated)] 151 154 let nonce = Nonce::from_slice(&encrypted[..12]); 152 155 let ciphertext = &encrypted[12..]; 153 156
+16
src/lib.rs
··· 262 262 "/xrpc/com.atproto.admin.sendEmail", 263 263 post(api::admin::send_email), 264 264 ) 265 + .route( 266 + "/xrpc/app.bsky.actor.getPreferences", 267 + get(api::actor::get_preferences), 268 + ) 269 + .route( 270 + "/xrpc/app.bsky.actor.putPreferences", 271 + post(api::actor::put_preferences), 272 + ) 273 + .route( 274 + "/xrpc/app.bsky.actor.getProfile", 275 + get(api::actor::get_profile), 276 + ) 277 + .route( 278 + "/xrpc/app.bsky.actor.getProfiles", 279 + get(api::actor::get_profiles), 280 + ) 265 281 // I know I know, I'm not supposed to implement appview endpoints. Leave me be 266 282 .route( 267 283 "/xrpc/app.bsky.feed.getTimeline",
+1 -1
src/notifications/mod.rs
··· 5 5 pub use sender::{EmailSender, NotificationSender}; 6 6 pub use service::{ 7 7 enqueue_account_deletion, enqueue_email_update, enqueue_email_verification, 8 - enqueue_notification, enqueue_password_reset, enqueue_welcome_email, NotificationService, 8 + enqueue_notification, enqueue_password_reset, enqueue_welcome, NotificationService, 9 9 }; 10 10 pub use types::{ 11 11 NewNotification, NotificationChannel, NotificationStatus, NotificationType, QueuedNotification,
+54 -20
src/notifications/service.rs
··· 254 254 .await 255 255 } 256 256 257 - pub async fn enqueue_welcome_email( 257 + pub struct UserNotificationPrefs { 258 + pub channel: NotificationChannel, 259 + pub email: String, 260 + pub handle: String, 261 + } 262 + 263 + pub async fn get_user_notification_prefs( 264 + db: &PgPool, 265 + user_id: Uuid, 266 + ) -> Result<UserNotificationPrefs, sqlx::Error> { 267 + let row = sqlx::query!( 268 + r#" 269 + SELECT 270 + email, 271 + handle, 272 + preferred_notification_channel as "channel: NotificationChannel" 273 + FROM users 274 + WHERE id = $1 275 + "#, 276 + user_id 277 + ) 278 + .fetch_one(db) 279 + .await?; 280 + 281 + Ok(UserNotificationPrefs { 282 + channel: row.channel, 283 + email: row.email, 284 + handle: row.handle, 285 + }) 286 + } 287 + 288 + pub async fn enqueue_welcome( 258 289 db: &PgPool, 259 290 user_id: Uuid, 260 - email: &str, 261 - handle: &str, 262 291 hostname: &str, 263 292 ) -> Result<Uuid, sqlx::Error> { 293 + let prefs = get_user_notification_prefs(db, user_id).await?; 294 + 264 295 let body = format!( 265 296 "Welcome to {}!\n\nYour handle is: @{}\n\nThank you for joining us.", 266 - hostname, handle 297 + hostname, prefs.handle 267 298 ); 268 299 269 300 enqueue_notification( 270 301 db, 271 - NewNotification::email( 302 + NewNotification::new( 272 303 user_id, 304 + prefs.channel, 273 305 super::types::NotificationType::Welcome, 274 - email.to_string(), 275 - format!("Welcome to {}", hostname), 306 + prefs.email.clone(), 307 + Some(format!("Welcome to {}", hostname)), 276 308 body, 277 309 ), 278 310 ) ··· 308 340 pub async fn enqueue_password_reset( 309 341 db: &PgPool, 310 342 user_id: Uuid, 311 - email: &str, 312 - handle: &str, 313 343 code: &str, 314 344 hostname: &str, 315 345 ) -> Result<Uuid, sqlx::Error> { 346 + let prefs = get_user_notification_prefs(db, user_id).await?; 347 + 316 348 let body = format!( 317 - "Hello @{},\n\nYour password reset code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this email.", 318 - handle, code 349 + "Hello @{},\n\nYour password reset code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please ignore this message.", 350 + prefs.handle, code 319 351 ); 320 352 321 353 enqueue_notification( 322 354 db, 323 - NewNotification::email( 355 + NewNotification::new( 324 356 user_id, 357 + prefs.channel, 325 358 super::types::NotificationType::PasswordReset, 326 - email.to_string(), 327 - format!("Password Reset - {}", hostname), 359 + prefs.email.clone(), 360 + Some(format!("Password Reset - {}", hostname)), 328 361 body, 329 362 ), 330 363 ) ··· 360 393 pub async fn enqueue_account_deletion( 361 394 db: &PgPool, 362 395 user_id: Uuid, 363 - email: &str, 364 - handle: &str, 365 396 code: &str, 366 397 hostname: &str, 367 398 ) -> Result<Uuid, sqlx::Error> { 399 + let prefs = get_user_notification_prefs(db, user_id).await?; 400 + 368 401 let body = format!( 369 402 "Hello @{},\n\nYour account deletion confirmation code is: {}\n\nThis code will expire in 10 minutes.\n\nIf you did not request this, please secure your account immediately.", 370 - handle, code 403 + prefs.handle, code 371 404 ); 372 405 373 406 enqueue_notification( 374 407 db, 375 - NewNotification::email( 408 + NewNotification::new( 376 409 user_id, 410 + prefs.channel, 377 411 super::types::NotificationType::AccountDeletion, 378 - email.to_string(), 379 - format!("Account Deletion Request - {}", hostname), 412 + prefs.email.clone(), 413 + Some(format!("Account Deletion Request - {}", hostname)), 380 414 body, 381 415 ), 382 416 )
+22 -4
src/notifications/types.rs
··· 63 63 } 64 64 65 65 impl NewNotification { 66 - pub fn email( 66 + pub fn new( 67 67 user_id: Uuid, 68 + channel: NotificationChannel, 68 69 notification_type: NotificationType, 69 70 recipient: String, 70 - subject: String, 71 + subject: Option<String>, 71 72 body: String, 72 73 ) -> Self { 73 74 Self { 74 75 user_id, 75 - channel: NotificationChannel::Email, 76 + channel, 76 77 notification_type, 77 78 recipient, 78 - subject: Some(subject), 79 + subject, 79 80 body, 80 81 metadata: None, 81 82 } 83 + } 84 + 85 + pub fn email( 86 + user_id: Uuid, 87 + notification_type: NotificationType, 88 + recipient: String, 89 + subject: String, 90 + body: String, 91 + ) -> Self { 92 + Self::new( 93 + user_id, 94 + NotificationChannel::Email, 95 + notification_type, 96 + recipient, 97 + Some(subject), 98 + body, 99 + ) 82 100 } 83 101 }
+375
tests/actor.rs
··· 1 + mod common; 2 + 3 + use common::{base_url, client, create_account_and_login}; 4 + use serde_json::{json, Value}; 5 + 6 + #[tokio::test] 7 + async fn test_get_preferences_empty() { 8 + let client = client(); 9 + let base = base_url().await; 10 + let (token, _did) = create_account_and_login(&client).await; 11 + 12 + let resp = client 13 + .get(format!("{}/xrpc/app.bsky.actor.getPreferences", base)) 14 + .header("Authorization", format!("Bearer {}", token)) 15 + .send() 16 + .await 17 + .unwrap(); 18 + 19 + assert_eq!(resp.status(), 200); 20 + let body: Value = resp.json().await.unwrap(); 21 + assert!(body.get("preferences").is_some()); 22 + assert!(body["preferences"].as_array().unwrap().is_empty()); 23 + } 24 + 25 + #[tokio::test] 26 + async fn test_get_preferences_no_auth() { 27 + let client = client(); 28 + let base = base_url().await; 29 + 30 + let resp = client 31 + .get(format!("{}/xrpc/app.bsky.actor.getPreferences", base)) 32 + .send() 33 + .await 34 + .unwrap(); 35 + 36 + assert_eq!(resp.status(), 401); 37 + } 38 + 39 + #[tokio::test] 40 + async fn test_put_preferences_success() { 41 + let client = client(); 42 + let base = base_url().await; 43 + let (token, _did) = create_account_and_login(&client).await; 44 + 45 + let prefs = json!({ 46 + "preferences": [ 47 + { 48 + "$type": "app.bsky.actor.defs#adultContentPref", 49 + "enabled": true 50 + }, 51 + { 52 + "$type": "app.bsky.actor.defs#contentLabelPref", 53 + "label": "nsfw", 54 + "visibility": "warn" 55 + } 56 + ] 57 + }); 58 + 59 + let resp = client 60 + .post(format!("{}/xrpc/app.bsky.actor.putPreferences", base)) 61 + .header("Authorization", format!("Bearer {}", token)) 62 + .json(&prefs) 63 + .send() 64 + .await 65 + .unwrap(); 66 + 67 + assert_eq!(resp.status(), 200); 68 + 69 + let resp = client 70 + .get(format!("{}/xrpc/app.bsky.actor.getPreferences", base)) 71 + .header("Authorization", format!("Bearer {}", token)) 72 + .send() 73 + .await 74 + .unwrap(); 75 + 76 + assert_eq!(resp.status(), 200); 77 + let body: Value = resp.json().await.unwrap(); 78 + let prefs_arr = body["preferences"].as_array().unwrap(); 79 + assert_eq!(prefs_arr.len(), 2); 80 + 81 + let adult_pref = prefs_arr.iter().find(|p| { 82 + p.get("$type").and_then(|t| t.as_str()) == Some("app.bsky.actor.defs#adultContentPref") 83 + }); 84 + assert!(adult_pref.is_some()); 85 + assert_eq!(adult_pref.unwrap()["enabled"], true); 86 + } 87 + 88 + #[tokio::test] 89 + async fn test_put_preferences_no_auth() { 90 + let client = client(); 91 + let base = base_url().await; 92 + 93 + let prefs = json!({ 94 + "preferences": [] 95 + }); 96 + 97 + let resp = client 98 + .post(format!("{}/xrpc/app.bsky.actor.putPreferences", base)) 99 + .json(&prefs) 100 + .send() 101 + .await 102 + .unwrap(); 103 + 104 + assert_eq!(resp.status(), 401); 105 + } 106 + 107 + #[tokio::test] 108 + async fn test_put_preferences_missing_type() { 109 + let client = client(); 110 + let base = base_url().await; 111 + let (token, _did) = create_account_and_login(&client).await; 112 + 113 + let prefs = json!({ 114 + "preferences": [ 115 + { 116 + "enabled": true 117 + } 118 + ] 119 + }); 120 + 121 + let resp = client 122 + .post(format!("{}/xrpc/app.bsky.actor.putPreferences", base)) 123 + .header("Authorization", format!("Bearer {}", token)) 124 + .json(&prefs) 125 + .send() 126 + .await 127 + .unwrap(); 128 + 129 + assert_eq!(resp.status(), 400); 130 + let body: Value = resp.json().await.unwrap(); 131 + assert_eq!(body["error"], "InvalidRequest"); 132 + } 133 + 134 + #[tokio::test] 135 + async fn test_put_preferences_invalid_namespace() { 136 + let client = client(); 137 + let base = base_url().await; 138 + let (token, _did) = create_account_and_login(&client).await; 139 + 140 + let prefs = json!({ 141 + "preferences": [ 142 + { 143 + "$type": "com.example.somePref", 144 + "value": "test" 145 + } 146 + ] 147 + }); 148 + 149 + let resp = client 150 + .post(format!("{}/xrpc/app.bsky.actor.putPreferences", base)) 151 + .header("Authorization", format!("Bearer {}", token)) 152 + .json(&prefs) 153 + .send() 154 + .await 155 + .unwrap(); 156 + 157 + assert_eq!(resp.status(), 400); 158 + let body: Value = resp.json().await.unwrap(); 159 + assert_eq!(body["error"], "InvalidRequest"); 160 + } 161 + 162 + #[tokio::test] 163 + async fn test_put_preferences_read_only_rejected() { 164 + let client = client(); 165 + let base = base_url().await; 166 + let (token, _did) = create_account_and_login(&client).await; 167 + 168 + let prefs = json!({ 169 + "preferences": [ 170 + { 171 + "$type": "app.bsky.actor.defs#declaredAgePref", 172 + "isOverAge18": true 173 + } 174 + ] 175 + }); 176 + 177 + let resp = client 178 + .post(format!("{}/xrpc/app.bsky.actor.putPreferences", base)) 179 + .header("Authorization", format!("Bearer {}", token)) 180 + .json(&prefs) 181 + .send() 182 + .await 183 + .unwrap(); 184 + 185 + assert_eq!(resp.status(), 400); 186 + let body: Value = resp.json().await.unwrap(); 187 + assert_eq!(body["error"], "InvalidRequest"); 188 + } 189 + 190 + #[tokio::test] 191 + async fn test_put_preferences_replaces_all() { 192 + let client = client(); 193 + let base = base_url().await; 194 + let (token, _did) = create_account_and_login(&client).await; 195 + 196 + let prefs1 = json!({ 197 + "preferences": [ 198 + { 199 + "$type": "app.bsky.actor.defs#adultContentPref", 200 + "enabled": true 201 + }, 202 + { 203 + "$type": "app.bsky.actor.defs#contentLabelPref", 204 + "label": "nsfw", 205 + "visibility": "warn" 206 + } 207 + ] 208 + }); 209 + 210 + client 211 + .post(format!("{}/xrpc/app.bsky.actor.putPreferences", base)) 212 + .header("Authorization", format!("Bearer {}", token)) 213 + .json(&prefs1) 214 + .send() 215 + .await 216 + .unwrap(); 217 + 218 + let prefs2 = json!({ 219 + "preferences": [ 220 + { 221 + "$type": "app.bsky.actor.defs#threadViewPref", 222 + "sort": "newest" 223 + } 224 + ] 225 + }); 226 + 227 + client 228 + .post(format!("{}/xrpc/app.bsky.actor.putPreferences", base)) 229 + .header("Authorization", format!("Bearer {}", token)) 230 + .json(&prefs2) 231 + .send() 232 + .await 233 + .unwrap(); 234 + 235 + let resp = client 236 + .get(format!("{}/xrpc/app.bsky.actor.getPreferences", base)) 237 + .header("Authorization", format!("Bearer {}", token)) 238 + .send() 239 + .await 240 + .unwrap(); 241 + 242 + assert_eq!(resp.status(), 200); 243 + let body: Value = resp.json().await.unwrap(); 244 + let prefs_arr = body["preferences"].as_array().unwrap(); 245 + assert_eq!(prefs_arr.len(), 1); 246 + assert_eq!(prefs_arr[0]["$type"], "app.bsky.actor.defs#threadViewPref"); 247 + } 248 + 249 + #[tokio::test] 250 + async fn test_put_preferences_saved_feeds() { 251 + let client = client(); 252 + let base = base_url().await; 253 + let (token, _did) = create_account_and_login(&client).await; 254 + 255 + let prefs = json!({ 256 + "preferences": [ 257 + { 258 + "$type": "app.bsky.actor.defs#savedFeedsPrefV2", 259 + "items": [ 260 + { 261 + "type": "feed", 262 + "value": "at://did:plc:example/app.bsky.feed.generator/my-feed", 263 + "pinned": true 264 + } 265 + ] 266 + } 267 + ] 268 + }); 269 + 270 + let resp = client 271 + .post(format!("{}/xrpc/app.bsky.actor.putPreferences", base)) 272 + .header("Authorization", format!("Bearer {}", token)) 273 + .json(&prefs) 274 + .send() 275 + .await 276 + .unwrap(); 277 + 278 + assert_eq!(resp.status(), 200); 279 + 280 + let resp = client 281 + .get(format!("{}/xrpc/app.bsky.actor.getPreferences", base)) 282 + .header("Authorization", format!("Bearer {}", token)) 283 + .send() 284 + .await 285 + .unwrap(); 286 + 287 + assert_eq!(resp.status(), 200); 288 + let body: Value = resp.json().await.unwrap(); 289 + let prefs_arr = body["preferences"].as_array().unwrap(); 290 + assert_eq!(prefs_arr.len(), 1); 291 + 292 + let saved_feeds = &prefs_arr[0]; 293 + assert_eq!(saved_feeds["$type"], "app.bsky.actor.defs#savedFeedsPrefV2"); 294 + assert!(saved_feeds["items"].as_array().unwrap().len() == 1); 295 + } 296 + 297 + #[tokio::test] 298 + async fn test_put_preferences_muted_words() { 299 + let client = client(); 300 + let base = base_url().await; 301 + let (token, _did) = create_account_and_login(&client).await; 302 + 303 + let prefs = json!({ 304 + "preferences": [ 305 + { 306 + "$type": "app.bsky.actor.defs#mutedWordsPref", 307 + "items": [ 308 + { 309 + "value": "spoiler", 310 + "targets": ["content", "tag"], 311 + "actorTarget": "all" 312 + } 313 + ] 314 + } 315 + ] 316 + }); 317 + 318 + let resp = client 319 + .post(format!("{}/xrpc/app.bsky.actor.putPreferences", base)) 320 + .header("Authorization", format!("Bearer {}", token)) 321 + .json(&prefs) 322 + .send() 323 + .await 324 + .unwrap(); 325 + 326 + assert_eq!(resp.status(), 200); 327 + 328 + let resp = client 329 + .get(format!("{}/xrpc/app.bsky.actor.getPreferences", base)) 330 + .header("Authorization", format!("Bearer {}", token)) 331 + .send() 332 + .await 333 + .unwrap(); 334 + 335 + let body: Value = resp.json().await.unwrap(); 336 + let prefs_arr = body["preferences"].as_array().unwrap(); 337 + assert_eq!(prefs_arr[0]["$type"], "app.bsky.actor.defs#mutedWordsPref"); 338 + } 339 + 340 + #[tokio::test] 341 + async fn test_preferences_isolation_between_users() { 342 + let client = client(); 343 + let base = base_url().await; 344 + 345 + let (token1, _did1) = create_account_and_login(&client).await; 346 + let (token2, _did2) = create_account_and_login(&client).await; 347 + 348 + let prefs1 = json!({ 349 + "preferences": [ 350 + { 351 + "$type": "app.bsky.actor.defs#adultContentPref", 352 + "enabled": true 353 + } 354 + ] 355 + }); 356 + 357 + client 358 + .post(format!("{}/xrpc/app.bsky.actor.putPreferences", base)) 359 + .header("Authorization", format!("Bearer {}", token1)) 360 + .json(&prefs1) 361 + .send() 362 + .await 363 + .unwrap(); 364 + 365 + let resp = client 366 + .get(format!("{}/xrpc/app.bsky.actor.getPreferences", base)) 367 + .header("Authorization", format!("Bearer {}", token2)) 368 + .send() 369 + .await 370 + .unwrap(); 371 + 372 + assert_eq!(resp.status(), 200); 373 + let body: Value = resp.json().await.unwrap(); 374 + assert!(body["preferences"].as_array().unwrap().is_empty()); 375 + }
+7 -7
tests/notifications.rs
··· 1 1 mod common; 2 2 3 3 use bspds::notifications::{ 4 - enqueue_notification, enqueue_welcome_email, NewNotification, NotificationChannel, 4 + enqueue_notification, enqueue_welcome, NewNotification, NotificationChannel, 5 5 NotificationStatus, NotificationType, 6 6 }; 7 7 use sqlx::PgPool; ··· 64 64 } 65 65 66 66 #[tokio::test] 67 - async fn test_enqueue_welcome_email() { 67 + async fn test_enqueue_welcome() { 68 68 let pool = get_pool().await; 69 69 70 70 let (_, did) = common::create_account_and_login(&common::client()).await; 71 71 72 - let user_id: uuid::Uuid = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 72 + let user_row = sqlx::query!("SELECT id, email, handle FROM users WHERE did = $1", did) 73 73 .fetch_one(&pool) 74 74 .await 75 75 .expect("User not found"); 76 76 77 - let notification_id = enqueue_welcome_email(&pool, user_id, "user@example.com", "testhandle", "example.com") 77 + let notification_id = enqueue_welcome(&pool, user_row.id, "example.com") 78 78 .await 79 - .expect("Failed to enqueue welcome email"); 79 + .expect("Failed to enqueue welcome notification"); 80 80 81 81 let row = sqlx::query!( 82 82 r#" ··· 92 92 .await 93 93 .expect("Notification not found"); 94 94 95 - assert_eq!(row.recipient, "user@example.com"); 95 + assert_eq!(row.recipient, user_row.email); 96 96 assert_eq!(row.subject.as_deref(), Some("Welcome to example.com")); 97 - assert!(row.body.contains("@testhandle")); 97 + assert!(row.body.contains(&format!("@{}", user_row.handle))); 98 98 assert_eq!(row.notification_type, NotificationType::Welcome); 99 99 } 100 100
+1 -1
tests/oauth.rs
··· 1428 1428 let mock_client = setup_mock_client_metadata(redirect_uri).await; 1429 1429 let client_id = mock_client.uri(); 1430 1430 1431 - let (code_verifier, code_challenge) = generate_pkce(); 1431 + let (_code_verifier, code_challenge) = generate_pkce(); 1432 1432 let special_state = "state=with&special=chars&plus+more"; 1433 1433 1434 1434 let par_body: Value = http_client
-1
tests/oauth_dpop.rs
··· 11 11 iat_offset_secs: i64, 12 12 ) -> String { 13 13 use p256::ecdsa::{SigningKey, Signature, signature::Signer}; 14 - use p256::elliptic_curve::sec1::ToEncodedPoint; 15 14 16 15 let signing_key = SigningKey::random(&mut rand::thread_rng()); 17 16 let verifying_key = signing_key.verifying_key();