feat(webdav): extend HTTP PATCH to the NextCloud surface (RFC 5789)
The NextCloud-compatible WebDAV surface (/remote.php/dav/…) had no PATCH dispatch arm at all — requests fell through to 405 — unlike the plain-file surface (see the sibling commit on this repo's rfc-5789-http-patch work). Adds handle_patch to nextcloud/webdav_handler.rs, reusing the plain surface's X-Update-Range mechanism directly instead of duplicating it: - api/handlers/webdav_handler.rs::parse_update_range is now pub(crate) so both surfaces share the same header-parsing/validation logic. - upload_ingest::ingest_range_patch_to_cas (already surface-agnostic) splices the request body between the file's untouched prefix/suffix byte ranges and re-ingests through the same content-addressable pipeline handle_put uses. Follows this file's own handle_put conventions rather than the plain handler's: no active-lock guard (the NC surface has no LOCK/UNLOCK dispatch arm at all) and no explicit storage-quota check (handle_put doesn't do one either on this surface) — matching the sibling handler instead of importing behavior the NC surface doesn't otherwise have. Adds PATCH to the OPTIONS Allow header. Also fixes a pre-existing clippy::useless_borrows_in_formatting warning in thumbnail_service.rs (unrelated to this change, but blocking a clean clippy run on this branch). Adds tests/api/nc_webdav_patch.hurl covering explicit-range and append PATCH, the Content-Range rejection, the missing-header 400, and PATCH on a nonexistent file.
This commit is contained in:
@@ -2006,7 +2006,11 @@ async fn handle_put(
|
|||||||
/// range isn't supported, use `append` or PUT for that.
|
/// range isn't supported, use `append` or PUT for that.
|
||||||
///
|
///
|
||||||
/// Returns `(start, end)`; `end` is `None` for `append`.
|
/// Returns `(start, end)`; `end` is `None` for `append`.
|
||||||
fn parse_update_range(header: &str, size: u64) -> Result<(u64, Option<u64>), AppError> {
|
///
|
||||||
|
/// `pub(crate)` so the NextCloud-surface PATCH handler
|
||||||
|
/// (`nextcloud/webdav_handler.rs::handle_patch`) can reuse it instead of
|
||||||
|
/// duplicating the parsing logic.
|
||||||
|
pub(crate) fn parse_update_range(header: &str, size: u64) -> Result<(u64, Option<u64>), AppError> {
|
||||||
let header = header.trim();
|
let header = header.trim();
|
||||||
if header.eq_ignore_ascii_case("append") {
|
if header.eq_ignore_ascii_case("append") {
|
||||||
return Ok((size, None));
|
return Ok((size, None));
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use bytes::{Buf, Bytes};
|
use bytes::{Buf, Bytes};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use futures::stream::{self, Stream};
|
||||||
use quick_xml::{
|
use quick_xml::{
|
||||||
Writer,
|
Writer,
|
||||||
events::{BytesEnd, BytesStart, BytesText, Event},
|
events::{BytesEnd, BytesStart, BytesText, Event},
|
||||||
};
|
};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -29,12 +31,12 @@ use crate::domain::services::authorization::{Permission, Resource, Subject};
|
|||||||
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
|
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
|
||||||
use crate::infrastructure::services::webdav_dead_property_store::ResourceRef;
|
use crate::infrastructure::services::webdav_dead_property_store::ResourceRef;
|
||||||
use crate::interfaces::api::handlers::webdav_handler::{
|
use crate::interfaces::api::handlers::webdav_handler::{
|
||||||
PROPFIND_BATCH_SIZE, dead_props_for, file_dead_props, files_dead_props_map, folder_dead_props,
|
PROPFIND_BATCH_SIZE, dead_props_for, file_dead_props, files_dead_props_map, folder_dead_props, parse_update_range,
|
||||||
folders_dead_props_map,
|
folders_dead_props_map, streamed_file_dead_props,
|
||||||
};
|
};
|
||||||
use crate::interfaces::errors::AppError;
|
use crate::interfaces::errors::AppError;
|
||||||
use crate::interfaces::range_requests::{not_modified_response, range_response};
|
use crate::interfaces::range_requests::{not_modified_response, range_response};
|
||||||
use crate::interfaces::upload_ingest::ingest_body_to_cas;
|
use crate::interfaces::upload_ingest::{ingest_body_to_cas, ingest_range_patch_to_cas};
|
||||||
|
|
||||||
/// Extension trait to map XML write errors to `String` concisely.
|
/// Extension trait to map XML write errors to `String` concisely.
|
||||||
trait XmlResultExt<T> {
|
trait XmlResultExt<T> {
|
||||||
@@ -232,6 +234,7 @@ pub async fn handle_nc_webdav(
|
|||||||
"PROPFIND" => handle_propfind(state, req, &session, &subpath).await,
|
"PROPFIND" => handle_propfind(state, req, &session, &subpath).await,
|
||||||
"GET" => handle_get(state, &session, &subpath, req.headers()).await,
|
"GET" => handle_get(state, &session, &subpath, req.headers()).await,
|
||||||
"PUT" => handle_put(state, req, &session, &subpath).await,
|
"PUT" => handle_put(state, req, &session, &subpath).await,
|
||||||
|
"PATCH" => handle_patch(state, req, &session, &subpath).await,
|
||||||
"MKCOL" => handle_mkcol(state, &session, &subpath).await,
|
"MKCOL" => handle_mkcol(state, &session, &subpath).await,
|
||||||
"DELETE" => handle_delete(state, &session, &subpath).await,
|
"DELETE" => handle_delete(state, &session, &subpath).await,
|
||||||
"MOVE" => handle_move(state, req, &session, &subpath).await,
|
"MOVE" => handle_move(state, req, &session, &subpath).await,
|
||||||
@@ -267,7 +270,7 @@ fn handle_options() -> Result<Response<Body>, AppError> {
|
|||||||
.header(HEADER_DAV, "1, 3")
|
.header(HEADER_DAV, "1, 3")
|
||||||
.header(
|
.header(
|
||||||
header::ALLOW,
|
header::ALLOW,
|
||||||
"OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, MOVE, PROPFIND, PROPPATCH, REPORT, SEARCH",
|
"OPTIONS, GET, HEAD, PUT, PATCH, DELETE, MKCOL, MOVE, PROPFIND, PROPPATCH, REPORT, SEARCH",
|
||||||
)
|
)
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap())
|
.unwrap())
|
||||||
@@ -963,6 +966,174 @@ async fn handle_put(
|
|||||||
.unwrap())
|
.unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────── PATCH ────────────────────
|
||||||
|
|
||||||
|
/// Handles PATCH requests (RFC 5789) for partial byte-range content
|
||||||
|
/// updates on the NextCloud file surface — extends the plain WebDAV
|
||||||
|
/// surface's `X-Update-Range` mechanism (see
|
||||||
|
/// `api/handlers/webdav_handler.rs::handle_patch` / `parse_update_range`)
|
||||||
|
/// here. New content is assembled by splicing the request body between
|
||||||
|
/// the file's untouched prefix/suffix byte ranges and re-ingesting the
|
||||||
|
/// result as one continuous stream through the same content-addressable
|
||||||
|
/// pipeline `handle_put` uses ([`ingest_range_patch_to_cas`]) — unedited
|
||||||
|
/// chunks on either side of the edit typically dedup for free.
|
||||||
|
///
|
||||||
|
/// No active-lock guard here — the NC surface has no LOCK/UNLOCK dispatch
|
||||||
|
/// arm at all (see `handle_options`'s doc comment), matching `handle_put`
|
||||||
|
/// above, which has the same omission.
|
||||||
|
async fn handle_patch(
|
||||||
|
state: Arc<AppState>,
|
||||||
|
req: Request<Body>,
|
||||||
|
session: &crate::interfaces::nextcloud::session::NcSession,
|
||||||
|
subpath: &str,
|
||||||
|
) -> Result<Response<Body>, AppError> {
|
||||||
|
let chroot = session.require_chroot()?;
|
||||||
|
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||||||
|
let file_service = &state.applications.file_retrieval_service;
|
||||||
|
let upload_service = &state.applications.file_upload_service;
|
||||||
|
|
||||||
|
if subpath.is_empty() || subpath == "/" {
|
||||||
|
return Err(AppError::bad_request("Cannot PATCH the root folder"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 5789 doesn't define Content-Range semantics; this server uses a
|
||||||
|
// dedicated `X-Update-Range` header instead (see `parse_update_range`)
|
||||||
|
// to avoid ambiguity with HTTP Range-Request semantics.
|
||||||
|
if req.headers().contains_key(header::CONTENT_RANGE) {
|
||||||
|
return Err(AppError::bad_request(
|
||||||
|
"PATCH must not use Content-Range; use the X-Update-Range header instead",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let update_range_header = req
|
||||||
|
.headers()
|
||||||
|
.get("X-Update-Range")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.ok_or_else(|| AppError::bad_request("PATCH requires an X-Update-Range header"))?;
|
||||||
|
|
||||||
|
// Extract all headers before consuming `req` into the body stream.
|
||||||
|
let if_none_match = req
|
||||||
|
.headers()
|
||||||
|
.get(header::IF_NONE_MATCH)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
let if_match = req
|
||||||
|
.headers()
|
||||||
|
.get(header::IF_MATCH)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
let content_length = req
|
||||||
|
.headers()
|
||||||
|
.get(header::CONTENT_LENGTH)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|s| s.parse::<u64>().ok());
|
||||||
|
let claimed_type = req
|
||||||
|
.headers()
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("application/octet-stream")
|
||||||
|
.to_string();
|
||||||
|
let max_upload = state.core.config.storage.direct_put_max_bytes;
|
||||||
|
|
||||||
|
// ── Existence check ───────────────────────────────────────────────
|
||||||
|
// Unlike PUT, PATCH requires an existing file — a partial update of
|
||||||
|
// nothing isn't meaningful.
|
||||||
|
let file = file_service
|
||||||
|
.get_file_by_path(&internal_path, chroot.drive_id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError::not_found(format!("File not found: {}", internal_path)))?;
|
||||||
|
|
||||||
|
// ── RFC 7232 conditional preconditions ────────────────────────────
|
||||||
|
let current_etag = Some(file.etag.as_str());
|
||||||
|
if let Some(ref value) = if_none_match
|
||||||
|
&& if_none_match_precondition_fails(value, current_etag)
|
||||||
|
{
|
||||||
|
return Ok(precondition_failed_response());
|
||||||
|
}
|
||||||
|
if let Some(ref value) = if_match
|
||||||
|
&& if_match_precondition_fails(value, current_etag)
|
||||||
|
{
|
||||||
|
return Ok(precondition_failed_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Range parsing + validation ─────────────────────────────────────
|
||||||
|
let (start, end) = parse_update_range(&update_range_header, file.size)?;
|
||||||
|
if let (Some(end), Some(len)) = (end, content_length) {
|
||||||
|
let expected = end - start + 1;
|
||||||
|
if len != expected {
|
||||||
|
return Err(AppError::bad_request(format!(
|
||||||
|
"Content-Length {len} does not match X-Update-Range span {expected}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Splice prefix/suffix around the patched span ───────────────────
|
||||||
|
let prefix_stream: Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> =
|
||||||
|
if start == 0 {
|
||||||
|
Box::pin(stream::empty())
|
||||||
|
} else {
|
||||||
|
Box::into_pin(
|
||||||
|
file_service
|
||||||
|
.get_file_range_stream_with_perms(&file.id, session.user.id, 0, Some(start))
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let suffix_stream: Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> = match end
|
||||||
|
{
|
||||||
|
Some(end) if end + 1 < file.size => Box::into_pin(
|
||||||
|
file_service
|
||||||
|
.get_file_range_stream_with_perms(&file.id, session.user.id, end + 1, None)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?,
|
||||||
|
),
|
||||||
|
_ => Box::pin(stream::empty()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let filename = filename_from_path(subpath).to_string();
|
||||||
|
let ingested = ingest_range_patch_to_cas(
|
||||||
|
prefix_stream,
|
||||||
|
req.into_body(),
|
||||||
|
suffix_stream,
|
||||||
|
&state.core.dedup_service,
|
||||||
|
&filename,
|
||||||
|
&claimed_type,
|
||||||
|
max_upload,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// ── Atomic store ──────────────────────────────────────────────────
|
||||||
|
let new_size = ingested.size;
|
||||||
|
let content_type = ingested.content_type.clone();
|
||||||
|
let stored = upload_service
|
||||||
|
.update_file_streaming_with_perms(
|
||||||
|
&internal_path,
|
||||||
|
chroot.drive_id,
|
||||||
|
ingested.stored(),
|
||||||
|
&content_type,
|
||||||
|
None,
|
||||||
|
session.user.id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::internal_error(format!("Failed to store file: {}", e)))?;
|
||||||
|
|
||||||
|
// Everything from `start` to the new EOF reflects the patch (the
|
||||||
|
// untouched suffix, if any, may have shifted when the body's length
|
||||||
|
// differs from the replaced span).
|
||||||
|
let range_end = new_size.saturating_sub(1);
|
||||||
|
Ok(Response::builder()
|
||||||
|
.status(StatusCode::NO_CONTENT)
|
||||||
|
.header(header::ETAG, format!("\"{}\"", stored.etag))
|
||||||
|
.header("oc-etag", format!("\"{}\"", stored.etag))
|
||||||
|
.header(
|
||||||
|
header::CONTENT_RANGE,
|
||||||
|
format!("bytes {}-{}/{}", start, range_end, new_size),
|
||||||
|
)
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────── MKCOL ────────────────────
|
// ──────────────────── MKCOL ────────────────────
|
||||||
|
|
||||||
async fn handle_mkcol(
|
async fn handle_mkcol(
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
# =============================================================
|
||||||
|
# OxiCloud — NextCloud HTTP PATCH partial content updates (RFC 5789)
|
||||||
|
# =============================================================
|
||||||
|
# The NextCloud-compatible WebDAV surface (/remote.php/dav/…) had no
|
||||||
|
# PATCH dispatch arm at all (fell through to 405), unlike the plain-file
|
||||||
|
# surface (see api/handlers/webdav_handler.rs::handle_patch). See
|
||||||
|
# nextcloud/webdav_handler.rs::handle_patch, which reuses the plain
|
||||||
|
# surface's `parse_update_range` and `upload_ingest::
|
||||||
|
# ingest_range_patch_to_cas` — both surface-agnostic.
|
||||||
|
#
|
||||||
|
# Coverage:
|
||||||
|
# 1. PATCH an explicit byte range (`X-Update-Range: bytes=<start>-<end>`)
|
||||||
|
# → 204, Content-Range header, and the resulting content reflects
|
||||||
|
# the patched span with the untouched prefix/suffix intact.
|
||||||
|
# 2. PATCH with `X-Update-Range: append` → 204, content grows.
|
||||||
|
# 3. PATCH with a Content-Range header → 400 (must use X-Update-Range).
|
||||||
|
# 4. PATCH without X-Update-Range → 400.
|
||||||
|
# 5. PATCH on a nonexistent file → 404.
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Setup 1 — JWT login (to mint the app password used for NC Basic Auth).
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "{{username}}", "password": "{{password}}" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
jwt: jsonpath "$.access_token"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Setup 2 — Mint an app password for NC Basic Auth.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/app-passwords
|
||||||
|
Authorization: Bearer {{jwt}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "label": "nc_webdav_patch hurl test" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
nc_username: jsonpath "$.username"
|
||||||
|
nc_password: jsonpath "$.password"
|
||||||
|
ap_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 1 — Seed a 10-byte probe file: "0123456789".
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PUT {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_username}}: {{nc_password}}
|
||||||
|
Content-Type: text/plain
|
||||||
|
```
|
||||||
|
0123456789
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 2 — PATCH bytes 3-5 ("345") with "XYZ".
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PATCH {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_username}}: {{nc_password}}
|
||||||
|
X-Update-Range: bytes=3-5
|
||||||
|
Content-Type: text/plain
|
||||||
|
```
|
||||||
|
XYZ
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
[Asserts]
|
||||||
|
header "Content-Range" == "bytes 3-9/10"
|
||||||
|
|
||||||
|
|
||||||
|
GET {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_username}}: {{nc_password}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
body == "012XYZ6789"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 3 — PATCH append.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PATCH {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_username}}: {{nc_password}}
|
||||||
|
X-Update-Range: append
|
||||||
|
Content-Type: text/plain
|
||||||
|
```
|
||||||
|
END
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
[Asserts]
|
||||||
|
header "Content-Range" == "bytes 10-12/13"
|
||||||
|
|
||||||
|
|
||||||
|
GET {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_username}}: {{nc_password}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
body == "012XYZ6789END"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 4 — Content-Range header on PATCH is rejected.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PATCH {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_username}}: {{nc_password}}
|
||||||
|
X-Update-Range: bytes=0-2
|
||||||
|
Content-Range: bytes 0-2/13
|
||||||
|
Content-Type: text/plain
|
||||||
|
```
|
||||||
|
abc
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 400
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 5 — Missing X-Update-Range header.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PATCH {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_username}}: {{nc_password}}
|
||||||
|
Content-Type: text/plain
|
||||||
|
```
|
||||||
|
abc
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 400
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 6 — PATCH on a nonexistent file → 404.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PATCH {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-does-not-exist.txt
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_username}}: {{nc_password}}
|
||||||
|
X-Update-Range: append
|
||||||
|
Content-Type: text/plain
|
||||||
|
```
|
||||||
|
abc
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 404
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Cleanup
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
DELETE {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-patch-probe.txt
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_username}}: {{nc_password}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}}
|
||||||
|
Authorization: Bearer {{jwt}}
|
||||||
|
HTTP 200
|
||||||
Reference in New Issue
Block a user