mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-04 00:16:06 +00:00
Remaining endpoints for MVP
This commit is contained in:
@@ -3,7 +3,7 @@ use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use bcrypt::{DEFAULT_COST, hash};
|
||||
@@ -16,6 +16,22 @@ use serde_json::json;
|
||||
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() {
|
||||
return first_ip.trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(real_ip) = headers.get("x-real-ip") {
|
||||
if let Ok(value) = real_ip.to_str() {
|
||||
return value.trim().to_string();
|
||||
}
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateAccountInput {
|
||||
@@ -38,9 +54,24 @@ pub struct CreateAccountOutput {
|
||||
|
||||
pub async fn create_account(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<CreateAccountInput>,
|
||||
) -> Response {
|
||||
info!("create_account called");
|
||||
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if state.rate_limiters.account_creation.check_key(&client_ip).is_err() {
|
||||
warn!(ip = %client_ip, "Account creation rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many account creation attempts. Please try again later."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if input.handle.contains('!') || input.handle.contains('@') {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -184,8 +215,40 @@ pub async fn create_account(
|
||||
let user_id = match user_insert {
|
||||
Ok(row) => row.id,
|
||||
Err(e) => {
|
||||
if let Some(db_err) = e.as_database_error() {
|
||||
if db_err.code().as_deref() == Some("23505") {
|
||||
let constraint = db_err.constraint().unwrap_or("");
|
||||
if constraint.contains("handle") || constraint.contains("users_handle") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "HandleNotAvailable",
|
||||
"message": "Handle already taken"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
} else if constraint.contains("email") || constraint.contains("users_email") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidEmail",
|
||||
"message": "Email already registered"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
} else if constraint.contains("did") || constraint.contains("users_did") {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "AccountAlreadyExists",
|
||||
"message": "An account with this DID already exists"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
error!("Error inserting user: {:?}", e);
|
||||
// TODO: Check for unique constraint violation on email/did specifically
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::circuit_breaker::{with_circuit_breaker, CircuitBreakerError};
|
||||
use crate::plc::{
|
||||
create_update_op, sign_operation, PlcClient, PlcError, PlcService,
|
||||
create_update_op, sign_operation, PlcClient, PlcError, PlcOpOrTombstone, PlcService,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
@@ -14,7 +15,7 @@ use k256::ecdsa::SigningKey;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use tracing::{error, info};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -166,9 +167,27 @@ pub async fn sign_plc_operation(
|
||||
};
|
||||
|
||||
let plc_client = PlcClient::new(None);
|
||||
let last_op = match plc_client.get_last_op(did).await {
|
||||
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 last_op = match result {
|
||||
Ok(op) => op,
|
||||
Err(PlcError::NotFound) => {
|
||||
Err(CircuitBreakerError::CircuitOpen(e)) => {
|
||||
warn!("PLC directory circuit breaker open: {}", e);
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({
|
||||
"error": "ServiceUnavailable",
|
||||
"message": "PLC directory service temporarily unavailable"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(CircuitBreakerError::OperationFailed(PlcError::NotFound)) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({
|
||||
@@ -178,7 +197,7 @@ pub async fn sign_plc_operation(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
Err(CircuitBreakerError::OperationFailed(e)) => {
|
||||
error!("Failed to fetch PLC operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::api::ApiError;
|
||||
use crate::plc::{signing_key_to_did_key, validate_plc_operation, PlcClient};
|
||||
use crate::circuit_breaker::{with_circuit_breaker, CircuitBreakerError};
|
||||
use crate::plc::{signing_key_to_did_key, validate_plc_operation, PlcClient, PlcError};
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
extract::State,
|
||||
@@ -183,16 +184,38 @@ pub async fn submit_plc_operation(
|
||||
}
|
||||
|
||||
let plc_client = PlcClient::new(None);
|
||||
if let Err(e) = plc_client.send_operation(did, &input.operation).await {
|
||||
error!("Failed to submit PLC operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({
|
||||
"error": "UpstreamError",
|
||||
"message": format!("Failed to submit to PLC directory: {}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
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;
|
||||
|
||||
match result {
|
||||
Ok(()) => {}
|
||||
Err(CircuitBreakerError::CircuitOpen(e)) => {
|
||||
warn!("PLC directory circuit breaker open: {}", e);
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(json!({
|
||||
"error": "ServiceUnavailable",
|
||||
"message": "PLC directory service temporarily unavailable"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(CircuitBreakerError::OperationFailed(e)) => {
|
||||
error!("Failed to submit PLC operation: {:?}", e);
|
||||
return (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Json(json!({
|
||||
"error": "UpstreamError",
|
||||
"message": format!("Failed to submit to PLC directory: {}", e)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
|
||||
@@ -10,6 +10,7 @@ pub mod proxy_client;
|
||||
pub mod read_after_write;
|
||||
pub mod repo;
|
||||
pub mod server;
|
||||
pub mod temp;
|
||||
pub mod validation;
|
||||
|
||||
pub use error::ApiError;
|
||||
|
||||
+46
-46
@@ -167,57 +167,57 @@ pub async fn list_records(
|
||||
|
||||
let limit = input.limit.unwrap_or(50).clamp(1, 100);
|
||||
let reverse = input.reverse.unwrap_or(false);
|
||||
|
||||
// Simplistic query construction - no sophisticated cursor handling or rkey ranges for now, just basic pagination
|
||||
// TODO: Implement rkeyStart/End and correct cursor logic
|
||||
|
||||
let limit_i64 = limit as i64;
|
||||
let rows_res = if let Some(cursor) = &input.cursor {
|
||||
if reverse {
|
||||
sqlx::query!(
|
||||
"SELECT rkey, record_cid FROM records WHERE repo_id = $1 AND collection = $2 AND rkey < $3 ORDER BY rkey DESC LIMIT $4",
|
||||
user_id,
|
||||
input.collection,
|
||||
cursor,
|
||||
limit_i64
|
||||
)
|
||||
let order = if reverse { "ASC" } else { "DESC" };
|
||||
|
||||
let rows_res: Result<Vec<(String, String)>, sqlx::Error> = if let Some(cursor) = &input.cursor {
|
||||
let comparator = if reverse { ">" } else { "<" };
|
||||
let query = format!(
|
||||
"SELECT rkey, record_cid FROM records WHERE repo_id = $1 AND collection = $2 AND rkey {} $3 ORDER BY rkey {} LIMIT $4",
|
||||
comparator, order
|
||||
);
|
||||
sqlx::query_as(&query)
|
||||
.bind(user_id)
|
||||
.bind(&input.collection)
|
||||
.bind(cursor)
|
||||
.bind(limit_i64)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map(|rows| rows.into_iter().map(|r| (r.rkey, r.record_cid)).collect::<Vec<_>>())
|
||||
} else {
|
||||
sqlx::query!(
|
||||
"SELECT rkey, record_cid FROM records WHERE repo_id = $1 AND collection = $2 AND rkey > $3 ORDER BY rkey ASC LIMIT $4",
|
||||
user_id,
|
||||
input.collection,
|
||||
cursor,
|
||||
limit_i64
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map(|rows| rows.into_iter().map(|r| (r.rkey, r.record_cid)).collect::<Vec<_>>())
|
||||
}
|
||||
} else {
|
||||
if reverse {
|
||||
sqlx::query!(
|
||||
"SELECT rkey, record_cid FROM records WHERE repo_id = $1 AND collection = $2 ORDER BY rkey DESC LIMIT $3",
|
||||
user_id,
|
||||
input.collection,
|
||||
limit_i64
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map(|rows| rows.into_iter().map(|r| (r.rkey, r.record_cid)).collect::<Vec<_>>())
|
||||
} else {
|
||||
sqlx::query!(
|
||||
"SELECT rkey, record_cid FROM records WHERE repo_id = $1 AND collection = $2 ORDER BY rkey ASC LIMIT $3",
|
||||
user_id,
|
||||
input.collection,
|
||||
limit_i64
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map(|rows| rows.into_iter().map(|r| (r.rkey, r.record_cid)).collect::<Vec<_>>())
|
||||
let mut conditions = vec!["repo_id = $1", "collection = $2"];
|
||||
let mut param_idx = 3;
|
||||
|
||||
if input.rkey_start.is_some() {
|
||||
conditions.push("rkey > $3");
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
if input.rkey_end.is_some() {
|
||||
conditions.push(if param_idx == 3 { "rkey < $3" } else { "rkey < $4" });
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
let limit_idx = param_idx;
|
||||
|
||||
let query = format!(
|
||||
"SELECT rkey, record_cid FROM records WHERE {} ORDER BY rkey {} LIMIT ${}",
|
||||
conditions.join(" AND "),
|
||||
order,
|
||||
limit_idx
|
||||
);
|
||||
|
||||
let mut query_builder = sqlx::query_as::<_, (String, String)>(&query)
|
||||
.bind(user_id)
|
||||
.bind(&input.collection);
|
||||
|
||||
if let Some(start) = &input.rkey_start {
|
||||
query_builder = query_builder.bind(start);
|
||||
}
|
||||
if let Some(end) = &input.rkey_end {
|
||||
query_builder = query_builder.bind(end);
|
||||
}
|
||||
|
||||
query_builder.bind(limit_i64).fetch_all(&state.db).await
|
||||
};
|
||||
|
||||
let rows = match rows_res {
|
||||
|
||||
@@ -58,6 +58,34 @@ pub async fn commit_and_log(
|
||||
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",
|
||||
user_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.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());
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
@@ -4,6 +4,14 @@ use serde_json::json;
|
||||
|
||||
use tracing::error;
|
||||
|
||||
pub async fn robots_txt() -> impl IntoResponse {
|
||||
(
|
||||
StatusCode::OK,
|
||||
[("content-type", "text/plain")],
|
||||
"# Hello!\n\n# Crawling the public API is allowed\nUser-agent: *\nAllow: /\n",
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn describe_server() -> impl IntoResponse {
|
||||
let domains_str =
|
||||
std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| "example.com".to_string());
|
||||
|
||||
@@ -15,7 +15,7 @@ pub use account_status::{
|
||||
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
|
||||
pub use email::{confirm_email, request_email_update, update_email};
|
||||
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
|
||||
pub use meta::{describe_server, health};
|
||||
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::{create_session, delete_session, get_session, refresh_session};
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use bcrypt::{hash, DEFAULT_COST};
|
||||
@@ -15,6 +15,22 @@ 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() {
|
||||
return first_ip.trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(real_ip) = headers.get("x-real-ip") {
|
||||
if let Ok(value) = real_ip.to_str() {
|
||||
return value.trim().to_string();
|
||||
}
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RequestPasswordResetInput {
|
||||
pub email: String,
|
||||
@@ -22,8 +38,22 @@ pub struct RequestPasswordResetInput {
|
||||
|
||||
pub async fn request_password_reset(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<RequestPasswordResetInput>,
|
||||
) -> Response {
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if state.rate_limiters.password_reset.check_key(&client_ip).is_err() {
|
||||
warn!(ip = %client_ip, "Password reset rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many password reset requests. Please try again later."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let email = input.email.trim().to_lowercase();
|
||||
if email.is_empty() {
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::state::AppState;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use bcrypt::verify;
|
||||
@@ -11,6 +12,22 @@ use serde::{Deserialize, Serialize};
|
||||
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() {
|
||||
return first_ip.trim().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(real_ip) = headers.get("x-real-ip") {
|
||||
if let Ok(value) = real_ip.to_str() {
|
||||
return value.trim().to_string();
|
||||
}
|
||||
}
|
||||
"unknown".to_string()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateSessionInput {
|
||||
pub identifier: String,
|
||||
@@ -28,10 +45,24 @@ pub struct CreateSessionOutput {
|
||||
|
||||
pub async fn create_session(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<CreateSessionInput>,
|
||||
) -> Response {
|
||||
info!("create_session called");
|
||||
|
||||
let client_ip = extract_client_ip(&headers);
|
||||
if state.rate_limiters.login.check_key(&client_ip).is_err() {
|
||||
warn!(ip = %client_ip, "Login rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many login attempts. Please try again later."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let row = match sqlx::query!(
|
||||
"SELECT u.id, u.did, u.handle, u.password_hash, k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1",
|
||||
input.identifier
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
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")]
|
||||
pub struct CheckSignupQueueOutput {
|
||||
pub activated: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub place_in_queue: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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 {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "Forbidden",
|
||||
"message": "OAuth credentials are not supported for this endpoint"
|
||||
})),
|
||||
).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Json(CheckSignupQueueOutput {
|
||||
activated: true,
|
||||
place_in_queue: None,
|
||||
estimated_time_ms: None,
|
||||
}).into_response()
|
||||
}
|
||||
Reference in New Issue
Block a user