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,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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
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(¤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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user