fdf445d2b0
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
330 lines
12 KiB
Rust
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()
|
|
}
|