feat(nextcloud): add Nextcloud-compatible API layer
Implement a complete Nextcloud client compatibility layer so that Nextcloud desktop/mobile sync clients can connect to OxiCloud. Key additions: - Login Flow v2 (device auth) with OIDC bridge support - WebDAV handler compatible with Nextcloud clients (PROPFIND, GET, PUT, DELETE, MKCOL, MOVE, COPY, HEAD, PROPPATCH) - OCS API endpoints (user info, capabilities, notifications stubs, sharees, unified search) - Basic Auth middleware with app password verification, account lockout integration, and blake3-keyed auth cache - App password management: create, list, revoke via both native API (JWT-authenticated profile page) and Nextcloud OCS endpoints - Nextcloud file ID mapping (oc:fileid) with persistent DB storage - Chunked upload support (Nextcloud v2 chunking protocol) - Trashbin WebDAV interface - Avatar (SVG placeholder) and preview (redirect) handlers - User profile page with app password management UI - URL user validation on all DAV routes (403 on mismatch) - Database schema for app_passwords and nextcloud_object_ids tables All services are behind a `nextcloud.enabled` config flag and cleanly separated under src/interfaces/nextcloud/. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,277 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{Html, IntoResponse, Json, Response},
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::common::di::AppState;
|
||||
use crate::common::errors::DomainError;
|
||||
|
||||
/// Serve an HTML page with a Content-Security-Policy header as defense-in-depth.
|
||||
fn html_with_csp(html: &'static str) -> Response {
|
||||
(
|
||||
[(
|
||||
header::CONTENT_SECURITY_POLICY,
|
||||
"default-src 'none'; script-src 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; form-action 'self'",
|
||||
)],
|
||||
Html(html),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn handle_login_initiate(State(state): State<Arc<AppState>>) -> Response {
|
||||
let nextcloud = match state.nextcloud.as_ref() {
|
||||
Some(nextcloud) => nextcloud,
|
||||
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||
};
|
||||
|
||||
let base_url = state.core.config.base_url();
|
||||
let flow = match nextcloud.login_flow.initiate(&base_url) {
|
||||
Ok(flow) => flow,
|
||||
Err(_) => {
|
||||
tracing::warn!("Login Flow v2: too many pending flows, rejecting");
|
||||
return StatusCode::TOO_MANY_REQUESTS.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
tracing::info!(
|
||||
base_url = %base_url,
|
||||
login_url = %flow.login_url,
|
||||
poll_endpoint = %flow.poll_endpoint,
|
||||
"Login Flow v2 initiated"
|
||||
);
|
||||
|
||||
Json(json!({
|
||||
"poll": {
|
||||
"token": flow.poll_token,
|
||||
"endpoint": flow.poll_endpoint,
|
||||
},
|
||||
"login": flow.login_url,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn handle_login_poll(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
body: String,
|
||||
) -> Response {
|
||||
let nextcloud = match state.nextcloud.as_ref() {
|
||||
Some(nextcloud) => nextcloud,
|
||||
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||
};
|
||||
|
||||
let content_type = headers
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("(none)");
|
||||
|
||||
tracing::debug!(
|
||||
body = %body,
|
||||
content_type = %content_type,
|
||||
query_has_token = query.contains_key("token"),
|
||||
"Login Flow v2 poll request"
|
||||
);
|
||||
|
||||
// Try to extract token from multiple sources:
|
||||
// 1. Form-encoded body (token=xxx)
|
||||
// 2. JSON body ({"token": "xxx"})
|
||||
// 3. Query parameter (?token=xxx)
|
||||
let token = parse_form_value(&body, "token")
|
||||
.or_else(|| {
|
||||
serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("token")?.as_str().map(String::from))
|
||||
})
|
||||
.or_else(|| query.get("token").cloned());
|
||||
|
||||
let token = match token {
|
||||
Some(token) => token,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
body = %body,
|
||||
content_type = %content_type,
|
||||
"Login Flow v2 poll: could not extract token from body, JSON, or query"
|
||||
);
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
match nextcloud.login_flow.poll(&token) {
|
||||
Some(result) => {
|
||||
tracing::info!(
|
||||
login_name = %result.login_name,
|
||||
server = %result.server,
|
||||
"Login Flow v2 poll: returning completed credentials"
|
||||
);
|
||||
Json(json!({
|
||||
"server": result.server,
|
||||
"loginName": result.login_name,
|
||||
"appPassword": result.app_password,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("Login Flow v2 poll: not yet completed");
|
||||
StatusCode::NOT_FOUND.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_login_page(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(token): Path<String>,
|
||||
) -> Response {
|
||||
let nextcloud = match state.nextcloud.as_ref() {
|
||||
Some(nextcloud) => nextcloud,
|
||||
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||
};
|
||||
|
||||
if !nextcloud.login_flow.flow_exists(&token) {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
|
||||
html_with_csp(include_str!("../../../static/nextcloud-login.html"))
|
||||
}
|
||||
|
||||
pub async fn handle_login_submit(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(token): Path<String>,
|
||||
body: String,
|
||||
) -> Response {
|
||||
let nextcloud = match state.nextcloud.as_ref() {
|
||||
Some(nextcloud) => nextcloud,
|
||||
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||
};
|
||||
|
||||
let params = parse_form(&body);
|
||||
let username = match params.get("user") {
|
||||
Some(value) if !value.is_empty() => value,
|
||||
_ => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
let password = match params.get("password") {
|
||||
Some(value) if !value.is_empty() => value,
|
||||
_ => return StatusCode::BAD_REQUEST.into_response(),
|
||||
};
|
||||
|
||||
let auth = match state.auth_service.as_ref() {
|
||||
Some(auth) => auth,
|
||||
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||
};
|
||||
|
||||
let current_user = match auth
|
||||
.auth_application_service
|
||||
.verify_credentials(username, password)
|
||||
.await
|
||||
{
|
||||
Ok(user) => user,
|
||||
Err(e) => return login_failed_response(e),
|
||||
};
|
||||
|
||||
let app_password = match nextcloud
|
||||
.app_passwords
|
||||
.create_nc(¤t_user.id, "Nextcloud")
|
||||
.await
|
||||
{
|
||||
Ok((_id, password)) => password,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, user = %current_user.username, "Login Flow v2: failed to create app password");
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let base_url = state.core.config.base_url();
|
||||
let completed =
|
||||
nextcloud
|
||||
.login_flow
|
||||
.complete(&token, ¤t_user.username, &base_url, &app_password);
|
||||
|
||||
if completed {
|
||||
tracing::info!(
|
||||
user = %current_user.username,
|
||||
base_url = %base_url,
|
||||
"Login Flow v2: flow completed successfully"
|
||||
);
|
||||
} else {
|
||||
tracing::error!(
|
||||
user = %current_user.username,
|
||||
"Login Flow v2: complete() returned false — flow token not found"
|
||||
);
|
||||
return axum::response::Redirect::to("/nextcloud-error.html?type=session-expired")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
html_with_csp(include_str!("../../../static/nextcloud-success.html"))
|
||||
}
|
||||
|
||||
/// GET /login/v2/flow/{token}/oidc — Start an OIDC authorization flow that is
|
||||
/// tied to a Nextcloud Login Flow v2 session. After successful IdP
|
||||
/// authentication the regular `/api/auth/oidc/callback` endpoint will detect
|
||||
/// the NC flow token and complete the Nextcloud login instead of issuing
|
||||
/// internal JWTs.
|
||||
pub async fn handle_login_oidc(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(token): Path<String>,
|
||||
) -> Response {
|
||||
// Verify Nextcloud services are configured
|
||||
let nextcloud = match state.nextcloud.as_ref() {
|
||||
Some(nc) => nc,
|
||||
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||
};
|
||||
|
||||
// Verify the NC login flow token exists
|
||||
if !nextcloud.login_flow.flow_exists(&token) {
|
||||
return axum::response::Redirect::to("/nextcloud-error.html?type=session-expired")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Verify auth + OIDC are configured and enabled
|
||||
let auth = match state.auth_service.as_ref() {
|
||||
Some(auth) => auth,
|
||||
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
|
||||
};
|
||||
|
||||
if !auth.auth_application_service.oidc_enabled() {
|
||||
tracing::warn!("OIDC login requested on NC login page but OIDC is not enabled");
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
|
||||
// Prepare an OIDC authorize flow that carries the NC flow token
|
||||
match auth
|
||||
.auth_application_service
|
||||
.prepare_oidc_authorize_for_nextcloud(&token)
|
||||
.await
|
||||
{
|
||||
Ok(authorize_url) => {
|
||||
tracing::info!("OIDC authorize redirect for Nextcloud Login Flow v2");
|
||||
axum::response::Redirect::temporary(&authorize_url).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "Failed to prepare OIDC authorize for NC login");
|
||||
StatusCode::INTERNAL_SERVER_ERROR.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn login_failed_response(_err: DomainError) -> Response {
|
||||
axum::response::Redirect::to("/nextcloud-error.html?type=invalid-credentials").into_response()
|
||||
}
|
||||
|
||||
fn parse_form(body: &str) -> HashMap<String, String> {
|
||||
body.split('&')
|
||||
.filter_map(|pair| {
|
||||
let (key, value) = pair.split_once('=')?;
|
||||
let key = urlencoding::decode(key).ok()?.to_string();
|
||||
let value = urlencoding::decode(value).ok()?.to_string();
|
||||
Some((key, value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_form_value(body: &str, key: &str) -> Option<String> {
|
||||
parse_form(body).remove(key)
|
||||
}
|
||||
Reference in New Issue
Block a user