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` 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) -> Router> { // 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>, Path((_url_user, subpath)): Path<(String, String)>, session: SharedNcSession, req: Request, ) -> Result { 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>, Path(_url_user): Path, session: SharedNcSession, req: Request, ) -> Result { 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>, Path((_url_user, upload_id, rest)): Path<(String, String, String)>, session: SharedNcSession, req: Request, ) -> Result { 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>, Path((_url_user, upload_id)): Path<(String, String)>, session: SharedNcSession, req: Request, ) -> Result { 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, 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>, Path((_url_user, subpath)): Path<(String, String)>, session: SharedNcSession, req: Request, ) -> Result { 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>, Path(_url_user): Path, session: SharedNcSession, req: Request, ) -> Result { 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() }