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:
zjean
2026-03-04 14:02:15 +01:00
parent ecd1a8148a
commit 54eedf5483
64 changed files with 6761 additions and 126 deletions
+198 -12
View File
@@ -1,16 +1,19 @@
use axum::{
Router,
extract::{Json, Query, State},
http::{HeaderMap, StatusCode},
extract::{Json, Path, Query, State},
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Redirect, Response},
routing::{get, post, put},
routing::{delete, get, post, put},
};
use std::sync::Arc;
use crate::application::dtos::user_dto::{
ChangePasswordDto, LoginDto, OidcCallbackQueryDto, OidcExchangeDto, OidcProviderInfoDto,
RefreshTokenDto, RegisterDto, SetupAdminDto,
AppPasswordCreatedDto, AppPasswordDto, ChangePasswordDto, CreateAppPasswordDto, LoginDto,
OidcCallbackQueryDto, OidcExchangeDto, OidcProviderInfoDto, RefreshTokenDto, RegisterDto,
SetupAdminDto,
};
use crate::application::ports::auth_ports::TokenServicePort;
use crate::application::services::auth_application_service::OidcCallbackResult;
use crate::common::di::AppState;
use crate::interfaces::api::cookie_auth;
use crate::interfaces::errors::AppError;
@@ -34,6 +37,11 @@ pub fn auth_protected_routes() -> Router<Arc<AppState>> {
.route("/me", get(get_current_user))
.route("/change-password", put(change_password))
.route("/logout", post(logout))
.route(
"/app-passwords",
get(list_app_passwords).post(create_app_password),
)
.route("/app-passwords/{id}", delete(delete_app_password))
}
/// Rate-limited auth routes — split out so main.rs can apply per-endpoint
@@ -522,6 +530,140 @@ async fn get_system_status(
Ok((StatusCode::OK, Json(status)))
}
// ============================================================================
// App Password Handlers
// ============================================================================
async fn create_app_password(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(dto): Json<CreateAppPasswordDto>,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
let token = headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.ok_or_else(|| AppError::unauthorized("Authorization token not found"))?;
let claims = auth_service
.token_service
.validate_token(token)
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
let nextcloud = state
.nextcloud
.as_ref()
.ok_or_else(|| AppError::internal_error("Nextcloud services not configured"))?;
let label = dto.label.trim();
if label.is_empty() || label.len() > 128 {
return Err(AppError::new(
StatusCode::BAD_REQUEST,
"Label must be between 1 and 128 characters",
"InvalidInput",
));
}
let (id, password) = nextcloud
.app_passwords
.create_nc(&claims.sub, label)
.await
.map_err(AppError::from)?;
Ok((
StatusCode::CREATED,
Json(AppPasswordCreatedDto {
id,
label: label.to_string(),
password,
}),
))
}
async fn list_app_passwords(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
let token = headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.ok_or_else(|| AppError::unauthorized("Authorization token not found"))?;
let claims = auth_service
.token_service
.validate_token(token)
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
let nextcloud = state
.nextcloud
.as_ref()
.ok_or_else(|| AppError::internal_error("Nextcloud services not configured"))?;
let records = nextcloud
.app_passwords
.list_nc(&claims.sub)
.await
.map_err(AppError::from)?;
let passwords: Vec<AppPasswordDto> = records
.into_iter()
.map(|r| AppPasswordDto {
id: r.id,
label: r.label,
created_at: r.created_at,
last_used_at: r.last_used_at,
})
.collect();
Ok((StatusCode::OK, Json(passwords)))
}
async fn delete_app_password(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(id): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let auth_service = state
.auth_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Authentication service not configured"))?;
let token = headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
.ok_or_else(|| AppError::unauthorized("Authorization token not found"))?;
let claims = auth_service
.token_service
.validate_token(token)
.map_err(|e| AppError::unauthorized(format!("Invalid token: {}", e)))?;
let nextcloud = state
.nextcloud
.as_ref()
.ok_or_else(|| AppError::internal_error("Nextcloud services not configured"))?;
nextcloud
.app_passwords
.delete_by_user(&id, &claims.sub)
.await
.map_err(AppError::from)?;
Ok(StatusCode::NO_CONTENT)
}
// ============================================================================
// OIDC Handlers
// ============================================================================
@@ -602,7 +744,7 @@ async fn oidc_callback(
tracing::info!("OIDC callback received with code");
// Exchange code, validate state/nonce/PKCE, authenticate user
let exchange_code = auth_app
let result = auth_app
.oidc_callback(&query.code, &query.state)
.await
.map_err(|e| {
@@ -610,14 +752,58 @@ async fn oidc_callback(
AppError::from(e)
})?;
// Redirect to frontend with one-time exchange code (NOT raw tokens)
let config = auth_app.oidc_config().unwrap();
let frontend_url = config.frontend_url.trim_end_matches('/');
let redirect_url = format!("{}/?oidc_code={}", frontend_url, exchange_code,);
match result {
OidcCallbackResult::WebLogin { exchange_code } => {
// Regular web login — redirect to frontend with exchange code
let config = auth_app.oidc_config().unwrap();
let frontend_url = config.frontend_url.trim_end_matches('/');
let redirect_url = format!("{}/?oidc_code={}", frontend_url, exchange_code);
tracing::info!("OIDC login successful, redirecting with exchange code");
Ok(Redirect::temporary(&redirect_url))
}
OidcCallbackResult::NextcloudLogin {
nc_flow_token,
user_id,
username,
} => {
// Nextcloud Login Flow v2 — create app password and complete flow
let nextcloud = state
.nextcloud
.as_ref()
.ok_or_else(|| AppError::internal_error("Nextcloud services not configured"))?;
tracing::info!("OIDC login successful, redirecting with exchange code");
let (_id, app_password) = nextcloud
.app_passwords
.create_nc(&user_id, "Nextcloud (OIDC)")
.await
.map_err(|e| {
tracing::error!(error = %e, user = %username, "OIDC+NC: failed to create app password");
AppError::from(e)
})?;
Ok(Redirect::temporary(&redirect_url))
let base_url = state.core.config.base_url();
let completed =
nextcloud
.login_flow
.complete(&nc_flow_token, &username, &base_url, &app_password);
if completed {
tracing::info!(
user = %username,
"OIDC login completed Nextcloud Login Flow v2 successfully"
);
Ok(Redirect::temporary("/nextcloud-success.html"))
} else {
tracing::error!(
user = %username,
"OIDC+NC: login flow token expired or not found"
);
Ok(Redirect::temporary(
"/nextcloud-error.html?type=session-expired",
))
}
}
}
}
/// POST /api/auth/oidc/exchange — Exchange one-time code for auth tokens
+2 -2
View File
@@ -335,10 +335,10 @@ impl FileHandler {
.into_response();
}
};
let file_path = state.core.dedup_service.blob_path(&blob_hash);
let blob_path = state.core.dedup_service.blob_path(&blob_hash);
match thumbnail_service
.get_thumbnail(&id, thumb_size.into(), &file_path)
.get_thumbnail(&id, thumb_size.into(), &blob_path)
.await
{
Ok(data) => {
+16
View File
@@ -57,6 +57,22 @@ where
}
}
// Implement FromRequestParts for CurrentUser — full user extractor from extensions
impl<S> FromRequestParts<S> for CurrentUser
where
S: Send + Sync,
{
type Rejection = AuthError;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
parts
.extensions
.get::<CurrentUser>()
.cloned()
.ok_or(AuthError::UserNotFound)
}
}
// Implement FromRequestParts for CurrentUserId — lightweight extractor for user_id only
impl<S> FromRequestParts<S> for CurrentUserId
where
+1
View File
@@ -1,6 +1,7 @@
pub mod api;
pub mod errors;
pub mod middleware;
pub mod nextcloud;
pub mod web;
pub use api::create_api_routes;
@@ -0,0 +1,87 @@
use axum::{
extract::{Path, State},
http::{StatusCode, header},
response::{IntoResponse, Response},
};
use std::sync::Arc;
use crate::common::di::AppState;
/// GET /index.php/avatar/{user}/{size}
///
/// Returns an SVG avatar with the user's initials on a colored background.
pub async fn handle_avatar(
State(_state): State<Arc<AppState>>,
Path((username, size)): Path<(String, u32)>,
) -> Response {
let size = size.clamp(16, 1024);
let initials = extract_initials(&username);
let color = pick_color(&username);
let font_size = (size as f32 * 0.45) as u32;
let safe_initials = xml_escape(&initials);
let svg = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="{s}" height="{s}" viewBox="0 0 {s} {s}">
<rect width="{s}" height="{s}" rx="{r}" fill="{c}"/>
<text x="50%" y="50%" dy="0.36em" fill="#fff" font-family="-apple-system,BlinkMacSystemFont,sans-serif" font-size="{fs}" font-weight="600" text-anchor="middle">{i}</text>
</svg>"##,
s = size,
r = size / 2,
c = color,
fs = font_size,
i = safe_initials,
);
(
StatusCode::OK,
[
(header::CONTENT_TYPE, "image/svg+xml"),
(header::CACHE_CONTROL, "public, max-age=86400, immutable"),
(
header::CONTENT_SECURITY_POLICY,
"default-src 'none'; style-src 'unsafe-inline'",
),
],
svg,
)
.into_response()
}
/// Escape XML special characters to prevent XSS in SVG output.
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
}
fn extract_initials(username: &str) -> String {
let parts: Vec<&str> = username.split_whitespace().collect();
match parts.len() {
0 => "?".to_string(),
1 => parts[0]
.chars()
.next()
.unwrap_or('?')
.to_uppercase()
.to_string(),
_ => {
let first = parts[0].chars().next().unwrap_or('?');
let last = parts[parts.len() - 1].chars().next().unwrap_or('?');
format!("{}{}", first.to_uppercase(), last.to_uppercase())
}
}
}
fn pick_color(username: &str) -> &'static str {
const PALETTE: [&str; 10] = [
"#0082c9", "#e9322d", "#2d8a0f", "#c37200", "#6c2d9e", "#007a87", "#b02e7c", "#465a64",
"#a65d00", "#3b5998",
];
let hash: u32 = username
.bytes()
.fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32));
PALETTE[(hash as usize) % PALETTE.len()]
}
@@ -0,0 +1,177 @@
use axum::{
extract::{Request, State},
http::{HeaderMap, StatusCode, header},
middleware::Next,
response::{IntoResponse, Response},
};
use base64::Engine;
use std::sync::Arc;
use crate::common::di::AppState;
use crate::interfaces::middleware::auth::CurrentUser;
#[derive(Debug, thiserror::Error)]
pub enum NextcloudAuthError {
#[error("Unauthorized")]
Unauthorized,
#[error("Nextcloud services unavailable")]
ServiceUnavailable,
#[error("Internal error: {0}")]
Internal(String),
}
impl IntoResponse for NextcloudAuthError {
fn into_response(self) -> Response {
match self {
NextcloudAuthError::Unauthorized => (
StatusCode::UNAUTHORIZED,
[(header::WWW_AUTHENTICATE, "Basic realm=\"OxiCloud\"")],
"Unauthorized",
)
.into_response(),
NextcloudAuthError::ServiceUnavailable => {
(StatusCode::SERVICE_UNAVAILABLE, "Nextcloud unavailable").into_response()
}
NextcloudAuthError::Internal(_) => {
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response()
}
}
}
}
pub async fn basic_auth_middleware(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
mut request: Request,
next: Next,
) -> Result<Response, NextcloudAuthError> {
tracing::debug!("[NC] {} {}", request.method(), request.uri());
let auth_header = headers
.get(header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.ok_or_else(|| {
tracing::warn!(
"[NC] 401 no auth header: {} {}",
request.method(),
request.uri()
);
NextcloudAuthError::Unauthorized
})?;
let (username, password) =
parse_basic_auth(auth_header).ok_or(NextcloudAuthError::Unauthorized)?;
// Check account lockout before attempting password verification (saves CPU)
if let Some(auth_svc) = state.auth_service.as_ref() {
if let Err(secs) = auth_svc.login_lockout.check(&username) {
tracing::warn!(
username = %username,
lockout_remaining_secs = secs,
"[NC] Account locked — too many failed attempts"
);
return Err(NextcloudAuthError::Unauthorized);
}
}
let nextcloud = state
.nextcloud
.as_ref()
.ok_or(NextcloudAuthError::ServiceUnavailable)?;
match nextcloud
.app_passwords
.verify_basic_auth(&username, &password)
.await
{
Ok((user_id, uname, email, role)) => {
// Reset lockout counter on success
if let Some(auth_svc) = state.auth_service.as_ref() {
auth_svc.login_lockout.record_success(&username);
}
request.extensions_mut().insert(CurrentUser {
id: user_id,
username: uname,
email,
role,
});
Ok(next.run(request).await)
}
Err(_) => {
// Record failed attempt for lockout tracking
if let Some(auth_svc) = state.auth_service.as_ref() {
auth_svc.login_lockout.record_failure(&username);
}
Err(NextcloudAuthError::Unauthorized)
}
}
}
/// Parse a `Basic` Authorization header into `(username, password)`.
pub fn parse_basic_auth(header_value: &str) -> Option<(String, String)> {
let mut parts = header_value.splitn(2, ' ');
let scheme = parts.next()?.trim();
let encoded = parts.next()?.trim();
if !scheme.eq_ignore_ascii_case("Basic") {
return None;
}
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.ok()?;
let decoded = String::from_utf8(decoded).ok()?;
let (user, pass) = decoded.split_once(':')?;
Some((user.to_string(), pass.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_basic_auth() {
let encoded = base64::engine::general_purpose::STANDARD.encode("alice:secret123");
let header = format!("Basic {}", encoded);
let (user, pass) = parse_basic_auth(&header).expect("should parse");
assert_eq!(user, "alice");
assert_eq!(pass, "secret123");
}
#[test]
fn test_parse_basic_auth_with_colon_in_password() {
let encoded = base64::engine::general_purpose::STANDARD.encode("user:pass:with:colons");
let header = format!("Basic {}", encoded);
let (user, pass) = parse_basic_auth(&header).expect("should parse");
assert_eq!(user, "user");
assert_eq!(pass, "pass:with:colons");
}
#[test]
fn test_parse_basic_auth_bearer_scheme_rejected() {
let encoded = base64::engine::general_purpose::STANDARD.encode("user:pass");
let header = format!("Bearer {}", encoded);
assert!(parse_basic_auth(&header).is_none());
}
#[test]
fn test_parse_basic_auth_missing_colon() {
let encoded = base64::engine::general_purpose::STANDARD.encode("nocolon");
let header = format!("Basic {}", encoded);
assert!(parse_basic_auth(&header).is_none());
}
#[test]
fn test_parse_basic_auth_invalid_base64() {
assert!(parse_basic_auth("Basic not-valid-base64!!!").is_none());
}
#[test]
fn test_parse_basic_auth_case_insensitive_scheme() {
let encoded = base64::engine::general_purpose::STANDARD.encode("user:pass");
let header = format!("BASIC {}", encoded);
let result = parse_basic_auth(&header);
assert!(result.is_some());
}
}
@@ -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(&current_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, &current_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)
}
+11
View File
@@ -0,0 +1,11 @@
pub mod avatar_handler;
pub mod basic_auth_middleware;
pub mod login_v2_handler;
pub mod ocs_handler;
pub mod preview_handler;
pub mod report_handler;
pub mod routes;
pub mod status_handler;
pub mod trashbin_handler;
pub mod uploads_handler;
pub mod webdav_handler;
+531
View File
@@ -0,0 +1,531 @@
use axum::Json;
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde_json::json;
use std::sync::Arc;
use crate::application::dtos::search_dto::SearchCriteriaDto;
use crate::application::ports::inbound::SearchUseCase;
use crate::application::ports::storage_ports::StorageUsagePort;
use crate::common::di::AppState;
use crate::interfaces::middleware::auth::CurrentUser;
/// Build an OCS success response with the given statuscode and data.
fn ocs_ok(statuscode: u16, data: serde_json::Value) -> serde_json::Value {
json!({
"ocs": {
"meta": { "status": "ok", "statuscode": statuscode, "message": "OK" },
"data": data,
}
})
}
/// Build an OCS error response.
fn ocs_err(statuscode: u16, message: &str) -> serde_json::Value {
json!({
"ocs": {
"meta": { "status": "failure", "statuscode": statuscode, "message": message },
"data": {},
}
})
}
pub async fn handle_capabilities_v1(State(state): State<Arc<AppState>>) -> Response {
let payload = capabilities_payload(&state, 1);
tracing::info!("[NC] capabilities v1 requested, returning payload");
Json(payload).into_response()
}
pub async fn handle_capabilities_v2(State(state): State<Arc<AppState>>) -> Response {
let payload = capabilities_payload(&state, 2);
tracing::info!("[NC] capabilities v2 requested, returning payload");
Json(payload).into_response()
}
pub async fn handle_user_info(State(state): State<Arc<AppState>>, user: CurrentUser) -> Response {
let quota: (i64, i64) = match state.storage_usage_service.as_ref() {
Some(service) => match service.get_user_storage_info(&user.id).await {
Ok((used, total)) => (used, total),
Err(_) => (0, 0),
},
None => (0, 0),
};
let free = quota.1.saturating_sub(quota.0);
let relative = if quota.1 > 0 {
(quota.0 as f64 / quota.1 as f64) * 100.0
} else {
0.0
};
Json(json!({
"ocs": {
"meta": { "status": "ok", "statuscode": 200, "message": "OK" },
"data": {
"enabled": true,
"id": user.username,
"display-name": user.username,
"displayname": user.username,
"email": user.email,
"quota": {
"used": quota.0,
"total": quota.1,
"free": free,
"relative": relative
}
}
}
}))
.into_response()
}
/// GET /ocs/v1.php/cloud/users/{userid}
pub async fn handle_user_provisioning_v1(
state: State<Arc<AppState>>,
path: Path<String>,
user: CurrentUser,
) -> Response {
user_provisioning_response(state, path, user, 1).await
}
/// GET /ocs/v2.php/cloud/users/{userid}
pub async fn handle_user_provisioning_v2(
state: State<Arc<AppState>>,
path: Path<String>,
user: CurrentUser,
) -> Response {
user_provisioning_response(state, path, user, 2).await
}
/// Returns user details in Nextcloud OCS provisioning API format.
/// Used by the Nextcloud mobile app to fetch the user profile screen.
async fn user_provisioning_response(
State(state): State<Arc<AppState>>,
Path(userid): Path<String>,
user: CurrentUser,
ocs_version: u8,
) -> Response {
let statuscode = if ocs_version == 1 { 100 } else { 200 };
// Only allow users to view their own profile, unless they are admin.
if user.username != userid && user.role != "admin" {
return Json(ocs_err(403, "Insufficient privileges")).into_response();
}
let auth_service = match state.auth_service.as_ref() {
Some(svc) => &svc.auth_application_service,
None => {
return Json(ocs_err(997, "Authentication not configured")).into_response();
}
};
let user_dto = match auth_service.get_user_by_username(&userid).await {
Ok(u) => u,
Err(_) => {
return Json(ocs_err(404, "User not found")).into_response();
}
};
// Determine groups based on role
let groups = if user_dto.role == "admin" {
vec!["admin", "users"]
} else {
vec!["users"]
};
// Determine backend based on auth provider
let backend = if user_dto.auth_provider.to_lowercase().contains("oidc") {
"OIDC"
} else {
"Database"
};
// Convert last_login_at to JS milliseconds
let last_login = user_dto
.last_login_at
.map(|dt| dt.timestamp() * 1000)
.unwrap_or(0);
// Fetch quota from storage usage service
let quota: (i64, i64) = match state.storage_usage_service.as_ref() {
Some(service) => match service.get_user_storage_info(&user_dto.id).await {
Ok((used, total)) => (used, total),
Err(_) => (0, 0),
},
None => (0, 0),
};
let free = quota.1.saturating_sub(quota.0);
let relative = if quota.1 > 0 {
(quota.0 as f64 / quota.1 as f64) * 100.0
} else {
0.0
};
Json(json!({
"ocs": {
"meta": { "status": "ok", "statuscode": statuscode, "message": "OK" },
"data": {
"enabled": user_dto.active,
"id": user_dto.username,
"display-name": user_dto.username,
"displayname": user_dto.username,
"email": user_dto.email,
"phone": "",
"address": "",
"website": "",
"twitter": "",
"groups": groups,
"language": "en",
"locale": "en_US",
"backend": backend,
"lastLogin": last_login,
"quota": {
"used": quota.0,
"total": quota.1,
"free": free,
"relative": relative
}
}
}
}))
.into_response()
}
pub async fn handle_revoke_apppassword(
State(state): State<Arc<AppState>>,
user: CurrentUser,
headers: axum::http::HeaderMap,
) -> Response {
let nextcloud = match state.nextcloud.as_ref() {
Some(nextcloud) => nextcloud,
None => return StatusCode::SERVICE_UNAVAILABLE.into_response(),
};
let app_password = match extract_basic_password(&headers) {
Some(password) => password,
None => return StatusCode::UNAUTHORIZED.into_response(),
};
if let Err(e) = nextcloud
.app_passwords
.revoke_by_password(&user.id, &app_password)
.await
{
tracing::warn!("Failed to revoke app password for {}: {}", user.id, e);
}
Json(ocs_ok(200, json!({}))).into_response()
}
pub async fn handle_notifications_list() -> Response {
Json(ocs_ok(200, json!([]))).into_response()
}
pub async fn handle_notifications_push() -> Response {
Json(ocs_ok(200, json!({}))).into_response()
}
/// GET /ocs/v2.php/apps/files_sharing/api/v1/sharees?search={query}&itemType={type}
///
/// Returns matching users for the sharing autocomplete UI.
/// Even though sharing is disabled, the Nextcloud mobile app still calls
/// this endpoint and expects a well-formed OCS response rather than a 404.
pub async fn handle_sharees_search(
State(state): State<Arc<AppState>>,
user: CurrentUser,
axum::extract::Query(params): axum::extract::Query<ShareeSearchParams>,
) -> Response {
let search = params.search.unwrap_or_default();
if search.is_empty() {
return sharees_response(vec![]).into_response();
}
let auth_service = match state.auth_service.as_ref() {
Some(svc) => &svc.auth_application_service,
None => return sharees_response(vec![]).into_response(),
};
// SQL-level ILIKE search with limit — avoids loading all users into memory.
let users = auth_service
.search_users(&search, 26)
.await
.unwrap_or_default();
let matches: Vec<serde_json::Value> = users
.into_iter()
.filter(|u| u.username != user.username) // Don't suggest self
.take(25)
.map(|u| {
json!({
"label": u.username,
"value": {
"shareType": 0,
"shareWith": u.username
}
})
})
.collect();
sharees_response(matches).into_response()
}
#[derive(serde::Deserialize)]
pub struct ShareeSearchParams {
search: Option<String>,
#[serde(rename = "itemType")]
#[allow(dead_code)]
item_type: Option<String>,
#[serde(rename = "perPage")]
#[allow(dead_code)]
per_page: Option<u32>,
}
fn sharees_response(users: Vec<serde_json::Value>) -> Json<serde_json::Value> {
Json(json!({
"ocs": {
"meta": { "status": "ok", "statuscode": 200, "message": "OK" },
"data": {
"exact": { "users": [], "groups": [], "remotes": [] },
"users": users,
"groups": [],
"remotes": []
}
}
}))
}
/// GET /ocs/v2.php/search/providers
///
/// Returns the list of available Unified Search providers.
/// We only expose the "files" provider.
pub async fn handle_search_providers() -> Response {
Json(json!({
"ocs": {
"meta": { "status": "ok", "statuscode": 200, "message": "OK" },
"data": [
{
"id": "files",
"appId": "files",
"name": "Files",
"icon": "/apps/files/img/app.svg",
"order": 5,
"filters": {},
"isPaginated": false
}
]
}
}))
.into_response()
}
/// GET /ocs/v2.php/search/providers/{provider_id}/search?term=…&limit=…&cursor=…
///
/// Executes a Unified Search query against the given provider.
/// Only the "files" provider is implemented; all others return empty results.
pub async fn handle_search(
State(state): State<Arc<AppState>>,
Path(provider_id): Path<String>,
axum::extract::Query(params): axum::extract::Query<UnifiedSearchParams>,
user: CurrentUser,
) -> Response {
// Only the "files" provider is supported
if provider_id != "files" {
return empty_search_response().into_response();
}
let search_service = match state.applications.search_service.as_ref() {
Some(svc) => svc,
None => return empty_search_response().into_response(),
};
let term = params.term.unwrap_or_default();
if term.is_empty() {
return empty_search_response().into_response();
}
let criteria = SearchCriteriaDto {
name_contains: Some(term),
recursive: true,
limit: params.limit.unwrap_or(25),
..SearchCriteriaDto::default()
};
let results = match search_service.search(criteria, &user.id).await {
Ok(r) => r,
Err(_) => return empty_search_response().into_response(),
};
let file_id_svc = state.nextcloud.as_ref().map(|n| &n.file_ids);
let mut entries: Vec<serde_json::Value> = Vec::new();
// Map file results
for file in &results.files {
let display_path = file
.path
.strip_prefix(&format!("My Folder - {}/", user.username))
.unwrap_or(&file.path);
let display_path = format!("/{}", display_path);
let numeric_id = if let Some(svc) = file_id_svc {
svc.get_or_create_file_id(&file.id).await.ok()
} else {
None
};
let thumbnail_url = match numeric_id {
Some(nid) => format!("/index.php/core/preview?fileId={}&x=32&y=32", nid),
None => String::new(),
};
let resource_url = match numeric_id {
Some(nid) => format!("/f/{}", nid),
None => String::new(),
};
entries.push(json!({
"thumbnailUrl": thumbnail_url,
"title": file.name,
"subline": display_path,
"resourceUrl": resource_url,
"icon": "",
"rounded": false
}));
}
// Map folder results
for folder in &results.folders {
let display_path = folder
.path
.strip_prefix(&format!("My Folder - {}/", user.username))
.unwrap_or(&folder.path);
let display_path = format!("/{}", display_path);
entries.push(json!({
"thumbnailUrl": "",
"title": folder.name,
"subline": display_path,
"resourceUrl": "",
"icon": "/apps/files/img/folder.svg",
"rounded": false
}));
}
Json(json!({
"ocs": {
"meta": { "status": "ok", "statuscode": 200, "message": "OK" },
"data": {
"name": "Files",
"isPaginated": false,
"entries": entries,
"cursor": null
}
}
}))
.into_response()
}
#[derive(serde::Deserialize)]
pub struct UnifiedSearchParams {
term: Option<String>,
limit: Option<usize>,
#[allow(dead_code)]
cursor: Option<String>,
}
fn empty_search_response() -> Json<serde_json::Value> {
Json(json!({
"ocs": {
"meta": { "status": "ok", "statuscode": 200, "message": "OK" },
"data": {
"name": "Files",
"isPaginated": false,
"entries": [],
"cursor": null
}
}
}))
}
fn capabilities_payload(state: &AppState, ocs_version: u8) -> serde_json::Value {
let statuscode = if ocs_version == 1 { 100 } else { 200 };
let base_url = state.core.config.base_url();
let (nc_major, nc_minor, nc_micro) = state.core.config.nextcloud.emulated_version;
let nc_version_str = state.core.config.nextcloud.version_string();
json!({
"ocs": {
"meta": {
"status": "ok",
"statuscode": statuscode,
"message": "OK"
},
"data": {
"version": {
"major": nc_major,
"minor": nc_minor,
"micro": nc_micro,
"string": nc_version_str,
"edition": "",
"extendedSupport": false
},
"capabilities": {
"core": {
"pollinterval": 60,
"webdav-root": "remote.php/dav",
"reference-api": false,
"reference-regex": ""
},
"files": {
"bigfilechunking": true,
"favorites": true,
"undelete": true,
"versioning": false
},
"dav": {
"chunking": "1.0"
},
"checksums": {
"preferredUploadType": "SHA1",
"supportedTypes": ["SHA1", "MD5"]
},
"files_sharing": {
"api_enabled": false,
"public": { "enabled": false },
"user": { "send_mail": false },
"resharing": false
},
"notifications": {
"ocs-endpoints": ["list", "get", "delete", "delete-all"]
},
"theming": {
"name": "OxiCloud",
"url": base_url,
"logo": format!("{}/logo.png", base_url),
"color": "#0082c9",
"color-text": "#ffffff",
"color-element": "#0082c9",
"color-element-bright": "#0082c9",
"color-element-dark": "#0082c9",
"background": "#0082c9",
"background-plain": true,
"background-default": true,
"logoheader": format!("{}/logo.png", base_url),
"favicon": format!("{}/favicon.ico", base_url)
}
}
}
}
})
}
fn extract_basic_password(headers: &axum::http::HeaderMap) -> Option<String> {
let value = headers
.get(axum::http::header::AUTHORIZATION)?
.to_str()
.ok()?;
super::basic_auth_middleware::parse_basic_auth(value).map(|(_, pass)| pass)
}
+164
View File
@@ -0,0 +1,164 @@
//! Nextcloud-compatible preview/thumbnail endpoint.
//!
//! Maps Nextcloud preview requests to OxiCloud's thumbnail service.
use axum::{
body::Body,
extract::{Query, State},
http::{StatusCode, header},
response::{IntoResponse, Response},
};
use serde::Deserialize;
use std::sync::Arc;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::storage_ports::FileReadPort;
use crate::application::ports::thumbnail_ports::{ThumbnailPort, ThumbnailSize};
use crate::common::di::AppState;
use crate::interfaces::middleware::auth::CurrentUser;
#[derive(Debug, Deserialize)]
pub struct PreviewParams {
#[serde(rename = "fileId")]
file_id: String,
x: Option<u32>,
y: Option<u32>,
#[serde(rename = "forceIcon")]
force_icon: Option<u8>,
}
/// Handle Nextcloud preview requests.
///
/// Maps:
/// - `/index.php/core/preview?fileId=X` to thumbnail generation
/// - Size selection based on request dimensions and forceIcon param
pub async fn handle_preview(
State(state): State<Arc<AppState>>,
user: CurrentUser,
Query(params): Query<PreviewParams>,
) -> impl IntoResponse {
// Parse the Nextcloud file ID (numeric) to get the OxiCloud UUID
let nc_file_id: i64 = match params.file_id.parse() {
Ok(id) => id,
Err(_) => {
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.body(Body::from("Invalid file ID"))
.unwrap();
}
};
// Look up the OxiCloud file UUID from the Nextcloud ID
let object_id = match state.nextcloud.as_ref() {
Some(nc) => match nc.file_ids.get_oxicloud_id(nc_file_id).await {
Ok(id) => id,
Err(_) => {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("File not found"))
.unwrap();
}
},
None => {
return Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from("Nextcloud integration not configured"))
.unwrap();
}
};
// Get file details
let file = match state
.applications
.file_retrieval_service
.get_file(&object_id)
.await
{
Ok(file) => file,
Err(_) => {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("File not found"))
.unwrap();
}
};
// Verify the authenticated user owns this file
if file.owner_id.as_deref() != Some(&user.id) {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("File not found"))
.unwrap();
}
// Determine thumbnail size based on request params
let thumb_size = if params.force_icon == Some(1) {
ThumbnailSize::Icon
} else {
// Map requested dimensions to our thumbnail sizes
let max_dim = params.x.unwrap_or(400).max(params.y.unwrap_or(400));
if max_dim <= 150 {
ThumbnailSize::Icon
} else if max_dim <= 400 {
ThumbnailSize::Preview
} else {
ThumbnailSize::Large
}
};
// Check if file is an image
if !state
.core
.thumbnail_service
.is_supported_image(&file.mime_type)
{
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("Preview not available for this file type"))
.unwrap();
}
// Get the physical blob path (content-addressable storage)
let blob_hash = match state
.repositories
.file_read_repository
.get_blob_hash(&object_id)
.await
{
Ok(hash) => hash,
Err(_) => {
return Response::builder()
.status(StatusCode::NOT_FOUND)
.body(Body::from("File blob not found"))
.unwrap();
}
};
let blob_path = state.core.dedup_service.blob_path(&blob_hash);
// Generate/get thumbnail
match state
.core
.thumbnail_service
.get_thumbnail(&object_id, thumb_size.into(), &blob_path)
.await
{
Ok(data) => {
let etag = format!("\"thumb-{}-{:?}\"", object_id, thumb_size);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "image/webp")
.header(header::CONTENT_LENGTH, data.len())
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header(header::ETAG, etag)
.body(Body::from(data))
.unwrap()
}
Err(err) => {
tracing::error!("Thumbnail generation failed for {}: {}", object_id, err);
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.body(Body::from("Failed to generate thumbnail"))
.unwrap()
}
}
}
+447
View File
@@ -0,0 +1,447 @@
use axum::{
body::{self, Body},
http::{Request, StatusCode, header},
response::Response,
};
use quick_xml::{
Reader, Writer,
events::{BytesEnd, BytesStart, Event},
};
use std::collections::HashSet;
use std::sync::Arc;
use crate::application::dtos::display_helpers::{
category_for, format_file_size, icon_class_for, icon_special_class_for,
};
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::application::dtos::search_dto::SearchCriteriaDto;
use crate::application::ports::favorites_ports::FavoritesUseCase;
use crate::application::ports::file_ports::FileRetrievalUseCase;
use crate::application::ports::inbound::{FolderUseCase, SearchUseCase};
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser;
use crate::interfaces::nextcloud::webdav_handler::{
format_oc_id, nc_href, resolve_file_id, resolve_folder_id, write_file_response,
write_folder_response,
};
/// Handle WebDAV REPORT and SEARCH methods for Nextcloud compatibility.
///
/// Dispatches based on the XML body:
/// - `oc:filter-files` -- list favorited items (REPORT)
/// - `d:searchrequest` -- search files by name (SEARCH)
pub async fn handle_nc_report(
state: Arc<AppState>,
req: Request<Body>,
user: &CurrentUser,
_subpath: &str,
) -> Result<Response<Body>, AppError> {
let body_bytes = body::to_bytes(req.into_body(), 64 * 1024)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read body: {}", e)))?;
let body_str = String::from_utf8_lossy(&body_bytes);
if body_str.contains("filter-files") {
handle_filter_files(state, &body_str, user).await
} else if body_str.contains("searchrequest") {
handle_search(state, &body_str, user).await
} else {
// Unknown REPORT type -- return empty multistatus.
Ok(empty_multistatus())
}
}
// ──────────────────── Favorites filter (oc:filter-files) ────────────────────
async fn handle_filter_files(
state: Arc<AppState>,
_body: &str,
user: &CurrentUser,
) -> Result<Response<Body>, AppError> {
let fav_svc = match state.favorites_service.as_ref() {
Some(svc) => svc,
None => return Ok(empty_multistatus()),
};
let favorites = fav_svc
.get_favorites(&user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to get favorites: {}", e)))?;
if favorites.is_empty() {
return Ok(empty_multistatus());
}
let file_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
let nc = state.nextcloud.as_ref();
let file_id_svc = nc.map(|n| &n.file_ids);
// All items in this response are favorites.
let favorite_ids: HashSet<String> = favorites.iter().map(|f| f.item_id.clone()).collect();
let home_prefix = format!("My Folder - {}/", user.username);
let mut buf = Vec::new();
{
let mut xml = Writer::new(&mut buf);
write_multistatus_start(&mut xml)?;
for fav in &favorites {
match fav.item_type.as_str() {
"file" => {
let file = match file_service.get_file(&fav.item_id).await {
Ok(f) => f,
Err(_) => continue, // Deleted or inaccessible -- skip.
};
let subpath = strip_home_prefix(&file.path, &home_prefix);
let href = nc_href(&user.username, subpath);
let fid = resolve_file_id(file_id_svc, &file.id).await;
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
write_file_response(
&mut xml,
&file,
&href,
fid,
oc_id.as_deref(),
&user.username,
&favorite_ids,
)
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
}
"folder" => {
let folder = match folder_service.get_folder(&fav.item_id).await {
Ok(f) => f,
Err(_) => continue,
};
let subpath = strip_home_prefix(&folder.path, &home_prefix);
let href = format!("{}/", nc_href(&user.username, subpath));
let fid = resolve_folder_id(file_id_svc, &folder.id).await;
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
write_folder_response(
&mut xml,
&folder,
&href,
fid,
oc_id.as_deref(),
&user.username,
&favorite_ids,
)
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
}
_ => continue,
}
}
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
}
Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(buf))
.unwrap())
}
// ──────────────────── Search (d:searchrequest) ────────────────────
async fn handle_search(
state: Arc<AppState>,
body: &str,
user: &CurrentUser,
) -> Result<Response<Body>, AppError> {
let search_svc = match state.applications.search_service.as_ref() {
Some(svc) => svc,
None => return Ok(empty_multistatus()),
};
let term = parse_literal(body).unwrap_or_default();
if term.is_empty() {
return Ok(empty_multistatus());
}
let nresults = parse_nresults(body).unwrap_or(100);
// Resolve folder scope from <d:href> inside <d:scope>.
let folder_id = resolve_scope_folder(&state, body, &user.username).await;
let criteria = SearchCriteriaDto {
name_contains: Some(term),
recursive: true,
limit: nresults,
folder_id,
..Default::default()
};
let results = search_svc
.search(criteria, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Search failed: {}", e)))?;
let nc = state.nextcloud.as_ref();
let file_id_svc = nc.map(|n| &n.file_ids);
let home_prefix = format!("My Folder - {}/", user.username);
// No favorite checking for search results -- pass an empty set.
let favorite_ids: HashSet<String> = HashSet::new();
let mut buf = Vec::new();
{
let mut xml = Writer::new(&mut buf);
write_multistatus_start(&mut xml)?;
// Files.
for fr in &results.files {
let file = file_dto_from_search(fr);
let subpath = strip_home_prefix(&file.path, &home_prefix);
let href = nc_href(&user.username, subpath);
let fid = resolve_file_id(file_id_svc, &file.id).await;
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
write_file_response(
&mut xml,
&file,
&href,
fid,
oc_id.as_deref(),
&user.username,
&favorite_ids,
)
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
}
// Folders.
for sr in &results.folders {
let folder = folder_dto_from_search(sr);
let subpath = strip_home_prefix(&folder.path, &home_prefix);
let href = format!("{}/", nc_href(&user.username, subpath));
let fid = resolve_folder_id(file_id_svc, &folder.id).await;
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
write_folder_response(
&mut xml,
&folder,
&href,
fid,
oc_id.as_deref(),
&user.username,
&favorite_ids,
)
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
}
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
}
Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(buf))
.unwrap())
}
// ──────────────────── DTO conversions ────────────────────
/// Build a `FileDto` from a search file result.
fn file_dto_from_search(fr: &crate::application::dtos::search_dto::SearchFileResultDto) -> FileDto {
FileDto {
id: fr.id.clone(),
name: fr.name.clone(),
path: fr.path.clone(),
size: fr.size,
mime_type: fr.mime_type.clone().into(),
folder_id: fr.folder_id.clone(),
created_at: fr.created_at,
modified_at: fr.modified_at,
icon_class: icon_class_for(&fr.name, &fr.mime_type).to_string().into(),
icon_special_class: icon_special_class_for(&fr.name, &fr.mime_type)
.to_string()
.into(),
category: category_for(&fr.name, &fr.mime_type).to_string().into(),
size_formatted: format_file_size(fr.size),
owner_id: None,
}
}
/// Build a `FolderDto` from a search folder result.
fn folder_dto_from_search(
sr: &crate::application::dtos::search_dto::SearchFolderResultDto,
) -> FolderDto {
FolderDto {
id: sr.id.clone(),
name: sr.name.clone(),
path: sr.path.clone(),
parent_id: sr.parent_id.clone(),
owner_id: None,
created_at: sr.created_at,
modified_at: sr.modified_at,
is_root: sr.is_root,
icon_class: Arc::from("fas fa-folder"),
icon_special_class: Arc::from("folder-icon"),
category: Arc::from("Folder"),
}
}
// ──────────────────── XML helpers ────────────────────
/// Write the opening `<d:multistatus>` element with namespace declarations.
fn write_multistatus_start<W: std::io::Write>(xml: &mut Writer<W>) -> Result<(), AppError> {
let mut ms = BytesStart::new("d:multistatus");
ms.push_attribute(("xmlns:d", "DAV:"));
ms.push_attribute(("xmlns:oc", "http://owncloud.org/ns"));
ms.push_attribute(("xmlns:nc", "http://nextcloud.org/ns"));
xml.write_event(Event::Start(ms))
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
Ok(())
}
/// Build an empty 207 Multi-Status response.
fn empty_multistatus() -> Response<Body> {
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
</d:multistatus>"#;
Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(xml))
.unwrap()
}
// ──────────────────── XML parsing helpers ────────────────────
/// Extract the search term from `<d:literal>%term%</d:literal>` using quick_xml.
fn parse_literal(body: &str) -> Option<String> {
let text = xml_extract_text(body, b"literal")?;
// Strip SQL-style % wildcards.
let term = text.trim_matches('%').trim();
if term.is_empty() {
None
} else {
Some(term.to_string())
}
}
/// Extract the result limit from `<d:nresults>100</d:nresults>` using quick_xml.
fn parse_nresults(body: &str) -> Option<usize> {
let text = xml_extract_text(body, b"nresults")?;
text.trim().parse::<usize>().ok()
}
/// Extract the scope href from `<d:href>` inside `<d:scope>` using quick_xml.
fn parse_scope_href(body: &str) -> Option<String> {
let mut reader = Reader::from_str(body);
let mut inside_scope = false;
let mut inside_href = false;
loop {
match reader.read_event() {
Ok(Event::Start(ref e)) => {
let local = e.local_name();
if local.as_ref() == b"scope" {
inside_scope = true;
} else if inside_scope && local.as_ref() == b"href" {
inside_href = true;
}
}
Ok(Event::Text(ref e)) if inside_href => {
let text = e.decode().ok()?;
let href = text.trim();
if href.is_empty() {
return None;
}
return Some(href.to_string());
}
Ok(Event::End(ref e)) => {
let local = e.local_name();
if local.as_ref() == b"scope" {
inside_scope = false;
} else if local.as_ref() == b"href" {
inside_href = false;
}
}
Ok(Event::Eof) => break,
Err(_) => break,
_ => {}
}
}
None
}
/// Generic helper: extract text content from the first element matching a local name.
fn xml_extract_text(body: &str, local_name: &[u8]) -> Option<String> {
let mut reader = Reader::from_str(body);
let mut inside = false;
loop {
match reader.read_event() {
Ok(Event::Start(ref e)) if e.local_name().as_ref() == local_name => {
inside = true;
}
Ok(Event::Text(ref e)) if inside => {
return e.decode().ok().map(|s| s.to_string());
}
Ok(Event::End(ref e)) if e.local_name().as_ref() == local_name => {
inside = false;
}
Ok(Event::Eof) => break,
Err(_) => break,
_ => {}
}
}
None
}
/// Resolve a scope href (e.g. `/files/username/Documents`) to a folder ID.
async fn resolve_scope_folder(state: &AppState, body: &str, username: &str) -> Option<String> {
let href = parse_scope_href(body)?;
// The href is typically `/files/{user}/subpath` or `/remote.php/dav/files/{user}/subpath`.
let subpath = extract_subpath_from_scope(&href, username)?;
if subpath.is_empty() {
// Root scope -- no folder_id filter needed.
return None;
}
let internal_path =
crate::interfaces::nextcloud::webdav_handler::nc_to_internal_path(username, &subpath)
.ok()?;
let folder_service = &state.applications.folder_service;
folder_service
.get_folder_by_path(&internal_path)
.await
.ok()
.map(|f| f.id)
}
/// Extract the subpath portion from a scope href.
///
/// Handles both short form `/files/{user}/sub` and full
/// `/remote.php/dav/files/{user}/sub`.
fn extract_subpath_from_scope(href: &str, username: &str) -> Option<String> {
let patterns = [
format!("/remote.php/dav/files/{}/", username),
format!("/files/{}/", username),
format!("/remote.php/dav/files/{}", username),
format!("/files/{}", username),
];
for pat in &patterns {
if let Some(rest) = href.strip_prefix(pat.as_str()) {
return Some(rest.trim_matches('/').to_string());
}
}
None
}
/// Strip the `My Folder - {username}/` prefix to get the DAV subpath.
fn strip_home_prefix<'a>(path: &'a str, prefix: &str) -> &'a str {
path.strip_prefix(prefix).unwrap_or(path)
}
+255
View File
@@ -0,0 +1,255 @@
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::CurrentUser;
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::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))
.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,
)),
)
// 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),
);
// Protected routes — require Basic Auth via app passwords.
let protected = Router::new()
.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/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),
)
.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 ────────────────
/// Reject requests where the URL `{user}` doesn't match the authenticated user.
fn verify_url_user(url_user: &str, auth_user: &CurrentUser) -> Result<(), Response> {
if url_user != auth_user.username {
Err(StatusCode::FORBIDDEN.into_response())
} else {
Ok(())
}
}
async fn handle_dav_files(
State(state): State<Arc<AppState>>,
Path((url_user, subpath)): Path<(String, String)>,
user_ext: CurrentUser,
req: Request<Body>,
) -> Result<Response, Response> {
verify_url_user(&url_user, &user_ext)?;
webdav_handler::handle_nc_webdav(state, req, user_ext, subpath)
.await
.map_err(|e| e.into_response())
}
async fn handle_dav_files_root(
State(state): State<Arc<AppState>>,
Path(url_user): Path<String>,
user_ext: CurrentUser,
req: Request<Body>,
) -> Result<Response, Response> {
verify_url_user(&url_user, &user_ext)?;
webdav_handler::handle_nc_webdav(state, req, user_ext, 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)>,
user_ext: CurrentUser,
req: Request<Body>,
) -> Result<Response, Response> {
verify_url_user(&url_user, &user_ext)?;
uploads_handler::handle_nc_uploads(state, req, user_ext, 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)>,
user_ext: CurrentUser,
req: Request<Body>,
) -> Result<Response, Response> {
verify_url_user(&url_user, &user_ext)?;
uploads_handler::handle_nc_uploads(state, req, user_ext, 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: CurrentUser) -> 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: CurrentUser) -> 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)>,
user_ext: CurrentUser,
req: Request<Body>,
) -> Result<Response, Response> {
verify_url_user(&url_user, &user_ext)?;
trashbin_handler::handle_nc_trashbin(state, req, user_ext, subpath)
.await
.map_err(|e| e.into_response())
}
async fn handle_dav_trashbin_root(
State(state): State<Arc<AppState>>,
Path(url_user): Path<String>,
user_ext: CurrentUser,
req: Request<Body>,
) -> Result<Response, Response> {
verify_url_user(&url_user, &user_ext)?;
trashbin_handler::handle_nc_trashbin(state, req, user_ext, String::new())
.await
.map_err(|e| e.into_response())
}
@@ -0,0 +1,22 @@
use axum::Json;
use axum::extract::State;
use axum::response::{IntoResponse, Response};
use serde_json::json;
use std::sync::Arc;
use crate::common::di::AppState;
pub async fn handle_status(State(state): State<Arc<AppState>>) -> Response {
let (major, minor, patch) = state.core.config.nextcloud.emulated_version;
let version_string = state.core.config.nextcloud.version_string();
Json(json!({
"installed": true,
"maintenance": false,
"needsDbUpgrade": false,
"version": format!("{}.{}.{}.1", major, minor, patch),
"versionstring": version_string,
"productname": "OxiCloud",
"edition": ""
}))
.into_response()
}
@@ -0,0 +1,363 @@
use axum::{
body::Body,
http::{HeaderName, Request, StatusCode, header},
response::Response,
};
use quick_xml::{
Writer,
events::{BytesEnd, BytesStart, Event},
};
use std::sync::Arc;
use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser;
use crate::interfaces::nextcloud::webdav_handler::{
format_oc_id, resolve_file_id, resolve_folder_id, write_text_element,
};
const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
/// Dispatch Nextcloud WebDAV trashbin request to the appropriate handler.
///
/// `subpath` is everything after `/remote.php/dav/trashbin/{user}/`.
pub async fn handle_nc_trashbin(
state: Arc<AppState>,
req: Request<Body>,
user: CurrentUser,
subpath: String,
) -> Result<Response<Body>, AppError> {
let method = req.method().clone();
let subpath_trimmed = subpath.trim_matches('/');
match method.as_str() {
"OPTIONS" => handle_options(),
"PROPFIND" if subpath_trimmed == "trash" || subpath_trimmed.is_empty() => {
handle_propfind(state, &user).await
}
"MOVE" if subpath_trimmed.starts_with("trash/") => {
handle_restore(state, &user, subpath_trimmed).await
}
"DELETE" if subpath_trimmed == "trash" || subpath_trimmed.is_empty() => {
handle_empty_trash(state, &user).await
}
"DELETE" if subpath_trimmed.starts_with("trash/") => {
handle_delete_permanent(state, &user, subpath_trimmed).await
}
_ => Ok(Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.body(Body::empty())
.unwrap()),
}
}
// ──────────────────── OPTIONS ────────────────────
fn handle_options() -> Result<Response<Body>, AppError> {
Ok(Response::builder()
.status(StatusCode::OK)
.header(HEADER_DAV, "1, 2, 3")
.header(header::ALLOW, "OPTIONS, PROPFIND, MOVE, DELETE")
.body(Body::empty())
.unwrap())
}
// ──────────────────── PROPFIND (list trash) ────────────────────
async fn handle_propfind(
state: Arc<AppState>,
user: &CurrentUser,
) -> Result<Response<Body>, AppError> {
let trash_svc = state
.trash_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Trash service not available"))?;
let items = trash_svc
.get_trash_items(&user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to list trash: {}", e)))?;
let nc = state.nextcloud.as_ref();
let file_id_svc = nc.map(|n| &n.file_ids);
let mut buf = Vec::new();
write_trashbin_multistatus(&mut buf, &items, &user.username, file_id_svc)
.await
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(buf))
.unwrap())
}
// ──────────────────── MOVE (restore) ────────────────────
async fn handle_restore(
state: Arc<AppState>,
user: &CurrentUser,
subpath: &str,
) -> Result<Response<Body>, AppError> {
let id = extract_trash_id(subpath)?;
let trash_svc = state
.trash_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Trash service not available"))?;
trash_svc
.restore_item(&id, &user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to restore item: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::CREATED)
.body(Body::empty())
.unwrap())
}
// ──────────────────── DELETE (empty trash) ────────────────────
async fn handle_empty_trash(
state: Arc<AppState>,
user: &CurrentUser,
) -> Result<Response<Body>, AppError> {
let trash_svc = state
.trash_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Trash service not available"))?;
trash_svc
.empty_trash(&user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to empty trash: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap())
}
// ──────────────────── DELETE (single item) ────────────────────
async fn handle_delete_permanent(
state: Arc<AppState>,
user: &CurrentUser,
subpath: &str,
) -> Result<Response<Body>, AppError> {
let id = extract_trash_id(subpath)?;
let trash_svc = state
.trash_service
.as_ref()
.ok_or_else(|| AppError::internal_error("Trash service not available"))?;
trash_svc
.delete_permanently(&id, &user.id)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to permanently delete item: {}", e))
})?;
Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap())
}
// ────────────── Helpers ──────────────
/// Extract the item ID from a trashbin subpath like `trash/{id}`.
fn extract_trash_id(subpath: &str) -> Result<String, AppError> {
// subpath is already trimmed, e.g. "trash/some-uuid"
subpath
.strip_prefix("trash/")
.map(|s| s.trim_matches('/').to_string())
.filter(|s| !s.is_empty())
.ok_or_else(|| AppError::bad_request("Missing trash item ID in path"))
}
/// Infer MIME content type from filename extension.
fn mime_from_name(name: &str) -> String {
mime_guess::from_path(name)
.first_or_octet_stream()
.to_string()
}
/// Strip the "My Folder - {username}/" prefix from an original path to produce
/// the Nextcloud-relative original location.
fn strip_home_prefix<'a>(original_path: &'a str, username: &str) -> &'a str {
let prefix = format!("My Folder - {}/", username);
original_path.strip_prefix(&prefix).unwrap_or(original_path)
}
// ────────────── Trashbin PROPFIND XML Generation ──────────────
use crate::application::dtos::trash_dto::TrashedItemDto;
use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService;
/// Generate a complete Nextcloud-compatible multistatus XML response for the trashbin.
async fn write_trashbin_multistatus<W: std::io::Write>(
writer: W,
items: &[TrashedItemDto],
username: &str,
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
) -> Result<(), String> {
let mut xml = Writer::new(writer);
// Root element with all required namespaces.
let mut ms = BytesStart::new("d:multistatus");
ms.push_attribute(("xmlns:d", "DAV:"));
ms.push_attribute(("xmlns:oc", "http://owncloud.org/ns"));
ms.push_attribute(("xmlns:nc", "http://nextcloud.org/ns"));
xml.write_event(Event::Start(ms))
.map_err(|e| e.to_string())?;
// Root container entry for the trash collection itself.
write_trash_root_response(&mut xml, username)?;
// Individual trashed items.
for item in items {
write_trash_item_response(&mut xml, item, username, file_id_svc).await?;
}
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
.map_err(|e| e.to_string())?;
Ok(())
}
/// Write the root collection response entry for the trash folder.
fn write_trash_root_response<W: std::io::Write>(
xml: &mut Writer<W>,
username: &str,
) -> Result<(), String> {
xml.write_event(Event::Start(BytesStart::new("d:response")))
.map_err(|e| e.to_string())?;
let href = format!("/remote.php/dav/trashbin/{}/trash/", username);
write_text_element(xml, "d:href", &href)?;
xml.write_event(Event::Start(BytesStart::new("d:propstat")))
.map_err(|e| e.to_string())?;
xml.write_event(Event::Start(BytesStart::new("d:prop")))
.map_err(|e| e.to_string())?;
// resourcetype = collection
xml.write_event(Event::Start(BytesStart::new("d:resourcetype")))
.map_err(|e| e.to_string())?;
xml.write_event(Event::Empty(BytesStart::new("d:collection")))
.map_err(|e| e.to_string())?;
xml.write_event(Event::End(BytesEnd::new("d:resourcetype")))
.map_err(|e| e.to_string())?;
xml.write_event(Event::End(BytesEnd::new("d:prop")))
.map_err(|e| e.to_string())?;
write_text_element(xml, "d:status", "HTTP/1.1 200 OK")?;
xml.write_event(Event::End(BytesEnd::new("d:propstat")))
.map_err(|e| e.to_string())?;
xml.write_event(Event::End(BytesEnd::new("d:response")))
.map_err(|e| e.to_string())?;
Ok(())
}
/// Write a single trashed item as a `<d:response>` element.
async fn write_trash_item_response<W: std::io::Write>(
xml: &mut Writer<W>,
item: &TrashedItemDto,
username: &str,
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
) -> Result<(), String> {
xml.write_event(Event::Start(BytesStart::new("d:response")))
.map_err(|e| e.to_string())?;
// href
let href = format!("/remote.php/dav/trashbin/{}/trash/{}", username, item.id);
write_text_element(xml, "d:href", &href)?;
xml.write_event(Event::Start(BytesStart::new("d:propstat")))
.map_err(|e| e.to_string())?;
xml.write_event(Event::Start(BytesStart::new("d:prop")))
.map_err(|e| e.to_string())?;
// d:displayname
write_text_element(xml, "d:displayname", &item.name)?;
// d:getlastmodified
write_text_element(xml, "d:getlastmodified", &item.trashed_at.to_rfc2822())?;
// d:getetag
write_text_element(xml, "d:getetag", &format!("\"{}\"", item.original_id))?;
// d:resourcetype
if item.item_type == "folder" {
xml.write_event(Event::Start(BytesStart::new("d:resourcetype")))
.map_err(|e| e.to_string())?;
xml.write_event(Event::Empty(BytesStart::new("d:collection")))
.map_err(|e| e.to_string())?;
xml.write_event(Event::End(BytesEnd::new("d:resourcetype")))
.map_err(|e| e.to_string())?;
} else {
xml.write_event(Event::Empty(BytesStart::new("d:resourcetype")))
.map_err(|e| e.to_string())?;
}
// d:getcontenttype
let content_type = if item.item_type == "folder" {
"httpd/unix-directory".to_string()
} else {
mime_from_name(&item.name)
};
write_text_element(xml, "d:getcontenttype", &content_type)?;
// d:getcontentlength
write_text_element(xml, "d:getcontentlength", "0")?;
// oc:fileid and oc:id — resolve numeric ID via file_id service
let file_id = if item.item_type == "folder" {
resolve_folder_id(file_id_svc, &item.original_id).await
} else {
resolve_file_id(file_id_svc, &item.original_id).await
};
if let Some(id) = file_id {
write_text_element(xml, "oc:fileid", &id.to_string())?;
let oc_id = format_oc_id(id, file_id_svc);
write_text_element(xml, "oc:id", &oc_id)?;
}
// nc:trashbin-filename
write_text_element(xml, "nc:trashbin-filename", &item.name)?;
// nc:trashbin-original-location
let original_location = strip_home_prefix(&item.original_path, username);
write_text_element(xml, "nc:trashbin-original-location", original_location)?;
// nc:trashbin-deletion-time
write_text_element(
xml,
"nc:trashbin-deletion-time",
&item.trashed_at.timestamp().to_string(),
)?;
// oc:permissions — empty in trash
write_text_element(xml, "oc:permissions", "")?;
// oc:size
write_text_element(xml, "oc:size", "0")?;
xml.write_event(Event::End(BytesEnd::new("d:prop")))
.map_err(|e| e.to_string())?;
write_text_element(xml, "d:status", "HTTP/1.1 200 OK")?;
xml.write_event(Event::End(BytesEnd::new("d:propstat")))
.map_err(|e| e.to_string())?;
xml.write_event(Event::End(BytesEnd::new("d:response")))
.map_err(|e| e.to_string())?;
Ok(())
}
+234
View File
@@ -0,0 +1,234 @@
use axum::{
body::{self, Body},
http::{Request, StatusCode, header},
response::Response,
};
use std::sync::Arc;
use crate::application::ports::file_ports::{FileRetrievalUseCase, FileUploadUseCase};
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::CurrentUser;
/// Dispatch Nextcloud chunked upload WebDAV requests.
///
/// Routes:
/// MKCOL /remote.php/dav/uploads/{user}/{upload_id} → create session
/// PUT /remote.php/dav/uploads/{user}/{upload_id}/{chunk} → store chunk
/// MOVE /remote.php/dav/uploads/{user}/{upload_id}/.file → assemble
/// DELETE /remote.php/dav/uploads/{user}/{upload_id} → abort
pub async fn handle_nc_uploads(
state: Arc<AppState>,
req: Request<Body>,
user: CurrentUser,
upload_id: String,
rest: String, // chunk name or ".file" or empty
) -> Result<Response<Body>, AppError> {
let method = req.method().clone();
match method.as_str() {
"MKCOL" => handle_mkcol(state, &user, &upload_id).await,
"PUT" => handle_put_chunk(state, req, &user, &upload_id, &rest).await,
"MOVE" => handle_assemble(state, req, &user, &upload_id).await,
"DELETE" => handle_abort(state, &user, &upload_id).await,
_ => Ok(Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.body(Body::empty())
.unwrap()),
}
}
/// MKCOL — create upload session directory.
async fn handle_mkcol(
state: Arc<AppState>,
user: &CurrentUser,
upload_id: &str,
) -> Result<Response<Body>, AppError> {
let nc = state
.nextcloud
.as_ref()
.ok_or_else(|| AppError::internal_error("Nextcloud services unavailable"))?;
nc.chunked_uploads
.create_session(&user.username, upload_id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to create session: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::CREATED)
.body(Body::empty())
.unwrap())
}
/// PUT — store a chunk.
async fn handle_put_chunk(
state: Arc<AppState>,
req: Request<Body>,
user: &CurrentUser,
upload_id: &str,
chunk_name: &str,
) -> Result<Response<Body>, AppError> {
let nc = state
.nextcloud
.as_ref()
.ok_or_else(|| AppError::internal_error("Nextcloud services unavailable"))?;
let chunk_name = chunk_name.trim_matches('/');
if chunk_name.is_empty() {
return Err(AppError::bad_request("Missing chunk name"));
}
let max_upload = state.core.config.storage.max_upload_size;
let body_bytes = body::to_bytes(req.into_body(), max_upload)
.await
.map_err(|e| AppError::bad_request(format!("Failed to read chunk body: {}", e)))?;
nc.chunked_uploads
.store_chunk(&user.username, upload_id, chunk_name, &body_bytes)
.await
.map_err(|e| AppError::internal_error(format!("Failed to store chunk: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::CREATED)
.body(Body::empty())
.unwrap())
}
/// MOVE — assemble chunks into final file.
///
/// The Destination header contains the final file path in the DAV files namespace.
async fn handle_assemble(
state: Arc<AppState>,
req: Request<Body>,
user: &CurrentUser,
upload_id: &str,
) -> Result<Response<Body>, AppError> {
let nc = state
.nextcloud
.as_ref()
.ok_or_else(|| AppError::internal_error("Nextcloud services unavailable"))?;
// Parse Destination header to determine final file path.
let destination = req
.headers()
.get("destination")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| AppError::bad_request("Missing Destination header"))?
.to_string();
let dest_subpath = extract_files_subpath(&destination, &user.username)
.ok_or_else(|| AppError::bad_request("Invalid Destination URL"))?;
// Assemble chunks into a temp file (no full-file buffering in RAM).
let (temp_path, size) = nc
.chunked_uploads
.assemble(&user.username, upload_id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to assemble chunks: {}", e)))?;
// Write assembled file to storage via the upload service.
let upload_service = &state.applications.file_upload_service;
let file_service = &state.applications.file_retrieval_service;
let internal_path = format!(
"My Folder - {}/{}",
user.username,
dest_subpath.trim_matches('/')
);
// Detect content type from file extension.
let content_type = mime_guess::from_path(&dest_subpath)
.first_or_octet_stream()
.to_string();
// Check if file exists (update vs create).
let existing = file_service.get_file_by_path(&internal_path).await;
if existing.is_ok() {
upload_service
.update_file_streaming(&internal_path, &temp_path, size, &content_type, None)
.await
.map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?;
} else {
// For new files we still need to read the temp file since create_file takes &[u8].
let assembled = tokio::fs::read(&temp_path).await.map_err(|e| {
AppError::internal_error(format!("Failed to read assembled file: {}", e))
})?;
let (parent_sub, filename) = match dest_subpath.rsplit_once('/') {
Some((p, n)) => (p, n),
None => ("", dest_subpath.as_str()),
};
let parent_internal = format!(
"My Folder - {}/{}",
user.username,
parent_sub.trim_matches('/')
);
let parent_internal = parent_internal.trim_end_matches('/');
upload_service
.create_file(parent_internal, filename, &assembled, &content_type)
.await
.map_err(|e| AppError::internal_error(format!("Failed to create file: {}", e)))?;
}
// Clean up temp file (session cleanup below removes the directory anyway).
let _ = tokio::fs::remove_file(&temp_path).await;
// Cleanup session.
let _ = nc.chunked_uploads.cleanup(&user.username, upload_id).await;
// Return etag if we can fetch the file.
if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
return Ok(Response::builder()
.status(StatusCode::CREATED)
.header(header::ETAG, format!("\"{}\"", file.id))
.body(Body::empty())
.unwrap());
}
Ok(Response::builder()
.status(StatusCode::CREATED)
.body(Body::empty())
.unwrap())
}
/// DELETE — abort an upload session.
async fn handle_abort(
state: Arc<AppState>,
user: &CurrentUser,
upload_id: &str,
) -> Result<Response<Body>, AppError> {
let nc = state
.nextcloud
.as_ref()
.ok_or_else(|| AppError::internal_error("Nextcloud services unavailable"))?;
nc.chunked_uploads
.cleanup(&user.username, upload_id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to abort upload: {}", e)))?;
Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap())
}
/// Extract the file subpath from a Destination header pointing to the files DAV namespace.
///
/// For full URLs the host is ignored — only the path component is used.
fn extract_files_subpath(dest: &str, username: &str) -> Option<String> {
let prefix = format!("/remote.php/dav/files/{}/", username);
let path = if dest.starts_with("http://") || dest.starts_with("https://") {
let after_scheme = dest.split_once("://")?.1;
let path_start = after_scheme.find('/').unwrap_or(after_scheme.len());
&after_scheme[path_start..]
} else {
dest
};
let decoded = urlencoding::decode(path).ok()?;
let decoded = decoded.trim_end_matches('/');
decoded
.strip_prefix(prefix.trim_end_matches('/'))
.map(|s| s.trim_start_matches('/').to_string())
}
File diff suppressed because it is too large Load Diff