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
+5 -1
View File
@@ -621,7 +621,11 @@ async fn oidc_callback(
user = %username,
"OIDC login completed Nextcloud Login Flow v2 successfully"
);
Ok(Redirect::temporary("/nextcloud-success.html"))
let nc_url = format!(
"nc://login/server:{}&user:{}&password:{}",
base_url, username, app_password
);
Ok(Redirect::temporary(&nc_url))
} else {
tracing::error!(
user = %username,
+9 -4
View File
@@ -196,16 +196,21 @@ pub async fn handle_login_submit(
base_url = %base_url,
"Login Flow v2: flow completed successfully"
);
// Redirect to nc:// deep link so the Nextcloud mobile app receives
// the credentials via Android/iOS intent. Desktop clients use polling
// instead, so they will pick up the result from the poll endpoint.
let nc_url = format!(
"nc://login/server:{}&user:{}&password:{}",
base_url, current_user.username, app_password
);
axum::response::Redirect::to(&nc_url).into_response()
} 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();
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
+13
View File
@@ -229,6 +229,19 @@ pub async fn handle_notifications_push() -> Response {
Json(ocs_ok(200, json!({}))).into_response()
}
/// GET /ocs/v2.php/apps/recommendations/api/v1/recommendations
///
/// Returns recommended files. Stub that returns an empty list.
pub async fn handle_recommendations() -> Response {
Json(json!({
"ocs": {
"meta": { "status": "ok", "statuscode": 200, "message": "OK" },
"data": []
}
}))
.into_response()
}
/// GET /ocs/v2.php/apps/files_sharing/api/v1/sharees?search={query}&itemType={type}
///
/// Returns matching users for the sharing autocomplete UI.
+4 -2
View File
@@ -37,8 +37,10 @@ pub async fn handle_preview(
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() {
// Parse the Nextcloud file ID — the NC app may append an instance suffix
// (e.g. "00000326ocnca"), so strip non-digit characters first.
let numeric_part: String = params.file_id.chars().take_while(|c| c.is_ascii_digit()).collect();
let nc_file_id: i64 = match numeric_part.parse() {
Ok(id) => id,
Err(_) => {
return Response::builder()
+28
View File
@@ -40,6 +40,11 @@ pub fn nextcloud_routes_with_state(state: Arc<AppState>) -> Router<Arc<AppState>
// Public routes — no auth required.
let public = Router::new()
.route("/status.php", get(status_handler::handle_status))
// NC connectivity check — app expects 204 to confirm server is reachable.
.route("/index.php/204", get(handle_connectivity_check))
// Bare /remote.php/dav — NC clients probe this to confirm WebDAV is available.
.route("/remote.php/dav", any(handle_dav_discovery))
.route("/remote.php/dav/", any(handle_dav_discovery))
.route(
"/index.php/login/v2",
post(login_v2_handler::handle_login_initiate),
@@ -96,6 +101,10 @@ pub fn nextcloud_routes_with_state(state: Arc<AppState>) -> Router<Arc<AppState>
"/ocs/v2.php/apps/notifications/api/v2/push",
post(ocs_handler::handle_notifications_push),
)
.route(
"/ocs/v2.php/apps/recommendations/api/v1/recommendations",
get(ocs_handler::handle_recommendations),
)
.route(
"/ocs/v2.php/apps/files_sharing/api/v1/sharees",
get(ocs_handler::handle_sharees_search),
@@ -254,3 +263,22 @@ async fn handle_dav_trashbin_root(
.await
.map_err(|e| e.into_response())
}
/// `GET /index.php/204` — NC app connectivity check. Returns 204 No Content.
async fn handle_connectivity_check() -> Response {
Response::builder()
.status(StatusCode::NO_CONTENT)
.body(Body::empty())
.unwrap()
}
/// Bare `/remote.php/dav` — NC clients (especially Android) probe this endpoint
/// during server discovery to confirm WebDAV is available.
async fn handle_dav_discovery() -> Response {
Response::builder()
.status(StatusCode::OK)
.header("DAV", "1, 3")
.header("Allow", "OPTIONS, GET, HEAD, PROPFIND")
.body(Body::empty())
.unwrap()
}
+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)