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

446 lines
16 KiB
Rust
Raw Normal View History

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::file_ports::FileRetrievalUseCase;
use crate::application::ports::folder_ports::FolderUseCase;
use crate::application::ports::trash_ports::TrashUseCase;
use crate::common::di::AppState;
use crate::interfaces::errors::AppError;
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
use crate::interfaces::nextcloud::webdav_handler::{
batch_resolve_ids, extract_nc_subpath_from_dest, format_oc_id, nc_to_internal_path,
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: AuthUser,
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/") => {
let dest_header = req
.headers()
.get("destination")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
handle_restore(state, dest_header, &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>,
dest_header: Option<String>,
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"))?;
// RFC 4918 §9.9.4 + Sabre convention: clients send `Destination` to
// tell the server where the restored item should land. We don't yet
// honor it for relocation (restore always lands at the original
// path), but we DO honor it for the collision check: if the requested
// destination is taken by a live resource the move must be refused
// with 412 — there is no `Overwrite: T` workflow for trash restore in
// either Sabre/DAV or the NC desktop client (a live file being
// silently replaced by an undeleted one would be a footgun).
if let Some(dest_header) = dest_header
&& let Some(dest_subpath) = extract_nc_subpath_from_dest(&dest_header, &user.username)
{
let dest_internal = nc_to_internal_path(&user.username, &dest_subpath)?;
let folder_service = &state.applications.folder_service;
let file_service = &state.applications.file_retrieval_service;
let dest_taken = file_service.get_file_by_path(&dest_internal).await.is_ok()
|| folder_service
2026-06-18 23:02:17 +02:00
.get_folder_by_path(&dest_internal, user.id)
.await
.is_ok();
if dest_taken {
return Ok(Response::builder()
.status(StatusCode::PRECONDITION_FAILED)
.body(Body::empty())
.unwrap());
}
}
match trash_svc.restore_item(&id, user.id).await {
Ok(()) => Ok(Response::builder()
.status(StatusCode::CREATED)
.body(Body::empty())
.unwrap()),
Err(e) => {
// Collision at the original path — a live file/folder is sitting
// where the trashed one wants to come back to. Mirrors the G4/G5
// semantics in webdav_handler::handle_move ("Overwrite: F to an
// existing path → 412"); restore has no Overwrite header so the
// refusal is unconditional. The caller can resolve by renaming
// or trashing the conflicting live resource first.
//
// We string-match for the unique-index / duplicate-key signature
// because restore_item currently re-wraps every storage error as
// InternalError, so the original DomainError::AlreadyExists kind
// is not propagated. A follow-up should thread the kind through
// and let this be a kind-based check.
let msg = format!("{}", e);
if msg.contains("duplicate key")
|| msg.contains("unique constraint")
|| msg.to_ascii_lowercase().contains("already exists")
{
return Ok(Response::builder()
.status(StatusCode::PRECONDITION_FAILED)
.body(Body::empty())
.unwrap());
}
Err(AppError::internal_error(format!(
"Failed to restore item: {}",
e
)))
}
}
}
// ──────────────────── 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()
}
2026-06-18 23:02:17 +02:00
/// Strip the home-folder prefix from an original path to produce the
/// Nextcloud-relative original location.
///
/// TODO(D1): replace the hardcoded "Personal/" with the caller's actual
/// default-drive root folder name read from `drives.root_folder_id`.
/// Correct for D0-provisioned default drives; secondary drives keep
/// their original root name. The `_username` arg stays for now so the
/// upcoming dynamic lookup has a way to identify the caller.
fn strip_home_prefix<'a>(original_path: &'a str, _username: &str) -> &'a str {
original_path
.strip_prefix("Personal/")
.unwrap_or(original_path)
}
// ────────────── Trashbin PROPFIND XML Generation ──────────────
use crate::application::dtos::trash_dto::TrashedItemDto;
use crate::application::services::nextcloud_file_id_service::NextcloudFileIdService;
use std::collections::HashMap;
/// 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)?;
// Pre-resolve every oc:fileid in two batch queries by object type (was one
// INSERT round-trip per item). File and folder UUIDs are disjoint, so the
// two maps merge cleanly into one keyed by original_id.
let mut file_uuids: Vec<String> = Vec::new();
let mut folder_uuids: Vec<String> = Vec::new();
for item in items {
if item.item_type == "folder" {
folder_uuids.push(item.original_id.clone());
} else {
file_uuids.push(item.original_id.clone());
}
}
let (mut id_map, folder_id_map) =
batch_resolve_ids(file_id_svc, &file_uuids, &folder_uuids).await;
id_map.extend(folder_id_map);
// Individual trashed items.
for item in items {
write_trash_item_response(&mut xml, item, username, file_id_svc, &id_map)?;
}
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.
fn write_trash_item_response<W: std::io::Write>(
xml: &mut Writer<W>,
item: &TrashedItemDto,
username: &str,
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
id_map: &HashMap<String, i64>,
) -> 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 — resolved up front in a batch query.
let file_id = id_map.get(&item.original_id).copied();
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(())
}