feat(faces): /api/people endpoints
Phase 2 increment 6 — the People HTTP API (mounted only when
OXICLOUD_ENABLE_FACES is on; every handler is caller-scoped):
- GET /api/people list identity clusters
- GET /api/people/{id}/photos a person's photo file ids
- PATCH /api/people/{id} name / rename a person
- POST /api/people/{id}/hide hide / unhide
- POST /api/people/merge merge two clusters
- POST /api/people/recluster re-run clustering
- DELETE /api/people/data erase all face data (opt-out)
- GET /api/people/faces/{file_id} face boxes for lightbox tagging
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
This commit is contained in:
@@ -16,6 +16,7 @@ pub mod grant_handler;
|
|||||||
pub mod i18n_handler;
|
pub mod i18n_handler;
|
||||||
pub mod magic_link_handler;
|
pub mod magic_link_handler;
|
||||||
pub mod music_handler;
|
pub mod music_handler;
|
||||||
|
pub mod people_handler;
|
||||||
pub mod photos_handler;
|
pub mod photos_handler;
|
||||||
pub mod recent_handler;
|
pub mod recent_handler;
|
||||||
pub mod search_handler;
|
pub mod search_handler;
|
||||||
|
|||||||
@@ -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<Arc<AppState>>, 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<Arc<AppState>>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PATCH /api/people/{id} — name (or clear the name of) a person.
|
||||||
|
pub async fn rename_person(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(body): Json<RenameBody>,
|
||||||
|
) -> 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<Arc<AppState>>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Json(body): Json<HideBody>,
|
||||||
|
) -> 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<Arc<AppState>>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Json(body): Json<MergeBody>,
|
||||||
|
) -> 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<Arc<AppState>>, 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<Arc<AppState>>, 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<Arc<AppState>>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Path(file_id): Path<String>,
|
||||||
|
) -> 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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ use axum::{
|
|||||||
extract::{DefaultBodyLimit, State},
|
extract::{DefaultBodyLimit, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Json as AxumJson, Response},
|
response::{IntoResponse, Json as AxumJson, Response},
|
||||||
routing::{any, delete, get, post, put},
|
routing::{any, delete, get, patch, post, put},
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -440,6 +440,24 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
|||||||
router = router.nest("/photos", photos_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
|
// Re-enable trash routes to make the trash view work
|
||||||
if let Some(_trash_service_ref) = trash_service.clone() {
|
if let Some(_trash_service_ref) = trash_service.clone() {
|
||||||
tracing::info!("Setting up trash routes for trash view");
|
tracing::info!("Setting up trash routes for trash view");
|
||||||
|
|||||||
Reference in New Issue
Block a user