diff --git a/src/interfaces/api/handlers/mod.rs b/src/interfaces/api/handlers/mod.rs index aebeb707..4859923e 100644 --- a/src/interfaces/api/handlers/mod.rs +++ b/src/interfaces/api/handlers/mod.rs @@ -16,6 +16,7 @@ pub mod grant_handler; pub mod i18n_handler; pub mod magic_link_handler; pub mod music_handler; +pub mod people_handler; pub mod photos_handler; pub mod recent_handler; pub mod search_handler; diff --git a/src/interfaces/api/handlers/people_handler.rs b/src/interfaces/api/handlers/people_handler.rs new file mode 100644 index 00000000..cb217313 --- /dev/null +++ b/src/interfaces/api/handlers/people_handler.rs @@ -0,0 +1,177 @@ +//! HTTP handlers for the People (faces) feature. +//! +//! Every route is mounted only when `OXICLOUD_ENABLE_FACES` is on (the service +//! is present in `AppState`); each handler is also defensive. All work is +//! strictly caller-scoped by `PeopleService` (the repository filters by user). + +use std::sync::Arc; + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Deserialize; +use uuid::Uuid; + +use crate::common::di::AppState; +use crate::interfaces::errors::AppError; +use crate::interfaces::middleware::auth::AuthUser; + +fn disabled() -> Response { + ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "People feature is disabled" })), + ) + .into_response() +} + +fn bad_id() -> Response { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "invalid id" })), + ) + .into_response() +} + +/// GET /api/people — identity clusters for the caller. +pub async fn list_people(State(state): State>, auth_user: AuthUser) -> Response { + let Some(svc) = state.people_service.as_ref() else { + return disabled(); + }; + match svc.list_people(auth_user.id).await { + Ok(people) => Json(people).into_response(), + Err(e) => AppError::from(e).into_response(), + } +} + +/// GET /api/people/{id}/photos — file ids of a person's photos. +pub async fn person_photos( + State(state): State>, + auth_user: AuthUser, + Path(id): Path, +) -> Response { + let Some(svc) = state.people_service.as_ref() else { + return disabled(); + }; + let Ok(person_id) = Uuid::parse_str(&id) else { + return bad_id(); + }; + match svc.person_photos(auth_user.id, person_id).await { + Ok(files) => Json(files).into_response(), + Err(e) => AppError::from(e).into_response(), + } +} + +#[derive(Deserialize)] +pub struct RenameBody { + pub name: Option, +} + +/// PATCH /api/people/{id} — name (or clear the name of) a person. +pub async fn rename_person( + State(state): State>, + auth_user: AuthUser, + Path(id): Path, + Json(body): Json, +) -> Response { + let Some(svc) = state.people_service.as_ref() else { + return disabled(); + }; + let Ok(person_id) = Uuid::parse_str(&id) else { + return bad_id(); + }; + match svc.rename_person(auth_user.id, person_id, body.name).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => AppError::from(e).into_response(), + } +} + +#[derive(Deserialize)] +pub struct HideBody { + pub hidden: bool, +} + +/// POST /api/people/{id}/hide — hide/unhide a person from the grid. +pub async fn hide_person( + State(state): State>, + auth_user: AuthUser, + Path(id): Path, + Json(body): Json, +) -> Response { + let Some(svc) = state.people_service.as_ref() else { + return disabled(); + }; + let Ok(person_id) = Uuid::parse_str(&id) else { + return bad_id(); + }; + match svc.set_hidden(auth_user.id, person_id, body.hidden).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => AppError::from(e).into_response(), + } +} + +#[derive(Deserialize)] +pub struct MergeBody { + pub into: String, + pub from: String, +} + +/// POST /api/people/merge — merge `from` into `into`. +pub async fn merge_people( + State(state): State>, + auth_user: AuthUser, + Json(body): Json, +) -> Response { + let Some(svc) = state.people_service.as_ref() else { + return disabled(); + }; + let (Ok(into), Ok(from)) = (Uuid::parse_str(&body.into), Uuid::parse_str(&body.from)) else { + return bad_id(); + }; + match svc.merge(auth_user.id, into, from).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => AppError::from(e).into_response(), + } +} + +/// POST /api/people/recluster — re-run identity clustering for the caller. +pub async fn recluster(State(state): State>, auth_user: AuthUser) -> Response { + let Some(svc) = state.people_service.as_ref() else { + return disabled(); + }; + match svc.recluster(auth_user.id).await { + Ok(n) => Json(serde_json::json!({ "persons_created": n })).into_response(), + Err(e) => AppError::from(e).into_response(), + } +} + +/// DELETE /api/people/data — erase all of the caller's face data. +pub async fn delete_all(State(state): State>, auth_user: AuthUser) -> Response { + let Some(svc) = state.people_service.as_ref() else { + return disabled(); + }; + match svc.delete_all(auth_user.id).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(e) => AppError::from(e).into_response(), + } +} + +/// GET /api/people/faces/{file_id} — face boxes within a photo (lightbox tags). +pub async fn faces_for_file( + State(state): State>, + auth_user: AuthUser, + Path(file_id): Path, +) -> Response { + let Some(svc) = state.people_service.as_ref() else { + return disabled(); + }; + let Ok(fid) = Uuid::parse_str(&file_id) else { + return bad_id(); + }; + match svc.faces_for_file(auth_user.id, fid).await { + Ok(boxes) => Json(boxes).into_response(), + Err(e) => AppError::from(e).into_response(), + } +} diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index af2ff022..feb06d64 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -6,7 +6,7 @@ use axum::{ extract::{DefaultBodyLimit, State}, http::StatusCode, response::{IntoResponse, Json as AxumJson, Response}, - routing::{any, delete, get, post, put}, + routing::{any, delete, get, patch, post, put}, }; use serde_json::json; use std::sync::Arc; @@ -440,6 +440,24 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { router = router.nest("/photos", photos_router); } + // People (faces) routes — mounted only when OXICLOUD_ENABLE_FACES is on. + if app_state.people_service.is_some() { + use crate::interfaces::api::handlers::people_handler; + + let people_router = Router::new() + .route("/", get(people_handler::list_people)) + .route("/merge", post(people_handler::merge_people)) + .route("/recluster", post(people_handler::recluster)) + .route("/data", delete(people_handler::delete_all)) + .route("/faces/{file_id}", get(people_handler::faces_for_file)) + .route("/{id}", patch(people_handler::rename_person)) + .route("/{id}/photos", get(people_handler::person_photos)) + .route("/{id}/hide", post(people_handler::hide_person)) + .with_state(app_state.clone()); + + router = router.nest("/people", people_router); + } + // Re-enable trash routes to make the trash view work if let Some(_trash_service_ref) = trash_service.clone() { tracing::info!("Setting up trash routes for trash view");