mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-03 08:46:55 +00:00
Cargo clippy typeshit
This commit is contained in:
+1
-1
@@ -53,7 +53,7 @@ AWS_SECRET_ACCESS_KEY=minioadmin
|
||||
# Appview URL for proxying app.bsky.* requests
|
||||
# APPVIEW_URL=https://api.bsky.app
|
||||
# Comma-separated list of relay URLs to notify via requestCrawl
|
||||
# CRAWLERS=https://bsky.network
|
||||
# CRAWLERS=https://bsky.network,https://relay.upcloud.world
|
||||
# =============================================================================
|
||||
# Firehose (subscribeRepos WebSocket)
|
||||
# =============================================================================
|
||||
|
||||
@@ -1,43 +1,58 @@
|
||||
# BSPDS
|
||||
A production-grade Personal Data Server (PDS) for the AT Protocol. Drop-in replacement for Bluesky's reference PDS, using postgres and s3-compatible blob storage.
|
||||
|
||||
A production-grade Personal Data Server (PDS) for the AT Protocol. Drop-in replacement for Bluesky's reference PDS, written in rust with postgres and s3-compatible blob storage.
|
||||
|
||||
## Features
|
||||
|
||||
- Full AT Protocol support (`com.atproto.*` endpoints)
|
||||
- OAuth 2.1 provider (PKCE, DPoP, PAR)
|
||||
- WebSocket firehose (`subscribeRepos`)
|
||||
- Multi-channel notifications (email, discord, telegram, signal)
|
||||
- Built-in web UI for account management
|
||||
- Per-IP rate limiting
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
podman compose up -d
|
||||
just run
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
See `.env.example` for all configuration options.
|
||||
|
||||
## Development
|
||||
|
||||
Run `just` to see available commands.
|
||||
|
||||
```bash
|
||||
just test # run tests
|
||||
just lint # clippy + fmt
|
||||
just test
|
||||
just lint
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Quick Deploy (Docker/Podman Compose)
|
||||
|
||||
Edit `.env.prod` with your values. Generate secrets with `openssl rand -base64 48`.
|
||||
|
||||
```bash
|
||||
cp .env.prod.example .env.prod
|
||||
# Edit .env.prod with your values (generate secrets with: openssl rand -base64 48)
|
||||
podman-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
### Full Installation Guides
|
||||
|
||||
### Installation Guides
|
||||
|
||||
| Guide | Best For |
|
||||
|-------|----------|
|
||||
| **Native Installation** | Maximum performance, full control |
|
||||
| [Debian](docs/install-debian.md) | Debian 13+ with systemd |
|
||||
| [Alpine](docs/install-alpine.md) | Alpine 3.23+ with OpenRC |
|
||||
| [OpenBSD](docs/install-openbsd.md) | OpenBSD 7.8+ with rc.d |
|
||||
| **Containerized** | Easier updates, isolation |
|
||||
| [Containers](docs/install-containers.md) | Podman with quadlets (Debian) or OpenRC (Alpine) |
|
||||
| **Orchestrated** | High availability, auto-scaling |
|
||||
| [Kubernetes](docs/install-kubernetes.md) | Multi-node k8s cluster deployment |
|
||||
| [Containers](docs/install-containers.md) | Podman with quadlets or OpenRC |
|
||||
| [Kubernetes](docs/install-kubernetes.md) | You know what you're doing |
|
||||
|
||||
## License
|
||||
|
||||
TBD
|
||||
|
||||
@@ -7,7 +7,7 @@ If you're reaching for kubernetes for this app, you're experienced enough to kno
|
||||
- s3-compatible object storage (minio operator, or just use a managed service)
|
||||
- the app itself (it's just a container with some env vars)
|
||||
|
||||
You'll need a wildcard TLS certificate for `*.your-pds-hostname.example.com` — user handles are served as subdomains.
|
||||
You'll need a wildcard TLS certificate for `*.your-pds-hostname.example.com`. User handles are served as subdomains.
|
||||
|
||||
The container image expects:
|
||||
- `DATABASE_URL` - postgres connection string
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const APP_BSKY_NAMESPACE: &str = "app.bsky";
|
||||
const MAX_PREFERENCES_COUNT: usize = 100;
|
||||
@@ -75,7 +75,8 @@ pub async fn get_preferences(
|
||||
let preferences: Vec<Value> = prefs
|
||||
.into_iter()
|
||||
.filter(|row| {
|
||||
row.name == APP_BSKY_NAMESPACE || row.name.starts_with(&format!("{}.", APP_BSKY_NAMESPACE))
|
||||
row.name == APP_BSKY_NAMESPACE
|
||||
|| row.name.starts_with(&format!("{}.", APP_BSKY_NAMESPACE))
|
||||
})
|
||||
.filter_map(|row| {
|
||||
if row.name == "app.bsky.actor.defs#declaredAgePref" {
|
||||
@@ -221,7 +222,7 @@ pub async fn put_preferences(
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
if let Err(_) = tx.commit().await {
|
||||
if tx.commit().await.is_err() {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to commit transaction"})),
|
||||
|
||||
+71
-24
@@ -1,14 +1,14 @@
|
||||
use crate::api::proxy_client::proxy_client;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use crate::api::proxy_client::proxy_client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use tracing::{error, info};
|
||||
|
||||
@@ -79,9 +79,13 @@ async fn proxy_to_appview(
|
||||
let appview_url = match std::env::var("APPVIEW_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
return Err(
|
||||
(StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError", "message": "No upstream AppView configured"}))).into_response()
|
||||
);
|
||||
return Err((
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(
|
||||
json!({"error": "UpstreamError", "message": "No upstream AppView configured"}),
|
||||
),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
};
|
||||
let target_url = format!("{}/xrpc/{}", appview_url, method);
|
||||
@@ -89,34 +93,53 @@ async fn proxy_to_appview(
|
||||
let client = proxy_client();
|
||||
let mut request_builder = client.get(&target_url).query(params);
|
||||
if let Some(key_bytes) = auth_key_bytes {
|
||||
let appview_did = std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
|
||||
let appview_did =
|
||||
std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
|
||||
match crate::auth::create_service_token(auth_did, &appview_did, method, key_bytes) {
|
||||
Ok(service_token) => {
|
||||
request_builder = request_builder.header("Authorization", format!("Bearer {}", service_token));
|
||||
request_builder =
|
||||
request_builder.header("Authorization", format!("Bearer {}", service_token));
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to create service token: {:?}", e);
|
||||
return Err((StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"}))).into_response());
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
}
|
||||
}
|
||||
match request_builder.send().await {
|
||||
Ok(resp) => {
|
||||
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||
let status =
|
||||
StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
||||
match resp.json::<Value>().await {
|
||||
Ok(body) => Ok((status, body)),
|
||||
Err(e) => {
|
||||
error!("Error parsing proxy response: {:?}", e);
|
||||
Err((StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response())
|
||||
Err((
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({"error": "UpstreamError"})),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error sending proxy request: {:?}", e);
|
||||
if e.is_timeout() {
|
||||
Err((StatusCode::GATEWAY_TIMEOUT, Json(json!({"error": "UpstreamTimeout"}))).into_response())
|
||||
Err((
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
Json(json!({"error": "UpstreamTimeout"})),
|
||||
)
|
||||
.into_response())
|
||||
} else {
|
||||
Err((StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError"}))).into_response())
|
||||
Err((
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({"error": "UpstreamError"})),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,7 +153,9 @@ pub async fn get_profile(
|
||||
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
|
||||
let auth_user = if let Some(h) = auth_header {
|
||||
if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) {
|
||||
crate::auth::validate_bearer_token(&state.db, &token).await.ok()
|
||||
crate::auth::validate_bearer_token(&state.db, &token)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -141,7 +166,14 @@ pub async fn get_profile(
|
||||
let auth_key_bytes = auth_user.as_ref().and_then(|u| u.key_bytes.clone());
|
||||
let mut query_params = HashMap::new();
|
||||
query_params.insert("actor".to_string(), params.actor.clone());
|
||||
let (status, body) = match proxy_to_appview("app.bsky.actor.getProfile", &query_params, auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref()).await {
|
||||
let (status, body) = match proxy_to_appview(
|
||||
"app.bsky.actor.getProfile",
|
||||
&query_params,
|
||||
auth_did.as_deref().unwrap_or(""),
|
||||
auth_key_bytes.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return e,
|
||||
};
|
||||
@@ -151,16 +183,18 @@ pub async fn get_profile(
|
||||
let mut profile: ProfileViewDetailed = match serde_json::from_value(body) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
return (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError", "message": "Invalid profile response"}))).into_response();
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({"error": "UpstreamError", "message": "Invalid profile response"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if let Some(ref did) = auth_did {
|
||||
if profile.did == *did {
|
||||
if let Some(local_record) = get_local_profile_record(&state, did).await {
|
||||
if let Some(ref did) = auth_did
|
||||
&& profile.did == *did
|
||||
&& let Some(local_record) = get_local_profile_record(&state, did).await {
|
||||
munge_profile_with_local(&mut profile, &local_record);
|
||||
}
|
||||
}
|
||||
}
|
||||
(StatusCode::OK, Json(profile)).into_response()
|
||||
}
|
||||
|
||||
@@ -172,7 +206,9 @@ pub async fn get_profiles(
|
||||
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
|
||||
let auth_user = if let Some(h) = auth_header {
|
||||
if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) {
|
||||
crate::auth::validate_bearer_token(&state.db, &token).await.ok()
|
||||
crate::auth::validate_bearer_token(&state.db, &token)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -183,7 +219,14 @@ pub async fn get_profiles(
|
||||
let auth_key_bytes = auth_user.as_ref().and_then(|u| u.key_bytes.clone());
|
||||
let mut query_params = HashMap::new();
|
||||
query_params.insert("actors".to_string(), params.actors.clone());
|
||||
let (status, body) = match proxy_to_appview("app.bsky.actor.getProfiles", &query_params, auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref()).await {
|
||||
let (status, body) = match proxy_to_appview(
|
||||
"app.bsky.actor.getProfiles",
|
||||
&query_params,
|
||||
auth_did.as_deref().unwrap_or(""),
|
||||
auth_key_bytes.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return e,
|
||||
};
|
||||
@@ -193,7 +236,11 @@ pub async fn get_profiles(
|
||||
let mut output: GetProfilesOutput = match serde_json::from_value(body) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
return (StatusCode::BAD_GATEWAY, Json(json!({"error": "UpstreamError", "message": "Invalid profiles response"}))).into_response();
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({"error": "UpstreamError", "message": "Invalid profiles response"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if let Some(ref did) = auth_did {
|
||||
|
||||
@@ -121,24 +121,39 @@ pub async fn delete_account(
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Failed to delete app passwords for user {}: {:?}", user_id, e);
|
||||
error!(
|
||||
"Failed to delete app passwords for user {}: {:?}",
|
||||
user_id, e
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to delete app passwords"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = sqlx::query!("DELETE FROM invite_code_uses WHERE used_by_user = $1", user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
if let Err(e) = sqlx::query!(
|
||||
"DELETE FROM invite_code_uses WHERE used_by_user = $1",
|
||||
user_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Failed to delete invite code uses for user {}: {:?}", user_id, e);
|
||||
error!(
|
||||
"Failed to delete invite code uses for user {}: {:?}",
|
||||
user_id, e
|
||||
);
|
||||
}
|
||||
if let Err(e) = sqlx::query!("DELETE FROM invite_codes WHERE created_by_user = $1", user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
if let Err(e) = sqlx::query!(
|
||||
"DELETE FROM invite_codes WHERE created_by_user = $1",
|
||||
user_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Failed to delete invite codes for user {}: {:?}", user_id, e);
|
||||
error!(
|
||||
"Failed to delete invite codes for user {}: {:?}",
|
||||
user_id, e
|
||||
);
|
||||
}
|
||||
if let Err(e) = sqlx::query!("DELETE FROM user_keys WHERE user_id = $1", user_id)
|
||||
.execute(&mut *tx)
|
||||
@@ -170,8 +185,13 @@ pub async fn delete_account(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await {
|
||||
warn!("Failed to sequence account deletion event for {}: {}", did, e);
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await
|
||||
{
|
||||
warn!(
|
||||
"Failed to sequence account deletion event for {}: {}",
|
||||
did, e
|
||||
);
|
||||
}
|
||||
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
|
||||
@@ -104,11 +104,7 @@ pub async fn send_email(
|
||||
let result = crate::notifications::enqueue_notification(&state.db, notification).await;
|
||||
match result {
|
||||
Ok(_) => {
|
||||
tracing::info!(
|
||||
"Admin email queued for {} ({})",
|
||||
handle,
|
||||
recipient_did
|
||||
);
|
||||
tracing::info!("Admin email queued for {} ({})", handle, recipient_did);
|
||||
(StatusCode::OK, Json(SendEmailOutput { sent: true })).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -65,22 +65,20 @@ pub async fn get_account_info(
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(Some(row)) => {
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(AccountInfo {
|
||||
did: row.did,
|
||||
handle: row.handle,
|
||||
email: row.email,
|
||||
indexed_at: row.created_at.to_rfc3339(),
|
||||
invite_note: None,
|
||||
invites_disabled: false,
|
||||
email_confirmed_at: None,
|
||||
deactivated_at: None,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Ok(Some(row)) => (
|
||||
StatusCode::OK,
|
||||
Json(AccountInfo {
|
||||
did: row.did,
|
||||
handle: row.handle,
|
||||
email: row.email,
|
||||
indexed_at: row.created_at.to_rfc3339(),
|
||||
invite_note: None,
|
||||
invites_disabled: false,
|
||||
email_confirmed_at: None,
|
||||
deactivated_at: None,
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
|
||||
|
||||
@@ -4,14 +4,17 @@ mod info;
|
||||
mod profile;
|
||||
mod update;
|
||||
|
||||
pub use delete::{delete_account, DeleteAccountInput};
|
||||
pub use email::{send_email, SendEmailInput, SendEmailOutput};
|
||||
pub use delete::{DeleteAccountInput, delete_account};
|
||||
pub use email::{SendEmailInput, SendEmailOutput, send_email};
|
||||
pub use info::{
|
||||
get_account_info, get_account_infos, AccountInfo, GetAccountInfoParams, GetAccountInfosOutput,
|
||||
GetAccountInfosParams,
|
||||
AccountInfo, GetAccountInfoParams, GetAccountInfosOutput, GetAccountInfosParams,
|
||||
get_account_info, get_account_infos,
|
||||
};
|
||||
pub use profile::{
|
||||
CreateProfileInput, CreateProfileOutput, CreateRecordAdminInput, create_profile,
|
||||
create_record_admin,
|
||||
};
|
||||
pub use profile::{create_profile, create_record_admin, CreateProfileInput, CreateProfileOutput, CreateRecordAdminInput};
|
||||
pub use update::{
|
||||
update_account_email, update_account_handle, update_account_password, UpdateAccountEmailInput,
|
||||
UpdateAccountHandleInput, UpdateAccountPasswordInput,
|
||||
UpdateAccountEmailInput, UpdateAccountHandleInput, UpdateAccountPasswordInput,
|
||||
update_account_email, update_account_handle, update_account_password,
|
||||
};
|
||||
|
||||
@@ -74,7 +74,9 @@ pub async fn create_profile(
|
||||
"app.bsky.actor.profile",
|
||||
"self",
|
||||
&profile_record,
|
||||
).await {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((uri, commit_cid)) => {
|
||||
info!(did = %did, uri = %uri, "Created profile for user");
|
||||
(
|
||||
@@ -120,17 +122,11 @@ pub async fn create_record_admin(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let rkey = input.rkey.unwrap_or_else(|| {
|
||||
chrono::Utc::now().format("%Y%m%d%H%M%S%f").to_string()
|
||||
});
|
||||
let rkey = input
|
||||
.rkey
|
||||
.unwrap_or_else(|| chrono::Utc::now().format("%Y%m%d%H%M%S%f").to_string());
|
||||
|
||||
match create_record_internal(
|
||||
&state,
|
||||
did,
|
||||
&input.collection,
|
||||
&rkey,
|
||||
&input.record,
|
||||
).await {
|
||||
match create_record_internal(&state, did, &input.collection, &rkey, &input.record).await {
|
||||
Ok((uri, commit_cid)) => {
|
||||
info!(did = %did, uri = %uri, "Admin created record");
|
||||
(
|
||||
|
||||
@@ -96,7 +96,9 @@ pub async fn update_account_handle(
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"})),
|
||||
Json(
|
||||
json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
@@ -105,9 +107,13 @@ pub async fn update_account_handle(
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let existing = sqlx::query!("SELECT id FROM users WHERE handle = $1 AND did != $2", handle, did)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let existing = sqlx::query!(
|
||||
"SELECT id FROM users WHERE handle = $1 AND did != $2",
|
||||
handle,
|
||||
did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
if let Ok(Some(_)) = existing {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -183,9 +189,13 @@ pub async fn update_account_password(
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let result = sqlx::query!("UPDATE users SET password_hash = $1 WHERE did = $2", password_hash, did)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
let result = sqlx::query!(
|
||||
"UPDATE users SET password_hash = $1 WHERE did = $2",
|
||||
password_hash,
|
||||
did
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(r) => {
|
||||
if r.rows_affected() == 0 {
|
||||
|
||||
+45
-17
@@ -31,9 +31,12 @@ pub async fn disable_invite_codes(
|
||||
}
|
||||
if let Some(codes) = &input.codes {
|
||||
for code in codes {
|
||||
let _ = sqlx::query!("UPDATE invite_codes SET disabled = TRUE WHERE code = $1", code)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
let _ = sqlx::query!(
|
||||
"UPDATE invite_codes SET disabled = TRUE WHERE code = $1",
|
||||
code
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if let Some(accounts) = &input.accounts {
|
||||
@@ -106,7 +109,16 @@ pub async fn get_invite_codes(
|
||||
_ => "created_at DESC",
|
||||
};
|
||||
let codes_result = if let Some(cursor) = ¶ms.cursor {
|
||||
sqlx::query_as::<_, (String, i32, Option<bool>, uuid::Uuid, chrono::DateTime<chrono::Utc>)>(&format!(
|
||||
sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
String,
|
||||
i32,
|
||||
Option<bool>,
|
||||
uuid::Uuid,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
),
|
||||
>(&format!(
|
||||
r#"
|
||||
SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at
|
||||
FROM invite_codes ic
|
||||
@@ -121,7 +133,16 @@ pub async fn get_invite_codes(
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query_as::<_, (String, i32, Option<bool>, uuid::Uuid, chrono::DateTime<chrono::Utc>)>(&format!(
|
||||
sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
String,
|
||||
i32,
|
||||
Option<bool>,
|
||||
uuid::Uuid,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
),
|
||||
>(&format!(
|
||||
r#"
|
||||
SELECT ic.code, ic.available_uses, ic.disabled, ic.created_by_user, ic.created_at
|
||||
FROM invite_codes ic
|
||||
@@ -147,12 +168,13 @@ pub async fn get_invite_codes(
|
||||
};
|
||||
let mut codes = Vec::new();
|
||||
for (code, available_uses, disabled, created_by_user, created_at) in &codes_rows {
|
||||
let creator_did = sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", created_by_user)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let creator_did =
|
||||
sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", created_by_user)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let uses_result = sqlx::query!(
|
||||
r#"
|
||||
SELECT u.did, icu.used_at
|
||||
@@ -226,9 +248,12 @@ pub async fn disable_account_invites(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let result = sqlx::query!("UPDATE users SET invites_disabled = TRUE WHERE did = $1", account)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
let result = sqlx::query!(
|
||||
"UPDATE users SET invites_disabled = TRUE WHERE did = $1",
|
||||
account
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(r) => {
|
||||
if r.rows_affected() == 0 {
|
||||
@@ -277,9 +302,12 @@ pub async fn enable_account_invites(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let result = sqlx::query!("UPDATE users SET invites_disabled = FALSE WHERE did = $1", account)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
let result = sqlx::query!(
|
||||
"UPDATE users SET invites_disabled = FALSE WHERE did = $1",
|
||||
account
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(r) => {
|
||||
if r.rows_affected() == 0 {
|
||||
|
||||
+47
-18
@@ -142,9 +142,12 @@ pub async fn get_subject_status(
|
||||
}
|
||||
}
|
||||
if let Some(blob_cid) = ¶ms.blob {
|
||||
let blob = sqlx::query!("SELECT cid, takedown_ref FROM blobs WHERE cid = $1", blob_cid)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let blob = sqlx::query!(
|
||||
"SELECT cid, takedown_ref FROM blobs WHERE cid = $1",
|
||||
blob_cid
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
match blob {
|
||||
Ok(Some(row)) => {
|
||||
let takedown = row.takedown_ref.as_ref().map(|r| StatusAttr {
|
||||
@@ -263,15 +266,15 @@ pub async fn update_subject_status(
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
} else {
|
||||
sqlx::query!(
|
||||
"UPDATE users SET deactivated_at = NULL WHERE did = $1",
|
||||
did
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
};
|
||||
if let Err(e) = result {
|
||||
error!("Failed to update user deactivation status for {}: {:?}", did, e);
|
||||
error!(
|
||||
"Failed to update user deactivation status for {}: {:?}",
|
||||
did, e
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to update deactivation status"})),
|
||||
@@ -288,20 +291,43 @@ pub async fn update_subject_status(
|
||||
.into_response();
|
||||
}
|
||||
if let Some(takedown) = &input.takedown {
|
||||
let status = if takedown.apply { Some("takendown") } else { None };
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(&state, did, !takedown.apply, status).await {
|
||||
let status = if takedown.apply {
|
||||
Some("takendown")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(
|
||||
&state,
|
||||
did,
|
||||
!takedown.apply,
|
||||
status,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to sequence account event for takedown: {}", e);
|
||||
}
|
||||
}
|
||||
if let Some(deactivated) = &input.deactivated {
|
||||
let status = if deactivated.apply { Some("deactivated") } else { None };
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(&state, did, !deactivated.apply, status).await {
|
||||
let status = if deactivated.apply {
|
||||
Some("deactivated")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(
|
||||
&state,
|
||||
did,
|
||||
!deactivated.apply,
|
||||
status,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to sequence account event for deactivation: {}", e);
|
||||
}
|
||||
}
|
||||
if let Ok(Some(handle)) = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
if let Ok(Some(handle)) =
|
||||
sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
let _ = state.cache.delete(&format!("handle:{}", handle)).await;
|
||||
}
|
||||
@@ -338,7 +364,10 @@ pub async fn update_subject_status(
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("Failed to update record takedown status for {}: {:?}", uri, e);
|
||||
error!(
|
||||
"Failed to update record takedown status for {}: {:?}",
|
||||
uri, e
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to update takedown status"})),
|
||||
|
||||
+24
-9
@@ -46,7 +46,11 @@ pub enum ApiError {
|
||||
UpstreamFailure,
|
||||
UpstreamTimeout,
|
||||
UpstreamUnavailable(String),
|
||||
UpstreamError { status: u16, error: Option<String>, message: Option<String> },
|
||||
UpstreamError {
|
||||
status: u16,
|
||||
error: Option<String>,
|
||||
message: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
@@ -135,16 +139,27 @@ impl ApiError {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn from_upstream_response(
|
||||
status: u16,
|
||||
body: &[u8],
|
||||
) -> Self {
|
||||
pub fn from_upstream_response(status: u16, body: &[u8]) -> Self {
|
||||
if let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(body) {
|
||||
let error = parsed.get("error").and_then(|v| v.as_str()).map(String::from);
|
||||
let message = parsed.get("message").and_then(|v| v.as_str()).map(String::from);
|
||||
return Self::UpstreamError { status, error, message };
|
||||
let error = parsed
|
||||
.get("error")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
let message = parsed
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
return Self::UpstreamError {
|
||||
status,
|
||||
error,
|
||||
message,
|
||||
};
|
||||
}
|
||||
Self::UpstreamError {
|
||||
status,
|
||||
error: None,
|
||||
message: None,
|
||||
}
|
||||
Self::UpstreamError { status, error: None, message: None }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use crate::api::read_after_write::{
|
||||
extract_repo_rev, format_munged_response, get_local_lag, get_records_since_rev,
|
||||
proxy_to_appview, FeedOutput, FeedViewPost, LikeRecord, PostView, RecordDescript,
|
||||
FeedOutput, FeedViewPost, LikeRecord, PostView, RecordDescript, extract_repo_rev,
|
||||
format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
@@ -68,7 +68,9 @@ pub async fn get_actor_likes(
|
||||
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
|
||||
let auth_user = if let Some(h) = auth_header {
|
||||
if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) {
|
||||
crate::auth::validate_bearer_token(&state.db, &token).await.ok()
|
||||
crate::auth::validate_bearer_token(&state.db, &token)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -85,11 +87,17 @@ pub async fn get_actor_likes(
|
||||
if let Some(cursor) = ¶ms.cursor {
|
||||
query_params.insert("cursor".to_string(), cursor.clone());
|
||||
}
|
||||
let proxy_result =
|
||||
match proxy_to_appview("app.bsky.feed.getActorLikes", &query_params, auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref()).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let proxy_result = match proxy_to_appview(
|
||||
"app.bsky.feed.getActorLikes",
|
||||
&query_params,
|
||||
auth_did.as_deref().unwrap_or(""),
|
||||
auth_key_bytes.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if !proxy_result.status.is_success() {
|
||||
return proxy_result.into_response();
|
||||
}
|
||||
|
||||
+21
-21
@@ -1,14 +1,14 @@
|
||||
use crate::api::read_after_write::{
|
||||
extract_repo_rev, format_local_post, format_munged_response, get_local_lag,
|
||||
get_records_since_rev, insert_posts_into_feed, proxy_to_appview, FeedOutput, FeedViewPost,
|
||||
ProfileRecord, RecordDescript,
|
||||
FeedOutput, FeedViewPost, ProfileRecord, RecordDescript, extract_repo_rev, format_local_post,
|
||||
format_munged_response, get_local_lag, get_records_since_rev, insert_posts_into_feed,
|
||||
proxy_to_appview,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
@@ -30,11 +30,10 @@ fn update_author_profile_in_feed(
|
||||
local_profile: &RecordDescript<ProfileRecord>,
|
||||
) {
|
||||
for item in feed.iter_mut() {
|
||||
if item.post.author.did == author_did {
|
||||
if let Some(ref display_name) = local_profile.record.display_name {
|
||||
if item.post.author.did == author_did
|
||||
&& let Some(ref display_name) = local_profile.record.display_name {
|
||||
item.post.author.display_name = Some(display_name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +45,9 @@ pub async fn get_author_feed(
|
||||
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
|
||||
let auth_user = if let Some(h) = auth_header {
|
||||
if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) {
|
||||
crate::auth::validate_bearer_token(&state.db, &token).await.ok()
|
||||
crate::auth::validate_bearer_token(&state.db, &token)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -69,11 +70,17 @@ pub async fn get_author_feed(
|
||||
if let Some(include_pins) = params.include_pins {
|
||||
query_params.insert("includePins".to_string(), include_pins.to_string());
|
||||
}
|
||||
let proxy_result =
|
||||
match proxy_to_appview("app.bsky.feed.getAuthorFeed", &query_params, auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref()).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let proxy_result = match proxy_to_appview(
|
||||
"app.bsky.feed.getAuthorFeed",
|
||||
&query_params,
|
||||
auth_did.as_deref().unwrap_or(""),
|
||||
auth_key_bytes.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if !proxy_result.status.is_success() {
|
||||
return proxy_result.into_response();
|
||||
}
|
||||
@@ -144,14 +151,7 @@ pub async fn get_author_feed(
|
||||
let local_posts: Vec<_> = local_records
|
||||
.posts
|
||||
.iter()
|
||||
.map(|p| {
|
||||
format_local_post(
|
||||
p,
|
||||
&requester_did,
|
||||
&handle,
|
||||
local_records.profile.as_ref(),
|
||||
)
|
||||
})
|
||||
.map(|p| format_local_post(p, &requester_did, &handle, local_records.profile.as_ref()))
|
||||
.collect();
|
||||
insert_posts_into_feed(&mut feed_output.feed, local_posts);
|
||||
let lag = get_local_lag(&local_records);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::api::proxy_client::{
|
||||
is_ssrf_safe, proxy_client, validate_at_uri, validate_limit, MAX_RESPONSE_SIZE,
|
||||
};
|
||||
use crate::api::ApiError;
|
||||
use crate::api::proxy_client::{
|
||||
MAX_RESPONSE_SIZE, is_ssrf_safe, proxy_client, validate_at_uri, validate_limit,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
@@ -61,10 +61,17 @@ pub async fn get_feed(
|
||||
let client = proxy_client();
|
||||
let mut request_builder = client.get(&target_url).query(&query_params);
|
||||
if let Some(key_bytes) = auth_user.key_bytes.as_ref() {
|
||||
let appview_did = std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
|
||||
match crate::auth::create_service_token(&auth_user.did, &appview_did, "app.bsky.feed.getFeed", key_bytes) {
|
||||
let appview_did =
|
||||
std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
|
||||
match crate::auth::create_service_token(
|
||||
&auth_user.did,
|
||||
&appview_did,
|
||||
"app.bsky.feed.getFeed",
|
||||
key_bytes,
|
||||
) {
|
||||
Ok(service_token) => {
|
||||
request_builder = request_builder.header("Authorization", format!("Bearer {}", service_token));
|
||||
request_builder =
|
||||
request_builder.header("Authorization", format!("Bearer {}", service_token));
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = ?e, "Failed to create service token for getFeed");
|
||||
|
||||
+41
-21
@@ -1,16 +1,16 @@
|
||||
use crate::api::read_after_write::{
|
||||
extract_repo_rev, format_local_post, format_munged_response, get_local_lag,
|
||||
get_records_since_rev, proxy_to_appview, PostRecord, PostView, RecordDescript,
|
||||
PostRecord, PostView, RecordDescript, extract_repo_rev, format_local_post,
|
||||
format_munged_response, get_local_lag, get_records_since_rev, proxy_to_appview,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -39,7 +39,7 @@ pub struct ThreadViewPost {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum ThreadNode {
|
||||
Post(ThreadViewPost),
|
||||
Post(Box<ThreadViewPost>),
|
||||
NotFound(ThreadNotFound),
|
||||
Blocked(ThreadBlocked),
|
||||
}
|
||||
@@ -96,13 +96,13 @@ fn add_replies_to_thread(
|
||||
})
|
||||
.map(|p| {
|
||||
let post_view = format_local_post(p, author_did, author_handle, None);
|
||||
ThreadNode::Post(ThreadViewPost {
|
||||
ThreadNode::Post(Box::new(ThreadViewPost {
|
||||
thread_type: Some("app.bsky.feed.defs#threadViewPost".to_string()),
|
||||
post: post_view,
|
||||
parent: None,
|
||||
replies: None,
|
||||
extra: HashMap::new(),
|
||||
})
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
if !replies.is_empty() {
|
||||
@@ -114,7 +114,13 @@ fn add_replies_to_thread(
|
||||
if let Some(ref mut existing_replies) = thread.replies {
|
||||
for reply in existing_replies.iter_mut() {
|
||||
if let ThreadNode::Post(reply_thread) = reply {
|
||||
add_replies_to_thread(reply_thread, local_posts, author_did, author_handle, depth + 1);
|
||||
add_replies_to_thread(
|
||||
reply_thread,
|
||||
local_posts,
|
||||
author_did,
|
||||
author_handle,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,7 +134,9 @@ pub async fn get_post_thread(
|
||||
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
|
||||
let auth_user = if let Some(h) = auth_header {
|
||||
if let Some(token) = crate::auth::extract_bearer_token_from_header(Some(h)) {
|
||||
crate::auth::validate_bearer_token(&state.db, &token).await.ok()
|
||||
crate::auth::validate_bearer_token(&state.db, &token)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -145,11 +153,17 @@ pub async fn get_post_thread(
|
||||
if let Some(parent_height) = params.parent_height {
|
||||
query_params.insert("parentHeight".to_string(), parent_height.to_string());
|
||||
}
|
||||
let proxy_result =
|
||||
match proxy_to_appview("app.bsky.feed.getPostThread", &query_params, auth_did.as_deref().unwrap_or(""), auth_key_bytes.as_deref()).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let proxy_result = match proxy_to_appview(
|
||||
"app.bsky.feed.getPostThread",
|
||||
&query_params,
|
||||
auth_did.as_deref().unwrap_or(""),
|
||||
auth_key_bytes.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if proxy_result.status == StatusCode::NOT_FOUND {
|
||||
return handle_not_found(&state, ¶ms.uri, auth_did, &proxy_result.headers).await;
|
||||
}
|
||||
@@ -193,7 +207,13 @@ pub async fn get_post_thread(
|
||||
}
|
||||
};
|
||||
if let ThreadNode::Post(ref mut thread_post) = thread_output.thread {
|
||||
add_replies_to_thread(thread_post, &local_records.posts, &requester_did, &handle, 0);
|
||||
add_replies_to_thread(
|
||||
thread_post,
|
||||
&local_records.posts,
|
||||
&requester_did,
|
||||
&handle,
|
||||
0,
|
||||
);
|
||||
}
|
||||
let lag = get_local_lag(&local_records);
|
||||
format_munged_response(thread_output, lag)
|
||||
@@ -212,7 +232,7 @@ async fn handle_not_found(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "NotFound", "message": "Post not found"})),
|
||||
)
|
||||
.into_response()
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let requester_did = match auth_did {
|
||||
@@ -222,7 +242,7 @@ async fn handle_not_found(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "NotFound", "message": "Post not found"})),
|
||||
)
|
||||
.into_response()
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let uri_parts: Vec<&str> = uri.trim_start_matches("at://").split('/').collect();
|
||||
@@ -248,7 +268,7 @@ async fn handle_not_found(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "NotFound", "message": "Post not found"})),
|
||||
)
|
||||
.into_response()
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let local_post = local_records.posts.iter().find(|p| p.uri == uri);
|
||||
@@ -259,7 +279,7 @@ async fn handle_not_found(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "NotFound", "message": "Post not found"})),
|
||||
)
|
||||
.into_response()
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let handle = match sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", requester_did)
|
||||
@@ -280,13 +300,13 @@ async fn handle_not_found(
|
||||
local_records.profile.as_ref(),
|
||||
);
|
||||
let thread = PostThreadOutput {
|
||||
thread: ThreadNode::Post(ThreadViewPost {
|
||||
thread: ThreadNode::Post(Box::new(ThreadViewPost {
|
||||
thread_type: Some("app.bsky.feed.defs#threadViewPost".to_string()),
|
||||
post: post_view,
|
||||
parent: None,
|
||||
replies: None,
|
||||
extra: HashMap::new(),
|
||||
}),
|
||||
})),
|
||||
threadgate: None,
|
||||
};
|
||||
let lag = get_local_lag(&local_records);
|
||||
|
||||
+45
-35
@@ -1,18 +1,18 @@
|
||||
use crate::api::read_after_write::{
|
||||
extract_repo_rev, format_local_post, format_munged_response, get_local_lag,
|
||||
get_records_since_rev, insert_posts_into_feed, proxy_to_appview, FeedOutput, FeedViewPost,
|
||||
PostView,
|
||||
FeedOutput, FeedViewPost, PostView, extract_repo_rev, format_local_post,
|
||||
format_munged_response, get_local_lag, get_records_since_rev, insert_posts_into_feed,
|
||||
proxy_to_appview,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -52,7 +52,13 @@ pub async fn get_timeline(
|
||||
};
|
||||
match std::env::var("APPVIEW_URL") {
|
||||
Ok(url) if !url.starts_with("http://127.0.0.1") => {
|
||||
return get_timeline_with_appview(&state, ¶ms, &auth_user.did, auth_user.key_bytes.as_deref()).await;
|
||||
return get_timeline_with_appview(
|
||||
&state,
|
||||
¶ms,
|
||||
&auth_user.did,
|
||||
auth_user.key_bytes.as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -75,11 +81,17 @@ async fn get_timeline_with_appview(
|
||||
if let Some(cursor) = ¶ms.cursor {
|
||||
query_params.insert("cursor".to_string(), cursor.clone());
|
||||
}
|
||||
let proxy_result =
|
||||
match proxy_to_appview("app.bsky.feed.getTimeline", &query_params, auth_did, auth_key_bytes).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => return e,
|
||||
};
|
||||
let proxy_result = match proxy_to_appview(
|
||||
"app.bsky.feed.getTimeline",
|
||||
&query_params,
|
||||
auth_did,
|
||||
auth_key_bytes,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return e,
|
||||
};
|
||||
if !proxy_result.status.is_success() {
|
||||
return proxy_result.into_response();
|
||||
}
|
||||
@@ -127,30 +139,28 @@ async fn get_timeline_with_appview(
|
||||
}
|
||||
|
||||
async fn get_timeline_local_only(state: &AppState, auth_did: &str) -> Response {
|
||||
let user_id: uuid::Uuid = match sqlx::query_scalar!(
|
||||
"SELECT id FROM users WHERE did = $1",
|
||||
auth_did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(id)) => id,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "User not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Database error fetching user: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Database error"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let user_id: uuid::Uuid =
|
||||
match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(id)) => id,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "User not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Database error fetching user: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Database error"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let follows_query = sqlx::query!(
|
||||
"SELECT record_cid FROM records WHERE repo_id = $1 AND collection = 'app.bsky.graph.follow' LIMIT 5000",
|
||||
user_id
|
||||
|
||||
+91
-48
@@ -1,5 +1,5 @@
|
||||
use super::did::verify_did_web;
|
||||
use crate::plc::{create_genesis_operation, signing_key_to_did_key, PlcClient};
|
||||
use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -10,7 +10,7 @@ use axum::{
|
||||
use bcrypt::{DEFAULT_COST, hash};
|
||||
use jacquard::types::{did::Did, integer::LimitedU32, string::Tid};
|
||||
use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore};
|
||||
use k256::{ecdsa::SigningKey, SecretKey};
|
||||
use k256::{SecretKey, ecdsa::SigningKey};
|
||||
use rand::rngs::OsRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
@@ -18,18 +18,15 @@ use std::sync::Arc;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for") {
|
||||
if let Ok(value) = forwarded.to_str() {
|
||||
if let Some(first_ip) = value.split(',').next() {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for")
|
||||
&& let Ok(value) = forwarded.to_str()
|
||||
&& let Some(first_ip) = value.split(',').next() {
|
||||
return first_ip.trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(real_ip) = headers.get("x-real-ip") {
|
||||
if let Ok(value) = real_ip.to_str() {
|
||||
if let Some(real_ip) = headers.get("x-real-ip")
|
||||
&& let Ok(value) = real_ip.to_str() {
|
||||
return value.trim().to_string();
|
||||
}
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
@@ -64,7 +61,10 @@ pub async fn create_account(
|
||||
) -> Response {
|
||||
info!("create_account called");
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if !state.check_rate_limit(RateLimitKind::AccountCreation, &client_ip).await {
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::AccountCreation, &client_ip)
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Account creation rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
@@ -84,18 +84,19 @@ pub async fn create_account(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let email: Option<String> = input.email.as_ref()
|
||||
let email: Option<String> = input
|
||||
.email
|
||||
.as_ref()
|
||||
.map(|e| e.trim().to_string())
|
||||
.filter(|e| !e.is_empty());
|
||||
if let Some(ref email) = email {
|
||||
if !crate::api::validation::is_valid_email(email) {
|
||||
if let Some(ref email) = email
|
||||
&& !crate::api::validation::is_valid_email(email) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
let verification_channel = input.verification_channel.as_deref().unwrap_or("email");
|
||||
let valid_channels = ["email", "discord", "telegram", "signal"];
|
||||
if !valid_channels.contains(&verification_channel) {
|
||||
@@ -220,7 +221,10 @@ pub async fn create_account(
|
||||
}
|
||||
};
|
||||
let plc_client = PlcClient::new(None);
|
||||
if let Err(e) = plc_client.send_operation(&genesis_result.did, &genesis_result.signed_operation).await {
|
||||
if let Err(e) = plc_client
|
||||
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
|
||||
.await
|
||||
{
|
||||
error!("Failed to submit PLC genesis operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
@@ -269,7 +273,10 @@ pub async fn create_account(
|
||||
}
|
||||
};
|
||||
let plc_client = PlcClient::new(None);
|
||||
if let Err(e) = plc_client.send_operation(&genesis_result.did, &genesis_result.signed_operation).await {
|
||||
if let Err(e) = plc_client
|
||||
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
|
||||
.await
|
||||
{
|
||||
error!("Failed to submit PLC genesis operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
@@ -316,10 +323,12 @@ pub async fn create_account(
|
||||
Ok(None) => {}
|
||||
}
|
||||
if let Some(code) = &input.invite_code {
|
||||
let invite_query =
|
||||
sqlx::query!("SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE", code)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await;
|
||||
let invite_query = sqlx::query!(
|
||||
"SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE",
|
||||
code
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await;
|
||||
match invite_query {
|
||||
Ok(Some(row)) => {
|
||||
if row.available_uses <= 0 {
|
||||
@@ -378,23 +387,41 @@ pub async fn create_account(
|
||||
discord_id, telegram_username, signal_number
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7::notification_channel, $8, $9, $10) RETURNING id"#,
|
||||
)
|
||||
.bind(short_handle)
|
||||
.bind(&email)
|
||||
.bind(&did)
|
||||
.bind(&password_hash)
|
||||
.bind(&verification_code)
|
||||
.bind(&code_expires_at)
|
||||
.bind(verification_channel)
|
||||
.bind(input.discord_id.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()))
|
||||
.bind(input.telegram_username.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()))
|
||||
.bind(input.signal_number.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()))
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
.bind(short_handle)
|
||||
.bind(&email)
|
||||
.bind(&did)
|
||||
.bind(&password_hash)
|
||||
.bind(&verification_code)
|
||||
.bind(code_expires_at)
|
||||
.bind(verification_channel)
|
||||
.bind(
|
||||
input
|
||||
.discord_id
|
||||
.as_deref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty()),
|
||||
)
|
||||
.bind(
|
||||
input
|
||||
.telegram_username
|
||||
.as_deref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty()),
|
||||
)
|
||||
.bind(
|
||||
input
|
||||
.signal_number
|
||||
.as_deref()
|
||||
.map(|s| s.trim())
|
||||
.filter(|s| !s.is_empty()),
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
let user_id = match user_insert {
|
||||
Ok((id,)) => id,
|
||||
Err(e) => {
|
||||
if let Some(db_err) = e.as_database_error() {
|
||||
if db_err.code().as_deref() == Some("23505") {
|
||||
if let Some(db_err) = e.as_database_error()
|
||||
&& db_err.code().as_deref() == Some("23505") {
|
||||
let constraint = db_err.constraint().unwrap_or("");
|
||||
if constraint.contains("handle") || constraint.contains("users_handle") {
|
||||
return (
|
||||
@@ -425,7 +452,6 @@ pub async fn create_account(
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
error!("Error inserting user: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -535,9 +561,13 @@ pub async fn create_account(
|
||||
}
|
||||
};
|
||||
let commit_cid_str = commit_cid.to_string();
|
||||
let repo_insert = sqlx::query!("INSERT INTO repos (user_id, repo_root_cid) VALUES ($1, $2)", user_id, commit_cid_str)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
let repo_insert = sqlx::query!(
|
||||
"INSERT INTO repos (user_id, repo_root_cid) VALUES ($1, $2)",
|
||||
user_id,
|
||||
commit_cid_str
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
if let Err(e) = repo_insert {
|
||||
error!("Error initializing repo: {:?}", e);
|
||||
return (
|
||||
@@ -547,10 +577,13 @@ pub async fn create_account(
|
||||
.into_response();
|
||||
}
|
||||
if let Some(code) = &input.invite_code {
|
||||
let use_insert =
|
||||
sqlx::query!("INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)", code, user_id)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
let use_insert = sqlx::query!(
|
||||
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
|
||||
code,
|
||||
user_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
if let Err(e) = use_insert {
|
||||
error!("Error recording invite usage: {:?}", e);
|
||||
return (
|
||||
@@ -568,10 +601,13 @@ pub async fn create_account(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)).await {
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)).await
|
||||
{
|
||||
warn!("Failed to sequence identity event for {}: {}", did, e);
|
||||
}
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(&state, &did, true, None).await {
|
||||
if let Err(e) = crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
|
||||
{
|
||||
warn!("Failed to sequence account event for {}: {}", did, e);
|
||||
}
|
||||
let profile_record = json!({
|
||||
@@ -584,7 +620,9 @@ pub async fn create_account(
|
||||
"app.bsky.actor.profile",
|
||||
"self",
|
||||
&profile_record,
|
||||
).await {
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to create default profile for {}: {}", did, e);
|
||||
}
|
||||
if let Err(e) = crate::notifications::enqueue_signup_verification(
|
||||
@@ -593,8 +631,13 @@ pub async fn create_account(
|
||||
verification_channel,
|
||||
&verification_recipient,
|
||||
&verification_code,
|
||||
).await {
|
||||
warn!("Failed to enqueue signup verification notification: {:?}", e);
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"Failed to enqueue signup verification notification: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
(
|
||||
StatusCode::OK,
|
||||
|
||||
+57
-34
@@ -47,7 +47,10 @@ pub async fn resolve_handle(
|
||||
.await;
|
||||
match user {
|
||||
Ok(Some(row)) => {
|
||||
let _ = state.cache.set(&cache_key, &row.did, std::time::Duration::from_secs(300)).await;
|
||||
let _ = state
|
||||
.cache
|
||||
.set(&cache_key, &row.did, std::time::Duration::from_secs(300))
|
||||
.await;
|
||||
(StatusCode::OK, Json(json!({ "did": row.did }))).into_response()
|
||||
}
|
||||
Ok(None) => (
|
||||
@@ -127,22 +130,23 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let key_row = sqlx::query!("SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let key_row = sqlx::query!(
|
||||
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let key_bytes: Vec<u8> = match key_row {
|
||||
Ok(Some(row)) => {
|
||||
match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
|
||||
Ok(k) => k,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Ok(Some(row)) => match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
|
||||
Ok(k) => k,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -283,7 +287,7 @@ pub async fn get_recommended_did_credentials(
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Response {
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok())
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
@@ -298,16 +302,24 @@ pub async fn get_recommended_did_credentials(
|
||||
Ok(user) => user,
|
||||
Err(e) => return ApiError::from(e).into_response(),
|
||||
};
|
||||
let user = match sqlx::query!("SELECT handle FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1", auth_user.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
let user = match sqlx::query!(
|
||||
"SELECT handle FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1",
|
||||
auth_user.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
_ => return ApiError::InternalError.into_response(),
|
||||
};
|
||||
let key_bytes = match auth_user.key_bytes {
|
||||
Some(kb) => kb,
|
||||
None => return ApiError::AuthenticationFailedMsg("OAuth tokens cannot get DID credentials".into()).into_response(),
|
||||
None => {
|
||||
return ApiError::AuthenticationFailedMsg(
|
||||
"OAuth tokens cannot get DID credentials".into(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let pds_endpoint = format!("https://{}", hostname);
|
||||
@@ -352,7 +364,7 @@ pub async fn update_handle(
|
||||
Json(input): Json<UpdateHandleInput>,
|
||||
) -> Response {
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok())
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => return ApiError::AuthenticationRequired.into_response(),
|
||||
@@ -378,7 +390,9 @@ pub async fn update_handle(
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"})),
|
||||
Json(
|
||||
json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
@@ -387,9 +401,13 @@ pub async fn update_handle(
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let existing = sqlx::query!("SELECT id FROM users WHERE handle = $1 AND id != $2", new_handle, user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let existing = sqlx::query!(
|
||||
"SELECT id FROM users WHERE handle = $1 AND id != $2",
|
||||
new_handle,
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
if let Ok(Some(_)) = existing {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -397,18 +415,26 @@ pub async fn update_handle(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let result = sqlx::query!("UPDATE users SET handle = $1 WHERE id = $2", new_handle, user_id)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
let result = sqlx::query!(
|
||||
"UPDATE users SET handle = $1 WHERE id = $2",
|
||||
new_handle,
|
||||
user_id
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(_) => {
|
||||
if let Some(old) = old_handle {
|
||||
let _ = state.cache.delete(&format!("handle:{}", old)).await;
|
||||
}
|
||||
let _ = state.cache.delete(&format!("handle:{}", new_handle)).await;
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let hostname =
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let full_handle = format!("{}.{}", new_handle, hostname);
|
||||
if let Err(e) = crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle)).await {
|
||||
if let Err(e) =
|
||||
crate::api::repo::record::sequence_identity_event(&state, &did, Some(&full_handle))
|
||||
.await
|
||||
{
|
||||
warn!("Failed to sequence identity event for handle update: {}", e);
|
||||
}
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
@@ -424,10 +450,7 @@ pub async fn update_handle(
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn well_known_atproto_did(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
pub async fn well_known_atproto_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
let host = match headers.get("host").and_then(|h| h.to_str().ok()) {
|
||||
Some(h) => h,
|
||||
None => return (StatusCode::BAD_REQUEST, "Missing host header").into_response(),
|
||||
|
||||
@@ -4,7 +4,7 @@ pub mod plc;
|
||||
|
||||
pub use account::create_account;
|
||||
pub use did::{
|
||||
get_recommended_did_credentials, resolve_handle, update_handle, user_did_doc, well_known_did,
|
||||
well_known_atproto_did,
|
||||
get_recommended_did_credentials, resolve_handle, update_handle, user_did_doc,
|
||||
well_known_atproto_did, well_known_did,
|
||||
};
|
||||
pub use plc::{request_plc_operation_signature, sign_plc_operation, submit_plc_operation};
|
||||
|
||||
@@ -3,5 +3,5 @@ mod sign;
|
||||
mod submit;
|
||||
|
||||
pub use request::request_plc_operation_signature;
|
||||
pub use sign::{sign_plc_operation, ServiceInput, SignPlcOperationInput, SignPlcOperationOutput};
|
||||
pub use submit::{submit_plc_operation, SubmitPlcOperationInput};
|
||||
pub use sign::{ServiceInput, SignPlcOperationInput, SignPlcOperationOutput, sign_plc_operation};
|
||||
pub use submit::{SubmitPlcOperationInput, submit_plc_operation};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use serde_json::json;
|
||||
@@ -67,16 +67,14 @@ pub async fn request_plc_operation_signature(
|
||||
.into_response();
|
||||
}
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) = crate::notifications::enqueue_plc_operation(
|
||||
&state.db,
|
||||
user.id,
|
||||
&plc_token,
|
||||
&hostname,
|
||||
)
|
||||
.await
|
||||
if let Err(e) =
|
||||
crate::notifications::enqueue_plc_operation(&state.db, user.id, &plc_token, &hostname).await
|
||||
{
|
||||
warn!("Failed to enqueue PLC operation notification: {:?}", e);
|
||||
}
|
||||
info!("PLC operation signature requested for user {}", auth_user.did);
|
||||
info!(
|
||||
"PLC operation signature requested for user {}",
|
||||
auth_user.did
|
||||
);
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::circuit_breaker::{with_circuit_breaker, CircuitBreakerError};
|
||||
use crate::circuit_breaker::{CircuitBreakerError, with_circuit_breaker};
|
||||
use crate::plc::{
|
||||
create_update_op, sign_operation, PlcClient, PlcError, PlcOpOrTombstone, PlcService,
|
||||
PlcClient, PlcError, PlcOpOrTombstone, PlcService, create_update_op, sign_operation,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use k256::ecdsa::SigningKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
@@ -59,8 +59,9 @@ pub async fn sign_plc_operation(
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return ApiError::InvalidRequest(
|
||||
"Email confirmation token required to sign PLC operations".into()
|
||||
).into_response();
|
||||
"Email confirmation token required to sign PLC operations".into(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", did)
|
||||
@@ -105,9 +106,12 @@ pub async fn sign_plc_operation(
|
||||
}
|
||||
};
|
||||
if Utc::now() > token_row.expires_at {
|
||||
let _ = sqlx::query!("DELETE FROM plc_operation_tokens WHERE id = $1", token_row.id)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
let _ = sqlx::query!(
|
||||
"DELETE FROM plc_operation_tokens WHERE id = $1",
|
||||
token_row.id
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
@@ -158,11 +162,11 @@ pub async fn sign_plc_operation(
|
||||
};
|
||||
let plc_client = PlcClient::new(None);
|
||||
let did_clone = did.clone();
|
||||
let result: Result<PlcOpOrTombstone, CircuitBreakerError<PlcError>> = with_circuit_breaker(
|
||||
&state.circuit_breakers.plc_directory,
|
||||
|| async { plc_client.get_last_op(&did_clone).await },
|
||||
)
|
||||
.await;
|
||||
let result: Result<PlcOpOrTombstone, CircuitBreakerError<PlcError>> =
|
||||
with_circuit_breaker(&state.circuit_breakers.plc_directory, || async {
|
||||
plc_client.get_last_op(&did_clone).await
|
||||
})
|
||||
.await;
|
||||
let last_op = match result {
|
||||
Ok(op) => op,
|
||||
Err(CircuitBreakerError::CircuitOpen(e)) => {
|
||||
@@ -259,9 +263,12 @@ pub async fn sign_plc_operation(
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let _ = sqlx::query!("DELETE FROM plc_operation_tokens WHERE id = $1", token_row.id)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
let _ = sqlx::query!(
|
||||
"DELETE FROM plc_operation_tokens WHERE id = $1",
|
||||
token_row.id
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
info!("Signed PLC operation for user {}", did);
|
||||
(
|
||||
StatusCode::OK,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::circuit_breaker::{with_circuit_breaker, CircuitBreakerError};
|
||||
use crate::plc::{signing_key_to_did_key, validate_plc_operation, PlcClient, PlcError};
|
||||
use crate::circuit_breaker::{CircuitBreakerError, with_circuit_breaker};
|
||||
use crate::plc::{PlcClient, PlcError, signing_key_to_did_key, validate_plc_operation};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use k256::ecdsa::SigningKey;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -110,8 +110,8 @@ pub async fn submit_plc_operation(
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
if let Some(services) = op.get("services").and_then(|v| v.as_object()) {
|
||||
if let Some(pds) = services.get("atproto_pds").and_then(|v| v.as_object()) {
|
||||
if let Some(services) = op.get("services").and_then(|v| v.as_object())
|
||||
&& let Some(pds) = services.get("atproto_pds").and_then(|v| v.as_object()) {
|
||||
let service_type = pds.get("type").and_then(|v| v.as_str());
|
||||
let endpoint = pds.get("endpoint").and_then(|v| v.as_str());
|
||||
if service_type != Some("AtprotoPersonalDataServer") {
|
||||
@@ -135,10 +135,9 @@ pub async fn submit_plc_operation(
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(verification_methods) = op.get("verificationMethods").and_then(|v| v.as_object()) {
|
||||
if let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str()) {
|
||||
if atproto_key != user_did_key {
|
||||
if let Some(verification_methods) = op.get("verificationMethods").and_then(|v| v.as_object())
|
||||
&& let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str())
|
||||
&& atproto_key != user_did_key {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
@@ -148,8 +147,6 @@ pub async fn submit_plc_operation(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(also_known_as) = op.get("alsoKnownAs").and_then(|v| v.as_array()) {
|
||||
let expected_handle = format!("at://{}", user.handle);
|
||||
let first_aka = also_known_as.first().and_then(|v| v.as_str());
|
||||
@@ -167,11 +164,13 @@ pub async fn submit_plc_operation(
|
||||
let plc_client = PlcClient::new(None);
|
||||
let operation_clone = input.operation.clone();
|
||||
let did_clone = did.clone();
|
||||
let result: Result<(), CircuitBreakerError<PlcError>> = with_circuit_breaker(
|
||||
&state.circuit_breakers.plc_directory,
|
||||
|| async { plc_client.send_operation(&did_clone, &operation_clone).await },
|
||||
)
|
||||
.await;
|
||||
let result: Result<(), CircuitBreakerError<PlcError>> =
|
||||
with_circuit_breaker(&state.circuit_breakers.plc_directory, || async {
|
||||
plc_client
|
||||
.send_operation(&did_clone, &operation_clone)
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
match result {
|
||||
Ok(()) => {}
|
||||
Err(CircuitBreakerError::CircuitOpen(e)) => {
|
||||
|
||||
+1
-1
@@ -15,4 +15,4 @@ pub mod temp;
|
||||
pub mod validation;
|
||||
|
||||
pub use error::ApiError;
|
||||
pub use proxy_client::{proxy_client, validate_at_uri, validate_did, validate_limit, AtUriParts};
|
||||
pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_did, validate_limit};
|
||||
|
||||
@@ -35,7 +35,7 @@ pub async fn create_report(
|
||||
Json(input): Json<CreateReportInput>,
|
||||
) -> Response {
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok())
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => return ApiError::AuthenticationRequired.into_response(),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use crate::api::proxy_client::{is_ssrf_safe, proxy_client, validate_did};
|
||||
use crate::api::ApiError;
|
||||
use crate::api::proxy_client::{is_ssrf_safe, proxy_client, validate_did};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::auth::validate_bearer_token;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -8,8 +10,6 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::Row;
|
||||
use tracing::info;
|
||||
use crate::auth::validate_bearer_token;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -24,21 +24,16 @@ pub struct NotificationPrefsResponse {
|
||||
pub signal_verified: bool,
|
||||
}
|
||||
|
||||
pub async fn get_notification_prefs(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
pub async fn get_notification_prefs(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
None => return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
|
||||
)
|
||||
.into_response(),
|
||||
};
|
||||
let user = match validate_bearer_token(&state.db, &token).await {
|
||||
Ok(u) => u,
|
||||
@@ -47,11 +42,12 @@ pub async fn get_notification_prefs(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
|
||||
)
|
||||
.into_response()
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let row = match sqlx::query(
|
||||
r#"
|
||||
let row =
|
||||
match sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
email,
|
||||
preferred_notification_channel::text as channel,
|
||||
@@ -63,21 +59,21 @@ pub async fn get_notification_prefs(
|
||||
signal_verified
|
||||
FROM users
|
||||
WHERE did = $1
|
||||
"#
|
||||
)
|
||||
.bind(&user.did)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return (
|
||||
"#,
|
||||
)
|
||||
.bind(&user.did)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
|
||||
Json(
|
||||
json!({"error": "InternalError", "message": format!("Database error: {}", e)}),
|
||||
),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
.into_response(),
|
||||
};
|
||||
let email: String = row.get("email");
|
||||
let channel: String = row.get("channel");
|
||||
let discord_id: Option<String> = row.get("discord_id");
|
||||
@@ -117,13 +113,11 @@ pub async fn update_notification_prefs(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
None => return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
|
||||
)
|
||||
.into_response(),
|
||||
};
|
||||
let user = match validate_bearer_token(&state.db, &token).await {
|
||||
Ok(u) => u,
|
||||
@@ -132,7 +126,7 @@ pub async fn update_notification_prefs(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
|
||||
)
|
||||
.into_response()
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if let Some(ref channel) = input.preferred_channel {
|
||||
@@ -208,7 +202,11 @@ pub async fn update_notification_prefs(
|
||||
info!(did = %user.did, "Updated Telegram username");
|
||||
}
|
||||
if let Some(ref signal) = input.signal_number {
|
||||
let signal_clean: Option<&str> = if signal.is_empty() { None } else { Some(signal.as_str()) };
|
||||
let signal_clean: Option<&str> = if signal.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(signal.as_str())
|
||||
};
|
||||
if let Err(e) = sqlx::query(
|
||||
r#"UPDATE users SET signal_number = $1, signal_verified = FALSE, updated_at = NOW() WHERE did = $2"#
|
||||
)
|
||||
|
||||
+15
-22
@@ -1,3 +1,4 @@
|
||||
use crate::api::proxy_client::proxy_client;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
@@ -5,19 +6,15 @@ use axum::{
|
||||
http::{HeaderMap, Method, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use crate::api::proxy_client::proxy_client;
|
||||
use std::collections::HashMap;
|
||||
use tracing::error;
|
||||
|
||||
fn resolve_service_did(did_with_fragment: &str) -> Option<(String, String)> {
|
||||
if did_with_fragment.starts_with("did:web:") {
|
||||
let without_prefix = &did_with_fragment[8..];
|
||||
if let Some(without_prefix) = did_with_fragment.strip_prefix("did:web:") {
|
||||
let host = without_prefix.split('#').next()?;
|
||||
let url = format!("https://{}", host);
|
||||
let did_without_fragment = format!("did:web:{}", host);
|
||||
Some((url, did_without_fragment))
|
||||
} else if did_with_fragment.starts_with("did:plc:") {
|
||||
None
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -41,7 +38,8 @@ pub async fn proxy_handler(
|
||||
Some(resolved) => resolved,
|
||||
None => {
|
||||
error!(did = %did_str, "Could not resolve service DID");
|
||||
return (StatusCode::BAD_GATEWAY, "Could not resolve service DID").into_response();
|
||||
return (StatusCode::BAD_GATEWAY, "Could not resolve service DID")
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
(url, Some(did_without_fragment))
|
||||
@@ -50,7 +48,8 @@ pub async fn proxy_handler(
|
||||
let url = match std::env::var("APPVIEW_URL") {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
return (StatusCode::BAD_GATEWAY, "No upstream AppView configured").into_response();
|
||||
return (StatusCode::BAD_GATEWAY, "No upstream AppView configured")
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let aud = std::env::var("APPVIEW_DID").ok();
|
||||
@@ -60,26 +59,20 @@ pub async fn proxy_handler(
|
||||
let target_url = format!("{}/xrpc/{}", appview_url, method);
|
||||
let client = proxy_client();
|
||||
let mut request_builder = client.request(method_verb, &target_url).query(¶ms);
|
||||
let mut auth_header_val = headers.get("Authorization").map(|h| h.clone());
|
||||
if let Some(aud) = &service_aud {
|
||||
if let Some(token) = crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok())
|
||||
) {
|
||||
if let Ok(auth_user) = crate::auth::validate_bearer_token(&state.db, &token).await {
|
||||
if let Some(key_bytes) = auth_user.key_bytes {
|
||||
if let Ok(new_token) =
|
||||
let mut auth_header_val = headers.get("Authorization").cloned();
|
||||
if let Some(aud) = &service_aud
|
||||
&& let Some(token) = crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
)
|
||||
&& let Ok(auth_user) = crate::auth::validate_bearer_token(&state.db, &token).await
|
||||
&& let Some(key_bytes) = auth_user.key_bytes
|
||||
&& let Ok(new_token) =
|
||||
crate::auth::create_service_token(&auth_user.did, aud, &method, &key_bytes)
|
||||
{
|
||||
if let Ok(val) =
|
||||
&& let Ok(val) =
|
||||
axum::http::HeaderValue::from_str(&format!("Bearer {}", new_token))
|
||||
{
|
||||
auth_header_val = Some(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(val) = auth_header_val {
|
||||
request_builder = request_builder.header("Authorization", val);
|
||||
}
|
||||
|
||||
+14
-5
@@ -20,7 +20,9 @@ pub fn proxy_client() -> &'static Client {
|
||||
.pool_idle_timeout(Duration::from_secs(90))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("Failed to build HTTP client - this indicates a TLS or system configuration issue")
|
||||
.expect(
|
||||
"Failed to build HTTP client - this indicates a TLS or system configuration issue",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -48,7 +50,9 @@ pub fn is_ssrf_safe(url: &str) -> Result<(), SsrfError> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let port = parsed.port().unwrap_or(if scheme == "https" { 443 } else { 80 });
|
||||
let port = parsed
|
||||
.port()
|
||||
.unwrap_or(if scheme == "https" { 443 } else { 80 });
|
||||
let socket_addrs: Vec<SocketAddr> = match (host, port).to_socket_addrs() {
|
||||
Ok(addrs) => addrs.collect(),
|
||||
Err(_) => return Err(SsrfError::DnsResolutionFailed(host.to_string())),
|
||||
@@ -104,7 +108,9 @@ impl std::fmt::Display for SsrfError {
|
||||
SsrfError::InsecureProtocol(p) => write!(f, "Insecure protocol: {}", p),
|
||||
SsrfError::NoHost => write!(f, "No host in URL"),
|
||||
SsrfError::NonUnicastIp(ip) => write!(f, "Non-unicast IP address: {}", ip),
|
||||
SsrfError::DnsResolutionFailed(host) => write!(f, "DNS resolution failed for: {}", host),
|
||||
SsrfError::DnsResolutionFailed(host) => {
|
||||
write!(f, "DNS resolution failed for: {}", host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -158,7 +164,7 @@ pub struct AtUriParts {
|
||||
|
||||
pub fn validate_limit(limit: Option<u32>, default: u32, max: u32) -> u32 {
|
||||
match limit {
|
||||
Some(l) if l == 0 => default,
|
||||
Some(0) => default,
|
||||
Some(l) if l > max => max,
|
||||
Some(l) => l,
|
||||
None => default,
|
||||
@@ -190,7 +196,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_ssrf_blocks_http_by_default() {
|
||||
let result = is_ssrf_safe("http://external.example.com/xrpc/test");
|
||||
assert!(matches!(result, Err(SsrfError::InsecureProtocol(_)) | Err(SsrfError::DnsResolutionFailed(_))));
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(SsrfError::InsecureProtocol(_)) | Err(SsrfError::DnsResolutionFailed(_))
|
||||
));
|
||||
}
|
||||
#[test]
|
||||
fn test_ssrf_allows_localhost_http() {
|
||||
|
||||
+20
-19
@@ -1,12 +1,12 @@
|
||||
use crate::api::proxy_client::{
|
||||
is_ssrf_safe, proxy_client, MAX_RESPONSE_SIZE, RESPONSE_HEADERS_TO_FORWARD,
|
||||
};
|
||||
use crate::api::ApiError;
|
||||
use crate::api::proxy_client::{
|
||||
MAX_RESPONSE_SIZE, RESPONSE_HEADERS_TO_FORWARD, is_ssrf_safe, proxy_client,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -182,8 +182,8 @@ pub async fn get_records_since_rev(
|
||||
record,
|
||||
});
|
||||
}
|
||||
} else if data.collection == "app.bsky.feed.like" {
|
||||
if let Ok(record) = serde_ipld_dagcbor::from_slice::<LikeRecord>(&block_bytes) {
|
||||
} else if data.collection == "app.bsky.feed.like"
|
||||
&& let Ok(record) = serde_ipld_dagcbor::from_slice::<LikeRecord>(&block_bytes) {
|
||||
result.likes.push(RecordDescript {
|
||||
uri,
|
||||
cid: data.cid_str,
|
||||
@@ -191,7 +191,6 @@ pub async fn get_records_since_rev(
|
||||
record,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
@@ -250,18 +249,21 @@ pub async fn proxy_to_appview(
|
||||
})?;
|
||||
if let Err(e) = is_ssrf_safe(&appview_url) {
|
||||
error!("SSRF check failed for appview URL: {}", e);
|
||||
return Err(ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e))
|
||||
.into_response());
|
||||
return Err(
|
||||
ApiError::UpstreamUnavailable(format!("Invalid upstream URL: {}", e)).into_response(),
|
||||
);
|
||||
}
|
||||
let target_url = format!("{}/xrpc/{}", appview_url, method);
|
||||
info!(target = %target_url, "Proxying request to appview");
|
||||
let client = proxy_client();
|
||||
let mut request_builder = client.get(&target_url).query(params);
|
||||
if let Some(key_bytes) = auth_key_bytes {
|
||||
let appview_did = std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
|
||||
let appview_did =
|
||||
std::env::var("APPVIEW_DID").unwrap_or_else(|_| "did:web:api.bsky.app".to_string());
|
||||
match crate::auth::create_service_token(auth_did, &appview_did, method, key_bytes) {
|
||||
Ok(service_token) => {
|
||||
request_builder = request_builder.header("Authorization", format!("Bearer {}", service_token));
|
||||
request_builder =
|
||||
request_builder.header("Authorization", format!("Bearer {}", service_token));
|
||||
}
|
||||
Err(e) => {
|
||||
error!(error = ?e, "Failed to create service token");
|
||||
@@ -287,9 +289,7 @@ pub async fn proxy_to_appview(
|
||||
Some((name, value))
|
||||
})
|
||||
.collect();
|
||||
let content_length = resp
|
||||
.content_length()
|
||||
.unwrap_or(0);
|
||||
let content_length = resp.content_length().unwrap_or(0);
|
||||
if content_length > MAX_RESPONSE_SIZE {
|
||||
error!(
|
||||
content_length,
|
||||
@@ -321,8 +321,10 @@ pub async fn proxy_to_appview(
|
||||
if e.is_timeout() {
|
||||
Err(ApiError::UpstreamTimeout.into_response())
|
||||
} else if e.is_connect() {
|
||||
Err(ApiError::UpstreamUnavailable("Failed to connect to upstream".to_string())
|
||||
.into_response())
|
||||
Err(
|
||||
ApiError::UpstreamUnavailable("Failed to connect to upstream".to_string())
|
||||
.into_response(),
|
||||
)
|
||||
} else {
|
||||
Err(ApiError::UpstreamFailure.into_response())
|
||||
}
|
||||
@@ -332,13 +334,12 @@ pub async fn proxy_to_appview(
|
||||
|
||||
pub fn format_munged_response<T: Serialize>(data: T, lag: Option<i64>) -> Response {
|
||||
let mut response = (StatusCode::OK, Json(data)).into_response();
|
||||
if let Some(lag_ms) = lag {
|
||||
if let Ok(header_val) = HeaderValue::from_str(&lag_ms.to_string()) {
|
||||
if let Some(lag_ms) = lag
|
||||
&& let Ok(header_val) = HeaderValue::from_str(&lag_ms.to_string()) {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(UPSTREAM_LAG_HEADER, header_val);
|
||||
}
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
|
||||
+27
-22
@@ -30,7 +30,7 @@ pub async fn upload_blob(
|
||||
.into_response();
|
||||
}
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok())
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
@@ -122,8 +122,12 @@ pub async fn upload_blob(
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if was_inserted {
|
||||
if let Err(e) = state.blob_store.put_bytes(&storage_key, bytes::Bytes::from(data)).await {
|
||||
if was_inserted
|
||||
&& let Err(e) = state
|
||||
.blob_store
|
||||
.put_bytes(&storage_key, bytes::Bytes::from(data))
|
||||
.await
|
||||
{
|
||||
error!("Failed to upload blob to storage: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -131,14 +135,15 @@ pub async fn upload_blob(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
if let Err(e) = tx.commit().await {
|
||||
error!("Failed to commit blob transaction: {:?}", e);
|
||||
if was_inserted {
|
||||
if let Err(cleanup_err) = state.blob_store.delete(&storage_key).await {
|
||||
error!("Failed to cleanup orphaned blob {}: {:?}", storage_key, cleanup_err);
|
||||
if was_inserted
|
||||
&& let Err(cleanup_err) = state.blob_store.delete(&storage_key).await {
|
||||
error!(
|
||||
"Failed to cleanup orphaned blob {}: {:?}",
|
||||
storage_key, cleanup_err
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
@@ -179,17 +184,13 @@ pub struct ListMissingBlobsOutput {
|
||||
|
||||
fn find_blobs(val: &serde_json::Value, blobs: &mut Vec<String>) {
|
||||
if let Some(obj) = val.as_object() {
|
||||
if let Some(type_val) = obj.get("$type") {
|
||||
if type_val == "blob" {
|
||||
if let Some(r) = obj.get("ref") {
|
||||
if let Some(link) = r.get("$link") {
|
||||
if let Some(s) = link.as_str() {
|
||||
if let Some(type_val) = obj.get("$type")
|
||||
&& type_val == "blob"
|
||||
&& let Some(r) = obj.get("ref")
|
||||
&& let Some(link) = r.get("$link")
|
||||
&& let Some(s) = link.as_str() {
|
||||
blobs.push(s.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (_, v) in obj {
|
||||
find_blobs(v, blobs);
|
||||
}
|
||||
@@ -206,7 +207,7 @@ pub async fn list_missing_blobs(
|
||||
Query(params): Query<ListMissingBlobsParams>,
|
||||
) -> Response {
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok())
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
@@ -276,7 +277,7 @@ pub async fn list_missing_blobs(
|
||||
let rkey = &row.rkey;
|
||||
let record_cid_str = &row.record_cid;
|
||||
last_cursor = Some(format!("{}|{}", collection, rkey));
|
||||
let record_cid = match Cid::from_str(&record_cid_str) {
|
||||
let record_cid = match Cid::from_str(record_cid_str) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
@@ -291,9 +292,13 @@ pub async fn list_missing_blobs(
|
||||
let mut blobs = Vec::new();
|
||||
find_blobs(&record_val, &mut blobs);
|
||||
for blob_cid_str in blobs {
|
||||
let exists = sqlx::query!("SELECT 1 as one FROM blobs WHERE cid = $1 AND created_by_user = $2", blob_cid_str, user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let exists = sqlx::query!(
|
||||
"SELECT 1 as one FROM blobs WHERE cid = $1 AND created_by_user = $2",
|
||||
blob_cid_str,
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
match exists {
|
||||
Ok(None) => {
|
||||
missing_blobs.push(RecordBlob {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::state::AppState;
|
||||
use crate::sync::import::{apply_import, parse_car, ImportError};
|
||||
use crate::sync::import::{ImportError, apply_import, parse_car};
|
||||
use crate::sync::verify::CarVerifier;
|
||||
use axum::{
|
||||
Json,
|
||||
body::Bytes,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
+20
-12
@@ -18,15 +18,21 @@ pub async fn describe_repo(
|
||||
Query(input): Query<DescribeRepoInput>,
|
||||
) -> Response {
|
||||
let user_row = if input.repo.starts_with("did:") {
|
||||
sqlx::query!("SELECT id, handle, did FROM users WHERE did = $1", input.repo)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map(|opt| opt.map(|r| (r.id, r.handle, r.did)))
|
||||
sqlx::query!(
|
||||
"SELECT id, handle, did FROM users WHERE did = $1",
|
||||
input.repo
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map(|opt| opt.map(|r| (r.id, r.handle, r.did)))
|
||||
} else {
|
||||
sqlx::query!("SELECT id, handle, did FROM users WHERE handle = $1", input.repo)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map(|opt| opt.map(|r| (r.id, r.handle, r.did)))
|
||||
sqlx::query!(
|
||||
"SELECT id, handle, did FROM users WHERE handle = $1",
|
||||
input.repo
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map(|opt| opt.map(|r| (r.id, r.handle, r.did)))
|
||||
};
|
||||
let (user_id, handle, did) = match user_row {
|
||||
Ok(Some((id, handle, did))) => (id, handle, did),
|
||||
@@ -38,10 +44,12 @@ pub async fn describe_repo(
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let collections_query =
|
||||
sqlx::query!("SELECT DISTINCT collection FROM records WHERE repo_id = $1", user_id)
|
||||
.fetch_all(&state.db)
|
||||
.await;
|
||||
let collections_query = sqlx::query!(
|
||||
"SELECT DISTINCT collection FROM records WHERE repo_id = $1",
|
||||
user_id
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await;
|
||||
let collections: Vec<String> = match collections_query {
|
||||
Ok(rows) => rows.iter().map(|r| r.collection.clone()).collect(),
|
||||
Err(_) => Vec::new(),
|
||||
|
||||
+3
-1
@@ -6,4 +6,6 @@ pub mod record;
|
||||
pub use blob::{list_missing_blobs, upload_blob};
|
||||
pub use import::import_repo;
|
||||
pub use meta::describe_repo;
|
||||
pub use record::{apply_writes, create_record, delete_record, get_record, list_records, put_record};
|
||||
pub use record::{
|
||||
apply_writes, create_record, delete_record, get_record, list_records, put_record,
|
||||
};
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
use super::validation::validate_record;
|
||||
use super::write::has_verified_notification_channel;
|
||||
use crate::api::repo::record::utils::{commit_and_log, RecordOp};
|
||||
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
|
||||
use crate::repo::tracking::TrackingBlockStore;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use cid::Cid;
|
||||
use jacquard::types::{integer::LimitedU32, string::{Nsid, Tid}};
|
||||
use jacquard::types::{
|
||||
integer::LimitedU32,
|
||||
string::{Nsid, Tid},
|
||||
};
|
||||
use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
@@ -77,7 +80,7 @@ pub async fn apply_writes(
|
||||
Json(input): Json<ApplyWritesInput>,
|
||||
) -> Response {
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok())
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
@@ -154,20 +157,22 @@ pub async fn apply_writes(
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let root_cid_str: String =
|
||||
match sqlx::query_scalar!("SELECT repo_root_cid FROM repos WHERE user_id = $1", user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(cid_str)) => cid_str,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Repo root not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let root_cid_str: String = match sqlx::query_scalar!(
|
||||
"SELECT repo_root_cid FROM repos WHERE user_id = $1",
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(cid_str)) => cid_str,
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Repo root not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let current_root_cid = match Cid::from_str(&root_cid_str) {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
@@ -178,15 +183,14 @@ pub async fn apply_writes(
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if let Some(swap_commit) = &input.swap_commit {
|
||||
if Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
|
||||
if let Some(swap_commit) = &input.swap_commit
|
||||
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
|
||||
let commit_bytes = match tracking_store.get(¤t_root_cid).await {
|
||||
Ok(Some(b)) => b,
|
||||
@@ -195,7 +199,7 @@ pub async fn apply_writes(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Commit block not found"})),
|
||||
)
|
||||
.into_response()
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let commit = match Commit::from_cbor(&commit_bytes) {
|
||||
@@ -205,7 +209,7 @@ pub async fn apply_writes(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to parse commit"})),
|
||||
)
|
||||
.into_response()
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let original_mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
|
||||
@@ -220,11 +224,10 @@ pub async fn apply_writes(
|
||||
rkey,
|
||||
value,
|
||||
} => {
|
||||
if input.validate.unwrap_or(true) {
|
||||
if let Err(err_response) = validate_record(value, collection) {
|
||||
return err_response;
|
||||
if input.validate.unwrap_or(true)
|
||||
&& let Err(err_response) = validate_record(value, collection) {
|
||||
return *err_response;
|
||||
}
|
||||
}
|
||||
let rkey = rkey
|
||||
.clone()
|
||||
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
|
||||
@@ -234,7 +237,13 @@ pub async fn apply_writes(
|
||||
}
|
||||
let record_cid = match tracking_store.put(&record_bytes).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to store record"}))).into_response(),
|
||||
Err(_) => return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(
|
||||
json!({"error": "InternalError", "message": "Failed to store record"}),
|
||||
),
|
||||
)
|
||||
.into_response(),
|
||||
};
|
||||
let collection_nsid = match collection.parse::<Nsid>() {
|
||||
Ok(n) => n,
|
||||
@@ -244,7 +253,11 @@ pub async fn apply_writes(
|
||||
modified_keys.push(key.clone());
|
||||
mst = match mst.add(&key, record_cid).await {
|
||||
Ok(m) => m,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to add to MST"}))).into_response(),
|
||||
Err(_) => return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to add to MST"})),
|
||||
)
|
||||
.into_response(),
|
||||
};
|
||||
let uri = format!("at://{}/{}/{}", did, collection, rkey);
|
||||
results.push(WriteResult::CreateResult {
|
||||
@@ -262,18 +275,23 @@ pub async fn apply_writes(
|
||||
rkey,
|
||||
value,
|
||||
} => {
|
||||
if input.validate.unwrap_or(true) {
|
||||
if let Err(err_response) = validate_record(value, collection) {
|
||||
return err_response;
|
||||
if input.validate.unwrap_or(true)
|
||||
&& let Err(err_response) = validate_record(value, collection) {
|
||||
return *err_response;
|
||||
}
|
||||
}
|
||||
let mut record_bytes = Vec::new();
|
||||
if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() {
|
||||
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
|
||||
}
|
||||
let record_cid = match tracking_store.put(&record_bytes).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to store record"}))).into_response(),
|
||||
Err(_) => return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(
|
||||
json!({"error": "InternalError", "message": "Failed to store record"}),
|
||||
),
|
||||
)
|
||||
.into_response(),
|
||||
};
|
||||
let collection_nsid = match collection.parse::<Nsid>() {
|
||||
Ok(n) => n,
|
||||
@@ -284,7 +302,11 @@ pub async fn apply_writes(
|
||||
let prev_record_cid = mst.get(&key).await.ok().flatten();
|
||||
mst = match mst.update(&key, record_cid).await {
|
||||
Ok(m) => m,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to update MST"}))).into_response(),
|
||||
Err(_) => return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to update MST"})),
|
||||
)
|
||||
.into_response(),
|
||||
};
|
||||
let uri = format!("at://{}/{}/{}", did, collection, rkey);
|
||||
results.push(WriteResult::UpdateResult {
|
||||
@@ -321,14 +343,24 @@ pub async fn apply_writes(
|
||||
}
|
||||
let new_mst_root = match mst.persist().await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to persist MST"}))).into_response(),
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to persist MST"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let mut relevant_blocks = std::collections::BTreeMap::new();
|
||||
for key in &modified_keys {
|
||||
if let Err(_) = mst.blocks_for_path(key, &mut relevant_blocks).await {
|
||||
if mst.blocks_for_path(key, &mut relevant_blocks).await.is_err() {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response();
|
||||
}
|
||||
if let Err(_) = original_mst.blocks_for_path(key, &mut relevant_blocks).await {
|
||||
if original_mst
|
||||
.blocks_for_path(key, &mut relevant_blocks)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response();
|
||||
}
|
||||
}
|
||||
@@ -344,13 +376,15 @@ pub async fn apply_writes(
|
||||
.collect::<Vec<_>>();
|
||||
let commit_res = match commit_and_log(
|
||||
&state,
|
||||
&did,
|
||||
user_id,
|
||||
Some(current_root_cid),
|
||||
Some(commit.data),
|
||||
new_mst_root,
|
||||
ops,
|
||||
&written_cids_str,
|
||||
CommitParams {
|
||||
did: &did,
|
||||
user_id,
|
||||
current_root_cid: Some(current_root_cid),
|
||||
prev_data_cid: Some(commit.data),
|
||||
new_mst_root,
|
||||
ops,
|
||||
blocks_cids: &written_cids_str,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use crate::api::repo::record::utils::{commit_and_log, RecordOp};
|
||||
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
|
||||
use crate::api::repo::record::write::prepare_repo_write;
|
||||
use crate::repo::tracking::TrackingBlockStore;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use cid::Cid;
|
||||
use jacquard::types::string::Nsid;
|
||||
@@ -38,32 +38,45 @@ pub async fn delete_record(
|
||||
Ok(res) => res,
|
||||
Err(err_res) => return err_res,
|
||||
};
|
||||
if let Some(swap_commit) = &input.swap_commit {
|
||||
if Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
|
||||
if let Some(swap_commit) = &input.swap_commit
|
||||
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
|
||||
let commit_bytes = match tracking_store.get(¤t_root_cid).await {
|
||||
Ok(Some(b)) => b,
|
||||
_ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Commit block not found"}))).into_response(),
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Commit block not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let commit = match Commit::from_cbor(&commit_bytes) {
|
||||
Ok(c) => c,
|
||||
_ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to parse commit"}))).into_response(),
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to parse commit"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let mst = Mst::load(
|
||||
Arc::new(tracking_store.clone()),
|
||||
commit.data,
|
||||
None,
|
||||
);
|
||||
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
|
||||
let collection_nsid = match input.collection.parse::<Nsid>() {
|
||||
Ok(n) => n,
|
||||
Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection"}))).into_response(),
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidCollection"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let key = format!("{}/{}", collection_nsid, input.rkey);
|
||||
if let Some(swap_record_str) = &input.swap_record {
|
||||
@@ -88,15 +101,23 @@ pub async fn delete_record(
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("Failed to persist MST: {:?}", e);
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to persist MST"}))).into_response();
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to persist MST"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let op = RecordOp::Delete { collection: input.collection, rkey: input.rkey, prev: prev_record_cid };
|
||||
let op = RecordOp::Delete {
|
||||
collection: input.collection,
|
||||
rkey: input.rkey,
|
||||
prev: prev_record_cid,
|
||||
};
|
||||
let mut relevant_blocks = std::collections::BTreeMap::new();
|
||||
if let Err(_) = new_mst.blocks_for_path(&key, &mut relevant_blocks).await {
|
||||
if new_mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response();
|
||||
}
|
||||
if let Err(_) = mst.blocks_for_path(&key, &mut relevant_blocks).await {
|
||||
if mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response();
|
||||
}
|
||||
let mut written_cids = tracking_store.get_all_relevant_cids();
|
||||
@@ -105,9 +126,29 @@ pub async fn delete_record(
|
||||
written_cids.push(*cid);
|
||||
}
|
||||
}
|
||||
let written_cids_str = written_cids.iter().map(|c| c.to_string()).collect::<Vec<_>>();
|
||||
if let Err(e) = commit_and_log(&state, &did, user_id, Some(current_root_cid), Some(commit.data), new_mst_root, vec![op], &written_cids_str).await {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": e}))).into_response();
|
||||
let written_cids_str = written_cids
|
||||
.iter()
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
if let Err(e) = commit_and_log(
|
||||
&state,
|
||||
CommitParams {
|
||||
did: &did,
|
||||
user_id,
|
||||
current_root_cid: Some(current_root_cid),
|
||||
prev_data_cid: Some(commit.data),
|
||||
new_mst_root,
|
||||
ops: vec![op],
|
||||
blocks_cids: &written_cids_str,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ pub use read::{GetRecordInput, ListRecordsInput, ListRecordsOutput, get_record,
|
||||
pub use utils::*;
|
||||
pub use write::{
|
||||
CreateRecordInput, CreateRecordOutput, PutRecordInput, PutRecordOutput, create_record,
|
||||
put_record, prepare_repo_write,
|
||||
prepare_repo_write, put_record,
|
||||
};
|
||||
|
||||
@@ -71,15 +71,14 @@ pub async fn get_record(
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if let Some(expected_cid) = &input.cid {
|
||||
if &record_cid_str != expected_cid {
|
||||
if let Some(expected_cid) = &input.cid
|
||||
&& &record_cid_str != expected_cid {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "NotFound", "message": "Record CID mismatch"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
let cid = match Cid::from_str(&record_cid_str) {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
@@ -192,7 +191,11 @@ pub async fn list_records(
|
||||
param_idx += 1;
|
||||
}
|
||||
if input.rkey_end.is_some() {
|
||||
conditions.push(if param_idx == 3 { "rkey < $3" } else { "rkey < $4" });
|
||||
conditions.push(if param_idx == 3 {
|
||||
"rkey < $3"
|
||||
} else {
|
||||
"rkey < $4"
|
||||
});
|
||||
param_idx += 1;
|
||||
}
|
||||
let limit_idx = param_idx;
|
||||
@@ -246,17 +249,15 @@ pub async fn list_records(
|
||||
};
|
||||
let mut records = Vec::new();
|
||||
for (cid, block_opt) in cids.iter().zip(blocks.into_iter()) {
|
||||
if let Some(block) = block_opt {
|
||||
if let Some((rkey, cid_str)) = cid_to_rkey.get(cid) {
|
||||
if let Ok(value) = serde_ipld_dagcbor::from_slice::<serde_json::Value>(&block) {
|
||||
if let Some(block) = block_opt
|
||||
&& let Some((rkey, cid_str)) = cid_to_rkey.get(cid)
|
||||
&& let Ok(value) = serde_ipld_dagcbor::from_slice::<serde_json::Value>(&block) {
|
||||
records.push(json!({
|
||||
"uri": format!("at://{}/{}/{}", input.repo, input.collection, rkey),
|
||||
"cid": cid_str,
|
||||
"value": value
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Json(ListRecordsOutput {
|
||||
cursor: last_rkey,
|
||||
|
||||
+150
-74
@@ -3,7 +3,7 @@ use bytes::Bytes;
|
||||
use cid::Cid;
|
||||
use jacquard::types::{integer::LimitedU32, string::Tid};
|
||||
use jacquard_repo::storage::BlockStore;
|
||||
use k256::ecdsa::{signature::Signer, Signature, SigningKey};
|
||||
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
@@ -71,9 +71,22 @@ fn create_signed_commit(
|
||||
}
|
||||
|
||||
pub enum RecordOp {
|
||||
Create { collection: String, rkey: String, cid: Cid },
|
||||
Update { collection: String, rkey: String, cid: Cid, prev: Option<Cid> },
|
||||
Delete { collection: String, rkey: String, prev: Option<Cid> },
|
||||
Create {
|
||||
collection: String,
|
||||
rkey: String,
|
||||
cid: Cid,
|
||||
},
|
||||
Update {
|
||||
collection: String,
|
||||
rkey: String,
|
||||
cid: Cid,
|
||||
prev: Option<Cid>,
|
||||
},
|
||||
Delete {
|
||||
collection: String,
|
||||
rkey: String,
|
||||
prev: Option<Cid>,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct CommitResult {
|
||||
@@ -81,16 +94,29 @@ pub struct CommitResult {
|
||||
pub rev: String,
|
||||
}
|
||||
|
||||
pub struct CommitParams<'a> {
|
||||
pub did: &'a str,
|
||||
pub user_id: Uuid,
|
||||
pub current_root_cid: Option<Cid>,
|
||||
pub prev_data_cid: Option<Cid>,
|
||||
pub new_mst_root: Cid,
|
||||
pub ops: Vec<RecordOp>,
|
||||
pub blocks_cids: &'a [String],
|
||||
}
|
||||
|
||||
pub async fn commit_and_log(
|
||||
state: &AppState,
|
||||
did: &str,
|
||||
user_id: Uuid,
|
||||
current_root_cid: Option<Cid>,
|
||||
prev_data_cid: Option<Cid>,
|
||||
new_mst_root: Cid,
|
||||
ops: Vec<RecordOp>,
|
||||
blocks_cids: &[String],
|
||||
params: CommitParams<'_>,
|
||||
) -> Result<CommitResult, String> {
|
||||
let CommitParams {
|
||||
did,
|
||||
user_id,
|
||||
current_root_cid,
|
||||
prev_data_cid,
|
||||
new_mst_root,
|
||||
ops,
|
||||
blocks_cids,
|
||||
} = params;
|
||||
let key_row = sqlx::query!(
|
||||
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
|
||||
user_id
|
||||
@@ -100,20 +126,21 @@ pub async fn commit_and_log(
|
||||
.map_err(|e| format!("Failed to fetch signing key: {}", e))?;
|
||||
let key_bytes = crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version)
|
||||
.map_err(|e| format!("Failed to decrypt signing key: {}", e))?;
|
||||
let signing_key = SigningKey::from_slice(&key_bytes)
|
||||
.map_err(|e| format!("Invalid signing key: {}", e))?;
|
||||
let signing_key =
|
||||
SigningKey::from_slice(&key_bytes).map_err(|e| format!("Invalid signing key: {}", e))?;
|
||||
let rev = Tid::now(LimitedU32::MIN);
|
||||
let rev_str = rev.to_string();
|
||||
let (new_commit_bytes, _sig) = create_signed_commit(
|
||||
did,
|
||||
new_mst_root,
|
||||
&rev_str,
|
||||
current_root_cid,
|
||||
&signing_key,
|
||||
)?;
|
||||
let new_root_cid = state.block_store.put(&new_commit_bytes).await
|
||||
let (new_commit_bytes, _sig) =
|
||||
create_signed_commit(did, new_mst_root, &rev_str, current_root_cid, &signing_key)?;
|
||||
let new_root_cid = state
|
||||
.block_store
|
||||
.put(&new_commit_bytes)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to save commit block: {:?}", e))?;
|
||||
let mut tx = state.db.begin().await
|
||||
let mut tx = state
|
||||
.db
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
|
||||
let lock_result = sqlx::query!(
|
||||
"SELECT repo_root_cid FROM repos WHERE user_id = $1 FOR UPDATE NOWAIT",
|
||||
@@ -123,28 +150,36 @@ pub async fn commit_and_log(
|
||||
.await;
|
||||
match lock_result {
|
||||
Err(e) => {
|
||||
if let Some(db_err) = e.as_database_error() {
|
||||
if db_err.code().as_deref() == Some("55P03") {
|
||||
return Err("ConcurrentModification: Another request is modifying this repo".to_string());
|
||||
if let Some(db_err) = e.as_database_error()
|
||||
&& db_err.code().as_deref() == Some("55P03") {
|
||||
return Err(
|
||||
"ConcurrentModification: Another request is modifying this repo"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
return Err(format!("Failed to acquire repo lock: {}", e));
|
||||
}
|
||||
Ok(Some(row)) => {
|
||||
if let Some(expected_root) = ¤t_root_cid {
|
||||
if row.repo_root_cid != expected_root.to_string() {
|
||||
return Err("ConcurrentModification: Repo has been modified since last read".to_string());
|
||||
if let Some(expected_root) = ¤t_root_cid
|
||||
&& row.repo_root_cid != expected_root.to_string() {
|
||||
return Err(
|
||||
"ConcurrentModification: Repo has been modified since last read"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
return Err("Repo not found".to_string());
|
||||
}
|
||||
}
|
||||
sqlx::query!("UPDATE repos SET repo_root_cid = $1 WHERE user_id = $2", new_root_cid.to_string(), user_id)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| format!("DB Error (repos): {}", e))?;
|
||||
sqlx::query!(
|
||||
"UPDATE repos SET repo_root_cid = $1 WHERE user_id = $2",
|
||||
new_root_cid.to_string(),
|
||||
user_id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| format!("DB Error (repos): {}", e))?;
|
||||
let mut upsert_collections: Vec<String> = Vec::new();
|
||||
let mut upsert_rkeys: Vec<String> = Vec::new();
|
||||
let mut upsert_cids: Vec<String> = Vec::new();
|
||||
@@ -152,12 +187,24 @@ pub async fn commit_and_log(
|
||||
let mut delete_rkeys: Vec<String> = Vec::new();
|
||||
for op in &ops {
|
||||
match op {
|
||||
RecordOp::Create { collection, rkey, cid } | RecordOp::Update { collection, rkey, cid, .. } => {
|
||||
RecordOp::Create {
|
||||
collection,
|
||||
rkey,
|
||||
cid,
|
||||
}
|
||||
| RecordOp::Update {
|
||||
collection,
|
||||
rkey,
|
||||
cid,
|
||||
..
|
||||
} => {
|
||||
upsert_collections.push(collection.clone());
|
||||
upsert_rkeys.push(rkey.clone());
|
||||
upsert_cids.push(cid.to_string());
|
||||
}
|
||||
RecordOp::Delete { collection, rkey, .. } => {
|
||||
RecordOp::Delete {
|
||||
collection, rkey, ..
|
||||
} => {
|
||||
delete_collections.push(collection.clone());
|
||||
delete_rkeys.push(rkey.clone());
|
||||
}
|
||||
@@ -197,14 +244,24 @@ pub async fn commit_and_log(
|
||||
.await
|
||||
.map_err(|e| format!("DB Error (records batch delete): {}", e))?;
|
||||
}
|
||||
let ops_json = ops.iter().map(|op| {
|
||||
match op {
|
||||
RecordOp::Create { collection, rkey, cid } => json!({
|
||||
let ops_json = ops
|
||||
.iter()
|
||||
.map(|op| match op {
|
||||
RecordOp::Create {
|
||||
collection,
|
||||
rkey,
|
||||
cid,
|
||||
} => json!({
|
||||
"action": "create",
|
||||
"path": format!("{}/{}", collection, rkey),
|
||||
"cid": cid.to_string()
|
||||
}),
|
||||
RecordOp::Update { collection, rkey, cid, prev } => {
|
||||
RecordOp::Update {
|
||||
collection,
|
||||
rkey,
|
||||
cid,
|
||||
prev,
|
||||
} => {
|
||||
let mut obj = json!({
|
||||
"action": "update",
|
||||
"path": format!("{}/{}", collection, rkey),
|
||||
@@ -214,8 +271,12 @@ pub async fn commit_and_log(
|
||||
obj["prev"] = json!(prev_cid.to_string());
|
||||
}
|
||||
obj
|
||||
},
|
||||
RecordOp::Delete { collection, rkey, prev } => {
|
||||
}
|
||||
RecordOp::Delete {
|
||||
collection,
|
||||
rkey,
|
||||
prev,
|
||||
} => {
|
||||
let mut obj = json!({
|
||||
"action": "delete",
|
||||
"path": format!("{}/{}", collection, rkey),
|
||||
@@ -225,9 +286,9 @@ pub async fn commit_and_log(
|
||||
obj["prev"] = json!(prev_cid.to_string());
|
||||
}
|
||||
obj
|
||||
},
|
||||
}
|
||||
}).collect::<Vec<_>>();
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let event_type = "commit";
|
||||
let prev_cid_str = current_root_cid.map(|c| c.to_string());
|
||||
let prev_data_cid_str = prev_data_cid.map(|c| c.to_string());
|
||||
@@ -249,13 +310,12 @@ pub async fn commit_and_log(
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| format!("DB Error (repo_seq): {}", e))?;
|
||||
sqlx::query(
|
||||
&format!("NOTIFY repo_updates, '{}'", seq_row.seq)
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| format!("DB Error (notify): {}", e))?;
|
||||
tx.commit().await
|
||||
sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq_row.seq))
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| format!("DB Error (notify): {}", e))?;
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to commit transaction: {}", e))?;
|
||||
let _ = sequence_sync_event(state, did, &new_root_cid.to_string()).await;
|
||||
Ok(CommitResult {
|
||||
@@ -278,16 +338,20 @@ pub async fn create_record_internal(
|
||||
.await
|
||||
.map_err(|e| format!("DB error: {}", e))?
|
||||
.ok_or_else(|| "User not found".to_string())?;
|
||||
let root_cid_str: String =
|
||||
sqlx::query_scalar!("SELECT repo_root_cid FROM repos WHERE user_id = $1", user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| format!("DB error: {}", e))?
|
||||
.ok_or_else(|| "Repo not found".to_string())?;
|
||||
let current_root_cid = Cid::from_str(&root_cid_str)
|
||||
.map_err(|_| "Invalid repo root CID".to_string())?;
|
||||
let root_cid_str: String = sqlx::query_scalar!(
|
||||
"SELECT repo_root_cid FROM repos WHERE user_id = $1",
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| format!("DB error: {}", e))?
|
||||
.ok_or_else(|| "Repo not found".to_string())?;
|
||||
let current_root_cid =
|
||||
Cid::from_str(&root_cid_str).map_err(|_| "Invalid repo root CID".to_string())?;
|
||||
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
|
||||
let commit_bytes = tracking_store.get(¤t_root_cid).await
|
||||
let commit_bytes = tracking_store
|
||||
.get(¤t_root_cid)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch commit: {:?}", e))?
|
||||
.ok_or_else(|| "Commit block not found".to_string())?;
|
||||
let commit = jacquard_repo::commit::Commit::from_cbor(&commit_bytes)
|
||||
@@ -296,12 +360,18 @@ pub async fn create_record_internal(
|
||||
let mut record_bytes = Vec::new();
|
||||
serde_ipld_dagcbor::to_writer(&mut record_bytes, record)
|
||||
.map_err(|e| format!("Failed to serialize record: {:?}", e))?;
|
||||
let record_cid = tracking_store.put(&record_bytes).await
|
||||
let record_cid = tracking_store
|
||||
.put(&record_bytes)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to save record block: {:?}", e))?;
|
||||
let key = format!("{}/{}", collection, rkey);
|
||||
let new_mst = mst.add(&key, record_cid).await
|
||||
let new_mst = mst
|
||||
.add(&key, record_cid)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to add to MST: {:?}", e))?;
|
||||
let new_mst_root = new_mst.persist().await
|
||||
let new_mst_root = new_mst
|
||||
.persist()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to persist MST: {:?}", e))?;
|
||||
let op = RecordOp::Create {
|
||||
collection: collection.to_string(),
|
||||
@@ -309,9 +379,12 @@ pub async fn create_record_internal(
|
||||
cid: record_cid,
|
||||
};
|
||||
let mut relevant_blocks = std::collections::BTreeMap::new();
|
||||
new_mst.blocks_for_path(&key, &mut relevant_blocks).await
|
||||
new_mst
|
||||
.blocks_for_path(&key, &mut relevant_blocks)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get new MST blocks for path: {:?}", e))?;
|
||||
mst.blocks_for_path(&key, &mut relevant_blocks).await
|
||||
mst.blocks_for_path(&key, &mut relevant_blocks)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get old MST blocks for path: {:?}", e))?;
|
||||
relevant_blocks.insert(record_cid, bytes::Bytes::from(record_bytes));
|
||||
let mut written_cids = tracking_store.get_all_relevant_cids();
|
||||
@@ -323,14 +396,17 @@ pub async fn create_record_internal(
|
||||
let written_cids_str: Vec<String> = written_cids.iter().map(|c| c.to_string()).collect();
|
||||
let result = commit_and_log(
|
||||
state,
|
||||
did,
|
||||
user_id,
|
||||
Some(current_root_cid),
|
||||
Some(commit.data),
|
||||
new_mst_root,
|
||||
vec![op],
|
||||
&written_cids_str,
|
||||
).await?;
|
||||
CommitParams {
|
||||
did,
|
||||
user_id,
|
||||
current_root_cid: Some(current_root_cid),
|
||||
prev_data_cid: Some(commit.data),
|
||||
new_mst_root,
|
||||
ops: vec![op],
|
||||
blocks_cids: &written_cids_str,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let uri = format!("at://{}/{}/{}", did, collection, rkey);
|
||||
Ok((uri, result.commit_cid))
|
||||
}
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
use crate::validation::{RecordValidator, ValidationError};
|
||||
use axum::{
|
||||
Json,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
pub fn validate_record(record: &serde_json::Value, collection: &str) -> Result<(), Response> {
|
||||
pub fn validate_record(record: &serde_json::Value, collection: &str) -> Result<(), Box<Response>> {
|
||||
let validator = RecordValidator::new();
|
||||
match validator.validate(record, collection) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(ValidationError::MissingType) => Err((
|
||||
Err(ValidationError::MissingType) => Err(Box::new((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": "Record must have a $type field"})),
|
||||
).into_response()),
|
||||
Err(ValidationError::TypeMismatch { expected, actual }) => Err((
|
||||
).into_response())),
|
||||
Err(ValidationError::TypeMismatch { expected, actual }) => Err(Box::new((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Record $type '{}' does not match collection '{}'", actual, expected)})),
|
||||
).into_response()),
|
||||
Err(ValidationError::MissingField(field)) => Err((
|
||||
).into_response())),
|
||||
Err(ValidationError::MissingField(field)) => Err(Box::new((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Missing required field: {}", field)})),
|
||||
).into_response()),
|
||||
Err(ValidationError::InvalidField { path, message }) => Err((
|
||||
).into_response())),
|
||||
Err(ValidationError::InvalidField { path, message }) => Err(Box::new((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Invalid field '{}': {}", path, message)})),
|
||||
).into_response()),
|
||||
Err(ValidationError::InvalidDatetime { path }) => Err((
|
||||
).into_response())),
|
||||
Err(ValidationError::InvalidDatetime { path }) => Err(Box::new((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": format!("Invalid datetime format at '{}'", path)})),
|
||||
).into_response()),
|
||||
Err(e) => Err((
|
||||
).into_response())),
|
||||
Err(e) => Err(Box::new((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": e.to_string()})),
|
||||
).into_response()),
|
||||
).into_response())),
|
||||
}
|
||||
}
|
||||
|
||||
+244
-86
@@ -1,15 +1,18 @@
|
||||
use super::validation::validate_record;
|
||||
use crate::api::repo::record::utils::{commit_and_log, RecordOp};
|
||||
use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log};
|
||||
use crate::repo::tracking::TrackingBlockStore;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use cid::Cid;
|
||||
use jacquard::types::{integer::LimitedU32, string::{Nsid, Tid}};
|
||||
use jacquard::types::{
|
||||
integer::LimitedU32,
|
||||
string::{Nsid, Tid},
|
||||
};
|
||||
use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
@@ -19,7 +22,10 @@ use std::sync::Arc;
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn has_verified_notification_channel(db: &PgPool, did: &str) -> Result<bool, sqlx::Error> {
|
||||
pub async fn has_verified_notification_channel(
|
||||
db: &PgPool,
|
||||
did: &str,
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
@@ -29,7 +35,7 @@ pub async fn has_verified_notification_channel(db: &PgPool, did: &str) -> Result
|
||||
signal_verified
|
||||
FROM users
|
||||
WHERE did = $1
|
||||
"#
|
||||
"#,
|
||||
)
|
||||
.bind(did)
|
||||
.fetch_optional(db)
|
||||
@@ -52,8 +58,9 @@ pub async fn prepare_repo_write(
|
||||
repo_did: &str,
|
||||
) -> Result<(String, Uuid, Cid), Response> {
|
||||
let token = crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok())
|
||||
).ok_or_else(|| {
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired"})),
|
||||
@@ -102,7 +109,11 @@ pub async fn prepare_repo_write(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching user: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"}))).into_response()
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
@@ -111,21 +122,27 @@ pub async fn prepare_repo_write(
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
let root_cid_str: String =
|
||||
sqlx::query_scalar!("SELECT repo_root_cid FROM repos WHERE user_id = $1", user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching repo root: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"}))).into_response()
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Repo root not found"})),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
let root_cid_str: String = sqlx::query_scalar!(
|
||||
"SELECT repo_root_cid FROM repos WHERE user_id = $1",
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("DB error fetching repo root: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Repo root not found"})),
|
||||
)
|
||||
.into_response()
|
||||
})?;
|
||||
let current_root_cid = Cid::from_str(&root_cid_str).map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -162,62 +179,102 @@ pub async fn create_record(
|
||||
Ok(res) => res,
|
||||
Err(err_res) => return err_res,
|
||||
};
|
||||
if let Some(swap_commit) = &input.swap_commit {
|
||||
if Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
|
||||
if let Some(swap_commit) = &input.swap_commit
|
||||
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
|
||||
let commit_bytes = match tracking_store.get(¤t_root_cid).await {
|
||||
Ok(Some(b)) => b,
|
||||
_ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Commit block not found"}))).into_response(),
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Commit block not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let commit = match Commit::from_cbor(&commit_bytes) {
|
||||
Ok(c) => c,
|
||||
_ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to parse commit"}))).into_response(),
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to parse commit"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let mst = Mst::load(
|
||||
Arc::new(tracking_store.clone()),
|
||||
commit.data,
|
||||
None,
|
||||
);
|
||||
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
|
||||
let collection_nsid = match input.collection.parse::<Nsid>() {
|
||||
Ok(n) => n,
|
||||
Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection"}))).into_response(),
|
||||
};
|
||||
if input.validate.unwrap_or(true) {
|
||||
if let Err(err_response) = validate_record(&input.record, &input.collection) {
|
||||
return err_response;
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidCollection"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
let rkey = input.rkey.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
|
||||
};
|
||||
if input.validate.unwrap_or(true)
|
||||
&& let Err(err_response) = validate_record(&input.record, &input.collection) {
|
||||
return *err_response;
|
||||
}
|
||||
let rkey = input
|
||||
.rkey
|
||||
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
|
||||
let mut record_bytes = Vec::new();
|
||||
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() {
|
||||
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let record_cid = match tracking_store.put(&record_bytes).await {
|
||||
Ok(c) => c,
|
||||
_ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to save record block"}))).into_response(),
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to save record block"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let key = format!("{}/{}", collection_nsid, rkey);
|
||||
let new_mst = match mst.add(&key, record_cid).await {
|
||||
Ok(m) => m,
|
||||
_ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to add to MST"}))).into_response(),
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to add to MST"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let new_mst_root = match new_mst.persist().await {
|
||||
Ok(c) => c,
|
||||
_ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to persist MST"}))).into_response(),
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to persist MST"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let op = RecordOp::Create {
|
||||
collection: input.collection.clone(),
|
||||
rkey: rkey.clone(),
|
||||
cid: record_cid,
|
||||
};
|
||||
let op = RecordOp::Create { collection: input.collection.clone(), rkey: rkey.clone(), cid: record_cid };
|
||||
let mut relevant_blocks = std::collections::BTreeMap::new();
|
||||
if let Err(_) = new_mst.blocks_for_path(&key, &mut relevant_blocks).await {
|
||||
if new_mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response();
|
||||
}
|
||||
if let Err(_) = mst.blocks_for_path(&key, &mut relevant_blocks).await {
|
||||
if mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response();
|
||||
}
|
||||
relevant_blocks.insert(record_cid, bytes::Bytes::from(record_bytes));
|
||||
@@ -227,14 +284,38 @@ pub async fn create_record(
|
||||
written_cids.push(*cid);
|
||||
}
|
||||
}
|
||||
let written_cids_str = written_cids.iter().map(|c| c.to_string()).collect::<Vec<_>>();
|
||||
if let Err(e) = commit_and_log(&state, &did, user_id, Some(current_root_cid), Some(commit.data), new_mst_root, vec![op], &written_cids_str).await {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": e}))).into_response();
|
||||
let written_cids_str = written_cids
|
||||
.iter()
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
if let Err(e) = commit_and_log(
|
||||
&state,
|
||||
CommitParams {
|
||||
did: &did,
|
||||
user_id,
|
||||
current_root_cid: Some(current_root_cid),
|
||||
prev_data_cid: Some(commit.data),
|
||||
new_mst_root,
|
||||
ops: vec![op],
|
||||
blocks_cids: &written_cids_str,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
(StatusCode::OK, Json(CreateRecordOutput {
|
||||
uri: format!("at://{}/{}/{}", did, input.collection, rkey),
|
||||
cid: record_cid.to_string(),
|
||||
})).into_response()
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(CreateRecordOutput {
|
||||
uri: format!("at://{}/{}/{}", did, input.collection, rkey),
|
||||
cid: record_cid.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
@@ -265,35 +346,51 @@ pub async fn put_record(
|
||||
Ok(res) => res,
|
||||
Err(err_res) => return err_res,
|
||||
};
|
||||
if let Some(swap_commit) = &input.swap_commit {
|
||||
if Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
|
||||
return (StatusCode::CONFLICT, Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"}))).into_response();
|
||||
if let Some(swap_commit) = &input.swap_commit
|
||||
&& Cid::from_str(swap_commit).ok() != Some(current_root_cid) {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
let tracking_store = TrackingBlockStore::new(state.block_store.clone());
|
||||
let commit_bytes = match tracking_store.get(¤t_root_cid).await {
|
||||
Ok(Some(b)) => b,
|
||||
_ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Commit block not found"}))).into_response(),
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Commit block not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let commit = match Commit::from_cbor(&commit_bytes) {
|
||||
Ok(c) => c,
|
||||
_ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to parse commit"}))).into_response(),
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to parse commit"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let mst = Mst::load(
|
||||
Arc::new(tracking_store.clone()),
|
||||
commit.data,
|
||||
None,
|
||||
);
|
||||
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
|
||||
let collection_nsid = match input.collection.parse::<Nsid>() {
|
||||
Ok(n) => n,
|
||||
Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection"}))).into_response(),
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidCollection"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let key = format!("{}/{}", collection_nsid, input.rkey);
|
||||
if input.validate.unwrap_or(true) {
|
||||
if let Err(err_response) = validate_record(&input.record, &input.collection) {
|
||||
return err_response;
|
||||
if input.validate.unwrap_or(true)
|
||||
&& let Err(err_response) = validate_record(&input.record, &input.collection) {
|
||||
return *err_response;
|
||||
}
|
||||
}
|
||||
if let Some(swap_record_str) = &input.swap_record {
|
||||
let expected_cid = Cid::from_str(swap_record_str).ok();
|
||||
let actual_cid = mst.get(&key).await.ok().flatten();
|
||||
@@ -304,37 +401,74 @@ pub async fn put_record(
|
||||
let existing_cid = mst.get(&key).await.ok().flatten();
|
||||
let mut record_bytes = Vec::new();
|
||||
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() {
|
||||
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let record_cid = match tracking_store.put(&record_bytes).await {
|
||||
Ok(c) => c,
|
||||
_ => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to save record block"}))).into_response(),
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to save record block"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let new_mst = if existing_cid.is_some() {
|
||||
match mst.update(&key, record_cid).await {
|
||||
Ok(m) => m,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to update MST"}))).into_response(),
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to update MST"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match mst.add(&key, record_cid).await {
|
||||
Ok(m) => m,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to add to MST"}))).into_response(),
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to add to MST"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
};
|
||||
let new_mst_root = match new_mst.persist().await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to persist MST"}))).into_response(),
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": "Failed to persist MST"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let op = if existing_cid.is_some() {
|
||||
RecordOp::Update { collection: input.collection.clone(), rkey: input.rkey.clone(), cid: record_cid, prev: existing_cid }
|
||||
RecordOp::Update {
|
||||
collection: input.collection.clone(),
|
||||
rkey: input.rkey.clone(),
|
||||
cid: record_cid,
|
||||
prev: existing_cid,
|
||||
}
|
||||
} else {
|
||||
RecordOp::Create { collection: input.collection.clone(), rkey: input.rkey.clone(), cid: record_cid }
|
||||
RecordOp::Create {
|
||||
collection: input.collection.clone(),
|
||||
rkey: input.rkey.clone(),
|
||||
cid: record_cid,
|
||||
}
|
||||
};
|
||||
let mut relevant_blocks = std::collections::BTreeMap::new();
|
||||
if let Err(_) = new_mst.blocks_for_path(&key, &mut relevant_blocks).await {
|
||||
if new_mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response();
|
||||
}
|
||||
if let Err(_) = mst.blocks_for_path(&key, &mut relevant_blocks).await {
|
||||
if mst.blocks_for_path(&key, &mut relevant_blocks).await.is_err() {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response();
|
||||
}
|
||||
relevant_blocks.insert(record_cid, bytes::Bytes::from(record_bytes));
|
||||
@@ -344,12 +478,36 @@ pub async fn put_record(
|
||||
written_cids.push(*cid);
|
||||
}
|
||||
}
|
||||
let written_cids_str = written_cids.iter().map(|c| c.to_string()).collect::<Vec<_>>();
|
||||
if let Err(e) = commit_and_log(&state, &did, user_id, Some(current_root_cid), Some(commit.data), new_mst_root, vec![op], &written_cids_str).await {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": e}))).into_response();
|
||||
let written_cids_str = written_cids
|
||||
.iter()
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
if let Err(e) = commit_and_log(
|
||||
&state,
|
||||
CommitParams {
|
||||
did: &did,
|
||||
user_id,
|
||||
current_root_cid: Some(current_root_cid),
|
||||
prev_data_cid: Some(commit.data),
|
||||
new_mst_root,
|
||||
ops: vec![op],
|
||||
blocks_cids: &written_cids_str,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": e})),
|
||||
)
|
||||
.into_response();
|
||||
};
|
||||
(StatusCode::OK, Json(PutRecordOutput {
|
||||
uri: format!("at://{}/{}/{}", did, input.collection, input.rkey),
|
||||
cid: record_cid.to_string(),
|
||||
})).into_response()
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(PutRecordOutput {
|
||||
uri: format!("at://{}/{}/{}", did, input.collection, input.rkey),
|
||||
cid: record_cid.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -32,14 +32,16 @@ pub async fn check_account_status(
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Response {
|
||||
let extracted = match crate::auth::extract_auth_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok())
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => return ApiError::AuthenticationRequired.into_response(),
|
||||
};
|
||||
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
|
||||
let http_uri = format!("https://{}/xrpc/com.atproto.server.checkAccountStatus",
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()));
|
||||
let http_uri = format!(
|
||||
"https://{}/xrpc/com.atproto.server.checkAccountStatus",
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
|
||||
);
|
||||
let did = match crate::auth::validate_token_with_dpop(
|
||||
&state.db,
|
||||
&extracted.token,
|
||||
@@ -48,7 +50,9 @@ pub async fn check_account_status(
|
||||
"GET",
|
||||
&http_uri,
|
||||
true,
|
||||
).await {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(user) => user.did,
|
||||
Err(e) => return ApiError::from(e).into_response(),
|
||||
};
|
||||
@@ -72,24 +76,30 @@ pub async fn check_account_status(
|
||||
Ok(Some(row)) => row.deactivated_at,
|
||||
_ => None,
|
||||
};
|
||||
let repo_result = sqlx::query!("SELECT repo_root_cid FROM repos WHERE user_id = $1", user_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let repo_result = sqlx::query!(
|
||||
"SELECT repo_root_cid FROM repos WHERE user_id = $1",
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let repo_commit = match repo_result {
|
||||
Ok(Some(row)) => row.repo_root_cid,
|
||||
_ => String::new(),
|
||||
};
|
||||
let record_count: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM records WHERE repo_id = $1", user_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap_or(Some(0))
|
||||
.unwrap_or(0);
|
||||
let blob_count: i64 =
|
||||
sqlx::query_scalar!("SELECT COUNT(*) FROM blobs WHERE created_by_user = $1", user_id)
|
||||
let record_count: i64 =
|
||||
sqlx::query_scalar!("SELECT COUNT(*) FROM records WHERE repo_id = $1", user_id)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap_or(Some(0))
|
||||
.unwrap_or(0);
|
||||
let blob_count: i64 = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM blobs WHERE created_by_user = $1",
|
||||
user_id
|
||||
)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
.unwrap_or(Some(0))
|
||||
.unwrap_or(0);
|
||||
let valid_did = did.starts_with("did:");
|
||||
(
|
||||
StatusCode::OK,
|
||||
@@ -113,14 +123,16 @@ pub async fn activate_account(
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Response {
|
||||
let extracted = match crate::auth::extract_auth_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok())
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => return ApiError::AuthenticationRequired.into_response(),
|
||||
};
|
||||
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
|
||||
let http_uri = format!("https://{}/xrpc/com.atproto.server.activateAccount",
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()));
|
||||
let http_uri = format!(
|
||||
"https://{}/xrpc/com.atproto.server.activateAccount",
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
|
||||
);
|
||||
let did = match crate::auth::validate_token_with_dpop(
|
||||
&state.db,
|
||||
&extracted.token,
|
||||
@@ -129,7 +141,9 @@ pub async fn activate_account(
|
||||
"POST",
|
||||
&http_uri,
|
||||
true,
|
||||
).await {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(user) => user.did,
|
||||
Err(e) => return ApiError::from(e).into_response(),
|
||||
};
|
||||
@@ -171,14 +185,16 @@ pub async fn deactivate_account(
|
||||
Json(_input): Json<DeactivateAccountInput>,
|
||||
) -> Response {
|
||||
let extracted = match crate::auth::extract_auth_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok())
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => return ApiError::AuthenticationRequired.into_response(),
|
||||
};
|
||||
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
|
||||
let http_uri = format!("https://{}/xrpc/com.atproto.server.deactivateAccount",
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()));
|
||||
let http_uri = format!(
|
||||
"https://{}/xrpc/com.atproto.server.deactivateAccount",
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
|
||||
);
|
||||
let did = match crate::auth::validate_token_with_dpop(
|
||||
&state.db,
|
||||
&extracted.token,
|
||||
@@ -187,7 +203,9 @@ pub async fn deactivate_account(
|
||||
"POST",
|
||||
&http_uri,
|
||||
false,
|
||||
).await {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(user) => user.did,
|
||||
Err(e) => return ApiError::from(e).into_response(),
|
||||
};
|
||||
@@ -196,9 +214,12 @@ pub async fn deactivate_account(
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let result = sqlx::query!("UPDATE users SET deactivated_at = NOW() WHERE did = $1", did)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
let result = sqlx::query!(
|
||||
"UPDATE users SET deactivated_at = NOW() WHERE did = $1",
|
||||
did
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(_) => {
|
||||
if let Some(h) = handle {
|
||||
@@ -222,14 +243,16 @@ pub async fn request_account_delete(
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Response {
|
||||
let extracted = match crate::auth::extract_auth_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok())
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => return ApiError::AuthenticationRequired.into_response(),
|
||||
};
|
||||
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
|
||||
let http_uri = format!("https://{}/xrpc/com.atproto.server.requestAccountDelete",
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()));
|
||||
let http_uri = format!(
|
||||
"https://{}/xrpc/com.atproto.server.requestAccountDelete",
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
|
||||
);
|
||||
let did = match crate::auth::validate_token_with_dpop(
|
||||
&state.db,
|
||||
&extracted.token,
|
||||
@@ -238,7 +261,9 @@ pub async fn request_account_delete(
|
||||
"POST",
|
||||
&http_uri,
|
||||
true,
|
||||
).await {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(user) => user.did,
|
||||
Err(e) => return ApiError::from(e).into_response(),
|
||||
};
|
||||
@@ -274,8 +299,13 @@ pub async fn request_account_delete(
|
||||
.into_response();
|
||||
}
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) =
|
||||
crate::notifications::enqueue_account_deletion(&state.db, user_id, &confirmation_token, &hostname).await
|
||||
if let Err(e) = crate::notifications::enqueue_account_deletion(
|
||||
&state.db,
|
||||
user_id,
|
||||
&confirmation_token,
|
||||
&hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to enqueue account deletion notification: {:?}", e);
|
||||
}
|
||||
@@ -395,9 +425,12 @@ pub async fn delete_account(
|
||||
.into_response();
|
||||
}
|
||||
if Utc::now() > expires_at {
|
||||
let _ = sqlx::query!("DELETE FROM account_deletion_requests WHERE token = $1", token)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
let _ = sqlx::query!(
|
||||
"DELETE FROM account_deletion_requests WHERE token = $1",
|
||||
token
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
|
||||
|
||||
@@ -80,7 +80,10 @@ pub async fn create_app_password(
|
||||
Json(input): Json<CreateAppPasswordInput>,
|
||||
) -> Response {
|
||||
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
|
||||
if !state.check_rate_limit(RateLimitKind::AppPassword, &client_ip).await {
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::AppPassword, &client_ip)
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "App password creation rate limit exceeded");
|
||||
return (
|
||||
axum::http::StatusCode::TOO_MANY_REQUESTS,
|
||||
@@ -88,7 +91,8 @@ pub async fn create_app_password(
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many requests. Please try again later."
|
||||
})),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let user_id = match get_user_id_by_did(&state.db, &auth_user.did).await {
|
||||
Ok(id) => id,
|
||||
|
||||
+37
-30
@@ -27,7 +27,10 @@ pub async fn request_email_update(
|
||||
Json(input): Json<RequestEmailUpdateInput>,
|
||||
) -> Response {
|
||||
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
|
||||
if !state.check_rate_limit(RateLimitKind::EmailUpdate, &client_ip).await {
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::EmailUpdate, &client_ip)
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Email update rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
@@ -35,10 +38,11 @@ pub async fn request_email_update(
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many requests. Please try again later."
|
||||
})),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok())
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
@@ -108,12 +112,7 @@ pub async fn request_email_update(
|
||||
}
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) = crate::notifications::enqueue_email_update(
|
||||
&state.db,
|
||||
user_id,
|
||||
&email,
|
||||
&handle,
|
||||
&code,
|
||||
&hostname,
|
||||
&state.db, user_id, &email, &handle, &code, &hostname,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -136,7 +135,10 @@ pub async fn confirm_email(
|
||||
Json(input): Json<ConfirmEmailInput>,
|
||||
) -> Response {
|
||||
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
|
||||
if !state.check_rate_limit(RateLimitKind::AppPassword, &client_ip).await {
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::AppPassword, &client_ip)
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Confirm email rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
@@ -144,10 +146,11 @@ pub async fn confirm_email(
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many requests. Please try again later."
|
||||
})),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok())
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
@@ -185,16 +188,19 @@ pub async fn confirm_email(
|
||||
let email_pending_verification = user.email_pending_verification;
|
||||
let email = input.email.trim().to_lowercase();
|
||||
let confirmation_code = input.token.trim();
|
||||
let (pending_email, saved_code, expiry) = match (email_pending_verification, stored_code, expires_at) {
|
||||
(Some(p), Some(c), Some(e)) => (p, c, e),
|
||||
_ => {
|
||||
return (
|
||||
let (pending_email, saved_code, expiry) =
|
||||
match (email_pending_verification, stored_code, expires_at) {
|
||||
(Some(p), Some(c), Some(e)) => (p, c, e),
|
||||
_ => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "No pending email update found"})),
|
||||
Json(
|
||||
json!({"error": "InvalidRequest", "message": "No pending email update found"}),
|
||||
),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
if pending_email != email {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -203,7 +209,7 @@ pub async fn confirm_email(
|
||||
.into_response();
|
||||
}
|
||||
if saved_code != confirmation_code {
|
||||
return (
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidToken", "message": "Invalid token"})),
|
||||
)
|
||||
@@ -225,13 +231,16 @@ pub async fn confirm_email(
|
||||
.await;
|
||||
if let Err(e) = update {
|
||||
error!("DB error finalizing email update: {:?}", e);
|
||||
if e.as_database_error().map(|db_err| db_err.is_unique_violation()).unwrap_or(false) {
|
||||
return (
|
||||
if e.as_database_error()
|
||||
.map(|db_err| db_err.is_unique_violation())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "EmailTaken", "message": "Email already taken"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
@@ -257,7 +266,7 @@ pub async fn update_email(
|
||||
Json(input): Json<UpdateEmailInput>,
|
||||
) -> Response {
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok())
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
@@ -302,11 +311,10 @@ pub async fn update_email(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Some(ref current) = current_email {
|
||||
if new_email == current.to_lowercase() {
|
||||
if let Some(ref current) = current_email
|
||||
&& new_email == current.to_lowercase() {
|
||||
return (StatusCode::OK, Json(json!({}))).into_response();
|
||||
}
|
||||
}
|
||||
let email_confirmed = stored_code.is_some() && email_pending_verification.is_some();
|
||||
if email_confirmed {
|
||||
let confirmation_token = match &input.token {
|
||||
@@ -353,15 +361,14 @@ pub async fn update_email(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Some(exp) = expires_at {
|
||||
if Utc::now() > exp {
|
||||
if let Some(exp) = expires_at
|
||||
&& Utc::now() > exp {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "ExpiredToken", "message": "Token has expired"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
let exists = sqlx::query!(
|
||||
"SELECT 1 as one FROM users WHERE LOWER(email) = $1 AND id != $2",
|
||||
|
||||
+16
-12
@@ -143,17 +143,18 @@ pub async fn create_invite_codes(
|
||||
});
|
||||
} else {
|
||||
for account_did in for_accounts {
|
||||
let target_user_id = match sqlx::query!("SELECT id FROM users WHERE did = $1", account_did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row.id,
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
error!("DB error looking up target account: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
};
|
||||
let target_user_id =
|
||||
match sqlx::query!("SELECT id FROM users WHERE did = $1", account_did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row.id,
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
error!("DB error looking up target account: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
};
|
||||
let mut codes = Vec::new();
|
||||
for _ in 0..code_count {
|
||||
let code = Uuid::new_v4().to_string();
|
||||
@@ -177,7 +178,10 @@ pub async fn create_invite_codes(
|
||||
});
|
||||
}
|
||||
}
|
||||
Json(CreateInviteCodesOutput { codes: result_codes }).into_response()
|
||||
Json(CreateInviteCodesOutput {
|
||||
codes: result_codes,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
||||
@@ -18,5 +18,8 @@ pub use invite::{create_invite_code, create_invite_codes, get_account_invite_cod
|
||||
pub use meta::{describe_server, health, robots_txt};
|
||||
pub use password::{request_password_reset, reset_password};
|
||||
pub use service_auth::get_service_auth;
|
||||
pub use session::{confirm_signup, create_session, delete_session, get_session, refresh_session, resend_verification};
|
||||
pub use session::{
|
||||
confirm_signup, create_session, delete_session, get_session, refresh_session,
|
||||
resend_verification,
|
||||
};
|
||||
pub use signing_key::reserve_signing_key;
|
||||
|
||||
+27
-20
@@ -5,7 +5,7 @@ use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use bcrypt::{hash, DEFAULT_COST};
|
||||
use bcrypt::{DEFAULT_COST, hash};
|
||||
use chrono::{Duration, Utc};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
@@ -15,18 +15,15 @@ fn generate_reset_code() -> String {
|
||||
crate::util::generate_token_code()
|
||||
}
|
||||
fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for") {
|
||||
if let Ok(value) = forwarded.to_str() {
|
||||
if let Some(first_ip) = value.split(',').next() {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for")
|
||||
&& let Ok(value) = forwarded.to_str()
|
||||
&& let Some(first_ip) = value.split(',').next() {
|
||||
return first_ip.trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(real_ip) = headers.get("x-real-ip") {
|
||||
if let Ok(value) = real_ip.to_str() {
|
||||
if let Some(real_ip) = headers.get("x-real-ip")
|
||||
&& let Ok(value) = real_ip.to_str() {
|
||||
return value.trim().to_string();
|
||||
}
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
@@ -41,7 +38,10 @@ pub async fn request_password_reset(
|
||||
Json(input): Json<RequestPasswordResetInput>,
|
||||
) -> Response {
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if !state.check_rate_limit(RateLimitKind::PasswordReset, &client_ip).await {
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::PasswordReset, &client_ip)
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Password reset rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
@@ -118,7 +118,10 @@ pub async fn reset_password(
|
||||
Json(input): Json<ResetPasswordInput>,
|
||||
) -> Response {
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if !state.check_rate_limit(RateLimitKind::ResetPassword, &client_ip).await {
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::ResetPassword, &client_ip)
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Reset password rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
@@ -126,7 +129,8 @@ pub async fn reset_password(
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many requests. Please try again later."
|
||||
})),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let token = input.token.trim();
|
||||
let password = &input.password;
|
||||
@@ -232,12 +236,9 @@ pub async fn reset_password(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let user_did = match sqlx::query_scalar!(
|
||||
"SELECT did FROM users WHERE id = $1",
|
||||
user_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
let user_did = match sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", user_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
{
|
||||
Ok(did) => did,
|
||||
Err(e) => {
|
||||
@@ -266,7 +267,10 @@ pub async fn reset_password(
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
error!("Failed to invalidate sessions after password reset: {:?}", e);
|
||||
error!(
|
||||
"Failed to invalidate sessions after password reset: {:?}",
|
||||
e
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
@@ -284,7 +288,10 @@ pub async fn reset_password(
|
||||
for jti in session_jtis {
|
||||
let cache_key = format!("auth:session:{}:{}", user_did, jti);
|
||||
if let Err(e) = state.cache.delete(&cache_key).await {
|
||||
warn!("Failed to invalidate session cache for {}: {:?}", cache_key, e);
|
||||
warn!(
|
||||
"Failed to invalidate session cache for {}: {:?}",
|
||||
cache_key, e
|
||||
);
|
||||
}
|
||||
}
|
||||
info!("Password reset completed for user {}", user_id);
|
||||
|
||||
@@ -28,7 +28,7 @@ pub async fn get_service_auth(
|
||||
Query(params): Query<GetServiceAuthParams>,
|
||||
) -> Response {
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok())
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => return ApiError::AuthenticationRequired.into_response(),
|
||||
@@ -39,20 +39,31 @@ pub async fn get_service_auth(
|
||||
};
|
||||
let key_bytes = match auth_user.key_bytes {
|
||||
Some(kb) => kb,
|
||||
None => return ApiError::AuthenticationFailedMsg("OAuth tokens cannot create service auth".into()).into_response(),
|
||||
};
|
||||
let lxm = params.lxm.as_deref().unwrap_or("*");
|
||||
let service_token = match crate::auth::create_service_token(&auth_user.did, ¶ms.aud, lxm, &key_bytes)
|
||||
{
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
error!("Failed to create service token: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
None => {
|
||||
return ApiError::AuthenticationFailedMsg(
|
||||
"OAuth tokens cannot create service auth".into(),
|
||||
)
|
||||
.into_response();
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
(StatusCode::OK, Json(GetServiceAuthOutput { token: service_token })).into_response()
|
||||
let lxm = params.lxm.as_deref().unwrap_or("*");
|
||||
let service_token =
|
||||
match crate::auth::create_service_token(&auth_user.did, ¶ms.aud, lxm, &key_bytes) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
error!("Failed to create service token: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(GetServiceAuthOutput {
|
||||
token: service_token,
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
+92
-60
@@ -14,18 +14,15 @@ use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for") {
|
||||
if let Ok(value) = forwarded.to_str() {
|
||||
if let Some(first_ip) = value.split(',').next() {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for")
|
||||
&& let Ok(value) = forwarded.to_str()
|
||||
&& let Some(first_ip) = value.split(',').next() {
|
||||
return first_ip.trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(real_ip) = headers.get("x-real-ip") {
|
||||
if let Ok(value) = real_ip.to_str() {
|
||||
if let Some(real_ip) = headers.get("x-real-ip")
|
||||
&& let Ok(value) = real_ip.to_str() {
|
||||
return value.trim().to_string();
|
||||
}
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
@@ -60,7 +57,10 @@ pub async fn create_session(
|
||||
) -> Response {
|
||||
info!("create_session called");
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if !state.check_rate_limit(RateLimitKind::Login, &client_ip).await {
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::Login, &client_ip)
|
||||
.await
|
||||
{
|
||||
warn!(ip = %client_ip, "Login rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
@@ -88,9 +88,13 @@ pub async fn create_session(
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
let _ = verify(&input.password, "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK");
|
||||
let _ = verify(
|
||||
&input.password,
|
||||
"$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK",
|
||||
);
|
||||
warn!("User not found for login attempt");
|
||||
return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()).into_response();
|
||||
return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into())
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Database error fetching user: {:?}", e);
|
||||
@@ -114,16 +118,17 @@ pub async fn create_session(
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
app_passwords.iter().any(|app| verify(&input.password, &app.password_hash).unwrap_or(false))
|
||||
app_passwords
|
||||
.iter()
|
||||
.any(|app| verify(&input.password, &app.password_hash).unwrap_or(false))
|
||||
};
|
||||
if !password_valid {
|
||||
warn!("Password verification failed for login attempt");
|
||||
return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()).into_response();
|
||||
return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into())
|
||||
.into_response();
|
||||
}
|
||||
let is_verified = row.email_confirmed
|
||||
|| row.discord_verified
|
||||
|| row.telegram_verified
|
||||
|| row.signal_verified;
|
||||
let is_verified =
|
||||
row.email_confirmed || row.discord_verified || row.telegram_verified || row.signal_verified;
|
||||
if !is_verified {
|
||||
warn!("Login attempt for unverified account: {}", row.did);
|
||||
return (
|
||||
@@ -133,7 +138,8 @@ pub async fn create_session(
|
||||
"message": "Please verify your account before logging in",
|
||||
"did": row.did
|
||||
})),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
|
||||
Ok(m) => m,
|
||||
@@ -169,7 +175,8 @@ pub async fn create_session(
|
||||
refresh_jwt: refresh_meta.token,
|
||||
handle: full_handle,
|
||||
did: row.did,
|
||||
}).into_response()
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn get_session(
|
||||
@@ -220,7 +227,7 @@ pub async fn delete_session(
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Response {
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok())
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => return ApiError::AuthenticationRequired.into_response(),
|
||||
@@ -254,7 +261,10 @@ pub async fn refresh_session(
|
||||
headers: axum::http::HeaderMap,
|
||||
) -> Response {
|
||||
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
|
||||
if !state.check_rate_limit(RateLimitKind::RefreshSession, &client_ip).await {
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::RefreshSession, &client_ip)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(ip = %client_ip, "Refresh session rate limit exceeded");
|
||||
return (
|
||||
axum::http::StatusCode::TOO_MANY_REQUESTS,
|
||||
@@ -262,17 +272,21 @@ pub async fn refresh_session(
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many requests. Please try again later."
|
||||
})),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let refresh_token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok())
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => return ApiError::AuthenticationRequired.into_response(),
|
||||
};
|
||||
let refresh_jti = match crate::auth::get_jti_from_token(&refresh_token) {
|
||||
Ok(jti) => jti,
|
||||
Err(_) => return ApiError::AuthenticationFailedMsg("Invalid token format".into()).into_response(),
|
||||
Err(_) => {
|
||||
return ApiError::AuthenticationFailedMsg("Invalid token format".into())
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let mut tx = match state.db.begin().await {
|
||||
Ok(tx) => tx,
|
||||
@@ -288,12 +302,18 @@ pub async fn refresh_session(
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
{
|
||||
warn!("Refresh token reuse detected! Revoking token family for session_id: {}", session_id);
|
||||
warn!(
|
||||
"Refresh token reuse detected! Revoking token family for session_id: {}",
|
||||
session_id
|
||||
);
|
||||
let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_id)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
let _ = tx.commit().await;
|
||||
return ApiError::ExpiredTokenMsg("Refresh token has been revoked due to suspected compromise".into()).into_response();
|
||||
return ApiError::ExpiredTokenMsg(
|
||||
"Refresh token has been revoked due to suspected compromise".into(),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let session_row = match sqlx::query!(
|
||||
r#"SELECT st.id, st.did, k.key_bytes, k.encryption_version
|
||||
@@ -308,36 +328,42 @@ pub async fn refresh_session(
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()).into_response(),
|
||||
Ok(None) => {
|
||||
return ApiError::AuthenticationFailedMsg("Invalid refresh token".into())
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Database error fetching session: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
};
|
||||
let key_bytes = match crate::config::decrypt_key(&session_row.key_bytes, session_row.encryption_version) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Failed to decrypt user key: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
};
|
||||
let key_bytes =
|
||||
match crate::config::decrypt_key(&session_row.key_bytes, session_row.encryption_version) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Failed to decrypt user key: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
};
|
||||
if crate::auth::verify_refresh_token(&refresh_token, &key_bytes).is_err() {
|
||||
return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()).into_response();
|
||||
}
|
||||
let new_access_meta = match crate::auth::create_access_token_with_metadata(&session_row.did, &key_bytes) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("Failed to create access token: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
};
|
||||
let new_refresh_meta = match crate::auth::create_refresh_token_with_metadata(&session_row.did, &key_bytes) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("Failed to create refresh token: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
};
|
||||
let new_access_meta =
|
||||
match crate::auth::create_access_token_with_metadata(&session_row.did, &key_bytes) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("Failed to create access token: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
};
|
||||
let new_refresh_meta =
|
||||
match crate::auth::create_refresh_token_with_metadata(&session_row.did, &key_bytes) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("Failed to create refresh token: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
};
|
||||
match sqlx::query!(
|
||||
"INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2) ON CONFLICT (refresh_jti) DO NOTHING",
|
||||
refresh_jti,
|
||||
@@ -482,12 +508,12 @@ pub async fn confirm_signup(
|
||||
warn!("Invalid verification code for user: {}", input.did);
|
||||
return ApiError::InvalidRequest("Invalid verification code".into()).into_response();
|
||||
}
|
||||
if let Some(expires_at) = row.email_confirmation_code_expires_at {
|
||||
if expires_at < Utc::now() {
|
||||
if let Some(expires_at) = row.email_confirmation_code_expires_at
|
||||
&& expires_at < Utc::now() {
|
||||
warn!("Verification code expired for user: {}", input.did);
|
||||
return ApiError::ExpiredTokenMsg("Verification code has expired".into()).into_response();
|
||||
return ApiError::ExpiredTokenMsg("Verification code has expired".into())
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
@@ -545,7 +571,10 @@ pub async fn confirm_signup(
|
||||
if let Err(e) = crate::notifications::enqueue_welcome(&state.db, row.id, &hostname).await {
|
||||
warn!("Failed to enqueue welcome notification: {:?}", e);
|
||||
}
|
||||
let email_confirmed = matches!(row.channel, crate::notifications::NotificationChannel::Email);
|
||||
let email_confirmed = matches!(
|
||||
row.channel,
|
||||
crate::notifications::NotificationChannel::Email
|
||||
);
|
||||
let preferred_channel = match row.channel {
|
||||
crate::notifications::NotificationChannel::Email => "email",
|
||||
crate::notifications::NotificationChannel::Discord => "discord",
|
||||
@@ -561,7 +590,8 @@ pub async fn confirm_signup(
|
||||
email_confirmed,
|
||||
preferred_channel: preferred_channel.to_string(),
|
||||
preferred_channel_verified: true,
|
||||
}).into_response()
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -597,10 +627,8 @@ pub async fn resend_verification(
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
};
|
||||
let is_verified = row.email_confirmed
|
||||
|| row.discord_verified
|
||||
|| row.telegram_verified
|
||||
|| row.signal_verified;
|
||||
let is_verified =
|
||||
row.email_confirmed || row.discord_verified || row.telegram_verified || row.signal_verified;
|
||||
if is_verified {
|
||||
return ApiError::InvalidRequest("Account is already verified".into()).into_response();
|
||||
}
|
||||
@@ -619,7 +647,9 @@ pub async fn resend_verification(
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
let (channel_str, recipient) = match row.channel {
|
||||
crate::notifications::NotificationChannel::Email => ("email", row.email.clone().unwrap_or_default()),
|
||||
crate::notifications::NotificationChannel::Email => {
|
||||
("email", row.email.clone().unwrap_or_default())
|
||||
}
|
||||
crate::notifications::NotificationChannel::Discord => {
|
||||
("discord", row.discord_id.unwrap_or_default())
|
||||
}
|
||||
@@ -636,7 +666,9 @@ pub async fn resend_verification(
|
||||
channel_str,
|
||||
&recipient,
|
||||
&verification_code,
|
||||
).await {
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to enqueue verification notification: {:?}", e);
|
||||
}
|
||||
Json(json!({"success": true})).into_response()
|
||||
|
||||
@@ -58,11 +58,7 @@ pub async fn reserve_signing_key(
|
||||
.await;
|
||||
match result {
|
||||
Ok(row) => {
|
||||
info!(
|
||||
"Reserved signing key {} for did {:?}",
|
||||
row.id,
|
||||
input.did
|
||||
);
|
||||
info!("Reserved signing key {} for did {:?}", row.id, input.did);
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(ReserveSigningKeyOutput {
|
||||
|
||||
+11
-15
@@ -1,3 +1,5 @@
|
||||
use crate::auth::{extract_bearer_token_from_header, validate_bearer_token};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -6,8 +8,6 @@ use axum::{
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use crate::auth::{extract_bearer_token_from_header, validate_bearer_token};
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -19,28 +19,24 @@ pub struct CheckSignupQueueOutput {
|
||||
pub estimated_time_ms: Option<i64>,
|
||||
}
|
||||
|
||||
pub async fn check_signup_queue(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
if let Some(token) = extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok())
|
||||
) {
|
||||
if let Ok(user) = validate_bearer_token(&state.db, &token).await {
|
||||
if user.is_oauth {
|
||||
pub async fn check_signup_queue(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
if let Some(token) =
|
||||
extract_bearer_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok()))
|
||||
&& let Ok(user) = validate_bearer_token(&state.db, &token).await
|
||||
&& user.is_oauth {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "Forbidden",
|
||||
"message": "OAuth credentials are not supported for this endpoint"
|
||||
})),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
Json(CheckSignupQueueOutput {
|
||||
activated: true,
|
||||
place_in_queue: None,
|
||||
estimated_time_ms: None,
|
||||
}).into_response()
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
+15
-6
@@ -1,13 +1,16 @@
|
||||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::{StatusCode, request::Parts, header::AUTHORIZATION},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
extract::FromRequestParts,
|
||||
http::{StatusCode, header::AUTHORIZATION, request::Parts},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
AuthenticatedUser, TokenValidationError, validate_bearer_token_cached,
|
||||
validate_bearer_token_cached_allow_deactivated,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use super::{AuthenticatedUser, TokenValidationError, validate_bearer_token_cached, validate_bearer_token_cached_allow_deactivated};
|
||||
|
||||
pub struct BearerAuth(pub AuthenticatedUser);
|
||||
|
||||
@@ -108,7 +111,10 @@ pub fn extract_auth_token_from_header(auth_header: Option<&str>) -> Option<Extra
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(ExtractedToken { token: token.to_string(), is_dpop: false });
|
||||
return Some(ExtractedToken {
|
||||
token: token.to_string(),
|
||||
is_dpop: false,
|
||||
});
|
||||
}
|
||||
|
||||
if header.len() >= 5 && header[..5].eq_ignore_ascii_case("dpop ") {
|
||||
@@ -116,7 +122,10 @@ pub fn extract_auth_token_from_header(auth_header: Option<&str>) -> Option<Extra
|
||||
if token.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(ExtractedToken { token: token.to_string(), is_dpop: true });
|
||||
return Some(ExtractedToken {
|
||||
token: token.to_string(),
|
||||
is_dpop: true,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
|
||||
+67
-47
@@ -10,15 +10,19 @@ pub mod extractor;
|
||||
pub mod token;
|
||||
pub mod verify;
|
||||
|
||||
pub use extractor::{BearerAuth, BearerAuthAllowDeactivated, AuthError, extract_bearer_token_from_header, extract_auth_token_from_header, ExtractedToken};
|
||||
pub use extractor::{
|
||||
AuthError, BearerAuth, BearerAuthAllowDeactivated, ExtractedToken,
|
||||
extract_auth_token_from_header, extract_bearer_token_from_header,
|
||||
};
|
||||
pub use token::{
|
||||
create_access_token, create_refresh_token, create_service_token,
|
||||
create_access_token_with_metadata, create_refresh_token_with_metadata,
|
||||
TokenWithMetadata,
|
||||
TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE,
|
||||
SCOPE_ACCESS, SCOPE_REFRESH, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED,
|
||||
SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS,
|
||||
TOKEN_TYPE_REFRESH, TOKEN_TYPE_SERVICE, TokenWithMetadata, create_access_token,
|
||||
create_access_token_with_metadata, create_refresh_token, create_refresh_token_with_metadata,
|
||||
create_service_token,
|
||||
};
|
||||
pub use verify::{
|
||||
get_did_from_token, get_jti_from_token, verify_access_token, verify_refresh_token, verify_token,
|
||||
};
|
||||
pub use verify::{get_did_from_token, get_jti_from_token, verify_token, verify_access_token, verify_refresh_token};
|
||||
|
||||
const KEY_CACHE_TTL_SECS: u64 = 300;
|
||||
const SESSION_CACHE_TTL_SECS: u64 = 60;
|
||||
@@ -113,30 +117,34 @@ async fn validate_bearer_token_with_options_internal(
|
||||
Some(status) => (Some(key), status.deactivated_at, status.takedown_ref),
|
||||
None => (None, None, None),
|
||||
}
|
||||
} else {
|
||||
if let Some(user) = sqlx::query!(
|
||||
"SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref
|
||||
FROM users u
|
||||
JOIN user_keys k ON u.id = k.user_id
|
||||
WHERE u.did = $1",
|
||||
did
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
{
|
||||
let key = crate::config::decrypt_key(&user.key_bytes, user.encryption_version)
|
||||
.map_err(|_| TokenValidationError::KeyDecryptionFailed)?;
|
||||
} else if let Some(user) = sqlx::query!(
|
||||
"SELECT k.key_bytes, k.encryption_version, u.deactivated_at, u.takedown_ref
|
||||
FROM users u
|
||||
JOIN user_keys k ON u.id = k.user_id
|
||||
WHERE u.did = $1",
|
||||
did
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
{
|
||||
let key = crate::config::decrypt_key(&user.key_bytes, user.encryption_version)
|
||||
.map_err(|_| TokenValidationError::KeyDecryptionFailed)?;
|
||||
|
||||
if let Some(c) = cache {
|
||||
let _ = c.set_bytes(&key_cache_key, &key, Duration::from_secs(KEY_CACHE_TTL_SECS)).await;
|
||||
}
|
||||
|
||||
(Some(key), user.deactivated_at, user.takedown_ref)
|
||||
} else {
|
||||
(None, None, None)
|
||||
if let Some(c) = cache {
|
||||
let _ = c
|
||||
.set_bytes(
|
||||
&key_cache_key,
|
||||
&key,
|
||||
Duration::from_secs(KEY_CACHE_TTL_SECS),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
(Some(key), user.deactivated_at, user.takedown_ref)
|
||||
} else {
|
||||
(None, None, None)
|
||||
};
|
||||
|
||||
if let Some(decrypted_key) = decrypted_key {
|
||||
@@ -175,11 +183,16 @@ async fn validate_bearer_token_with_options_internal(
|
||||
|
||||
session_valid = session_exists.is_some();
|
||||
|
||||
if session_valid {
|
||||
if let Some(c) = cache {
|
||||
let _ = c.set(&session_cache_key, "1", Duration::from_secs(SESSION_CACHE_TTL_SECS)).await;
|
||||
if session_valid
|
||||
&& let Some(c) = cache {
|
||||
let _ = c
|
||||
.set(
|
||||
&session_cache_key,
|
||||
"1",
|
||||
Duration::from_secs(SESSION_CACHE_TTL_SECS),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if session_valid {
|
||||
@@ -193,8 +206,8 @@ async fn validate_bearer_token_with_options_internal(
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(oauth_info) = crate::oauth::verify::extract_oauth_token_info(token) {
|
||||
if let Some(oauth_token) = sqlx::query!(
|
||||
if let Ok(oauth_info) = crate::oauth::verify::extract_oauth_token_info(token)
|
||||
&& let Some(oauth_token) = sqlx::query!(
|
||||
r#"SELECT t.did, t.expires_at, u.deactivated_at, u.takedown_ref,
|
||||
k.key_bytes as "key_bytes?", k.encryption_version as "encryption_version?"
|
||||
FROM oauth_token t
|
||||
@@ -218,7 +231,9 @@ async fn validate_bearer_token_with_options_internal(
|
||||
|
||||
let now = chrono::Utc::now();
|
||||
if oauth_token.expires_at > now {
|
||||
let key_bytes = if let (Some(kb), Some(ev)) = (&oauth_token.key_bytes, oauth_token.encryption_version) {
|
||||
let key_bytes = if let (Some(kb), Some(ev)) =
|
||||
(&oauth_token.key_bytes, oauth_token.encryption_version)
|
||||
{
|
||||
crate::config::decrypt_key(kb, Some(ev)).ok()
|
||||
} else {
|
||||
None
|
||||
@@ -230,7 +245,6 @@ async fn validate_bearer_token_with_options_internal(
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(TokenValidationError::AuthenticationFailed)
|
||||
}
|
||||
@@ -256,7 +270,15 @@ pub async fn validate_token_with_dpop(
|
||||
return validate_bearer_token(db, token).await;
|
||||
}
|
||||
}
|
||||
match crate::oauth::verify::verify_oauth_access_token(db, token, dpop_proof, http_method, http_uri).await {
|
||||
match crate::oauth::verify::verify_oauth_access_token(
|
||||
db,
|
||||
token,
|
||||
dpop_proof,
|
||||
http_method,
|
||||
http_uri,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
if !allow_deactivated {
|
||||
let deactivated = sqlx::query_scalar!(
|
||||
@@ -272,15 +294,13 @@ pub async fn validate_token_with_dpop(
|
||||
return Err(TokenValidationError::AccountDeactivated);
|
||||
}
|
||||
}
|
||||
let takedown = sqlx::query_scalar!(
|
||||
"SELECT takedown_ref FROM users WHERE did = $1",
|
||||
result.did
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.flatten();
|
||||
let takedown =
|
||||
sqlx::query_scalar!("SELECT takedown_ref FROM users WHERE did = $1", result.did)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.flatten();
|
||||
if takedown.is_some() {
|
||||
return Err(TokenValidationError::AccountTakedown);
|
||||
}
|
||||
|
||||
+46
-8
@@ -33,11 +33,26 @@ pub fn create_refresh_token(did: &str, key_bytes: &[u8]) -> Result<String> {
|
||||
}
|
||||
|
||||
pub fn create_access_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result<TokenWithMetadata> {
|
||||
create_signed_token_with_metadata(did, SCOPE_ACCESS, TOKEN_TYPE_ACCESS, key_bytes, Duration::minutes(120))
|
||||
create_signed_token_with_metadata(
|
||||
did,
|
||||
SCOPE_ACCESS,
|
||||
TOKEN_TYPE_ACCESS,
|
||||
key_bytes,
|
||||
Duration::minutes(120),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_refresh_token_with_metadata(did: &str, key_bytes: &[u8]) -> Result<TokenWithMetadata> {
|
||||
create_signed_token_with_metadata(did, SCOPE_REFRESH, TOKEN_TYPE_REFRESH, key_bytes, Duration::days(90))
|
||||
pub fn create_refresh_token_with_metadata(
|
||||
did: &str,
|
||||
key_bytes: &[u8],
|
||||
) -> Result<TokenWithMetadata> {
|
||||
create_signed_token_with_metadata(
|
||||
did,
|
||||
SCOPE_REFRESH,
|
||||
TOKEN_TYPE_REFRESH,
|
||||
key_bytes,
|
||||
Duration::days(90),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_service_token(did: &str, aud: &str, lxm: &str, key_bytes: &[u8]) -> Result<String> {
|
||||
@@ -132,15 +147,38 @@ pub fn create_refresh_token_hs256(did: &str, secret: &[u8]) -> Result<String> {
|
||||
Ok(create_refresh_token_hs256_with_metadata(did, secret)?.token)
|
||||
}
|
||||
|
||||
pub fn create_access_token_hs256_with_metadata(did: &str, secret: &[u8]) -> Result<TokenWithMetadata> {
|
||||
create_hs256_token_with_metadata(did, SCOPE_ACCESS, TOKEN_TYPE_ACCESS, secret, Duration::minutes(120))
|
||||
pub fn create_access_token_hs256_with_metadata(
|
||||
did: &str,
|
||||
secret: &[u8],
|
||||
) -> Result<TokenWithMetadata> {
|
||||
create_hs256_token_with_metadata(
|
||||
did,
|
||||
SCOPE_ACCESS,
|
||||
TOKEN_TYPE_ACCESS,
|
||||
secret,
|
||||
Duration::minutes(120),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_refresh_token_hs256_with_metadata(did: &str, secret: &[u8]) -> Result<TokenWithMetadata> {
|
||||
create_hs256_token_with_metadata(did, SCOPE_REFRESH, TOKEN_TYPE_REFRESH, secret, Duration::days(90))
|
||||
pub fn create_refresh_token_hs256_with_metadata(
|
||||
did: &str,
|
||||
secret: &[u8],
|
||||
) -> Result<TokenWithMetadata> {
|
||||
create_hs256_token_with_metadata(
|
||||
did,
|
||||
SCOPE_REFRESH,
|
||||
TOKEN_TYPE_REFRESH,
|
||||
secret,
|
||||
Duration::days(90),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn create_service_token_hs256(did: &str, aud: &str, lxm: &str, secret: &[u8]) -> Result<String> {
|
||||
pub fn create_service_token_hs256(
|
||||
did: &str,
|
||||
aud: &str,
|
||||
lxm: &str,
|
||||
secret: &[u8],
|
||||
) -> Result<String> {
|
||||
let expiration = Utc::now()
|
||||
.checked_add_signed(Duration::seconds(60))
|
||||
.expect("valid timestamp")
|
||||
|
||||
+22
-12
@@ -1,5 +1,8 @@
|
||||
use super::token::{
|
||||
SCOPE_ACCESS, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED, SCOPE_REFRESH, TOKEN_TYPE_ACCESS,
|
||||
TOKEN_TYPE_REFRESH,
|
||||
};
|
||||
use super::{Claims, Header, TokenData, UnsafeClaims};
|
||||
use super::token::{TOKEN_TYPE_ACCESS, TOKEN_TYPE_REFRESH, SCOPE_ACCESS, SCOPE_REFRESH, SCOPE_APP_PASS, SCOPE_APP_PASS_PRIVILEGED};
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
@@ -40,7 +43,8 @@ pub fn get_jti_from_token(token: &str) -> Result<String, String> {
|
||||
let claims: serde_json::Value =
|
||||
serde_json::from_slice(&payload_bytes).map_err(|e| format!("JSON decode failed: {}", e))?;
|
||||
|
||||
claims.get("jti")
|
||||
claims
|
||||
.get("jti")
|
||||
.and_then(|j| j.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| "No jti claim in token".to_string())
|
||||
@@ -108,11 +112,14 @@ fn verify_token_internal(
|
||||
let header: Header =
|
||||
serde_json::from_slice(&header_bytes).context("JSON decode of header failed")?;
|
||||
|
||||
if let Some(expected) = expected_typ {
|
||||
if header.typ != expected {
|
||||
return Err(anyhow!("Invalid token type: expected {}, got {}", expected, header.typ));
|
||||
if let Some(expected) = expected_typ
|
||||
&& header.typ != expected {
|
||||
return Err(anyhow!(
|
||||
"Invalid token type: expected {}, got {}",
|
||||
expected,
|
||||
header.typ
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let signature_bytes = URL_SAFE_NO_PAD
|
||||
.decode(signature_b64)
|
||||
@@ -177,11 +184,14 @@ fn verify_token_hs256_internal(
|
||||
return Err(anyhow!("Expected HS256 algorithm, got {}", header.alg));
|
||||
}
|
||||
|
||||
if let Some(expected) = expected_typ {
|
||||
if header.typ != expected {
|
||||
return Err(anyhow!("Invalid token type: expected {}, got {}", expected, header.typ));
|
||||
if let Some(expected) = expected_typ
|
||||
&& header.typ != expected {
|
||||
return Err(anyhow!(
|
||||
"Invalid token type: expected {}, got {}",
|
||||
expected,
|
||||
header.typ
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let signature_bytes = URL_SAFE_NO_PAD
|
||||
.decode(signature_b64)
|
||||
@@ -189,8 +199,8 @@ fn verify_token_hs256_internal(
|
||||
|
||||
let message = format!("{}.{}", header_b64, claims_b64);
|
||||
|
||||
let mut mac = HmacSha256::new_from_slice(secret)
|
||||
.map_err(|e| anyhow!("Invalid secret: {}", e))?;
|
||||
let mut mac =
|
||||
HmacSha256::new_from_slice(secret).map_err(|e| anyhow!("Invalid secret: {}", e))?;
|
||||
mac.update(message.as_bytes());
|
||||
|
||||
let expected_signature = mac.finalize().into_bytes();
|
||||
|
||||
Vendored
+2
-43
@@ -32,8 +32,7 @@ pub struct ValkeyCache {
|
||||
|
||||
impl ValkeyCache {
|
||||
pub async fn new(url: &str) -> Result<Self, CacheError> {
|
||||
let client = redis::Client::open(url)
|
||||
.map_err(|e| CacheError::Connection(e.to_string()))?;
|
||||
let client = redis::Client::open(url).map_err(|e| CacheError::Connection(e.to_string()))?;
|
||||
let manager = client
|
||||
.get_connection_manager()
|
||||
.await
|
||||
@@ -118,7 +117,7 @@ impl DistributedRateLimiter for RedisRateLimiter {
|
||||
async fn check_rate_limit(&self, key: &str, limit: u32, window_ms: u64) -> bool {
|
||||
let mut conn = self.conn.clone();
|
||||
let full_key = format!("rl:{}", key);
|
||||
let window_secs = ((window_ms + 999) / 1000).max(1) as i64;
|
||||
let window_secs = window_ms.div_ceil(1000).max(1) as i64;
|
||||
let count: Result<i64, _> = redis::cmd("INCR")
|
||||
.arg(&full_key)
|
||||
.query_async(&mut conn)
|
||||
@@ -150,46 +149,6 @@ impl DistributedRateLimiter for NoOpRateLimiter {
|
||||
}
|
||||
}
|
||||
|
||||
pub enum CacheBackend {
|
||||
Valkey(ValkeyCache),
|
||||
NoOp,
|
||||
}
|
||||
|
||||
impl CacheBackend {
|
||||
pub fn rate_limiter(&self) -> Arc<dyn DistributedRateLimiter> {
|
||||
match self {
|
||||
CacheBackend::Valkey(cache) => {
|
||||
Arc::new(RedisRateLimiter::new(cache.connection()))
|
||||
}
|
||||
CacheBackend::NoOp => Arc::new(NoOpRateLimiter),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Cache for CacheBackend {
|
||||
async fn get(&self, key: &str) -> Option<String> {
|
||||
match self {
|
||||
CacheBackend::Valkey(c) => c.get(key).await,
|
||||
CacheBackend::NoOp => None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn set(&self, key: &str, value: &str, ttl: Duration) -> Result<(), CacheError> {
|
||||
match self {
|
||||
CacheBackend::Valkey(c) => c.set(key, value, ttl).await,
|
||||
CacheBackend::NoOp => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, key: &str) -> Result<(), CacheError> {
|
||||
match self {
|
||||
CacheBackend::Valkey(c) => c.delete(key).await,
|
||||
CacheBackend::NoOp => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_cache() -> (Arc<dyn Cache>, Arc<dyn DistributedRateLimiter>) {
|
||||
match std::env::var("VALKEY_URL") {
|
||||
Ok(url) => match ValkeyCache::new(&url).await {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
@@ -22,7 +22,12 @@ pub struct CircuitBreaker {
|
||||
}
|
||||
|
||||
impl CircuitBreaker {
|
||||
pub fn new(name: &str, failure_threshold: u32, success_threshold: u32, timeout_secs: u64) -> Self {
|
||||
pub fn new(
|
||||
name: &str,
|
||||
failure_threshold: u32,
|
||||
success_threshold: u32,
|
||||
timeout_secs: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
name: name.to_string(),
|
||||
failure_threshold,
|
||||
|
||||
+16
-9
@@ -1,8 +1,5 @@
|
||||
#[allow(deprecated)]
|
||||
use aes_gcm::{
|
||||
Aes256Gcm, KeyInit, Nonce,
|
||||
aead::Aead,
|
||||
};
|
||||
use aes_gcm::{Aes256Gcm, KeyInit, Nonce, aead::Aead};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use hkdf::Hkdf;
|
||||
use p256::ecdsa::SigningKey;
|
||||
@@ -62,17 +59,25 @@ impl AuthConfig {
|
||||
hasher.update(jwt_secret.as_bytes());
|
||||
let seed = hasher.finalize();
|
||||
|
||||
let signing_key = SigningKey::from_slice(&seed)
|
||||
.unwrap_or_else(|e| panic!("Failed to create signing key from seed: {}. This is a bug.", e));
|
||||
let signing_key = SigningKey::from_slice(&seed).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"Failed to create signing key from seed: {}. This is a bug.",
|
||||
e
|
||||
)
|
||||
});
|
||||
|
||||
let verifying_key = signing_key.verifying_key();
|
||||
let point = verifying_key.to_encoded_point(false);
|
||||
|
||||
let signing_key_x = URL_SAFE_NO_PAD.encode(
|
||||
point.x().expect("EC point missing X coordinate - this should never happen")
|
||||
point
|
||||
.x()
|
||||
.expect("EC point missing X coordinate - this should never happen"),
|
||||
);
|
||||
let signing_key_y = URL_SAFE_NO_PAD.encode(
|
||||
point.y().expect("EC point missing Y coordinate - this should never happen")
|
||||
point
|
||||
.y()
|
||||
.expect("EC point missing Y coordinate - this should never happen"),
|
||||
);
|
||||
|
||||
let mut kid_hasher = Sha256::new();
|
||||
@@ -114,7 +119,9 @@ impl AuthConfig {
|
||||
}
|
||||
|
||||
pub fn get() -> &'static Self {
|
||||
CONFIG.get().expect("AuthConfig not initialized - call AuthConfig::init() first")
|
||||
CONFIG
|
||||
.get()
|
||||
.expect("AuthConfig not initialized - call AuthConfig::init() first")
|
||||
}
|
||||
|
||||
pub fn jwt_secret(&self) -> &str {
|
||||
|
||||
+7
-5
@@ -1,8 +1,8 @@
|
||||
use crate::circuit_breaker::CircuitBreaker;
|
||||
use crate::sync::firehose::SequencedEvent;
|
||||
use reqwest::Client;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{broadcast, watch};
|
||||
use tracing::{debug, error, info, warn};
|
||||
@@ -78,18 +78,20 @@ impl Crawlers {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(cb) = &self.circuit_breaker {
|
||||
if !cb.can_execute().await {
|
||||
if let Some(cb) = &self.circuit_breaker
|
||||
&& !cb.can_execute().await {
|
||||
debug!("Skipping crawler notification due to circuit breaker open");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
self.mark_notified();
|
||||
let circuit_breaker = self.circuit_breaker.clone();
|
||||
|
||||
for crawler_url in &self.crawler_urls {
|
||||
let url = format!("{}/xrpc/com.atproto.sync.requestCrawl", crawler_url.trim_end_matches('/'));
|
||||
let url = format!(
|
||||
"{}/xrpc/com.atproto.sync.requestCrawl",
|
||||
crawler_url.trim_end_matches('/')
|
||||
);
|
||||
let hostname = self.hostname.clone();
|
||||
let client = self.http_client.clone();
|
||||
let cb = circuit_breaker.clone();
|
||||
|
||||
+20
-7
@@ -90,7 +90,11 @@ impl ImageProcessor {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn process(&self, data: &[u8], mime_type: &str) -> Result<ImageProcessingResult, ImageError> {
|
||||
pub fn process(
|
||||
&self,
|
||||
data: &[u8],
|
||||
mime_type: &str,
|
||||
) -> Result<ImageProcessingResult, ImageError> {
|
||||
if data.len() > self.max_file_size {
|
||||
return Err(ImageError::FileTooLarge {
|
||||
size: data.len(),
|
||||
@@ -107,12 +111,16 @@ impl ImageProcessor {
|
||||
});
|
||||
}
|
||||
let original = self.encode_image(&img)?;
|
||||
let thumbnail_feed = if self.generate_thumbnails && (img.width() > THUMB_SIZE_FEED || img.height() > THUMB_SIZE_FEED) {
|
||||
let thumbnail_feed = if self.generate_thumbnails
|
||||
&& (img.width() > THUMB_SIZE_FEED || img.height() > THUMB_SIZE_FEED)
|
||||
{
|
||||
Some(self.generate_thumbnail(&img, THUMB_SIZE_FEED)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let thumbnail_full = if self.generate_thumbnails && (img.width() > THUMB_SIZE_FULL || img.height() > THUMB_SIZE_FULL) {
|
||||
let thumbnail_full = if self.generate_thumbnails
|
||||
&& (img.width() > THUMB_SIZE_FULL || img.height() > THUMB_SIZE_FULL)
|
||||
{
|
||||
Some(self.generate_thumbnail(&img, THUMB_SIZE_FULL)?)
|
||||
} else {
|
||||
None
|
||||
@@ -183,7 +191,11 @@ impl ImageProcessor {
|
||||
})
|
||||
}
|
||||
|
||||
fn generate_thumbnail(&self, img: &DynamicImage, max_size: u32) -> Result<ProcessedImage, ImageError> {
|
||||
fn generate_thumbnail(
|
||||
&self,
|
||||
img: &DynamicImage,
|
||||
max_size: u32,
|
||||
) -> Result<ProcessedImage, ImageError> {
|
||||
let (orig_width, orig_height) = (img.width(), img.height());
|
||||
let (new_width, new_height) = if orig_width > orig_height {
|
||||
let ratio = max_size as f64 / orig_width as f64;
|
||||
@@ -204,8 +216,8 @@ impl ImageProcessor {
|
||||
}
|
||||
|
||||
pub fn strip_exif(data: &[u8]) -> Result<Vec<u8>, ImageError> {
|
||||
let format = image::guess_format(data)
|
||||
.map_err(|e| ImageError::DecodeError(e.to_string()))?;
|
||||
let format =
|
||||
image::guess_format(data).map_err(|e| ImageError::DecodeError(e.to_string()))?;
|
||||
let cursor = Cursor::new(data);
|
||||
let img = ImageReader::with_format(cursor, format)
|
||||
.decode()
|
||||
@@ -224,7 +236,8 @@ mod tests {
|
||||
fn create_test_image(width: u32, height: u32) -> Vec<u8> {
|
||||
let img = DynamicImage::new_rgb8(width, height);
|
||||
let mut buf = Vec::new();
|
||||
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png).unwrap();
|
||||
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)
|
||||
.unwrap();
|
||||
buf
|
||||
}
|
||||
|
||||
|
||||
+39
-43
@@ -109,18 +109,9 @@ pub fn app(state: AppState) -> Router {
|
||||
"/xrpc/com.atproto.sync.getLatestCommit",
|
||||
get(sync::get_latest_commit),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.sync.listRepos",
|
||||
get(sync::list_repos),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.sync.getBlob",
|
||||
get(sync::get_blob),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.sync.listBlobs",
|
||||
get(sync::list_blobs),
|
||||
)
|
||||
.route("/xrpc/com.atproto.sync.listRepos", get(sync::list_repos))
|
||||
.route("/xrpc/com.atproto.sync.getBlob", get(sync::get_blob))
|
||||
.route("/xrpc/com.atproto.sync.listBlobs", get(sync::list_blobs))
|
||||
.route(
|
||||
"/xrpc/com.atproto.sync.getRepoStatus",
|
||||
get(sync::get_repo_status),
|
||||
@@ -145,26 +136,14 @@ pub fn app(state: AppState) -> Router {
|
||||
"/xrpc/com.atproto.sync.requestCrawl",
|
||||
post(sync::request_crawl),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.sync.getBlocks",
|
||||
get(sync::get_blocks),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.sync.getRepo",
|
||||
get(sync::get_repo),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.sync.getRecord",
|
||||
get(sync::get_record),
|
||||
)
|
||||
.route("/xrpc/com.atproto.sync.getBlocks", get(sync::get_blocks))
|
||||
.route("/xrpc/com.atproto.sync.getRepo", get(sync::get_repo))
|
||||
.route("/xrpc/com.atproto.sync.getRecord", get(sync::get_record))
|
||||
.route(
|
||||
"/xrpc/com.atproto.sync.subscribeRepos",
|
||||
get(sync::subscribe_repos),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.sync.getHead",
|
||||
get(sync::get_head),
|
||||
)
|
||||
.route("/xrpc/com.atproto.sync.getHead", get(sync::get_head))
|
||||
.route(
|
||||
"/xrpc/com.atproto.sync.getCheckout",
|
||||
get(sync::get_checkout),
|
||||
@@ -349,16 +328,16 @@ pub fn app(state: AppState) -> Router {
|
||||
"/xrpc/app.bsky.feed.getPostThread",
|
||||
get(api::feed::get_post_thread),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/app.bsky.feed.getFeed",
|
||||
get(api::feed::get_feed),
|
||||
)
|
||||
.route("/xrpc/app.bsky.feed.getFeed", get(api::feed::get_feed))
|
||||
.route(
|
||||
"/xrpc/app.bsky.notification.registerPush",
|
||||
post(api::notification::register_push),
|
||||
)
|
||||
.route("/.well-known/did.json", get(api::identity::well_known_did))
|
||||
.route("/.well-known/atproto-did", get(api::identity::well_known_atproto_did))
|
||||
.route(
|
||||
"/.well-known/atproto-did",
|
||||
get(api::identity::well_known_atproto_did),
|
||||
)
|
||||
.route("/u/{handle}/did.json", get(api::identity::user_did_doc))
|
||||
.route(
|
||||
"/.well-known/oauth-protected-resource",
|
||||
@@ -375,13 +354,28 @@ pub fn app(state: AppState) -> Router {
|
||||
)
|
||||
.route("/oauth/authorize", get(oauth::endpoints::authorize_get))
|
||||
.route("/oauth/authorize", post(oauth::endpoints::authorize_post))
|
||||
.route("/oauth/authorize/select", post(oauth::endpoints::authorize_select))
|
||||
.route("/oauth/authorize/2fa", get(oauth::endpoints::authorize_2fa_get))
|
||||
.route("/oauth/authorize/2fa", post(oauth::endpoints::authorize_2fa_post))
|
||||
.route("/oauth/authorize/deny", post(oauth::endpoints::authorize_deny))
|
||||
.route(
|
||||
"/oauth/authorize/select",
|
||||
post(oauth::endpoints::authorize_select),
|
||||
)
|
||||
.route(
|
||||
"/oauth/authorize/2fa",
|
||||
get(oauth::endpoints::authorize_2fa_get),
|
||||
)
|
||||
.route(
|
||||
"/oauth/authorize/2fa",
|
||||
post(oauth::endpoints::authorize_2fa_post),
|
||||
)
|
||||
.route(
|
||||
"/oauth/authorize/deny",
|
||||
post(oauth::endpoints::authorize_deny),
|
||||
)
|
||||
.route("/oauth/token", post(oauth::endpoints::token_endpoint))
|
||||
.route("/oauth/revoke", post(oauth::endpoints::revoke_token))
|
||||
.route("/oauth/introspect", post(oauth::endpoints::introspect_token))
|
||||
.route(
|
||||
"/oauth/introspect",
|
||||
post(oauth::endpoints::introspect_token),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.temp.checkSignupQueue",
|
||||
get(api::temp::check_signup_queue),
|
||||
@@ -404,13 +398,15 @@ pub fn app(state: AppState) -> Router {
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let frontend_dir = std::env::var("FRONTEND_DIR")
|
||||
.unwrap_or_else(|_| "./frontend/dist".to_string());
|
||||
let frontend_dir =
|
||||
std::env::var("FRONTEND_DIR").unwrap_or_else(|_| "./frontend/dist".to_string());
|
||||
|
||||
if std::path::Path::new(&frontend_dir).join("index.html").exists() {
|
||||
if std::path::Path::new(&frontend_dir)
|
||||
.join("index.html")
|
||||
.exists()
|
||||
{
|
||||
let index_path = format!("{}/index.html", frontend_dir);
|
||||
let serve_dir = ServeDir::new(&frontend_dir)
|
||||
.not_found_service(ServeFile::new(index_path));
|
||||
let serve_dir = ServeDir::new(&frontend_dir).not_found_service(ServeFile::new(index_path));
|
||||
router.fallback_service(serve_dir)
|
||||
} else {
|
||||
router
|
||||
|
||||
+9
-3
@@ -1,5 +1,7 @@
|
||||
use bspds::crawlers::{Crawlers, start_crawlers_service};
|
||||
use bspds::notifications::{DiscordSender, EmailSender, NotificationService, SignalSender, TelegramSender};
|
||||
use bspds::notifications::{
|
||||
DiscordSender, EmailSender, NotificationService, SignalSender, TelegramSender,
|
||||
};
|
||||
use bspds::state::AppState;
|
||||
use std::net::SocketAddr;
|
||||
use std::process::ExitCode;
|
||||
@@ -94,11 +96,15 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
let crawlers_handle = if let Some(crawlers) = Crawlers::from_env() {
|
||||
let crawlers = Arc::new(
|
||||
crawlers.with_circuit_breaker(state.circuit_breakers.relay_notification.clone())
|
||||
crawlers.with_circuit_breaker(state.circuit_breakers.relay_notification.clone()),
|
||||
);
|
||||
let firehose_rx = state.firehose_tx.subscribe();
|
||||
info!("Crawlers notification service enabled");
|
||||
Some(tokio::spawn(start_crawlers_service(crawlers, firehose_rx, shutdown_rx)))
|
||||
Some(tokio::spawn(start_crawlers_service(
|
||||
crawlers,
|
||||
firehose_rx,
|
||||
shutdown_rx,
|
||||
)))
|
||||
} else {
|
||||
warn!("Crawlers notification service disabled (PDS_HOSTNAME or CRAWLERS not set)");
|
||||
None
|
||||
|
||||
+9
-12
@@ -24,10 +24,7 @@ pub fn init_metrics() -> PrometheusHandle {
|
||||
}
|
||||
|
||||
fn describe_metrics() {
|
||||
metrics::describe_counter!(
|
||||
"bspds_http_requests_total",
|
||||
"Total number of HTTP requests"
|
||||
);
|
||||
metrics::describe_counter!("bspds_http_requests_total", "Total number of HTTP requests");
|
||||
metrics::describe_histogram!(
|
||||
"bspds_http_request_duration_seconds",
|
||||
"HTTP request duration in seconds"
|
||||
@@ -64,10 +61,7 @@ fn describe_metrics() {
|
||||
"bspds_rate_limit_rejections_total",
|
||||
"Total number of rate limit rejections"
|
||||
);
|
||||
metrics::describe_counter!(
|
||||
"bspds_db_queries_total",
|
||||
"Total number of database queries"
|
||||
);
|
||||
metrics::describe_counter!("bspds_db_queries_total", "Total number of database queries");
|
||||
metrics::describe_histogram!(
|
||||
"bspds_db_query_duration_seconds",
|
||||
"Database query duration in seconds"
|
||||
@@ -78,7 +72,11 @@ pub async fn metrics_handler() -> impl IntoResponse {
|
||||
match PROMETHEUS_HANDLE.get() {
|
||||
Some(handle) => {
|
||||
let metrics = handle.render();
|
||||
(StatusCode::OK, [("content-type", "text/plain; version=0.0.4")], metrics)
|
||||
(
|
||||
StatusCode::OK,
|
||||
[("content-type", "text/plain; version=0.0.4")],
|
||||
metrics,
|
||||
)
|
||||
}
|
||||
None => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -117,14 +115,13 @@ pub async fn metrics_middleware(request: Request<Body>, next: Next) -> Response
|
||||
}
|
||||
|
||||
fn normalize_path(path: &str) -> String {
|
||||
if path.starts_with("/xrpc/") {
|
||||
if let Some(method) = path.strip_prefix("/xrpc/") {
|
||||
if path.starts_with("/xrpc/")
|
||||
&& let Some(method) = path.strip_prefix("/xrpc/") {
|
||||
if let Some(q) = method.find('?') {
|
||||
return format!("/xrpc/{}", &method[..q]);
|
||||
}
|
||||
return path.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if path.starts_with("/u/") && path.ends_with("/did.json") {
|
||||
return "/u/{handle}/did.json".to_string();
|
||||
|
||||
@@ -8,9 +8,9 @@ pub use sender::{
|
||||
};
|
||||
|
||||
pub use service::{
|
||||
channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_email_update,
|
||||
enqueue_email_verification, enqueue_notification, enqueue_password_reset,
|
||||
enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, NotificationService,
|
||||
NotificationService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion,
|
||||
enqueue_email_update, enqueue_email_verification, enqueue_notification, enqueue_password_reset,
|
||||
enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
|
||||
};
|
||||
|
||||
pub use types::{
|
||||
|
||||
+14
-19
@@ -80,7 +80,8 @@ impl EmailSender {
|
||||
Self {
|
||||
from_address,
|
||||
from_name,
|
||||
sendmail_path: std::env::var("SENDMAIL_PATH").unwrap_or_else(|_| "/usr/sbin/sendmail".to_string()),
|
||||
sendmail_path: std::env::var("SENDMAIL_PATH")
|
||||
.unwrap_or_else(|_| "/usr/sbin/sendmail".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,19 +92,21 @@ impl EmailSender {
|
||||
}
|
||||
|
||||
pub fn format_email(&self, notification: &QueuedNotification) -> String {
|
||||
let subject = sanitize_header_value(notification.subject.as_deref().unwrap_or("Notification"));
|
||||
let subject =
|
||||
sanitize_header_value(notification.subject.as_deref().unwrap_or("Notification"));
|
||||
let recipient = sanitize_header_value(¬ification.recipient);
|
||||
let from_header = if self.from_name.is_empty() {
|
||||
self.from_address.clone()
|
||||
} else {
|
||||
format!("{} <{}>", sanitize_header_value(&self.from_name), self.from_address)
|
||||
format!(
|
||||
"{} <{}>",
|
||||
sanitize_header_value(&self.from_name),
|
||||
self.from_address
|
||||
)
|
||||
};
|
||||
format!(
|
||||
"From: {}\r\nTo: {}\r\nSubject: {}\r\nContent-Type: text/plain; charset=utf-8\r\nMIME-Version: 1.0\r\n\r\n{}",
|
||||
from_header,
|
||||
recipient,
|
||||
subject,
|
||||
notification.body
|
||||
from_header, recipient, subject, notification.body
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -195,7 +198,7 @@ impl NotificationSender for DiscordSender {
|
||||
Err(e) => {
|
||||
if e.is_timeout() {
|
||||
if attempt < MAX_RETRIES - 1 {
|
||||
last_error = Some(format!("Discord request timed out"));
|
||||
last_error = Some("Discord request timed out".to_string());
|
||||
retry_delay(attempt).await;
|
||||
continue;
|
||||
}
|
||||
@@ -243,10 +246,7 @@ impl NotificationSender for TelegramSender {
|
||||
let chat_id = ¬ification.recipient;
|
||||
let subject = notification.subject.as_deref().unwrap_or("Notification");
|
||||
let text = format!("*{}*\n\n{}", subject, notification.body);
|
||||
let url = format!(
|
||||
"https://api.telegram.org/bot{}/sendMessage",
|
||||
self.bot_token
|
||||
);
|
||||
let url = format!("https://api.telegram.org/bot{}/sendMessage", self.bot_token);
|
||||
let payload = json!({
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
@@ -254,12 +254,7 @@ impl NotificationSender for TelegramSender {
|
||||
});
|
||||
let mut last_error = None;
|
||||
for attempt in 0..MAX_RETRIES {
|
||||
let result = self
|
||||
.http_client
|
||||
.post(&url)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await;
|
||||
let result = self.http_client.post(&url).json(&payload).send().await;
|
||||
match result {
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
@@ -280,7 +275,7 @@ impl NotificationSender for TelegramSender {
|
||||
Err(e) => {
|
||||
if e.is_timeout() {
|
||||
if attempt < MAX_RETRIES - 1 {
|
||||
last_error = Some(format!("Telegram request timed out"));
|
||||
last_error = Some("Telegram request timed out".to_string());
|
||||
retry_delay(attempt).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,9 @@ impl NotificationService {
|
||||
|
||||
pub async fn run(self, mut shutdown: watch::Receiver<bool>) {
|
||||
if self.senders.is_empty() {
|
||||
warn!("Notification service starting with no senders configured. Notifications will be queued but not delivered until senders are configured.");
|
||||
warn!(
|
||||
"Notification service starting with no senders configured. Notifications will be queued but not delivered until senders are configured."
|
||||
);
|
||||
}
|
||||
info!(
|
||||
poll_interval_secs = self.poll_interval.as_secs(),
|
||||
@@ -231,7 +233,10 @@ impl NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn enqueue_notification(db: &PgPool, notification: NewNotification) -> Result<Uuid, sqlx::Error> {
|
||||
pub async fn enqueue_notification(
|
||||
db: &PgPool,
|
||||
notification: NewNotification,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
sqlx::query_scalar!(
|
||||
r#"
|
||||
INSERT INTO notification_queue
|
||||
|
||||
+117
-80
@@ -88,18 +88,15 @@ impl ClientMetadataCache {
|
||||
|
||||
fn is_loopback_client(client_id: &str) -> bool {
|
||||
if let Ok(url) = reqwest::Url::parse(client_id) {
|
||||
url.scheme() == "http"
|
||||
&& url.host_str() == Some("localhost")
|
||||
&& url.port().is_none()
|
||||
url.scheme() == "http" && url.host_str() == Some("localhost") && url.port().is_none()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn build_loopback_metadata(client_id: &str) -> Result<ClientMetadata, OAuthError> {
|
||||
let url = reqwest::Url::parse(client_id).map_err(|_| {
|
||||
OAuthError::InvalidClient("Invalid loopback client_id URL".to_string())
|
||||
})?;
|
||||
let url = reqwest::Url::parse(client_id)
|
||||
.map_err(|_| OAuthError::InvalidClient("Invalid loopback client_id URL".to_string()))?;
|
||||
let mut redirect_uris = Vec::new();
|
||||
for (key, value) in url.query_pairs() {
|
||||
if key == "redirect_uri" {
|
||||
@@ -117,7 +114,10 @@ impl ClientMetadataCache {
|
||||
client_uri: None,
|
||||
logo_uri: None,
|
||||
redirect_uris,
|
||||
grant_types: vec!["authorization_code".to_string(), "refresh_token".to_string()],
|
||||
grant_types: vec![
|
||||
"authorization_code".to_string(),
|
||||
"refresh_token".to_string(),
|
||||
],
|
||||
response_types: vec!["code".to_string()],
|
||||
scope,
|
||||
token_endpoint_auth_method: Some("none".to_string()),
|
||||
@@ -134,11 +134,10 @@ impl ClientMetadataCache {
|
||||
}
|
||||
{
|
||||
let cache = self.cache.read().await;
|
||||
if let Some(cached) = cache.get(client_id) {
|
||||
if cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs {
|
||||
if let Some(cached) = cache.get(client_id)
|
||||
&& cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs {
|
||||
return Ok(cached.metadata.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let metadata = self.fetch_metadata(client_id).await?;
|
||||
{
|
||||
@@ -154,7 +153,10 @@ impl ClientMetadataCache {
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
pub async fn get_jwks(&self, metadata: &ClientMetadata) -> Result<serde_json::Value, OAuthError> {
|
||||
pub async fn get_jwks(
|
||||
&self,
|
||||
metadata: &ClientMetadata,
|
||||
) -> Result<serde_json::Value, OAuthError> {
|
||||
if let Some(jwks) = &metadata.jwks {
|
||||
return Ok(jwks.clone());
|
||||
}
|
||||
@@ -165,11 +167,10 @@ impl ClientMetadataCache {
|
||||
})?;
|
||||
{
|
||||
let cache = self.jwks_cache.read().await;
|
||||
if let Some(cached) = cache.get(jwks_uri) {
|
||||
if cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs {
|
||||
if let Some(cached) = cache.get(jwks_uri)
|
||||
&& cached.cached_at.elapsed().as_secs() < self.cache_ttl_secs {
|
||||
return Ok(cached.jwks.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let jwks = self.fetch_jwks(jwks_uri).await?;
|
||||
{
|
||||
@@ -186,15 +187,14 @@ impl ClientMetadataCache {
|
||||
}
|
||||
|
||||
async fn fetch_jwks(&self, jwks_uri: &str) -> Result<serde_json::Value, OAuthError> {
|
||||
if !jwks_uri.starts_with("https://") {
|
||||
if !jwks_uri.starts_with("http://")
|
||||
|| (!jwks_uri.contains("localhost") && !jwks_uri.contains("127.0.0.1"))
|
||||
if !jwks_uri.starts_with("https://")
|
||||
&& (!jwks_uri.starts_with("http://")
|
||||
|| (!jwks_uri.contains("localhost") && !jwks_uri.contains("127.0.0.1")))
|
||||
{
|
||||
return Err(OAuthError::InvalidClient(
|
||||
"jwks_uri must use https (except for localhost)".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let response = self
|
||||
.http_client
|
||||
.get(jwks_uri)
|
||||
@@ -242,17 +242,18 @@ impl ClientMetadataCache {
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| OAuthError::InvalidClient(format!("Failed to fetch client metadata: {}", e)))?;
|
||||
.map_err(|e| {
|
||||
OAuthError::InvalidClient(format!("Failed to fetch client metadata: {}", e))
|
||||
})?;
|
||||
if !response.status().is_success() {
|
||||
return Err(OAuthError::InvalidClient(format!(
|
||||
"Failed to fetch client metadata: HTTP {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
let mut metadata: ClientMetadata = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| OAuthError::InvalidClient(format!("Invalid client metadata JSON: {}", e)))?;
|
||||
let mut metadata: ClientMetadata = response.json().await.map_err(|e| {
|
||||
OAuthError::InvalidClient(format!("Invalid client metadata JSON: {}", e))
|
||||
})?;
|
||||
if metadata.client_id.is_empty() {
|
||||
metadata.client_id = client_id.to_string();
|
||||
} else if metadata.client_id != client_id {
|
||||
@@ -274,7 +275,9 @@ impl ClientMetadataCache {
|
||||
self.validate_redirect_uri_format(uri)?;
|
||||
}
|
||||
if !metadata.grant_types.is_empty()
|
||||
&& !metadata.grant_types.contains(&"authorization_code".to_string())
|
||||
&& !metadata
|
||||
.grant_types
|
||||
.contains(&"authorization_code".to_string())
|
||||
{
|
||||
return Err(OAuthError::InvalidClient(
|
||||
"authorization_code grant type is required".to_string(),
|
||||
@@ -298,8 +301,8 @@ impl ClientMetadataCache {
|
||||
if metadata.redirect_uris.contains(&redirect_uri.to_string()) {
|
||||
return Ok(());
|
||||
}
|
||||
if Self::is_loopback_client(&metadata.client_id) {
|
||||
if let Ok(req_url) = reqwest::Url::parse(redirect_uri) {
|
||||
if Self::is_loopback_client(&metadata.client_id)
|
||||
&& let Ok(req_url) = reqwest::Url::parse(redirect_uri) {
|
||||
let req_host = req_url.host_str().unwrap_or("");
|
||||
let is_loopback_redirect = req_url.scheme() == "http"
|
||||
&& (req_host == "localhost" || req_host == "127.0.0.1" || req_host == "[::1]");
|
||||
@@ -319,7 +322,6 @@ impl ClientMetadataCache {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(OAuthError::InvalidRequest(
|
||||
"redirect_uri not registered for client".to_string(),
|
||||
))
|
||||
@@ -331,9 +333,8 @@ impl ClientMetadataCache {
|
||||
"redirect_uri must not contain a fragment".to_string(),
|
||||
));
|
||||
}
|
||||
let parsed = reqwest::Url::parse(uri).map_err(|_| {
|
||||
OAuthError::InvalidClient(format!("Invalid redirect_uri: {}", uri))
|
||||
})?;
|
||||
let parsed = reqwest::Url::parse(uri)
|
||||
.map_err(|_| OAuthError::InvalidClient(format!("Invalid redirect_uri: {}", uri)))?;
|
||||
let scheme = parsed.scheme();
|
||||
if scheme == "http" {
|
||||
let host = parsed.host_str().unwrap_or("");
|
||||
@@ -343,8 +344,15 @@ impl ClientMetadataCache {
|
||||
));
|
||||
}
|
||||
} else if scheme == "https" {
|
||||
} else if scheme.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '+' || c == '.' || c == '-') {
|
||||
if !scheme.chars().next().map(|c| c.is_ascii_lowercase()).unwrap_or(false) {
|
||||
} else if scheme.chars().all(|c| {
|
||||
c.is_ascii_lowercase() || c.is_ascii_digit() || c == '+' || c == '.' || c == '-'
|
||||
}) {
|
||||
if !scheme
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| c.is_ascii_lowercase())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(OAuthError::InvalidClient(format!(
|
||||
"Invalid redirect_uri scheme: {}",
|
||||
scheme
|
||||
@@ -366,9 +374,7 @@ impl ClientMetadata {
|
||||
}
|
||||
|
||||
pub fn auth_method(&self) -> &str {
|
||||
self.token_endpoint_auth_method
|
||||
.as_deref()
|
||||
.unwrap_or("none")
|
||||
self.token_endpoint_auth_method.as_deref().unwrap_or("none")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,10 +417,15 @@ async fn verify_private_key_jwt_async(
|
||||
metadata: &ClientMetadata,
|
||||
client_assertion: &str,
|
||||
) -> Result<(), OAuthError> {
|
||||
use base64::{Engine as _, engine::general_purpose::{URL_SAFE_NO_PAD, STANDARD}};
|
||||
use base64::{
|
||||
Engine as _,
|
||||
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||
};
|
||||
let parts: Vec<&str> = client_assertion.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(OAuthError::InvalidClient("Invalid client_assertion format".to_string()));
|
||||
return Err(OAuthError::InvalidClient(
|
||||
"Invalid client_assertion format".to_string(),
|
||||
));
|
||||
}
|
||||
let header_bytes = URL_SAFE_NO_PAD
|
||||
.decode(parts[0])
|
||||
@@ -422,10 +433,14 @@ async fn verify_private_key_jwt_async(
|
||||
.map_err(|_| OAuthError::InvalidClient("Invalid assertion header encoding".to_string()))?;
|
||||
let header: serde_json::Value = serde_json::from_slice(&header_bytes)
|
||||
.map_err(|_| OAuthError::InvalidClient("Invalid assertion header JSON".to_string()))?;
|
||||
let alg = header.get("alg").and_then(|a| a.as_str()).ok_or_else(|| {
|
||||
OAuthError::InvalidClient("Missing alg in client_assertion".to_string())
|
||||
})?;
|
||||
if !matches!(alg, "ES256" | "ES384" | "RS256" | "RS384" | "RS512" | "EdDSA") {
|
||||
let alg = header
|
||||
.get("alg")
|
||||
.and_then(|a| a.as_str())
|
||||
.ok_or_else(|| OAuthError::InvalidClient("Missing alg in client_assertion".to_string()))?;
|
||||
if !matches!(
|
||||
alg,
|
||||
"ES256" | "ES384" | "RS256" | "RS384" | "RS512" | "EdDSA"
|
||||
) {
|
||||
return Err(OAuthError::InvalidClient(format!(
|
||||
"Unsupported client_assertion algorithm: {}",
|
||||
alg
|
||||
@@ -441,17 +456,19 @@ async fn verify_private_key_jwt_async(
|
||||
})?;
|
||||
let payload: serde_json::Value = serde_json::from_slice(&payload_bytes)
|
||||
.map_err(|_| OAuthError::InvalidClient("Invalid assertion payload JSON".to_string()))?;
|
||||
let iss = payload.get("iss").and_then(|i| i.as_str()).ok_or_else(|| {
|
||||
OAuthError::InvalidClient("Missing iss in client_assertion".to_string())
|
||||
})?;
|
||||
let iss = payload
|
||||
.get("iss")
|
||||
.and_then(|i| i.as_str())
|
||||
.ok_or_else(|| OAuthError::InvalidClient("Missing iss in client_assertion".to_string()))?;
|
||||
if iss != metadata.client_id {
|
||||
return Err(OAuthError::InvalidClient(
|
||||
"client_assertion iss does not match client_id".to_string(),
|
||||
));
|
||||
}
|
||||
let sub = payload.get("sub").and_then(|s| s.as_str()).ok_or_else(|| {
|
||||
OAuthError::InvalidClient("Missing sub in client_assertion".to_string())
|
||||
})?;
|
||||
let sub = payload
|
||||
.get("sub")
|
||||
.and_then(|s| s.as_str())
|
||||
.ok_or_else(|| OAuthError::InvalidClient("Missing sub in client_assertion".to_string()))?;
|
||||
if sub != metadata.client_id {
|
||||
return Err(OAuthError::InvalidClient(
|
||||
"client_assertion sub does not match client_id".to_string(),
|
||||
@@ -462,30 +479,38 @@ async fn verify_private_key_jwt_async(
|
||||
let iat = payload.get("iat").and_then(|i| i.as_i64());
|
||||
if let Some(exp) = exp {
|
||||
if exp < now {
|
||||
return Err(OAuthError::InvalidClient("client_assertion has expired".to_string()));
|
||||
return Err(OAuthError::InvalidClient(
|
||||
"client_assertion has expired".to_string(),
|
||||
));
|
||||
}
|
||||
} else if let Some(iat) = iat {
|
||||
let max_age_secs = 300;
|
||||
if now - iat > max_age_secs {
|
||||
tracing::warn!(iat = iat, now = now, "client_assertion too old (no exp, using iat)");
|
||||
return Err(OAuthError::InvalidClient("client_assertion is too old".to_string()));
|
||||
tracing::warn!(
|
||||
iat = iat,
|
||||
now = now,
|
||||
"client_assertion too old (no exp, using iat)"
|
||||
);
|
||||
return Err(OAuthError::InvalidClient(
|
||||
"client_assertion is too old".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(OAuthError::InvalidClient(
|
||||
"client_assertion must have exp or iat claim".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(iat) = iat {
|
||||
if iat > now + 60 {
|
||||
if let Some(iat) = iat
|
||||
&& iat > now + 60 {
|
||||
return Err(OAuthError::InvalidClient(
|
||||
"client_assertion iat is in the future".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let jwks = cache.get_jwks(metadata).await?;
|
||||
let keys = jwks.get("keys").and_then(|k| k.as_array()).ok_or_else(|| {
|
||||
OAuthError::InvalidClient("Invalid JWKS: missing keys array".to_string())
|
||||
})?;
|
||||
let keys = jwks
|
||||
.get("keys")
|
||||
.and_then(|k| k.as_array())
|
||||
.ok_or_else(|| OAuthError::InvalidClient("Invalid JWKS: missing keys array".to_string()))?;
|
||||
let matching_keys: Vec<&serde_json::Value> = if let Some(kid) = kid {
|
||||
keys.iter()
|
||||
.filter(|k| k.get("kid").and_then(|v| v.as_str()) == Some(kid))
|
||||
@@ -532,17 +557,21 @@ fn verify_es256(
|
||||
signature: &[u8],
|
||||
) -> Result<(), OAuthError> {
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
|
||||
use p256::EncodedPoint;
|
||||
let x = key.get("x").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
OAuthError::InvalidClient("Missing x coordinate in EC key".to_string())
|
||||
})?;
|
||||
let y = key.get("y").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
OAuthError::InvalidClient("Missing y coordinate in EC key".to_string())
|
||||
})?;
|
||||
let x_bytes = URL_SAFE_NO_PAD.decode(x)
|
||||
use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
|
||||
let x = key
|
||||
.get("x")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| OAuthError::InvalidClient("Missing x coordinate in EC key".to_string()))?;
|
||||
let y = key
|
||||
.get("y")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?;
|
||||
let x_bytes = URL_SAFE_NO_PAD
|
||||
.decode(x)
|
||||
.map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?;
|
||||
let y_bytes = URL_SAFE_NO_PAD.decode(y)
|
||||
let y_bytes = URL_SAFE_NO_PAD
|
||||
.decode(y)
|
||||
.map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?;
|
||||
let mut point_bytes = vec![0x04];
|
||||
point_bytes.extend_from_slice(&x_bytes);
|
||||
@@ -564,17 +593,21 @@ fn verify_es384(
|
||||
signature: &[u8],
|
||||
) -> Result<(), OAuthError> {
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use p384::ecdsa::{Signature, VerifyingKey, signature::Verifier};
|
||||
use p384::EncodedPoint;
|
||||
let x = key.get("x").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
OAuthError::InvalidClient("Missing x coordinate in EC key".to_string())
|
||||
})?;
|
||||
let y = key.get("y").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
OAuthError::InvalidClient("Missing y coordinate in EC key".to_string())
|
||||
})?;
|
||||
let x_bytes = URL_SAFE_NO_PAD.decode(x)
|
||||
use p384::ecdsa::{Signature, VerifyingKey, signature::Verifier};
|
||||
let x = key
|
||||
.get("x")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| OAuthError::InvalidClient("Missing x coordinate in EC key".to_string()))?;
|
||||
let y = key
|
||||
.get("y")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| OAuthError::InvalidClient("Missing y coordinate in EC key".to_string()))?;
|
||||
let x_bytes = URL_SAFE_NO_PAD
|
||||
.decode(x)
|
||||
.map_err(|_| OAuthError::InvalidClient("Invalid x coordinate encoding".to_string()))?;
|
||||
let y_bytes = URL_SAFE_NO_PAD.decode(y)
|
||||
let y_bytes = URL_SAFE_NO_PAD
|
||||
.decode(y)
|
||||
.map_err(|_| OAuthError::InvalidClient("Invalid y coordinate encoding".to_string()))?;
|
||||
let mut point_bytes = vec![0x04];
|
||||
point_bytes.extend_from_slice(&x_bytes);
|
||||
@@ -615,16 +648,20 @@ fn verify_eddsa(
|
||||
crv
|
||||
)));
|
||||
}
|
||||
let x = key.get("x").and_then(|v| v.as_str()).ok_or_else(|| {
|
||||
OAuthError::InvalidClient("Missing x in OKP key".to_string())
|
||||
})?;
|
||||
let x_bytes = URL_SAFE_NO_PAD.decode(x)
|
||||
let x = key
|
||||
.get("x")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| OAuthError::InvalidClient("Missing x in OKP key".to_string()))?;
|
||||
let x_bytes = URL_SAFE_NO_PAD
|
||||
.decode(x)
|
||||
.map_err(|_| OAuthError::InvalidClient("Invalid x encoding".to_string()))?;
|
||||
let key_bytes: [u8; 32] = x_bytes.try_into()
|
||||
let key_bytes: [u8; 32] = x_bytes
|
||||
.try_into()
|
||||
.map_err(|_| OAuthError::InvalidClient("Invalid Ed25519 key length".to_string()))?;
|
||||
let verifying_key = VerifyingKey::from_bytes(&key_bytes)
|
||||
.map_err(|_| OAuthError::InvalidClient("Invalid Ed25519 key".to_string()))?;
|
||||
let sig_bytes: [u8; 64] = signature.try_into()
|
||||
let sig_bytes: [u8; 64] = signature
|
||||
.try_into()
|
||||
.map_err(|_| OAuthError::InvalidClient("Invalid EdDSA signature length".to_string()))?;
|
||||
let sig = Signature::from_bytes(&sig_bytes);
|
||||
verifying_key
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use sqlx::PgPool;
|
||||
use super::super::{AuthorizedClientData, OAuthError};
|
||||
use super::helpers::{from_json, to_json};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub async fn upsert_authorized_client(
|
||||
pool: &PgPool,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::super::{DeviceData, OAuthError};
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use super::super::{DeviceData, OAuthError};
|
||||
|
||||
pub struct DeviceAccountRow {
|
||||
pub did: String,
|
||||
@@ -49,10 +49,7 @@ pub async fn get_device(pool: &PgPool, device_id: &str) -> Result<Option<DeviceD
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn update_device_last_seen(
|
||||
pool: &PgPool,
|
||||
device_id: &str,
|
||||
) -> Result<(), OAuthError> {
|
||||
pub async fn update_device_last_seen(pool: &PgPool, device_id: &str) -> Result<(), OAuthError> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE oauth_device
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
use sqlx::PgPool;
|
||||
use super::super::OAuthError;
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub async fn check_and_record_dpop_jti(
|
||||
pool: &PgPool,
|
||||
jti: &str,
|
||||
) -> Result<bool, OAuthError> {
|
||||
pub async fn check_and_record_dpop_jti(pool: &PgPool, jti: &str) -> Result<bool, OAuthError> {
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO oauth_dpop_jti (jti)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
use super::super::OAuthError;
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
|
||||
pub fn to_json<T: Serialize>(value: &T) -> Result<serde_json::Value, OAuthError> {
|
||||
serde_json::to_value(value).map_err(|e| {
|
||||
|
||||
+5
-5
@@ -8,8 +8,8 @@ mod two_factor;
|
||||
|
||||
pub use client::{get_authorized_client, upsert_authorized_client};
|
||||
pub use device::{
|
||||
create_device, delete_device, get_device, get_device_accounts, update_device_last_seen,
|
||||
upsert_account_device, verify_account_on_device, DeviceAccountRow,
|
||||
DeviceAccountRow, create_device, delete_device, get_device, get_device_accounts,
|
||||
update_device_last_seen, upsert_account_device, verify_account_on_device,
|
||||
};
|
||||
pub use dpop::{check_and_record_dpop_jti, cleanup_expired_dpop_jtis};
|
||||
pub use request::{
|
||||
@@ -23,7 +23,7 @@ pub use token::{
|
||||
get_token_by_refresh_token, list_tokens_for_user, rotate_token,
|
||||
};
|
||||
pub use two_factor::{
|
||||
check_user_2fa_enabled, cleanup_expired_2fa_challenges, create_2fa_challenge,
|
||||
delete_2fa_challenge, delete_2fa_challenge_by_request_uri, generate_2fa_code,
|
||||
get_2fa_challenge, increment_2fa_attempts, TwoFactorChallenge,
|
||||
TwoFactorChallenge, check_user_2fa_enabled, cleanup_expired_2fa_challenges,
|
||||
create_2fa_challenge, delete_2fa_challenge, delete_2fa_challenge_by_request_uri,
|
||||
generate_2fa_code, get_2fa_challenge, increment_2fa_attempts,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use sqlx::PgPool;
|
||||
use super::super::{AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData};
|
||||
use super::helpers::{from_json, to_json};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub async fn create_authorization_request(
|
||||
pool: &PgPool,
|
||||
|
||||
+4
-10
@@ -1,12 +1,9 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
use super::super::{OAuthError, TokenData};
|
||||
use super::helpers::{from_json, to_json};
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::PgPool;
|
||||
|
||||
pub async fn create_token(
|
||||
pool: &PgPool,
|
||||
data: &TokenData,
|
||||
) -> Result<i32, OAuthError> {
|
||||
pub async fn create_token(pool: &PgPool, data: &TokenData) -> Result<i32, OAuthError> {
|
||||
let client_auth_json = to_json(&data.client_auth)?;
|
||||
let parameters_json = to_json(&data.parameters)?;
|
||||
let row = sqlx::query!(
|
||||
@@ -193,10 +190,7 @@ pub async fn delete_token_family(pool: &PgPool, db_id: i32) -> Result<(), OAuthE
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_tokens_for_user(
|
||||
pool: &PgPool,
|
||||
did: &str,
|
||||
) -> Result<Vec<TokenData>, OAuthError> {
|
||||
pub async fn list_tokens_for_user(pool: &PgPool, did: &str) -> Result<Vec<TokenData>, OAuthError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
SELECT did, token_id, created_at, updated_at, expires_at, client_id, client_auth,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::super::OAuthError;
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use rand::Rng;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
use super::super::OAuthError;
|
||||
|
||||
pub struct TwoFactorChallenge {
|
||||
pub id: Uuid,
|
||||
|
||||
+79
-48
@@ -61,7 +61,7 @@ impl DPoPVerifier {
|
||||
let timestamp_bytes = timestamp.to_be_bytes();
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&self.secret);
|
||||
hasher.update(×tamp_bytes);
|
||||
hasher.update(timestamp_bytes);
|
||||
let hash = hasher.finalize();
|
||||
let mut nonce_data = Vec::with_capacity(8 + 16);
|
||||
nonce_data.extend_from_slice(×tamp_bytes);
|
||||
@@ -74,7 +74,9 @@ impl DPoPVerifier {
|
||||
.decode(nonce)
|
||||
.map_err(|_| OAuthError::InvalidDpopProof("Invalid nonce encoding".to_string()))?;
|
||||
if nonce_bytes.len() < 24 {
|
||||
return Err(OAuthError::InvalidDpopProof("Invalid nonce length".to_string()));
|
||||
return Err(OAuthError::InvalidDpopProof(
|
||||
"Invalid nonce length".to_string(),
|
||||
));
|
||||
}
|
||||
let timestamp_bytes: [u8; 8] = nonce_bytes[..8]
|
||||
.try_into()
|
||||
@@ -86,10 +88,12 @@ impl DPoPVerifier {
|
||||
}
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&self.secret);
|
||||
hasher.update(×tamp_bytes);
|
||||
hasher.update(timestamp_bytes);
|
||||
let expected_hash = hasher.finalize();
|
||||
if nonce_bytes[8..24] != expected_hash[..16] {
|
||||
return Err(OAuthError::InvalidDpopProof("Invalid nonce signature".to_string()));
|
||||
return Err(OAuthError::InvalidDpopProof(
|
||||
"Invalid nonce signature".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -103,7 +107,9 @@ impl DPoPVerifier {
|
||||
) -> Result<DPoPVerifyResult, OAuthError> {
|
||||
let parts: Vec<&str> = dpop_header.split('.').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(OAuthError::InvalidDpopProof("Invalid DPoP proof format".to_string()));
|
||||
return Err(OAuthError::InvalidDpopProof(
|
||||
"Invalid DPoP proof format".to_string(),
|
||||
));
|
||||
}
|
||||
let header_json = URL_SAFE_NO_PAD
|
||||
.decode(parts[0])
|
||||
@@ -116,22 +122,32 @@ impl DPoPVerifier {
|
||||
let payload: DPoPProofPayload = serde_json::from_slice(&payload_json)
|
||||
.map_err(|_| OAuthError::InvalidDpopProof("Invalid payload JSON".to_string()))?;
|
||||
if header.typ != "dpop+jwt" {
|
||||
return Err(OAuthError::InvalidDpopProof("Invalid typ claim".to_string()));
|
||||
return Err(OAuthError::InvalidDpopProof(
|
||||
"Invalid typ claim".to_string(),
|
||||
));
|
||||
}
|
||||
if !matches!(header.alg.as_str(), "ES256" | "ES384" | "ES512" | "EdDSA") {
|
||||
return Err(OAuthError::InvalidDpopProof("Unsupported algorithm".to_string()));
|
||||
return Err(OAuthError::InvalidDpopProof(
|
||||
"Unsupported algorithm".to_string(),
|
||||
));
|
||||
}
|
||||
if payload.htm.to_uppercase() != http_method.to_uppercase() {
|
||||
return Err(OAuthError::InvalidDpopProof("HTTP method mismatch".to_string()));
|
||||
return Err(OAuthError::InvalidDpopProof(
|
||||
"HTTP method mismatch".to_string(),
|
||||
));
|
||||
}
|
||||
let proof_uri = payload.htu.split('?').next().unwrap_or(&payload.htu);
|
||||
let request_uri = http_uri.split('?').next().unwrap_or(http_uri);
|
||||
if proof_uri != request_uri {
|
||||
return Err(OAuthError::InvalidDpopProof("HTTP URI mismatch".to_string()));
|
||||
return Err(OAuthError::InvalidDpopProof(
|
||||
"HTTP URI mismatch".to_string(),
|
||||
));
|
||||
}
|
||||
let now = Utc::now().timestamp();
|
||||
if (now - payload.iat).abs() > DPOP_MAX_AGE_SECS {
|
||||
return Err(OAuthError::InvalidDpopProof("Proof too old or from the future".to_string()));
|
||||
return Err(OAuthError::InvalidDpopProof(
|
||||
"Proof too old or from the future".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(nonce) = &payload.nonce {
|
||||
self.validate_nonce(nonce)?;
|
||||
@@ -155,7 +171,12 @@ impl DPoPVerifier {
|
||||
.decode(parts[2])
|
||||
.map_err(|_| OAuthError::InvalidDpopProof("Invalid signature encoding".to_string()))?;
|
||||
let signing_input = format!("{}.{}", parts[0], parts[1]);
|
||||
verify_dpop_signature(&header.alg, &header.jwk, signing_input.as_bytes(), &signature_bytes)?;
|
||||
verify_dpop_signature(
|
||||
&header.alg,
|
||||
&header.jwk,
|
||||
signing_input.as_bytes(),
|
||||
&signature_bytes,
|
||||
)?;
|
||||
let jkt = compute_jwk_thumbprint(&header.jwk)?;
|
||||
Ok(DPoPVerifyResult {
|
||||
jkt,
|
||||
@@ -186,9 +207,10 @@ fn verify_es256(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
|
||||
use p256::ecdsa::{Signature, VerifyingKey};
|
||||
use p256::elliptic_curve::sec1::FromEncodedPoint;
|
||||
use p256::{AffinePoint, EncodedPoint};
|
||||
let crv = jwk.crv.as_ref().ok_or_else(|| {
|
||||
OAuthError::InvalidDpopProof("Missing crv for ES256".to_string())
|
||||
})?;
|
||||
let crv = jwk
|
||||
.crv
|
||||
.as_ref()
|
||||
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing crv for ES256".to_string()))?;
|
||||
if crv != "P-256" {
|
||||
return Err(OAuthError::InvalidDpopProof(format!(
|
||||
"Invalid curve for ES256: {}",
|
||||
@@ -196,14 +218,18 @@ fn verify_es256(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
|
||||
)));
|
||||
}
|
||||
let x_bytes = URL_SAFE_NO_PAD
|
||||
.decode(jwk.x.as_ref().ok_or_else(|| {
|
||||
OAuthError::InvalidDpopProof("Missing x coordinate".to_string())
|
||||
})?)
|
||||
.decode(
|
||||
jwk.x
|
||||
.as_ref()
|
||||
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?,
|
||||
)
|
||||
.map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?;
|
||||
let y_bytes = URL_SAFE_NO_PAD
|
||||
.decode(jwk.y.as_ref().ok_or_else(|| {
|
||||
OAuthError::InvalidDpopProof("Missing y coordinate".to_string())
|
||||
})?)
|
||||
.decode(
|
||||
jwk.y
|
||||
.as_ref()
|
||||
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?,
|
||||
)
|
||||
.map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?;
|
||||
let point = EncodedPoint::from_affine_coordinates(
|
||||
x_bytes.as_slice().into(),
|
||||
@@ -211,8 +237,8 @@ fn verify_es256(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
|
||||
false,
|
||||
);
|
||||
let affine_opt: Option<AffinePoint> = AffinePoint::from_encoded_point(&point).into();
|
||||
let affine = affine_opt
|
||||
.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?;
|
||||
let affine =
|
||||
affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?;
|
||||
let verifying_key = VerifyingKey::from_affine(affine)
|
||||
.map_err(|_| OAuthError::InvalidDpopProof("Invalid verifying key".to_string()))?;
|
||||
let sig = Signature::from_slice(signature)
|
||||
@@ -227,9 +253,10 @@ fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
|
||||
use p384::ecdsa::{Signature, VerifyingKey};
|
||||
use p384::elliptic_curve::sec1::FromEncodedPoint;
|
||||
use p384::{AffinePoint, EncodedPoint};
|
||||
let crv = jwk.crv.as_ref().ok_or_else(|| {
|
||||
OAuthError::InvalidDpopProof("Missing crv for ES384".to_string())
|
||||
})?;
|
||||
let crv = jwk
|
||||
.crv
|
||||
.as_ref()
|
||||
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing crv for ES384".to_string()))?;
|
||||
if crv != "P-384" {
|
||||
return Err(OAuthError::InvalidDpopProof(format!(
|
||||
"Invalid curve for ES384: {}",
|
||||
@@ -237,14 +264,18 @@ fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
|
||||
)));
|
||||
}
|
||||
let x_bytes = URL_SAFE_NO_PAD
|
||||
.decode(jwk.x.as_ref().ok_or_else(|| {
|
||||
OAuthError::InvalidDpopProof("Missing x coordinate".to_string())
|
||||
})?)
|
||||
.decode(
|
||||
jwk.x
|
||||
.as_ref()
|
||||
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?,
|
||||
)
|
||||
.map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?;
|
||||
let y_bytes = URL_SAFE_NO_PAD
|
||||
.decode(jwk.y.as_ref().ok_or_else(|| {
|
||||
OAuthError::InvalidDpopProof("Missing y coordinate".to_string())
|
||||
})?)
|
||||
.decode(
|
||||
jwk.y
|
||||
.as_ref()
|
||||
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing y coordinate".to_string()))?,
|
||||
)
|
||||
.map_err(|_| OAuthError::InvalidDpopProof("Invalid y encoding".to_string()))?;
|
||||
let point = EncodedPoint::from_affine_coordinates(
|
||||
x_bytes.as_slice().into(),
|
||||
@@ -252,8 +283,8 @@ fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
|
||||
false,
|
||||
);
|
||||
let affine_opt: Option<AffinePoint> = AffinePoint::from_encoded_point(&point).into();
|
||||
let affine = affine_opt
|
||||
.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?;
|
||||
let affine =
|
||||
affine_opt.ok_or_else(|| OAuthError::InvalidDpopProof("Invalid EC point".to_string()))?;
|
||||
let verifying_key = VerifyingKey::from_affine(affine)
|
||||
.map_err(|_| OAuthError::InvalidDpopProof("Invalid verifying key".to_string()))?;
|
||||
let sig = Signature::from_slice(signature)
|
||||
@@ -265,9 +296,10 @@ fn verify_es384(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
|
||||
|
||||
fn verify_eddsa(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), OAuthError> {
|
||||
use ed25519_dalek::{Signature, VerifyingKey};
|
||||
let crv = jwk.crv.as_ref().ok_or_else(|| {
|
||||
OAuthError::InvalidDpopProof("Missing crv for EdDSA".to_string())
|
||||
})?;
|
||||
let crv = jwk
|
||||
.crv
|
||||
.as_ref()
|
||||
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing crv for EdDSA".to_string()))?;
|
||||
if crv != "Ed25519" {
|
||||
return Err(OAuthError::InvalidDpopProof(format!(
|
||||
"Invalid curve for EdDSA: {}",
|
||||
@@ -275,13 +307,15 @@ fn verify_eddsa(jwk: &DPoPJwk, message: &[u8], signature: &[u8]) -> Result<(), O
|
||||
)));
|
||||
}
|
||||
let x_bytes = URL_SAFE_NO_PAD
|
||||
.decode(jwk.x.as_ref().ok_or_else(|| {
|
||||
OAuthError::InvalidDpopProof("Missing x coordinate".to_string())
|
||||
})?)
|
||||
.decode(
|
||||
jwk.x
|
||||
.as_ref()
|
||||
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing x coordinate".to_string()))?,
|
||||
)
|
||||
.map_err(|_| OAuthError::InvalidDpopProof("Invalid x encoding".to_string()))?;
|
||||
let key_bytes: [u8; 32] = x_bytes.try_into().map_err(|_| {
|
||||
OAuthError::InvalidDpopProof("Invalid Ed25519 key length".to_string())
|
||||
})?;
|
||||
let key_bytes: [u8; 32] = x_bytes
|
||||
.try_into()
|
||||
.map_err(|_| OAuthError::InvalidDpopProof("Invalid Ed25519 key length".to_string()))?;
|
||||
let verifying_key = VerifyingKey::from_bytes(&key_bytes)
|
||||
.map_err(|_| OAuthError::InvalidDpopProof("Invalid Ed25519 key".to_string()))?;
|
||||
let sig_bytes: [u8; 64] = signature.try_into().map_err(|_| {
|
||||
@@ -308,10 +342,7 @@ pub fn compute_jwk_thumbprint(jwk: &DPoPJwk) -> Result<String, OAuthError> {
|
||||
.y
|
||||
.as_ref()
|
||||
.ok_or_else(|| OAuthError::InvalidDpopProof("Missing y".to_string()))?;
|
||||
format!(
|
||||
r#"{{"crv":"{}","kty":"EC","x":"{}","y":"{}"}}"#,
|
||||
crv, x, y
|
||||
)
|
||||
format!(r#"{{"crv":"{}","kty":"EC","x":"{}","y":"{}"}}"#, crv, x, y)
|
||||
}
|
||||
"OKP" => {
|
||||
let crv = jwk
|
||||
@@ -333,14 +364,14 @@ pub fn compute_jwk_thumbprint(jwk: &DPoPJwk) -> Result<String, OAuthError> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(canonical.as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
Ok(URL_SAFE_NO_PAD.encode(&hash))
|
||||
Ok(URL_SAFE_NO_PAD.encode(hash))
|
||||
}
|
||||
|
||||
pub fn compute_access_token_hash(access_token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(access_token.as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
URL_SAFE_NO_PAD.encode(&hash)
|
||||
URL_SAFE_NO_PAD.encode(hash)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
use crate::notifications::{NotificationChannel, channel_display_name, enqueue_2fa_code};
|
||||
use crate::oauth::{
|
||||
Code, DeviceAccount, DeviceData, DeviceId, OAuthError, SessionId, db, templates,
|
||||
};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use axum::{
|
||||
Form, Json,
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, StatusCode, header::{SET_COOKIE, LOCATION}},
|
||||
response::{IntoResponse, Redirect, Response, Html},
|
||||
http::{
|
||||
HeaderMap, StatusCode,
|
||||
header::{LOCATION, SET_COOKIE},
|
||||
},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use subtle::ConstantTimeEq;
|
||||
use urlencoding::encode as url_encode;
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::oauth::{Code, DeviceAccount, DeviceData, DeviceId, OAuthError, SessionId, db, templates};
|
||||
use crate::notifications::{NotificationChannel, channel_display_name, enqueue_2fa_code};
|
||||
|
||||
const DEVICE_COOKIE_NAME: &str = "oauth_device_id";
|
||||
|
||||
@@ -34,18 +39,15 @@ fn extract_device_cookie(headers: &HeaderMap) -> Option<String> {
|
||||
}
|
||||
|
||||
fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for") {
|
||||
if let Ok(value) = forwarded.to_str() {
|
||||
if let Some(first_ip) = value.split(',').next() {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for")
|
||||
&& let Ok(value) = forwarded.to_str()
|
||||
&& let Some(first_ip) = value.split(',').next() {
|
||||
return first_ip.trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(real_ip) = headers.get("x-real-ip") {
|
||||
if let Ok(value) = real_ip.to_str() {
|
||||
if let Some(real_ip) = headers.get("x-real-ip")
|
||||
&& let Ok(value) = real_ip.to_str() {
|
||||
return value.trim().to_string();
|
||||
}
|
||||
}
|
||||
"0.0.0.0".to_string()
|
||||
}
|
||||
|
||||
@@ -59,8 +61,7 @@ fn extract_user_agent(headers: &HeaderMap) -> Option<String> {
|
||||
fn make_device_cookie(device_id: &str) -> String {
|
||||
format!(
|
||||
"{}={}; Path=/oauth; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000",
|
||||
DEVICE_COOKIE_NAME,
|
||||
device_id
|
||||
DEVICE_COOKIE_NAME, device_id
|
||||
)
|
||||
}
|
||||
|
||||
@@ -127,7 +128,8 @@ pub async fn authorize_get(
|
||||
"invalid_request",
|
||||
Some("Missing request_uri parameter. Use PAR to initiate authorization."),
|
||||
)),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let request_data = match db::get_authorization_request(&state.db, &request_uri).await {
|
||||
@@ -146,9 +148,12 @@ pub async fn authorize_get(
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
Html(templates::error_page(
|
||||
"invalid_request",
|
||||
Some("Invalid or expired request_uri. Please start a new authorization request."),
|
||||
Some(
|
||||
"Invalid or expired request_uri. Please start a new authorization request.",
|
||||
),
|
||||
)),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
if wants_json(&headers) {
|
||||
@@ -158,7 +163,8 @@ pub async fn authorize_get(
|
||||
"error": "server_error",
|
||||
"error_description": format!("Database error: {:?}", e)
|
||||
})),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return (
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@@ -166,7 +172,8 @@ pub async fn authorize_get(
|
||||
"server_error",
|
||||
Some(&format!("Database error: {:?}", e)),
|
||||
)),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if request_data.expires_at < Utc::now() {
|
||||
@@ -186,7 +193,8 @@ pub async fn authorize_get(
|
||||
"invalid_request",
|
||||
Some("Authorization request has expired. Please start a new request."),
|
||||
)),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if wants_json(&headers) {
|
||||
return Json(AuthorizeResponse {
|
||||
@@ -196,13 +204,14 @@ pub async fn authorize_get(
|
||||
redirect_uri: request_data.parameters.redirect_uri.clone(),
|
||||
state: request_data.parameters.state.clone(),
|
||||
login_hint: request_data.parameters.login_hint.clone(),
|
||||
}).into_response();
|
||||
})
|
||||
.into_response();
|
||||
}
|
||||
let force_new_account = query.new_account.unwrap_or(false);
|
||||
if !force_new_account {
|
||||
if let Some(device_id) = extract_device_cookie(&headers) {
|
||||
if let Ok(accounts) = db::get_device_accounts(&state.db, &device_id).await {
|
||||
if !accounts.is_empty() {
|
||||
if !force_new_account
|
||||
&& let Some(device_id) = extract_device_cookie(&headers)
|
||||
&& let Ok(accounts) = db::get_device_accounts(&state.db, &device_id).await
|
||||
&& !accounts.is_empty() {
|
||||
let device_accounts: Vec<DeviceAccount> = accounts
|
||||
.into_iter()
|
||||
.map(|row| DeviceAccount {
|
||||
@@ -217,11 +226,9 @@ pub async fn authorize_get(
|
||||
None,
|
||||
&request_uri,
|
||||
&device_accounts,
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Html(templates::login_page(
|
||||
&request_data.parameters.client_id,
|
||||
None,
|
||||
@@ -229,22 +236,25 @@ pub async fn authorize_get(
|
||||
&request_uri,
|
||||
None,
|
||||
request_data.parameters.login_hint.as_deref(),
|
||||
)).into_response()
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn authorize_get_json(
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<AuthorizeQuery>,
|
||||
) -> Result<Json<AuthorizeResponse>, OAuthError> {
|
||||
let request_uri = query.request_uri.ok_or_else(|| {
|
||||
OAuthError::InvalidRequest("request_uri is required".to_string())
|
||||
})?;
|
||||
let request_uri = query
|
||||
.request_uri
|
||||
.ok_or_else(|| OAuthError::InvalidRequest("request_uri is required".to_string()))?;
|
||||
let request_data = db::get_authorization_request(&state.db, &request_uri)
|
||||
.await?
|
||||
.ok_or_else(|| OAuthError::InvalidRequest("Invalid or expired request_uri".to_string()))?;
|
||||
if request_data.expires_at < Utc::now() {
|
||||
db::delete_authorization_request(&state.db, &request_uri).await?;
|
||||
return Err(OAuthError::InvalidRequest("request_uri has expired".to_string()));
|
||||
return Err(OAuthError::InvalidRequest(
|
||||
"request_uri has expired".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Json(AuthorizeResponse {
|
||||
client_id: request_data.parameters.client_id.clone(),
|
||||
@@ -263,7 +273,10 @@ pub async fn authorize_post(
|
||||
) -> Response {
|
||||
let json_response = wants_json(&headers);
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if !state.check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip).await {
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(ip = %client_ip, "OAuth authorize rate limit exceeded");
|
||||
if json_response {
|
||||
return (
|
||||
@@ -272,7 +285,8 @@ pub async fn authorize_post(
|
||||
"error": "RateLimitExceeded",
|
||||
"error_description": "Too many login attempts. Please try again later."
|
||||
})),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return (
|
||||
axum::http::StatusCode::TOO_MANY_REQUESTS,
|
||||
@@ -280,7 +294,8 @@ pub async fn authorize_post(
|
||||
"RateLimitExceeded",
|
||||
Some("Too many login attempts. Please try again later."),
|
||||
)),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await {
|
||||
Ok(Some(data)) => data,
|
||||
@@ -292,12 +307,14 @@ pub async fn authorize_post(
|
||||
"error": "invalid_request",
|
||||
"error_description": "Invalid or expired request_uri."
|
||||
})),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return Html(templates::error_page(
|
||||
"invalid_request",
|
||||
Some("Invalid or expired request_uri. Please start a new authorization request."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
if json_response {
|
||||
@@ -307,12 +324,14 @@ pub async fn authorize_post(
|
||||
"error": "server_error",
|
||||
"error_description": format!("Database error: {:?}", e)
|
||||
})),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return Html(templates::error_page(
|
||||
"server_error",
|
||||
Some(&format!("Database error: {:?}", e)),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if request_data.expires_at < Utc::now() {
|
||||
@@ -324,12 +343,14 @@ pub async fn authorize_post(
|
||||
"error": "invalid_request",
|
||||
"error_description": "Authorization request has expired."
|
||||
})),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
return Html(templates::error_page(
|
||||
"invalid_request",
|
||||
Some("Authorization request has expired. Please start a new request."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
let show_login_error = |error_msg: &str, json: bool| -> Response {
|
||||
if json {
|
||||
@@ -339,7 +360,8 @@ pub async fn authorize_post(
|
||||
"error": "access_denied",
|
||||
"error_description": error_msg
|
||||
})),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Html(templates::login_page(
|
||||
&request_data.parameters.client_id,
|
||||
@@ -348,12 +370,17 @@ pub async fn authorize_post(
|
||||
&form.request_uri,
|
||||
Some(error_msg),
|
||||
Some(&form.username),
|
||||
)).into_response()
|
||||
))
|
||||
.into_response()
|
||||
};
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let normalized_username = form.username.trim();
|
||||
let normalized_username = normalized_username.strip_prefix('@').unwrap_or(normalized_username);
|
||||
let normalized_username = if let Some(bare_handle) = normalized_username.strip_suffix(&format!(".{}", pds_hostname)) {
|
||||
let normalized_username = normalized_username
|
||||
.strip_prefix('@')
|
||||
.unwrap_or(normalized_username);
|
||||
let normalized_username = if let Some(bare_handle) =
|
||||
normalized_username.strip_suffix(&format!(".{}", pds_hostname))
|
||||
{
|
||||
bare_handle.to_string()
|
||||
} else {
|
||||
normalized_username.to_string()
|
||||
@@ -401,13 +428,11 @@ pub async fn authorize_post(
|
||||
let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await;
|
||||
match db::create_2fa_challenge(&state.db, &user.did, &form.request_uri).await {
|
||||
Ok(challenge) => {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) = enqueue_2fa_code(
|
||||
&state.db,
|
||||
user.id,
|
||||
&challenge.code,
|
||||
&hostname,
|
||||
).await {
|
||||
let hostname =
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) =
|
||||
enqueue_2fa_code(&state.db, user.id, &challenge.code, &hostname).await
|
||||
{
|
||||
tracing::warn!(
|
||||
did = %user.did,
|
||||
error = %e,
|
||||
@@ -441,7 +466,10 @@ pub async fn authorize_post(
|
||||
ip_address: extract_client_ip(&headers),
|
||||
last_seen_at: Utc::now(),
|
||||
};
|
||||
if db::create_device(&state.db, &new_id.0, &device_data).await.is_ok() {
|
||||
if db::create_device(&state.db, &new_id.0, &device_data)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
new_cookie = Some(make_device_cookie(&new_id.0));
|
||||
device_id = Some(new_id.0.clone());
|
||||
}
|
||||
@@ -449,7 +477,7 @@ pub async fn authorize_post(
|
||||
};
|
||||
let _ = db::upsert_account_device(&state.db, &user.did, &final_device_id).await;
|
||||
}
|
||||
if let Err(_) = db::update_authorization_request(
|
||||
if db::update_authorization_request(
|
||||
&state.db,
|
||||
&form.request_uri,
|
||||
&user.did,
|
||||
@@ -457,6 +485,7 @@ pub async fn authorize_post(
|
||||
&code.0,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return show_login_error("An error occurred. Please try again.", json_response);
|
||||
}
|
||||
@@ -466,7 +495,11 @@ pub async fn authorize_post(
|
||||
request_data.parameters.state.as_deref(),
|
||||
);
|
||||
if let Some(cookie) = new_cookie {
|
||||
(StatusCode::SEE_OTHER, [(SET_COOKIE, cookie), (LOCATION, redirect_url)]).into_response()
|
||||
(
|
||||
StatusCode::SEE_OTHER,
|
||||
[(SET_COOKIE, cookie), (LOCATION, redirect_url)],
|
||||
)
|
||||
.into_response()
|
||||
} else {
|
||||
redirect_see_other(&redirect_url)
|
||||
}
|
||||
@@ -483,13 +516,15 @@ pub async fn authorize_select(
|
||||
return Html(templates::error_page(
|
||||
"invalid_request",
|
||||
Some("Invalid or expired request_uri. Please start a new authorization request."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return Html(templates::error_page(
|
||||
"server_error",
|
||||
Some("An error occurred. Please try again."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if request_data.expires_at < Utc::now() {
|
||||
@@ -497,7 +532,8 @@ pub async fn authorize_select(
|
||||
return Html(templates::error_page(
|
||||
"invalid_request",
|
||||
Some("Authorization request has expired. Please start a new request."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
let device_id = match extract_device_cookie(&headers) {
|
||||
Some(id) => id,
|
||||
@@ -505,7 +541,8 @@ pub async fn authorize_select(
|
||||
return Html(templates::error_page(
|
||||
"invalid_request",
|
||||
Some("No device session found. Please sign in."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let account_valid = match db::verify_account_on_device(&state.db, &device_id, &form.did).await {
|
||||
@@ -514,14 +551,16 @@ pub async fn authorize_select(
|
||||
return Html(templates::error_page(
|
||||
"server_error",
|
||||
Some("An error occurred. Please try again."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if !account_valid {
|
||||
return Html(templates::error_page(
|
||||
"access_denied",
|
||||
Some("This account is not available on this device. Please sign in."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
let user = match sqlx::query!(
|
||||
r#"
|
||||
@@ -553,13 +592,11 @@ pub async fn authorize_select(
|
||||
let _ = db::delete_2fa_challenge_by_request_uri(&state.db, &form.request_uri).await;
|
||||
match db::create_2fa_challenge(&state.db, &form.did, &form.request_uri).await {
|
||||
Ok(challenge) => {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) = enqueue_2fa_code(
|
||||
&state.db,
|
||||
user.id,
|
||||
&challenge.code,
|
||||
&hostname,
|
||||
).await {
|
||||
let hostname =
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) =
|
||||
enqueue_2fa_code(&state.db, user.id, &challenge.code, &hostname).await
|
||||
{
|
||||
tracing::warn!(
|
||||
did = %form.did,
|
||||
error = %e,
|
||||
@@ -578,13 +615,14 @@ pub async fn authorize_select(
|
||||
return Html(templates::error_page(
|
||||
"server_error",
|
||||
Some("An error occurred. Please try again."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = db::upsert_account_device(&state.db, &form.did, &device_id).await;
|
||||
let code = Code::generate();
|
||||
if let Err(_) = db::update_authorization_request(
|
||||
if db::update_authorization_request(
|
||||
&state.db,
|
||||
&form.request_uri,
|
||||
&form.did,
|
||||
@@ -592,11 +630,13 @@ pub async fn authorize_select(
|
||||
&code.0,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Html(templates::error_page(
|
||||
"server_error",
|
||||
Some("An error occurred. Please try again."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
let redirect_url = build_success_redirect(
|
||||
&request_data.parameters.redirect_uri,
|
||||
@@ -615,7 +655,10 @@ fn build_success_redirect(redirect_uri: &str, code: &str, state: Option<&str>) -
|
||||
redirect_url.push_str(&format!("&state={}", url_encode(req_state)));
|
||||
}
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
redirect_url.push_str(&format!("&iss={}", url_encode(&format!("https://{}", pds_hostname))));
|
||||
redirect_url.push_str(&format!(
|
||||
"&iss={}",
|
||||
url_encode(&format!("https://{}", pds_hostname))
|
||||
));
|
||||
redirect_url
|
||||
}
|
||||
|
||||
@@ -674,13 +717,15 @@ pub async fn authorize_2fa_get(
|
||||
return Html(templates::error_page(
|
||||
"invalid_request",
|
||||
Some("No 2FA challenge found. Please start over."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return Html(templates::error_page(
|
||||
"server_error",
|
||||
Some("An error occurred. Please try again."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if challenge.expires_at < Utc::now() {
|
||||
@@ -688,7 +733,8 @@ pub async fn authorize_2fa_get(
|
||||
return Html(templates::error_page(
|
||||
"invalid_request",
|
||||
Some("2FA code has expired. Please start over."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
let _request_data = match db::get_authorization_request(&state.db, &query.request_uri).await {
|
||||
Ok(Some(d)) => d,
|
||||
@@ -696,13 +742,15 @@ pub async fn authorize_2fa_get(
|
||||
return Html(templates::error_page(
|
||||
"invalid_request",
|
||||
Some("Authorization request not found. Please start over."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return Html(templates::error_page(
|
||||
"server_error",
|
||||
Some("An error occurred. Please try again."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let channel = query.channel.as_deref().unwrap_or("email");
|
||||
@@ -710,7 +758,8 @@ pub async fn authorize_2fa_get(
|
||||
&query.request_uri,
|
||||
channel,
|
||||
None,
|
||||
)).into_response()
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn authorize_2fa_post(
|
||||
@@ -719,7 +768,10 @@ pub async fn authorize_2fa_post(
|
||||
Form(form): Form<Authorize2faSubmit>,
|
||||
) -> Response {
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if !state.check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip).await {
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::OAuthAuthorize, &client_ip)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(ip = %client_ip, "OAuth 2FA rate limit exceeded");
|
||||
return (
|
||||
axum::http::StatusCode::TOO_MANY_REQUESTS,
|
||||
@@ -727,7 +779,8 @@ pub async fn authorize_2fa_post(
|
||||
"RateLimitExceeded",
|
||||
Some("Too many attempts. Please try again later."),
|
||||
)),
|
||||
).into_response();
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let challenge = match db::get_2fa_challenge(&state.db, &form.request_uri).await {
|
||||
Ok(Some(c)) => c,
|
||||
@@ -735,13 +788,15 @@ pub async fn authorize_2fa_post(
|
||||
return Html(templates::error_page(
|
||||
"invalid_request",
|
||||
Some("No 2FA challenge found. Please start over."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return Html(templates::error_page(
|
||||
"server_error",
|
||||
Some("An error occurred. Please try again."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if challenge.expires_at < Utc::now() {
|
||||
@@ -749,16 +804,23 @@ pub async fn authorize_2fa_post(
|
||||
return Html(templates::error_page(
|
||||
"invalid_request",
|
||||
Some("2FA code has expired. Please start over."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
if challenge.attempts >= MAX_2FA_ATTEMPTS {
|
||||
let _ = db::delete_2fa_challenge(&state.db, challenge.id).await;
|
||||
return Html(templates::error_page(
|
||||
"access_denied",
|
||||
Some("Too many failed attempts. Please start over."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
let code_valid: bool = form.code.trim().as_bytes().ct_eq(challenge.code.as_bytes()).into();
|
||||
let code_valid: bool = form
|
||||
.code
|
||||
.trim()
|
||||
.as_bytes()
|
||||
.ct_eq(challenge.code.as_bytes())
|
||||
.into();
|
||||
if !code_valid {
|
||||
let _ = db::increment_2fa_attempts(&state.db, challenge.id).await;
|
||||
let channel = match sqlx::query_scalar!(
|
||||
@@ -771,26 +833,30 @@ pub async fn authorize_2fa_post(
|
||||
Ok(Some(ch)) => channel_display_name(ch).to_string(),
|
||||
Ok(None) | Err(_) => "email".to_string(),
|
||||
};
|
||||
let _request_data = match db::get_authorization_request(&state.db, &form.request_uri).await {
|
||||
let _request_data = match db::get_authorization_request(&state.db, &form.request_uri).await
|
||||
{
|
||||
Ok(Some(d)) => d,
|
||||
Ok(None) => {
|
||||
return Html(templates::error_page(
|
||||
"invalid_request",
|
||||
Some("Authorization request not found. Please start over."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return Html(templates::error_page(
|
||||
"server_error",
|
||||
Some("An error occurred. Please try again."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
return Html(templates::two_factor_page(
|
||||
&form.request_uri,
|
||||
&channel,
|
||||
Some("Invalid verification code. Please try again."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
let _ = db::delete_2fa_challenge(&state.db, challenge.id).await;
|
||||
let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await {
|
||||
@@ -799,18 +865,20 @@ pub async fn authorize_2fa_post(
|
||||
return Html(templates::error_page(
|
||||
"invalid_request",
|
||||
Some("Authorization request not found."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
Err(_) => {
|
||||
return Html(templates::error_page(
|
||||
"server_error",
|
||||
Some("An error occurred."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let code = Code::generate();
|
||||
let device_id = extract_device_cookie(&headers);
|
||||
if let Err(_) = db::update_authorization_request(
|
||||
if db::update_authorization_request(
|
||||
&state.db,
|
||||
&form.request_uri,
|
||||
&challenge.did,
|
||||
@@ -818,11 +886,13 @@ pub async fn authorize_2fa_post(
|
||||
&code.0,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Html(templates::error_page(
|
||||
"server_error",
|
||||
Some("An error occurred. Please try again."),
|
||||
)).into_response();
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
let redirect_url = build_success_redirect(
|
||||
&request_data.parameters.redirect_uri,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::oauth::jwks::{JwkSet, create_jwk_set};
|
||||
use crate::state::AppState;
|
||||
use axum::{Json, extract::State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::state::AppState;
|
||||
use crate::oauth::jwks::{JwkSet, create_jwk_set};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ProtectedResourceMetadata {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
pub mod authorize;
|
||||
pub mod metadata;
|
||||
pub mod par;
|
||||
pub mod authorize;
|
||||
pub mod token;
|
||||
|
||||
pub use authorize::*;
|
||||
pub use metadata::*;
|
||||
pub use par::*;
|
||||
pub use authorize::*;
|
||||
pub use token::*;
|
||||
|
||||
+12
-12
@@ -1,16 +1,11 @@
|
||||
use axum::{
|
||||
Form, Json,
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::oauth::{
|
||||
AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData, RequestId,
|
||||
client::ClientMetadataCache,
|
||||
db,
|
||||
client::ClientMetadataCache, db,
|
||||
};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use axum::{Form, Json, extract::State, http::HeaderMap};
|
||||
use chrono::{Duration, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const PAR_EXPIRY_SECONDS: i64 = 600;
|
||||
const SUPPORTED_SCOPES: &[&str] = &["atproto", "transition:generic", "transition:chat.bsky"];
|
||||
@@ -52,7 +47,10 @@ pub async fn pushed_authorization_request(
|
||||
Form(request): Form<ParRequest>,
|
||||
) -> Result<(axum::http::StatusCode, Json<ParResponse>), OAuthError> {
|
||||
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
|
||||
if !state.check_rate_limit(RateLimitKind::OAuthPar, &client_ip).await {
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::OAuthPar, &client_ip)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(ip = %client_ip, "OAuth PAR rate limit exceeded");
|
||||
return Err(OAuthError::RateLimited);
|
||||
}
|
||||
@@ -61,7 +59,9 @@ pub async fn pushed_authorization_request(
|
||||
"response_type must be 'code'".to_string(),
|
||||
));
|
||||
}
|
||||
let code_challenge = request.code_challenge.as_ref()
|
||||
let code_challenge = request
|
||||
.code_challenge
|
||||
.as_ref()
|
||||
.filter(|s| !s.is_empty())
|
||||
.ok_or_else(|| OAuthError::InvalidRequest("code_challenge is required".to_string()))?;
|
||||
let code_challenge_method = request.code_challenge_method.as_deref().unwrap_or("");
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
use axum::http::HeaderMap;
|
||||
use axum::Json;
|
||||
use chrono::{Duration, Utc};
|
||||
use super::helpers::{create_access_token, verify_pkce};
|
||||
use super::types::{TokenRequest, TokenResponse};
|
||||
use crate::config::AuthConfig;
|
||||
use crate::state::AppState;
|
||||
use crate::oauth::{
|
||||
ClientAuth, OAuthError, RefreshToken, TokenData, TokenId,
|
||||
client::{ClientMetadataCache, verify_client_auth},
|
||||
db,
|
||||
dpop::DPoPVerifier,
|
||||
};
|
||||
use super::types::{TokenRequest, TokenResponse};
|
||||
use super::helpers::{create_access_token, verify_pkce};
|
||||
use crate::state::AppState;
|
||||
use axum::Json;
|
||||
use axum::http::HeaderMap;
|
||||
use chrono::{Duration, Utc};
|
||||
|
||||
const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 3600;
|
||||
const REFRESH_TOKEN_EXPIRY_DAYS: i64 = 60;
|
||||
@@ -31,19 +31,22 @@ pub async fn handle_authorization_code_grant(
|
||||
.await?
|
||||
.ok_or_else(|| OAuthError::InvalidGrant("Invalid or expired code".to_string()))?;
|
||||
if auth_request.expires_at < Utc::now() {
|
||||
return Err(OAuthError::InvalidGrant("Authorization code has expired".to_string()));
|
||||
return Err(OAuthError::InvalidGrant(
|
||||
"Authorization code has expired".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(request_client_id) = &request.client_id {
|
||||
if request_client_id != &auth_request.client_id {
|
||||
if let Some(request_client_id) = &request.client_id
|
||||
&& request_client_id != &auth_request.client_id {
|
||||
return Err(OAuthError::InvalidGrant("client_id mismatch".to_string()));
|
||||
}
|
||||
}
|
||||
let did = auth_request
|
||||
.did
|
||||
.ok_or_else(|| OAuthError::InvalidGrant("Authorization not completed".to_string()))?;
|
||||
let client_metadata_cache = ClientMetadataCache::new(3600);
|
||||
let client_metadata = client_metadata_cache.get(&auth_request.client_id).await?;
|
||||
let client_auth = if let (Some(assertion), Some(assertion_type)) = (&request.client_assertion, &request.client_assertion_type) {
|
||||
let client_auth = if let (Some(assertion), Some(assertion_type)) =
|
||||
(&request.client_assertion, &request.client_assertion_type)
|
||||
{
|
||||
if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
|
||||
return Err(OAuthError::InvalidClient(
|
||||
"Unsupported client_assertion_type".to_string(),
|
||||
@@ -61,15 +64,17 @@ pub async fn handle_authorization_code_grant(
|
||||
};
|
||||
verify_client_auth(&client_metadata_cache, &client_metadata, &client_auth).await?;
|
||||
verify_pkce(&auth_request.parameters.code_challenge, &code_verifier)?;
|
||||
if let Some(redirect_uri) = &request.redirect_uri {
|
||||
if redirect_uri != &auth_request.parameters.redirect_uri {
|
||||
return Err(OAuthError::InvalidGrant("redirect_uri mismatch".to_string()));
|
||||
if let Some(redirect_uri) = &request.redirect_uri
|
||||
&& redirect_uri != &auth_request.parameters.redirect_uri {
|
||||
return Err(OAuthError::InvalidGrant(
|
||||
"redirect_uri mismatch".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let dpop_jkt = if let Some(proof) = &dpop_proof {
|
||||
let config = AuthConfig::get();
|
||||
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let pds_hostname =
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let token_endpoint = format!("https://{}/oauth/token", pds_hostname);
|
||||
let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?;
|
||||
if !db::check_and_record_dpop_jti(&state.db, &result.jti).await? {
|
||||
@@ -77,13 +82,12 @@ pub async fn handle_authorization_code_grant(
|
||||
"DPoP proof has already been used".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(expected_jkt) = &auth_request.parameters.dpop_jkt {
|
||||
if &result.jkt != expected_jkt {
|
||||
if let Some(expected_jkt) = &auth_request.parameters.dpop_jkt
|
||||
&& &result.jkt != expected_jkt {
|
||||
return Err(OAuthError::InvalidDpopProof(
|
||||
"DPoP key binding mismatch".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(result.jkt)
|
||||
} else if auth_request.parameters.dpop_jkt.is_some() {
|
||||
return Err(OAuthError::InvalidRequest(
|
||||
@@ -124,10 +128,7 @@ pub async fn handle_authorization_code_grant(
|
||||
let mut response_headers = HeaderMap::new();
|
||||
let config = AuthConfig::get();
|
||||
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
|
||||
response_headers.insert(
|
||||
"DPoP-Nonce",
|
||||
verifier.generate_nonce().parse().unwrap(),
|
||||
);
|
||||
response_headers.insert("DPoP-Nonce", verifier.generate_nonce().parse().unwrap());
|
||||
Ok((
|
||||
response_headers,
|
||||
Json(TokenResponse {
|
||||
@@ -161,12 +162,15 @@ pub async fn handle_refresh_token_grant(
|
||||
.ok_or_else(|| OAuthError::InvalidGrant("Invalid refresh token".to_string()))?;
|
||||
if token_data.expires_at < Utc::now() {
|
||||
db::delete_token_family(&state.db, db_id).await?;
|
||||
return Err(OAuthError::InvalidGrant("Refresh token has expired".to_string()));
|
||||
return Err(OAuthError::InvalidGrant(
|
||||
"Refresh token has expired".to_string(),
|
||||
));
|
||||
}
|
||||
let dpop_jkt = if let Some(proof) = &dpop_proof {
|
||||
let config = AuthConfig::get();
|
||||
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let pds_hostname =
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let token_endpoint = format!("https://{}/oauth/token", pds_hostname);
|
||||
let result = verifier.verify_proof(proof, "POST", &token_endpoint, None)?;
|
||||
if !db::check_and_record_dpop_jti(&state.db, &result.jti).await? {
|
||||
@@ -174,13 +178,12 @@ pub async fn handle_refresh_token_grant(
|
||||
"DPoP proof has already been used".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(expected_jkt) = &token_data.parameters.dpop_jkt {
|
||||
if &result.jkt != expected_jkt {
|
||||
if let Some(expected_jkt) = &token_data.parameters.dpop_jkt
|
||||
&& &result.jkt != expected_jkt {
|
||||
return Err(OAuthError::InvalidDpopProof(
|
||||
"DPoP key binding mismatch".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(result.jkt)
|
||||
} else if token_data.parameters.dpop_jkt.is_some() {
|
||||
return Err(OAuthError::InvalidRequest(
|
||||
@@ -204,10 +207,7 @@ pub async fn handle_refresh_token_grant(
|
||||
let mut response_headers = HeaderMap::new();
|
||||
let config = AuthConfig::get();
|
||||
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
|
||||
response_headers.insert(
|
||||
"DPoP-Nonce",
|
||||
verifier.generate_nonce().parse().unwrap(),
|
||||
);
|
||||
response_headers.insert("DPoP-Nonce", verifier.generate_nonce().parse().unwrap());
|
||||
Ok((
|
||||
response_headers,
|
||||
Json(TokenResponse {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use crate::config::AuthConfig;
|
||||
use crate::oauth::OAuthError;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use chrono::Utc;
|
||||
use hmac::Mac;
|
||||
use sha2::{Digest, Sha256};
|
||||
use subtle::ConstantTimeEq;
|
||||
use crate::config::AuthConfig;
|
||||
use crate::oauth::OAuthError;
|
||||
|
||||
const ACCESS_TOKEN_EXPIRY_SECONDS: i64 = 3600;
|
||||
|
||||
@@ -19,9 +19,15 @@ pub fn verify_pkce(code_challenge: &str, code_verifier: &str) -> Result<(), OAut
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(code_verifier.as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
let computed_challenge = URL_SAFE_NO_PAD.encode(&hash);
|
||||
if !bool::from(computed_challenge.as_bytes().ct_eq(code_challenge.as_bytes())) {
|
||||
return Err(OAuthError::InvalidGrant("PKCE verification failed".to_string()));
|
||||
let computed_challenge = URL_SAFE_NO_PAD.encode(hash);
|
||||
if !bool::from(
|
||||
computed_challenge
|
||||
.as_bytes()
|
||||
.ct_eq(code_challenge.as_bytes()),
|
||||
) {
|
||||
return Err(OAuthError::InvalidGrant(
|
||||
"PKCE verification failed".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -61,7 +67,7 @@ pub fn create_access_token(
|
||||
.map_err(|_| OAuthError::ServerError("HMAC key error".to_string()))?;
|
||||
mac.update(signing_input.as_bytes());
|
||||
let signature = mac.finalize().into_bytes();
|
||||
let signature_b64 = URL_SAFE_NO_PAD.encode(&signature);
|
||||
let signature_b64 = URL_SAFE_NO_PAD.encode(signature);
|
||||
Ok(format!("{}.{}", signing_input, signature_b64))
|
||||
}
|
||||
|
||||
@@ -76,10 +82,14 @@ pub fn extract_token_claims(token: &str) -> Result<TokenClaims, OAuthError> {
|
||||
let header: serde_json::Value = serde_json::from_slice(&header_bytes)
|
||||
.map_err(|_| OAuthError::InvalidToken("Invalid token header".to_string()))?;
|
||||
if header.get("typ").and_then(|t| t.as_str()) != Some("at+jwt") {
|
||||
return Err(OAuthError::InvalidToken("Not an OAuth access token".to_string()));
|
||||
return Err(OAuthError::InvalidToken(
|
||||
"Not an OAuth access token".to_string(),
|
||||
));
|
||||
}
|
||||
if header.get("alg").and_then(|a| a.as_str()) != Some("HS256") {
|
||||
return Err(OAuthError::InvalidToken("Unsupported algorithm".to_string()));
|
||||
return Err(OAuthError::InvalidToken(
|
||||
"Unsupported algorithm".to_string(),
|
||||
));
|
||||
}
|
||||
let config = AuthConfig::get();
|
||||
let secret = config.jwt_secret();
|
||||
@@ -93,7 +103,9 @@ pub fn extract_token_claims(token: &str) -> Result<TokenClaims, OAuthError> {
|
||||
mac.update(signing_input.as_bytes());
|
||||
let expected_sig = mac.finalize().into_bytes();
|
||||
if !bool::from(expected_sig.ct_eq(&provided_sig)) {
|
||||
return Err(OAuthError::InvalidToken("Invalid token signature".to_string()));
|
||||
return Err(OAuthError::InvalidToken(
|
||||
"Invalid token signature".to_string(),
|
||||
));
|
||||
}
|
||||
let payload_bytes = URL_SAFE_NO_PAD
|
||||
.decode(parts[1])
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use axum::{Form, Json};
|
||||
use super::helpers::extract_token_claims;
|
||||
use crate::oauth::{OAuthError, db};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use axum::extract::State;
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::{Form, Json};
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::oauth::{OAuthError, db};
|
||||
use super::helpers::extract_token_claims;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct RevokeRequest {
|
||||
@@ -20,7 +20,10 @@ pub async fn revoke_token(
|
||||
Form(request): Form<RevokeRequest>,
|
||||
) -> Result<StatusCode, OAuthError> {
|
||||
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
|
||||
if !state.check_rate_limit(RateLimitKind::OAuthIntrospect, &client_ip).await {
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::OAuthIntrospect, &client_ip)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(ip = %client_ip, "OAuth revoke rate limit exceeded");
|
||||
return Err(OAuthError::RateLimited);
|
||||
}
|
||||
@@ -74,7 +77,10 @@ pub async fn introspect_token(
|
||||
Form(request): Form<IntrospectRequest>,
|
||||
) -> Result<Json<IntrospectResponse>, OAuthError> {
|
||||
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
|
||||
if !state.check_rate_limit(RateLimitKind::OAuthIntrospect, &client_ip).await {
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::OAuthIntrospect, &client_ip)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(ip = %client_ip, "OAuth introspect rate limit exceeded");
|
||||
return Err(OAuthError::RateLimited);
|
||||
}
|
||||
|
||||
@@ -3,34 +3,27 @@ mod helpers;
|
||||
mod introspect;
|
||||
mod types;
|
||||
|
||||
use axum::{
|
||||
Form, Json,
|
||||
extract::State,
|
||||
http::HeaderMap,
|
||||
};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::oauth::OAuthError;
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use axum::{Form, Json, extract::State, http::HeaderMap};
|
||||
|
||||
pub use grants::{handle_authorization_code_grant, handle_refresh_token_grant};
|
||||
pub use helpers::{create_access_token, extract_token_claims, verify_pkce, TokenClaims};
|
||||
pub use helpers::{TokenClaims, create_access_token, extract_token_claims, verify_pkce};
|
||||
pub use introspect::{
|
||||
introspect_token, revoke_token, IntrospectRequest, IntrospectResponse, RevokeRequest,
|
||||
IntrospectRequest, IntrospectResponse, RevokeRequest, introspect_token, revoke_token,
|
||||
};
|
||||
pub use types::{TokenRequest, TokenResponse};
|
||||
|
||||
fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for") {
|
||||
if let Ok(value) = forwarded.to_str() {
|
||||
if let Some(first_ip) = value.split(',').next() {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for")
|
||||
&& let Ok(value) = forwarded.to_str()
|
||||
&& let Some(first_ip) = value.split(',').next() {
|
||||
return first_ip.trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(real_ip) = headers.get("x-real-ip") {
|
||||
if let Ok(value) = real_ip.to_str() {
|
||||
if let Some(real_ip) = headers.get("x-real-ip")
|
||||
&& let Ok(value) = real_ip.to_str() {
|
||||
return value.trim().to_string();
|
||||
}
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
@@ -40,7 +33,10 @@ pub async fn token_endpoint(
|
||||
Form(request): Form<TokenRequest>,
|
||||
) -> Result<(HeaderMap, Json<TokenResponse>), OAuthError> {
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if !state.check_rate_limit(RateLimitKind::OAuthToken, &client_ip).await {
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::OAuthToken, &client_ip)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(ip = %client_ip, "OAuth token rate limit exceeded");
|
||||
return Err(OAuthError::InvalidRequest(
|
||||
"Too many requests. Please try again later.".to_string(),
|
||||
@@ -54,9 +50,7 @@ pub async fn token_endpoint(
|
||||
"authorization_code" => {
|
||||
handle_authorization_code_grant(state, headers, request, dpop_proof).await
|
||||
}
|
||||
"refresh_token" => {
|
||||
handle_refresh_token_grant(state, headers, request, dpop_proof).await
|
||||
}
|
||||
"refresh_token" => handle_refresh_token_grant(state, headers, request, dpop_proof).await,
|
||||
_ => Err(OAuthError::UnsupportedGrantType(format!(
|
||||
"Unsupported grant_type: {}",
|
||||
request.grant_type
|
||||
|
||||
+10
-18
@@ -37,21 +37,15 @@ impl IntoResponse for OAuthError {
|
||||
OAuthError::InvalidClient(msg) => {
|
||||
(StatusCode::UNAUTHORIZED, "invalid_client", Some(msg))
|
||||
}
|
||||
OAuthError::InvalidGrant(msg) => {
|
||||
(StatusCode::BAD_REQUEST, "invalid_grant", Some(msg))
|
||||
}
|
||||
OAuthError::InvalidGrant(msg) => (StatusCode::BAD_REQUEST, "invalid_grant", Some(msg)),
|
||||
OAuthError::UnauthorizedClient(msg) => {
|
||||
(StatusCode::UNAUTHORIZED, "unauthorized_client", Some(msg))
|
||||
}
|
||||
OAuthError::UnsupportedGrantType(msg) => {
|
||||
(StatusCode::BAD_REQUEST, "unsupported_grant_type", Some(msg))
|
||||
}
|
||||
OAuthError::InvalidScope(msg) => {
|
||||
(StatusCode::BAD_REQUEST, "invalid_scope", Some(msg))
|
||||
}
|
||||
OAuthError::AccessDenied(msg) => {
|
||||
(StatusCode::FORBIDDEN, "access_denied", Some(msg))
|
||||
}
|
||||
OAuthError::InvalidScope(msg) => (StatusCode::BAD_REQUEST, "invalid_scope", Some(msg)),
|
||||
OAuthError::AccessDenied(msg) => (StatusCode::FORBIDDEN, "access_denied", Some(msg)),
|
||||
OAuthError::ServerError(msg) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "server_error", Some(msg))
|
||||
}
|
||||
@@ -69,15 +63,13 @@ impl IntoResponse for OAuthError {
|
||||
OAuthError::InvalidDpopProof(msg) => {
|
||||
(StatusCode::UNAUTHORIZED, "invalid_dpop_proof", Some(msg))
|
||||
}
|
||||
OAuthError::ExpiredToken(msg) => {
|
||||
(StatusCode::UNAUTHORIZED, "invalid_token", Some(msg))
|
||||
}
|
||||
OAuthError::InvalidToken(msg) => {
|
||||
(StatusCode::UNAUTHORIZED, "invalid_token", Some(msg))
|
||||
}
|
||||
OAuthError::RateLimited => {
|
||||
(StatusCode::TOO_MANY_REQUESTS, "rate_limited", Some("Too many requests. Please try again later.".to_string()))
|
||||
}
|
||||
OAuthError::ExpiredToken(msg) => (StatusCode::UNAUTHORIZED, "invalid_token", Some(msg)),
|
||||
OAuthError::InvalidToken(msg) => (StatusCode::UNAUTHORIZED, "invalid_token", Some(msg)),
|
||||
OAuthError::RateLimited => (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"rate_limited",
|
||||
Some("Too many requests. Please try again later.".to_string()),
|
||||
),
|
||||
};
|
||||
(
|
||||
status,
|
||||
|
||||
+7
-5
@@ -1,14 +1,16 @@
|
||||
pub mod types;
|
||||
pub mod client;
|
||||
pub mod db;
|
||||
pub mod dpop;
|
||||
pub mod jwks;
|
||||
pub mod client;
|
||||
pub mod endpoints;
|
||||
pub mod error;
|
||||
pub mod jwks;
|
||||
pub mod templates;
|
||||
pub mod types;
|
||||
pub mod verify;
|
||||
|
||||
pub use types::*;
|
||||
pub use error::OAuthError;
|
||||
pub use verify::{verify_oauth_access_token, generate_dpop_nonce, VerifyResult, OAuthUser, OAuthAuthError};
|
||||
pub use templates::{DeviceAccount, mask_email};
|
||||
pub use types::*;
|
||||
pub use verify::{
|
||||
OAuthAuthError, OAuthUser, VerifyResult, generate_dpop_nonce, verify_oauth_access_token,
|
||||
};
|
||||
|
||||
+21
-10
@@ -487,18 +487,23 @@ pub fn account_selector_page(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn two_factor_page(
|
||||
request_uri: &str,
|
||||
channel: &str,
|
||||
error_message: Option<&str>,
|
||||
) -> String {
|
||||
pub fn two_factor_page(request_uri: &str, channel: &str, error_message: Option<&str>) -> String {
|
||||
let error_html = error_message
|
||||
.map(|msg| format!(r#"<div class="error-banner">{}</div>"#, html_escape(msg)))
|
||||
.unwrap_or_default();
|
||||
let (title, subtitle) = match channel {
|
||||
"email" => ("Check your email", "We sent a verification code to your email"),
|
||||
"Discord" => ("Check Discord", "We sent a verification code to your Discord"),
|
||||
"Telegram" => ("Check Telegram", "We sent a verification code to your Telegram"),
|
||||
"email" => (
|
||||
"Check your email",
|
||||
"We sent a verification code to your email",
|
||||
),
|
||||
"Discord" => (
|
||||
"Check Discord",
|
||||
"We sent a verification code to your Discord",
|
||||
),
|
||||
"Telegram" => (
|
||||
"Check Telegram",
|
||||
"We sent a verification code to your Telegram",
|
||||
),
|
||||
"Signal" => ("Check Signal", "We sent a verification code to your Signal"),
|
||||
_ => ("Check your messages", "We sent you a verification code"),
|
||||
};
|
||||
@@ -546,7 +551,8 @@ pub fn two_factor_page(
|
||||
}
|
||||
|
||||
pub fn error_page(error: &str, error_description: Option<&str>) -> String {
|
||||
let description = error_description.unwrap_or("An error occurred during the authorization process.");
|
||||
let description =
|
||||
error_description.unwrap_or("An error occurred during the authorization process.");
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -618,7 +624,12 @@ fn get_initials(handle: &str) -> String {
|
||||
if clean.is_empty() {
|
||||
return "?".to_string();
|
||||
}
|
||||
clean.chars().next().unwrap_or('?').to_uppercase().to_string()
|
||||
clean
|
||||
.chars()
|
||||
.next()
|
||||
.unwrap_or('?')
|
||||
.to_uppercase()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn mask_email(email: &str) -> String {
|
||||
|
||||
+4
-1
@@ -22,7 +22,10 @@ pub struct RefreshToken(pub String);
|
||||
|
||||
impl RequestId {
|
||||
pub fn generate() -> Self {
|
||||
Self(format!("urn:ietf:params:oauth:request_uri:{}", uuid::Uuid::new_v4()))
|
||||
Self(format!(
|
||||
"urn:ietf:params:oauth:request_uri:{}",
|
||||
uuid::Uuid::new_v4()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+43
-26
@@ -1,8 +1,8 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::FromRequestParts,
|
||||
http::{StatusCode, request::Parts},
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use hmac::{Hmac, Mac};
|
||||
@@ -11,11 +11,11 @@ use sha2::Sha256;
|
||||
use sqlx::PgPool;
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
use crate::config::AuthConfig;
|
||||
use crate::state::AppState;
|
||||
use super::OAuthError;
|
||||
use super::db;
|
||||
use super::dpop::DPoPVerifier;
|
||||
use super::OAuthError;
|
||||
use crate::config::AuthConfig;
|
||||
use crate::state::AppState;
|
||||
|
||||
pub struct OAuthTokenInfo {
|
||||
pub did: String,
|
||||
@@ -48,13 +48,13 @@ pub async fn verify_oauth_access_token(
|
||||
return Err(OAuthError::InvalidToken("Token has expired".to_string()));
|
||||
}
|
||||
if let Some(expected_jkt) = &token_data.parameters.dpop_jkt {
|
||||
let proof = dpop_proof.ok_or_else(|| {
|
||||
OAuthError::UseDpopNonce("DPoP proof required".to_string())
|
||||
})?;
|
||||
let proof = dpop_proof
|
||||
.ok_or_else(|| OAuthError::UseDpopNonce("DPoP proof required".to_string()))?;
|
||||
let config = AuthConfig::get();
|
||||
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
|
||||
let access_token_hash = compute_ath(access_token);
|
||||
let result = verifier.verify_proof(proof, http_method, http_uri, Some(&access_token_hash))?;
|
||||
let result =
|
||||
verifier.verify_proof(proof, http_method, http_uri, Some(&access_token_hash))?;
|
||||
if !db::check_and_record_dpop_jti(pool, &result.jti).await? {
|
||||
return Err(OAuthError::InvalidDpopProof(
|
||||
"DPoP proof has already been used".to_string(),
|
||||
@@ -85,10 +85,14 @@ pub fn extract_oauth_token_info(token: &str) -> Result<OAuthTokenInfo, OAuthErro
|
||||
let header: serde_json::Value = serde_json::from_slice(&header_bytes)
|
||||
.map_err(|_| OAuthError::InvalidToken("Invalid token header".to_string()))?;
|
||||
if header.get("typ").and_then(|t| t.as_str()) != Some("at+jwt") {
|
||||
return Err(OAuthError::InvalidToken("Not an OAuth access token".to_string()));
|
||||
return Err(OAuthError::InvalidToken(
|
||||
"Not an OAuth access token".to_string(),
|
||||
));
|
||||
}
|
||||
if header.get("alg").and_then(|a| a.as_str()) != Some("HS256") {
|
||||
return Err(OAuthError::InvalidToken("Unsupported algorithm".to_string()));
|
||||
return Err(OAuthError::InvalidToken(
|
||||
"Unsupported algorithm".to_string(),
|
||||
));
|
||||
}
|
||||
let config = AuthConfig::get();
|
||||
let secret = config.jwt_secret();
|
||||
@@ -102,7 +106,9 @@ pub fn extract_oauth_token_info(token: &str) -> Result<OAuthTokenInfo, OAuthErro
|
||||
mac.update(signing_input.as_bytes());
|
||||
let expected_sig = mac.finalize().into_bytes();
|
||||
if !bool::from(expected_sig.ct_eq(&provided_sig)) {
|
||||
return Err(OAuthError::InvalidToken("Invalid token signature".to_string()));
|
||||
return Err(OAuthError::InvalidToken(
|
||||
"Invalid token signature".to_string(),
|
||||
));
|
||||
}
|
||||
let payload_bytes = URL_SAFE_NO_PAD
|
||||
.decode(parts[1])
|
||||
@@ -127,7 +133,10 @@ pub fn extract_oauth_token_info(token: &str) -> Result<OAuthTokenInfo, OAuthErro
|
||||
.and_then(|s| s.as_str())
|
||||
.ok_or_else(|| OAuthError::InvalidToken("Missing sub claim".to_string()))?
|
||||
.to_string();
|
||||
let scope = payload.get("scope").and_then(|s| s.as_str()).map(|s| s.to_string());
|
||||
let scope = payload
|
||||
.get("scope")
|
||||
.and_then(|s| s.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let dpop_jkt = payload
|
||||
.get("cnf")
|
||||
.and_then(|c| c.get("jkt"))
|
||||
@@ -152,7 +161,7 @@ fn compute_ath(access_token: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(access_token.as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
URL_SAFE_NO_PAD.encode(&hash)
|
||||
URL_SAFE_NO_PAD.encode(hash)
|
||||
}
|
||||
|
||||
pub fn generate_dpop_nonce() -> String {
|
||||
@@ -186,10 +195,9 @@ impl IntoResponse for OAuthAuthError {
|
||||
)
|
||||
.into_response();
|
||||
if let Some(nonce) = self.dpop_nonce {
|
||||
response.headers_mut().insert(
|
||||
"DPoP-Nonce",
|
||||
nonce.parse().unwrap(),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("DPoP-Nonce", nonce.parse().unwrap());
|
||||
}
|
||||
response
|
||||
}
|
||||
@@ -198,7 +206,10 @@ impl IntoResponse for OAuthAuthError {
|
||||
impl FromRequestParts<AppState> for OAuthUser {
|
||||
type Rejection = OAuthAuthError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
async fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &AppState,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let auth_header = parts
|
||||
.headers
|
||||
.get("Authorization")
|
||||
@@ -210,9 +221,13 @@ impl FromRequestParts<AppState> for OAuthUser {
|
||||
dpop_nonce: None,
|
||||
})?;
|
||||
let auth_header_trimmed = auth_header.trim();
|
||||
let (token, is_dpop_token) = if auth_header_trimmed.len() >= 7 && auth_header_trimmed[..7].eq_ignore_ascii_case("bearer ") {
|
||||
let (token, is_dpop_token) = if auth_header_trimmed.len() >= 7
|
||||
&& auth_header_trimmed[..7].eq_ignore_ascii_case("bearer ")
|
||||
{
|
||||
(auth_header_trimmed[7..].trim(), false)
|
||||
} else if auth_header_trimmed.len() >= 5 && auth_header_trimmed[..5].eq_ignore_ascii_case("dpop ") {
|
||||
} else if auth_header_trimmed.len() >= 5
|
||||
&& auth_header_trimmed[..5].eq_ignore_ascii_case("dpop ")
|
||||
{
|
||||
(auth_header_trimmed[5..].trim(), true)
|
||||
} else {
|
||||
return Err(OAuthAuthError {
|
||||
@@ -222,10 +237,7 @@ impl FromRequestParts<AppState> for OAuthUser {
|
||||
dpop_nonce: None,
|
||||
});
|
||||
};
|
||||
let dpop_proof = parts
|
||||
.headers
|
||||
.get("DPoP")
|
||||
.and_then(|v| v.to_str().ok());
|
||||
let dpop_proof = parts.headers.get("DPoP").and_then(|v| v.to_str().ok());
|
||||
if let Ok(result) = try_legacy_auth(&state.db, token).await {
|
||||
return Ok(OAuthUser {
|
||||
did: result.did,
|
||||
@@ -236,7 +248,8 @@ impl FromRequestParts<AppState> for OAuthUser {
|
||||
}
|
||||
let http_method = parts.method.as_str();
|
||||
let http_uri = parts.uri.to_string();
|
||||
match verify_oauth_access_token(&state.db, token, dpop_proof, http_method, &http_uri).await {
|
||||
match verify_oauth_access_token(&state.db, token, dpop_proof, http_method, &http_uri).await
|
||||
{
|
||||
Ok(result) => Ok(OAuthUser {
|
||||
did: result.did,
|
||||
client_id: Some(result.client_id),
|
||||
@@ -259,7 +272,11 @@ impl FromRequestParts<AppState> for OAuthUser {
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
let nonce = if is_dpop_token { Some(generate_dpop_nonce()) } else { None };
|
||||
let nonce = if is_dpop_token {
|
||||
Some(generate_dpop_nonce())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Err(OAuthAuthError {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
error: "AuthenticationFailed".to_string(),
|
||||
|
||||
+96
-67
@@ -1,9 +1,9 @@
|
||||
use base32::Alphabet;
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use k256::ecdsa::{SigningKey, Signature, signature::Signer};
|
||||
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
@@ -102,10 +102,7 @@ impl PlcClient {
|
||||
.pool_max_idle_per_host(5)
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
Self {
|
||||
base_url,
|
||||
client,
|
||||
}
|
||||
Self { base_url, client }
|
||||
}
|
||||
|
||||
fn encode_did(did: &str) -> String {
|
||||
@@ -126,7 +123,10 @@ impl PlcClient {
|
||||
status, body
|
||||
)));
|
||||
}
|
||||
response.json().await.map_err(|e| PlcError::InvalidResponse(e.to_string()))
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| PlcError::InvalidResponse(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn get_document_data(&self, did: &str) -> Result<Value, PlcError> {
|
||||
@@ -143,7 +143,10 @@ impl PlcClient {
|
||||
status, body
|
||||
)));
|
||||
}
|
||||
response.json().await.map_err(|e| PlcError::InvalidResponse(e.to_string()))
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| PlcError::InvalidResponse(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn get_last_op(&self, did: &str) -> Result<PlcOpOrTombstone, PlcError> {
|
||||
@@ -160,7 +163,10 @@ impl PlcClient {
|
||||
status, body
|
||||
)));
|
||||
}
|
||||
response.json().await.map_err(|e| PlcError::InvalidResponse(e.to_string()))
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| PlcError::InvalidResponse(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn get_audit_log(&self, did: &str) -> Result<Vec<Value>, PlcError> {
|
||||
@@ -177,16 +183,15 @@ impl PlcClient {
|
||||
status, body
|
||||
)));
|
||||
}
|
||||
response.json().await.map_err(|e| PlcError::InvalidResponse(e.to_string()))
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| PlcError::InvalidResponse(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn send_operation(&self, did: &str, operation: &Value) -> Result<(), PlcError> {
|
||||
let url = format!("{}/{}", self.base_url, Self::encode_did(did));
|
||||
let response = self.client
|
||||
.post(&url)
|
||||
.json(operation)
|
||||
.send()
|
||||
.await?;
|
||||
let response = self.client.post(&url).json(operation).send().await?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
@@ -200,8 +205,8 @@ impl PlcClient {
|
||||
}
|
||||
|
||||
pub fn cid_for_cbor(value: &Value) -> Result<String, PlcError> {
|
||||
let cbor_bytes = serde_ipld_dagcbor::to_vec(value)
|
||||
.map_err(|e| PlcError::Serialization(e.to_string()))?;
|
||||
let cbor_bytes =
|
||||
serde_ipld_dagcbor::to_vec(value).map_err(|e| PlcError::Serialization(e.to_string()))?;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&cbor_bytes);
|
||||
let hash = hasher.finalize();
|
||||
@@ -211,16 +216,13 @@ pub fn cid_for_cbor(value: &Value) -> Result<String, PlcError> {
|
||||
Ok(cid.to_string())
|
||||
}
|
||||
|
||||
pub fn sign_operation(
|
||||
operation: &Value,
|
||||
signing_key: &SigningKey,
|
||||
) -> Result<Value, PlcError> {
|
||||
pub fn sign_operation(operation: &Value, signing_key: &SigningKey) -> Result<Value, PlcError> {
|
||||
let mut op = operation.clone();
|
||||
if let Some(obj) = op.as_object_mut() {
|
||||
obj.remove("sig");
|
||||
}
|
||||
let cbor_bytes = serde_ipld_dagcbor::to_vec(&op)
|
||||
.map_err(|e| PlcError::Serialization(e.to_string()))?;
|
||||
let cbor_bytes =
|
||||
serde_ipld_dagcbor::to_vec(&op).map_err(|e| PlcError::Serialization(e.to_string()))?;
|
||||
let signature: Signature = signing_key.sign(&cbor_bytes);
|
||||
let sig_bytes = signature.to_bytes();
|
||||
let sig_b64 = URL_SAFE_NO_PAD.encode(sig_bytes);
|
||||
@@ -238,10 +240,12 @@ pub fn create_update_op(
|
||||
services: Option<HashMap<String, PlcService>>,
|
||||
) -> Result<Value, PlcError> {
|
||||
let prev_value = match last_op {
|
||||
PlcOpOrTombstone::Operation(op) => serde_json::to_value(op)
|
||||
.map_err(|e| PlcError::Serialization(e.to_string()))?,
|
||||
PlcOpOrTombstone::Tombstone(t) => serde_json::to_value(t)
|
||||
.map_err(|e| PlcError::Serialization(e.to_string()))?,
|
||||
PlcOpOrTombstone::Operation(op) => {
|
||||
serde_json::to_value(op).map_err(|e| PlcError::Serialization(e.to_string()))?
|
||||
}
|
||||
PlcOpOrTombstone::Tombstone(t) => {
|
||||
serde_json::to_value(t).map_err(|e| PlcError::Serialization(e.to_string()))?
|
||||
}
|
||||
};
|
||||
let prev_cid = cid_for_cbor(&prev_value)?;
|
||||
let (base_rotation_keys, base_verification_methods, base_also_known_as, base_services) =
|
||||
@@ -309,8 +313,8 @@ pub fn create_genesis_operation(
|
||||
prev: None,
|
||||
sig: None,
|
||||
};
|
||||
let genesis_value = serde_json::to_value(&genesis_op)
|
||||
.map_err(|e| PlcError::Serialization(e.to_string()))?;
|
||||
let genesis_value =
|
||||
serde_json::to_value(&genesis_op).map_err(|e| PlcError::Serialization(e.to_string()))?;
|
||||
let signed_op = sign_operation(&genesis_value, signing_key)?;
|
||||
let did = did_for_genesis_op(&signed_op)?;
|
||||
Ok(GenesisResult {
|
||||
@@ -331,20 +335,29 @@ pub fn did_for_genesis_op(signed_op: &Value) -> Result<String, PlcError> {
|
||||
}
|
||||
|
||||
pub fn validate_plc_operation(op: &Value) -> Result<(), PlcError> {
|
||||
let obj = op.as_object()
|
||||
let obj = op
|
||||
.as_object()
|
||||
.ok_or_else(|| PlcError::InvalidResponse("Operation must be an object".to_string()))?;
|
||||
let op_type = obj.get("type")
|
||||
let op_type = obj
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| PlcError::InvalidResponse("Missing type field".to_string()))?;
|
||||
if op_type != "plc_operation" && op_type != "plc_tombstone" {
|
||||
return Err(PlcError::InvalidResponse(format!("Invalid type: {}", op_type)));
|
||||
return Err(PlcError::InvalidResponse(format!(
|
||||
"Invalid type: {}",
|
||||
op_type
|
||||
)));
|
||||
}
|
||||
if op_type == "plc_operation" {
|
||||
if obj.get("rotationKeys").is_none() {
|
||||
return Err(PlcError::InvalidResponse("Missing rotationKeys".to_string()));
|
||||
return Err(PlcError::InvalidResponse(
|
||||
"Missing rotationKeys".to_string(),
|
||||
));
|
||||
}
|
||||
if obj.get("verificationMethods").is_none() {
|
||||
return Err(PlcError::InvalidResponse("Missing verificationMethods".to_string()));
|
||||
return Err(PlcError::InvalidResponse(
|
||||
"Missing verificationMethods".to_string(),
|
||||
));
|
||||
}
|
||||
if obj.get("alsoKnownAs").is_none() {
|
||||
return Err(PlcError::InvalidResponse("Missing alsoKnownAs".to_string()));
|
||||
@@ -371,35 +384,37 @@ pub fn validate_plc_operation_for_submission(
|
||||
ctx: &PlcValidationContext,
|
||||
) -> Result<(), PlcError> {
|
||||
validate_plc_operation(op)?;
|
||||
let obj = op.as_object()
|
||||
let obj = op
|
||||
.as_object()
|
||||
.ok_or_else(|| PlcError::InvalidResponse("Operation must be an object".to_string()))?;
|
||||
let op_type = obj.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let op_type = obj.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if op_type != "plc_operation" {
|
||||
return Ok(());
|
||||
}
|
||||
let rotation_keys = obj.get("rotationKeys")
|
||||
let rotation_keys = obj
|
||||
.get("rotationKeys")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| PlcError::InvalidResponse("rotationKeys must be an array".to_string()))?;
|
||||
let rotation_key_strings: Vec<&str> = rotation_keys
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str())
|
||||
.collect();
|
||||
let rotation_key_strings: Vec<&str> = rotation_keys.iter().filter_map(|v| v.as_str()).collect();
|
||||
if !rotation_key_strings.contains(&ctx.server_rotation_key.as_str()) {
|
||||
return Err(PlcError::InvalidResponse(
|
||||
"Rotation keys do not include server's rotation key".to_string()
|
||||
"Rotation keys do not include server's rotation key".to_string(),
|
||||
));
|
||||
}
|
||||
let verification_methods = obj.get("verificationMethods")
|
||||
let verification_methods = obj
|
||||
.get("verificationMethods")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| PlcError::InvalidResponse("verificationMethods must be an object".to_string()))?;
|
||||
if let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str()) {
|
||||
if atproto_key != ctx.expected_signing_key {
|
||||
return Err(PlcError::InvalidResponse("Incorrect signing key".to_string()));
|
||||
.ok_or_else(|| {
|
||||
PlcError::InvalidResponse("verificationMethods must be an object".to_string())
|
||||
})?;
|
||||
if let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str())
|
||||
&& atproto_key != ctx.expected_signing_key {
|
||||
return Err(PlcError::InvalidResponse(
|
||||
"Incorrect signing key".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let also_known_as = obj.get("alsoKnownAs")
|
||||
let also_known_as = obj
|
||||
.get("alsoKnownAs")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| PlcError::InvalidResponse("alsoKnownAs must be an array".to_string()))?;
|
||||
let expected_handle_uri = format!("at://{}", ctx.expected_handle);
|
||||
@@ -409,36 +424,42 @@ pub fn validate_plc_operation_for_submission(
|
||||
.any(|s| s == expected_handle_uri);
|
||||
if !has_correct_handle && !also_known_as.is_empty() {
|
||||
return Err(PlcError::InvalidResponse(
|
||||
"Incorrect handle in alsoKnownAs".to_string()
|
||||
"Incorrect handle in alsoKnownAs".to_string(),
|
||||
));
|
||||
}
|
||||
let services = obj.get("services")
|
||||
let services = obj
|
||||
.get("services")
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| PlcError::InvalidResponse("services must be an object".to_string()))?;
|
||||
if let Some(pds_service) = services.get("atproto_pds").and_then(|v| v.as_object()) {
|
||||
let service_type = pds_service.get("type").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let service_type = pds_service
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if service_type != "AtprotoPersonalDataServer" {
|
||||
return Err(PlcError::InvalidResponse(
|
||||
"Incorrect type on atproto_pds service".to_string()
|
||||
"Incorrect type on atproto_pds service".to_string(),
|
||||
));
|
||||
}
|
||||
let endpoint = pds_service.get("endpoint").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let endpoint = pds_service
|
||||
.get("endpoint")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
if endpoint != ctx.expected_pds_endpoint {
|
||||
return Err(PlcError::InvalidResponse(
|
||||
"Incorrect endpoint on atproto_pds service".to_string()
|
||||
"Incorrect endpoint on atproto_pds service".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn verify_operation_signature(
|
||||
op: &Value,
|
||||
rotation_keys: &[String],
|
||||
) -> Result<bool, PlcError> {
|
||||
let obj = op.as_object()
|
||||
pub fn verify_operation_signature(op: &Value, rotation_keys: &[String]) -> Result<bool, PlcError> {
|
||||
let obj = op
|
||||
.as_object()
|
||||
.ok_or_else(|| PlcError::InvalidResponse("Operation must be an object".to_string()))?;
|
||||
let sig_b64 = obj.get("sig")
|
||||
let sig_b64 = obj
|
||||
.get("sig")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| PlcError::InvalidResponse("Missing sig".to_string()))?;
|
||||
let sig_bytes = URL_SAFE_NO_PAD
|
||||
@@ -467,21 +488,29 @@ fn verify_signature_with_did_key(
|
||||
) -> Result<bool, PlcError> {
|
||||
use k256::ecdsa::{VerifyingKey, signature::Verifier};
|
||||
if !did_key.starts_with("did:key:z") {
|
||||
return Err(PlcError::InvalidResponse("Invalid did:key format".to_string()));
|
||||
return Err(PlcError::InvalidResponse(
|
||||
"Invalid did:key format".to_string(),
|
||||
));
|
||||
}
|
||||
let multibase_part = &did_key[8..];
|
||||
let (_, decoded) = multibase::decode(multibase_part)
|
||||
.map_err(|e| PlcError::InvalidResponse(format!("Failed to decode did:key: {}", e)))?;
|
||||
if decoded.len() < 2 {
|
||||
return Err(PlcError::InvalidResponse("Invalid did:key data".to_string()));
|
||||
return Err(PlcError::InvalidResponse(
|
||||
"Invalid did:key data".to_string(),
|
||||
));
|
||||
}
|
||||
let (codec, key_bytes) = if decoded[0] == 0xe7 && decoded[1] == 0x01 {
|
||||
(0xe701u16, &decoded[2..])
|
||||
} else {
|
||||
return Err(PlcError::InvalidResponse("Unsupported key type in did:key".to_string()));
|
||||
return Err(PlcError::InvalidResponse(
|
||||
"Unsupported key type in did:key".to_string(),
|
||||
));
|
||||
};
|
||||
if codec != 0xe701 {
|
||||
return Err(PlcError::InvalidResponse("Only secp256k1 keys are supported".to_string()));
|
||||
return Err(PlcError::InvalidResponse(
|
||||
"Only secp256k1 keys are supported".to_string(),
|
||||
));
|
||||
}
|
||||
let verifying_key = VerifyingKey::from_sec1_bytes(key_bytes)
|
||||
.map_err(|e| PlcError::InvalidResponse(format!("Invalid public key: {}", e)))?;
|
||||
|
||||
+60
-66
@@ -1,21 +1,17 @@
|
||||
use axum::{
|
||||
Json,
|
||||
body::Body,
|
||||
extract::ConnectInfo,
|
||||
http::{HeaderMap, Request, StatusCode},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
Json,
|
||||
};
|
||||
use governor::{
|
||||
Quota, RateLimiter,
|
||||
clock::DefaultClock,
|
||||
state::{InMemoryState, NotKeyed, keyed::DefaultKeyedStateStore},
|
||||
};
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
num::NonZeroU32,
|
||||
sync::Arc,
|
||||
};
|
||||
use std::{net::SocketAddr, num::NonZeroU32, sync::Arc};
|
||||
|
||||
pub type KeyedRateLimiter = RateLimiter<String, DefaultKeyedStateStore<String>, DefaultClock>;
|
||||
pub type GlobalRateLimiter = RateLimiter<NotKeyed, InMemoryState, DefaultClock>;
|
||||
@@ -44,101 +40,99 @@ impl Default for RateLimiters {
|
||||
impl RateLimiters {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
login: Arc::new(RateLimiter::keyed(
|
||||
Quota::per_minute(NonZeroU32::new(10).unwrap())
|
||||
)),
|
||||
oauth_token: Arc::new(RateLimiter::keyed(
|
||||
Quota::per_minute(NonZeroU32::new(30).unwrap())
|
||||
)),
|
||||
oauth_authorize: Arc::new(RateLimiter::keyed(
|
||||
Quota::per_minute(NonZeroU32::new(10).unwrap())
|
||||
)),
|
||||
password_reset: Arc::new(RateLimiter::keyed(
|
||||
Quota::per_hour(NonZeroU32::new(5).unwrap())
|
||||
)),
|
||||
account_creation: Arc::new(RateLimiter::keyed(
|
||||
Quota::per_hour(NonZeroU32::new(10).unwrap())
|
||||
)),
|
||||
refresh_session: Arc::new(RateLimiter::keyed(
|
||||
Quota::per_minute(NonZeroU32::new(60).unwrap())
|
||||
)),
|
||||
reset_password: Arc::new(RateLimiter::keyed(
|
||||
Quota::per_minute(NonZeroU32::new(10).unwrap())
|
||||
)),
|
||||
oauth_par: Arc::new(RateLimiter::keyed(
|
||||
Quota::per_minute(NonZeroU32::new(30).unwrap())
|
||||
)),
|
||||
oauth_introspect: Arc::new(RateLimiter::keyed(
|
||||
Quota::per_minute(NonZeroU32::new(30).unwrap())
|
||||
)),
|
||||
app_password: Arc::new(RateLimiter::keyed(
|
||||
Quota::per_minute(NonZeroU32::new(10).unwrap())
|
||||
)),
|
||||
email_update: Arc::new(RateLimiter::keyed(
|
||||
Quota::per_hour(NonZeroU32::new(5).unwrap())
|
||||
)),
|
||||
login: Arc::new(RateLimiter::keyed(Quota::per_minute(
|
||||
NonZeroU32::new(10).unwrap(),
|
||||
))),
|
||||
oauth_token: Arc::new(RateLimiter::keyed(Quota::per_minute(
|
||||
NonZeroU32::new(30).unwrap(),
|
||||
))),
|
||||
oauth_authorize: Arc::new(RateLimiter::keyed(Quota::per_minute(
|
||||
NonZeroU32::new(10).unwrap(),
|
||||
))),
|
||||
password_reset: Arc::new(RateLimiter::keyed(Quota::per_hour(
|
||||
NonZeroU32::new(5).unwrap(),
|
||||
))),
|
||||
account_creation: Arc::new(RateLimiter::keyed(Quota::per_hour(
|
||||
NonZeroU32::new(10).unwrap(),
|
||||
))),
|
||||
refresh_session: Arc::new(RateLimiter::keyed(Quota::per_minute(
|
||||
NonZeroU32::new(60).unwrap(),
|
||||
))),
|
||||
reset_password: Arc::new(RateLimiter::keyed(Quota::per_minute(
|
||||
NonZeroU32::new(10).unwrap(),
|
||||
))),
|
||||
oauth_par: Arc::new(RateLimiter::keyed(Quota::per_minute(
|
||||
NonZeroU32::new(30).unwrap(),
|
||||
))),
|
||||
oauth_introspect: Arc::new(RateLimiter::keyed(Quota::per_minute(
|
||||
NonZeroU32::new(30).unwrap(),
|
||||
))),
|
||||
app_password: Arc::new(RateLimiter::keyed(Quota::per_minute(
|
||||
NonZeroU32::new(10).unwrap(),
|
||||
))),
|
||||
email_update: Arc::new(RateLimiter::keyed(Quota::per_hour(
|
||||
NonZeroU32::new(5).unwrap(),
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_login_limit(mut self, per_minute: u32) -> Self {
|
||||
self.login = Arc::new(RateLimiter::keyed(
|
||||
Quota::per_minute(NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap()))
|
||||
));
|
||||
self.login = Arc::new(RateLimiter::keyed(Quota::per_minute(
|
||||
NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap()),
|
||||
)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_oauth_token_limit(mut self, per_minute: u32) -> Self {
|
||||
self.oauth_token = Arc::new(RateLimiter::keyed(
|
||||
Quota::per_minute(NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(30).unwrap()))
|
||||
));
|
||||
self.oauth_token = Arc::new(RateLimiter::keyed(Quota::per_minute(
|
||||
NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(30).unwrap()),
|
||||
)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_oauth_authorize_limit(mut self, per_minute: u32) -> Self {
|
||||
self.oauth_authorize = Arc::new(RateLimiter::keyed(
|
||||
Quota::per_minute(NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap()))
|
||||
));
|
||||
self.oauth_authorize = Arc::new(RateLimiter::keyed(Quota::per_minute(
|
||||
NonZeroU32::new(per_minute).unwrap_or(NonZeroU32::new(10).unwrap()),
|
||||
)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_password_reset_limit(mut self, per_hour: u32) -> Self {
|
||||
self.password_reset = Arc::new(RateLimiter::keyed(
|
||||
Quota::per_hour(NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(5).unwrap()))
|
||||
));
|
||||
self.password_reset = Arc::new(RateLimiter::keyed(Quota::per_hour(
|
||||
NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(5).unwrap()),
|
||||
)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_account_creation_limit(mut self, per_hour: u32) -> Self {
|
||||
self.account_creation = Arc::new(RateLimiter::keyed(
|
||||
Quota::per_hour(NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(10).unwrap()))
|
||||
));
|
||||
self.account_creation = Arc::new(RateLimiter::keyed(Quota::per_hour(
|
||||
NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(10).unwrap()),
|
||||
)));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_email_update_limit(mut self, per_hour: u32) -> Self {
|
||||
self.email_update = Arc::new(RateLimiter::keyed(
|
||||
Quota::per_hour(NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(5).unwrap()))
|
||||
));
|
||||
self.email_update = Arc::new(RateLimiter::keyed(Quota::per_hour(
|
||||
NonZeroU32::new(per_hour).unwrap_or(NonZeroU32::new(5).unwrap()),
|
||||
)));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_client_ip(headers: &HeaderMap, addr: Option<SocketAddr>) -> String {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for") {
|
||||
if let Ok(value) = forwarded.to_str() {
|
||||
if let Some(first_ip) = value.split(',').next() {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for")
|
||||
&& let Ok(value) = forwarded.to_str()
|
||||
&& let Some(first_ip) = value.split(',').next() {
|
||||
return first_ip.trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(real_ip) = headers.get("x-real-ip") {
|
||||
if let Ok(value) = real_ip.to_str() {
|
||||
if let Some(real_ip) = headers.get("x-real-ip")
|
||||
&& let Ok(value) = real_ip.to_str() {
|
||||
return value.trim().to_string();
|
||||
}
|
||||
}
|
||||
|
||||
addr.map(|a| a.ip().to_string()).unwrap_or_else(|| "unknown".to_string())
|
||||
addr.map(|a| a.ip().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
fn rate_limit_response() -> Response {
|
||||
|
||||
+18
-10
@@ -27,7 +27,7 @@ impl BlockStore for PostgresBlockStore {
|
||||
let row = sqlx::query!("SELECT data FROM blocks WHERE cid = $1", &cid_bytes)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepoError::storage(e))?;
|
||||
.map_err(RepoError::storage)?;
|
||||
match row {
|
||||
Some(row) => Ok(Some(Bytes::from(row.data))),
|
||||
None => Ok(None),
|
||||
@@ -39,14 +39,22 @@ impl BlockStore for PostgresBlockStore {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
let hash = hasher.finalize();
|
||||
let multihash = Multihash::wrap(0x12, &hash)
|
||||
.map_err(|e| RepoError::storage(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("Failed to wrap multihash: {:?}", e))))?;
|
||||
let multihash = Multihash::wrap(0x12, &hash).map_err(|e| {
|
||||
RepoError::storage(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("Failed to wrap multihash: {:?}", e),
|
||||
))
|
||||
})?;
|
||||
let cid = Cid::new_v1(0x71, multihash);
|
||||
let cid_bytes = cid.to_bytes();
|
||||
sqlx::query!("INSERT INTO blocks (cid, data) VALUES ($1, $2) ON CONFLICT (cid) DO NOTHING", &cid_bytes, data)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepoError::storage(e))?;
|
||||
sqlx::query!(
|
||||
"INSERT INTO blocks (cid, data) VALUES ($1, $2) ON CONFLICT (cid) DO NOTHING",
|
||||
&cid_bytes,
|
||||
data
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(RepoError::storage)?;
|
||||
Ok(cid)
|
||||
}
|
||||
|
||||
@@ -56,7 +64,7 @@ impl BlockStore for PostgresBlockStore {
|
||||
let row = sqlx::query!("SELECT 1 as one FROM blocks WHERE cid = $1", &cid_bytes)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepoError::storage(e))?;
|
||||
.map_err(RepoError::storage)?;
|
||||
Ok(row.is_some())
|
||||
}
|
||||
|
||||
@@ -82,7 +90,7 @@ impl BlockStore for PostgresBlockStore {
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepoError::storage(e))?;
|
||||
.map_err(RepoError::storage)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -98,7 +106,7 @@ impl BlockStore for PostgresBlockStore {
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| RepoError::storage(e))?;
|
||||
.map_err(RepoError::storage)?;
|
||||
let found: std::collections::HashMap<Vec<u8>, Bytes> = rows
|
||||
.into_iter()
|
||||
.map(|row| (row.cid, Bytes::from(row.data)))
|
||||
|
||||
+15
-7
@@ -51,8 +51,12 @@ impl BlockStore for TrackingBlockStore {
|
||||
let result = self.inner.get(cid).await?;
|
||||
if result.is_some() {
|
||||
match self.read_cids.lock() {
|
||||
Ok(mut guard) => { guard.insert(*cid); },
|
||||
Err(poisoned) => { poisoned.into_inner().insert(*cid); },
|
||||
Ok(mut guard) => {
|
||||
guard.insert(*cid);
|
||||
}
|
||||
Err(poisoned) => {
|
||||
poisoned.into_inner().insert(*cid);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
@@ -61,8 +65,8 @@ impl BlockStore for TrackingBlockStore {
|
||||
async fn put(&self, data: &[u8]) -> Result<Cid, RepoError> {
|
||||
let cid = self.inner.put(data).await?;
|
||||
match self.written_cids.lock() {
|
||||
Ok(mut guard) => guard.push(cid.clone()),
|
||||
Err(poisoned) => poisoned.into_inner().push(cid.clone()),
|
||||
Ok(mut guard) => guard.push(cid),
|
||||
Err(poisoned) => poisoned.into_inner().push(cid),
|
||||
}
|
||||
Ok(cid)
|
||||
}
|
||||
@@ -76,7 +80,7 @@ impl BlockStore for TrackingBlockStore {
|
||||
blocks: impl IntoIterator<Item = (Cid, Bytes)> + Send,
|
||||
) -> Result<(), RepoError> {
|
||||
let blocks: Vec<_> = blocks.into_iter().collect();
|
||||
let cids: Vec<Cid> = blocks.iter().map(|(cid, _)| cid.clone()).collect();
|
||||
let cids: Vec<Cid> = blocks.iter().map(|(cid, _)| *cid).collect();
|
||||
self.inner.put_many(blocks).await?;
|
||||
match self.written_cids.lock() {
|
||||
Ok(mut guard) => guard.extend(cids),
|
||||
@@ -90,8 +94,12 @@ impl BlockStore for TrackingBlockStore {
|
||||
for (cid, result) in cids.iter().zip(results.iter()) {
|
||||
if result.is_some() {
|
||||
match self.read_cids.lock() {
|
||||
Ok(mut guard) => { guard.insert(*cid); },
|
||||
Err(poisoned) => { poisoned.into_inner().insert(*cid); },
|
||||
Ok(mut guard) => {
|
||||
guard.insert(*cid);
|
||||
}
|
||||
Err(poisoned) => {
|
||||
poisoned.into_inner().insert(*cid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -117,7 +117,11 @@ impl AppState {
|
||||
let limiter_name = kind.key_prefix();
|
||||
let (limit, window_ms) = kind.limit_and_window_ms();
|
||||
|
||||
if !self.distributed_rate_limiter.check_rate_limit(&key, limit, window_ms).await {
|
||||
if !self
|
||||
.distributed_rate_limiter
|
||||
.check_rate_limit(&key, limit, window_ms)
|
||||
.await
|
||||
{
|
||||
crate::metrics::record_rate_limit_rejection(limiter_name);
|
||||
return false;
|
||||
}
|
||||
|
||||
+4
-2
@@ -62,7 +62,8 @@ impl BlobStorage for S3BlobStorage {
|
||||
}
|
||||
|
||||
async fn put_bytes(&self, key: &str, data: Bytes) -> Result<(), StorageError> {
|
||||
let result = self.client
|
||||
let result = self
|
||||
.client
|
||||
.put_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(key)
|
||||
@@ -112,7 +113,8 @@ impl BlobStorage for S3BlobStorage {
|
||||
}
|
||||
|
||||
async fn delete(&self, key: &str) -> Result<(), StorageError> {
|
||||
let result = self.client
|
||||
let result = self
|
||||
.client
|
||||
.delete_object()
|
||||
.bucket(&self.bucket)
|
||||
.key(key)
|
||||
|
||||
+9
-13
@@ -58,14 +58,17 @@ pub async fn get_blob(
|
||||
}
|
||||
Ok(Some(_)) => {}
|
||||
}
|
||||
let blob_result = sqlx::query!("SELECT storage_key, mime_type FROM blobs WHERE cid = $1", cid)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let blob_result = sqlx::query!(
|
||||
"SELECT storage_key, mime_type FROM blobs WHERE cid = $1",
|
||||
cid
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
match blob_result {
|
||||
Ok(Some(row)) => {
|
||||
let storage_key = &row.storage_key;
|
||||
let mime_type = &row.mime_type;
|
||||
match state.blob_store.get(&storage_key).await {
|
||||
match state.blob_store.get(storage_key).await {
|
||||
Ok(data) => Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, mime_type)
|
||||
@@ -184,15 +187,8 @@ pub async fn list_blobs(
|
||||
match cids_result {
|
||||
Ok(cids) => {
|
||||
let has_more = cids.len() as i64 > limit;
|
||||
let cids: Vec<String> = cids
|
||||
.into_iter()
|
||||
.take(limit as usize)
|
||||
.collect();
|
||||
let next_cursor = if has_more {
|
||||
cids.last().cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let cids: Vec<String> = cids.into_iter().take(limit as usize).collect();
|
||||
let next_cursor = if has_more { cids.last().cloned() } else { None };
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(ListBlobsOutput {
|
||||
|
||||
+4
-2
@@ -24,8 +24,10 @@ pub fn ld_write<W: Write>(mut writer: W, data: &[u8]) -> std::io::Result<()> {
|
||||
}
|
||||
|
||||
pub fn encode_car_header(root_cid: &Cid) -> Result<Vec<u8>, String> {
|
||||
let header = CarHeader::new_v1(vec![root_cid.clone()]);
|
||||
let header_cbor = header.encode().map_err(|e| format!("Failed to encode CAR header: {:?}", e))?;
|
||||
let header = CarHeader::new_v1(vec![*root_cid]);
|
||||
let header_cbor = header
|
||||
.encode()
|
||||
.map_err(|e| format!("Failed to encode CAR header: {:?}", e))?;
|
||||
let mut result = Vec::new();
|
||||
write_varint(&mut result, header_cbor.len() as u64)
|
||||
.expect("Writing to Vec<u8> should never fail");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user