Merge pull request #538 from swissiety/webdav-litmus-compliance
implement dead properties for nextcloud handler and fixup frontend migration leftover
This commit is contained in:
@@ -105,7 +105,6 @@ FROM base AS builder-cache
|
||||
WORKDIR /app
|
||||
COPY Cargo.toml Cargo.lock build.rs ./
|
||||
COPY src src
|
||||
COPY static static
|
||||
COPY migrations migrations
|
||||
COPY templates templates
|
||||
COPY --from=frontend /static-dist ./static-dist
|
||||
|
||||
@@ -72,6 +72,46 @@ impl std::fmt::Display for QualifiedName {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether PROPPATCH must refuse to set/remove this property as a dead
|
||||
/// property (RFC 4918 §9.2 — server MAY reject a PROPPATCH attempt on a
|
||||
/// live property; DeadPropertyStore has no business holding a value that
|
||||
/// PROPFIND / REPORT already emit from live server state).
|
||||
pub fn is_protected_property(qn: &QualifiedName) -> bool {
|
||||
match qn.namespace.as_str() {
|
||||
// RFC 4918 §15 — the DAV: namespace is server-owned in its
|
||||
// entirety. Any PROPPATCH into it either forges a live
|
||||
// property (dual-emission) or accumulates unread garbage
|
||||
// (silent litter).
|
||||
"DAV:" => true,
|
||||
|
||||
// Every name below appears verbatim in write_folder_response
|
||||
// / write_file_response in the NC handler. Adding a new
|
||||
// live emitter → add its name here.
|
||||
"http://owncloud.org/ns" => matches!(
|
||||
qn.name.as_str(),
|
||||
"favorite"
|
||||
| "fileid"
|
||||
| "id"
|
||||
| "owner-id"
|
||||
| "owner-display-name"
|
||||
| "permissions"
|
||||
| "share-types"
|
||||
| "size"
|
||||
),
|
||||
|
||||
"http://nextcloud.org/ns" => matches!(
|
||||
qn.name.as_str(),
|
||||
"has-preview" | "is-encrypted" | "mount-type" | "creation_time" | "upload_time"
|
||||
),
|
||||
|
||||
"http://open-collaboration-services.org/ns" => {
|
||||
matches!(qn.name.as_str(), "share-permissions")
|
||||
}
|
||||
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// PROPFIND request type
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum PropFindType {
|
||||
@@ -461,7 +501,12 @@ impl WebDavAdapter {
|
||||
///
|
||||
/// Written AFTER the live-property propstats inside a `<D:response>`.
|
||||
/// Only emitted when `dead_props` is non-empty.
|
||||
fn write_dead_props_propstat<W: Write>(
|
||||
///
|
||||
/// `pub(crate)` so the NextCloud-compatible handler
|
||||
/// (`interfaces::nextcloud::webdav_handler`) can append the same
|
||||
/// dead-property block to its own bespoke PROPFIND writers instead
|
||||
/// of duplicating this XML shape.
|
||||
pub(crate) fn write_dead_props_propstat<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
dead_props: &[(QualifiedName, Option<String>)],
|
||||
) -> Result<()> {
|
||||
|
||||
@@ -18,7 +18,7 @@ use quick_xml::Writer;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::adapters::webdav_adapter::{
|
||||
LockInfo, PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter,
|
||||
LockInfo, PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, is_protected_property,
|
||||
};
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
@@ -980,6 +980,12 @@ async fn handle_proppatch(
|
||||
let mut results: Vec<(&QualifiedName, bool)> = Vec::new();
|
||||
for op in &ops {
|
||||
match op {
|
||||
PropPatchOp::Set(pv) if is_protected_property(&pv.name) => {
|
||||
results.push((&pv.name, false));
|
||||
}
|
||||
PropPatchOp::Remove(name) if is_protected_property(name) => {
|
||||
results.push((name, false));
|
||||
}
|
||||
PropPatchOp::Set(pv) => {
|
||||
dead_props
|
||||
.set(resource_ref, pv.name.clone(), pv.value.clone())
|
||||
@@ -1334,7 +1340,11 @@ async fn resolve_or_legacy(
|
||||
/// the dead-prop lookup is broken; surfacing a 500 here would mask the
|
||||
/// resource entirely from sync clients. The legacy path-keyed lookup
|
||||
/// behaved the same way (`.unwrap_or_default()`); we preserve it.
|
||||
async fn file_dead_props(
|
||||
///
|
||||
/// `pub(crate)` — also reused by the NextCloud-compatible PROPFIND
|
||||
/// handler (`interfaces::nextcloud::webdav_handler`), which needs the
|
||||
/// same lenient fetch for its own response writers.
|
||||
pub(crate) async fn file_dead_props(
|
||||
state: &Arc<AppState>,
|
||||
file: &FileDto,
|
||||
) -> Vec<(QualifiedName, Option<String>)> {
|
||||
@@ -1349,8 +1359,9 @@ async fn file_dead_props(
|
||||
}
|
||||
|
||||
/// Same shape as `file_dead_props` but for folder rows. Used by the
|
||||
/// streaming PROPFIND walker.
|
||||
async fn folder_dead_props(
|
||||
/// streaming PROPFIND walker (and, via `pub(crate)`, by the NextCloud
|
||||
/// handler's own streaming walker).
|
||||
pub(crate) async fn folder_dead_props(
|
||||
store: &DeadPropertyStore,
|
||||
folder: &FolderDto,
|
||||
) -> Vec<(QualifiedName, Option<String>)> {
|
||||
@@ -1366,7 +1377,7 @@ async fn folder_dead_props(
|
||||
/// File-leaf variant for the streaming walker (takes a `&DeadPropertyStore`
|
||||
/// rather than the full `&Arc<AppState>` so it can be called from inside
|
||||
/// the async-stream future without cloning state).
|
||||
async fn streamed_file_dead_props(
|
||||
pub(crate) async fn streamed_file_dead_props(
|
||||
store: &DeadPropertyStore,
|
||||
file: &FileDto,
|
||||
) -> Vec<(QualifiedName, Option<String>)> {
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::application::ports::folder_ports::FolderUseCase;
|
||||
use crate::application::ports::inbound::SearchUseCase;
|
||||
use crate::common::di::AppState;
|
||||
use crate::domain::entities::file::File;
|
||||
use crate::interfaces::api::handlers::webdav_handler::{file_dead_props, folder_dead_props};
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::nextcloud::webdav_handler::{
|
||||
batch_resolve_ids, format_oc_id, nc_href, write_file_response, write_folder_response,
|
||||
@@ -178,14 +179,15 @@ async fn handle_filter_files(
|
||||
let href = nc_href(url_user, subpath);
|
||||
let fid = file_id_map.get(&file.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
let dead = file_dead_props(&state, file).await;
|
||||
write_file_response(
|
||||
&mut xml,
|
||||
file,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
(fid, oc_id.as_deref()),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
&dead,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
@@ -203,14 +205,15 @@ async fn handle_filter_files(
|
||||
let href = format!("{}/", nc_href(url_user, subpath));
|
||||
let fid = folder_id_map.get(&folder.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
let dead = folder_dead_props(&state.webdav_dead_props, folder).await;
|
||||
write_folder_response(
|
||||
&mut xml,
|
||||
folder,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
(fid, oc_id.as_deref()),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
&dead,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
@@ -308,14 +311,15 @@ async fn handle_search(
|
||||
let href = nc_href(url_user, subpath);
|
||||
let fid = file_id_map.get(&file.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
let dead = file_dead_props(&state, file).await;
|
||||
write_file_response(
|
||||
&mut xml,
|
||||
file,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
(fid, oc_id.as_deref()),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
&dead,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
@@ -334,14 +338,15 @@ async fn handle_search(
|
||||
let href = format!("{}/", nc_href(url_user, subpath));
|
||||
let fid = folder_id_map.get(&folder.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
let dead = folder_dead_props(&state.webdav_dead_props, folder).await;
|
||||
write_folder_response(
|
||||
&mut xml,
|
||||
folder,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
(fid, oc_id.as_deref()),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
&dead,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::adapters::webdav_adapter::{PropFindRequest, WebDavAdapter};
|
||||
use crate::application::adapters::webdav_adapter::{
|
||||
PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, is_protected_property,
|
||||
};
|
||||
use crate::application::dtos::pagination::PaginationRequestDto;
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::favorites_ports::FavoritesUseCase;
|
||||
@@ -26,7 +28,10 @@ use crate::common::di::AppState;
|
||||
use crate::common::mime_detect::filename_from_path;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
|
||||
use crate::interfaces::api::handlers::webdav_handler::PROPFIND_BATCH_SIZE;
|
||||
use crate::infrastructure::services::webdav_dead_property_store::ResourceRef;
|
||||
use crate::interfaces::api::handlers::webdav_handler::{
|
||||
PROPFIND_BATCH_SIZE, file_dead_props, folder_dead_props, streamed_file_dead_props,
|
||||
};
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::range_requests::{not_modified_response, range_response};
|
||||
use crate::interfaces::upload_ingest::ingest_body_to_cas;
|
||||
@@ -371,6 +376,8 @@ async fn handle_propfind(
|
||||
let nc = state.nextcloud.as_ref();
|
||||
let file_id_svc = nc.map(|n| &n.file_ids);
|
||||
|
||||
let dead_props = file_dead_props(&state, &file).await;
|
||||
|
||||
let mut buf = Vec::new();
|
||||
write_nc_file_multistatus(
|
||||
&mut buf,
|
||||
@@ -379,7 +386,7 @@ async fn handle_propfind(
|
||||
&user.username,
|
||||
subpath,
|
||||
file_id_svc,
|
||||
&favorite_ids,
|
||||
(&favorite_ids, &dead_props),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
|
||||
@@ -620,6 +627,12 @@ async fn handle_head(
|
||||
|
||||
// ──────────────────── PROPPATCH ────────────────────
|
||||
|
||||
/// The `oc:favorite` element is live server state routed through the
|
||||
/// favorites service, not a dead property — every other
|
||||
/// namespace/local-name pair PROPPATCH sends is stored verbatim via
|
||||
/// `DeadPropertyStore`.
|
||||
const OC_FAVORITE_NS: &str = "http://owncloud.org/ns";
|
||||
|
||||
async fn handle_proppatch(
|
||||
state: Arc<AppState>,
|
||||
req: Request<Body>,
|
||||
@@ -633,171 +646,139 @@ async fn handle_proppatch(
|
||||
.await
|
||||
.map_err(|e| AppError::bad_request(format!("Failed to read body: {}", e)))?;
|
||||
|
||||
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||
|
||||
// Resolve the target resource once — needed for two things:
|
||||
// 1. Applying the oc:favorite mutation when the PROPPATCH body
|
||||
// carries one (`item_type` distinguishes file vs folder rows
|
||||
// in the favorites table).
|
||||
// 2. Picking the right `<d:href>` shape in the multi-status
|
||||
// Resolve the target resource — needed for three things:
|
||||
// 1. The dead-property store key is the resource id (folder_id
|
||||
// XOR file_id), so we need a `ResourceRef`.
|
||||
// 2. Applying the oc:favorite mutation (`item_type` distinguishes
|
||||
// file vs folder rows in the favorites table).
|
||||
// 3. Picking the right `<d:href>` shape in the multi-status
|
||||
// response: collection (folder) hrefs MUST end in `/` per
|
||||
// RFC 4918 §5.2 — see `nc_collection_href` for the full
|
||||
// reasoning. Without this distinction the NC desktop client
|
||||
// parser aborted on PROPFIND; PROPPATCH would hit the same
|
||||
// wall the moment the user favourited a folder.
|
||||
// reasoning.
|
||||
//
|
||||
// When the resource is missing we tolerate it for the no-op
|
||||
// PROPPATCH path (no favorite directive in the body) — matches
|
||||
// the prior behaviour. A PROPPATCH that *does* try to set
|
||||
// favorite on a missing resource still returns NotFound.
|
||||
// A missing resource is now always a 404: unlike the previous
|
||||
// favorite-only implementation (which merely re-declared success
|
||||
// without doing anything), this handler performs real writes, so
|
||||
// silently no-opping on a nonexistent path would be a foot-gun —
|
||||
// matches the native `/webdav/` handler's contract.
|
||||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||||
|
||||
// Single-query path resolution — PROPPATCH may target either a
|
||||
// folder or a file. Post-D7 the resolver is drive-scoped, so we
|
||||
// `authz.require(Read, …)` on the returned resource before
|
||||
// reading its type. The favorite mutation below itself doesn't
|
||||
// require additional authz (favorites are per-user; the caller can
|
||||
// favourite any resource they can see).
|
||||
let resource = match nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id).await {
|
||||
Some(ResolvedResource::File(f)) => {
|
||||
let file_uuid =
|
||||
Uuid::parse_str(&f.id).map_err(|_| AppError::not_found("Resource not found"))?;
|
||||
let (resource_ref, item_id, item_type, is_collection) =
|
||||
match nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id).await {
|
||||
Some(ResolvedResource::File(file)) => {
|
||||
let id = Uuid::parse_str(&file.id)
|
||||
.map_err(|_| AppError::not_found("Resource not found"))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::File(file_uuid),
|
||||
)
|
||||
.require(Subject::User(user.id), Permission::Read, Resource::File(id))
|
||||
.await?;
|
||||
Some((f.id, "file"))
|
||||
(ResourceRef::File(id), file.id, "file", false)
|
||||
}
|
||||
Some(ResolvedResource::Folder(folder)) => {
|
||||
let folder_uuid = Uuid::parse_str(&folder.id)
|
||||
let id = Uuid::parse_str(&folder.id)
|
||||
.map_err(|_| AppError::not_found("Resource not found"))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::Folder(folder_uuid),
|
||||
Resource::Folder(id),
|
||||
)
|
||||
.await?;
|
||||
Some((folder.id, "folder"))
|
||||
(ResourceRef::Folder(id), folder.id, "folder", true)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let is_collection = matches!(resource, Some((_, "folder")));
|
||||
|
||||
// Parse oc:favorite value from PROPPATCH XML.
|
||||
let favorite_value = parse_proppatch_favorite(&body_str);
|
||||
|
||||
if let Some(value) = favorite_value {
|
||||
let Some((item_id, item_type)) = resource else {
|
||||
return Err(AppError::not_found("Resource not found"));
|
||||
None => return Err(AppError::not_found("Resource not found")),
|
||||
};
|
||||
|
||||
let ops = WebDavAdapter::parse_proppatch(body_bytes.reader())
|
||||
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH request: {}", e)))?;
|
||||
|
||||
let dead_props = &state.webdav_dead_props;
|
||||
let mut results: Vec<(&QualifiedName, bool)> = Vec::new();
|
||||
for op in &ops {
|
||||
let is_favorite =
|
||||
|name: &QualifiedName| name.namespace == OC_FAVORITE_NS && name.name == "favorite";
|
||||
match op {
|
||||
PropPatchOp::Set(pv) if is_favorite(&pv.name) => {
|
||||
if let Some(fav_svc) = state.favorites_service.as_ref() {
|
||||
if value == 1 {
|
||||
if pv.value.as_deref().map(str::trim) == Some("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))
|
||||
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))
|
||||
AppError::internal_error(format!("Failed to remove favorite: {e}"))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
results.push((&pv.name, true));
|
||||
}
|
||||
PropPatchOp::Remove(name) if is_favorite(name) => {
|
||||
if let Some(fav_svc) = state.favorites_service.as_ref() {
|
||||
fav_svc
|
||||
.remove_from_favorites(user.id, &item_id, item_type)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to remove favorite: {e}"))
|
||||
})?;
|
||||
}
|
||||
results.push((name, true));
|
||||
}
|
||||
PropPatchOp::Set(pv) if is_protected_property(&pv.name) => {
|
||||
results.push((&pv.name, false));
|
||||
}
|
||||
PropPatchOp::Remove(name) if is_protected_property(name) => {
|
||||
results.push((name, false));
|
||||
}
|
||||
PropPatchOp::Set(pv) => {
|
||||
dead_props
|
||||
.set(resource_ref, pv.name.clone(), pv.value.clone())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to store dead property: {e}"))
|
||||
})?;
|
||||
results.push((&pv.name, true));
|
||||
}
|
||||
PropPatchOp::Remove(name) => {
|
||||
dead_props.remove(resource_ref, name).await.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to remove dead property: {e}"))
|
||||
})?;
|
||||
results.push((name, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return 207 Multi-Status with success response using quick_xml
|
||||
// for safe escaping. Collection vs file href chosen by resource
|
||||
// type to satisfy the RFC 4918 §5.2 trailing-slash invariant —
|
||||
// see the comment block at the top of this function.
|
||||
// Collection vs file href chosen by resource type to satisfy the
|
||||
// RFC 4918 §5.2 trailing-slash invariant — see the comment block
|
||||
// at the top of this function.
|
||||
let href = if is_collection {
|
||||
nc_collection_href(url_user, subpath)
|
||||
} else {
|
||||
nc_href(url_user, 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)))?;
|
||||
}
|
||||
let mut response_body = Vec::new();
|
||||
WebDavAdapter::generate_proppatch_response(&mut response_body, &href, &results).map_err(
|
||||
|e| AppError::internal_error(format!("Failed to generate PROPPATCH response: {}", e)),
|
||||
)?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::MULTI_STATUS)
|
||||
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||
.body(Body::from(buf))
|
||||
.body(Body::from(response_body))
|
||||
.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();
|
||||
}
|
||||
Ok(Event::End(ref e)) if e.local_name().as_ref() == b"favorite" => {
|
||||
inside_favorite = false;
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ──────────────────── PUT ────────────────────
|
||||
|
||||
/// Strip the optional `W/` weak prefix and surrounding double-quotes
|
||||
@@ -1441,6 +1422,10 @@ fn write_nc_multistatus_open<W: std::io::Write>(xml: &mut Writer<W>) -> Result<(
|
||||
|
||||
/// Generate the multistatus XML for a single-file PROPFIND. The folder
|
||||
/// case streams via [`build_nc_streaming_propfind`] instead.
|
||||
///
|
||||
/// `extras` bundles `(favorite_ids, dead_props)` — both are per-resource
|
||||
/// decorations fetched by the caller — to stay under clippy's
|
||||
/// argument-count lint.
|
||||
async fn write_nc_file_multistatus<W: std::io::Write>(
|
||||
writer: W,
|
||||
file: &FileDto,
|
||||
@@ -1448,8 +1433,9 @@ async fn write_nc_file_multistatus<W: std::io::Write>(
|
||||
username: &str,
|
||||
subpath: &str,
|
||||
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
|
||||
favorite_ids: &HashSet<String>,
|
||||
extras: (&HashSet<String>, &[(QualifiedName, Option<String>)]),
|
||||
) -> Result<(), String> {
|
||||
let (favorite_ids, dead_props) = extras;
|
||||
let (file_id_map, _) =
|
||||
batch_resolve_ids(file_id_svc, std::slice::from_ref(&file.id), &[]).await;
|
||||
|
||||
@@ -1468,10 +1454,10 @@ async fn write_nc_file_multistatus<W: std::io::Write>(
|
||||
&mut xml,
|
||||
file,
|
||||
&href,
|
||||
file_id,
|
||||
oc_id.as_deref(),
|
||||
(file_id, oc_id.as_deref()),
|
||||
username,
|
||||
favorite_ids,
|
||||
dead_props,
|
||||
)?;
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
|
||||
@@ -1514,6 +1500,7 @@ fn build_nc_streaming_propfind(
|
||||
};
|
||||
let (_, folder_id_map) =
|
||||
batch_resolve_ids(file_id_svc, &[], std::slice::from_ref(&folder.id)).await;
|
||||
let folder_dead = folder_dead_props(&state.webdav_dead_props, &folder).await;
|
||||
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
{
|
||||
@@ -1522,7 +1509,7 @@ fn build_nc_streaming_propfind(
|
||||
let href = nc_collection_href(&username, &subpath);
|
||||
let fid = folder_id_map.get(&folder.id).copied();
|
||||
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(), &username, &folder_favs)
|
||||
write_folder_response(&mut xml, &folder, &href, (fid, oc_id.as_deref()), &username, &folder_favs, &folder_dead)
|
||||
.map_err(std::io::Error::other)?;
|
||||
}
|
||||
yield Bytes::from(buf);
|
||||
@@ -1551,11 +1538,15 @@ fn build_nc_streaming_propfind(
|
||||
};
|
||||
let file_uuids: Vec<String> = batch.iter().map(|f| f.id.clone()).collect();
|
||||
let (file_id_map, _) = batch_resolve_ids(file_id_svc, &file_uuids, &[]).await;
|
||||
let mut file_deads = Vec::with_capacity(batch_len);
|
||||
for file in &batch {
|
||||
file_deads.push(streamed_file_dead_props(&state.webdav_dead_props, file).await);
|
||||
}
|
||||
|
||||
let mut chunk = Vec::with_capacity(batch_len * 1024);
|
||||
{
|
||||
let mut xml = Writer::new(&mut chunk);
|
||||
for file in &batch {
|
||||
for (file, dead) in batch.iter().zip(file_deads.iter()) {
|
||||
let child_sub = if subpath.is_empty() {
|
||||
file.name.clone()
|
||||
} else {
|
||||
@@ -1564,7 +1555,7 @@ fn build_nc_streaming_propfind(
|
||||
let href = nc_href(&username, &child_sub);
|
||||
let fid = file_id_map.get(&file.id).copied();
|
||||
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(), &username, &favs)
|
||||
write_file_response(&mut xml, file, &href, (fid, oc_id.as_deref()), &username, &favs, dead)
|
||||
.map_err(std::io::Error::other)?;
|
||||
}
|
||||
}
|
||||
@@ -1600,11 +1591,15 @@ fn build_nc_streaming_propfind(
|
||||
};
|
||||
let folder_uuids: Vec<String> = result.items.iter().map(|sf| sf.id.clone()).collect();
|
||||
let (_, sub_id_map) = batch_resolve_ids(file_id_svc, &[], &folder_uuids).await;
|
||||
let mut sub_deads = Vec::with_capacity(result.items.len());
|
||||
for sf in &result.items {
|
||||
sub_deads.push(folder_dead_props(&state.webdav_dead_props, sf).await);
|
||||
}
|
||||
|
||||
let mut chunk = Vec::with_capacity(result.items.len() * 1024);
|
||||
{
|
||||
let mut xml = Writer::new(&mut chunk);
|
||||
for sf in &result.items {
|
||||
for (sf, dead) in result.items.iter().zip(sub_deads.iter()) {
|
||||
let child_sub = if subpath.is_empty() {
|
||||
sf.name.clone()
|
||||
} else {
|
||||
@@ -1613,7 +1608,7 @@ fn build_nc_streaming_propfind(
|
||||
let href = nc_collection_href(&username, &child_sub);
|
||||
let fid = sub_id_map.get(&sf.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
write_folder_response(&mut xml, sf, &href, fid, oc_id.as_deref(), &username, &favs)
|
||||
write_folder_response(&mut xml, sf, &href, (fid, oc_id.as_deref()), &username, &favs, dead)
|
||||
.map_err(std::io::Error::other)?;
|
||||
}
|
||||
}
|
||||
@@ -1648,15 +1643,20 @@ fn build_nc_streaming_propfind(
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// `oc_ids` bundles `(file_id, oc_id)` — always fetched and passed
|
||||
/// together (`oc_id` is derived from `file_id`) — to stay under
|
||||
/// clippy's argument-count lint now that `dead_props` is also threaded
|
||||
/// through.
|
||||
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>,
|
||||
oc_ids: (Option<i64>, Option<&str>),
|
||||
owner: &str,
|
||||
favorite_ids: &HashSet<String>,
|
||||
dead_props: &[(QualifiedName, Option<String>)],
|
||||
) -> Result<(), String> {
|
||||
let (file_id, oc_id) = oc_ids;
|
||||
xml.write_event(Event::Start(BytesStart::new("d:response")))
|
||||
.xml_err()?;
|
||||
|
||||
@@ -1727,21 +1727,26 @@ pub fn write_folder_response<W: std::io::Write>(
|
||||
xml.write_event(Event::End(BytesEnd::new("d:propstat")))
|
||||
.xml_err()?;
|
||||
|
||||
WebDavAdapter::write_dead_props_propstat(xml, dead_props).xml_err()?;
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:response")))
|
||||
.xml_err()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// See `write_folder_response` for why `(file_id, oc_id)` are bundled
|
||||
/// into `oc_ids`.
|
||||
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>,
|
||||
oc_ids: (Option<i64>, Option<&str>),
|
||||
owner: &str,
|
||||
favorite_ids: &HashSet<String>,
|
||||
dead_props: &[(QualifiedName, Option<String>)],
|
||||
) -> Result<(), String> {
|
||||
let (file_id, oc_id) = oc_ids;
|
||||
xml.write_event(Event::Start(BytesStart::new("d:response")))
|
||||
.xml_err()?;
|
||||
|
||||
@@ -1816,6 +1821,8 @@ pub fn write_file_response<W: std::io::Write>(
|
||||
xml.write_event(Event::End(BytesEnd::new("d:propstat")))
|
||||
.xml_err()?;
|
||||
|
||||
WebDavAdapter::write_dead_props_propstat(xml, dead_props).xml_err()?;
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:response")))
|
||||
.xml_err()?;
|
||||
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
# =============================================================
|
||||
# OxiCloud — NextCloud WebDAV: dead-properties (RFC 4918 §4.2)
|
||||
# =============================================================
|
||||
# `tests/api/webdav_dead_properties.hurl` covers the native
|
||||
# `/webdav/` surface end-to-end. This file covers the same
|
||||
# PROPPATCH/PROPFIND contract on the NextCloud-compatible surface
|
||||
# (`/remote.php/dav/files/{user}/...`), which — until now — had NO
|
||||
# generic dead-property support: PROPPATCH only special-cased
|
||||
# `oc:favorite` via an ad hoc XML scan and silently discarded any
|
||||
# other property while still claiming `200 OK`; PROPFIND always
|
||||
# emitted a fixed hardcoded property set with no dead-property
|
||||
# lookup at all. A client (or litmus) PROPPATCHing a custom label
|
||||
# through the NextCloud mount got a false success and then never
|
||||
# saw the property again.
|
||||
#
|
||||
# Coverage:
|
||||
# 1. Setup: JWT login, mint an NC app password.
|
||||
# 2. PUT a probe file via the NC DAV surface.
|
||||
# 3. PROPPATCH set a custom property → 207.
|
||||
# 4. PROPFIND → value round-trips verbatim.
|
||||
# 5. PROPPATCH upsert (same name, new value) → PROPFIND confirms
|
||||
# overwrite, not a duplicate row.
|
||||
# 6. PROPPATCH remove → PROPFIND confirms absence.
|
||||
# 7. PROPPATCH on a nonexistent resource → 404 (the tightened
|
||||
# contract: PROPPATCH now does real work, so a previous
|
||||
# "always claim success" no-op on a missing resource would be
|
||||
# a foot-gun, not a feature).
|
||||
# 8. Re-set a property, MOVE the file → PROPFIND on the new path
|
||||
# still returns it (resource id is stable across MOVE).
|
||||
# 9. DELETE, then PUT a fresh file at the same path → PROPFIND
|
||||
# does NOT see the old marker (new resource, no leaked state).
|
||||
# 10. Regression guard: `oc:favorite` PROPPATCH/PROPFIND still
|
||||
# works, unaffected by the refactor from the ad hoc favorite
|
||||
# scanner to generic `WebDavAdapter::parse_proppatch`.
|
||||
# 11. Folder coverage: MKCOL, PROPPATCH a dead property on the
|
||||
# folder, PROPFIND confirms it, cleanup.
|
||||
#
|
||||
# XPath assertions use `local-name()` so the test is robust against
|
||||
# the server's chosen namespace prefix for dead properties (`X:`).
|
||||
#
|
||||
# NOTE: in Hurl, [BasicAuth] must be the LAST section before the
|
||||
# blank-line/body — any request headers go above it, not below.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — JWT login, then mint an NC app password (NC DAV uses
|
||||
# Basic Auth, not the JWT bearer token).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
jwt: jsonpath "$.access_token"
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/app-passwords
|
||||
Authorization: Bearer {{jwt}}
|
||||
Content-Type: application/json
|
||||
{ "label": "nc_webdav_dead_properties" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
nc_username: jsonpath "$.username"
|
||||
nc_password: jsonpath "$.password"
|
||||
ap_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — PUT a probe file through the NC DAV surface.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
hello nc dead properties
|
||||
```
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — PROPPATCH set a custom (dead) property.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<X:testlabel>hello-nc-dead-property</X:testlabel>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "200 OK"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — PROPFIND confirms the round-trip.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='testlabel'])" == "hello-nc-dead-property"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — Upsert: setting the same name again overwrites rather
|
||||
# than duplicating (ON CONFLICT DO UPDATE at the store).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<X:testlabel>updated-nc-value</X:testlabel>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='testlabel'])" == "updated-nc-value"
|
||||
xpath "count(//*[local-name()='testlabel'])" == 1
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — Remove the property; PROPFIND confirms absence.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:remove>
|
||||
<D:prop>
|
||||
<X:testlabel/>
|
||||
</D:prop>
|
||||
</D:remove>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "count(//*[local-name()='testlabel'])" == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — PROPPATCH against a nonexistent resource → 404.
|
||||
# Prior behaviour on this handler silently no-opped
|
||||
# (and still claimed success) when the body carried no
|
||||
# `oc:favorite` directive; now that PROPPATCH performs
|
||||
# real dead-property writes, a missing resource must be
|
||||
# a hard failure, matching the native `/webdav/` handler.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-does-not-exist.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<X:testlabel>should-not-be-stored</X:testlabel>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — Re-set a marker, MOVE the file, confirm the property
|
||||
# followed the resource (id-stable across MOVE — no
|
||||
# store-side rename bookkeeping needed).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<X:testlabel>survives-nc-move</X:testlabel>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
|
||||
|
||||
MOVE {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Destination: {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
# Fresh destination → 201 (RFC 4918 §9.9.4).
|
||||
HTTP 201
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='testlabel'])" == "survives-nc-move"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — DELETE, then PUT a fresh file at the same path: the
|
||||
# old marker must NOT resurface (new resource, no leaked
|
||||
# dead-property state). Whether DELETE soft-deletes to
|
||||
# trash or hard-deletes, the recreated path resolves to
|
||||
# a brand-new resource id with no dead-property rows of
|
||||
# its own.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
PUT {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
fresh file at the same nc path
|
||||
```
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "count(//*[local-name()='testlabel'])" == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — Regression guard: `oc:favorite` still works after the
|
||||
# PROPPATCH handler was rewritten from an ad hoc
|
||||
# favorite-only scanner to generic dead-property
|
||||
# handling with an `oc:favorite` special case.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<oc:favorite>1</oc:favorite>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "200 OK"
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='favorite'])" == "1"
|
||||
|
||||
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<oc:favorite>0</oc:favorite>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='favorite'])" == "0"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Cleanup — probe file.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 — Folder coverage: MKCOL, PROPPATCH, PROPFIND, cleanup.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MKCOL {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-folder/
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-folder/
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<X:foldermark>nc-folder-keeps-this</X:foldermark>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-folder/
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='foldermark'])" == "nc-folder-keeps-this"
|
||||
|
||||
|
||||
DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-folder/
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Teardown — revoke the app password minted in Step 1.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}}
|
||||
Authorization: Bearer {{jwt}}
|
||||
|
||||
HTTP 200
|
||||
@@ -188,6 +188,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
||||
"$API_DIR/cross_drive_move.hurl" \
|
||||
"$API_DIR/cross_drive_copy.hurl" \
|
||||
"$API_DIR/webdav_dead_properties.hurl" \
|
||||
"$API_DIR/nc_webdav_dead_properties.hurl" \
|
||||
"$API_DIR/webdav_protected_properties.hurl" \
|
||||
"$API_DIR/webdav_drive_root.hurl" \
|
||||
"$API_DIR/webdav_permissions.hurl" \
|
||||
"$API_DIR/webdav_nested_move_cascade.hurl" \
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
# =============================================================
|
||||
# OxiCloud — WebDAV protected properties (RFC 4918 §9.2 / §15)
|
||||
# =============================================================
|
||||
# `DeadPropertyStore` lets a PROPPATCH set arbitrary namespace/name
|
||||
# pairs verbatim (RFC 4918 §4.2). Without a denylist, a client could
|
||||
# PROPPATCH `DAV:getetag`, `oc:fileid`, `oc:permissions`, etc. — names
|
||||
# the server ALSO emits as live state in PROPFIND/REPORT responses
|
||||
# (see `write_file_response` / `write_folder_response` in the NC
|
||||
# handler and the native PROPFIND writer). That produces either a
|
||||
# forged live property (the server would need to pick which of two
|
||||
# values to emit) or a silently stored, never-read row.
|
||||
#
|
||||
# `is_protected_property()` (src/application/adapters/webdav_adapter.rs)
|
||||
# defends the whole `DAV:` namespace plus the specific oc:/nc:/ocs:
|
||||
# names the server actually emits elsewhere. Both PROPPATCH handlers
|
||||
# (native `/webdav/` and NC `/remote.php/dav/`) consult it before
|
||||
# touching `DeadPropertyStore`, and reject with RFC 4918 §9.2's
|
||||
# per-property `403 Forbidden` inside the 207 multi-status — not a
|
||||
# blanket request failure, and not a silent no-op success.
|
||||
#
|
||||
# Coverage:
|
||||
# 1. Native /webdav/: PROPPATCH set on `D:displayname` (DAV:
|
||||
# namespace) → 207 envelope, inner 403 for that property.
|
||||
# 2. PROPFIND confirms the live displayname is unchanged — the
|
||||
# forged value never landed anywhere.
|
||||
# 3. Native /webdav/: PROPPATCH remove on `D:getetag` → same 403
|
||||
# contract on the Remove path, not just Set.
|
||||
# 4. Native /webdav/: an oc:-namespaced protected name
|
||||
# (`oc:fileid`) is blocked even on the surface that doesn't
|
||||
# normally speak NextCloud namespaces — protection is
|
||||
# namespace-global, not surface-scoped.
|
||||
# 5. Mixed request: one protected DAV: prop + one ordinary custom
|
||||
# dead property in the SAME PROPPATCH → 207 with both a 403
|
||||
# propstat block and a 200 propstat block; the custom property
|
||||
# DOES get stored (per-property granularity, not all-or-nothing
|
||||
# rejection).
|
||||
# 6. NC surface: PROPPATCH set on a protected oc: name
|
||||
# (`oc:permissions`) → 403; PROPFIND confirms it was never
|
||||
# written to the dead-property store.
|
||||
# 7. NC surface: PROPPATCH set on a protected nc: name
|
||||
# (`nc:has-preview`) → 403.
|
||||
# 8. Regression guard: `oc:favorite` is on the protected list too
|
||||
# (it's live state the NC handler emits), but the handler's
|
||||
# favorite special-case runs BEFORE the protected-property
|
||||
# check, so toggling favorite through PROPPATCH still works —
|
||||
# protection must not swallow the one oc: name that's
|
||||
# legitimately client-writable via a side channel.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — Login, capture JWT; mint an NC app password for the
|
||||
# NC-surface half of this file (NC DAV uses Basic Auth).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/app-passwords
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{ "label": "webdav_protected_properties" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
nc_username: jsonpath "$.username"
|
||||
nc_password: jsonpath "$.password"
|
||||
ap_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — PUT a probe file via native WebDAV.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: text/plain
|
||||
```
|
||||
hello protected properties
|
||||
```
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — PROPPATCH set on DAV:displayname (live property) must
|
||||
# be rejected with a per-property 403, not silently
|
||||
# accepted into DeadPropertyStore.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<D:displayname>forged-name</D:displayname>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — PROPFIND confirms the live displayname is untouched.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='displayname'])" == "protected-props-probe.txt"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — PROPPATCH remove on DAV:getetag → same 403 contract
|
||||
# on the Remove path.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:">
|
||||
<D:remove>
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
</D:prop>
|
||||
</D:remove>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — Protection is namespace-global: an oc:-namespaced
|
||||
# protected name is blocked even on the native /webdav/
|
||||
# surface, which doesn't otherwise speak NextCloud
|
||||
# namespaces.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<oc:fileid>should-not-be-stored</oc:fileid>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — Mixed request: one protected DAV: prop + one ordinary
|
||||
# custom dead property in the SAME PROPPATCH → per-
|
||||
# property granularity, not all-or-nothing rejection.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<D:resourcetype>forged</D:resourcetype>
|
||||
<X:testlabel>allowed-alongside-protected</X:testlabel>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "count(//*[local-name()='propstat'])" == 2
|
||||
xpath "string(//*[local-name()='propstat'][*[local-name()='prop']/*[local-name()='resourcetype']]/*[local-name()='status'])" contains "403"
|
||||
xpath "string(//*[local-name()='propstat'][*[local-name()='prop']/*[local-name()='testlabel']]/*[local-name()='status'])" contains "200 OK"
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='testlabel'])" == "allowed-alongside-protected"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Cleanup — native probe file.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — NC surface: PUT a probe file via the NC DAV mount.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
hello nc protected properties
|
||||
```
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — NC surface: PROPPATCH set on a protected oc: name
|
||||
# (`oc:permissions`, not the specially-handled favorite)
|
||||
# → 403.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<oc:permissions>forged</oc:permissions>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403"
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='permissions'])" != "forged"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — NC surface: PROPPATCH set on a protected nc: name
|
||||
# (`nc:has-preview`) → 403.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:nc="http://nextcloud.org/ns">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<nc:has-preview>forged</nc:has-preview>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 — Regression guard: oc:favorite is on the protected
|
||||
# list too, but the handler's favorite special-case
|
||||
# runs before the protection check, so toggling it via
|
||||
# PROPPATCH must still work end to end.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<oc:favorite>1</oc:favorite>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "200 OK"
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='favorite'])" == "1"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Cleanup — NC probe file, teardown app password.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
Reference in New Issue
Block a user