Files
Oxicloud/src/interfaces/nextcloud/webdav_handler.rs
T

1318 lines
46 KiB
Rust
Raw Normal View History

use axum::{
body::{self, Body},
http::{HeaderName, Request, StatusCode, header},
response::Response,
};
use bytes::Buf;
use chrono::Utc;
use quick_xml::{
Writer,
events::{BytesEnd, BytesStart, BytesText, Event},
};
use std::collections::HashSet;
use std::sync::Arc;
use crate::application::adapters::webdav_adapter::{PropFindRequest, WebDavAdapter};
use crate::application::ports::favorites_ports::FavoritesUseCase;
use crate::application::ports::file_ports::{
FileManagementUseCase, FileRetrievalUseCase, FileUploadUseCase,
};
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::di::AppState;
use crate::common::mime_detect::{filename_from_path, refine_content_type};
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
/// Extension trait to map XML write errors to `String` concisely.
trait XmlResultExt<T> {
fn xml_err(self) -> Result<T, String>;
}
impl<T, E: std::fmt::Display> XmlResultExt<T> for Result<T, E> {
fn xml_err(self) -> Result<T, String> {
self.map_err(|e| e.to_string())
}
}
/// Convert a `u64` timestamp to `i64` safely, falling back to 0 on overflow.
fn timestamp_to_i64(ts: u64) -> i64 {
i64::try_from(ts).unwrap_or(0)
}
const HEADER_DAV: HeaderName = HeaderName::from_static("dav");
/// Resolve the internal OxiCloud path from a Nextcloud DAV subpath.
///
/// Nextcloud: /remote.php/dav/files/{user}/{subpath}
/// Internal: My Folder - {username}/{subpath}
///
/// An empty subpath maps to the user's home folder root.
pub fn nc_to_internal_path(username: &str, subpath: &str) -> Result<String, AppError> {
let home = format!("My Folder - {}", username);
let subpath = subpath.trim_matches('/');
if subpath.is_empty() {
return Ok(home);
}
// Reject path traversal attempts.
if subpath.split('/').any(|seg| seg == ".." || seg == ".") {
return Err(AppError::bad_request("Invalid path: traversal not allowed"));
}
Ok(format!("{}/{}", home, subpath))
}
/// Build the Nextcloud DAV href for a resource.
///
/// Each path segment is URL-encoded individually so filenames with spaces,
/// `#`, `%`, or non-ASCII characters produce valid PROPFIND hrefs.
pub fn nc_href(username: &str, subpath: &str) -> String {
let subpath = subpath.trim_matches('/');
let encoded_user = urlencoding::encode(username);
if subpath.is_empty() {
format!("/remote.php/dav/files/{}/", encoded_user)
} else {
let encoded_segments: Vec<_> = subpath
.split('/')
.map(|seg| urlencoding::encode(seg))
.collect();
format!(
"/remote.php/dav/files/{}/{}",
encoded_user,
encoded_segments.join("/")
)
}
}
/// Dispatch Nextcloud WebDAV request to the appropriate handler.
///
/// `subpath` is everything after `/remote.php/dav/files/{user}/`.
pub async fn handle_nc_webdav(
state: Arc<AppState>,
req: Request<Body>,
user: AuthUser,
subpath: String,
) -> Result<Response<Body>, AppError> {
let method = req.method().clone();
match method.as_str() {
"OPTIONS" => handle_options(),
"PROPFIND" => handle_propfind(state, req, &user, &subpath).await,
"GET" => handle_get(state, &user, &subpath).await,
"PUT" => handle_put(state, req, &user, &subpath).await,
"MKCOL" => handle_mkcol(state, &user, &subpath).await,
"DELETE" => handle_delete(state, &user, &subpath).await,
"MOVE" => handle_move(state, req, &user, &subpath).await,
"HEAD" => handle_head(state, &user, &subpath).await,
"PROPPATCH" => handle_proppatch(state, req, &user, &subpath).await,
"REPORT" | "SEARCH" => {
crate::interfaces::nextcloud::report_handler::handle_nc_report(
state, req, &user, &subpath,
)
.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, GET, HEAD, PUT, DELETE, MKCOL, MOVE, PROPFIND, PROPPATCH, REPORT, SEARCH",
)
.body(Body::empty())
.unwrap())
}
// ──────────────────── PROPFIND ────────────────────
async fn handle_propfind(
state: Arc<AppState>,
req: Request<Body>,
user: &CurrentUser,
subpath: &str,
) -> Result<Response<Body>, AppError> {
let depth = req
.headers()
.get("depth")
.and_then(|v| v.to_str().ok())
.unwrap_or("1")
.to_string();
// Parse the PROPFIND XML body (or assume allprop if empty).
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 propfind = if body_bytes.is_empty() {
PropFindRequest {
prop_find_type: crate::application::adapters::webdav_adapter::PropFindType::AllProp,
}
} else {
WebDavAdapter::parse_propfind(body_bytes.reader())
.map_err(|e| AppError::bad_request(format!("Invalid PROPFIND XML: {}", e)))?
};
let internal_path = nc_to_internal_path(&user.username, subpath)?;
let folder_service = &state.applications.folder_service;
let file_service = &state.applications.file_retrieval_service;
// Try to resolve as folder first.
let folder_result = folder_service.get_folder_by_path(&internal_path).await;
if let Ok(folder) = folder_result {
// It's a folder.
let (files, subfolders) = if depth != "0" {
let files = file_service
.list_files(Some(&folder.id))
.await
.unwrap_or_default();
let subfolders = folder_service
.list_folders(Some(&folder.id))
.await
.unwrap_or_default();
(files, subfolders)
} else {
(vec![], vec![])
};
// Batch-check favorites for all items in this listing.
let favorite_ids = if let Some(fav_svc) = state.favorites_service.as_ref() {
let mut items: Vec<(&str, &str)> = Vec::new();
items.push((&folder.id, "folder"));
for f in &files {
items.push((&f.id, "file"));
}
for sf in &subfolders {
items.push((&sf.id, "folder"));
}
fav_svc
.batch_check_favorites(user.id, &items)
.await
.unwrap_or_default()
} else {
HashSet::new()
};
// Generate Nextcloud-aware XML.
let nc = state.nextcloud.as_ref();
let file_id_svc = nc.map(|n| &n.file_ids);
let mut buf = Vec::new();
write_nc_multistatus(
&mut buf,
Some(&folder),
&files,
&subfolders,
&propfind,
&depth,
&user.username,
subpath,
file_id_svc,
&favorite_ids,
)
.await
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(buf))
.unwrap());
}
// Not a folder — try as a file.
let file_result = file_service.get_file_by_path(&internal_path).await;
if let Ok(file) = file_result {
// Batch-check favorites for this single file.
let favorite_ids = if let Some(fav_svc) = state.favorites_service.as_ref() {
let items: Vec<(&str, &str)> = vec![(&file.id, "file")];
fav_svc
.batch_check_favorites(user.id, &items)
.await
.unwrap_or_default()
} else {
HashSet::new()
};
let nc = state.nextcloud.as_ref();
let file_id_svc = nc.map(|n| &n.file_ids);
let mut buf = Vec::new();
write_nc_multistatus(
&mut buf,
None,
&[file],
&[],
&propfind,
"0",
&user.username,
subpath,
file_id_svc,
&favorite_ids,
)
.await
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(buf))
.unwrap());
}
Err(AppError::not_found("Resource not found"))
}
// ──────────────────── GET ────────────────────
async fn handle_get(
state: Arc<AppState>,
user: &CurrentUser,
subpath: &str,
) -> Result<Response<Body>, AppError> {
// GET on root folder — NC clients use this as an existence check
if subpath.is_empty() || subpath == "/" {
return Ok(Response::builder()
.status(StatusCode::OK)
.header("DAV", "1, 3")
.body(Body::empty())
.unwrap());
}
let internal_path = nc_to_internal_path(&user.username, subpath)?;
let file_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
// Check if path is a folder first (NC clients use GET as existence check)
if folder_service
.get_folder_by_path(&internal_path)
.await
.is_ok()
{
return Ok(Response::builder()
.status(StatusCode::OK)
.header("DAV", "1, 3")
.body(Body::empty())
.unwrap());
}
let file = file_service
.get_file_by_path(&internal_path)
.await
.map_err(|_| AppError::not_found("File not found"))?;
let stream = file_service
.get_file_stream(&file.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to read file: {}", e)))?;
let modified_at =
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.modified_at), 0)
.unwrap_or_else(Utc::now);
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, file.mime_type.as_ref())
.header(header::CONTENT_LENGTH, file.size)
.header(header::ETAG, format!("\"{}\"", file.id))
.header(header::LAST_MODIFIED, modified_at.to_rfc2822())
.body(Body::from_stream(std::pin::Pin::from(stream)))
.unwrap())
}
// ──────────────────── HEAD ────────────────────
async fn handle_head(
state: Arc<AppState>,
user: &CurrentUser,
subpath: &str,
) -> Result<Response<Body>, AppError> {
// HEAD on root folder — NC clients use this as an existence check
if subpath.is_empty() || subpath == "/" {
return Ok(Response::builder()
.status(StatusCode::OK)
.header("DAV", "1, 3")
.body(Body::empty())
.unwrap());
}
let internal_path = nc_to_internal_path(&user.username, subpath)?;
let file_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
// Check if path is a folder (NC clients use HEAD as existence check)
if folder_service
.get_folder_by_path(&internal_path)
.await
.is_ok()
{
return Ok(Response::builder()
.status(StatusCode::OK)
.header("DAV", "1, 3")
.body(Body::empty())
.unwrap());
}
let file = file_service
.get_file_by_path(&internal_path)
.await
.map_err(|_| AppError::not_found("File not found"))?;
let modified_at =
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.modified_at), 0)
.unwrap_or_else(Utc::now);
Ok(Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, file.mime_type.as_ref())
.header(header::CONTENT_LENGTH, file.size)
.header(header::ETAG, format!("\"{}\"", file.id))
.header(header::LAST_MODIFIED, modified_at.to_rfc2822())
.body(Body::empty())
.unwrap())
}
// ──────────────────── PROPPATCH ────────────────────
async fn handle_proppatch(
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);
// Parse oc:favorite value from PROPPATCH XML.
let favorite_value = parse_proppatch_favorite(&body_str);
if let Some(value) = favorite_value {
let internal_path = nc_to_internal_path(&user.username, subpath)?;
let file_service = &state.applications.file_retrieval_service;
let folder_service = &state.applications.folder_service;
// Determine item_id and item_type.
let (item_id, item_type) =
if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
(file.id, "file")
} else if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await {
(folder.id, "folder")
} else {
return Err(AppError::not_found("Resource not found"));
};
if let Some(fav_svc) = state.favorites_service.as_ref() {
if value == 1 {
fav_svc
.add_to_favorites(user.id, &item_id, item_type)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to add favorite: {}", e))
})?;
} else {
fav_svc
.remove_from_favorites(user.id, &item_id, item_type)
.await
.map_err(|e| {
AppError::internal_error(format!("Failed to remove favorite: {}", e))
})?;
}
}
}
// Return 207 Multi-Status with success response using quick_xml for safe escaping.
let href = nc_href(&user.username, subpath);
let mut buf = Vec::new();
{
let mut xml = Writer::new(&mut buf);
xml.write_event(Event::Text(BytesText::new(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>",
)))
.map_err(|e| AppError::internal_error(format!("XML write failed: {}", e)))?;
let mut ms = BytesStart::new("d:multistatus");
ms.push_attribute(("xmlns:d", "DAV:"));
ms.push_attribute(("xmlns:oc", "http://owncloud.org/ns"));
xml.write_event(Event::Start(ms))
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
xml.write_event(Event::Start(BytesStart::new("d:response")))
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
write_text_element(&mut xml, "d:href", &href)
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
xml.write_event(Event::Start(BytesStart::new("d:propstat")))
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
xml.write_event(Event::Start(BytesStart::new("d:prop")))
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
xml.write_event(Event::Empty(BytesStart::new("oc:favorite")))
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
xml.write_event(Event::End(BytesEnd::new("d:prop")))
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
write_text_element(&mut xml, "d:status", "HTTP/1.1 200 OK")
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
xml.write_event(Event::End(BytesEnd::new("d:propstat")))
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
xml.write_event(Event::End(BytesEnd::new("d:response")))
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
}
Ok(Response::builder()
.status(StatusCode::MULTI_STATUS)
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
.body(Body::from(buf))
.unwrap())
}
/// Parse the oc:favorite value from a PROPPATCH XML body using quick_xml.
fn parse_proppatch_favorite(body: &str) -> Option<u8> {
use quick_xml::Reader;
let mut reader = Reader::from_str(body);
let mut inside_favorite = false;
loop {
match reader.read_event() {
Ok(Event::Start(ref e)) => {
let local = e.local_name();
if local.as_ref() == b"favorite" {
inside_favorite = true;
}
}
Ok(Event::Text(ref e)) if inside_favorite => {
let text = e.decode().ok()?;
return text.trim().parse::<u8>().ok();
}
2026-05-04 12:46:57 +02:00
Ok(Event::End(ref e)) if e.local_name().as_ref() == b"favorite" => {
inside_favorite = false;
}
Ok(Event::Eof) => break,
Err(_) => break,
_ => {}
}
}
None
}
// ──────────────────── PUT ────────────────────
async fn handle_put(
state: Arc<AppState>,
req: Request<Body>,
user: &CurrentUser,
subpath: &str,
) -> Result<Response<Body>, AppError> {
let internal_path = nc_to_internal_path(&user.username, subpath)?;
let file_service = &state.applications.file_retrieval_service;
let upload_service = &state.applications.file_upload_service;
let claimed_type = req
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let oc_mtime = req
.headers()
.get("x-oc-mtime")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<i64>().ok());
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 body: {}", e)))?;
// Detect real MIME type via magic bytes + extension, falling back to client header.
let filename = filename_from_path(subpath);
let content_type = refine_content_type(&body_bytes, filename, &claimed_type);
// Check if the file already exists (update vs create).
let existing = file_service.get_file_by_path(&internal_path).await;
if existing.is_ok() {
// Update existing file — returns FileDto with fresh content-hash etag.
let updated = upload_service
.update_file(&internal_path, &body_bytes, &content_type, oc_mtime)
.await
.map_err(|e| AppError::internal_error(format!("Failed to update file: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.header(header::ETAG, format!("\"{}\"", updated.etag))
.header("oc-etag", format!("\"{}\"", updated.etag))
.body(Body::empty())
.unwrap());
}
// Create new file — split subpath into parent dir and filename.
let (parent_subpath, filename) = match subpath.rsplit_once('/') {
Some((parent, name)) => (parent, name),
None => ("", subpath),
};
let parent_internal = nc_to_internal_path(&user.username, parent_subpath)?;
let file_dto = upload_service
.create_file(&parent_internal, filename, &body_bytes, &content_type)
.await
.map_err(|e| AppError::internal_error(format!("Failed to create file: {}", e)))?;
let builder = Response::builder()
.status(StatusCode::CREATED)
.header(header::ETAG, format!("\"{}\"", file_dto.etag))
.header("oc-etag", format!("\"{}\"", file_dto.etag));
Ok(builder.body(Body::empty()).unwrap())
}
// ──────────────────── MKCOL ────────────────────
async fn handle_mkcol(
state: Arc<AppState>,
user: &CurrentUser,
subpath: &str,
) -> Result<Response<Body>, AppError> {
use crate::application::dtos::folder_dto::CreateFolderDto;
let folder_service = &state.applications.folder_service;
let internal_path = nc_to_internal_path(&user.username, subpath)?;
// If the folder already exists, return 405 per RFC 4918 §9.3.1
if folder_service
.get_folder_by_path(&internal_path)
.await
.is_ok()
{
return Ok(Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.body(Body::empty())
.unwrap());
}
// Collect path segments that need to be created (walk from root to leaf)
let segments: Vec<&str> = subpath.split('/').filter(|s| !s.is_empty()).collect();
let user_root = nc_to_internal_path(&user.username, "")?;
let mut current_path = user_root.clone();
let mut parent_id = folder_service
.get_folder_by_path(&user_root)
.await
.map_err(|_| AppError::not_found("User root folder not found"))?
.id
.clone();
for segment in &segments {
current_path = format!("{}/{}", current_path, segment);
match folder_service.get_folder_by_path(&current_path).await {
Ok(existing) => {
parent_id = existing.id.clone();
}
Err(_) => {
let dto = CreateFolderDto {
name: segment.to_string(),
parent_id: Some(parent_id.clone()),
};
match folder_service.create_folder_with_perms(dto, user.id).await {
Ok(created) => {
parent_id = created.id.clone();
}
Err(e)
if e.message.contains("already exists")
|| e.message.contains("Already Exists") =>
{
// Race condition — folder created concurrently
let folder = folder_service
.get_folder_by_path(&current_path)
.await
.map_err(|_| {
AppError::internal_error("Folder exists but cannot be found")
})?;
parent_id = folder.id.clone();
}
Err(e) => {
return Err(AppError::internal_error(format!(
"Failed to create folder: {}",
e
)));
}
}
}
}
}
Ok(Response::builder()
.status(StatusCode::CREATED)
.body(Body::empty())
.unwrap())
}
// ──────────────────── DELETE ────────────────────
async fn handle_delete(
state: Arc<AppState>,
user: &CurrentUser,
subpath: &str,
) -> Result<Response<Body>, AppError> {
let internal_path = nc_to_internal_path(&user.username, subpath)?;
let folder_service = &state.applications.folder_service;
let file_service = &state.applications.file_retrieval_service;
// Prefer soft-delete (move to trash) when trash service is available.
// This is what Nextcloud clients expect — items appear in the trashbin.
if let Some(trash_svc) = state.trash_service.as_ref() {
if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await {
trash_svc
.move_to_trash(&folder.id, "folder", user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to trash folder: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap());
}
if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
trash_svc
.move_to_trash(&file.id, "file", user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to trash file: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap());
}
return Err(AppError::not_found("Resource not found"));
}
// Fallback: hard delete when trash service is not available.
let file_mgmt = &state.applications.file_management_service;
if let Ok(folder) = folder_service.get_folder_by_path(&internal_path).await {
folder_service
.delete_folder_with_perms(&folder.id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete folder: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap());
}
if let Ok(file) = file_service.get_file_by_path(&internal_path).await {
file_mgmt
.delete_file_with_perms(&file.id, user.id)
.await
.map_err(|e| AppError::internal_error(format!("Failed to delete file: {}", e)))?;
return Ok(Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap());
}
Err(AppError::not_found("Resource not found"))
}
// ──────────────────── MOVE ────────────────────
async fn handle_move(
state: Arc<AppState>,
req: Request<Body>,
user: &CurrentUser,
subpath: &str,
) -> Result<Response<Body>, AppError> {
let destination = req
.headers()
.get("destination")
.and_then(|v| v.to_str().ok())
.ok_or_else(|| AppError::bad_request("Missing Destination header"))?
.to_string();
// Parse destination path: extract subpath after /remote.php/dav/files/{user}/
let dest_subpath = extract_nc_subpath_from_dest(&destination, &user.username)
.ok_or_else(|| AppError::bad_request("Invalid Destination URL"))?;
let src_internal = nc_to_internal_path(&user.username, subpath)?;
let folder_service = &state.applications.folder_service;
let file_service = &state.applications.file_retrieval_service;
let file_mgmt = &state.applications.file_management_service;
// Try as file first.
if let Ok(file) = file_service.get_file_by_path(&src_internal).await {
let (dest_parent_sub, dest_name) = match dest_subpath.rsplit_once('/') {
Some((parent, name)) => (parent, name),
None => ("", dest_subpath.as_str()),
};
let dest_parent_internal = nc_to_internal_path(&user.username, dest_parent_sub)?;
// Rename if only the name changes (same parent).
let src_parent_sub = match subpath.rsplit_once('/') {
Some((parent, _)) => parent,
None => "",
};
if src_parent_sub == dest_parent_sub {
// Same parent → rename.
file_mgmt
.rename_file_with_perms(&file.id, user.id, dest_name)
.await
.map_err(|e| AppError::internal_error(format!("Rename failed: {}", e)))?;
} else {
// Different parent → move.
let dest_parent = folder_service
.get_folder_by_path(&dest_parent_internal)
.await
.map_err(|_| AppError::not_found("Destination folder not found"))?;
file_mgmt
.move_file_with_perms(&file.id, user.id, Some(dest_parent.id.clone()))
.await
.map_err(|e| AppError::internal_error(format!("Move failed: {}", e)))?;
// If the filename changed too, rename after move.
if file.name != dest_name {
file_mgmt
.rename_file_with_perms(&file.id, user.id, dest_name)
.await
.map_err(|e| AppError::internal_error(format!("Rename failed: {}", e)))?;
}
}
// Return ETag and OC-ETag so Nextcloud clients can track the moved file.
let dest_internal = nc_to_internal_path(&user.username, &dest_subpath)?;
let mut builder = Response::builder().status(StatusCode::CREATED);
if let Ok(moved) = file_service.get_file_by_path(&dest_internal).await {
builder = builder
.header(header::ETAG, format!("\"{}\"", moved.id))
.header("oc-etag", format!("\"{}\"", moved.id));
}
return Ok(builder.body(Body::empty()).unwrap());
}
// Try as folder.
if let Ok(folder) = folder_service.get_folder_by_path(&src_internal).await {
let (dest_parent_sub, dest_name) = match dest_subpath.rsplit_once('/') {
Some((parent, name)) => (parent, name),
None => ("", dest_subpath.as_str()),
};
let dest_parent_internal = nc_to_internal_path(&user.username, dest_parent_sub)?;
let src_parent_sub = match subpath.rsplit_once('/') {
Some((parent, _)) => parent,
None => "",
};
if src_parent_sub == dest_parent_sub {
// Same parent → rename.
use crate::application::dtos::folder_dto::RenameFolderDto;
folder_service
.rename_folder_with_perms(
&folder.id,
RenameFolderDto {
name: dest_name.to_string(),
},
user.id,
)
.await
.map_err(|e| AppError::internal_error(format!("Rename failed: {}", e)))?;
} else {
// Different parent → move.
let dest_parent = folder_service
.get_folder_by_path(&dest_parent_internal)
.await
.map_err(|_| AppError::not_found("Destination parent not found"))?;
use crate::application::dtos::folder_dto::MoveFolderDto;
folder_service
.move_folder_with_perms(
&folder.id,
MoveFolderDto {
parent_id: Some(dest_parent.id.clone()),
},
user.id,
)
.await
.map_err(|e| AppError::internal_error(format!("Move failed: {}", e)))?;
// If the name changed too, rename.
if folder.name != dest_name {
use crate::application::dtos::folder_dto::RenameFolderDto;
folder_service
.rename_folder_with_perms(
&folder.id,
RenameFolderDto {
name: dest_name.to_string(),
},
user.id,
)
.await
.map_err(|e| AppError::internal_error(format!("Rename failed: {}", e)))?;
}
}
return Ok(Response::builder()
.status(StatusCode::CREATED)
.body(Body::empty())
.unwrap());
}
Err(AppError::not_found("Source resource not found"))
}
/// Extract the subpath from a Destination header URL.
///
/// Only accepts relative paths or absolute URLs whose path starts with the
/// expected DAV prefix. For full URLs the host is ignored — the path alone is
/// used — so an attacker cannot redirect the server to a different host.
fn extract_nc_subpath_from_dest(dest: &str, username: &str) -> Option<String> {
let prefix = format!("/remote.php/dav/files/{}/", username);
// For full URLs, extract the path portion (everything after the authority).
let path = if dest.starts_with("http://") || dest.starts_with("https://") {
// Find the start of the path after "scheme://host".
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())
}
// ────────────── Nextcloud PROPFIND XML Generation ──────────────
use crate::application::dtos::file_dto::FileDto;
use crate::application::dtos::folder_dto::FolderDto;
use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService;
/// Generate a complete Nextcloud-compatible multistatus XML response.
#[allow(clippy::too_many_arguments)]
async fn write_nc_multistatus<W: std::io::Write>(
writer: W,
folder: Option<&FolderDto>,
files: &[FileDto],
subfolders: &[FolderDto],
_request: &PropFindRequest,
depth: &str,
username: &str,
subpath: &str,
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
favorite_ids: &HashSet<String>,
) -> 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"));
ms.push_attribute(("xmlns:ocs", "http://open-collaboration-services.org/ns"));
xml.write_event(Event::Start(ms)).xml_err()?;
// Current folder entry.
if let Some(f) = folder {
let href = nc_href(username, subpath);
let file_id = resolve_folder_id(file_id_svc, &f.id).await;
let oc_id = file_id.map(|id| format_oc_id(id, file_id_svc));
write_folder_response(
&mut xml,
f,
&href,
file_id,
oc_id.as_deref(),
username,
favorite_ids,
)?;
}
// When folder is None, files are the target resource itself (single-file
// PROPFIND) and must always be emitted. When folder is Some, files/subfolders
// are children and should only be listed when depth > 0.
let emit_children = folder.is_none() || depth != "0";
if emit_children {
// Files.
for file in files {
let child_sub = if folder.is_none() {
// Single-file PROPFIND — subpath already points to the file.
subpath.to_string()
} else if subpath.is_empty() {
file.name.clone()
} else {
format!("{}/{}", subpath.trim_end_matches('/'), file.name)
};
let href = nc_href(username, &child_sub);
let file_id = resolve_file_id(file_id_svc, &file.id).await;
let oc_id = file_id.map(|id| format_oc_id(id, file_id_svc));
write_file_response(
&mut xml,
file,
&href,
file_id,
oc_id.as_deref(),
username,
favorite_ids,
)?;
}
// Subfolders.
for sf in subfolders {
let child_sub = if subpath.is_empty() {
sf.name.clone()
} else {
format!("{}/{}", subpath.trim_end_matches('/'), sf.name)
};
let href = format!("{}/", nc_href(username, &child_sub));
let file_id = resolve_folder_id(file_id_svc, &sf.id).await;
let oc_id = file_id.map(|id| format_oc_id(id, file_id_svc));
write_folder_response(
&mut xml,
sf,
&href,
file_id,
oc_id.as_deref(),
username,
favorite_ids,
)?;
}
}
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
.xml_err()?;
Ok(())
}
pub fn write_folder_response<W: std::io::Write>(
xml: &mut Writer<W>,
folder: &FolderDto,
href: &str,
file_id: Option<i64>,
oc_id: Option<&str>,
owner: &str,
favorite_ids: &HashSet<String>,
) -> Result<(), String> {
xml.write_event(Event::Start(BytesStart::new("d:response")))
.xml_err()?;
// href
write_text_element(xml, "d:href", href)?;
xml.write_event(Event::Start(BytesStart::new("d:propstat")))
.xml_err()?;
xml.write_event(Event::Start(BytesStart::new("d:prop")))
.xml_err()?;
// resourcetype
xml.write_event(Event::Start(BytesStart::new("d:resourcetype")))
.xml_err()?;
xml.write_event(Event::Empty(BytesStart::new("d:collection")))
.xml_err()?;
xml.write_event(Event::End(BytesEnd::new("d:resourcetype")))
.xml_err()?;
write_text_element(xml, "d:displayname", &folder.name)?;
let created_at =
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(folder.created_at), 0)
.unwrap_or_else(Utc::now);
let modified_at =
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(folder.modified_at), 0)
.unwrap_or_else(Utc::now);
write_text_element(xml, "d:getlastmodified", &modified_at.to_rfc2822())?;
write_text_element(xml, "d:getetag", &format!("\"{}\"", folder.id))?;
write_text_element(xml, "d:getcontenttype", "httpd/unix-directory")?;
write_text_element(xml, "d:getcontentlength", "0")?;
write_text_element(xml, "d:creationdate", &created_at.to_rfc3339())?;
// Nextcloud/ownCloud properties
if let Some(id) = file_id {
write_text_element(xml, "oc:fileid", &id.to_string())?;
}
if let Some(oid) = oc_id {
write_text_element(xml, "oc:id", oid)?;
}
write_text_element(xml, "oc:permissions", "RGDNVCK")?;
// Numeric share-permissions bitmask: Read=1 + Update=2 + Create=4 + Delete=8 + Share=16 = 31
write_text_element(xml, "ocs:share-permissions", "31")?;
write_text_element(xml, "oc:size", "0")?;
write_text_element(xml, "oc:owner-id", owner)?;
write_text_element(xml, "oc:owner-display-name", owner)?;
write_text_element(xml, "nc:has-preview", "false")?;
write_text_element(xml, "nc:is-encrypted", "0")?;
write_text_element(xml, "nc:mount-type", "")?;
let is_fav = if favorite_ids.contains(&folder.id) {
"1"
} else {
"0"
};
write_text_element(xml, "oc:favorite", is_fav)?;
// Empty share-types (no sharing API yet)
xml.write_event(Event::Empty(BytesStart::new("oc:share-types")))
.xml_err()?;
xml.write_event(Event::End(BytesEnd::new("d:prop")))
.xml_err()?;
write_text_element(xml, "d:status", "HTTP/1.1 200 OK")?;
xml.write_event(Event::End(BytesEnd::new("d:propstat")))
.xml_err()?;
xml.write_event(Event::End(BytesEnd::new("d:response")))
.xml_err()?;
Ok(())
}
pub fn write_file_response<W: std::io::Write>(
xml: &mut Writer<W>,
file: &FileDto,
href: &str,
file_id: Option<i64>,
oc_id: Option<&str>,
owner: &str,
favorite_ids: &HashSet<String>,
) -> Result<(), String> {
xml.write_event(Event::Start(BytesStart::new("d:response")))
.xml_err()?;
write_text_element(xml, "d:href", href)?;
xml.write_event(Event::Start(BytesStart::new("d:propstat")))
.xml_err()?;
xml.write_event(Event::Start(BytesStart::new("d:prop")))
.xml_err()?;
// resourcetype (empty for files)
xml.write_event(Event::Empty(BytesStart::new("d:resourcetype")))
.xml_err()?;
write_text_element(xml, "d:displayname", &file.name)?;
write_text_element(xml, "d:getcontenttype", &file.mime_type)?;
write_text_element(xml, "d:getcontentlength", &file.size.to_string())?;
let created_at = chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.created_at), 0)
.unwrap_or_else(Utc::now);
let modified_at =
chrono::DateTime::<Utc>::from_timestamp(timestamp_to_i64(file.modified_at), 0)
.unwrap_or_else(Utc::now);
write_text_element(xml, "d:getlastmodified", &modified_at.to_rfc2822())?;
write_text_element(xml, "d:getetag", &format!("\"{}\"", file.etag))?;
write_text_element(xml, "d:creationdate", &created_at.to_rfc3339())?;
// Nextcloud/ownCloud properties
if let Some(id) = file_id {
write_text_element(xml, "oc:fileid", &id.to_string())?;
}
if let Some(oid) = oc_id {
write_text_element(xml, "oc:id", oid)?;
}
write_text_element(xml, "oc:permissions", "RGDNVW")?;
// Numeric share-permissions bitmask: Read=1 + Update=2 + Delete=8 + Share=16 = 27
write_text_element(xml, "ocs:share-permissions", "27")?;
write_text_element(xml, "oc:size", &file.size.to_string())?;
write_text_element(xml, "oc:owner-id", owner)?;
write_text_element(xml, "oc:owner-display-name", owner)?;
let is_fav = if favorite_ids.contains(&file.id) {
"1"
} else {
"0"
};
write_text_element(xml, "oc:favorite", is_fav)?;
// Empty share-types (no sharing API yet)
xml.write_event(Event::Empty(BytesStart::new("oc:share-types")))
.xml_err()?;
// Check if file is an image that can have previews
let has_preview = matches!(
&*file.mime_type,
"image/jpeg" | "image/jpg" | "image/png" | "image/gif" | "image/webp"
);
write_text_element(
xml,
"nc:has-preview",
if has_preview { "true" } else { "false" },
)?;
write_text_element(xml, "nc:is-encrypted", "0")?;
write_text_element(xml, "nc:mount-type", "")?;
write_text_element(xml, "nc:creation_time", &file.created_at.to_string())?;
write_text_element(xml, "nc:upload_time", &file.modified_at.to_string())?;
xml.write_event(Event::End(BytesEnd::new("d:prop")))
.xml_err()?;
write_text_element(xml, "d:status", "HTTP/1.1 200 OK")?;
xml.write_event(Event::End(BytesEnd::new("d:propstat")))
.xml_err()?;
xml.write_event(Event::End(BytesEnd::new("d:response")))
.xml_err()?;
Ok(())
}
pub fn write_text_element<W: std::io::Write>(
xml: &mut Writer<W>,
tag: &str,
value: &str,
) -> Result<(), String> {
xml.write_event(Event::Start(BytesStart::new(tag)))
.xml_err()?;
xml.write_event(Event::Text(BytesText::new(value)))
.xml_err()?;
xml.write_event(Event::End(BytesEnd::new(tag))).xml_err()?;
Ok(())
}
pub async fn resolve_file_id(
svc: Option<&Arc<NextcloudFileIdService>>,
file_uuid: &str,
) -> Option<i64> {
let svc = svc?;
svc.get_or_create_file_id(file_uuid).await.ok()
}
pub async fn resolve_folder_id(
svc: Option<&Arc<NextcloudFileIdService>>,
folder_uuid: &str,
) -> Option<i64> {
let svc = svc?;
svc.get_or_create_folder_id(folder_uuid).await.ok()
}
pub fn format_oc_id(id: i64, svc: Option<&Arc<NextcloudFileIdService>>) -> String {
match svc {
Some(s) => s.format_oc_id(id),
None => format!("{:08}ocnca", id),
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── nc_to_internal_path ──
#[test]
fn test_empty_subpath_returns_home() {
assert_eq!(
nc_to_internal_path("alice", "").unwrap(),
"My Folder - alice"
);
}
#[test]
fn test_subpath_appended() {
assert_eq!(
nc_to_internal_path("alice", "Documents/work").unwrap(),
"My Folder - alice/Documents/work"
);
}
#[test]
fn test_strips_surrounding_slashes() {
assert_eq!(
nc_to_internal_path("alice", "/Photos/").unwrap(),
"My Folder - alice/Photos"
);
}
#[test]
fn test_rejects_dot_dot_traversal() {
assert!(nc_to_internal_path("alice", "../etc/passwd").is_err());
}
#[test]
fn test_rejects_single_dot() {
assert!(nc_to_internal_path("alice", "foo/./bar").is_err());
}
// ── nc_href ──
#[test]
fn test_href_root() {
assert_eq!(nc_href("alice", ""), "/remote.php/dav/files/alice/");
}
#[test]
fn test_href_encodes_spaces() {
assert_eq!(
nc_href("alice", "My Photos/vacation pic.jpg"),
"/remote.php/dav/files/alice/My%20Photos/vacation%20pic.jpg"
);
}
#[test]
fn test_href_encodes_special_chars() {
let href = nc_href("alice", "file#1.txt");
assert!(href.contains("file%231.txt"));
}
// ── extract_nc_subpath_from_dest ──
#[test]
fn test_extract_relative_path() {
let result = extract_nc_subpath_from_dest(
"/remote.php/dav/files/alice/Documents/moved.txt",
"alice",
);
assert_eq!(result.as_deref(), Some("Documents/moved.txt"));
}
#[test]
fn test_extract_absolute_url() {
let result = extract_nc_subpath_from_dest(
"https://cloud.example.com/remote.php/dav/files/alice/new.txt",
"alice",
);
assert_eq!(result.as_deref(), Some("new.txt"));
}
#[test]
fn test_extract_url_encoded() {
let result = extract_nc_subpath_from_dest(
"/remote.php/dav/files/alice/My%20Folder/file.txt",
"alice",
);
assert_eq!(result.as_deref(), Some("My Folder/file.txt"));
}
#[test]
fn test_extract_wrong_user_returns_none() {
let result = extract_nc_subpath_from_dest("/remote.php/dav/files/bob/secret.txt", "alice");
assert!(result.is_none());
}
// ── timestamp_to_i64 ──
#[test]
fn test_timestamp_normal() {
assert_eq!(timestamp_to_i64(1700000000), 1700000000i64);
}
#[test]
fn test_timestamp_overflow_returns_zero() {
assert_eq!(timestamp_to_i64(u64::MAX), 0);
}
}