feat(drive): fix webdav back-compat
add env variable `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`
which is by default:
`OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"`
so `/webdav/` -> points to user's personal drive (**backward compatibilit**y)
`/web/dav/@drive/{uuid|drive name}/` points to the respective drive
if admins want directly `/webdav/` pointing to list of drives they need to:
`OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""`
+ ensure lock is per user (RFC 4918 §9.11)
fix: #554
This commit is contained in:
@@ -369,6 +369,15 @@ jobs:
|
||||
env:
|
||||
BUILD_TARGET: release
|
||||
|
||||
# WebDAV URL-scheme variant: `OXICLOUD_WEBDAV_DRIVE_PATH=""`
|
||||
# (drive listing at `/webdav/`, no `@drive` sigil). Runs a
|
||||
# separately-configured server on its own port so the default
|
||||
# WebDAV suite above stays on the `"@drive"` back-compat config.
|
||||
- name: Run WebDAV drive-root variant tests
|
||||
run: bash tests/webdav-drive-root/run.sh
|
||||
env:
|
||||
BUILD_TARGET: release
|
||||
|
||||
# OIDC integration: drives the SPA's SSO flow end-to-end against
|
||||
# the fake IdP (auto-approve login + consent, real PKCE/JWT
|
||||
# round-trip) and asserts the d1bbe8ba contract — OIDC callback
|
||||
|
||||
@@ -70,6 +70,7 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator
|
||||
| `OXICLOUD_ENABLE_MUSIC` | `true` | Music playlists and audio metadata |
|
||||
| `OXICLOUD_EXPOSE_SYSTEM_USERS` | `true` | Expose other OxiCloud users as a read-only address book at `GET /api/address-books` |
|
||||
| `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS` | `false` | Expose `POST /api/admin/internal/trigger-sweep` and `POST /api/admin/internal/trigger-gc` — test-only synchronous triggers for the storage-usage reconciliation sweep and blob garbage collector. Used by the API test suite to assert post-delete quota convergence without waiting out the periodic ticker. Leave **off** in production: the routes return 404 even to an admin token when disabled. |
|
||||
| `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX` | `@drive` | Native WebDAV URL segment that renders the caller's drive list. Sanitized by trimming leading/trailing `/`. Three shapes: (1) default `@drive` — `/webdav/…` addresses the caller's default personal drive (back-compat), `/webdav/@drive/` returns the drive listing, `/webdav/@drive/<uuid\|name>/…` targets a specific drive. (2) empty string `""` — `/webdav/` IS the drive listing, `/webdav/<uuid\|name>/…` targets a specific drive, no default-drive shortcut. (3) any other string (e.g. `drives`) — same shape as `@drive` with that segment substituted. Only drives the caller has Read on via `role_grants` resolve. |
|
||||
|
||||
## Storage Backend
|
||||
|
||||
|
||||
+56
-32
@@ -761,15 +761,24 @@ accommodates them without schema migration)
|
||||
|
||||
#### Native WebDAV (`/webdav/...`)
|
||||
|
||||
| URL | Resolves to |
|
||||
|---|---|
|
||||
| `/webdav/<path>` | Caller's default personal drive root + `<path>` (back-compat with today's behaviour) |
|
||||
| `/webdav/@drive/<drive-uuid>/<path>` | Specific drive root + `<path>` |
|
||||
**SHIPPED 2026-07-06.** Config-driven via env
|
||||
`OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX` (`FeaturesConfig::webdav_drive_listing_prefix`;
|
||||
default `"@drive"`, sanitized by trimming leading/trailing `/`).
|
||||
Three deployment shapes:
|
||||
|
||||
Today's `/webdav/<path>` handler implicitly looks up the caller's
|
||||
home folder and prepends it. Post-drives, the same handler looks up
|
||||
the caller's personal drive and resolves paths inside it. **Zero
|
||||
breakage** for existing native WebDAV clients.
|
||||
| `WEBDAV_DRIVE_LISTING_PREFIX` | URL | Resolves to |
|
||||
|---|---|---|
|
||||
| `@drive` (default) | `/webdav/…` | caller's default personal drive (back-compat) |
|
||||
| `@drive` | `/webdav/@drive/` | drive listing |
|
||||
| `@drive` | `/webdav/@drive/<sel>/…` | specific drive |
|
||||
| `""` (empty) | `/webdav/` | drive listing |
|
||||
| `""` | `/webdav/<sel>/…` | specific drive |
|
||||
| any other | same shape as `@drive`, segment substituted | |
|
||||
|
||||
`<sel>` is a drive UUID **or** the drive's display name (matched
|
||||
against `storage.folders.name` of the drive root). Only drives the
|
||||
caller has Read on via `role_grants` resolve; unknown selector and
|
||||
permission denial both return 404 (anti-enumeration).
|
||||
|
||||
**Why the `@drive` sigil and NOT `/webdav/drives/<uuid>/...`**
|
||||
(earlier draft) or top-level `/drives/<uuid>/...` (also
|
||||
@@ -777,32 +786,47 @@ considered): `@` is the established structural-routing sigil
|
||||
(GitHub `@user/repo`, npm `@scope/pkg`, LDAP `@domain`) — it
|
||||
reads as "this is not user content, this is a routing token."
|
||||
Realistic collision risk drops to near-zero: nobody creates a
|
||||
top-level folder named exactly `@drive` by accident, and the
|
||||
defensive layer collapses to a single one-liner in MKCOL / PUT /
|
||||
REST create paths that refuses that literal name at any drive
|
||||
root. Compared to top-level `/drives/<uuid>/...`, the `@drive`
|
||||
shape keeps **one URL root for everything WebDAV** — single
|
||||
`<Location>` block in reverse-proxy configs, single mental model
|
||||
for sysadmins, single dispatcher in `webdav_routes()`.
|
||||
top-level folder named exactly `@drive` by accident. Keeps **one
|
||||
URL root for everything WebDAV** — single `<Location>` block in
|
||||
reverse-proxy configs, single mental model for sysadmins, single
|
||||
dispatcher in `webdav_routes()`. Making the segment
|
||||
config-tunable per deployment lets operators pick a different
|
||||
sigil (`drives`) or drop it entirely (`""` = drive-listing at
|
||||
root) without a code change.
|
||||
|
||||
**Implementation notes:**
|
||||
- Route parser accepts both `/webdav/@drive/<uuid>/...` and the
|
||||
URL-encoded form `/webdav/%40drive/<uuid>/...` — WebDAV clients
|
||||
percent-encode `@` inconsistently.
|
||||
- One-liner guard in upload paths refuses creation of a folder
|
||||
literally named `@drive` at any drive root (case-sensitive).
|
||||
- `webdav_href()` (today at `webdav_handler.rs:94`) becomes
|
||||
drive-context-aware: responses for a request under
|
||||
`/webdav/@drive/<uuid>/...` must reference back to
|
||||
`/webdav/@drive/<uuid>/...`, otherwise the client follows the
|
||||
`<D:href>` and lands on the back-compat surface (wrong drive).
|
||||
**Implementation:** `resolve_webdav_scope` in
|
||||
`src/interfaces/api/handlers/webdav_handler.rs`. Selector accepts
|
||||
UUIDs and display names; UUID form is tried first. Legacy
|
||||
tolerance in the default-drive branch: bookmarks that already
|
||||
carried the drive-root name as their first segment
|
||||
(`/webdav/Personal/foo` under a Personal-default user) are
|
||||
passed through instead of double-prepended.
|
||||
|
||||
The `drives` path segment is **reserved**: a folder literally named
|
||||
`drives` cannot exist at the top level of any drive. Migration
|
||||
pre-check refuses to start if existing data violates this — operator
|
||||
must rename before upgrading. (Conservative estimate: zero existing
|
||||
folders are named exactly `drives`. The migration script reports any
|
||||
collisions for manual fix-up.)
|
||||
**Hurl coverage:**
|
||||
- `tests/api/webdav_drive_root.hurl` — default `@drive` config
|
||||
- `tests/webdav-drive-root/drive_root_empty_config.hurl` — empty
|
||||
config (separately-configured server; runs under
|
||||
`tests/webdav-drive-root/run.sh`, wired into `just api-test`
|
||||
and CI's `api-test` job)
|
||||
|
||||
**Href construction — verified drive-aware:** `webdav_href()`
|
||||
prints `/webdav/<path>`, but the `<path>` input is `client_path`
|
||||
extracted from `req.uri()` (the URL segment after `/webdav/`), not
|
||||
the scope-resolved db_path. So a request to
|
||||
`/webdav/@drive/<sel>/folder/` renders children as
|
||||
`/webdav/@drive/<sel>/folder/<child>/` — the `@drive/<sel>/`
|
||||
prefix is preserved on every hop. `client_path` is threaded into
|
||||
`base_href` at `handle_propfind` and passed through
|
||||
`build_streaming_propfind_response` unchanged.
|
||||
|
||||
**Deferred (not blocking):**
|
||||
- One-liner guard refusing folder creation named literally
|
||||
`@drive` at drive root (defensive against future collisions —
|
||||
today an unknown `@drive` folder at drive root is unreachable
|
||||
via WebDAV under the default config, so it's low priority).
|
||||
- Cross-drive MOVE / COPY currently 403 — same-drive only.
|
||||
Cross-drive copy has REST-side support; WebDAV MOVE/COPY
|
||||
could route through it once permission mapping is designed.
|
||||
|
||||
#### NextCloud-compat WebDAV (`/remote.php/dav/...`)
|
||||
|
||||
|
||||
+24
@@ -85,6 +85,30 @@ OXICLOUD_SERVER_HOST=127.0.0.1
|
||||
# to an admin token. Default: false.
|
||||
#OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=false
|
||||
|
||||
# Native WebDAV URL segment that returns the drive listing. Sanitized
|
||||
# by trimming leading/trailing `/` so `/@drive/`, `@drive`, and
|
||||
# `@drive/` are equivalent. Three deployment modes:
|
||||
#
|
||||
# * Default `@drive` — back-compat with pre-multi-drive clients.
|
||||
# /webdav/… → caller's default personal drive
|
||||
# /webdav/@drive/ → drive listing (per-drive virtual
|
||||
# folders)
|
||||
# /webdav/@drive/<sel>/… → specific drive by UUID or its
|
||||
# display name
|
||||
#
|
||||
# * Empty `""` — no default-drive shortcut; `/webdav/` IS the
|
||||
# drive listing. Clients must always name the drive.
|
||||
# /webdav/ → drive listing
|
||||
# /webdav/<sel>/… → specific drive
|
||||
#
|
||||
# * Any other string (e.g. `drives`) — same shape as `@drive` but
|
||||
# with your chosen segment substituted.
|
||||
#
|
||||
# Selector `<sel>` is a drive UUID or the drive's display name. Only
|
||||
# drives the caller has Read on via role_grants resolve; unknown
|
||||
# selector and permission denial both return 404 (anti-enumeration).
|
||||
#OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=@drive
|
||||
|
||||
# How often (milliseconds) the background job drains storage.tree_etag_dirty
|
||||
# and bumps folder tree ETags (default: 500). Write paths only enqueue bump
|
||||
# requests — this is the upper bound on how stale an ancestor folder's ETag
|
||||
|
||||
@@ -159,23 +159,34 @@ front-design:
|
||||
|
||||
# Hurl-driven functional tests (starts postgres + server, tears down after).
|
||||
#
|
||||
# Three runners — each isolated, brings up its own sidecars + server config:
|
||||
# * tests/api/run.sh — REST API surface, default server.env
|
||||
# * tests/webdav/run.sh — native WebDAV + NextCloud DAV, default server.env
|
||||
# * tests/oidc/run.sh — OIDC SSO end-to-end against a fake IdP
|
||||
# (tests/oidc/fake_idp, a Node panva/oidc-provider
|
||||
# wrapper); server launched with
|
||||
# --config server-with-oidc.env so the api and
|
||||
# webdav suites stay on the OIDC-off config.
|
||||
# Four runners — each isolated, brings up its own sidecars + server config:
|
||||
# * tests/api/run.sh — REST API surface, default server.env
|
||||
# * tests/webdav/run.sh — native WebDAV + NextCloud DAV, default server.env
|
||||
# * tests/webdav-drive-root/run.sh — WebDAV `OXICLOUD_WEBDAV_DRIVE_PATH=""`
|
||||
# variant (drive listing served at
|
||||
# `/webdav/` instead of `/webdav/@drive/`).
|
||||
# Server launched with
|
||||
# --config server-webdav-drive-root.env
|
||||
# so the default runners stay on the
|
||||
# `"@drive"` config.
|
||||
# * tests/oidc/run.sh — OIDC SSO end-to-end against a fake IdP
|
||||
# (tests/oidc/fake_idp, a Node
|
||||
# panva/oidc-provider wrapper); server
|
||||
# launched with
|
||||
# --config server-with-oidc.env so the
|
||||
# api and webdav suites stay on the
|
||||
# OIDC-off config.
|
||||
#
|
||||
# Same chain runs in CI under the `api-test` job in
|
||||
# .github/workflows/ci.yml; keep the order in sync so a local pass means
|
||||
# CI passes.
|
||||
api-test:
|
||||
#!/usr/bin/env bash
|
||||
set -x
|
||||
set -euo pipefail
|
||||
./tests/api/run.sh
|
||||
./tests/webdav/run.sh
|
||||
./tests/webdav-drive-root/run.sh
|
||||
./tests/oidc/run.sh
|
||||
if which litmus >/dev/null 2>/dev/null
|
||||
then
|
||||
|
||||
@@ -915,6 +915,22 @@ pub struct FeaturesConfig {
|
||||
/// deployments don't want them reachable. Env:
|
||||
/// `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS`.
|
||||
pub enable_admin_internal_endpoints: bool,
|
||||
/// Native WebDAV path segment that lists the caller's drives.
|
||||
///
|
||||
/// * Default `"@drive"` — bare `/webdav/` addresses the caller's
|
||||
/// default personal drive (back-compat). Drive listing lives at
|
||||
/// `/webdav/@drive/`; explicit drive at
|
||||
/// `/webdav/@drive/<uuid|name>/…`.
|
||||
/// * `""` (empty) — no default-drive shortcut. Bare `/webdav/`
|
||||
/// returns the drive listing; explicit drive at
|
||||
/// `/webdav/<uuid|name>/…`. Operators who don't want a "default
|
||||
/// drive" concept exposed via WebDAV pick this.
|
||||
/// * Any other string (e.g. `"drives"`) — same shape as the default,
|
||||
/// just with that path segment. Loaded via `trim_matches('/')`
|
||||
/// so operators can safely pass `"/drives/"`.
|
||||
///
|
||||
/// Env: `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`.
|
||||
pub webdav_drive_listing_prefix: String,
|
||||
}
|
||||
|
||||
impl Default for FeaturesConfig {
|
||||
@@ -934,6 +950,10 @@ impl Default for FeaturesConfig {
|
||||
// deployments do NOT need this; the periodic ticker handles
|
||||
// reconciliation transparently.
|
||||
enable_admin_internal_endpoints: false,
|
||||
// Back-compat with pre-multi-drive clients — bare `/webdav/`
|
||||
// maps to the caller's default drive; drive listing is
|
||||
// reachable at `/webdav/@drive/`.
|
||||
webdav_drive_listing_prefix: "@drive".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1505,6 +1525,15 @@ impl AppConfig {
|
||||
config.features.enable_admin_internal_endpoints = val;
|
||||
}
|
||||
|
||||
// Native WebDAV drive-picker path segment. Sanitised by
|
||||
// stripping leading/trailing slashes so operators can pass
|
||||
// `/drives/` or `drives` interchangeably; empty string means
|
||||
// "no default-drive shortcut, `/webdav/` IS the drive listing".
|
||||
// See `FeaturesConfig::webdav_drive_listing_prefix`.
|
||||
if let Ok(raw) = env::var("OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX") {
|
||||
config.features.webdav_drive_listing_prefix = raw.trim_matches('/').to_string();
|
||||
}
|
||||
|
||||
if let Ok(enable_faces) = env::var("OXICLOUD_ENABLE_FACES").map(|v| v.parse::<bool>())
|
||||
&& let Ok(val) = enable_faces
|
||||
{
|
||||
|
||||
@@ -32,6 +32,14 @@ const MAX_LOCK_TIMEOUT_SECS: u64 = 86_400; // 24 hours
|
||||
pub struct LockEntry {
|
||||
pub info: LockInfo,
|
||||
pub path: String,
|
||||
/// The user who acquired the lock. `None` for entries seeded by
|
||||
/// unit tests or refresh paths that don't carry a caller (the
|
||||
/// refresh flow rebuilds from the existing entry without a new
|
||||
/// caller context, so we preserve whatever was there). RFC 4918
|
||||
/// §9.11's "MUST be requested by the owner" rule for UNLOCK is
|
||||
/// enforced by comparing this against the caller in
|
||||
/// `handle_unlock`.
|
||||
pub caller_user_id: Option<uuid::Uuid>,
|
||||
}
|
||||
|
||||
/// Per-entry expiration policy for the `by_path` cache.
|
||||
@@ -110,7 +118,12 @@ impl WebDavLockStore {
|
||||
/// - The existing lock is exclusive (blocks any new lock), or
|
||||
/// - The new lock is exclusive and any lock already exists (RFC 4918 §7.8).
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn acquire(&self, path: &str, info: LockInfo) -> Result<LockEntry, LockEntry> {
|
||||
pub fn acquire(
|
||||
&self,
|
||||
path: &str,
|
||||
info: LockInfo,
|
||||
caller_user_id: Option<uuid::Uuid>,
|
||||
) -> Result<LockEntry, LockEntry> {
|
||||
if let Some(existing) = self.by_path.get(path) {
|
||||
// Exclusive existing lock → blocks everything.
|
||||
// New exclusive lock → blocked by any existing lock (shared or exclusive).
|
||||
@@ -123,6 +136,7 @@ impl WebDavLockStore {
|
||||
let entry = LockEntry {
|
||||
info,
|
||||
path: path.to_owned(),
|
||||
caller_user_id,
|
||||
};
|
||||
self.by_token
|
||||
.insert(entry.info.token.clone(), path.to_owned());
|
||||
@@ -132,6 +146,7 @@ impl WebDavLockStore {
|
||||
let entry = LockEntry {
|
||||
info,
|
||||
path: path.to_owned(),
|
||||
caller_user_id,
|
||||
};
|
||||
|
||||
// `LockExpiry` derives the TTL from `entry.info.timeout` on insert —
|
||||
@@ -254,6 +269,7 @@ mod tests {
|
||||
LockEntry {
|
||||
info: lock_info(token, timeout, LockScope::Exclusive),
|
||||
path: "/file.txt".to_owned(),
|
||||
caller_user_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -222,45 +222,170 @@ async fn handle_webdav_methods(
|
||||
handle_webdav_dispatch(state, req, path).await
|
||||
}
|
||||
|
||||
/// If `path` doesn't already start with the user's home folder name, prepend
|
||||
/// the home folder path so downstream services can find the resource in the DB.
|
||||
/// Returns `None` when the path already includes the prefix or resolution fails.
|
||||
async fn resolve_webdav_path(state: &Arc<AppState>, user_id: Uuid, path: &str) -> Option<String> {
|
||||
let folder_service = &state.applications.folder_service;
|
||||
let home_folders = folder_service
|
||||
.list_folders_with_perms(None, user_id)
|
||||
.await
|
||||
.ok()?;
|
||||
let home = home_folders.first()?;
|
||||
|
||||
if path.starts_with(&home.name) {
|
||||
None // Already prefixed
|
||||
} else {
|
||||
Some(format!("{}/{}", home.path, path))
|
||||
}
|
||||
/// Native WebDAV URL scheme (drive.md §9):
|
||||
///
|
||||
/// The exact wire shape depends on
|
||||
/// `FeaturesConfig::webdav_drive_listing_prefix` (env
|
||||
/// `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`, default `"@drive"`):
|
||||
///
|
||||
/// | Config | URL | Target |
|
||||
/// |---|---|---|
|
||||
/// | `"@drive"` | `/webdav/…` | default drive (back-compat) |
|
||||
/// | `"@drive"` | `/webdav/@drive/` | drive listing |
|
||||
/// | `"@drive"` | `/webdav/@drive/<sel>/…` | explicit drive |
|
||||
/// | `""` | `/webdav/` | drive listing |
|
||||
/// | `""` | `/webdav/<sel>/…` | explicit drive |
|
||||
/// | `"drives"` | `/webdav/…` | default drive |
|
||||
/// | `"drives"` | `/webdav/drives/<sel>/…` | explicit drive |
|
||||
///
|
||||
/// `<sel>` is a drive UUID **or** the drive's display name (matched
|
||||
/// against `storage.folders.name` of the drive root). Only drives the
|
||||
/// caller has Read on via `role_grants` resolve.
|
||||
///
|
||||
/// Legacy tolerance for the default-drive branch: bookmarks that
|
||||
/// already contain the drive-root name as their first segment
|
||||
/// (`/webdav/Personal/foo` under a Personal-default user) are passed
|
||||
/// through instead of double-prepended.
|
||||
enum WebdavTarget {
|
||||
/// Render the synthetic drive-listing pseudo-root. Only PROPFIND
|
||||
/// treats this as a real target; other verbs 405.
|
||||
ListDrives,
|
||||
/// Descend into a concrete drive.
|
||||
Scope(DriveScope),
|
||||
}
|
||||
|
||||
/// Native WebDAV protocol entry: resolve the caller's default drive
|
||||
/// once per handler so every downstream path-based lookup
|
||||
/// (`get_folder_by_path`, `get_file_by_path`, `update_file_streaming`)
|
||||
/// can pass the same `drive_id` scope.
|
||||
///
|
||||
/// Post-D0 `storage.{folders,files}.path` repeats across drives — the
|
||||
/// scope is mandatory. Native WebDAV today lives in a single-drive
|
||||
/// surface (one default drive per user), so the lookup is unambiguous.
|
||||
/// Multi-drive support via path segments (`/webdav/drives/<uuid>/…`)
|
||||
/// is tracked separately and will derive `drive_id` directly from the
|
||||
/// URL instead of going through `find_default_for_user`.
|
||||
async fn resolve_drive_id_for_native_webdav(
|
||||
struct DriveScope {
|
||||
drive_id: Uuid,
|
||||
/// Path in `storage.folders.path` format (drive-root name is the
|
||||
/// leading segment; that prefix is stored per D7).
|
||||
db_path: String,
|
||||
}
|
||||
|
||||
async fn resolve_webdav_scope(
|
||||
state: &Arc<AppState>,
|
||||
user_id: Uuid,
|
||||
) -> Result<Uuid, AppError> {
|
||||
state
|
||||
url_path: &str,
|
||||
) -> Result<WebdavTarget, AppError> {
|
||||
let drive_prefix = state
|
||||
.core
|
||||
.config
|
||||
.features
|
||||
.webdav_drive_listing_prefix
|
||||
.as_str();
|
||||
let normalized = url_path.trim_matches('/');
|
||||
|
||||
// Mode A: empty prefix. `/webdav/` IS the drive listing.
|
||||
if drive_prefix.is_empty() {
|
||||
if normalized.is_empty() {
|
||||
return Ok(WebdavTarget::ListDrives);
|
||||
}
|
||||
let (selector, subpath) = normalized.split_once('/').unwrap_or((normalized, ""));
|
||||
let drive = lookup_drive_selector(state, user_id, selector).await?;
|
||||
return Ok(WebdavTarget::Scope(DriveScope {
|
||||
drive_id: drive.drive.id,
|
||||
db_path: join_drive_path(&drive.root_folder_name, subpath),
|
||||
}));
|
||||
}
|
||||
|
||||
// Mode B: non-empty prefix (default `@drive`). Bare `/webdav/` is
|
||||
// the caller's default drive; drive listing lives at
|
||||
// `/webdav/<prefix>/`.
|
||||
let listing_marker = drive_prefix;
|
||||
if normalized == listing_marker {
|
||||
return Ok(WebdavTarget::ListDrives);
|
||||
}
|
||||
let with_slash = format!("{}/", listing_marker);
|
||||
if let Some(after_prefix) = normalized.strip_prefix(&with_slash) {
|
||||
if after_prefix.is_empty() {
|
||||
return Ok(WebdavTarget::ListDrives);
|
||||
}
|
||||
let (selector, subpath) = after_prefix.split_once('/').unwrap_or((after_prefix, ""));
|
||||
let drive = lookup_drive_selector(state, user_id, selector).await?;
|
||||
return Ok(WebdavTarget::Scope(DriveScope {
|
||||
drive_id: drive.drive.id,
|
||||
db_path: join_drive_path(&drive.root_folder_name, subpath),
|
||||
}));
|
||||
}
|
||||
|
||||
// Default-drive back-compat.
|
||||
let default = state
|
||||
.drive_repo
|
||||
.find_default_for_user(user_id)
|
||||
.await
|
||||
.map(|d| d.drive.id)
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to resolve default drive: {:?}", e)))
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to resolve default drive: {:?}", e))
|
||||
})?;
|
||||
let root_name = default.root_folder_name.as_str();
|
||||
let db_path = if normalized.is_empty() {
|
||||
root_name.to_string()
|
||||
} else if normalized == root_name || normalized.starts_with(&format!("{}/", root_name)) {
|
||||
// Pre-refactor bookmark already carried the drive-root prefix.
|
||||
normalized.to_string()
|
||||
} else {
|
||||
join_drive_path(root_name, normalized)
|
||||
};
|
||||
Ok(WebdavTarget::Scope(DriveScope {
|
||||
drive_id: default.drive.id,
|
||||
db_path,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Convenience: unwrap the common Scope branch or map ListDrives to a
|
||||
/// 405-shape error. Used by every write verb (PUT/DELETE/MOVE/COPY/…)
|
||||
/// that can't sensibly operate on the drive-listing pseudo-root.
|
||||
async fn resolve_webdav_scope_or_405(
|
||||
state: &Arc<AppState>,
|
||||
user_id: Uuid,
|
||||
url_path: &str,
|
||||
) -> Result<DriveScope, AppError> {
|
||||
match resolve_webdav_scope(state, user_id, url_path).await? {
|
||||
WebdavTarget::Scope(s) => Ok(s),
|
||||
WebdavTarget::ListDrives => Err(AppError::method_not_allowed(
|
||||
"Method not supported on the drive-listing pseudo-root",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn join_drive_path(root_name: &str, subpath: &str) -> String {
|
||||
let subpath = subpath.trim_start_matches('/').trim_end_matches('/');
|
||||
if subpath.is_empty() {
|
||||
root_name.to_string()
|
||||
} else {
|
||||
format!("{}/{}", root_name, subpath)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve `@drive/<selector>`: try the selector as a UUID first, then
|
||||
/// fall back to matching the drive-root folder's display name. Only
|
||||
/// drives the caller has Read access to via `role_grants` are
|
||||
/// considered — an unknown selector and a permission denial return the
|
||||
/// same `NotFound` to preserve anti-enumeration.
|
||||
async fn lookup_drive_selector(
|
||||
state: &Arc<AppState>,
|
||||
user_id: Uuid,
|
||||
selector: &str,
|
||||
) -> Result<crate::domain::repositories::drive_repository::DriveWithRootName, AppError> {
|
||||
let selector_decoded = percent_decode_str(selector).decode_utf8_lossy();
|
||||
let uuid_opt = Uuid::parse_str(selector_decoded.as_ref()).ok();
|
||||
let visible = state
|
||||
.drive_repo
|
||||
.list_readable_by(user_id)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("Failed to list drives: {:?}", e)))?;
|
||||
for d in visible {
|
||||
if let Some(uuid) = uuid_opt
|
||||
&& d.drive.id == uuid
|
||||
{
|
||||
return Ok(d);
|
||||
}
|
||||
if d.root_folder_name == selector_decoded.as_ref() {
|
||||
return Ok(d);
|
||||
}
|
||||
}
|
||||
Err(AppError::not_found(format!(
|
||||
"Drive '{}' not found",
|
||||
selector_decoded
|
||||
)))
|
||||
}
|
||||
|
||||
async fn handle_webdav_dispatch(
|
||||
@@ -270,21 +395,9 @@ async fn handle_webdav_dispatch(
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let method = req.method().clone();
|
||||
|
||||
// Translate WebDAV path → DB path by prepending user's home folder
|
||||
// prefix when the path doesn't already include it.
|
||||
// Extract user_id before any async call to keep the future Send.
|
||||
let path = if !path.is_empty() && method.as_str() != "OPTIONS" {
|
||||
let user_id = req.extensions().get::<Arc<CurrentUser>>().map(|u| u.id);
|
||||
if let Some(uid) = user_id {
|
||||
resolve_webdav_path(&state, uid, &path)
|
||||
.await
|
||||
.unwrap_or(path)
|
||||
} else {
|
||||
path
|
||||
}
|
||||
} else {
|
||||
path
|
||||
};
|
||||
// Path is left as the raw URL path (post-`/webdav/`). Every handler
|
||||
// that touches storage calls `resolve_webdav_scope` to translate the
|
||||
// URL → (drive_id, db_path).
|
||||
|
||||
match method.as_str() {
|
||||
"OPTIONS" => handle_options(path).await,
|
||||
@@ -419,46 +532,46 @@ async fn handle_propfind(
|
||||
};
|
||||
|
||||
// ── 5. Determine target resource ─────────────────────────────
|
||||
if path.is_empty() || path == "/" {
|
||||
// Root folder
|
||||
let root_folder = FolderDto {
|
||||
id: "root".to_string(),
|
||||
etag: "root".to_string(),
|
||||
name: "".to_string(),
|
||||
path: "".to_string(),
|
||||
parent_id: None,
|
||||
// Synthetic root folder for PROPFIND on `/`; not an
|
||||
// actual DB row, so drive_id has no meaningful value.
|
||||
drive_id: Uuid::nil(),
|
||||
created_at: Utc::now().timestamp() as u64,
|
||||
modified_at: Utc::now().timestamp() as u64,
|
||||
is_root: true,
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
// §14 provenance not applicable to the synthetic root.
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
};
|
||||
|
||||
return build_streaming_propfind_response(
|
||||
root_folder,
|
||||
None, // folder_id = None → root children
|
||||
&depth_owned,
|
||||
&base_href,
|
||||
propfind_request,
|
||||
folder_service,
|
||||
file_retrieval_service,
|
||||
user.id,
|
||||
state.webdav_dead_props.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
// `drive_id` is mandatory post-D0 for path-based lookups. Native
|
||||
// WebDAV resolves it once from the caller's default drive and
|
||||
// reuses it for the resolver / fallback probes below.
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
//
|
||||
// `resolve_webdav_scope` handles the URL → scope translation using
|
||||
// `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`. It can return either a concrete
|
||||
// drive scope or the synthetic drive-listing pseudo-root. Only
|
||||
// PROPFIND treats `ListDrives` as a valid target — other verbs use
|
||||
// `resolve_webdav_scope_or_405` which errors on that branch.
|
||||
let (drive_id, path) = match resolve_webdav_scope(&state, user.id, &path).await? {
|
||||
WebdavTarget::ListDrives => {
|
||||
let root_folder = FolderDto {
|
||||
id: "root".to_string(),
|
||||
etag: "root".to_string(),
|
||||
name: "".to_string(),
|
||||
path: "".to_string(),
|
||||
parent_id: None,
|
||||
// Synthetic root — not a real DB row.
|
||||
drive_id: Uuid::nil(),
|
||||
created_at: Utc::now().timestamp() as u64,
|
||||
modified_at: Utc::now().timestamp() as u64,
|
||||
is_root: true,
|
||||
icon_class: Arc::from("fas fa-folder"),
|
||||
icon_special_class: Arc::from("folder-icon"),
|
||||
category: Arc::from("Folder"),
|
||||
created_by: None,
|
||||
updated_by: None,
|
||||
};
|
||||
return build_streaming_propfind_response(
|
||||
root_folder,
|
||||
None, // folder_id = None → root children (drive-root folders)
|
||||
&depth_owned,
|
||||
&base_href,
|
||||
propfind_request,
|
||||
folder_service,
|
||||
file_retrieval_service,
|
||||
user.id,
|
||||
state.webdav_dead_props.clone(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
WebdavTarget::Scope(scope) => (scope.drive_id, scope.db_path),
|
||||
};
|
||||
|
||||
// Single-query path resolution: folder OR file in one DB round-trip.
|
||||
//
|
||||
@@ -772,6 +885,13 @@ async fn handle_proppatch(
|
||||
let user = extract_user(&req)?;
|
||||
// Client-facing path for href construction (without home folder prefix).
|
||||
let client_path = extract_webdav_path(req.uri());
|
||||
// Scope the URL → (drive_id, db_path). The synthetic drive-listing
|
||||
// pseudo-root has no DB row to anchor dead properties on; treat
|
||||
// it as an empty target and reject the PROPPATCH itself below.
|
||||
let (drive_id, path) = match resolve_webdav_scope(&state, user.id, &path).await? {
|
||||
WebdavTarget::ListDrives => (Uuid::nil(), String::new()),
|
||||
WebdavTarget::Scope(scope) => (scope.drive_id, scope.db_path),
|
||||
};
|
||||
|
||||
// Active-lock guard (RFC 4918 §9.10.4): PROPPATCH writes properties,
|
||||
// so a lock on the target must release them via `If:`. Captured
|
||||
@@ -813,7 +933,7 @@ async fn handle_proppatch(
|
||||
// PROPPATCH itself below so we don't fabricate a target.
|
||||
(None, true)
|
||||
} else {
|
||||
match resolve_or_legacy(&state, &path, user.id).await {
|
||||
match resolve_or_legacy(&state, &path, drive_id).await {
|
||||
Some(ResolvedResource::Folder(folder)) => {
|
||||
let id = Uuid::parse_str(&folder.id).map_err(|e| {
|
||||
AppError::internal_error(format!("Folder id is not a UUID: {e}"))
|
||||
@@ -831,6 +951,21 @@ async fn handle_proppatch(
|
||||
let resource_ref = resource_ref
|
||||
.ok_or_else(|| AppError::forbidden("PROPPATCH on the WebDAV root is not supported"))?;
|
||||
|
||||
// AuthZ: PROPPATCH writes dead properties on the target — that's
|
||||
// a mutation, requires `Update`. Without this check any caller who
|
||||
// can Read (e.g. a Viewer-role grant) could persist dead-prop rows
|
||||
// on someone else's file. Anti-enum-preserving: `require` maps
|
||||
// denial to `NotFound`, matching the anonymous-not-found response
|
||||
// above.
|
||||
let resource = match resource_ref {
|
||||
ResourceRef::Folder(id) => Resource::Folder(id),
|
||||
ResourceRef::File(id) => Resource::File(id),
|
||||
};
|
||||
state
|
||||
.authorization
|
||||
.require(Subject::User(user.id), Permission::Update, resource)
|
||||
.await?;
|
||||
|
||||
// Read request body (XML — bounded to 1 MB)
|
||||
let body_bytes = body::to_bytes(req.into_body(), MAX_XML_BODY)
|
||||
.await
|
||||
@@ -909,7 +1044,9 @@ async fn handle_get(
|
||||
// `drive_id` is the path-lookup scope post-D0 (paths repeat across
|
||||
// drives), derived once from the caller's default drive and reused
|
||||
// by both the resolver + legacy fallback.
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
|
||||
let drive_id = scope.drive_id;
|
||||
let path = scope.db_path;
|
||||
|
||||
// Resolve file — drive-scoped when PathResolver is available.
|
||||
// Post-D7 both branches enforce `Read` on the resolved file
|
||||
@@ -1034,7 +1171,9 @@ async fn handle_head(
|
||||
|
||||
// `drive_id` is the path-lookup scope post-D0 — derive once and
|
||||
// reuse across the resolver + fallback branches below.
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
|
||||
let drive_id = scope.drive_id;
|
||||
let path = scope.db_path;
|
||||
|
||||
// Single-query path resolution (drive-scoped). Both branches
|
||||
// enforce `Read` on the resolved resource before emitting the
|
||||
@@ -1165,20 +1304,12 @@ async fn handle_head(
|
||||
async fn resolve_or_legacy(
|
||||
state: &Arc<AppState>,
|
||||
path: &str,
|
||||
user_id: Uuid,
|
||||
drive_id: Uuid,
|
||||
) -> Option<ResolvedResource> {
|
||||
// Path-lookup scope post-D0 — derive the caller's default drive
|
||||
// once and reuse across both probes. `find_default_for_user`
|
||||
// returning Err (e.g. external user, or boot before the lifecycle
|
||||
// hook fired) means no resolution is possible: return None.
|
||||
let drive_id = state
|
||||
.drive_repo
|
||||
.find_default_for_user(user_id)
|
||||
.await
|
||||
.ok()?
|
||||
.drive
|
||||
.id;
|
||||
|
||||
// `drive_id` is now passed in by the caller (already computed by
|
||||
// `resolve_webdav_scope`) so the fallback probes stay consistent
|
||||
// with the primary resolver — cross-drive URLs no longer silently
|
||||
// fall back to the caller's default drive.
|
||||
if let Some(resolver) = &state.path_resolver
|
||||
&& let Ok(r) = resolver.resolve_path_in_drive(path, drive_id).await
|
||||
{
|
||||
@@ -1594,7 +1725,9 @@ async fn handle_put(
|
||||
// `drive_id` is the path-lookup scope post-D0 — resolve once from
|
||||
// the caller's default drive, reused by the resolver checks below
|
||||
// and by the atomic-store call further down.
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
|
||||
let drive_id = scope.drive_id;
|
||||
let path = scope.db_path;
|
||||
|
||||
// ── Existence check ───────────────────────────────────────────────
|
||||
// Resolves to: File(existing), Folder(wrong), or Err(new file).
|
||||
@@ -1782,10 +1915,11 @@ async fn handle_put(
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
}
|
||||
Err(e) => Err(AppError::internal_error(format!(
|
||||
"Failed to put file: {}",
|
||||
e
|
||||
))),
|
||||
// Propagate DomainError kinds — NotFound (authz denial via
|
||||
// `require_target_folder_perm`), Conflict (missing parent) etc.
|
||||
// Wrapping everything as InternalError swallowed 404s from the
|
||||
// service's own AuthZ, surfacing them to callers as 500.
|
||||
Err(e) => Err(AppError::from(e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1807,9 +1941,13 @@ async fn handle_mkcol(
|
||||
let user = extract_user(&req)?;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
|
||||
if path.is_empty() || path == "/" {
|
||||
return Err(AppError::conflict("Root folder already exists"));
|
||||
}
|
||||
// Bare `/webdav/` handling: routed through `resolve_webdav_scope_or_405`
|
||||
// below. In the empty-drive-path config that resolves to the
|
||||
// drive-listing pseudo-root (405 method-not-allowed); in the
|
||||
// default `@drive` config it resolves to the default drive's root
|
||||
// folder (which already exists — the existence probe at
|
||||
// `exists_in_drive` further down returns 405 per RFC 4918 §9.3.1).
|
||||
// Both configs end at 405 without a special-case.
|
||||
|
||||
// Extract content-type before consuming the body.
|
||||
let req_content_type = req
|
||||
@@ -1848,7 +1986,9 @@ async fn handle_mkcol(
|
||||
// This handler only creates a single collection (the last path segment).
|
||||
// It does NOT auto-create intermediate ancestors ("mkdir -p" semantics
|
||||
// violate the RFC and were causing the test failures).
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
|
||||
let drive_id = scope.drive_id;
|
||||
let path = scope.db_path;
|
||||
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
|
||||
|
||||
if segments.is_empty() {
|
||||
@@ -1977,6 +2117,22 @@ async fn handle_delete(
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let user = extract_user(&req)?;
|
||||
|
||||
// Refuse DELETE on the pseudo-root before any scope work — bare
|
||||
// `/webdav/` (empty-config drive listing OR classic-config default
|
||||
// drive root) can't be deleted from the WebDAV surface.
|
||||
if path.is_empty() || path == "/" {
|
||||
return Err(AppError::forbidden("Cannot delete root folder"));
|
||||
}
|
||||
|
||||
// Scope resolution BEFORE the lock guard so `enforce_native_lock`
|
||||
// keys on the same DB path that `handle_lock` used when it
|
||||
// registered the lock. Doing it in the reverse order (as before
|
||||
// the drive-scope refactor) silently defeated every LOCK because
|
||||
// the lock-store key mismatch made every DELETE look unlocked.
|
||||
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
|
||||
let drive_id = scope.drive_id;
|
||||
let path = scope.db_path;
|
||||
|
||||
// Active-lock guard (RFC 4918 §9.10.4).
|
||||
let if_header_owned = req
|
||||
.headers()
|
||||
@@ -1997,17 +2153,12 @@ async fn handle_delete(
|
||||
let file_management_service = &state.applications.file_management_service;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
|
||||
// Check if path is empty (root folder)
|
||||
if path.is_empty() || path == "/" {
|
||||
return Err(AppError::forbidden("Cannot delete root folder"));
|
||||
}
|
||||
|
||||
// Resolve via optimized resolver, falling back to the legacy
|
||||
// double-query lookup (the one GET uses). Necessary because the
|
||||
// optimized resolver and the read repositories disagree on path
|
||||
// shape for some files; see `resolve_or_legacy` docs.
|
||||
let _ = file_retrieval_service; // present for legacy fallback if needed elsewhere
|
||||
match resolve_or_legacy(&state, &path, user.id).await {
|
||||
match resolve_or_legacy(&state, &path, drive_id).await {
|
||||
Some(ResolvedResource::Folder(folder)) => {
|
||||
folder_service
|
||||
.delete_folder_with_perms(&folder.id, user.id)
|
||||
@@ -2062,17 +2213,6 @@ async fn handle_move(
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// Active-lock guard on the SOURCE (RFC 4918 §9.10.4): the move
|
||||
// removes the source resource, which counts as modifying it.
|
||||
if let Some(resp) = enforce_native_lock(
|
||||
&state.webdav_lock_store,
|
||||
if_header_owned.as_deref(),
|
||||
&source_path,
|
||||
None,
|
||||
) {
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
// Get destination from Destination header
|
||||
let destination = req
|
||||
.headers()
|
||||
@@ -2101,22 +2241,41 @@ async fn handle_move(
|
||||
// SECURITY: reject path-traversal in destination
|
||||
reject_path_traversal(&destination_path)?;
|
||||
|
||||
// Normalize destination through the SAME path-prefixing that
|
||||
// `resolve_webdav_path` applied to `source_path` during dispatch.
|
||||
// Without this, comparing source_parent_path (already prefixed with
|
||||
// the user's home folder name) against dest_parent_path (raw from
|
||||
// the URL, no prefix) always reports "different parent" — even for a
|
||||
// pure rename at the same level — and breaks the move/rename branch
|
||||
// selection below.
|
||||
let destination_path = resolve_webdav_path(&state, user.id, &destination_path)
|
||||
.await
|
||||
.unwrap_or(destination_path);
|
||||
// Resolve BOTH source and destination scope. Cross-drive MOVE is
|
||||
// permitted: the underlying service methods
|
||||
// (`move_folder_with_perms` / `move_file_with_perms`) support it
|
||||
// natively — they enforce the D5 `forbid_cross_drive_move` policy
|
||||
// per drive and emit a D6 `resource.moved_between_drives` audit
|
||||
// line when the move crosses a boundary. Downstream probes that
|
||||
// walk `storage.{folders,files}.path` need the RIGHT drive scope
|
||||
// for each side; we thread `src_drive_id` for source probes and
|
||||
// `dst_drive_id` for destination probes.
|
||||
let src_scope = resolve_webdav_scope_or_405(&state, user.id, &source_path).await?;
|
||||
let dst_scope = resolve_webdav_scope_or_405(&state, user.id, &destination_path).await?;
|
||||
let src_drive_id = src_scope.drive_id;
|
||||
let dst_drive_id = dst_scope.drive_id;
|
||||
let source_path = src_scope.db_path;
|
||||
let path = source_path.clone();
|
||||
let destination_path = dst_scope.db_path;
|
||||
|
||||
// RFC 4918 §9.9.3: MOVE to self MUST return 403 Forbidden.
|
||||
if destination_path == source_path {
|
||||
if destination_path == path {
|
||||
return Err(AppError::forbidden("Cannot MOVE a resource to itself"));
|
||||
}
|
||||
|
||||
// Active-lock guard on the SOURCE (RFC 4918 §9.10.4): the move
|
||||
// removes the source resource, which counts as modifying it. The
|
||||
// guard runs AFTER scope resolution so its lookup keys on the DB
|
||||
// path — same key `handle_lock` used when it registered the lock.
|
||||
if let Some(resp) = enforce_native_lock(
|
||||
&state.webdav_lock_store,
|
||||
if_header_owned.as_deref(),
|
||||
&source_path,
|
||||
None,
|
||||
) {
|
||||
return Ok(resp);
|
||||
}
|
||||
|
||||
// Destination lock guard: MOVE also creates/replaces a resource at
|
||||
// the destination. If that path is locked, the same If: header must
|
||||
// satisfy it.
|
||||
@@ -2133,21 +2292,19 @@ async fn handle_move(
|
||||
let file_management_service = &state.applications.file_management_service;
|
||||
let folder_service = &state.applications.folder_service;
|
||||
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
|
||||
// Probe destination existence for Overwrite semantics and 201 vs 204.
|
||||
let dest_existed = if let Some(resolver) = &state.path_resolver {
|
||||
resolver
|
||||
.exists_in_drive(&destination_path, drive_id)
|
||||
.exists_in_drive(&destination_path, dst_drive_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
folder_service
|
||||
.get_folder_by_path(&destination_path, drive_id)
|
||||
.get_folder_by_path(&destination_path, dst_drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
|| file_retrieval_service
|
||||
.get_file_by_path(&destination_path, drive_id)
|
||||
.get_file_by_path(&destination_path, dst_drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
};
|
||||
@@ -2161,7 +2318,7 @@ async fn handle_move(
|
||||
// RFC 4918 §9.9.3: when Overwrite: T, perform a DELETE on the
|
||||
// destination before moving. Without this the rename/move fails
|
||||
// on a unique-index conflict (same name in same parent).
|
||||
match resolve_or_legacy(&state, &destination_path, user.id).await {
|
||||
match resolve_or_legacy(&state, &destination_path, dst_drive_id).await {
|
||||
Some(ResolvedResource::Folder(f)) => {
|
||||
folder_service
|
||||
.delete_folder_with_perms(&f.id, user.id)
|
||||
@@ -2189,7 +2346,7 @@ async fn handle_move(
|
||||
}
|
||||
|
||||
let _ = file_retrieval_service;
|
||||
let resolved = resolve_or_legacy(&state, &source_path, user.id)
|
||||
let resolved = resolve_or_legacy(&state, &source_path, src_drive_id)
|
||||
.await
|
||||
.ok_or_else(|| AppError::not_found(format!("Resource not found: {}", source_path)))?;
|
||||
|
||||
@@ -2213,7 +2370,7 @@ async fn handle_move(
|
||||
None
|
||||
} else {
|
||||
match folder_service
|
||||
.get_folder_by_path(dest_parent_path, drive_id)
|
||||
.get_folder_by_path(dest_parent_path, dst_drive_id)
|
||||
.await
|
||||
{
|
||||
Ok(parent) => {
|
||||
@@ -2262,13 +2419,19 @@ async fn handle_move(
|
||||
}
|
||||
}
|
||||
ResolvedResource::File(file) => {
|
||||
if source_parent_path != dest_parent_path {
|
||||
// A cross-drive move always changes the parent folder id even
|
||||
// if the RELATIVE path within each drive looks the same, so
|
||||
// we key the "same-parent rename" fast-path off drive id
|
||||
// agreement as well.
|
||||
let is_same_parent =
|
||||
src_drive_id == dst_drive_id && source_parent_path == dest_parent_path;
|
||||
if !is_same_parent {
|
||||
// RFC 4918 §9.9.5: missing destination parent → 409 Conflict.
|
||||
let target_parent_id = if dest_parent_path.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let parent = folder_service
|
||||
.get_folder_by_path(dest_parent_path, drive_id)
|
||||
.get_folder_by_path(dest_parent_path, dst_drive_id)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
AppError::conflict(format!(
|
||||
@@ -2382,12 +2545,20 @@ async fn handle_copy(
|
||||
// SECURITY: reject path-traversal in destination
|
||||
reject_path_traversal(&destination_path)?;
|
||||
|
||||
// Normalize through the same path-prefixing the dispatcher applied
|
||||
// to source_path. See the long comment in handle_move for why this
|
||||
// matters — same root-cause class of asymmetric-path bugs.
|
||||
let destination_path = resolve_webdav_path(&state, user.id, &destination_path)
|
||||
.await
|
||||
.unwrap_or(destination_path);
|
||||
// Resolve BOTH source and destination scope. Cross-drive COPY is
|
||||
// permitted: `copy_file_with_perms` / `copy_folder_tree_with_perms`
|
||||
// take a target folder id and don't care which drive it lives in;
|
||||
// the D5 `forbid_cross_drive_move` policy applies to MOVE only,
|
||||
// never to COPY (copying is non-destructive on the source side).
|
||||
// Downstream probes need the right drive per side, so we thread
|
||||
// `src_drive_id` for source probes and `dst_drive_id` for
|
||||
// destination probes.
|
||||
let src_scope = resolve_webdav_scope_or_405(&state, user.id, &source_path).await?;
|
||||
let dst_scope = resolve_webdav_scope_or_405(&state, user.id, &destination_path).await?;
|
||||
let src_drive_id = src_scope.drive_id;
|
||||
let dst_drive_id = dst_scope.drive_id;
|
||||
let source_path = src_scope.db_path;
|
||||
let destination_path = dst_scope.db_path;
|
||||
|
||||
// RFC 4918 §9.8.5: COPY to self MUST return 403 Forbidden.
|
||||
if destination_path == source_path {
|
||||
@@ -2416,21 +2587,23 @@ async fn handle_copy(
|
||||
let folder_service = &state.applications.folder_service;
|
||||
let file_management_service = &state.applications.file_management_service;
|
||||
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
// Scope already resolved above; keep `path` alias for downstream code
|
||||
// that still reads `path` under its original name.
|
||||
let _path = source_path.clone();
|
||||
|
||||
// Probe destination existence for Overwrite semantics and 201 vs 204.
|
||||
let dest_existed = if let Some(resolver) = &state.path_resolver {
|
||||
resolver
|
||||
.exists_in_drive(&destination_path, drive_id)
|
||||
.exists_in_drive(&destination_path, dst_drive_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
folder_service
|
||||
.get_folder_by_path(&destination_path, drive_id)
|
||||
.get_folder_by_path(&destination_path, dst_drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
|| file_retrieval_service
|
||||
.get_file_by_path(&destination_path, drive_id)
|
||||
.get_file_by_path(&destination_path, dst_drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
};
|
||||
@@ -2444,7 +2617,7 @@ async fn handle_copy(
|
||||
// RFC 4918 §9.8.4: when Overwrite: T, the server MUST perform a
|
||||
// DELETE on the destination before the copy. Without this the copy
|
||||
// service returns a unique-index conflict (500).
|
||||
match resolve_or_legacy(&state, &destination_path, user.id).await {
|
||||
match resolve_or_legacy(&state, &destination_path, dst_drive_id).await {
|
||||
Some(ResolvedResource::Folder(f)) => {
|
||||
folder_service
|
||||
.delete_folder_with_perms(&f.id, user.id)
|
||||
@@ -2472,7 +2645,7 @@ async fn handle_copy(
|
||||
}
|
||||
|
||||
let _ = file_retrieval_service;
|
||||
let resolved = resolve_or_legacy(&state, &source_path, user.id)
|
||||
let resolved = resolve_or_legacy(&state, &source_path, src_drive_id)
|
||||
.await
|
||||
.ok_or_else(|| AppError::not_found(format!("Resource not found: {}", source_path)))?;
|
||||
|
||||
@@ -2490,7 +2663,7 @@ async fn handle_copy(
|
||||
None
|
||||
} else {
|
||||
match folder_service
|
||||
.get_folder_by_path(dest_parent_path, drive_id)
|
||||
.get_folder_by_path(dest_parent_path, dst_drive_id)
|
||||
.await
|
||||
{
|
||||
Ok(parent) => {
|
||||
@@ -2590,25 +2763,101 @@ async fn handle_lock(
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let user = extract_user(&req)?;
|
||||
|
||||
// Determine collection-vs-file for href shape. Root + known
|
||||
// folders → collection; everything else (existing files,
|
||||
// lock-null on a non-existent path) → file. RFC 4918 §9.10.1
|
||||
// allows LOCK on a non-existent resource (the "lock-null
|
||||
// resource" pattern used by Office save flows) — that arm
|
||||
// falls through to the file href shape, matching the
|
||||
// request-line shape clients send.
|
||||
let is_collection = if path.is_empty() || path == "/" {
|
||||
true
|
||||
// Scope resolution BEFORE the collection probe so `path` becomes
|
||||
// the drive-scoped DB path everywhere downstream — critically the
|
||||
// `lock_store.acquire(&path, …)` call must use the SAME key that
|
||||
// `enforce_native_lock` will look up from the write verbs
|
||||
// (PUT/DELETE/MOVE/COPY/PROPPATCH), all of which pass the DB path.
|
||||
// Locking the URL path here and looking up the DB path in PUT
|
||||
// would silently defeat the lock — that's the regression this
|
||||
// shape prevents.
|
||||
let (drive_id, path) = if path.is_empty() || path == "/" {
|
||||
(Uuid::nil(), path)
|
||||
} else {
|
||||
let drive_id = resolve_drive_id_for_native_webdav(&state, user.id).await?;
|
||||
state
|
||||
.applications
|
||||
.folder_service
|
||||
.get_folder_by_path(&path, drive_id)
|
||||
.await
|
||||
.is_ok()
|
||||
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
|
||||
(scope.drive_id, scope.db_path)
|
||||
};
|
||||
|
||||
// Determine collection-vs-file for href shape AND resolve the
|
||||
// target for AuthZ. Root + known folders → collection; existing
|
||||
// files → file; missing path → lock-null (RFC 4918 §7.3 /
|
||||
// §9.10.1, used by Office save flows). AuthZ per case:
|
||||
// * Existing folder / file → `Update` on the resource.
|
||||
// * Lock-null (target doesn't exist yet) → `Create` on the
|
||||
// parent folder (the lock reserves the URL for a future PUT
|
||||
// that would need `Create` anyway; deny here so a Viewer
|
||||
// can't create a lock-null placeholder on someone else's
|
||||
// namespace).
|
||||
// Denial routes through `NotFound` (anti-enum), matching the
|
||||
// rest of the WebDAV surface.
|
||||
let (is_collection, lockable_resource) = if path.is_empty() {
|
||||
(true, None)
|
||||
} else if let Ok(folder) = state
|
||||
.applications
|
||||
.folder_service
|
||||
.get_folder_by_path(&path, drive_id)
|
||||
.await
|
||||
{
|
||||
let uuid = Uuid::parse_str(&folder.id)
|
||||
.map_err(|e| AppError::internal_error(format!("Folder id is not a UUID: {e}")))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Update,
|
||||
Resource::Folder(uuid),
|
||||
)
|
||||
.await?;
|
||||
(true, Some(Resource::Folder(uuid)))
|
||||
} else if let Ok(file) = state
|
||||
.applications
|
||||
.file_retrieval_service
|
||||
.get_file_by_path(&path, drive_id)
|
||||
.await
|
||||
{
|
||||
let uuid = Uuid::parse_str(&file.id)
|
||||
.map_err(|e| AppError::internal_error(format!("File id is not a UUID: {e}")))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Update,
|
||||
Resource::File(uuid),
|
||||
)
|
||||
.await?;
|
||||
(false, Some(Resource::File(uuid)))
|
||||
} else {
|
||||
// Lock-null: authorise on the parent folder. The last `/` in
|
||||
// `path` splits parent from name; empty parent means the drive
|
||||
// root (which itself was already resolved above — the caller
|
||||
// must have Read on it to have gotten this far via
|
||||
// `resolve_webdav_scope`).
|
||||
let parent_path = path.rfind('/').map(|i| &path[..i]).unwrap_or("");
|
||||
if !parent_path.is_empty() {
|
||||
let parent = state
|
||||
.applications
|
||||
.folder_service
|
||||
.get_folder_by_path(parent_path, drive_id)
|
||||
.await
|
||||
.map_err(|_| AppError::conflict("Parent folder not found for lock-null"))?;
|
||||
let parent_uuid = Uuid::parse_str(&parent.id).map_err(|e| {
|
||||
AppError::internal_error(format!("Parent folder id is not a UUID: {e}"))
|
||||
})?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Create,
|
||||
Resource::Folder(parent_uuid),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
// No resource to authorise directly — the lock reserves the URL,
|
||||
// downstream PUT will re-authorise via its own Create/Update.
|
||||
(false, None)
|
||||
};
|
||||
let _ = lockable_resource;
|
||||
|
||||
// Get the headers that we need
|
||||
let depth = req
|
||||
.headers()
|
||||
@@ -2690,13 +2939,17 @@ async fn handle_lock(
|
||||
type_,
|
||||
};
|
||||
|
||||
// Try to acquire the lock (conflict detection via moka store)
|
||||
let entry = lock_store.acquire(&path, lock_info).map_err(|existing| {
|
||||
AppError::locked(format!(
|
||||
"Resource already locked by token {}",
|
||||
existing.info.token
|
||||
))
|
||||
})?;
|
||||
// Try to acquire the lock (conflict detection via moka store).
|
||||
// `caller_user_id` is stamped on the entry so `handle_unlock`
|
||||
// can enforce RFC 4918 §9.11's owner-only rule.
|
||||
let entry = lock_store
|
||||
.acquire(&path, lock_info, Some(user.id))
|
||||
.map_err(|existing| {
|
||||
AppError::locked(format!(
|
||||
"Resource already locked by token {}",
|
||||
existing.info.token
|
||||
))
|
||||
})?;
|
||||
|
||||
// Generate response — collection vs file href chosen above.
|
||||
let href = if is_collection {
|
||||
@@ -2735,9 +2988,9 @@ async fn handle_lock(
|
||||
async fn handle_unlock(
|
||||
state: Arc<AppState>,
|
||||
req: Request<Body>,
|
||||
_path: String,
|
||||
path: String,
|
||||
) -> Result<Response<Body>, AppError> {
|
||||
let _user = extract_user(&req)?;
|
||||
let user = extract_user(&req)?;
|
||||
|
||||
// Get lock token from Lock-Token header
|
||||
let lock_token = req
|
||||
@@ -2753,6 +3006,65 @@ async fn handle_unlock(
|
||||
.trim_end_matches('>')
|
||||
.to_string();
|
||||
|
||||
// RFC 4918 §9.11 owner-only check. `LockEntry.caller_user_id`
|
||||
// was stamped by `handle_lock` at acquire time. When the lock
|
||||
// exists AND we know the acquirer, only that user can UNLOCK.
|
||||
// Denial routes through the standard authz `NotFound` anti-enum
|
||||
// — a caller who neither holds the lock nor has any perm on the
|
||||
// resource shouldn't learn whether the lock exists.
|
||||
//
|
||||
// Approximations preserved:
|
||||
// * Lock entries seeded by tests (`caller_user_id = None`) fall
|
||||
// through to the Update-based check below — they were never
|
||||
// bound to a real user.
|
||||
// * If the token isn't in the store at all (expired, never
|
||||
// existed) we skip the owner check and let the `release`
|
||||
// call below return the RFC-standard 409.
|
||||
let lock_entry = state.webdav_lock_store.get_by_token(&token);
|
||||
if let Some(entry) = &lock_entry
|
||||
&& let Some(owner_id) = entry.caller_user_id
|
||||
&& owner_id != user.id
|
||||
{
|
||||
tracing::info!(
|
||||
target: "audit",
|
||||
event = "webdav.unlock_denied",
|
||||
reason = "not_lock_owner",
|
||||
caller_id = %user.id,
|
||||
lock_owner_id = %owner_id,
|
||||
token = %token,
|
||||
"👮🏻♂️ UNLOCK refused: caller does not own the lock",
|
||||
);
|
||||
return Err(AppError::not_found(format!(
|
||||
"Lock token not found or already expired: {}",
|
||||
token
|
||||
)));
|
||||
}
|
||||
|
||||
// Defence-in-depth for the test-seeded / legacy `caller_user_id
|
||||
// = None` case: require `Update` on the target resource so a
|
||||
// Read-only grantee still can't unlock. Uses the URL path (the
|
||||
// lock's target) to resolve the resource. Missing target → skip
|
||||
// (lock-null unlock is legitimate).
|
||||
if let Some(entry) = &lock_entry
|
||||
&& entry.caller_user_id.is_none()
|
||||
&& !path.is_empty()
|
||||
&& path != "/"
|
||||
{
|
||||
let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?;
|
||||
let drive_id = scope.drive_id;
|
||||
let db_path = scope.db_path;
|
||||
if let Some(resource) = match resolve_or_legacy(&state, &db_path, drive_id).await {
|
||||
Some(ResolvedResource::Folder(f)) => Uuid::parse_str(&f.id).ok().map(Resource::Folder),
|
||||
Some(ResolvedResource::File(f)) => Uuid::parse_str(&f.id).ok().map(Resource::File),
|
||||
None => None,
|
||||
} {
|
||||
state
|
||||
.authorization
|
||||
.require(Subject::User(user.id), Permission::Update, resource)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the lock from the store
|
||||
if !state.webdav_lock_store.release(&token) {
|
||||
// RFC 4918 §9.11.1: If the lock does not exist, return 409 Conflict
|
||||
|
||||
@@ -185,6 +185,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/webdav_drive_root.hurl" \
|
||||
"$API_DIR/webdav_permissions.hurl" \
|
||||
"$API_DIR/webdav_nested_move_cascade.hurl" \
|
||||
"$API_DIR/wopi_authz.hurl"
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
# =============================================================
|
||||
# OxiCloud — WebDAV drive-root URL scheme
|
||||
# =============================================================
|
||||
# Exercises the native WebDAV URL scheme documented in
|
||||
# `src/interfaces/api/handlers/webdav_handler.rs::resolve_webdav_scope`:
|
||||
#
|
||||
# Default deployment (`OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"`):
|
||||
# * `/webdav/` → default drive's contents
|
||||
# * `/webdav/@drive/` → drive listing (per-drive
|
||||
# virtual folders)
|
||||
# * `/webdav/@drive/<uuid>/…` → explicit drive by UUID
|
||||
# * `/webdav/@drive/<name>/…` → explicit drive by name
|
||||
#
|
||||
# Coverage:
|
||||
# 1. Login, capture JWT
|
||||
# 2. Resolve caller's default drive (id + display name)
|
||||
# 3. Create a magic folder under the home root via REST
|
||||
# 4. PROPFIND `/webdav/` — Depth: 1 lists the magic folder as
|
||||
# an immediate child of the default drive. This is the
|
||||
# user-visible bug fix: pre-refactor, `/webdav/` returned a
|
||||
# drive listing instead of the default drive's contents.
|
||||
# 5. PROPFIND `/webdav/@drive/` — Depth: 1 lists each drive as
|
||||
# a virtual child (at least the caller's default is present).
|
||||
# 6. PROPFIND `/webdav/@drive/<uuid>/` — descends into the
|
||||
# selected drive by UUID; magic folder appears here too.
|
||||
# 7. PROPFIND `/webdav/@drive/<name>/` — same via display name.
|
||||
# 8. Cleanup: DELETE the magic folder via REST.
|
||||
#
|
||||
# The magic folder name embeds a run-scoped marker so parallel
|
||||
# `hurl --jobs N` runs don't step on each other and repeat runs
|
||||
# against a shared DB don't collide.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — Login, capture JWT
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — Resolve caller's default drive (id + display name).
|
||||
# `GET /api/drives` returns rows in a stable order:
|
||||
# the caller's default personal drive first, then by
|
||||
# display name. See `DriveRepository::list_readable_by`.
|
||||
# `default_for_user` on the DTO is present-only for
|
||||
# default rows (`Option<Uuid>` with `skip_serializing_if`),
|
||||
# so `$[0]` — combined with the stable order — is the
|
||||
# default drive for a fresh admin account.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/drives
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
default_drive_id: jsonpath "$[0].id"
|
||||
default_drive_name: jsonpath "$[0].name"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — Resolve the caller's home root folder id.
|
||||
# A default personal drive has exactly one root folder
|
||||
# (the drive-root itself). We need its id to create the
|
||||
# magic folder as its child.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/folders
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
home_folder_id: jsonpath "$[0].id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — Create a magic folder under the home root via REST.
|
||||
# The name is deterministic-yet-unique so PROPFIND
|
||||
# assertions below can find it by exact string match,
|
||||
# and parallel test runs can't collide.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"name": "hurl-drive-root-magic-marker",
|
||||
"parent_id": "{{home_folder_id}}"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
magic_folder_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — PROPFIND on `/webdav/` (bare root). The default
|
||||
# deployment maps this to the caller's DEFAULT drive
|
||||
# contents, so Depth: 1 must include the magic folder.
|
||||
#
|
||||
# Pre-refactor this returned a drive listing instead —
|
||||
# the exact regression that broke back-compat with
|
||||
# pre-multi-drive WebDAV clients.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 1
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-magic-marker')]" exists
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — PROPFIND on `/webdav/@drive/`. This is the explicit
|
||||
# drive picker — Depth: 1 returns one virtual child
|
||||
# per drive the caller has Read on. The default drive
|
||||
# must appear (by its display name).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/@drive/
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 1
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), '{{default_drive_name}}')]" exists
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — PROPFIND on `/webdav/@drive/<uuid>/`. The explicit
|
||||
# by-UUID selector — descends INTO the chosen drive.
|
||||
# Depth: 1 lists that drive's top-level children —
|
||||
# the magic folder must be one of them.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/@drive/{{default_drive_id}}/
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 1
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-magic-marker')]" exists
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — PROPFIND on `/webdav/@drive/<name>/`. The explicit
|
||||
# by-name selector — same result as the UUID form.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/@drive/{{default_drive_name}}/
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 1
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-magic-marker')]" exists
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — Reject MKCOL at `/webdav/@drive/` (bare pseudo-root).
|
||||
# The drive-listing target has no writable parent
|
||||
# folder — 405 Method Not Allowed. This guard prevents
|
||||
# a client from silently succeeding at "creating a
|
||||
# drive by MKCOL" (the drive-create surface is
|
||||
# `POST /api/drives`, not WebDAV).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MKCOL {{base_url}}/webdav/@drive/
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 405
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — Reject MKCOL at `/webdav/@drive/<not-a-drive>`.
|
||||
# `<not-a-drive>` gets interpreted as a drive selector;
|
||||
# no drive with that name/UUID exists → 404. Sits
|
||||
# adjacent to Step 9 so any future maintainer touching
|
||||
# the pseudo-root rejection sees BOTH shapes at once
|
||||
# (bare listing = 405, unknown selector = 404).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MKCOL {{base_url}}/webdav/@drive/hurl-not-a-real-drive
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 — Reject PUT at `/webdav/@drive/<not-a-drive>/x.txt`.
|
||||
# Same rejection shape as MKCOL — trying to write a
|
||||
# file into a non-existent drive.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/@drive/hurl-not-a-real-drive/probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: text/plain
|
||||
```
|
||||
probe
|
||||
```
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11b — Reject PUT at `/webdav/@drive/test.txt`. The URL
|
||||
# segment immediately after `@drive/` is ALWAYS a
|
||||
# drive selector — never a filename. A caller that
|
||||
# bookmarks a file URL under `@drive` with a name
|
||||
# that doesn't match any drive must get 404, not
|
||||
# silently create a file at the drive-listing level.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/@drive/test.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: text/plain
|
||||
```
|
||||
probe
|
||||
```
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 12 — Cleanup: DELETE the magic folder via REST so
|
||||
# subsequent test runs / other hurl files don't see
|
||||
# our marker.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/folders/{{magic_folder_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
@@ -0,0 +1,300 @@
|
||||
# =============================================================
|
||||
# OxiCloud — WebDAV per-role permissions + cross-drive MOVE policy
|
||||
# =============================================================
|
||||
# End-to-end coverage for the two WebDAV authz axes exposed by the
|
||||
# `@drive` URL scheme:
|
||||
#
|
||||
# 1. Per-role gates through the drive-scope resolver: a Viewer on a
|
||||
# shared drive can PROPFIND/GET but cannot MKCOL/PUT/MOVE. An
|
||||
# Editor can. AuthZ denials return `NotFound` (anti-enum), so
|
||||
# a probing caller can't tell a genuinely-missing folder from
|
||||
# one they simply lack Create on.
|
||||
#
|
||||
# 2. Drive policy `forbid_cross_drive_move` gates MOVE at the
|
||||
# SOURCE drive (see `DrivePolicies::refuse_cross_drive_move`
|
||||
# in `src/domain/entities/drive.rs`) — even a fully-authorised
|
||||
# Editor can't move content OUT of a drive whose owner has
|
||||
# forbidden cross-drive movement. Rejection is 405
|
||||
# (`ErrorKind::UnsupportedOperation` → `METHOD_NOT_ALLOWED`).
|
||||
#
|
||||
# Assumes the default `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"` config —
|
||||
# runs alongside the other tests in `tests/api/run.sh`. Uses the
|
||||
# `@drive/<uuid>` selector so the paths don't collide with any
|
||||
# drive-name-collision oddities.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — Login as admin (bootstrapped by `setup.hurl`).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
admin_token: jsonpath "$.access_token"
|
||||
admin_user_id: jsonpath "$.user.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — Create a fresh user "webdav_bob" via the admin
|
||||
# endpoint, log him in.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "webdav_bob",
|
||||
"password": "WebdavBobPassword1!",
|
||||
"email": "webdav_bob@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
bob_user_id: jsonpath "$.id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "webdav_bob", "password": "WebdavBobPassword1!" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
bob_token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# Capture Bob's default personal drive id — used by the cross-drive
|
||||
# MOVE scenario. Bob is not a member of any shared drive yet, so his
|
||||
# `/api/drives` listing has exactly one entry (his own default).
|
||||
GET {{base_url}}/api/drives
|
||||
Authorization: Bearer {{bob_token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
bob_personal_drive_id: jsonpath "$[0].id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — Admin creates a shared drive owned by admin.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/drives
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"kind": "shared",
|
||||
"name": "webdav-perm-shared",
|
||||
"owner": { "type": "user", "id": "{{admin_user_id}}" }
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
shared_drive_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — Grant Bob VIEWER on the shared drive via /api/grants.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{bob_user_id}}" },
|
||||
"resource": { "type": "drive", "id": "{{shared_drive_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — Bob (VIEWER) CAN PROPFIND the shared drive root.
|
||||
# Depth 0 to keep the assertion minimal; a 207 with the
|
||||
# drive's own href suffices as "Bob has Read".
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/@drive/{{shared_drive_id}}/
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Depth: 0
|
||||
|
||||
HTTP 207
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — Bob (VIEWER) CANNOT MKCOL on the shared drive.
|
||||
# `authz.require(Create, Folder)` denial returns
|
||||
# `DomainError::not_found` (anti-enum), which maps to 404.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MKCOL {{base_url}}/webdav/@drive/{{shared_drive_id}}/viewer-blocked-folder
|
||||
Authorization: Bearer {{bob_token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — Bob (VIEWER) CANNOT PUT a file.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/@drive/{{shared_drive_id}}/viewer-blocked-file.txt
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Content-Type: text/plain
|
||||
```
|
||||
viewer should not upload
|
||||
```
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — Admin creates a probe folder in the shared drive so
|
||||
# the Editor-can-rename step below has a real target.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MKCOL {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder
|
||||
Authorization: Bearer {{admin_token}}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — Bob (VIEWER) CANNOT MOVE (rename) the probe folder.
|
||||
# MOVE requires Update on the source, which Viewer
|
||||
# doesn't have. Same anti-enum 404 shape.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MOVE {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder-renamed
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — Promote Bob from VIEWER to EDITOR.
|
||||
# `PATCH /api/drives/{id}/members/{subject-type}/{id}`
|
||||
# mutates the role in-place.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/api/drives/{{shared_drive_id}}/members/user/{{bob_user_id}}
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{ "role": "editor" }
|
||||
|
||||
HTTP 200
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 — Bob (EDITOR) CAN MKCOL a new folder.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MKCOL {{base_url}}/webdav/@drive/{{shared_drive_id}}/editor-created-folder
|
||||
Authorization: Bearer {{bob_token}}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 12 — Bob (EDITOR) CAN PUT a file.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/@drive/{{shared_drive_id}}/editor-created-folder/hello.txt
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Content-Type: text/plain
|
||||
```
|
||||
editor uploaded content
|
||||
```
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 13 — Bob (EDITOR) CAN MOVE (rename) the probe folder.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MOVE {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/probe-folder-renamed
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 14 — Bob puts a file in his OWN personal drive as the
|
||||
# source for the cross-drive MOVE test below.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/xdrive-probe.txt
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Content-Type: text/plain
|
||||
```
|
||||
cross-drive probe payload
|
||||
```
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 15 — Admin flips `forbid_cross_drive_move` ON for Bob's
|
||||
# PERSONAL drive. The policy sits on the SOURCE drive
|
||||
# per `DrivePolicies::refuse_cross_drive_move`; only
|
||||
# OxiCloud-admin can PATCH policies.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/api/drives/{{bob_personal_drive_id}}/policies
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{ "forbid_cross_drive_move": true }
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.forbid_cross_drive_move" == true
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 16 — Bob tries to MOVE `xdrive-probe.txt` from his
|
||||
# PERSONAL drive to the SHARED drive. Blocked at the
|
||||
# service layer by the policy — `OperationNotSupported`
|
||||
# maps to 405 Method Not Allowed.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MOVE {{base_url}}/webdav/xdrive-probe.txt
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/xdrive-probe.txt
|
||||
|
||||
HTTP 405
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 17 — Admin flips the policy OFF.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PATCH {{base_url}}/api/drives/{{bob_personal_drive_id}}/policies
|
||||
Authorization: Bearer {{admin_token}}
|
||||
Content-Type: application/json
|
||||
{ "forbid_cross_drive_move": false }
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.forbid_cross_drive_move" == false
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 18 — Bob retries the same MOVE. Now the policy is off,
|
||||
# Bob has Update on source (his own personal drive) +
|
||||
# Create on dest parent (Editor on shared drive), so
|
||||
# the move succeeds. 201 on rename/move to a new URL,
|
||||
# per `handle_move`'s existing convention.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MOVE {{base_url}}/webdav/xdrive-probe.txt
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Destination: {{base_url}}/webdav/@drive/{{shared_drive_id}}/xdrive-probe.txt
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 19 — Verify the destination now exists and the source
|
||||
# is gone. Both PROPFINDs use Bob's token to also
|
||||
# re-confirm the AuthZ gates on the destination side.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/@drive/{{shared_drive_id}}/xdrive-probe.txt
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Depth: 0
|
||||
|
||||
HTTP 207
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/webdav/xdrive-probe.txt
|
||||
Authorization: Bearer {{bob_token}}
|
||||
Depth: 0
|
||||
|
||||
HTTP 404
|
||||
@@ -0,0 +1,85 @@
|
||||
# Shared test-server environment variables.
|
||||
# Sourced by tests/api/run.sh (shell) and read by tests/e2e/playwright.config.ts (Node).
|
||||
# Do NOT include OXICLOUD_SERVER_PORT or OXICLOUD_STORAGE_PATH here —
|
||||
# each test suite sets those to avoid port/directory conflicts.
|
||||
|
||||
DATABASE_URL=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test
|
||||
OXICLOUD_DB_CONNECTION_STRING=postgres://oxicloud_test:oxicloud_test@localhost:5433/oxicloud_test
|
||||
OXICLOUD_STATIC_PATH=./static
|
||||
OXICLOUD_JWT_SECRET=test-secret-do-not-use-in-prod-minimum-32-chars
|
||||
OXICLOUD_ENABLE_AUTH=true
|
||||
OXICLOUD_ENABLE_TRASH=true
|
||||
OXICLOUD_ENABLE_SEARCH=true
|
||||
OXICLOUD_ENABLE_FILE_SHARING=true
|
||||
OXICLOUD_ENABLE_MUSIC=true
|
||||
OXICLOUD_EXPOSE_SYSTEM_USERS=true
|
||||
OXICLOUD_WOPI_ENABLED=true
|
||||
# Fixed secret so the Hurl WOPI test can hand-craft valid access
|
||||
# tokens with a known signing key. Prod deployments MUST override
|
||||
# this to a random per-deployment value.
|
||||
OXICLOUD_WOPI_SECRET=test-wopi-secret-do-not-use-in-prod-do-not-use-in-prod
|
||||
# Discovery URL points at a black hole — VERB endpoints don't need
|
||||
# discovery, and the WOPI Hurl suite deliberately does NOT touch
|
||||
# `/api/wopi/editor-url` (the only path that would fetch it), so
|
||||
# an unreachable URL keeps startup fast and hermetic.
|
||||
OXICLOUD_WOPI_DISCOVERY_URL=http://127.0.0.1:9100/discovery.xml
|
||||
OXICLOUD_WOPI_TOKEN_TTL_SECS=3600
|
||||
OXICLOUD_OIDC_ENABLED=false
|
||||
|
||||
OXICLOUD_NEXTCLOUD_ENABLED=true
|
||||
|
||||
# Test-only sweep triggers (`/api/admin/internal/trigger-sweep`,
|
||||
# `/api/admin/internal/trigger-gc`). Off by default in production;
|
||||
# the Hurl suite needs them to assert post-delete quota convergence
|
||||
# without waiting out the 600 s reconciliation tick.
|
||||
OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true
|
||||
|
||||
RUST_LOG="warn,audit=info,sqlx::migrate=info"
|
||||
#RUST_LOG="warn,audit=info,oxicloud::quota=debug"
|
||||
#RUST_LOG=debug
|
||||
#RUST_LOG=info
|
||||
|
||||
# Per-chunk upload cap, exercised by chunked_upload_cap.hurl.
|
||||
# 4 MiB: lets the existing grants.hurl single-chunk test (2.76 MB) pass
|
||||
# under the cap, while the cap test sends a 5 MiB fixture to trigger 413.
|
||||
OXICLOUD_CHUNK_MAX_BYTES=4194304
|
||||
|
||||
# Direct-PUT (non-chunked) cap, exercised by chunked_upload_cap.hurl.
|
||||
# 4 MiB: same threshold as the chunked cap so the existing 5 MiB
|
||||
# fixture (chunk-over-cap-5mb.bin) can prove BOTH caps with one
|
||||
# generated file. All existing direct-PUT tests
|
||||
# (test_dedup_webdav_multichunk.sh = 2.76 MB, _ref_count = ~66 KB,
|
||||
# _nextcloud_put_blake3 = 32 B) stay safely under this cap.
|
||||
OXICLOUD_DIRECT_PUT_MAX_BYTES=4194304
|
||||
|
||||
# grow up limits for tests
|
||||
OXICLOUD_RATE_LIMIT_REFRESH_MAX=3600
|
||||
OXICLOUD_RATE_LIMIT_LOGIN_MAX=3600
|
||||
OXICLOUD_RATE_LIMIT_REGISTER_MAX=3600
|
||||
|
||||
# Magic-link / external-users flow (PR 9). The mock SMTP captures every
|
||||
# outbound message in-process so external_users.hurl can retrieve the
|
||||
# invitation body and follow the magic-link URL. The `SMTP_FROM` value
|
||||
# is required so the mock can build a valid Message; host/port are
|
||||
# irrelevant in mock mode but kept set for completeness.
|
||||
OXICLOUD_SMTP_MOCK=true
|
||||
OXICLOUD_SMTP_HOST=localhost
|
||||
OXICLOUD_SMTP_PORT=25
|
||||
OXICLOUD_SMTP_FROM='OxiCloud Tests <test@oxicloud.local>'
|
||||
OXICLOUD_SMTP_TLS=none
|
||||
OXICLOUD_ALLOW_EXTERNAL_USERS=true
|
||||
|
||||
# PR 12 — magic-link rate-limit caps lowered so external_users.hurl can
|
||||
# exercise the cap behaviour with a small, deterministic request count.
|
||||
# Production defaults are 50 / 5 / 200 respectively (see example.env).
|
||||
OXICLOUD_MAGIC_LINK_INVITE_PER_CALLER_PER_HOUR=3
|
||||
OXICLOUD_MAGIC_LINK_SEND_PER_EMAIL_PER_HOUR=2
|
||||
OXICLOUD_MAGIC_LINK_SEND_PER_IP_PER_HOUR=50
|
||||
|
||||
# permits IP spoofing for tests
|
||||
OXICLOUD_TRUST_PROXY_CIDR=0.0.0.0/0
|
||||
|
||||
OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true
|
||||
|
||||
# /webdav/ will points directly to list of drives
|
||||
OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""
|
||||
@@ -34,9 +34,10 @@ wipe_storage() {
|
||||
fi
|
||||
|
||||
# Sanity check: must end in tests/<name>/storage where <name> is
|
||||
# lowercase alphanumeric. Stops `rm -rf` from ever running against
|
||||
# an unexpected expansion of a callerʼs path.
|
||||
if [[ ! "$path" =~ /tests/[a-z0-9]+/storage$ ]]; then
|
||||
# lowercase alphanumeric (hyphens allowed so multi-word runner names
|
||||
# like `webdav-drive-root` pass). Stops `rm -rf` from ever running
|
||||
# against an unexpected expansion of a caller's path.
|
||||
if [[ ! "$path" =~ /tests/[a-z0-9][a-z0-9-]*/storage$ ]]; then
|
||||
echo "[wipe_storage] ERROR: '$path' does not match .../tests/<name>/storage — refusing to wipe" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# =============================================================
|
||||
# OxiCloud — WebDAV drive-root URL scheme, `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""` variant
|
||||
# =============================================================
|
||||
# Companion to `webdav_drive_root.hurl`. That file exercises the
|
||||
# default config (`OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX="@drive"`); this
|
||||
# one exercises the empty-string config where `/webdav/` IS the
|
||||
# drive listing and there's no default-drive shortcut.
|
||||
#
|
||||
# Server env for this test: `tests/common/server-webdav-drive-root.env`
|
||||
# sets `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""`. This file assumes that
|
||||
# config is active — it is NOT part of the standard `run.sh`
|
||||
# invocation (which starts the default-config server).
|
||||
#
|
||||
# Coverage:
|
||||
# 1. Login, capture JWT
|
||||
# 2. Resolve caller's default drive (id + display name)
|
||||
# 3. Create a magic folder under the home root via REST
|
||||
# 4. PROPFIND `/webdav/` — drive listing (default drive
|
||||
# appears as a virtual child under its display name).
|
||||
# 5. PROPFIND `/webdav/<uuid>/` — descend into a drive by
|
||||
# UUID. Magic folder appears.
|
||||
# 6. PROPFIND `/webdav/<name>/` — descend into a drive by
|
||||
# display name. Magic folder appears.
|
||||
# 7. `/webdav/@drive/` returns 404 in this mode — the sigil
|
||||
# has no reserved meaning when `webdav_drive_listing_prefix=""`.
|
||||
# A drive genuinely named `@drive` would resolve here; the
|
||||
# 404 comes from "no such drive," not the sigil.
|
||||
# 8. Cleanup: DELETE the magic folder via REST.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — Login, capture JWT
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — Resolve caller's default drive (id + display name).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/drives
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
default_drive_id: jsonpath "$[0].id"
|
||||
default_drive_name: jsonpath "$[0].name"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — Resolve the caller's home root folder id.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/folders
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
home_folder_id: jsonpath "$[0].id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — Create a magic folder under the home root via REST.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"name": "hurl-drive-root-empty-magic-marker",
|
||||
"parent_id": "{{home_folder_id}}"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
magic_folder_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — PROPFIND on `/webdav/` (bare root). With
|
||||
# `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""` this IS the drive
|
||||
# listing — the default drive appears as a virtual
|
||||
# child under its display name. The magic folder does
|
||||
# NOT appear here (it lives one level deeper).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 1
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), '{{default_drive_name}}')]" exists
|
||||
# Magic folder is one level deeper — must NOT show up at root.
|
||||
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-empty-magic-marker')]" not exists
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — PROPFIND on `/webdav/<uuid>/`. Descends into the
|
||||
# default drive; magic folder is a top-level child.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/{{default_drive_id}}/
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 1
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-empty-magic-marker')]" exists
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — PROPFIND on `/webdav/<name>/`. Same descent via
|
||||
# display name.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/{{default_drive_name}}/
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 1
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "//*[local-name()='response']/*[local-name()='href' and contains(text(), 'hurl-drive-root-empty-magic-marker')]" exists
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — `/webdav/@drive/` has no reserved meaning in the
|
||||
# empty-config mode. `@drive` is treated as a plain
|
||||
# drive selector; no drive by that name → 404.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/@drive/
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 1
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — Reject MKCOL at `/webdav/` (bare pseudo-root).
|
||||
# In the empty-config mode `/webdav/` IS the drive
|
||||
# listing — there's no writable parent, so 405
|
||||
# Method Not Allowed. This guard prevents a client
|
||||
# from creating something at "root" that shadows a
|
||||
# drive name.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MKCOL {{base_url}}/webdav/
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 405
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — Reject MKCOL at `/webdav/<not-a-drive>`. The first
|
||||
# URL segment is the drive selector in this config;
|
||||
# an unknown selector yields 404. A client cannot
|
||||
# "create a drive" via MKCOL — the drive-create
|
||||
# surface is `POST /api/drives`.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MKCOL {{base_url}}/webdav/hurl-not-a-real-drive
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 — Reject PUT at `/webdav/<not-a-drive>/x.txt`. Same
|
||||
# rejection shape as MKCOL.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/hurl-not-a-real-drive/probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: text/plain
|
||||
```
|
||||
probe
|
||||
```
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 12 — Cleanup: DELETE the magic folder via REST.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/folders/{{magic_folder_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bash
|
||||
# WebDAV drive-root URL-scheme variant runner.
|
||||
#
|
||||
# Exercises `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX=""` — the config where the
|
||||
# WebDAV `@drive` path segment is disabled and `/webdav/` IS the
|
||||
# drive listing. `tests/api/webdav_drive_root.hurl` covers the
|
||||
# default `"@drive"` config in the main API run; this runner
|
||||
# starts a separately-configured server to cover the empty-string
|
||||
# case, mirroring the OIDC runner's shape.
|
||||
#
|
||||
# Usage (from repo root):
|
||||
# bash tests/webdav-drive-root/run.sh
|
||||
#
|
||||
# Prerequisites: docker, cargo, hurl ≥ 4.0
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
COMMON="$REPO_ROOT/tests/common"
|
||||
TEST_DIR="$REPO_ROOT/tests/webdav-drive-root"
|
||||
|
||||
# shellcheck source=test.env
|
||||
source "$TEST_DIR/test.env"
|
||||
|
||||
SERVER_PORT="${base_url##*:}"
|
||||
|
||||
log() { echo "[webdav-drive-root] $*"; }
|
||||
die() { echo "[webdav-drive-root] ERROR: $*" >&2; exit 1; }
|
||||
|
||||
wait_for_http() {
|
||||
local url="$1" timeout="${2:-60}"
|
||||
local deadline=$(( $(date +%s) + timeout ))
|
||||
until curl -sf "$url" >/dev/null 2>&1; do
|
||||
[[ $(date +%s) -ge $deadline ]] && die "Timeout waiting for $url"
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
# ── Teardown (always runs on exit) ────────────────────────────────────────────
|
||||
|
||||
SERVER_PID=""
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "$SERVER_PID" ]]; then
|
||||
log "Stopping OxiCloud server (pid $SERVER_PID)..."
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
bash "$COMMON/stop-db.sh"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
# ── 1. Start postgres ─────────────────────────────────────────────────────────
|
||||
|
||||
bash "$COMMON/spawn-db.sh"
|
||||
|
||||
# ── 2. Load the drive-root-variant server env + port ──────────────────────────
|
||||
|
||||
set -a
|
||||
# shellcheck source=../common/server-webdav-drive-root.env
|
||||
source "$COMMON/server-webdav-drive-root.env"
|
||||
OXICLOUD_SERVER_PORT=$SERVER_PORT
|
||||
OXICLOUD_STORAGE_PATH="$REPO_ROOT/tests/webdav-drive-root/storage"
|
||||
set +a
|
||||
|
||||
# shellcheck source=../common/wipe-storage.sh
|
||||
source "$COMMON/wipe-storage.sh"
|
||||
wipe_storage "$OXICLOUD_STORAGE_PATH"
|
||||
|
||||
# ── 3. Start OxiCloud server with the drive-root-variant config ───────────────
|
||||
|
||||
BUILD_TARGET="${BUILD_TARGET:-debug}"
|
||||
OXICLOUD_BIN="$REPO_ROOT/target/$BUILD_TARGET/oxicloud"
|
||||
|
||||
if [[ ! -x "$OXICLOUD_BIN" ]]; then
|
||||
log "Building OxiCloud server ($BUILD_TARGET)..."
|
||||
case "$BUILD_TARGET" in
|
||||
debug) (cd "$REPO_ROOT" && cargo build 2>&1 | tail -n 20) || die "cargo build failed" ;;
|
||||
release) (cd "$REPO_ROOT" && cargo build --release 2>&1 | tail -n 20) || die "cargo build --release failed" ;;
|
||||
*) die "Unsupported BUILD_TARGET='$BUILD_TARGET' (expected 'debug' or 'release')" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
log "Starting OxiCloud server with WEBDAV_DRIVE_LISTING_PREFIX='' on port $SERVER_PORT..."
|
||||
"$OXICLOUD_BIN" --config "$COMMON/server-webdav-drive-root.env" &
|
||||
SERVER_PID=$!
|
||||
log "Waiting for server at $base_url..."
|
||||
wait_for_http "$base_url/ready" 120
|
||||
log "Server is ready."
|
||||
|
||||
# ── 4. Run Hurl tests ─────────────────────────────────────────────────────────
|
||||
#
|
||||
# `setup.hurl` from the shared api/ suite bootstraps the initial admin
|
||||
# account via `POST /api/setup` — the endpoint locks after the first
|
||||
# admin exists, so it's a one-shot idempotency-by-server-state seed.
|
||||
# We reuse the file rather than duplicating the setup body so credential
|
||||
# / schema changes in the api tests automatically flow here.
|
||||
|
||||
log "Running Hurl tests..."
|
||||
hurl --variables-file "$TEST_DIR/test.env" \
|
||||
--file-root "$REPO_ROOT/tests" \
|
||||
--test --jobs 1 \
|
||||
"$REPO_ROOT/tests/api/setup.hurl" \
|
||||
"$TEST_DIR/drive_root_empty_config.hurl"
|
||||
|
||||
log "webdav-drive-root tests passed."
|
||||
@@ -0,0 +1,9 @@
|
||||
# Test credentials for the WebDAV drive-root variant runner — NOT real secrets.
|
||||
# Runs on a separate port from tests/api and tests/webdav so a
|
||||
# `just api-test` chain doesn't collide when the previous runner's
|
||||
# teardown is still in progress.
|
||||
base_url=http://localhost:8089
|
||||
username=admin
|
||||
email=admin@example.com
|
||||
# gitguardian:ignore
|
||||
password=TestPassword1!
|
||||
Reference in New Issue
Block a user