fix(nc): enable Nextcloud Android app connectivity and uploads

- Add /remote.php/dav discovery endpoint for Android app server detection
- Add /index.php/204 connectivity check endpoint (returns 204 No Content)
- Redirect login flow to nc:// deep link for mobile credential delivery
- Support GET/HEAD on folders (NC clients use as existence checks)
- Recursive MKCOL to create missing parent directories
- Fix single-file PROPFIND returning empty multistatus response
- Strip instance suffix from preview fileId (e.g. "00000326ocnca")
- Add recommendations stub endpoint
This commit is contained in:
Jared Wolff
2026-03-05 20:42:30 -05:00
parent 3fc408d72d
commit d8eecbd9ca
6 changed files with 180 additions and 42 deletions
+121 -35
View File
@@ -277,8 +277,31 @@ async fn handle_get(
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)
@@ -311,8 +334,31 @@ async fn handle_head(
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)
@@ -552,43 +598,75 @@ async fn handle_mkcol(
use crate::application::dtos::folder_dto::CreateFolderDto;
let folder_service = &state.applications.folder_service;
let internal_path = nc_to_internal_path(&user.username, subpath)?;
// Split into parent + new folder name.
let (parent_subpath, folder_name) = match subpath.rsplit_once('/') {
Some((parent, name)) => (parent, name),
None => ("", subpath),
};
let parent_internal = nc_to_internal_path(&user.username, parent_subpath)?;
// Resolve parent folder ID.
let parent_folder = folder_service
.get_folder_by_path(&parent_internal)
// If the folder already exists, return 405 per RFC 4918 §9.3.1
if folder_service
.get_folder_by_path(&internal_path)
.await
.map_err(|_| AppError::not_found("Parent folder not found"))?;
let dto = CreateFolderDto {
name: folder_name.to_string(),
parent_id: Some(parent_folder.id.clone()),
};
match folder_service.create_folder(dto).await {
Ok(_) => Ok(Response::builder()
.status(StatusCode::CREATED)
.is_ok()
{
return Ok(Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.body(Body::empty())
.unwrap()),
Err(e) if e.message.contains("already exists") || e.message.contains("Already Exists") => {
// RFC 4918 §9.3.1: MKCOL on existing resource → 405
Ok(Response::builder()
.status(StatusCode::METHOD_NOT_ALLOWED)
.body(Body::empty())
.unwrap())
}
Err(e) => Err(AppError::internal_error(format!(
"Failed to create folder: {}",
e
))),
.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(dto).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 ────────────────────
@@ -874,10 +952,18 @@ async fn write_nc_multistatus<W: std::io::Write>(
)?;
}
if depth != "0" {
// 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 subpath.is_empty() {
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)