···11+CREATE TABLE IF NOT EXISTS app_passwords (
22+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
33+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
44+ name TEXT NOT NULL,
55+ password_hash TEXT NOT NULL,
66+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
77+ privileged BOOLEAN NOT NULL DEFAULT FALSE,
88+ UNIQUE(user_id, name)
99+);
+484
src/api/admin/mod.rs
···11+use crate::state::AppState;
22+use axum::{
33+ Json,
44+ extract::{Query, State},
55+ http::StatusCode,
66+ response::{IntoResponse, Response},
77+};
88+use serde::{Deserialize, Serialize};
99+use serde_json::json;
1010+use sqlx::Row;
1111+use tracing::error;
1212+1313+#[derive(Deserialize)]
1414+pub struct GetAccountInfoParams {
1515+ pub did: String,
1616+}
1717+1818+#[derive(Serialize)]
1919+#[serde(rename_all = "camelCase")]
2020+pub struct AccountInfo {
2121+ pub did: String,
2222+ pub handle: String,
2323+ pub email: Option<String>,
2424+ pub indexed_at: String,
2525+ pub invite_note: Option<String>,
2626+ pub invites_disabled: bool,
2727+ pub email_confirmed_at: Option<String>,
2828+ pub deactivated_at: Option<String>,
2929+}
3030+3131+#[derive(Serialize)]
3232+#[serde(rename_all = "camelCase")]
3333+pub struct GetAccountInfosOutput {
3434+ pub infos: Vec<AccountInfo>,
3535+}
3636+3737+pub async fn get_account_info(
3838+ State(state): State<AppState>,
3939+ headers: axum::http::HeaderMap,
4040+ Query(params): Query<GetAccountInfoParams>,
4141+) -> Response {
4242+ let auth_header = headers.get("Authorization");
4343+ if auth_header.is_none() {
4444+ return (
4545+ StatusCode::UNAUTHORIZED,
4646+ Json(json!({"error": "AuthenticationRequired"})),
4747+ )
4848+ .into_response();
4949+ }
5050+5151+ let did = params.did.trim();
5252+ if did.is_empty() {
5353+ return (
5454+ StatusCode::BAD_REQUEST,
5555+ Json(json!({"error": "InvalidRequest", "message": "did is required"})),
5656+ )
5757+ .into_response();
5858+ }
5959+6060+ let result = sqlx::query(
6161+ r#"
6262+ SELECT did, handle, email, created_at
6363+ FROM users
6464+ WHERE did = $1
6565+ "#,
6666+ )
6767+ .bind(did)
6868+ .fetch_optional(&state.db)
6969+ .await;
7070+7171+ match result {
7272+ Ok(Some(row)) => {
7373+ let user_did: String = row.get("did");
7474+ let handle: String = row.get("handle");
7575+ let email: String = row.get("email");
7676+ let created_at: chrono::DateTime<chrono::Utc> = row.get("created_at");
7777+7878+ (
7979+ StatusCode::OK,
8080+ Json(AccountInfo {
8181+ did: user_did,
8282+ handle,
8383+ email: Some(email),
8484+ indexed_at: created_at.to_rfc3339(),
8585+ invite_note: None,
8686+ invites_disabled: false,
8787+ email_confirmed_at: None,
8888+ deactivated_at: None,
8989+ }),
9090+ )
9191+ .into_response()
9292+ }
9393+ Ok(None) => (
9494+ StatusCode::NOT_FOUND,
9595+ Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
9696+ )
9797+ .into_response(),
9898+ Err(e) => {
9999+ error!("DB error in get_account_info: {:?}", e);
100100+ (
101101+ StatusCode::INTERNAL_SERVER_ERROR,
102102+ Json(json!({"error": "InternalError"})),
103103+ )
104104+ .into_response()
105105+ }
106106+ }
107107+}
108108+109109+#[derive(Deserialize)]
110110+pub struct GetAccountInfosParams {
111111+ pub dids: String,
112112+}
113113+114114+pub async fn get_account_infos(
115115+ State(state): State<AppState>,
116116+ headers: axum::http::HeaderMap,
117117+ Query(params): Query<GetAccountInfosParams>,
118118+) -> Response {
119119+ let auth_header = headers.get("Authorization");
120120+ if auth_header.is_none() {
121121+ return (
122122+ StatusCode::UNAUTHORIZED,
123123+ Json(json!({"error": "AuthenticationRequired"})),
124124+ )
125125+ .into_response();
126126+ }
127127+128128+ let dids: Vec<&str> = params.dids.split(',').map(|s| s.trim()).collect();
129129+ if dids.is_empty() {
130130+ return (
131131+ StatusCode::BAD_REQUEST,
132132+ Json(json!({"error": "InvalidRequest", "message": "dids is required"})),
133133+ )
134134+ .into_response();
135135+ }
136136+137137+ let mut infos = Vec::new();
138138+139139+ for did in dids {
140140+ if did.is_empty() {
141141+ continue;
142142+ }
143143+144144+ let result = sqlx::query(
145145+ r#"
146146+ SELECT did, handle, email, created_at
147147+ FROM users
148148+ WHERE did = $1
149149+ "#,
150150+ )
151151+ .bind(did)
152152+ .fetch_optional(&state.db)
153153+ .await;
154154+155155+ if let Ok(Some(row)) = result {
156156+ let user_did: String = row.get("did");
157157+ let handle: String = row.get("handle");
158158+ let email: String = row.get("email");
159159+ let created_at: chrono::DateTime<chrono::Utc> = row.get("created_at");
160160+161161+ infos.push(AccountInfo {
162162+ did: user_did,
163163+ handle,
164164+ email: Some(email),
165165+ indexed_at: created_at.to_rfc3339(),
166166+ invite_note: None,
167167+ invites_disabled: false,
168168+ email_confirmed_at: None,
169169+ deactivated_at: None,
170170+ });
171171+ }
172172+ }
173173+174174+ (StatusCode::OK, Json(GetAccountInfosOutput { infos })).into_response()
175175+}
176176+177177+#[derive(Deserialize)]
178178+pub struct DeleteAccountInput {
179179+ pub did: String,
180180+}
181181+182182+pub async fn delete_account(
183183+ State(state): State<AppState>,
184184+ headers: axum::http::HeaderMap,
185185+ Json(input): Json<DeleteAccountInput>,
186186+) -> Response {
187187+ let auth_header = headers.get("Authorization");
188188+ if auth_header.is_none() {
189189+ return (
190190+ StatusCode::UNAUTHORIZED,
191191+ Json(json!({"error": "AuthenticationRequired"})),
192192+ )
193193+ .into_response();
194194+ }
195195+196196+ let did = input.did.trim();
197197+ if did.is_empty() {
198198+ return (
199199+ StatusCode::BAD_REQUEST,
200200+ Json(json!({"error": "InvalidRequest", "message": "did is required"})),
201201+ )
202202+ .into_response();
203203+ }
204204+205205+ let user = sqlx::query("SELECT id FROM users WHERE did = $1")
206206+ .bind(did)
207207+ .fetch_optional(&state.db)
208208+ .await;
209209+210210+ let user_id: uuid::Uuid = match user {
211211+ Ok(Some(row)) => row.get("id"),
212212+ Ok(None) => {
213213+ return (
214214+ StatusCode::NOT_FOUND,
215215+ Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
216216+ )
217217+ .into_response();
218218+ }
219219+ Err(e) => {
220220+ error!("DB error in delete_account: {:?}", e);
221221+ return (
222222+ StatusCode::INTERNAL_SERVER_ERROR,
223223+ Json(json!({"error": "InternalError"})),
224224+ )
225225+ .into_response();
226226+ }
227227+ };
228228+229229+ let _ = sqlx::query("DELETE FROM sessions WHERE did = $1")
230230+ .bind(did)
231231+ .execute(&state.db)
232232+ .await;
233233+234234+ let _ = sqlx::query("DELETE FROM records WHERE repo_id = $1")
235235+ .bind(user_id)
236236+ .execute(&state.db)
237237+ .await;
238238+239239+ let _ = sqlx::query("DELETE FROM repos WHERE user_id = $1")
240240+ .bind(user_id)
241241+ .execute(&state.db)
242242+ .await;
243243+244244+ let _ = sqlx::query("DELETE FROM blobs WHERE created_by_user = $1")
245245+ .bind(user_id)
246246+ .execute(&state.db)
247247+ .await;
248248+249249+ let _ = sqlx::query("DELETE FROM user_keys WHERE user_id = $1")
250250+ .bind(user_id)
251251+ .execute(&state.db)
252252+ .await;
253253+254254+ let result = sqlx::query("DELETE FROM users WHERE id = $1")
255255+ .bind(user_id)
256256+ .execute(&state.db)
257257+ .await;
258258+259259+ match result {
260260+ Ok(_) => (StatusCode::OK, Json(json!({}))).into_response(),
261261+ Err(e) => {
262262+ error!("DB error deleting account: {:?}", e);
263263+ (
264264+ StatusCode::INTERNAL_SERVER_ERROR,
265265+ Json(json!({"error": "InternalError"})),
266266+ )
267267+ .into_response()
268268+ }
269269+ }
270270+}
271271+272272+#[derive(Deserialize)]
273273+pub struct UpdateAccountEmailInput {
274274+ pub account: String,
275275+ pub email: String,
276276+}
277277+278278+pub async fn update_account_email(
279279+ State(state): State<AppState>,
280280+ headers: axum::http::HeaderMap,
281281+ Json(input): Json<UpdateAccountEmailInput>,
282282+) -> Response {
283283+ let auth_header = headers.get("Authorization");
284284+ if auth_header.is_none() {
285285+ return (
286286+ StatusCode::UNAUTHORIZED,
287287+ Json(json!({"error": "AuthenticationRequired"})),
288288+ )
289289+ .into_response();
290290+ }
291291+292292+ let account = input.account.trim();
293293+ let email = input.email.trim();
294294+295295+ if account.is_empty() || email.is_empty() {
296296+ return (
297297+ StatusCode::BAD_REQUEST,
298298+ Json(json!({"error": "InvalidRequest", "message": "account and email are required"})),
299299+ )
300300+ .into_response();
301301+ }
302302+303303+ let result = sqlx::query("UPDATE users SET email = $1 WHERE did = $2")
304304+ .bind(email)
305305+ .bind(account)
306306+ .execute(&state.db)
307307+ .await;
308308+309309+ match result {
310310+ Ok(r) => {
311311+ if r.rows_affected() == 0 {
312312+ return (
313313+ StatusCode::NOT_FOUND,
314314+ Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
315315+ )
316316+ .into_response();
317317+ }
318318+ (StatusCode::OK, Json(json!({}))).into_response()
319319+ }
320320+ Err(e) => {
321321+ error!("DB error updating email: {:?}", e);
322322+ (
323323+ StatusCode::INTERNAL_SERVER_ERROR,
324324+ Json(json!({"error": "InternalError"})),
325325+ )
326326+ .into_response()
327327+ }
328328+ }
329329+}
330330+331331+#[derive(Deserialize)]
332332+pub struct UpdateAccountHandleInput {
333333+ pub did: String,
334334+ pub handle: String,
335335+}
336336+337337+pub async fn update_account_handle(
338338+ State(state): State<AppState>,
339339+ headers: axum::http::HeaderMap,
340340+ Json(input): Json<UpdateAccountHandleInput>,
341341+) -> Response {
342342+ let auth_header = headers.get("Authorization");
343343+ if auth_header.is_none() {
344344+ return (
345345+ StatusCode::UNAUTHORIZED,
346346+ Json(json!({"error": "AuthenticationRequired"})),
347347+ )
348348+ .into_response();
349349+ }
350350+351351+ let did = input.did.trim();
352352+ let handle = input.handle.trim();
353353+354354+ if did.is_empty() || handle.is_empty() {
355355+ return (
356356+ StatusCode::BAD_REQUEST,
357357+ Json(json!({"error": "InvalidRequest", "message": "did and handle are required"})),
358358+ )
359359+ .into_response();
360360+ }
361361+362362+ if !handle
363363+ .chars()
364364+ .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
365365+ {
366366+ return (
367367+ StatusCode::BAD_REQUEST,
368368+ Json(json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"})),
369369+ )
370370+ .into_response();
371371+ }
372372+373373+ let existing = sqlx::query("SELECT id FROM users WHERE handle = $1 AND did != $2")
374374+ .bind(handle)
375375+ .bind(did)
376376+ .fetch_optional(&state.db)
377377+ .await;
378378+379379+ if let Ok(Some(_)) = existing {
380380+ return (
381381+ StatusCode::BAD_REQUEST,
382382+ Json(json!({"error": "HandleTaken", "message": "Handle is already in use"})),
383383+ )
384384+ .into_response();
385385+ }
386386+387387+ let result = sqlx::query("UPDATE users SET handle = $1 WHERE did = $2")
388388+ .bind(handle)
389389+ .bind(did)
390390+ .execute(&state.db)
391391+ .await;
392392+393393+ match result {
394394+ Ok(r) => {
395395+ if r.rows_affected() == 0 {
396396+ return (
397397+ StatusCode::NOT_FOUND,
398398+ Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
399399+ )
400400+ .into_response();
401401+ }
402402+ (StatusCode::OK, Json(json!({}))).into_response()
403403+ }
404404+ Err(e) => {
405405+ error!("DB error updating handle: {:?}", e);
406406+ (
407407+ StatusCode::INTERNAL_SERVER_ERROR,
408408+ Json(json!({"error": "InternalError"})),
409409+ )
410410+ .into_response()
411411+ }
412412+ }
413413+}
414414+415415+#[derive(Deserialize)]
416416+pub struct UpdateAccountPasswordInput {
417417+ pub did: String,
418418+ pub password: String,
419419+}
420420+421421+pub async fn update_account_password(
422422+ State(state): State<AppState>,
423423+ headers: axum::http::HeaderMap,
424424+ Json(input): Json<UpdateAccountPasswordInput>,
425425+) -> Response {
426426+ let auth_header = headers.get("Authorization");
427427+ if auth_header.is_none() {
428428+ return (
429429+ StatusCode::UNAUTHORIZED,
430430+ Json(json!({"error": "AuthenticationRequired"})),
431431+ )
432432+ .into_response();
433433+ }
434434+435435+ let did = input.did.trim();
436436+ let password = input.password.trim();
437437+438438+ if did.is_empty() || password.is_empty() {
439439+ return (
440440+ StatusCode::BAD_REQUEST,
441441+ Json(json!({"error": "InvalidRequest", "message": "did and password are required"})),
442442+ )
443443+ .into_response();
444444+ }
445445+446446+ let password_hash = match bcrypt::hash(password, bcrypt::DEFAULT_COST) {
447447+ Ok(h) => h,
448448+ Err(e) => {
449449+ error!("Failed to hash password: {:?}", e);
450450+ return (
451451+ StatusCode::INTERNAL_SERVER_ERROR,
452452+ Json(json!({"error": "InternalError"})),
453453+ )
454454+ .into_response();
455455+ }
456456+ };
457457+458458+ let result = sqlx::query("UPDATE users SET password_hash = $1 WHERE did = $2")
459459+ .bind(&password_hash)
460460+ .bind(did)
461461+ .execute(&state.db)
462462+ .await;
463463+464464+ match result {
465465+ Ok(r) => {
466466+ if r.rows_affected() == 0 {
467467+ return (
468468+ StatusCode::NOT_FOUND,
469469+ Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
470470+ )
471471+ .into_response();
472472+ }
473473+ (StatusCode::OK, Json(json!({}))).into_response()
474474+ }
475475+ Err(e) => {
476476+ error!("DB error updating password: {:?}", e);
477477+ (
478478+ StatusCode::INTERNAL_SERVER_ERROR,
479479+ Json(json!({"error": "InternalError"})),
480480+ )
481481+ .into_response()
482482+ }
483483+ }
484484+}
···22pub mod meta;
33pub mod record;
4455-pub use blob::upload_blob;
55+pub use blob::{list_missing_blobs, upload_blob};
66pub use meta::describe_repo;
77pub use record::{apply_writes, create_record, delete_record, get_record, list_records, put_record};
+5-1
src/api/server/mod.rs
···22pub mod session;
3344pub use meta::{describe_server, health};
55-pub use session::{create_session, delete_session, get_service_auth, get_session, refresh_session};
55+pub use session::{
66+ activate_account, check_account_status, create_app_password, create_session,
77+ deactivate_account, delete_session, get_service_auth, get_session, list_app_passwords,
88+ refresh_session, revoke_app_password,
99+};