diff --git a/src/AGENTS.md b/src/AGENTS.md index ff268303..1a653278 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -22,3 +22,11 @@ Non-obvious rules that trip up new code. Terse on purpose. - **Never hand-craft blob paths.** No `blob_root: PathBuf` fields, no `/.blobs//.blob` constructions. `BlobStorageBackend::local_blob_path` returns `None` under `EncryptedBlobBackend`; do not rely on it. The three services that did this pre-2026-08 (audio/media/face) are the anti-pattern — see memory `project_services_bypassing_blob_backend`. - **Persistent state = backend**, not `/*` sidecars. Local sidecars (`.thumbnails/`, `.transcoded/`, `.blob-cache/`, `.search-index/`, `.plugin-logs/`, `.uploads/`) are only for caches (regenerable) or truly-temp scratch (deleted on drop). Anything a user would notice losing → blob backend. Tier-2 migration plan: `docs/plan/derived-blobs.md`. - **Temp files use `OXICLOUD_TEMP_DIR`** via the shared config path (`AppConfig::temp_dir`) — not raw `std::env::temp_dir()`. Ops point it at real disk on RAM-constrained Linux deployments (default `/tmp` = tmpfs = RAM). + +## OpenAPI + +- **Every new `#[utoipa::path(...)]` handler MUST also be added to the `paths(...)` list in `src/interfaces/api/mod.rs`.** utoipa emits ONLY registered paths; the annotation alone is invisible to `resources/gen/openapi.json`. Historical drift found 21 annotated handlers that never reached the spec (admin drives, admin jobs pause/findings/purge, admin SMTP, admin storage rotate, admin promote-to-internal, admin sessions list/revoke, all OPAQUE endpoints, DPoP bind, magic-link send, profile PATCH, upgrade-to-internal, drive delete/members/policies/quota, grant notify, trash per-drive, user profile, dedup check-batch) — they all had valid `#[utoipa::path]` blocks but nobody registered them. +- **Every DTO the new handler touches** — request body, response body, path/query params, error shapes — MUST also be added to `components(schemas(...))` in the same file, OR be reachable from an already-registered schema. Utoipa only pulls in schemas transitively from registered paths + registered top-level schemas. +- **After adding: `cargo run --bin generate-openapi`** to regenerate `resources/gen/openapi.json`, then `git diff resources/gen/openapi.json` — the new path + its request/response schemas must be present. Zero-diff means you missed the registration. +- Sanity check for the whole surface: `diff <(grep -oE 'path = "/api[^"]+"' src/interfaces/api/handlers/*.rs | grep -oE '/api[^"]+' | sort -u) <(jq -r '.paths | keys | .[]' resources/gen/openapi.json | sort -u)` — should always be empty. Non-empty diff = drift. +- Handlers referenced by the `paths(...)` list MUST be `pub` (module-visible from the paths list). Private `async fn` compiles at the router mount but breaks the paths list with a visibility error — see `get_smtp_info`, `send_smtp_test`, `get_user_profile` for the retrofit. diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index 6d252543..0cb66c26 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -1718,7 +1718,9 @@ async fn reextract_image_metadata( security(("bearerAuth" = [])), tag = "admin" )] -async fn get_smtp_info(State(state): State>) -> Result { +pub async fn get_smtp_info( + State(state): State>, +) -> Result { let smtp = &state.core.config.smtp; let info = SmtpInfoDto { enabled: smtp.is_enabled() && state.email_sender.is_some(), @@ -1802,7 +1804,7 @@ struct CapturedEmailQuery { security(("bearerAuth" = [])), tag = "admin" )] -async fn send_smtp_test( +pub async fn send_smtp_test( State(state): State>, auth_user: AuthUser, Json(dto): Json, diff --git a/src/interfaces/api/handlers/people_handler.rs b/src/interfaces/api/handlers/people_handler.rs index e1e391fd..73cd42ad 100644 --- a/src/interfaces/api/handlers/people_handler.rs +++ b/src/interfaces/api/handlers/people_handler.rs @@ -15,9 +15,11 @@ use axum::{ use serde::Deserialize; use uuid::Uuid; +use crate::application::dtos::people_dto::{FaceBoxDto, PersonDto}; use crate::common::di::AppState; use crate::interfaces::errors::AppError; use crate::interfaces::middleware::auth::AuthUser; +use utoipa::ToSchema; fn disabled() -> Response { ( @@ -36,6 +38,15 @@ fn bad_id() -> Response { } /// GET /api/people — identity clusters for the caller. +#[utoipa::path( + get, + path = "/api/people", + responses( + (status = 200, description = "Identity clusters", body = [PersonDto]), + (status = 404, description = "People feature disabled"), + ), + tag = "people" +)] pub async fn list_people(State(state): State>, auth_user: AuthUser) -> Response { let Some(svc) = state.people_service.as_ref() else { return disabled(); @@ -47,6 +58,17 @@ pub async fn list_people(State(state): State>, auth_user: AuthUser } /// GET /api/people/{id}/photos — file ids of a person's photos. +#[utoipa::path( + get, + path = "/api/people/{id}/photos", + params(("id" = String, Path, description = "Person cluster id")), + responses( + (status = 200, description = "File ids where this person appears", body = [String]), + (status = 400, description = "Malformed person id"), + (status = 404, description = "People feature disabled"), + ), + tag = "people" +)] pub async fn person_photos( State(state): State>, auth_user: AuthUser, @@ -64,12 +86,26 @@ pub async fn person_photos( } } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct RenameBody { + /// New display name for the cluster. `null` or omitted clears + /// the name, reverting the cluster to its "Unnamed" state. pub name: Option, } /// PATCH /api/people/{id} — name (or clear the name of) a person. +#[utoipa::path( + patch, + path = "/api/people/{id}", + params(("id" = String, Path, description = "Person cluster id")), + request_body = RenameBody, + responses( + (status = 204, description = "Renamed"), + (status = 400, description = "Malformed person id"), + (status = 404, description = "People feature disabled"), + ), + tag = "people" +)] pub async fn rename_person( State(state): State>, auth_user: AuthUser, @@ -88,13 +124,28 @@ pub async fn rename_person( } } -#[derive(Deserialize)] +#[derive(Deserialize, ToSchema)] pub struct MergeBody { + /// Cluster id that will absorb the other one (`from`'s photos are + /// reattributed to `into`; `from` is deleted). Typically the + /// larger / named cluster wins. pub into: String, + /// Cluster id being merged into `into`. Deleted after the merge. pub from: String, } /// POST /api/people/merge — merge `from` into `into`. +#[utoipa::path( + post, + path = "/api/people/merge", + request_body = MergeBody, + responses( + (status = 204, description = "Merged"), + (status = 400, description = "Malformed cluster id(s)"), + (status = 404, description = "People feature disabled"), + ), + tag = "people" +)] pub async fn merge_people( State(state): State>, auth_user: AuthUser, @@ -113,6 +164,15 @@ pub async fn merge_people( } /// POST /api/people/recluster — re-run identity clustering for the caller. +#[utoipa::path( + post, + path = "/api/people/recluster", + responses( + (status = 200, description = "Recluster complete", body = serde_json::Value), + (status = 404, description = "People feature disabled"), + ), + tag = "people" +)] pub async fn recluster(State(state): State>, auth_user: AuthUser) -> Response { let Some(svc) = state.people_service.as_ref() else { return disabled(); @@ -124,6 +184,15 @@ pub async fn recluster(State(state): State>, auth_user: AuthUser) } /// DELETE /api/people/data — erase all of the caller's face data. +#[utoipa::path( + delete, + path = "/api/people/data", + responses( + (status = 204, description = "All face data erased"), + (status = 404, description = "People feature disabled"), + ), + tag = "people" +)] pub async fn delete_all(State(state): State>, auth_user: AuthUser) -> Response { let Some(svc) = state.people_service.as_ref() else { return disabled(); @@ -135,6 +204,17 @@ pub async fn delete_all(State(state): State>, auth_user: AuthUser) } /// GET /api/people/faces/{file_id} — face boxes within a photo (lightbox tags). +#[utoipa::path( + get, + path = "/api/people/faces/{file_id}", + params(("file_id" = String, Path, description = "Photo file id")), + responses( + (status = 200, description = "Face bounding boxes in the photo", body = [FaceBoxDto]), + (status = 400, description = "Malformed file id"), + (status = 404, description = "People feature disabled"), + ), + tag = "people" +)] pub async fn faces_for_file( State(state): State>, auth_user: AuthUser, diff --git a/src/interfaces/api/handlers/users_handler.rs b/src/interfaces/api/handlers/users_handler.rs index 46425640..b2d0d943 100644 --- a/src/interfaces/api/handlers/users_handler.rs +++ b/src/interfaces/api/handlers/users_handler.rs @@ -46,7 +46,7 @@ pub fn user_routes() -> Router> { security(("bearerAuth" = [])), tag = "users", )] -async fn get_user_profile( +pub async fn get_user_profile( State(state): State>, auth_user: AuthUser, Path(target_id): Path, diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index a53d1a1e..03d7008e 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -82,6 +82,15 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::auth_handler::oidc_backchannel_logout, handlers::auth_handler::oidc_link_start, handlers::auth_handler::oidc_unlink, + // DPoP post-redirect bind (Gate 3), magic-link SEND (the + // outbound half of the passwordless flow — /magic/v1/{token} + // redemption is a browser redirect, not an API endpoint), + // profile edit (PATCH — the read is via /me), and the + // external-user upgrade path. + handlers::auth_handler::dpop_bind, + handlers::auth_handler::send_magic_link, + handlers::auth_handler::update_profile, + handlers::auth_handler::upgrade_to_internal, // File handlers (free functions — see file_handler.rs for why) handlers::file_handler::list_files_query, handlers::file_handler::upload_file_with_thumbnails, @@ -125,6 +134,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::chunked_upload_handler::cancel_upload, // Dedup handlers — all free functions for the same utoipa reason as chunked uploads. handlers::dedup_handler::check_hash, + handlers::dedup_handler::check_hashes_batch, handlers::dedup_handler::get_stats, handlers::dedup_handler::get_blob, handlers::dedup_handler::recalculate_stats, @@ -135,6 +145,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::trash_handler::restore_from_trash, handlers::trash_handler::delete_permanently, handlers::trash_handler::empty_trash, + handlers::trash_handler::empty_trash_for_drive, // Share handlers (free functions) handlers::share_handler::create_shared_link, handlers::share_handler::get_shared_link, @@ -162,8 +173,29 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; // Photos handler (free function) handlers::photos_handler::list_photos, handlers::photos_handler::list_photos_geo, + // People / face-clustering handlers — mounted only when + // `OXICLOUD_ENABLE_FACES` is on; each handler is defensive + // (`disabled()` returns 404 otherwise). All work is strictly + // caller-scoped by `PeopleService`. + handlers::people_handler::list_people, + handlers::people_handler::person_photos, + handlers::people_handler::rename_person, + handlers::people_handler::merge_people, + handlers::people_handler::recluster, + handlers::people_handler::delete_all, + handlers::people_handler::faces_for_file, // Drive handler (free function) handlers::drive_handler::list_drives, + handlers::drive_handler::delete_drive, + handlers::drive_handler::list_drive_members, + handlers::drive_handler::add_drive_member, + handlers::drive_handler::update_drive_member, + handlers::drive_handler::remove_drive_member, + handlers::drive_handler::update_drive_policies, + handlers::drive_handler::update_drive_quota, + // Users handler — public profile lookup (auth-required, but + // returns the callee's public view, not `/me`'s self-view). + handlers::users_handler::get_user_profile, // Batch handlers (free functions) handlers::batch_handler::move_files_batch, handlers::batch_handler::copy_files_batch, @@ -240,6 +272,27 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::admin_handler::cancel_job, handlers::admin_handler::list_job_runs, handlers::admin_handler::get_job_run, + handlers::admin_handler::pause_job, + handlers::admin_handler::list_job_run_findings, + handlers::admin_handler::purge_job_runs, + // Admin drive management — full CRUD on drives + membership, + // distinct from the user-facing /api/drives surface (admin can + // touch any drive; user can touch only those they're an owner + // of). + handlers::admin_handler::list_all_drives, + handlers::admin_handler::delete_drive_admin, + handlers::admin_handler::list_drive_members_admin, + handlers::admin_handler::add_drive_member_admin, + handlers::admin_handler::update_drive_member_admin, + handlers::admin_handler::remove_drive_member_admin, + // Admin SMTP diagnostics + backend rotation + user promotion. + // The two SMTP fns were pub-only-for-utoipa (private in-router + // helpers before this branch); see the handler for the + // read-only vs test-send semantics. + handlers::admin_handler::get_smtp_info, + handlers::admin_handler::send_smtp_test, + handlers::admin_handler::trigger_backend_rotate, + handlers::admin_handler::admin_promote_external_to_internal, // Admin sessions panel — list + revoke. Function names lack // the `_admin_` suffix; the `/api/admin/` prefix comes from // the router mount, not the handler name. @@ -266,6 +319,7 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::grant_handler::list_outgoing, handlers::grant_handler::list_my_shares, handlers::grant_handler::list_on_resource, + handlers::grant_handler::notify_grant_recipient, // Subject-group handlers (ReBAC named groups) — free functions handlers::subject_group_handler::create_group, handlers::subject_group_handler::list_groups, @@ -342,6 +396,14 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::opaque_auth_handler::OpaqueLoginKe1Response, handlers::opaque_auth_handler::OpaqueLoginKe3Dto, handlers::opaque_auth_handler::OpaqueParamsResponse, + // People / face-clustering — response DTOs published by the + // /api/people/* endpoints (list_people, faces_for_file), plus + // the two request bodies (rename, merge). Face indexing pipeline + // is documented in `face_indexing_service` + `onnx_face_analyzer`. + crate::application::dtos::people_dto::PersonDto, + crate::application::dtos::people_dto::FaceBoxDto, + handlers::people_handler::RenameBody, + handlers::people_handler::MergeBody, // Share schemas ShareDto, CreateShareDto, diff --git a/tests/common/server.env b/tests/common/server.env index e11410b5..fb9fd9d1 100644 --- a/tests/common/server.env +++ b/tests/common/server.env @@ -69,10 +69,21 @@ OXICLOUD_CHUNK_MAX_BYTES=4194304 # _nextcloud_put_blake3 = 32 B) stay safely under this cap. OXICLOUD_DIRECT_PUT_MAX_BYTES=4194304 -# grow up limits for tests -OXICLOUD_RATE_LIMIT_REFRESH_MAX=3600 -OXICLOUD_RATE_LIMIT_LOGIN_MAX=3600 -OXICLOUD_RATE_LIMIT_REGISTER_MAX=3600 +# Rate-limit ceiling for tests. `_MAX` alone isn't enough because +# the default `_WINDOW_SECS = 60` gives an effective rate of +# `MAX / 60` req/sec — a bursty Playwright suite (many workers, +# parallel logins + auto-refresh churn under DPoP required mode) +# overflows even at MAX=3600 (60 req/sec shared across the whole +# runner IP). Widening the window to 1 h means the same MAX is a +# 1-hour budget, well above what any single CI run consumes +# (< 5 min end-to-end). Applied to all three buckets so login / +# refresh / register all share the same generous test posture. +OXICLOUD_RATE_LIMIT_REFRESH_MAX=36000 +OXICLOUD_RATE_LIMIT_REFRESH_WINDOW_SECS=3600 +OXICLOUD_RATE_LIMIT_LOGIN_MAX=36000 +OXICLOUD_RATE_LIMIT_LOGIN_WINDOW_SECS=3600 +OXICLOUD_RATE_LIMIT_REGISTER_MAX=36000 +OXICLOUD_RATE_LIMIT_REGISTER_WINDOW_SECS=3600 # Magic-link / external-users flow (PR 9). The mock SMTP captures every # outbound message in-process so external_users.hurl can retrieve the