+1
Cargo.lock
+1
Cargo.lock
+1
Cargo.toml
+1
Cargo.toml
···
20
20
jacquard-repo = "0.9.2"
21
21
jsonwebtoken = { version = "10.2.0", features = ["rust_crypto"] }
22
22
k256 = { version = "0.13.3", features = ["ecdsa", "pem", "pkcs8"] }
23
+
multibase = "0.9.1"
23
24
multihash = "0.19.3"
24
25
rand = "0.8.5"
25
26
reqwest = { version = "0.12.24", features = ["json"] }
+22
-22
TODO.md
+22
-22
TODO.md
···
25
25
- [x] Implement `com.atproto.server.getSession`.
26
26
- [x] Implement `com.atproto.server.refreshSession`.
27
27
- [x] Implement `com.atproto.server.deleteSession` (Logout).
28
-
- [ ] Implement `com.atproto.server.activateAccount`.
29
-
- [ ] Implement `com.atproto.server.checkAccountStatus`.
30
-
- [ ] Implement `com.atproto.server.confirmEmail`.
31
-
- [ ] Implement `com.atproto.server.createAppPassword`.
28
+
- [x] Implement `com.atproto.server.activateAccount`.
29
+
- [x] Implement `com.atproto.server.checkAccountStatus`.
30
+
- [x] Implement `com.atproto.server.createAppPassword`.
32
31
- [ ] Implement `com.atproto.server.createInviteCode`.
33
32
- [ ] Implement `com.atproto.server.createInviteCodes`.
34
-
- [ ] Implement `com.atproto.server.deactivateAccount` / `deleteAccount`.
33
+
- [x] Implement `com.atproto.server.deactivateAccount` / `deleteAccount`.
35
34
- [ ] Implement `com.atproto.server.getAccountInviteCodes`.
36
35
- [x] Implement `com.atproto.server.getServiceAuth` (Cross-service auth).
37
-
- [ ] Implement `com.atproto.server.listAppPasswords`.
36
+
- [x] Implement `com.atproto.server.listAppPasswords`.
38
37
- [ ] Implement `com.atproto.server.requestAccountDelete`.
39
38
- [ ] Implement `com.atproto.server.requestEmailConfirmation` / `requestEmailUpdate`.
40
39
- [ ] Implement `com.atproto.server.requestPasswordReset` / `resetPassword`.
41
40
- [ ] Implement `com.atproto.server.reserveSigningKey`.
42
-
- [ ] Implement `com.atproto.server.revokeAppPassword`.
41
+
- [x] Implement `com.atproto.server.revokeAppPassword`.
43
42
- [ ] Implement `com.atproto.server.updateEmail`.
43
+
- [ ] Implement `com.atproto.server.confirmEmail`.
44
44
45
45
## Repository Operations (`com.atproto.repo`)
46
46
- [ ] Record CRUD
···
56
56
- [x] Implement `com.atproto.repo.describeRepo`.
57
57
- [x] Implement `com.atproto.repo.applyWrites` (Batch writes).
58
58
- [ ] Implement `com.atproto.repo.importRepo` (Migration).
59
-
- [ ] Implement `com.atproto.repo.listMissingBlobs`.
59
+
- [x] Implement `com.atproto.repo.listMissingBlobs`.
60
60
- [ ] Blob Management
61
61
- [x] Implement `com.atproto.repo.uploadBlob`.
62
62
- [x] Store blob (S3).
···
72
72
- [ ] Implement `com.atproto.sync.getBlocks` (Return specific blocks via CIDs).
73
73
- [x] Implement `com.atproto.sync.getLatestCommit`.
74
74
- [ ] Implement `com.atproto.sync.getRecord` (Sync version, distinct from repo.getRecord).
75
-
- [ ] Implement `com.atproto.sync.getRepoStatus`.
75
+
- [x] Implement `com.atproto.sync.getRepoStatus`.
76
76
- [x] Implement `com.atproto.sync.listRepos`.
77
-
- [ ] Implement `com.atproto.sync.notifyOfUpdate`.
77
+
- [x] Implement `com.atproto.sync.notifyOfUpdate`.
78
78
- [ ] Blob Sync
79
-
- [ ] Implement `com.atproto.sync.getBlob`.
80
-
- [ ] Implement `com.atproto.sync.listBlobs`.
81
-
- [ ] Crawler Interaction
82
-
- [ ] Implement `com.atproto.sync.requestCrawl` (Notify relays to index us).
79
+
- [x] Implement `com.atproto.sync.getBlob`.
80
+
- [x] Implement `com.atproto.sync.listBlobs`.
81
+
- [x] Crawler Interaction
82
+
- [x] Implement `com.atproto.sync.requestCrawl` (Notify relays to index us).
83
83
84
84
## Identity (`com.atproto.identity`)
85
85
- [ ] Resolution
86
86
- [x] Implement `com.atproto.identity.resolveHandle` (Can be internal or proxy to PLC).
87
-
- [ ] Implement `com.atproto.identity.updateHandle`.
87
+
- [x] Implement `com.atproto.identity.updateHandle`.
88
88
- [ ] Implement `com.atproto.identity.submitPlcOperation` / `signPlcOperation` / `requestPlcOperationSignature`.
89
-
- [ ] Implement `com.atproto.identity.getRecommendedDidCredentials`.
89
+
- [x] Implement `com.atproto.identity.getRecommendedDidCredentials`.
90
90
- [x] Implement `/.well-known/did.json` (Depends on supporting did:web).
91
91
92
92
## Admin Management (`com.atproto.admin`)
93
-
- [ ] Implement `com.atproto.admin.deleteAccount`.
93
+
- [x] Implement `com.atproto.admin.deleteAccount`.
94
94
- [ ] Implement `com.atproto.admin.disableAccountInvites`.
95
95
- [ ] Implement `com.atproto.admin.disableInviteCodes`.
96
96
- [ ] Implement `com.atproto.admin.enableAccountInvites`.
97
-
- [ ] Implement `com.atproto.admin.getAccountInfo` / `getAccountInfos`.
97
+
- [x] Implement `com.atproto.admin.getAccountInfo` / `getAccountInfos`.
98
98
- [ ] Implement `com.atproto.admin.getInviteCodes`.
99
99
- [ ] Implement `com.atproto.admin.getSubjectStatus`.
100
100
- [ ] Implement `com.atproto.admin.sendEmail`.
101
-
- [ ] Implement `com.atproto.admin.updateAccountEmail`.
102
-
- [ ] Implement `com.atproto.admin.updateAccountHandle`.
103
-
- [ ] Implement `com.atproto.admin.updateAccountPassword`.
101
+
- [x] Implement `com.atproto.admin.updateAccountEmail`.
102
+
- [x] Implement `com.atproto.admin.updateAccountHandle`.
103
+
- [x] Implement `com.atproto.admin.updateAccountPassword`.
104
104
- [ ] Implement `com.atproto.admin.updateSubjectStatus`.
105
105
106
106
## Moderation (`com.atproto.moderation`)
107
-
- [ ] Implement `com.atproto.moderation.createReport`.
107
+
- [x] Implement `com.atproto.moderation.createReport`.
108
108
109
109
## Record Schema Validation
110
110
- [ ] Handle this generically.
+9
migrations/202512211500_app_passwords.sql
+9
migrations/202512211500_app_passwords.sql
···
1
+
CREATE TABLE IF NOT EXISTS app_passwords (
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
+
password_hash TEXT NOT NULL,
6
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
7
+
privileged BOOLEAN NOT NULL DEFAULT FALSE,
8
+
UNIQUE(user_id, name)
9
+
);
+484
src/api/admin/mod.rs
+484
src/api/admin/mod.rs
···
1
+
use crate::state::AppState;
2
+
use axum::{
3
+
Json,
4
+
extract::{Query, State},
5
+
http::StatusCode,
6
+
response::{IntoResponse, Response},
7
+
};
8
+
use serde::{Deserialize, Serialize};
9
+
use serde_json::json;
10
+
use sqlx::Row;
11
+
use tracing::error;
12
+
13
+
#[derive(Deserialize)]
14
+
pub struct GetAccountInfoParams {
15
+
pub did: String,
16
+
}
17
+
18
+
#[derive(Serialize)]
19
+
#[serde(rename_all = "camelCase")]
20
+
pub struct AccountInfo {
21
+
pub did: String,
22
+
pub handle: String,
23
+
pub email: Option<String>,
24
+
pub indexed_at: String,
25
+
pub invite_note: Option<String>,
26
+
pub invites_disabled: bool,
27
+
pub email_confirmed_at: Option<String>,
28
+
pub deactivated_at: Option<String>,
29
+
}
30
+
31
+
#[derive(Serialize)]
32
+
#[serde(rename_all = "camelCase")]
33
+
pub struct GetAccountInfosOutput {
34
+
pub infos: Vec<AccountInfo>,
35
+
}
36
+
37
+
pub async fn get_account_info(
38
+
State(state): State<AppState>,
39
+
headers: axum::http::HeaderMap,
40
+
Query(params): Query<GetAccountInfoParams>,
41
+
) -> Response {
42
+
let auth_header = headers.get("Authorization");
43
+
if auth_header.is_none() {
44
+
return (
45
+
StatusCode::UNAUTHORIZED,
46
+
Json(json!({"error": "AuthenticationRequired"})),
47
+
)
48
+
.into_response();
49
+
}
50
+
51
+
let did = params.did.trim();
52
+
if did.is_empty() {
53
+
return (
54
+
StatusCode::BAD_REQUEST,
55
+
Json(json!({"error": "InvalidRequest", "message": "did is required"})),
56
+
)
57
+
.into_response();
58
+
}
59
+
60
+
let result = sqlx::query(
61
+
r#"
62
+
SELECT did, handle, email, created_at
63
+
FROM users
64
+
WHERE did = $1
65
+
"#,
66
+
)
67
+
.bind(did)
68
+
.fetch_optional(&state.db)
69
+
.await;
70
+
71
+
match result {
72
+
Ok(Some(row)) => {
73
+
let user_did: String = row.get("did");
74
+
let handle: String = row.get("handle");
75
+
let email: String = row.get("email");
76
+
let created_at: chrono::DateTime<chrono::Utc> = row.get("created_at");
77
+
78
+
(
79
+
StatusCode::OK,
80
+
Json(AccountInfo {
81
+
did: user_did,
82
+
handle,
83
+
email: Some(email),
84
+
indexed_at: created_at.to_rfc3339(),
85
+
invite_note: None,
86
+
invites_disabled: false,
87
+
email_confirmed_at: None,
88
+
deactivated_at: None,
89
+
}),
90
+
)
91
+
.into_response()
92
+
}
93
+
Ok(None) => (
94
+
StatusCode::NOT_FOUND,
95
+
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
96
+
)
97
+
.into_response(),
98
+
Err(e) => {
99
+
error!("DB error in get_account_info: {:?}", e);
100
+
(
101
+
StatusCode::INTERNAL_SERVER_ERROR,
102
+
Json(json!({"error": "InternalError"})),
103
+
)
104
+
.into_response()
105
+
}
106
+
}
107
+
}
108
+
109
+
#[derive(Deserialize)]
110
+
pub struct GetAccountInfosParams {
111
+
pub dids: String,
112
+
}
113
+
114
+
pub async fn get_account_infos(
115
+
State(state): State<AppState>,
116
+
headers: axum::http::HeaderMap,
117
+
Query(params): Query<GetAccountInfosParams>,
118
+
) -> Response {
119
+
let auth_header = headers.get("Authorization");
120
+
if auth_header.is_none() {
121
+
return (
122
+
StatusCode::UNAUTHORIZED,
123
+
Json(json!({"error": "AuthenticationRequired"})),
124
+
)
125
+
.into_response();
126
+
}
127
+
128
+
let dids: Vec<&str> = params.dids.split(',').map(|s| s.trim()).collect();
129
+
if dids.is_empty() {
130
+
return (
131
+
StatusCode::BAD_REQUEST,
132
+
Json(json!({"error": "InvalidRequest", "message": "dids is required"})),
133
+
)
134
+
.into_response();
135
+
}
136
+
137
+
let mut infos = Vec::new();
138
+
139
+
for did in dids {
140
+
if did.is_empty() {
141
+
continue;
142
+
}
143
+
144
+
let result = sqlx::query(
145
+
r#"
146
+
SELECT did, handle, email, created_at
147
+
FROM users
148
+
WHERE did = $1
149
+
"#,
150
+
)
151
+
.bind(did)
152
+
.fetch_optional(&state.db)
153
+
.await;
154
+
155
+
if let Ok(Some(row)) = result {
156
+
let user_did: String = row.get("did");
157
+
let handle: String = row.get("handle");
158
+
let email: String = row.get("email");
159
+
let created_at: chrono::DateTime<chrono::Utc> = row.get("created_at");
160
+
161
+
infos.push(AccountInfo {
162
+
did: user_did,
163
+
handle,
164
+
email: Some(email),
165
+
indexed_at: created_at.to_rfc3339(),
166
+
invite_note: None,
167
+
invites_disabled: false,
168
+
email_confirmed_at: None,
169
+
deactivated_at: None,
170
+
});
171
+
}
172
+
}
173
+
174
+
(StatusCode::OK, Json(GetAccountInfosOutput { infos })).into_response()
175
+
}
176
+
177
+
#[derive(Deserialize)]
178
+
pub struct DeleteAccountInput {
179
+
pub did: String,
180
+
}
181
+
182
+
pub async fn delete_account(
183
+
State(state): State<AppState>,
184
+
headers: axum::http::HeaderMap,
185
+
Json(input): Json<DeleteAccountInput>,
186
+
) -> Response {
187
+
let auth_header = headers.get("Authorization");
188
+
if auth_header.is_none() {
189
+
return (
190
+
StatusCode::UNAUTHORIZED,
191
+
Json(json!({"error": "AuthenticationRequired"})),
192
+
)
193
+
.into_response();
194
+
}
195
+
196
+
let did = input.did.trim();
197
+
if did.is_empty() {
198
+
return (
199
+
StatusCode::BAD_REQUEST,
200
+
Json(json!({"error": "InvalidRequest", "message": "did is required"})),
201
+
)
202
+
.into_response();
203
+
}
204
+
205
+
let user = sqlx::query("SELECT id FROM users WHERE did = $1")
206
+
.bind(did)
207
+
.fetch_optional(&state.db)
208
+
.await;
209
+
210
+
let user_id: uuid::Uuid = match user {
211
+
Ok(Some(row)) => row.get("id"),
212
+
Ok(None) => {
213
+
return (
214
+
StatusCode::NOT_FOUND,
215
+
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
216
+
)
217
+
.into_response();
218
+
}
219
+
Err(e) => {
220
+
error!("DB error in delete_account: {:?}", e);
221
+
return (
222
+
StatusCode::INTERNAL_SERVER_ERROR,
223
+
Json(json!({"error": "InternalError"})),
224
+
)
225
+
.into_response();
226
+
}
227
+
};
228
+
229
+
let _ = sqlx::query("DELETE FROM sessions WHERE did = $1")
230
+
.bind(did)
231
+
.execute(&state.db)
232
+
.await;
233
+
234
+
let _ = sqlx::query("DELETE FROM records WHERE repo_id = $1")
235
+
.bind(user_id)
236
+
.execute(&state.db)
237
+
.await;
238
+
239
+
let _ = sqlx::query("DELETE FROM repos WHERE user_id = $1")
240
+
.bind(user_id)
241
+
.execute(&state.db)
242
+
.await;
243
+
244
+
let _ = sqlx::query("DELETE FROM blobs WHERE created_by_user = $1")
245
+
.bind(user_id)
246
+
.execute(&state.db)
247
+
.await;
248
+
249
+
let _ = sqlx::query("DELETE FROM user_keys WHERE user_id = $1")
250
+
.bind(user_id)
251
+
.execute(&state.db)
252
+
.await;
253
+
254
+
let result = sqlx::query("DELETE FROM users WHERE id = $1")
255
+
.bind(user_id)
256
+
.execute(&state.db)
257
+
.await;
258
+
259
+
match result {
260
+
Ok(_) => (StatusCode::OK, Json(json!({}))).into_response(),
261
+
Err(e) => {
262
+
error!("DB error deleting account: {:?}", e);
263
+
(
264
+
StatusCode::INTERNAL_SERVER_ERROR,
265
+
Json(json!({"error": "InternalError"})),
266
+
)
267
+
.into_response()
268
+
}
269
+
}
270
+
}
271
+
272
+
#[derive(Deserialize)]
273
+
pub struct UpdateAccountEmailInput {
274
+
pub account: String,
275
+
pub email: String,
276
+
}
277
+
278
+
pub async fn update_account_email(
279
+
State(state): State<AppState>,
280
+
headers: axum::http::HeaderMap,
281
+
Json(input): Json<UpdateAccountEmailInput>,
282
+
) -> Response {
283
+
let auth_header = headers.get("Authorization");
284
+
if auth_header.is_none() {
285
+
return (
286
+
StatusCode::UNAUTHORIZED,
287
+
Json(json!({"error": "AuthenticationRequired"})),
288
+
)
289
+
.into_response();
290
+
}
291
+
292
+
let account = input.account.trim();
293
+
let email = input.email.trim();
294
+
295
+
if account.is_empty() || email.is_empty() {
296
+
return (
297
+
StatusCode::BAD_REQUEST,
298
+
Json(json!({"error": "InvalidRequest", "message": "account and email are required"})),
299
+
)
300
+
.into_response();
301
+
}
302
+
303
+
let result = sqlx::query("UPDATE users SET email = $1 WHERE did = $2")
304
+
.bind(email)
305
+
.bind(account)
306
+
.execute(&state.db)
307
+
.await;
308
+
309
+
match result {
310
+
Ok(r) => {
311
+
if r.rows_affected() == 0 {
312
+
return (
313
+
StatusCode::NOT_FOUND,
314
+
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
315
+
)
316
+
.into_response();
317
+
}
318
+
(StatusCode::OK, Json(json!({}))).into_response()
319
+
}
320
+
Err(e) => {
321
+
error!("DB error updating email: {:?}", e);
322
+
(
323
+
StatusCode::INTERNAL_SERVER_ERROR,
324
+
Json(json!({"error": "InternalError"})),
325
+
)
326
+
.into_response()
327
+
}
328
+
}
329
+
}
330
+
331
+
#[derive(Deserialize)]
332
+
pub struct UpdateAccountHandleInput {
333
+
pub did: String,
334
+
pub handle: String,
335
+
}
336
+
337
+
pub async fn update_account_handle(
338
+
State(state): State<AppState>,
339
+
headers: axum::http::HeaderMap,
340
+
Json(input): Json<UpdateAccountHandleInput>,
341
+
) -> Response {
342
+
let auth_header = headers.get("Authorization");
343
+
if auth_header.is_none() {
344
+
return (
345
+
StatusCode::UNAUTHORIZED,
346
+
Json(json!({"error": "AuthenticationRequired"})),
347
+
)
348
+
.into_response();
349
+
}
350
+
351
+
let did = input.did.trim();
352
+
let handle = input.handle.trim();
353
+
354
+
if did.is_empty() || handle.is_empty() {
355
+
return (
356
+
StatusCode::BAD_REQUEST,
357
+
Json(json!({"error": "InvalidRequest", "message": "did and handle are required"})),
358
+
)
359
+
.into_response();
360
+
}
361
+
362
+
if !handle
363
+
.chars()
364
+
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
365
+
{
366
+
return (
367
+
StatusCode::BAD_REQUEST,
368
+
Json(json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"})),
369
+
)
370
+
.into_response();
371
+
}
372
+
373
+
let existing = sqlx::query("SELECT id FROM users WHERE handle = $1 AND did != $2")
374
+
.bind(handle)
375
+
.bind(did)
376
+
.fetch_optional(&state.db)
377
+
.await;
378
+
379
+
if let Ok(Some(_)) = existing {
380
+
return (
381
+
StatusCode::BAD_REQUEST,
382
+
Json(json!({"error": "HandleTaken", "message": "Handle is already in use"})),
383
+
)
384
+
.into_response();
385
+
}
386
+
387
+
let result = sqlx::query("UPDATE users SET handle = $1 WHERE did = $2")
388
+
.bind(handle)
389
+
.bind(did)
390
+
.execute(&state.db)
391
+
.await;
392
+
393
+
match result {
394
+
Ok(r) => {
395
+
if r.rows_affected() == 0 {
396
+
return (
397
+
StatusCode::NOT_FOUND,
398
+
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
399
+
)
400
+
.into_response();
401
+
}
402
+
(StatusCode::OK, Json(json!({}))).into_response()
403
+
}
404
+
Err(e) => {
405
+
error!("DB error updating handle: {:?}", e);
406
+
(
407
+
StatusCode::INTERNAL_SERVER_ERROR,
408
+
Json(json!({"error": "InternalError"})),
409
+
)
410
+
.into_response()
411
+
}
412
+
}
413
+
}
414
+
415
+
#[derive(Deserialize)]
416
+
pub struct UpdateAccountPasswordInput {
417
+
pub did: String,
418
+
pub password: String,
419
+
}
420
+
421
+
pub async fn update_account_password(
422
+
State(state): State<AppState>,
423
+
headers: axum::http::HeaderMap,
424
+
Json(input): Json<UpdateAccountPasswordInput>,
425
+
) -> Response {
426
+
let auth_header = headers.get("Authorization");
427
+
if auth_header.is_none() {
428
+
return (
429
+
StatusCode::UNAUTHORIZED,
430
+
Json(json!({"error": "AuthenticationRequired"})),
431
+
)
432
+
.into_response();
433
+
}
434
+
435
+
let did = input.did.trim();
436
+
let password = input.password.trim();
437
+
438
+
if did.is_empty() || password.is_empty() {
439
+
return (
440
+
StatusCode::BAD_REQUEST,
441
+
Json(json!({"error": "InvalidRequest", "message": "did and password are required"})),
442
+
)
443
+
.into_response();
444
+
}
445
+
446
+
let password_hash = match bcrypt::hash(password, bcrypt::DEFAULT_COST) {
447
+
Ok(h) => h,
448
+
Err(e) => {
449
+
error!("Failed to hash password: {:?}", e);
450
+
return (
451
+
StatusCode::INTERNAL_SERVER_ERROR,
452
+
Json(json!({"error": "InternalError"})),
453
+
)
454
+
.into_response();
455
+
}
456
+
};
457
+
458
+
let result = sqlx::query("UPDATE users SET password_hash = $1 WHERE did = $2")
459
+
.bind(&password_hash)
460
+
.bind(did)
461
+
.execute(&state.db)
462
+
.await;
463
+
464
+
match result {
465
+
Ok(r) => {
466
+
if r.rows_affected() == 0 {
467
+
return (
468
+
StatusCode::NOT_FOUND,
469
+
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
470
+
)
471
+
.into_response();
472
+
}
473
+
(StatusCode::OK, Json(json!({}))).into_response()
474
+
}
475
+
Err(e) => {
476
+
error!("DB error updating password: {:?}", e);
477
+
(
478
+
StatusCode::INTERNAL_SERVER_ERROR,
479
+
Json(json!({"error": "InternalError"})),
480
+
)
481
+
.into_response()
482
+
}
483
+
}
484
+
}
+255
src/api/identity/did.rs
+255
src/api/identity/did.rs
···
245
245
}
246
246
}
247
247
}
248
+
249
+
#[derive(serde::Serialize)]
250
+
#[serde(rename_all = "camelCase")]
251
+
pub struct GetRecommendedDidCredentialsOutput {
252
+
pub rotation_keys: Vec<String>,
253
+
pub also_known_as: Vec<String>,
254
+
pub verification_methods: VerificationMethods,
255
+
pub services: Services,
256
+
}
257
+
258
+
#[derive(serde::Serialize)]
259
+
#[serde(rename_all = "camelCase")]
260
+
pub struct VerificationMethods {
261
+
pub atproto: String,
262
+
}
263
+
264
+
#[derive(serde::Serialize)]
265
+
#[serde(rename_all = "camelCase")]
266
+
pub struct Services {
267
+
pub atproto_pds: AtprotoPds,
268
+
}
269
+
270
+
#[derive(serde::Serialize)]
271
+
#[serde(rename_all = "camelCase")]
272
+
pub struct AtprotoPds {
273
+
#[serde(rename = "type")]
274
+
pub service_type: String,
275
+
pub endpoint: String,
276
+
}
277
+
278
+
pub async fn get_recommended_did_credentials(
279
+
State(state): State<AppState>,
280
+
headers: axum::http::HeaderMap,
281
+
) -> Response {
282
+
let auth_header = headers.get("Authorization");
283
+
if auth_header.is_none() {
284
+
return (
285
+
StatusCode::UNAUTHORIZED,
286
+
Json(json!({"error": "AuthenticationRequired"})),
287
+
)
288
+
.into_response();
289
+
}
290
+
291
+
let token = auth_header
292
+
.unwrap()
293
+
.to_str()
294
+
.unwrap_or("")
295
+
.replace("Bearer ", "");
296
+
297
+
let session = sqlx::query(
298
+
r#"
299
+
SELECT s.did, k.key_bytes, u.handle
300
+
FROM sessions s
301
+
JOIN users u ON s.did = u.did
302
+
JOIN user_keys k ON u.id = k.user_id
303
+
WHERE s.access_jwt = $1
304
+
"#,
305
+
)
306
+
.bind(&token)
307
+
.fetch_optional(&state.db)
308
+
.await;
309
+
310
+
let (_did, key_bytes, handle) = match session {
311
+
Ok(Some(row)) => (
312
+
row.get::<String, _>("did"),
313
+
row.get::<Vec<u8>, _>("key_bytes"),
314
+
row.get::<String, _>("handle"),
315
+
),
316
+
Ok(None) => {
317
+
return (
318
+
StatusCode::UNAUTHORIZED,
319
+
Json(json!({"error": "AuthenticationFailed"})),
320
+
)
321
+
.into_response();
322
+
}
323
+
Err(e) => {
324
+
error!("DB error in get_recommended_did_credentials: {:?}", e);
325
+
return (
326
+
StatusCode::INTERNAL_SERVER_ERROR,
327
+
Json(json!({"error": "InternalError"})),
328
+
)
329
+
.into_response();
330
+
}
331
+
};
332
+
333
+
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
334
+
return (
335
+
StatusCode::UNAUTHORIZED,
336
+
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
337
+
)
338
+
.into_response();
339
+
}
340
+
341
+
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
342
+
let pds_endpoint = format!("https://{}", hostname);
343
+
344
+
let secret_key = match k256::SecretKey::from_slice(&key_bytes) {
345
+
Ok(k) => k,
346
+
Err(_) => {
347
+
return (
348
+
StatusCode::INTERNAL_SERVER_ERROR,
349
+
Json(json!({"error": "InternalError"})),
350
+
)
351
+
.into_response();
352
+
}
353
+
};
354
+
355
+
let public_key = secret_key.public_key();
356
+
let encoded = public_key.to_encoded_point(true);
357
+
let did_key = format!(
358
+
"did:key:zQ3sh{}",
359
+
multibase::encode(multibase::Base::Base58Btc, encoded.as_bytes())
360
+
.chars()
361
+
.skip(1)
362
+
.collect::<String>()
363
+
);
364
+
365
+
(
366
+
StatusCode::OK,
367
+
Json(GetRecommendedDidCredentialsOutput {
368
+
rotation_keys: vec![did_key.clone()],
369
+
also_known_as: vec![format!("at://{}", handle)],
370
+
verification_methods: VerificationMethods { atproto: did_key },
371
+
services: Services {
372
+
atproto_pds: AtprotoPds {
373
+
service_type: "AtprotoPersonalDataServer".to_string(),
374
+
endpoint: pds_endpoint,
375
+
},
376
+
},
377
+
}),
378
+
)
379
+
.into_response()
380
+
}
381
+
382
+
#[derive(Deserialize)]
383
+
pub struct UpdateHandleInput {
384
+
pub handle: String,
385
+
}
386
+
387
+
pub async fn update_handle(
388
+
State(state): State<AppState>,
389
+
headers: axum::http::HeaderMap,
390
+
Json(input): Json<UpdateHandleInput>,
391
+
) -> Response {
392
+
let auth_header = headers.get("Authorization");
393
+
if auth_header.is_none() {
394
+
return (
395
+
StatusCode::UNAUTHORIZED,
396
+
Json(json!({"error": "AuthenticationRequired"})),
397
+
)
398
+
.into_response();
399
+
}
400
+
401
+
let token = auth_header
402
+
.unwrap()
403
+
.to_str()
404
+
.unwrap_or("")
405
+
.replace("Bearer ", "");
406
+
407
+
let session = sqlx::query(
408
+
r#"
409
+
SELECT s.did, k.key_bytes, u.id as user_id
410
+
FROM sessions s
411
+
JOIN users u ON s.did = u.did
412
+
JOIN user_keys k ON u.id = k.user_id
413
+
WHERE s.access_jwt = $1
414
+
"#,
415
+
)
416
+
.bind(&token)
417
+
.fetch_optional(&state.db)
418
+
.await;
419
+
420
+
let (_did, key_bytes, user_id) = match session {
421
+
Ok(Some(row)) => (
422
+
row.get::<String, _>("did"),
423
+
row.get::<Vec<u8>, _>("key_bytes"),
424
+
row.get::<uuid::Uuid, _>("user_id"),
425
+
),
426
+
Ok(None) => {
427
+
return (
428
+
StatusCode::UNAUTHORIZED,
429
+
Json(json!({"error": "AuthenticationFailed"})),
430
+
)
431
+
.into_response();
432
+
}
433
+
Err(e) => {
434
+
error!("DB error in update_handle: {:?}", e);
435
+
return (
436
+
StatusCode::INTERNAL_SERVER_ERROR,
437
+
Json(json!({"error": "InternalError"})),
438
+
)
439
+
.into_response();
440
+
}
441
+
};
442
+
443
+
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
444
+
return (
445
+
StatusCode::UNAUTHORIZED,
446
+
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
447
+
)
448
+
.into_response();
449
+
}
450
+
451
+
let new_handle = input.handle.trim();
452
+
if new_handle.is_empty() {
453
+
return (
454
+
StatusCode::BAD_REQUEST,
455
+
Json(json!({"error": "InvalidRequest", "message": "handle is required"})),
456
+
)
457
+
.into_response();
458
+
}
459
+
460
+
if !new_handle
461
+
.chars()
462
+
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
463
+
{
464
+
return (
465
+
StatusCode::BAD_REQUEST,
466
+
Json(json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"})),
467
+
)
468
+
.into_response();
469
+
}
470
+
471
+
let existing = sqlx::query("SELECT id FROM users WHERE handle = $1 AND id != $2")
472
+
.bind(new_handle)
473
+
.bind(user_id)
474
+
.fetch_optional(&state.db)
475
+
.await;
476
+
477
+
if let Ok(Some(_)) = existing {
478
+
return (
479
+
StatusCode::BAD_REQUEST,
480
+
Json(json!({"error": "HandleTaken", "message": "Handle is already in use"})),
481
+
)
482
+
.into_response();
483
+
}
484
+
485
+
let result = sqlx::query("UPDATE users SET handle = $1 WHERE id = $2")
486
+
.bind(new_handle)
487
+
.bind(user_id)
488
+
.execute(&state.db)
489
+
.await;
490
+
491
+
match result {
492
+
Ok(_) => (StatusCode::OK, Json(json!({}))).into_response(),
493
+
Err(e) => {
494
+
error!("DB error updating handle: {:?}", e);
495
+
(
496
+
StatusCode::INTERNAL_SERVER_ERROR,
497
+
Json(json!({"error": "InternalError"})),
498
+
)
499
+
.into_response()
500
+
}
501
+
}
502
+
}
+3
-1
src/api/identity/mod.rs
+3
-1
src/api/identity/mod.rs
+2
src/api/mod.rs
+2
src/api/mod.rs
+128
src/api/moderation/mod.rs
+128
src/api/moderation/mod.rs
···
1
+
use crate::state::AppState;
2
+
use axum::{
3
+
Json,
4
+
extract::State,
5
+
http::StatusCode,
6
+
response::{IntoResponse, Response},
7
+
};
8
+
use serde::{Deserialize, Serialize};
9
+
use serde_json::{Value, json};
10
+
use sqlx::Row;
11
+
use tracing::error;
12
+
13
+
#[derive(Deserialize)]
14
+
#[serde(rename_all = "camelCase")]
15
+
pub struct CreateReportInput {
16
+
pub reason_type: String,
17
+
pub reason: Option<String>,
18
+
pub subject: Value,
19
+
}
20
+
21
+
#[derive(Serialize)]
22
+
#[serde(rename_all = "camelCase")]
23
+
pub struct CreateReportOutput {
24
+
pub id: i64,
25
+
pub reason_type: String,
26
+
pub reason: Option<String>,
27
+
pub subject: Value,
28
+
pub reported_by: String,
29
+
pub created_at: String,
30
+
}
31
+
32
+
pub async fn create_report(
33
+
State(state): State<AppState>,
34
+
headers: axum::http::HeaderMap,
35
+
Json(input): Json<CreateReportInput>,
36
+
) -> Response {
37
+
let auth_header = headers.get("Authorization");
38
+
if auth_header.is_none() {
39
+
return (
40
+
StatusCode::UNAUTHORIZED,
41
+
Json(json!({"error": "AuthenticationRequired"})),
42
+
)
43
+
.into_response();
44
+
}
45
+
46
+
let token = auth_header
47
+
.unwrap()
48
+
.to_str()
49
+
.unwrap_or("")
50
+
.replace("Bearer ", "");
51
+
52
+
let session = sqlx::query(
53
+
r#"
54
+
SELECT s.did, k.key_bytes
55
+
FROM sessions s
56
+
JOIN users u ON s.did = u.did
57
+
JOIN user_keys k ON u.id = k.user_id
58
+
WHERE s.access_jwt = $1
59
+
"#,
60
+
)
61
+
.bind(&token)
62
+
.fetch_optional(&state.db)
63
+
.await;
64
+
65
+
let (did, key_bytes) = match session {
66
+
Ok(Some(row)) => (
67
+
row.get::<String, _>("did"),
68
+
row.get::<Vec<u8>, _>("key_bytes"),
69
+
),
70
+
Ok(None) => {
71
+
return (
72
+
StatusCode::UNAUTHORIZED,
73
+
Json(json!({"error": "AuthenticationFailed"})),
74
+
)
75
+
.into_response();
76
+
}
77
+
Err(e) => {
78
+
error!("DB error in create_report: {:?}", e);
79
+
return (
80
+
StatusCode::INTERNAL_SERVER_ERROR,
81
+
Json(json!({"error": "InternalError"})),
82
+
)
83
+
.into_response();
84
+
}
85
+
};
86
+
87
+
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
88
+
return (
89
+
StatusCode::UNAUTHORIZED,
90
+
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
91
+
)
92
+
.into_response();
93
+
}
94
+
95
+
let valid_reason_types = [
96
+
"com.atproto.moderation.defs#reasonSpam",
97
+
"com.atproto.moderation.defs#reasonViolation",
98
+
"com.atproto.moderation.defs#reasonMisleading",
99
+
"com.atproto.moderation.defs#reasonSexual",
100
+
"com.atproto.moderation.defs#reasonRude",
101
+
"com.atproto.moderation.defs#reasonOther",
102
+
"com.atproto.moderation.defs#reasonAppeal",
103
+
];
104
+
105
+
if !valid_reason_types.contains(&input.reason_type.as_str()) {
106
+
return (
107
+
StatusCode::BAD_REQUEST,
108
+
Json(json!({"error": "InvalidRequest", "message": "Invalid reasonType"})),
109
+
)
110
+
.into_response();
111
+
}
112
+
113
+
let created_at = chrono::Utc::now().to_rfc3339();
114
+
let report_id = chrono::Utc::now().timestamp_millis();
115
+
116
+
(
117
+
StatusCode::OK,
118
+
Json(CreateReportOutput {
119
+
id: report_id,
120
+
reason_type: input.reason_type,
121
+
reason: input.reason,
122
+
subject: input.subject,
123
+
reported_by: did,
124
+
created_at,
125
+
}),
126
+
)
127
+
.into_response()
128
+
}
+45
-1
src/api/repo/blob.rs
+45
-1
src/api/repo/blob.rs
···
2
2
use axum::body::Bytes;
3
3
use axum::{
4
4
Json,
5
-
extract::State,
5
+
extract::{Query, State},
6
6
http::StatusCode,
7
7
response::{IntoResponse, Response},
8
8
};
9
9
use cid::Cid;
10
10
use multihash::Multihash;
11
+
use serde::{Deserialize, Serialize};
11
12
use serde_json::json;
12
13
use sha2::{Digest, Sha256};
13
14
use sqlx::Row;
···
136
137
}))
137
138
.into_response()
138
139
}
140
+
141
+
#[derive(Deserialize)]
142
+
pub struct ListMissingBlobsParams {
143
+
pub limit: Option<i64>,
144
+
pub cursor: Option<String>,
145
+
}
146
+
147
+
#[derive(Serialize)]
148
+
#[serde(rename_all = "camelCase")]
149
+
pub struct RecordBlob {
150
+
pub cid: String,
151
+
pub record_uri: String,
152
+
}
153
+
154
+
#[derive(Serialize)]
155
+
pub struct ListMissingBlobsOutput {
156
+
pub cursor: Option<String>,
157
+
pub blobs: Vec<RecordBlob>,
158
+
}
159
+
160
+
pub async fn list_missing_blobs(
161
+
State(_state): State<AppState>,
162
+
headers: axum::http::HeaderMap,
163
+
Query(_params): Query<ListMissingBlobsParams>,
164
+
) -> Response {
165
+
let auth_header = headers.get("Authorization");
166
+
if auth_header.is_none() {
167
+
return (
168
+
StatusCode::UNAUTHORIZED,
169
+
Json(json!({"error": "AuthenticationRequired"})),
170
+
)
171
+
.into_response();
172
+
}
173
+
174
+
(
175
+
StatusCode::OK,
176
+
Json(ListMissingBlobsOutput {
177
+
cursor: None,
178
+
blobs: vec![],
179
+
}),
180
+
)
181
+
.into_response()
182
+
}
+1
-1
src/api/repo/mod.rs
+1
-1
src/api/repo/mod.rs
+5
-1
src/api/server/mod.rs
+5
-1
src/api/server/mod.rs
···
2
2
pub mod session;
3
3
4
4
pub use meta::{describe_server, health};
5
-
pub use session::{create_session, delete_session, get_service_auth, get_session, refresh_session};
5
+
pub use session::{
6
+
activate_account, check_account_status, create_app_password, create_session,
7
+
deactivate_account, delete_session, get_service_auth, get_session, list_app_passwords,
8
+
refresh_session, revoke_app_password,
9
+
};
+554
-5
src/api/server/session.rs
+554
-5
src/api/server/session.rs
···
125
125
) -> Response {
126
126
info!("create_session: identifier='{}'", input.identifier);
127
127
128
-
let user_row = sqlx::query("SELECT u.did, u.handle, u.password_hash, k.key_bytes FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1")
128
+
let user_row = sqlx::query("SELECT u.id, u.did, u.handle, u.password_hash, k.key_bytes FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1")
129
129
.bind(&input.identifier)
130
130
.fetch_optional(&state.db)
131
131
.await;
132
132
133
133
match user_row {
134
134
Ok(Some(row)) => {
135
+
let user_id: uuid::Uuid = row.get("id");
135
136
let stored_hash: String = row.get("password_hash");
137
+
let did: String = row.get("did");
138
+
let handle: String = row.get("handle");
139
+
let key_bytes: Vec<u8> = row.get("key_bytes");
136
140
137
-
if verify(&input.password, &stored_hash).unwrap_or(false) {
138
-
let did: String = row.get("did");
139
-
let handle: String = row.get("handle");
140
-
let key_bytes: Vec<u8> = row.get("key_bytes");
141
+
let password_valid = if verify(&input.password, &stored_hash).unwrap_or(false) {
142
+
true
143
+
} else {
144
+
let app_pass_rows = sqlx::query("SELECT password_hash FROM app_passwords WHERE user_id = $1")
145
+
.bind(user_id)
146
+
.fetch_all(&state.db)
147
+
.await
148
+
.unwrap_or_default();
141
149
150
+
app_pass_rows.iter().any(|row| {
151
+
let hash: String = row.get("password_hash");
152
+
verify(&input.password, &hash).unwrap_or(false)
153
+
})
154
+
};
155
+
156
+
if password_valid {
142
157
let access_jwt = match crate::auth::create_access_token(&did, &key_bytes) {
143
158
Ok(t) => t,
144
159
Err(e) => {
···
468
483
}
469
484
}
470
485
}
486
+
487
+
#[derive(Serialize)]
488
+
#[serde(rename_all = "camelCase")]
489
+
pub struct CheckAccountStatusOutput {
490
+
pub activated: bool,
491
+
pub valid_did: bool,
492
+
pub repo_commit: String,
493
+
pub repo_rev: String,
494
+
pub repo_blocks: i64,
495
+
pub indexed_records: i64,
496
+
pub private_state_values: i64,
497
+
pub expected_blobs: i64,
498
+
pub imported_blobs: i64,
499
+
}
500
+
501
+
pub async fn check_account_status(
502
+
State(state): State<AppState>,
503
+
headers: axum::http::HeaderMap,
504
+
) -> Response {
505
+
let auth_header = headers.get("Authorization");
506
+
if auth_header.is_none() {
507
+
return (
508
+
StatusCode::UNAUTHORIZED,
509
+
Json(json!({"error": "AuthenticationRequired"})),
510
+
)
511
+
.into_response();
512
+
}
513
+
514
+
let token = auth_header
515
+
.unwrap()
516
+
.to_str()
517
+
.unwrap_or("")
518
+
.replace("Bearer ", "");
519
+
520
+
let session = sqlx::query(
521
+
r#"
522
+
SELECT s.did, k.key_bytes, u.id as user_id
523
+
FROM sessions s
524
+
JOIN users u ON s.did = u.did
525
+
JOIN user_keys k ON u.id = k.user_id
526
+
WHERE s.access_jwt = $1
527
+
"#,
528
+
)
529
+
.bind(&token)
530
+
.fetch_optional(&state.db)
531
+
.await;
532
+
533
+
let (did, key_bytes, user_id) = match session {
534
+
Ok(Some(row)) => (
535
+
row.get::<String, _>("did"),
536
+
row.get::<Vec<u8>, _>("key_bytes"),
537
+
row.get::<uuid::Uuid, _>("user_id"),
538
+
),
539
+
Ok(None) => {
540
+
return (
541
+
StatusCode::UNAUTHORIZED,
542
+
Json(json!({"error": "AuthenticationFailed"})),
543
+
)
544
+
.into_response();
545
+
}
546
+
Err(e) => {
547
+
error!("DB error in check_account_status: {:?}", e);
548
+
return (
549
+
StatusCode::INTERNAL_SERVER_ERROR,
550
+
Json(json!({"error": "InternalError"})),
551
+
)
552
+
.into_response();
553
+
}
554
+
};
555
+
556
+
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
557
+
return (
558
+
StatusCode::UNAUTHORIZED,
559
+
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
560
+
)
561
+
.into_response();
562
+
}
563
+
564
+
let repo_result = sqlx::query("SELECT repo_root_cid FROM repos WHERE user_id = $1")
565
+
.bind(user_id)
566
+
.fetch_optional(&state.db)
567
+
.await;
568
+
569
+
let repo_commit = match repo_result {
570
+
Ok(Some(row)) => row.get::<String, _>("repo_root_cid"),
571
+
_ => String::new(),
572
+
};
573
+
574
+
let record_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM records WHERE repo_id = $1")
575
+
.bind(user_id)
576
+
.fetch_one(&state.db)
577
+
.await
578
+
.unwrap_or(0);
579
+
580
+
let blob_count: i64 =
581
+
sqlx::query_scalar("SELECT COUNT(*) FROM blobs WHERE created_by_user = $1")
582
+
.bind(user_id)
583
+
.fetch_one(&state.db)
584
+
.await
585
+
.unwrap_or(0);
586
+
587
+
let valid_did = did.starts_with("did:");
588
+
589
+
(
590
+
StatusCode::OK,
591
+
Json(CheckAccountStatusOutput {
592
+
activated: true,
593
+
valid_did,
594
+
repo_commit: repo_commit.clone(),
595
+
repo_rev: chrono::Utc::now().timestamp_millis().to_string(),
596
+
repo_blocks: 0,
597
+
indexed_records: record_count,
598
+
private_state_values: 0,
599
+
expected_blobs: blob_count,
600
+
imported_blobs: blob_count,
601
+
}),
602
+
)
603
+
.into_response()
604
+
}
605
+
606
+
pub async fn activate_account(
607
+
State(_state): State<AppState>,
608
+
headers: axum::http::HeaderMap,
609
+
) -> Response {
610
+
let auth_header = headers.get("Authorization");
611
+
if auth_header.is_none() {
612
+
return (
613
+
StatusCode::UNAUTHORIZED,
614
+
Json(json!({"error": "AuthenticationRequired"})),
615
+
)
616
+
.into_response();
617
+
}
618
+
619
+
(StatusCode::OK, Json(json!({}))).into_response()
620
+
}
621
+
622
+
#[derive(Deserialize)]
623
+
#[serde(rename_all = "camelCase")]
624
+
pub struct DeactivateAccountInput {
625
+
pub delete_after: Option<String>,
626
+
}
627
+
628
+
pub async fn deactivate_account(
629
+
State(_state): State<AppState>,
630
+
headers: axum::http::HeaderMap,
631
+
Json(_input): Json<DeactivateAccountInput>,
632
+
) -> Response {
633
+
let auth_header = headers.get("Authorization");
634
+
if auth_header.is_none() {
635
+
return (
636
+
StatusCode::UNAUTHORIZED,
637
+
Json(json!({"error": "AuthenticationRequired"})),
638
+
)
639
+
.into_response();
640
+
}
641
+
642
+
(StatusCode::OK, Json(json!({}))).into_response()
643
+
}
644
+
645
+
#[derive(Serialize)]
646
+
#[serde(rename_all = "camelCase")]
647
+
pub struct AppPassword {
648
+
pub name: String,
649
+
pub created_at: String,
650
+
pub privileged: bool,
651
+
}
652
+
653
+
#[derive(Serialize)]
654
+
pub struct ListAppPasswordsOutput {
655
+
pub passwords: Vec<AppPassword>,
656
+
}
657
+
658
+
pub async fn list_app_passwords(
659
+
State(state): State<AppState>,
660
+
headers: axum::http::HeaderMap,
661
+
) -> Response {
662
+
let auth_header = headers.get("Authorization");
663
+
if auth_header.is_none() {
664
+
return (
665
+
StatusCode::UNAUTHORIZED,
666
+
Json(json!({"error": "AuthenticationRequired"})),
667
+
)
668
+
.into_response();
669
+
}
670
+
671
+
let token = auth_header
672
+
.unwrap()
673
+
.to_str()
674
+
.unwrap_or("")
675
+
.replace("Bearer ", "");
676
+
677
+
let session = sqlx::query(
678
+
r#"
679
+
SELECT s.did, k.key_bytes, u.id as user_id
680
+
FROM sessions s
681
+
JOIN users u ON s.did = u.did
682
+
JOIN user_keys k ON u.id = k.user_id
683
+
WHERE s.access_jwt = $1
684
+
"#,
685
+
)
686
+
.bind(&token)
687
+
.fetch_optional(&state.db)
688
+
.await;
689
+
690
+
let (_did, key_bytes, user_id) = match session {
691
+
Ok(Some(row)) => (
692
+
row.get::<String, _>("did"),
693
+
row.get::<Vec<u8>, _>("key_bytes"),
694
+
row.get::<uuid::Uuid, _>("user_id"),
695
+
),
696
+
Ok(None) => {
697
+
return (
698
+
StatusCode::UNAUTHORIZED,
699
+
Json(json!({"error": "AuthenticationFailed"})),
700
+
)
701
+
.into_response();
702
+
}
703
+
Err(e) => {
704
+
error!("DB error in list_app_passwords: {:?}", e);
705
+
return (
706
+
StatusCode::INTERNAL_SERVER_ERROR,
707
+
Json(json!({"error": "InternalError"})),
708
+
)
709
+
.into_response();
710
+
}
711
+
};
712
+
713
+
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
714
+
return (
715
+
StatusCode::UNAUTHORIZED,
716
+
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
717
+
)
718
+
.into_response();
719
+
}
720
+
721
+
let result = sqlx::query("SELECT name, created_at, privileged FROM app_passwords WHERE user_id = $1 ORDER BY created_at DESC")
722
+
.bind(user_id)
723
+
.fetch_all(&state.db)
724
+
.await;
725
+
726
+
match result {
727
+
Ok(rows) => {
728
+
let passwords: Vec<AppPassword> = rows
729
+
.iter()
730
+
.map(|row| {
731
+
let name: String = row.get("name");
732
+
let created_at: chrono::DateTime<chrono::Utc> = row.get("created_at");
733
+
let privileged: bool = row.get("privileged");
734
+
AppPassword {
735
+
name,
736
+
created_at: created_at.to_rfc3339(),
737
+
privileged,
738
+
}
739
+
})
740
+
.collect();
741
+
742
+
(StatusCode::OK, Json(ListAppPasswordsOutput { passwords })).into_response()
743
+
}
744
+
Err(e) => {
745
+
error!("DB error listing app passwords: {:?}", e);
746
+
(
747
+
StatusCode::INTERNAL_SERVER_ERROR,
748
+
Json(json!({"error": "InternalError"})),
749
+
)
750
+
.into_response()
751
+
}
752
+
}
753
+
}
754
+
755
+
#[derive(Deserialize)]
756
+
pub struct CreateAppPasswordInput {
757
+
pub name: String,
758
+
pub privileged: Option<bool>,
759
+
}
760
+
761
+
#[derive(Serialize)]
762
+
#[serde(rename_all = "camelCase")]
763
+
pub struct CreateAppPasswordOutput {
764
+
pub name: String,
765
+
pub password: String,
766
+
pub created_at: String,
767
+
pub privileged: bool,
768
+
}
769
+
770
+
pub async fn create_app_password(
771
+
State(state): State<AppState>,
772
+
headers: axum::http::HeaderMap,
773
+
Json(input): Json<CreateAppPasswordInput>,
774
+
) -> Response {
775
+
let auth_header = headers.get("Authorization");
776
+
if auth_header.is_none() {
777
+
return (
778
+
StatusCode::UNAUTHORIZED,
779
+
Json(json!({"error": "AuthenticationRequired"})),
780
+
)
781
+
.into_response();
782
+
}
783
+
784
+
let token = auth_header
785
+
.unwrap()
786
+
.to_str()
787
+
.unwrap_or("")
788
+
.replace("Bearer ", "");
789
+
790
+
let session = sqlx::query(
791
+
r#"
792
+
SELECT s.did, k.key_bytes, u.id as user_id
793
+
FROM sessions s
794
+
JOIN users u ON s.did = u.did
795
+
JOIN user_keys k ON u.id = k.user_id
796
+
WHERE s.access_jwt = $1
797
+
"#,
798
+
)
799
+
.bind(&token)
800
+
.fetch_optional(&state.db)
801
+
.await;
802
+
803
+
let (_did, key_bytes, user_id) = match session {
804
+
Ok(Some(row)) => (
805
+
row.get::<String, _>("did"),
806
+
row.get::<Vec<u8>, _>("key_bytes"),
807
+
row.get::<uuid::Uuid, _>("user_id"),
808
+
),
809
+
Ok(None) => {
810
+
return (
811
+
StatusCode::UNAUTHORIZED,
812
+
Json(json!({"error": "AuthenticationFailed"})),
813
+
)
814
+
.into_response();
815
+
}
816
+
Err(e) => {
817
+
error!("DB error in create_app_password: {:?}", e);
818
+
return (
819
+
StatusCode::INTERNAL_SERVER_ERROR,
820
+
Json(json!({"error": "InternalError"})),
821
+
)
822
+
.into_response();
823
+
}
824
+
};
825
+
826
+
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
827
+
return (
828
+
StatusCode::UNAUTHORIZED,
829
+
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
830
+
)
831
+
.into_response();
832
+
}
833
+
834
+
let name = input.name.trim();
835
+
if name.is_empty() {
836
+
return (
837
+
StatusCode::BAD_REQUEST,
838
+
Json(json!({"error": "InvalidRequest", "message": "name is required"})),
839
+
)
840
+
.into_response();
841
+
}
842
+
843
+
let existing = sqlx::query("SELECT id FROM app_passwords WHERE user_id = $1 AND name = $2")
844
+
.bind(user_id)
845
+
.bind(name)
846
+
.fetch_optional(&state.db)
847
+
.await;
848
+
849
+
if let Ok(Some(_)) = existing {
850
+
return (
851
+
StatusCode::BAD_REQUEST,
852
+
Json(json!({"error": "DuplicateAppPassword", "message": "App password with this name already exists"})),
853
+
)
854
+
.into_response();
855
+
}
856
+
857
+
let password: String = (0..4)
858
+
.map(|_| {
859
+
use rand::Rng;
860
+
let mut rng = rand::thread_rng();
861
+
let chars: Vec<char> = "abcdefghijklmnopqrstuvwxyz234567".chars().collect();
862
+
(0..4).map(|_| chars[rng.gen_range(0..chars.len())]).collect::<String>()
863
+
})
864
+
.collect::<Vec<String>>()
865
+
.join("-");
866
+
867
+
let password_hash = match bcrypt::hash(&password, bcrypt::DEFAULT_COST) {
868
+
Ok(h) => h,
869
+
Err(e) => {
870
+
error!("Failed to hash password: {:?}", e);
871
+
return (
872
+
StatusCode::INTERNAL_SERVER_ERROR,
873
+
Json(json!({"error": "InternalError"})),
874
+
)
875
+
.into_response();
876
+
}
877
+
};
878
+
879
+
let privileged = input.privileged.unwrap_or(false);
880
+
let created_at = chrono::Utc::now();
881
+
882
+
let result = sqlx::query(
883
+
"INSERT INTO app_passwords (user_id, name, password_hash, created_at, privileged) VALUES ($1, $2, $3, $4, $5)"
884
+
)
885
+
.bind(user_id)
886
+
.bind(name)
887
+
.bind(&password_hash)
888
+
.bind(created_at)
889
+
.bind(privileged)
890
+
.execute(&state.db)
891
+
.await;
892
+
893
+
match result {
894
+
Ok(_) => (
895
+
StatusCode::OK,
896
+
Json(CreateAppPasswordOutput {
897
+
name: name.to_string(),
898
+
password,
899
+
created_at: created_at.to_rfc3339(),
900
+
privileged,
901
+
}),
902
+
)
903
+
.into_response(),
904
+
Err(e) => {
905
+
error!("DB error creating app password: {:?}", e);
906
+
(
907
+
StatusCode::INTERNAL_SERVER_ERROR,
908
+
Json(json!({"error": "InternalError"})),
909
+
)
910
+
.into_response()
911
+
}
912
+
}
913
+
}
914
+
915
+
#[derive(Deserialize)]
916
+
pub struct RevokeAppPasswordInput {
917
+
pub name: String,
918
+
}
919
+
920
+
pub async fn revoke_app_password(
921
+
State(state): State<AppState>,
922
+
headers: axum::http::HeaderMap,
923
+
Json(input): Json<RevokeAppPasswordInput>,
924
+
) -> Response {
925
+
let auth_header = headers.get("Authorization");
926
+
if auth_header.is_none() {
927
+
return (
928
+
StatusCode::UNAUTHORIZED,
929
+
Json(json!({"error": "AuthenticationRequired"})),
930
+
)
931
+
.into_response();
932
+
}
933
+
934
+
let token = auth_header
935
+
.unwrap()
936
+
.to_str()
937
+
.unwrap_or("")
938
+
.replace("Bearer ", "");
939
+
940
+
let session = sqlx::query(
941
+
r#"
942
+
SELECT s.did, k.key_bytes, u.id as user_id
943
+
FROM sessions s
944
+
JOIN users u ON s.did = u.did
945
+
JOIN user_keys k ON u.id = k.user_id
946
+
WHERE s.access_jwt = $1
947
+
"#,
948
+
)
949
+
.bind(&token)
950
+
.fetch_optional(&state.db)
951
+
.await;
952
+
953
+
let (_did, key_bytes, user_id) = match session {
954
+
Ok(Some(row)) => (
955
+
row.get::<String, _>("did"),
956
+
row.get::<Vec<u8>, _>("key_bytes"),
957
+
row.get::<uuid::Uuid, _>("user_id"),
958
+
),
959
+
Ok(None) => {
960
+
return (
961
+
StatusCode::UNAUTHORIZED,
962
+
Json(json!({"error": "AuthenticationFailed"})),
963
+
)
964
+
.into_response();
965
+
}
966
+
Err(e) => {
967
+
error!("DB error in revoke_app_password: {:?}", e);
968
+
return (
969
+
StatusCode::INTERNAL_SERVER_ERROR,
970
+
Json(json!({"error": "InternalError"})),
971
+
)
972
+
.into_response();
973
+
}
974
+
};
975
+
976
+
if let Err(_) = crate::auth::verify_token(&token, &key_bytes) {
977
+
return (
978
+
StatusCode::UNAUTHORIZED,
979
+
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token signature"})),
980
+
)
981
+
.into_response();
982
+
}
983
+
984
+
let name = input.name.trim();
985
+
if name.is_empty() {
986
+
return (
987
+
StatusCode::BAD_REQUEST,
988
+
Json(json!({"error": "InvalidRequest", "message": "name is required"})),
989
+
)
990
+
.into_response();
991
+
}
992
+
993
+
let result = sqlx::query("DELETE FROM app_passwords WHERE user_id = $1 AND name = $2")
994
+
.bind(user_id)
995
+
.bind(name)
996
+
.execute(&state.db)
997
+
.await;
998
+
999
+
match result {
1000
+
Ok(r) => {
1001
+
if r.rows_affected() == 0 {
1002
+
return (
1003
+
StatusCode::NOT_FOUND,
1004
+
Json(json!({"error": "AppPasswordNotFound", "message": "App password not found"})),
1005
+
)
1006
+
.into_response();
1007
+
}
1008
+
(StatusCode::OK, Json(json!({}))).into_response()
1009
+
}
1010
+
Err(e) => {
1011
+
error!("DB error revoking app password: {:?}", e);
1012
+
(
1013
+
StatusCode::INTERNAL_SERVER_ERROR,
1014
+
Json(json!({"error": "InternalError"})),
1015
+
)
1016
+
.into_response()
1017
+
}
1018
+
}
1019
+
}
+84
src/lib.rs
+84
src/lib.rs
···
86
86
"/xrpc/com.atproto.sync.listRepos",
87
87
get(sync::list_repos),
88
88
)
89
+
.route(
90
+
"/xrpc/com.atproto.sync.getBlob",
91
+
get(sync::get_blob),
92
+
)
93
+
.route(
94
+
"/xrpc/com.atproto.sync.listBlobs",
95
+
get(sync::list_blobs),
96
+
)
97
+
.route(
98
+
"/xrpc/com.atproto.sync.getRepoStatus",
99
+
get(sync::get_repo_status),
100
+
)
101
+
.route(
102
+
"/xrpc/com.atproto.server.checkAccountStatus",
103
+
get(api::server::check_account_status),
104
+
)
105
+
.route(
106
+
"/xrpc/com.atproto.identity.getRecommendedDidCredentials",
107
+
get(api::identity::get_recommended_did_credentials),
108
+
)
109
+
.route(
110
+
"/xrpc/com.atproto.repo.listMissingBlobs",
111
+
get(api::repo::list_missing_blobs),
112
+
)
113
+
.route(
114
+
"/xrpc/com.atproto.sync.notifyOfUpdate",
115
+
post(sync::notify_of_update),
116
+
)
117
+
.route(
118
+
"/xrpc/com.atproto.sync.requestCrawl",
119
+
post(sync::request_crawl),
120
+
)
121
+
.route(
122
+
"/xrpc/com.atproto.moderation.createReport",
123
+
post(api::moderation::create_report),
124
+
)
125
+
.route(
126
+
"/xrpc/com.atproto.admin.getAccountInfo",
127
+
get(api::admin::get_account_info),
128
+
)
129
+
.route(
130
+
"/xrpc/com.atproto.admin.getAccountInfos",
131
+
get(api::admin::get_account_infos),
132
+
)
133
+
.route(
134
+
"/xrpc/com.atproto.server.activateAccount",
135
+
post(api::server::activate_account),
136
+
)
137
+
.route(
138
+
"/xrpc/com.atproto.server.deactivateAccount",
139
+
post(api::server::deactivate_account),
140
+
)
141
+
.route(
142
+
"/xrpc/com.atproto.identity.updateHandle",
143
+
post(api::identity::update_handle),
144
+
)
145
+
.route(
146
+
"/xrpc/com.atproto.admin.deleteAccount",
147
+
post(api::admin::delete_account),
148
+
)
149
+
.route(
150
+
"/xrpc/com.atproto.admin.updateAccountEmail",
151
+
post(api::admin::update_account_email),
152
+
)
153
+
.route(
154
+
"/xrpc/com.atproto.admin.updateAccountHandle",
155
+
post(api::admin::update_account_handle),
156
+
)
157
+
.route(
158
+
"/xrpc/com.atproto.admin.updateAccountPassword",
159
+
post(api::admin::update_account_password),
160
+
)
161
+
.route(
162
+
"/xrpc/com.atproto.server.listAppPasswords",
163
+
get(api::server::list_app_passwords),
164
+
)
165
+
.route(
166
+
"/xrpc/com.atproto.server.createAppPassword",
167
+
post(api::server::create_app_password),
168
+
)
169
+
.route(
170
+
"/xrpc/com.atproto.server.revokeAppPassword",
171
+
post(api::server::revoke_app_password),
172
+
)
89
173
// I know I know, I'm not supposed to implement appview endpoints. Leave me be
90
174
.route(
91
175
"/xrpc/app.bsky.feed.getTimeline",
+318
-1
src/sync/mod.rs
+318
-1
src/sync/mod.rs
···
1
1
use crate::state::AppState;
2
2
use axum::{
3
3
Json,
4
+
body::Body,
4
5
extract::{Query, State},
5
6
http::StatusCode,
7
+
http::header,
6
8
response::{IntoResponse, Response},
7
9
};
8
10
use serde::{Deserialize, Serialize};
9
11
use serde_json::json;
10
12
use sqlx::Row;
11
-
use tracing::error;
13
+
use tracing::{error, info};
12
14
13
15
#[derive(Deserialize)]
14
16
pub struct GetLatestCommitParams {
···
161
163
}
162
164
}
163
165
}
166
+
167
+
#[derive(Deserialize)]
168
+
pub struct GetBlobParams {
169
+
pub did: String,
170
+
pub cid: String,
171
+
}
172
+
173
+
pub async fn get_blob(
174
+
State(state): State<AppState>,
175
+
Query(params): Query<GetBlobParams>,
176
+
) -> Response {
177
+
let did = params.did.trim();
178
+
let cid = params.cid.trim();
179
+
180
+
if did.is_empty() {
181
+
return (
182
+
StatusCode::BAD_REQUEST,
183
+
Json(json!({"error": "InvalidRequest", "message": "did is required"})),
184
+
)
185
+
.into_response();
186
+
}
187
+
188
+
if cid.is_empty() {
189
+
return (
190
+
StatusCode::BAD_REQUEST,
191
+
Json(json!({"error": "InvalidRequest", "message": "cid is required"})),
192
+
)
193
+
.into_response();
194
+
}
195
+
196
+
let user_exists = sqlx::query("SELECT id FROM users WHERE did = $1")
197
+
.bind(did)
198
+
.fetch_optional(&state.db)
199
+
.await;
200
+
201
+
match user_exists {
202
+
Ok(None) => {
203
+
return (
204
+
StatusCode::NOT_FOUND,
205
+
Json(json!({"error": "RepoNotFound", "message": "Could not find repo for DID"})),
206
+
)
207
+
.into_response();
208
+
}
209
+
Err(e) => {
210
+
error!("DB error in get_blob: {:?}", e);
211
+
return (
212
+
StatusCode::INTERNAL_SERVER_ERROR,
213
+
Json(json!({"error": "InternalError"})),
214
+
)
215
+
.into_response();
216
+
}
217
+
Ok(Some(_)) => {}
218
+
}
219
+
220
+
let blob_result = sqlx::query("SELECT storage_key, mime_type FROM blobs WHERE cid = $1")
221
+
.bind(cid)
222
+
.fetch_optional(&state.db)
223
+
.await;
224
+
225
+
match blob_result {
226
+
Ok(Some(row)) => {
227
+
let storage_key: String = row.get("storage_key");
228
+
let mime_type: String = row.get("mime_type");
229
+
230
+
match state.blob_store.get(&storage_key).await {
231
+
Ok(data) => Response::builder()
232
+
.status(StatusCode::OK)
233
+
.header(header::CONTENT_TYPE, mime_type)
234
+
.body(Body::from(data))
235
+
.unwrap(),
236
+
Err(e) => {
237
+
error!("Failed to fetch blob from storage: {:?}", e);
238
+
(
239
+
StatusCode::NOT_FOUND,
240
+
Json(json!({"error": "BlobNotFound", "message": "Blob not found in storage"})),
241
+
)
242
+
.into_response()
243
+
}
244
+
}
245
+
}
246
+
Ok(None) => (
247
+
StatusCode::NOT_FOUND,
248
+
Json(json!({"error": "BlobNotFound", "message": "Blob not found"})),
249
+
)
250
+
.into_response(),
251
+
Err(e) => {
252
+
error!("DB error in get_blob: {:?}", e);
253
+
(
254
+
StatusCode::INTERNAL_SERVER_ERROR,
255
+
Json(json!({"error": "InternalError"})),
256
+
)
257
+
.into_response()
258
+
}
259
+
}
260
+
}
261
+
262
+
#[derive(Deserialize)]
263
+
pub struct ListBlobsParams {
264
+
pub did: String,
265
+
pub since: Option<String>,
266
+
pub limit: Option<i64>,
267
+
pub cursor: Option<String>,
268
+
}
269
+
270
+
#[derive(Serialize)]
271
+
pub struct ListBlobsOutput {
272
+
pub cursor: Option<String>,
273
+
pub cids: Vec<String>,
274
+
}
275
+
276
+
pub async fn list_blobs(
277
+
State(state): State<AppState>,
278
+
Query(params): Query<ListBlobsParams>,
279
+
) -> Response {
280
+
let did = params.did.trim();
281
+
282
+
if did.is_empty() {
283
+
return (
284
+
StatusCode::BAD_REQUEST,
285
+
Json(json!({"error": "InvalidRequest", "message": "did is required"})),
286
+
)
287
+
.into_response();
288
+
}
289
+
290
+
let limit = params.limit.unwrap_or(500).min(1000);
291
+
let cursor_cid = params.cursor.as_deref().unwrap_or("");
292
+
293
+
let user_result = sqlx::query("SELECT id FROM users WHERE did = $1")
294
+
.bind(did)
295
+
.fetch_optional(&state.db)
296
+
.await;
297
+
298
+
let user_id: uuid::Uuid = match user_result {
299
+
Ok(Some(row)) => row.get("id"),
300
+
Ok(None) => {
301
+
return (
302
+
StatusCode::NOT_FOUND,
303
+
Json(json!({"error": "RepoNotFound", "message": "Could not find repo for DID"})),
304
+
)
305
+
.into_response();
306
+
}
307
+
Err(e) => {
308
+
error!("DB error in list_blobs: {:?}", e);
309
+
return (
310
+
StatusCode::INTERNAL_SERVER_ERROR,
311
+
Json(json!({"error": "InternalError"})),
312
+
)
313
+
.into_response();
314
+
}
315
+
};
316
+
317
+
let result = if let Some(since) = ¶ms.since {
318
+
sqlx::query(
319
+
r#"
320
+
SELECT cid FROM blobs
321
+
WHERE created_by_user = $1 AND cid > $2 AND created_at > $3
322
+
ORDER BY cid ASC
323
+
LIMIT $4
324
+
"#,
325
+
)
326
+
.bind(user_id)
327
+
.bind(cursor_cid)
328
+
.bind(since)
329
+
.bind(limit + 1)
330
+
.fetch_all(&state.db)
331
+
.await
332
+
} else {
333
+
sqlx::query(
334
+
r#"
335
+
SELECT cid FROM blobs
336
+
WHERE created_by_user = $1 AND cid > $2
337
+
ORDER BY cid ASC
338
+
LIMIT $3
339
+
"#,
340
+
)
341
+
.bind(user_id)
342
+
.bind(cursor_cid)
343
+
.bind(limit + 1)
344
+
.fetch_all(&state.db)
345
+
.await
346
+
};
347
+
348
+
match result {
349
+
Ok(rows) => {
350
+
let has_more = rows.len() as i64 > limit;
351
+
let cids: Vec<String> = rows
352
+
.iter()
353
+
.take(limit as usize)
354
+
.map(|row| row.get("cid"))
355
+
.collect();
356
+
357
+
let next_cursor = if has_more {
358
+
cids.last().cloned()
359
+
} else {
360
+
None
361
+
};
362
+
363
+
(
364
+
StatusCode::OK,
365
+
Json(ListBlobsOutput {
366
+
cursor: next_cursor,
367
+
cids,
368
+
}),
369
+
)
370
+
.into_response()
371
+
}
372
+
Err(e) => {
373
+
error!("DB error in list_blobs: {:?}", e);
374
+
(
375
+
StatusCode::INTERNAL_SERVER_ERROR,
376
+
Json(json!({"error": "InternalError"})),
377
+
)
378
+
.into_response()
379
+
}
380
+
}
381
+
}
382
+
383
+
#[derive(Deserialize)]
384
+
pub struct GetRepoStatusParams {
385
+
pub did: String,
386
+
}
387
+
388
+
#[derive(Serialize)]
389
+
pub struct GetRepoStatusOutput {
390
+
pub did: String,
391
+
pub active: bool,
392
+
pub rev: Option<String>,
393
+
}
394
+
395
+
pub async fn get_repo_status(
396
+
State(state): State<AppState>,
397
+
Query(params): Query<GetRepoStatusParams>,
398
+
) -> Response {
399
+
let did = params.did.trim();
400
+
401
+
if did.is_empty() {
402
+
return (
403
+
StatusCode::BAD_REQUEST,
404
+
Json(json!({"error": "InvalidRequest", "message": "did is required"})),
405
+
)
406
+
.into_response();
407
+
}
408
+
409
+
let result = sqlx::query(
410
+
r#"
411
+
SELECT u.did, r.repo_root_cid
412
+
FROM users u
413
+
LEFT JOIN repos r ON u.id = r.user_id
414
+
WHERE u.did = $1
415
+
"#,
416
+
)
417
+
.bind(did)
418
+
.fetch_optional(&state.db)
419
+
.await;
420
+
421
+
match result {
422
+
Ok(Some(row)) => {
423
+
let user_did: String = row.get("did");
424
+
let repo_root: Option<String> = row.get("repo_root_cid");
425
+
426
+
let rev = repo_root.map(|_| chrono::Utc::now().timestamp_millis().to_string());
427
+
428
+
(
429
+
StatusCode::OK,
430
+
Json(GetRepoStatusOutput {
431
+
did: user_did,
432
+
active: true,
433
+
rev,
434
+
}),
435
+
)
436
+
.into_response()
437
+
}
438
+
Ok(None) => (
439
+
StatusCode::NOT_FOUND,
440
+
Json(json!({"error": "RepoNotFound", "message": "Could not find repo for DID"})),
441
+
)
442
+
.into_response(),
443
+
Err(e) => {
444
+
error!("DB error in get_repo_status: {:?}", e);
445
+
(
446
+
StatusCode::INTERNAL_SERVER_ERROR,
447
+
Json(json!({"error": "InternalError"})),
448
+
)
449
+
.into_response()
450
+
}
451
+
}
452
+
}
453
+
454
+
#[derive(Deserialize)]
455
+
pub struct NotifyOfUpdateParams {
456
+
pub hostname: String,
457
+
}
458
+
459
+
pub async fn notify_of_update(
460
+
State(_state): State<AppState>,
461
+
Query(params): Query<NotifyOfUpdateParams>,
462
+
) -> Response {
463
+
info!("Received notifyOfUpdate from hostname: {}", params.hostname);
464
+
465
+
(StatusCode::OK, Json(json!({}))).into_response()
466
+
}
467
+
468
+
#[derive(Deserialize)]
469
+
pub struct RequestCrawlInput {
470
+
pub hostname: String,
471
+
}
472
+
473
+
pub async fn request_crawl(
474
+
State(_state): State<AppState>,
475
+
Json(input): Json<RequestCrawlInput>,
476
+
) -> Response {
477
+
info!("Received requestCrawl for hostname: {}", input.hostname);
478
+
479
+
(StatusCode::OK, Json(json!({}))).into_response()
480
+
}
+52
tests/identity.rs
+52
tests/identity.rs
···
304
304
assert_eq!(record_body["value"]["displayName"], "DID Web User");
305
305
*/
306
306
}
307
+
308
+
#[tokio::test]
309
+
async fn test_get_recommended_did_credentials_success() {
310
+
let client = client();
311
+
let (access_jwt, _) = create_account_and_login(&client).await;
312
+
313
+
let res = client
314
+
.get(format!(
315
+
"{}/xrpc/com.atproto.identity.getRecommendedDidCredentials",
316
+
base_url().await
317
+
))
318
+
.bearer_auth(&access_jwt)
319
+
.send()
320
+
.await
321
+
.expect("Failed to send request");
322
+
323
+
assert_eq!(res.status(), StatusCode::OK);
324
+
let body: Value = res.json().await.expect("Response was not valid JSON");
325
+
assert!(body["rotationKeys"].is_array());
326
+
assert!(body["alsoKnownAs"].is_array());
327
+
assert!(body["verificationMethods"].is_object());
328
+
assert!(body["services"].is_object());
329
+
330
+
let rotation_keys = body["rotationKeys"].as_array().unwrap();
331
+
assert!(!rotation_keys.is_empty());
332
+
assert!(rotation_keys[0].as_str().unwrap().starts_with("did:key:"));
333
+
334
+
let also_known_as = body["alsoKnownAs"].as_array().unwrap();
335
+
assert!(!also_known_as.is_empty());
336
+
assert!(also_known_as[0].as_str().unwrap().starts_with("at://"));
337
+
338
+
assert!(body["verificationMethods"]["atproto"].is_string());
339
+
assert_eq!(body["services"]["atprotoPds"]["type"], "AtprotoPersonalDataServer");
340
+
assert!(body["services"]["atprotoPds"]["endpoint"].is_string());
341
+
}
342
+
343
+
#[tokio::test]
344
+
async fn test_get_recommended_did_credentials_no_auth() {
345
+
let client = client();
346
+
let res = client
347
+
.get(format!(
348
+
"{}/xrpc/com.atproto.identity.getRecommendedDidCredentials",
349
+
base_url().await
350
+
))
351
+
.send()
352
+
.await
353
+
.expect("Failed to send request");
354
+
355
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
356
+
let body: Value = res.json().await.expect("Response was not valid JSON");
357
+
assert_eq!(body["error"], "AuthenticationRequired");
358
+
}
+222
tests/lifecycle.rs
+222
tests/lifecycle.rs
···
1620
1620
assert_eq!(describe_body["did"], did);
1621
1621
assert_eq!(describe_body["handle"], handle);
1622
1622
}
1623
+
1624
+
#[tokio::test]
1625
+
async fn test_app_password_lifecycle() {
1626
+
let client = client();
1627
+
let ts = Utc::now().timestamp_millis();
1628
+
let handle = format!("apppass-{}.test", ts);
1629
+
let email = format!("apppass-{}@test.com", ts);
1630
+
let password = "apppass-password";
1631
+
1632
+
let create_res = client
1633
+
.post(format!(
1634
+
"{}/xrpc/com.atproto.server.createAccount",
1635
+
base_url().await
1636
+
))
1637
+
.json(&json!({
1638
+
"handle": handle,
1639
+
"email": email,
1640
+
"password": password
1641
+
}))
1642
+
.send()
1643
+
.await
1644
+
.expect("Failed to create account");
1645
+
1646
+
assert_eq!(create_res.status(), StatusCode::OK);
1647
+
let account: Value = create_res.json().await.unwrap();
1648
+
let jwt = account["accessJwt"].as_str().unwrap();
1649
+
1650
+
let create_app_pass_res = client
1651
+
.post(format!(
1652
+
"{}/xrpc/com.atproto.server.createAppPassword",
1653
+
base_url().await
1654
+
))
1655
+
.bearer_auth(jwt)
1656
+
.json(&json!({ "name": "Test App" }))
1657
+
.send()
1658
+
.await
1659
+
.expect("Failed to create app password");
1660
+
1661
+
assert_eq!(create_app_pass_res.status(), StatusCode::OK);
1662
+
let app_pass: Value = create_app_pass_res.json().await.unwrap();
1663
+
let app_password = app_pass["password"].as_str().unwrap().to_string();
1664
+
assert_eq!(app_pass["name"], "Test App");
1665
+
1666
+
let list_res = client
1667
+
.get(format!(
1668
+
"{}/xrpc/com.atproto.server.listAppPasswords",
1669
+
base_url().await
1670
+
))
1671
+
.bearer_auth(jwt)
1672
+
.send()
1673
+
.await
1674
+
.expect("Failed to list app passwords");
1675
+
1676
+
assert_eq!(list_res.status(), StatusCode::OK);
1677
+
let list_body: Value = list_res.json().await.unwrap();
1678
+
let passwords = list_body["passwords"].as_array().unwrap();
1679
+
assert_eq!(passwords.len(), 1);
1680
+
assert_eq!(passwords[0]["name"], "Test App");
1681
+
1682
+
let login_res = client
1683
+
.post(format!(
1684
+
"{}/xrpc/com.atproto.server.createSession",
1685
+
base_url().await
1686
+
))
1687
+
.json(&json!({
1688
+
"identifier": handle,
1689
+
"password": app_password
1690
+
}))
1691
+
.send()
1692
+
.await
1693
+
.expect("Failed to login with app password");
1694
+
1695
+
assert_eq!(login_res.status(), StatusCode::OK, "App password login should work");
1696
+
1697
+
let revoke_res = client
1698
+
.post(format!(
1699
+
"{}/xrpc/com.atproto.server.revokeAppPassword",
1700
+
base_url().await
1701
+
))
1702
+
.bearer_auth(jwt)
1703
+
.json(&json!({ "name": "Test App" }))
1704
+
.send()
1705
+
.await
1706
+
.expect("Failed to revoke app password");
1707
+
1708
+
assert_eq!(revoke_res.status(), StatusCode::OK);
1709
+
1710
+
let login_after_revoke = client
1711
+
.post(format!(
1712
+
"{}/xrpc/com.atproto.server.createSession",
1713
+
base_url().await
1714
+
))
1715
+
.json(&json!({
1716
+
"identifier": handle,
1717
+
"password": app_password
1718
+
}))
1719
+
.send()
1720
+
.await
1721
+
.expect("Failed to attempt login after revoke");
1722
+
1723
+
assert!(
1724
+
login_after_revoke.status() == StatusCode::UNAUTHORIZED
1725
+
|| login_after_revoke.status() == StatusCode::BAD_REQUEST,
1726
+
"Revoked app password should not work"
1727
+
);
1728
+
1729
+
let list_after_revoke = client
1730
+
.get(format!(
1731
+
"{}/xrpc/com.atproto.server.listAppPasswords",
1732
+
base_url().await
1733
+
))
1734
+
.bearer_auth(jwt)
1735
+
.send()
1736
+
.await
1737
+
.expect("Failed to list after revoke");
1738
+
1739
+
let list_after: Value = list_after_revoke.json().await.unwrap();
1740
+
let passwords_after = list_after["passwords"].as_array().unwrap();
1741
+
assert_eq!(passwords_after.len(), 0, "No app passwords should remain");
1742
+
}
1743
+
1744
+
#[tokio::test]
1745
+
async fn test_account_deactivation_lifecycle() {
1746
+
let client = client();
1747
+
let ts = Utc::now().timestamp_millis();
1748
+
let handle = format!("deactivate-{}.test", ts);
1749
+
let email = format!("deactivate-{}@test.com", ts);
1750
+
let password = "deactivate-password";
1751
+
1752
+
let create_res = client
1753
+
.post(format!(
1754
+
"{}/xrpc/com.atproto.server.createAccount",
1755
+
base_url().await
1756
+
))
1757
+
.json(&json!({
1758
+
"handle": handle,
1759
+
"email": email,
1760
+
"password": password
1761
+
}))
1762
+
.send()
1763
+
.await
1764
+
.expect("Failed to create account");
1765
+
1766
+
assert_eq!(create_res.status(), StatusCode::OK);
1767
+
let account: Value = create_res.json().await.unwrap();
1768
+
let did = account["did"].as_str().unwrap().to_string();
1769
+
let jwt = account["accessJwt"].as_str().unwrap().to_string();
1770
+
1771
+
let (post_uri, _) = create_post(&client, &did, &jwt, "Post before deactivation").await;
1772
+
let post_rkey = post_uri.split('/').last().unwrap();
1773
+
1774
+
let status_before = client
1775
+
.get(format!(
1776
+
"{}/xrpc/com.atproto.server.checkAccountStatus",
1777
+
base_url().await
1778
+
))
1779
+
.bearer_auth(&jwt)
1780
+
.send()
1781
+
.await
1782
+
.expect("Failed to check status");
1783
+
1784
+
assert_eq!(status_before.status(), StatusCode::OK);
1785
+
let status_body: Value = status_before.json().await.unwrap();
1786
+
assert_eq!(status_body["activated"], true);
1787
+
1788
+
let deactivate_res = client
1789
+
.post(format!(
1790
+
"{}/xrpc/com.atproto.server.deactivateAccount",
1791
+
base_url().await
1792
+
))
1793
+
.bearer_auth(&jwt)
1794
+
.json(&json!({}))
1795
+
.send()
1796
+
.await
1797
+
.expect("Failed to deactivate");
1798
+
1799
+
assert_eq!(deactivate_res.status(), StatusCode::OK);
1800
+
1801
+
let get_post_res = client
1802
+
.get(format!(
1803
+
"{}/xrpc/com.atproto.repo.getRecord",
1804
+
base_url().await
1805
+
))
1806
+
.query(&[
1807
+
("repo", did.as_str()),
1808
+
("collection", "app.bsky.feed.post"),
1809
+
("rkey", post_rkey),
1810
+
])
1811
+
.send()
1812
+
.await
1813
+
.expect("Failed to get post while deactivated");
1814
+
1815
+
assert_eq!(get_post_res.status(), StatusCode::OK, "Records should still be readable");
1816
+
1817
+
let activate_res = client
1818
+
.post(format!(
1819
+
"{}/xrpc/com.atproto.server.activateAccount",
1820
+
base_url().await
1821
+
))
1822
+
.bearer_auth(&jwt)
1823
+
.json(&json!({}))
1824
+
.send()
1825
+
.await
1826
+
.expect("Failed to reactivate");
1827
+
1828
+
assert_eq!(activate_res.status(), StatusCode::OK);
1829
+
1830
+
let status_after_activate = client
1831
+
.get(format!(
1832
+
"{}/xrpc/com.atproto.server.checkAccountStatus",
1833
+
base_url().await
1834
+
))
1835
+
.bearer_auth(&jwt)
1836
+
.send()
1837
+
.await
1838
+
.expect("Failed to check status after activate");
1839
+
1840
+
assert_eq!(status_after_activate.status(), StatusCode::OK);
1841
+
1842
+
let (new_post_uri, _) = create_post(&client, &did, &jwt, "Post after reactivation").await;
1843
+
assert!(!new_post_uri.is_empty(), "Should be able to post after reactivation");
1844
+
}
+35
tests/repo.rs
+35
tests/repo.rs
···
757
757
758
758
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
759
759
}
760
+
761
+
#[tokio::test]
762
+
async fn test_list_missing_blobs() {
763
+
let client = client();
764
+
let (access_jwt, _) = create_account_and_login(&client).await;
765
+
766
+
let res = client
767
+
.get(format!(
768
+
"{}/xrpc/com.atproto.repo.listMissingBlobs",
769
+
base_url().await
770
+
))
771
+
.bearer_auth(&access_jwt)
772
+
.send()
773
+
.await
774
+
.expect("Failed to send request");
775
+
776
+
assert_eq!(res.status(), StatusCode::OK);
777
+
let body: Value = res.json().await.expect("Response was not valid JSON");
778
+
assert!(body["blobs"].is_array());
779
+
}
780
+
781
+
#[tokio::test]
782
+
async fn test_list_missing_blobs_no_auth() {
783
+
let client = client();
784
+
let res = client
785
+
.get(format!(
786
+
"{}/xrpc/com.atproto.repo.listMissingBlobs",
787
+
base_url().await
788
+
))
789
+
.send()
790
+
.await
791
+
.expect("Failed to send request");
792
+
793
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
794
+
}
+93
tests/server.rs
+93
tests/server.rs
···
317
317
318
318
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
319
319
}
320
+
321
+
#[tokio::test]
322
+
async fn test_check_account_status_success() {
323
+
let client = client();
324
+
let (access_jwt, _) = create_account_and_login(&client).await;
325
+
326
+
let res = client
327
+
.get(format!(
328
+
"{}/xrpc/com.atproto.server.checkAccountStatus",
329
+
base_url().await
330
+
))
331
+
.bearer_auth(&access_jwt)
332
+
.send()
333
+
.await
334
+
.expect("Failed to send request");
335
+
336
+
assert_eq!(res.status(), StatusCode::OK);
337
+
let body: Value = res.json().await.expect("Response was not valid JSON");
338
+
assert_eq!(body["activated"], true);
339
+
assert_eq!(body["validDid"], true);
340
+
assert!(body["repoCommit"].is_string());
341
+
assert!(body["repoRev"].is_string());
342
+
assert!(body["indexedRecords"].is_number());
343
+
}
344
+
345
+
#[tokio::test]
346
+
async fn test_check_account_status_no_auth() {
347
+
let client = client();
348
+
let res = client
349
+
.get(format!(
350
+
"{}/xrpc/com.atproto.server.checkAccountStatus",
351
+
base_url().await
352
+
))
353
+
.send()
354
+
.await
355
+
.expect("Failed to send request");
356
+
357
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
358
+
let body: Value = res.json().await.expect("Response was not valid JSON");
359
+
assert_eq!(body["error"], "AuthenticationRequired");
360
+
}
361
+
362
+
#[tokio::test]
363
+
async fn test_activate_account_success() {
364
+
let client = client();
365
+
let (access_jwt, _) = create_account_and_login(&client).await;
366
+
367
+
let res = client
368
+
.post(format!(
369
+
"{}/xrpc/com.atproto.server.activateAccount",
370
+
base_url().await
371
+
))
372
+
.bearer_auth(&access_jwt)
373
+
.send()
374
+
.await
375
+
.expect("Failed to send request");
376
+
377
+
assert_eq!(res.status(), StatusCode::OK);
378
+
}
379
+
380
+
#[tokio::test]
381
+
async fn test_activate_account_no_auth() {
382
+
let client = client();
383
+
let res = client
384
+
.post(format!(
385
+
"{}/xrpc/com.atproto.server.activateAccount",
386
+
base_url().await
387
+
))
388
+
.send()
389
+
.await
390
+
.expect("Failed to send request");
391
+
392
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
393
+
}
394
+
395
+
#[tokio::test]
396
+
async fn test_deactivate_account_success() {
397
+
let client = client();
398
+
let (access_jwt, _) = create_account_and_login(&client).await;
399
+
400
+
let res = client
401
+
.post(format!(
402
+
"{}/xrpc/com.atproto.server.deactivateAccount",
403
+
base_url().await
404
+
))
405
+
.bearer_auth(&access_jwt)
406
+
.json(&json!({}))
407
+
.send()
408
+
.await
409
+
.expect("Failed to send request");
410
+
411
+
assert_eq!(res.status(), StatusCode::OK);
412
+
}
+201
tests/sync.rs
+201
tests/sync.rs
···
1
1
mod common;
2
2
use common::*;
3
3
use reqwest::StatusCode;
4
+
use reqwest::header;
4
5
use serde_json::Value;
5
6
6
7
#[tokio::test]
···
151
152
assert_ne!(repos[0]["did"], repos2[0]["did"]);
152
153
}
153
154
}
155
+
156
+
#[tokio::test]
157
+
async fn test_get_repo_status_success() {
158
+
let client = client();
159
+
let (_, did) = create_account_and_login(&client).await;
160
+
161
+
let params = [("did", did.as_str())];
162
+
let res = client
163
+
.get(format!(
164
+
"{}/xrpc/com.atproto.sync.getRepoStatus",
165
+
base_url().await
166
+
))
167
+
.query(¶ms)
168
+
.send()
169
+
.await
170
+
.expect("Failed to send request");
171
+
172
+
assert_eq!(res.status(), StatusCode::OK);
173
+
let body: Value = res.json().await.expect("Response was not valid JSON");
174
+
assert_eq!(body["did"], did);
175
+
assert_eq!(body["active"], true);
176
+
assert!(body["rev"].is_string());
177
+
}
178
+
179
+
#[tokio::test]
180
+
async fn test_get_repo_status_not_found() {
181
+
let client = client();
182
+
let params = [("did", "did:plc:nonexistent12345")];
183
+
let res = client
184
+
.get(format!(
185
+
"{}/xrpc/com.atproto.sync.getRepoStatus",
186
+
base_url().await
187
+
))
188
+
.query(¶ms)
189
+
.send()
190
+
.await
191
+
.expect("Failed to send request");
192
+
193
+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
194
+
let body: Value = res.json().await.expect("Response was not valid JSON");
195
+
assert_eq!(body["error"], "RepoNotFound");
196
+
}
197
+
198
+
#[tokio::test]
199
+
async fn test_list_blobs_success() {
200
+
let client = client();
201
+
let (access_jwt, did) = create_account_and_login(&client).await;
202
+
203
+
let blob_res = client
204
+
.post(format!(
205
+
"{}/xrpc/com.atproto.repo.uploadBlob",
206
+
base_url().await
207
+
))
208
+
.header(header::CONTENT_TYPE, "text/plain")
209
+
.bearer_auth(&access_jwt)
210
+
.body("test blob content")
211
+
.send()
212
+
.await
213
+
.expect("Failed to upload blob");
214
+
215
+
assert_eq!(blob_res.status(), StatusCode::OK);
216
+
217
+
let params = [("did", did.as_str())];
218
+
let res = client
219
+
.get(format!(
220
+
"{}/xrpc/com.atproto.sync.listBlobs",
221
+
base_url().await
222
+
))
223
+
.query(¶ms)
224
+
.send()
225
+
.await
226
+
.expect("Failed to send request");
227
+
228
+
assert_eq!(res.status(), StatusCode::OK);
229
+
let body: Value = res.json().await.expect("Response was not valid JSON");
230
+
assert!(body["cids"].is_array());
231
+
let cids = body["cids"].as_array().unwrap();
232
+
assert!(!cids.is_empty());
233
+
}
234
+
235
+
#[tokio::test]
236
+
async fn test_list_blobs_not_found() {
237
+
let client = client();
238
+
let params = [("did", "did:plc:nonexistent12345")];
239
+
let res = client
240
+
.get(format!(
241
+
"{}/xrpc/com.atproto.sync.listBlobs",
242
+
base_url().await
243
+
))
244
+
.query(¶ms)
245
+
.send()
246
+
.await
247
+
.expect("Failed to send request");
248
+
249
+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
250
+
let body: Value = res.json().await.expect("Response was not valid JSON");
251
+
assert_eq!(body["error"], "RepoNotFound");
252
+
}
253
+
254
+
#[tokio::test]
255
+
async fn test_get_blob_success() {
256
+
let client = client();
257
+
let (access_jwt, did) = create_account_and_login(&client).await;
258
+
259
+
let blob_content = "test blob for get_blob";
260
+
let blob_res = client
261
+
.post(format!(
262
+
"{}/xrpc/com.atproto.repo.uploadBlob",
263
+
base_url().await
264
+
))
265
+
.header(header::CONTENT_TYPE, "text/plain")
266
+
.bearer_auth(&access_jwt)
267
+
.body(blob_content)
268
+
.send()
269
+
.await
270
+
.expect("Failed to upload blob");
271
+
272
+
assert_eq!(blob_res.status(), StatusCode::OK);
273
+
let blob_body: Value = blob_res.json().await.expect("Response was not valid JSON");
274
+
let cid = blob_body["blob"]["ref"]["$link"].as_str().expect("No CID");
275
+
276
+
let params = [("did", did.as_str()), ("cid", cid)];
277
+
let res = client
278
+
.get(format!(
279
+
"{}/xrpc/com.atproto.sync.getBlob",
280
+
base_url().await
281
+
))
282
+
.query(¶ms)
283
+
.send()
284
+
.await
285
+
.expect("Failed to send request");
286
+
287
+
assert_eq!(res.status(), StatusCode::OK);
288
+
assert_eq!(
289
+
res.headers()
290
+
.get("content-type")
291
+
.and_then(|h| h.to_str().ok()),
292
+
Some("text/plain")
293
+
);
294
+
let body = res.text().await.expect("Failed to get body");
295
+
assert_eq!(body, blob_content);
296
+
}
297
+
298
+
#[tokio::test]
299
+
async fn test_get_blob_not_found() {
300
+
let client = client();
301
+
let (_, did) = create_account_and_login(&client).await;
302
+
303
+
let params = [
304
+
("did", did.as_str()),
305
+
("cid", "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"),
306
+
];
307
+
let res = client
308
+
.get(format!(
309
+
"{}/xrpc/com.atproto.sync.getBlob",
310
+
base_url().await
311
+
))
312
+
.query(¶ms)
313
+
.send()
314
+
.await
315
+
.expect("Failed to send request");
316
+
317
+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
318
+
let body: Value = res.json().await.expect("Response was not valid JSON");
319
+
assert_eq!(body["error"], "BlobNotFound");
320
+
}
321
+
322
+
#[tokio::test]
323
+
async fn test_notify_of_update() {
324
+
let client = client();
325
+
let params = [("hostname", "example.com")];
326
+
let res = client
327
+
.post(format!(
328
+
"{}/xrpc/com.atproto.sync.notifyOfUpdate",
329
+
base_url().await
330
+
))
331
+
.query(¶ms)
332
+
.send()
333
+
.await
334
+
.expect("Failed to send request");
335
+
336
+
assert_eq!(res.status(), StatusCode::OK);
337
+
}
338
+
339
+
#[tokio::test]
340
+
async fn test_request_crawl() {
341
+
let client = client();
342
+
let payload = serde_json::json!({"hostname": "example.com"});
343
+
let res = client
344
+
.post(format!(
345
+
"{}/xrpc/com.atproto.sync.requestCrawl",
346
+
base_url().await
347
+
))
348
+
.json(&payload)
349
+
.send()
350
+
.await
351
+
.expect("Failed to send request");
352
+
353
+
assert_eq!(res.status(), StatusCode::OK);
354
+
}