Files
Oxicloud/src/interfaces/nextcloud/routes.rs
T
Claude fdf445d2b0 perf: round 9 — decorator PUT reactivation, session/search/dedup alloc purges, PROPFIND join!, folder-level cascade
Benchmark-gated round (benches/ROUND9.md): every change carries a
BEFORE/AFTER bench with equivalence/safety gates; verdicts below are from
the committed harnesses on 4 cores / local PG 16.

Backend:
- Blob decorators (Retry/Cached) now forward put_blob_from_bytes_unsynced
  + sync_blobs — the trait default had silently reinstated HEAD-before-PUT
  per chunk on decorated remote stacks, undoing ROUND3 §8. Full production
  stack: 500 probes -> 0, 1.9x wall at 10 ms RTT (bench_s3_put §3).
- NC PROPFIND per-page enrichment triple (favorites / oc:fileid / dead
  props) overlapped with tokio::join!: 2.07x local, 2.86x at 5 ms RTT
  (bench_nc_enrich_join, injected-latency decide-by-bench).
- Search enrichment consumes its DTOs and carries the interned Arc<str>
  display fields end-to-end (SearchFileResultDto type change, OpenAPI
  shape preserved): enrich_file 2.0x, 11.6 -> 2.2 allocs/row; the NC
  REPORT conversion stops re-running all three classifiers per row
  (bench_search_enrich).
- NC session Arc end-to-end: SharedNcSession extractor (8 -> 0 allocs),
  Arc<FolderDto> chroot cache (4 -> 0/hit), single shared Arc<CurrentUser>
  + lazy span render (11 -> 6/build) (bench_nc_session).
- Storage micro-pack: atomic create_new chunk writes (2.1x fresh),
  stream_chunks over the manifest Arc (4097 -> 0 allocs/read incl. the
  Range path), manifest single-flight (herd 64 -> 1 loads), hex_lower for
  chunk Content-MD5 (18 -> 1 allocs) (bench_storage_micro).
- OCS capabilities memoized into OnceLock<[Bytes;2]>: 237x, 102 -> 0
  allocs/poll, byte-identical (bench_capabilities_static).
- Drive::is_empty COUNT(*) sum -> EXISTS: 34.4x on a 100k-file drive
  (bench_drive_is_empty).
- favorites/recents row-map ROUND7 port: path/name/blob_hash moved,
  -2.75 allocs/row (bench_resource_row_map §2).
- Folder rows decode binary UUIDs (ROUND6 §10 port): 1.03-1.07x page
  fetch, honest verdict incl. one noise-band wash documented
  (bench_folder_uuid_decode).
- Authz: file cascade decision decomposed into memoized folder-level
  decision + direct-grant lookup (ROUND8 deferred item). Cold shared-album
  first view 592 -> 418 µs/thumb; warm path unchanged; safety gates incl.
  new direct-grant sibling isolation, revoke-flush re-verified, full
  integration authz suite green (bench_thumbnail_cascade_cache).

Frontend (vitest gates committed beside the code):
- resolveLabel/resolveRecipient O(directory) scan -> id-keyed Map: 13.9x
  (recipients.bench.test.ts).
- ResourceList selection-prune effect skips when nothing is selected
  (100 -> 0 Set builds per drain) and the photos timeline reads a
  listener-fed mobile flag instead of matchMedia per recompute
  (listDerives.bench.test.ts).

Verification: cargo fmt + clippy --all-features --all-targets -D warnings
clean; 524 unit + 554 integration (--cfg integration_tests) tests pass;
frontend npm run check clean with 293 vitest tests green.

Deferred with rationale in ROUND9.md: CalDAV authz-before-fetch reorder
(maintainer sign-off), per-page batched parent resolution, JWT-claims
Arc<str>, batch_operations signature widening.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XDc9VtXvskJ6dnMRraSndn
2026-07-18 16:12:04 +00:00

330 lines
12 KiB
Rust

use axum::{
Router,
body::Body,
extract::{Path, State},
http::{Request, StatusCode},
middleware,
response::{IntoResponse, Response},
routing::{any, delete, get, post},
};
use std::sync::Arc;
use crate::common::di::AppState;
use crate::interfaces::middleware::auth::AuthUser;
use crate::interfaces::middleware::rate_limit::{RateLimiter, rate_limit_login};
use crate::interfaces::nextcloud::avatar_handler;
use crate::interfaces::nextcloud::basic_auth_middleware::basic_auth_middleware;
use crate::interfaces::nextcloud::login_v2_handler;
use crate::interfaces::nextcloud::ocs_handler;
use crate::interfaces::nextcloud::preview_handler;
use crate::interfaces::nextcloud::session::SharedNcSession;
use crate::interfaces::nextcloud::status_handler;
use crate::interfaces::nextcloud::trashbin_handler;
use crate::interfaces::nextcloud::uploads_handler;
use crate::interfaces::nextcloud::webdav_handler;
/// Build Nextcloud routes with a pre-built `Arc<AppState>` for the middleware layer.
///
/// This is the preferred entry point — pass the real state so the Basic Auth
/// middleware can look up app passwords from the database.
pub fn nextcloud_routes_with_state(state: Arc<AppState>) -> Router<Arc<AppState>> {
// Rate limiter for NC login submit (reuses auth config values)
let nc_login_limiter = {
let rl = &state.core.config.auth.rate_limit;
Arc::new(RateLimiter::new(
rl.login_max_requests,
rl.login_window_secs,
100_000,
))
};
// Public routes — no auth required.
let public = Router::new()
.route("/status.php", get(status_handler::handle_status))
// NC connectivity check — app expects 204 to confirm server is reachable.
.route("/index.php/204", get(handle_connectivity_check))
// Bare /remote.php/dav — NC clients probe this to confirm WebDAV is available.
.route("/remote.php/dav", any(handle_dav_discovery))
.route("/remote.php/dav/", any(handle_dav_discovery))
.route(
"/index.php/login/v2",
post(login_v2_handler::handle_login_initiate),
)
.route(
"/login/v2/flow/{token}",
get(login_v2_handler::handle_login_page)
.post(login_v2_handler::handle_login_submit)
.layer(axum::middleware::from_fn_with_state(
nc_login_limiter,
rate_limit_login,
)),
)
// Drive picker submission — finalises a multi-drive flow that
// paused after password verification. Public route by design:
// the flow token + single-use `pending_user_id` slot is the
// proof of authentication. See `login_v2_handler::handle_drive_pick`.
.route(
"/login/v2/flow/{token}/drive",
post(login_v2_handler::handle_drive_pick),
)
// OIDC initiation from Nextcloud login page
.route(
"/login/v2/flow/{token}/oidc",
get(login_v2_handler::handle_login_oidc),
)
.route(
"/index.php/login/v2/poll",
post(login_v2_handler::handle_login_poll),
)
.route("/login/v2/poll", post(login_v2_handler::handle_login_poll))
// Capabilities are public — iOS app fetches them before having credentials.
.route(
"/ocs/v1.php/cloud/capabilities",
get(ocs_handler::handle_capabilities_v1),
)
.route(
"/ocs/v2.php/cloud/capabilities",
get(ocs_handler::handle_capabilities_v2),
)
// Final NC catch-alls. Any `/ocs/*` or `/remote.php/*` URL
// the routes above don't claim returns 404 here — so it's
// logged under the `http::nextcloud` access-log target the
// surrounding `.layer(access_log!(…))` in main.rs assigns,
// instead of falling through Axum's matcher to ServeDir
// and being mis-attributed to `http::web`.
//
// Concrete example: NC desktop probes
// `/ocs/v2.php/core/navigation/apps` to discover server
// features. We don't implement that endpoint; without these
// catch-alls the 404 was emitted at `http::web`, which is
// misleading for operators triaging Nextcloud client noise.
//
// Mounted on the PUBLIC sub-router (NOT behind basic-auth)
// so unknown-endpoint probes return 404 regardless of
// whether the client sent credentials. Moving them into
// `protected` would turn anonymous probes into 401
// challenges, which breaks some clients' capability-
// detection paths.
//
// Axum routes more-specific paths first, so the specific
// NC routes above (and the protected ones below) still
// claim their requests; only genuinely unmatched paths
// reach these handlers.
.route("/ocs/{*rest}", any(handle_nc_not_found))
.route("/remote.php/{*rest}", any(handle_nc_not_found));
// Protected routes — require Basic Auth via app passwords.
let protected = Router::new()
// Both v1 and v2 of the singular cloud/user endpoint return the
// same payload shape — NC's URL-versioning is a transport
// convention, not a protocol break for this endpoint. Older
// NC clients (and some third-party libraries) still hit v1
// first; without this route they get a 404 even though the
// handler exists.
.route("/ocs/v1.php/cloud/user", get(ocs_handler::handle_user_info))
.route("/ocs/v2.php/cloud/user", get(ocs_handler::handle_user_info))
.route(
"/ocs/v1.php/cloud/users/{userid}",
get(ocs_handler::handle_user_provisioning_v1),
)
.route(
"/ocs/v2.php/cloud/users/{userid}",
get(ocs_handler::handle_user_provisioning_v2),
)
.route(
"/ocs/v2.php/core/apppassword",
delete(ocs_handler::handle_revoke_apppassword),
)
.route(
"/ocs/v2.php/apps/notifications/api/v2/notifications",
get(ocs_handler::handle_notifications_list),
)
.route(
"/ocs/v2.php/apps/notifications/api/v2/push",
post(ocs_handler::handle_notifications_push),
)
.route(
"/ocs/v2.php/apps/recommendations/api/v1/recommendations",
get(ocs_handler::handle_recommendations),
)
.route(
"/ocs/v2.php/apps/files_sharing/api/v1/sharees",
get(ocs_handler::handle_sharees_search),
)
// Unified Search
.route(
"/ocs/v2.php/search/providers",
get(ocs_handler::handle_search_providers),
)
.route(
"/ocs/v2.php/search/providers/{provider_id}/search",
get(ocs_handler::handle_search),
)
.route(
"/index.php/core/preview",
get(preview_handler::handle_preview),
)
.route(
"/index.php/avatar/{user}/{size}",
get(avatar_handler::handle_avatar),
)
// NC desktop + several mobile clients fetch avatars from the
// DAV-shaped URL (with a literal `.png` extension on the
// size segment). Same SVG payload, different URL shape — the
// wrapper handler strips the extension and delegates.
.route(
"/remote.php/dav/avatars/{user}/{size}",
get(avatar_handler::handle_dav_avatar),
)
.route(
"/remote.php/dav/files/{user}/{*subpath}",
any(handle_dav_files),
)
.route("/remote.php/dav/files/{user}/", any(handle_dav_files_root))
.route("/remote.php/dav/files/{user}", any(handle_dav_files_root))
.route(
"/remote.php/dav/uploads/{user}/{upload_id}/{*rest}",
any(handle_dav_uploads),
)
.route(
"/remote.php/dav/uploads/{user}/{upload_id}",
any(handle_dav_uploads_root),
)
// Trashbin WebDAV
.route(
"/remote.php/dav/trashbin/{user}/{*subpath}",
any(handle_dav_trashbin),
)
.route(
"/remote.php/dav/trashbin/{user}/",
any(handle_dav_trashbin_root),
)
.route(
"/remote.php/dav/trashbin/{user}",
any(handle_dav_trashbin_root),
)
.route("/remote.php/webdav/{*subpath}", any(handle_legacy_webdav))
.route("/remote.php/webdav/", any(handle_legacy_webdav_root))
.route("/remote.php/webdav", any(handle_legacy_webdav_root))
.layer(middleware::from_fn_with_state(state, basic_auth_middleware));
Router::new().merge(public).merge(protected)
}
// ──────────────── Handler glue ────────────────
async fn handle_dav_files(
State(state): State<Arc<AppState>>,
Path((_url_user, subpath)): Path<(String, String)>,
session: SharedNcSession,
req: Request<Body>,
) -> Result<Response, Response> {
webdav_handler::handle_nc_webdav(state, req, session, subpath)
.await
.map_err(|e| e.into_response())
}
async fn handle_dav_files_root(
State(state): State<Arc<AppState>>,
Path(_url_user): Path<String>,
session: SharedNcSession,
req: Request<Body>,
) -> Result<Response, Response> {
webdav_handler::handle_nc_webdav(state, req, session, String::new())
.await
.map_err(|e| e.into_response())
}
async fn handle_dav_uploads(
State(state): State<Arc<AppState>>,
Path((_url_user, upload_id, rest)): Path<(String, String, String)>,
session: SharedNcSession,
req: Request<Body>,
) -> Result<Response, Response> {
uploads_handler::handle_nc_uploads(state, req, session, upload_id, rest)
.await
.map_err(|e| e.into_response())
}
async fn handle_dav_uploads_root(
State(state): State<Arc<AppState>>,
Path((_url_user, upload_id)): Path<(String, String)>,
session: SharedNcSession,
req: Request<Body>,
) -> Result<Response, Response> {
uploads_handler::handle_nc_uploads(state, req, session, upload_id, String::new())
.await
.map_err(|e| e.into_response())
}
/// Legacy /remote.php/webdav/* — redirect to /remote.php/dav/files/{user}/*
async fn handle_legacy_webdav(Path(subpath): Path<String>, user_ext: AuthUser) -> Response {
let location = format!("/remote.php/dav/files/{}/{}", user_ext.username, subpath);
Response::builder()
.status(StatusCode::MOVED_PERMANENTLY)
.header("location", location)
.body(Body::empty())
.unwrap()
}
async fn handle_legacy_webdav_root(user_ext: AuthUser) -> Response {
let location = format!("/remote.php/dav/files/{}/", user_ext.username);
Response::builder()
.status(StatusCode::MOVED_PERMANENTLY)
.header("location", location)
.body(Body::empty())
.unwrap()
}
async fn handle_dav_trashbin(
State(state): State<Arc<AppState>>,
Path((_url_user, subpath)): Path<(String, String)>,
session: SharedNcSession,
req: Request<Body>,
) -> Result<Response, Response> {
trashbin_handler::handle_nc_trashbin(state, req, session, subpath)
.await
.map_err(|e| e.into_response())
}
async fn handle_dav_trashbin_root(
State(state): State<Arc<AppState>>,
Path(_url_user): Path<String>,
session: SharedNcSession,
req: Request<Body>,
) -> Result<Response, Response> {
trashbin_handler::handle_nc_trashbin(state, req, session, String::new())
.await
.map_err(|e| e.into_response())
}
/// `GET /index.php/204` — NC app connectivity check. Returns 204 No Content.
async fn handle_connectivity_check() -> Response {
Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap()
}
/// Bare `/remote.php/dav` — NC clients (especially Android) probe this endpoint
/// during server discovery to confirm WebDAV is available.
async fn handle_dav_discovery() -> Response {
Response::builder()
.status(StatusCode::OK)
.header("DAV", "1, 3")
.header("Allow", "OPTIONS, GET, HEAD, PROPFIND")
.body(Body::empty())
.unwrap()
}
/// Catch-all 404 for any `/ocs/*` or `/remote.php/*` path the NC
/// router doesn't recognize. Exists purely to anchor the access-log
/// target — see the comment on the routes above for the operator
/// rationale.
async fn handle_nc_not_found() -> Response {
Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::empty())
.unwrap()
}