Moves every remaining hand-built error response onto ApiError: ~300
sites across faces, image, video, files, memories, versions,
duplicates, favorites, libraries, auth, clip/unified search, prefs,
reels, tts, insight chat, persona chat, knowledge and personas.
- 500s: the body was the error's text (anyhow / diesel / {:?}); now
ApiError::internal logs it and sends a generic message.
- Each 4xx/502/503 has a stable code and a message written for users;
statuses are unchanged. Shared helpers per module (faces_unavailable,
person_not_found, unknown_library, insight_not_found, ...).
- Pre-format top-level fields kept where clients read them: upload 409
duplicate_of / content_hash / library_name.
- PersonaChatError converts into ApiError in one place.
- Multipart upload errors use the format (error::multipart_config).
- Guard test: no non-test code builds HttpResponse::<error>() by hand.
Closes #202
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
149 lines
4.6 KiB
Rust
149 lines
4.6 KiB
Rust
use crate::error::ApiError;
|
|
use actix_web::Responder;
|
|
use actix_web::{
|
|
HttpResponse,
|
|
web::{self, Json},
|
|
};
|
|
use chrono::{Duration, Utc};
|
|
use jsonwebtoken::{EncodingKey, Header, encode};
|
|
use log::{error, info};
|
|
use std::sync::Mutex;
|
|
|
|
use crate::{
|
|
data::{Claims, CreateAccountRequest, LoginRequest, Token, secret_key},
|
|
database::UserDao,
|
|
};
|
|
|
|
/// Validate password meets security requirements
|
|
fn validate_password(password: &str) -> Result<(), String> {
|
|
if password.len() < 12 {
|
|
return Err("Password must be at least 12 characters".into());
|
|
}
|
|
if !password.chars().any(|c| c.is_uppercase()) {
|
|
return Err("Password must contain at least one uppercase letter".into());
|
|
}
|
|
if !password.chars().any(|c| c.is_lowercase()) {
|
|
return Err("Password must contain at least one lowercase letter".into());
|
|
}
|
|
if !password.chars().any(|c| c.is_numeric()) {
|
|
return Err("Password must contain at least one number".into());
|
|
}
|
|
if !password.chars().any(|c| !c.is_alphanumeric()) {
|
|
return Err("Password must contain at least one special character".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
async fn register<D: UserDao>(
|
|
user: Json<CreateAccountRequest>,
|
|
user_dao: web::Data<Mutex<D>>,
|
|
) -> impl Responder {
|
|
// Validate password strength
|
|
if let Err(msg) = validate_password(&user.password) {
|
|
return ApiError::bad_request("weak_password", msg).into_response();
|
|
}
|
|
|
|
if !user.username.is_empty() && user.password == user.confirmation {
|
|
let mut dao = user_dao.lock().expect("Unable to get UserDao");
|
|
if dao.user_exists(&user.username) {
|
|
ApiError::conflict("username_taken", "That username is taken").into_response()
|
|
} else if let Some(_user) = dao.create_user(&user.username, &user.password) {
|
|
HttpResponse::Ok().finish()
|
|
} else {
|
|
ApiError::internal("create_user failed").into_response()
|
|
}
|
|
} else {
|
|
ApiError::bad_request(
|
|
"invalid_registration",
|
|
"Enter a username and the same password twice",
|
|
)
|
|
.into_response()
|
|
}
|
|
}
|
|
|
|
pub async fn login<D: UserDao>(
|
|
creds: Json<LoginRequest>,
|
|
user_dao: web::Data<Mutex<D>>,
|
|
) -> HttpResponse {
|
|
info!("Logging in: {}", creds.username);
|
|
|
|
let mut user_dao = user_dao.lock().expect("Unable to get UserDao");
|
|
|
|
if let Some(user) = user_dao.get_user(&creds.username, &creds.password) {
|
|
let claims = Claims {
|
|
sub: user.id.to_string(),
|
|
exp: (Utc::now() + Duration::days(5)).timestamp(),
|
|
};
|
|
let token = match encode(
|
|
&Header::default(),
|
|
&claims,
|
|
&EncodingKey::from_secret(secret_key().as_bytes()),
|
|
) {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
return ApiError::internal(format!("Failed to encode JWT: {}", e)).into_response();
|
|
}
|
|
};
|
|
|
|
HttpResponse::Ok().json(Token { token: &token })
|
|
} else {
|
|
error!("Failed login attempt for user: '{}'", creds.username);
|
|
ApiError::unauthorized("invalid_credentials", "Wrong username or password").into_response()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::testhelpers::{BodyReader, TestUserDao};
|
|
|
|
#[actix_rt::test]
|
|
async fn test_login_reports_200_when_user_exists() {
|
|
let mut dao = TestUserDao::new();
|
|
dao.create_user("user", "pass");
|
|
|
|
let j = Json(LoginRequest {
|
|
username: "user".to_string(),
|
|
password: "pass".to_string(),
|
|
});
|
|
|
|
let response = login::<TestUserDao>(j, web::Data::new(Mutex::new(dao))).await;
|
|
|
|
assert_eq!(response.status(), 200);
|
|
}
|
|
|
|
#[actix_rt::test]
|
|
async fn test_login_returns_token_on_success() {
|
|
let mut dao = TestUserDao::new();
|
|
dao.create_user("user", "password");
|
|
|
|
let j = Json(LoginRequest {
|
|
username: "user".to_string(),
|
|
password: "password".to_string(),
|
|
});
|
|
|
|
let response = login::<TestUserDao>(j, web::Data::new(Mutex::new(dao))).await;
|
|
|
|
assert_eq!(response.status(), 200);
|
|
let response_text: String = response.read_to_str();
|
|
|
|
assert!(response_text.contains("\"token\""));
|
|
}
|
|
|
|
#[actix_rt::test]
|
|
async fn test_login_reports_401_when_user_does_not_exist() {
|
|
let mut dao = TestUserDao::new();
|
|
dao.create_user("user", "password");
|
|
|
|
let j = Json(LoginRequest {
|
|
username: "doesnotexist".to_string(),
|
|
password: "password".to_string(),
|
|
});
|
|
|
|
let response = login::<TestUserDao>(j, web::Data::new(Mutex::new(dao))).await;
|
|
|
|
assert_eq!(response.status(), 401);
|
|
}
|
|
}
|