+28
.sqlx/query-12351a50c151a1a4b0b74dcd2604427aed5e8e3ccc067f253c9e342a9b505941.json
+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
-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
+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
+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
+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
+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
+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
+3
Cargo.toml
+54
-7
TODO.md
+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
+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
+5
src/api/actor/mod.rs
+233
src/api/actor/preferences.rs
+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
+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
+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
(
+4
-14
src/api/server/account_status.rs
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
-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();