Merge pull request #572 from EdouardVanbelle/feat/nextcloud-chrooted-drive
feat/nextcloud chrooted drive
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"postbuild": "node scripts/emit-askama-common.mjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json && eslint . && stylelint \"src/**/*.{css,svelte}\" && prettier --check .",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json && eslint . && stylelint \"src/**/*.{css,svelte}\" && prettier --check .",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/*
|
||||||
|
* Emit `static-dist/askama-common.css` from the SvelteKit design-token
|
||||||
|
* source of truth (`src/lib/styles/base/variables.css`) plus the auth-page
|
||||||
|
* component styles (`src/lib/styles/askama-common.css`).
|
||||||
|
*
|
||||||
|
* WHY A POST-BUILD SCRIPT:
|
||||||
|
* Vite's `writeBundle` hooks fire mid-build, before
|
||||||
|
* `@sveltejs/adapter-static` copies the finalised site to
|
||||||
|
* `../static-dist/`. Anything written to that directory during
|
||||||
|
* Vite gets wiped when adapter-static runs. A `postbuild` script
|
||||||
|
* runs after everything the SvelteKit build owns, so its output
|
||||||
|
* survives — one predictable moment, no ordering trap.
|
||||||
|
*
|
||||||
|
* WHAT IT PRODUCES:
|
||||||
|
* A single stable-named CSS file at `static-dist/askama-common.css`
|
||||||
|
* containing:
|
||||||
|
* 1. Every design token declared in `base/variables.css` (:root,
|
||||||
|
* `light-dark(...)`, dark-mode blocks, etc.)
|
||||||
|
* 2. The auth-page component rules from `askama-common.css`
|
||||||
|
* Concatenated, prefixed with a "do not edit" header, written UTF-8.
|
||||||
|
*
|
||||||
|
* SINGLE SOURCE OF TRUTH:
|
||||||
|
* If a token changes in `variables.css`, one rebuild propagates it to
|
||||||
|
* both the SPA (via Svelte's normal build pipeline) AND the askama
|
||||||
|
* templates (via this file). Two consumers, one source. No manual
|
||||||
|
* sync step.
|
||||||
|
*
|
||||||
|
* SERVER SIDE:
|
||||||
|
* Server-rendered askama templates reference:
|
||||||
|
* <link rel="stylesheet" href="/askama-common.css">
|
||||||
|
* The Rust web layer serves `static-dist/askama-common.css` at that
|
||||||
|
* URL through the same ServeDir the SPA uses. No route wiring needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const stylesDir = resolve(__dirname, '../src/lib/styles');
|
||||||
|
const outputFile = resolve(__dirname, '../../static-dist/askama-common.css');
|
||||||
|
|
||||||
|
const header =
|
||||||
|
'/* Auto-generated by frontend/scripts/emit-askama-common.mjs.\n' +
|
||||||
|
' * Do NOT edit by hand — regenerated on every `npm run build`.\n' +
|
||||||
|
' * Sources: src/lib/styles/base/variables.css (design tokens)\n' +
|
||||||
|
' * src/lib/styles/askama-common.css (auth components)\n' +
|
||||||
|
' */\n\n';
|
||||||
|
|
||||||
|
const tokens = readFileSync(resolve(stylesDir, 'base/variables.css'), 'utf8');
|
||||||
|
const components = readFileSync(resolve(stylesDir, 'askama-common.css'), 'utf8');
|
||||||
|
|
||||||
|
mkdirSync(dirname(outputFile), { recursive: true });
|
||||||
|
writeFileSync(outputFile, header + tokens + '\n' + components, 'utf8');
|
||||||
|
|
||||||
|
const bytes = Buffer.byteLength(header + tokens + '\n' + components, 'utf8');
|
||||||
|
console.log(`emit-askama-common: wrote ${bytes} bytes → ${outputFile}`);
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/*
|
||||||
|
* askama-common.css — component styles for server-rendered askama pages.
|
||||||
|
*
|
||||||
|
* BUILD PIPELINE:
|
||||||
|
* `vite.config.ts` prepends `base/variables.css` at build time (the
|
||||||
|
* `emitAskamaCommon` plugin) and writes the result to
|
||||||
|
* `static-dist/askama-common.css`. That output is the single non-hashed
|
||||||
|
* URL every askama template references:
|
||||||
|
*
|
||||||
|
* <link rel="stylesheet" href="/askama-common.css">
|
||||||
|
*
|
||||||
|
* SINGLE SOURCE OF TRUTH:
|
||||||
|
* Design tokens (`--color-*`, `--space-*`, `--radius-*`, `--text-*`,
|
||||||
|
* etc.) live in `base/variables.css`. This file only carries the
|
||||||
|
* component-level rules for the class vocabulary the askama templates
|
||||||
|
* actually use. Update tokens in ONE place; the build packages both.
|
||||||
|
*
|
||||||
|
* NO JAVASCRIPT:
|
||||||
|
* Dark-mode detection uses `light-dark()` + the `color-scheme` on
|
||||||
|
* :root (declared in `variables.css`). Askama pages are pre-auth flows
|
||||||
|
* (login / magic-link error) — no per-user override needed. Browsers
|
||||||
|
* older than Chrome 123 / Safari 17.5 / Firefox 120 fall back to the
|
||||||
|
* light values; the pages are readable either way.
|
||||||
|
*
|
||||||
|
* CLASS VOCABULARY (mirrors `grep 'class=' templates/**\/*.html`):
|
||||||
|
* .auth-container .auth-panel
|
||||||
|
* .auth-logo .auth-logo-icon .auth-logo-text
|
||||||
|
* .auth-title .auth-subtitle
|
||||||
|
* .auth-form .auth-button
|
||||||
|
* .auth-drive-option .auth-drive-name .auth-drive-badge
|
||||||
|
* .magic-note
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Reset ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
background: var(--color-bg-page);
|
||||||
|
color: var(--color-text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Layout shell ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.auth-container {
|
||||||
|
min-height: 100dvh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-panel {
|
||||||
|
width: min(400px, 100%);
|
||||||
|
background: var(--color-bg-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-2xl);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
padding: var(--space-8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Logo strip ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.auth-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-logo-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-accent);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-logo-icon svg {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-logo-text {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
color: var(--color-text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Title strip ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.auth-title {
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
font-size: var(--text-xl);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
color: var(--color-text-heading);
|
||||||
|
line-height: var(--leading-snug);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-subtitle {
|
||||||
|
margin: 0 0 var(--space-5);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-subtitle a {
|
||||||
|
color: var(--color-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-subtitle a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form + button ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-on-accent);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-button:hover {
|
||||||
|
background: var(--color-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-button:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Drive picker radios ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.auth-drive-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.12s ease,
|
||||||
|
background 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-drive-option:hover {
|
||||||
|
border-color: var(--color-border-medium);
|
||||||
|
background: var(--color-bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-drive-option:has(input:checked) {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background: var(--color-accent-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-drive-option input[type='radio'] {
|
||||||
|
accent-color: var(--color-accent);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-drive-name {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-drive-badge {
|
||||||
|
padding: 2px var(--space-2);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-on-accent);
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Magic-link note block ────────────────────────────────────── */
|
||||||
|
|
||||||
|
.magic-note {
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-bg-input);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
<svelte:head><title>{view.title} · OxiCloud</title></svelte:head>
|
<svelte:head><title>{view.title} · OxiCloud</title></svelte:head>
|
||||||
|
|
||||||
<main class="nc-status">
|
<main class="nc-status">
|
||||||
<Icon name="ban" class="nc-status__icon nc-status__icon--err" />
|
<Icon name="exclamation-circle" class="nc-status__icon nc-status__icon--err" />
|
||||||
<h1>{view.title}</h1>
|
<h1>{view.title}</h1>
|
||||||
<p>{view.message}</p>
|
<p>{view.message}</p>
|
||||||
<button
|
<button
|
||||||
@@ -80,7 +80,13 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.nc-status {
|
.nc-status {
|
||||||
min-height: 100vh;
|
/* `base/reset.css` sets `body { display: flex }`. Public
|
||||||
|
`/nextcloud/*` routes render children directly (bypassing
|
||||||
|
AppShell), so <main> is a flex item on the body's row axis
|
||||||
|
and needs to claim the full slot for its own centering to
|
||||||
|
land in the viewport middle. */
|
||||||
|
flex: 1;
|
||||||
|
min-height: 100dvh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -95,7 +101,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:global(.nc-status__icon--err) {
|
:global(.nc-status__icon--err) {
|
||||||
color: var(--color-danger-text);
|
/* `--color-danger-text` is white — it's the foreground for text
|
||||||
|
sitting on a red button bg, not the standalone red glyph
|
||||||
|
colour. `--color-error-text` is the light-dark(...) red pair
|
||||||
|
designed for standalone use on the page background: darker
|
||||||
|
red in light mode, softer coral in dark mode. */
|
||||||
|
color: var(--color-error-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nc-status__action {
|
.nc-status__action {
|
||||||
|
|||||||
@@ -8,8 +8,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Mirror the legacy flow: auto-close the popup shortly after success so
|
// Auto-close the tab a few seconds after landing. NC clients
|
||||||
// the user is returned to their Nextcloud client without an extra click.
|
// receive their credentials through the LFv2 poll endpoint
|
||||||
|
// (`/login/v2/poll`) in the backchannel — this browser tab is
|
||||||
|
// only useful as a "flow succeeded" landing. Users who want
|
||||||
|
// to keep it around click nothing; users who want it gone
|
||||||
|
// get it gone automatically.
|
||||||
const timer = setTimeout(closeWindow, 3000);
|
const timer = setTimeout(closeWindow, 3000);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
});
|
});
|
||||||
@@ -34,7 +38,13 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.nc-status {
|
.nc-status {
|
||||||
min-height: 100vh;
|
/* `base/reset.css` sets `body { display: flex }`. Public
|
||||||
|
`/nextcloud/*` routes render children directly (bypassing
|
||||||
|
AppShell), so <main> is a flex item on the body's row axis
|
||||||
|
and needs to claim the full slot for its own centering to
|
||||||
|
land in the viewport middle. */
|
||||||
|
flex: 1;
|
||||||
|
min-height: 100dvh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { defineConfig } from 'vitest/config';
|
|||||||
import istanbul from 'vite-plugin-istanbul';
|
import istanbul from 'vite-plugin-istanbul';
|
||||||
import { svelteTesting } from '@testing-library/svelte/vite';
|
import { svelteTesting } from '@testing-library/svelte/vite';
|
||||||
|
|
||||||
|
// `static-dist/askama-common.css` is emitted by `scripts/emit-askama-common.mjs`,
|
||||||
|
// wired into `package.json` as a `postbuild` step. That runs AFTER
|
||||||
|
// `@sveltejs/adapter-static` finalises `static-dist/`, avoiding the
|
||||||
|
// wipe-and-copy race that would eat any file a `writeBundle` hook wrote
|
||||||
|
// during the Vite build phase. See the script header for the pipeline
|
||||||
|
// rationale and the single-source-of-truth invariant it preserves.
|
||||||
|
|
||||||
// Backend dev server (cargo run) — the Vite dev server proxies API/protocol
|
// Backend dev server (cargo run) — the Vite dev server proxies API/protocol
|
||||||
// traffic here so cookies, CSRF, and the auth-refresh flow are same-origin.
|
// traffic here so cookies, CSRF, and the auth-refresh flow are same-origin.
|
||||||
const BACKEND = process.env.OXICLOUD_BACKEND ?? 'http://localhost:8086';
|
const BACKEND = process.env.OXICLOUD_BACKEND ?? 'http://localhost:8086';
|
||||||
|
|||||||
@@ -332,11 +332,30 @@ async fn complete_flow(
|
|||||||
base_url = %base_url,
|
base_url = %base_url,
|
||||||
"Login Flow v2: flow completed successfully"
|
"Login Flow v2: flow completed successfully"
|
||||||
);
|
);
|
||||||
let nc_url = format!(
|
// Redirect the browser to a visible success page. NC clients
|
||||||
"nc://login/server:{}&user:{}&password:{}",
|
// that use the LFv2 poll endpoint (the standard pattern) have
|
||||||
base_url, login_name, app_password
|
// already received the credentials server-to-server through
|
||||||
);
|
// `login_flow.complete()` above — they don't need any browser
|
||||||
axum::response::Redirect::to(&nc_url).into_response()
|
// hand-off.
|
||||||
|
//
|
||||||
|
// We deliberately do NOT redirect to `nc://login/…` here:
|
||||||
|
// 1. Plain browsers can't follow it → the tab looks stuck
|
||||||
|
// on the picker → user clicks Continue again → second
|
||||||
|
// click hits an already-consumed flow token → ends up
|
||||||
|
// on `/nextcloud/error?type=session-expired`.
|
||||||
|
// 2. NC desktop clients that pick it up while their poll
|
||||||
|
// has already succeeded try to complete the flow a
|
||||||
|
// second time, which fails validation ("Impossible de
|
||||||
|
// valider la requête") — the poll session is fine, the
|
||||||
|
// dialog is spurious noise.
|
||||||
|
//
|
||||||
|
// If a client ever needs a frontchannel `nc://` handoff
|
||||||
|
// (older NC releases, mobile), reintroduce the URL as a
|
||||||
|
// client-side-only fragment (`#target=…`) and add a manual
|
||||||
|
// "Open Nextcloud" fallback on the success page. Keep the
|
||||||
|
// credentials out of the query string either way — the query
|
||||||
|
// string reaches server access logs.
|
||||||
|
axum::response::Redirect::to("/nextcloud/success").into_response()
|
||||||
} else {
|
} else {
|
||||||
tracing::error!(
|
tracing::error!(
|
||||||
user = %user.username,
|
user = %user.username,
|
||||||
|
|||||||
@@ -102,8 +102,16 @@ async fn handle_propfind(
|
|||||||
let nc = state.nextcloud.as_ref();
|
let nc = state.nextcloud.as_ref();
|
||||||
let file_id_svc = nc.map(|n| &n.file_ids);
|
let file_id_svc = nc.map(|n| &n.file_ids);
|
||||||
|
|
||||||
|
// Emit hrefs with `session.raw_username` (composite `admin~<uuid>` on
|
||||||
|
// non-home drives), NOT `user.username` (bare `admin`). The
|
||||||
|
// `NcSession` extractor cross-checks the URL `{user}` segment
|
||||||
|
// against `raw_username` and 403s on mismatch (see
|
||||||
|
// `session.rs::from_request_parts`). Emitting the bare form here
|
||||||
|
// would make every follow-up MOVE/DELETE from a non-home client
|
||||||
|
// 403 before the handler runs — the composite-credential Hurl
|
||||||
|
// regression caught this (B5 in `nc_multidrive_move_regression`).
|
||||||
let mut buf = Vec::new();
|
let mut buf = Vec::new();
|
||||||
write_trashbin_multistatus(&mut buf, &items, &user.username, chroot, file_id_svc)
|
write_trashbin_multistatus(&mut buf, &items, &session.raw_username, chroot, file_id_svc)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
|
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
|
||||||
|
|
||||||
@@ -139,8 +147,17 @@ async fn handle_restore(
|
|||||||
// with 412 — there is no `Overwrite: T` workflow for trash restore in
|
// with 412 — there is no `Overwrite: T` workflow for trash restore in
|
||||||
// either Sabre/DAV or the NC desktop client (a live file being
|
// either Sabre/DAV or the NC desktop client (a live file being
|
||||||
// silently replaced by an undeleted one would be a footgun).
|
// silently replaced by an undeleted one would be a footgun).
|
||||||
|
// Use `session.raw_username` (composite `admin~<drive-uuid>` on
|
||||||
|
// non-home drives) to strip the destination prefix, NOT
|
||||||
|
// `user.username` (bare `admin`). NC clients send `Destination:
|
||||||
|
// /remote.php/dav/files/{raw_username}/…`; passing the bare
|
||||||
|
// username would leave the `~<uuid>/` marker glued to the leading
|
||||||
|
// subpath segment and turn the collision-check into a lookup at
|
||||||
|
// a fabricated path. See `uploads_handler::handle_assemble` for
|
||||||
|
// the same fix in the chunked-upload MOVE.
|
||||||
if let Some(dest_header) = dest_header
|
if let Some(dest_header) = dest_header
|
||||||
&& let Some(dest_subpath) = extract_nc_subpath_from_dest(&dest_header, &user.username)
|
&& let Some(dest_subpath) =
|
||||||
|
extract_nc_subpath_from_dest(&dest_header, &session.raw_username)
|
||||||
{
|
{
|
||||||
let dest_internal = nc_to_internal_path(chroot, &dest_subpath)?;
|
let dest_internal = nc_to_internal_path(chroot, &dest_subpath)?;
|
||||||
let folder_service = &state.applications.folder_service;
|
let folder_service = &state.applications.folder_service;
|
||||||
|
|||||||
@@ -150,7 +150,20 @@ async fn handle_propfind_session(
|
|||||||
.map_err(|e| AppError::internal_error(format!("Failed to list chunks: {}", e)))?
|
.map_err(|e| AppError::internal_error(format!("Failed to list chunks: {}", e)))?
|
||||||
.ok_or_else(|| AppError::not_found("Upload session not found"))?;
|
.ok_or_else(|| AppError::not_found("Upload session not found"))?;
|
||||||
|
|
||||||
let session_href = format!("/remote.php/dav/uploads/{}/{}/", user.username, upload_id);
|
// Href MUST use `session.raw_username` (composite `admin~<uuid>` on
|
||||||
|
// non-home drives), NOT `user.username` (bare `admin`). The
|
||||||
|
// `NcSession` extractor cross-checks the URL `{user}` segment
|
||||||
|
// against `raw_username` and 403s on mismatch — a composite-cred
|
||||||
|
// client that PROPFINDs, then MOVEs a chunk href back to us, would
|
||||||
|
// otherwise 403 at the extractor before any handler runs. Same
|
||||||
|
// fix shape as `trashbin_handler::handle_propfind` and
|
||||||
|
// `handle_assemble`'s destination-URL parsing. Storage-side keying
|
||||||
|
// stays on `user.username` — upload sessions are per-user, not
|
||||||
|
// per-drive.
|
||||||
|
let session_href = format!(
|
||||||
|
"/remote.php/dav/uploads/{}/{}/",
|
||||||
|
session.raw_username, upload_id
|
||||||
|
);
|
||||||
let session_last_modified =
|
let session_last_modified =
|
||||||
chrono::DateTime::<chrono::Utc>::from_timestamp(listing.session_mtime as i64, 0)
|
chrono::DateTime::<chrono::Utc>::from_timestamp(listing.session_mtime as i64, 0)
|
||||||
.unwrap_or_else(chrono::Utc::now)
|
.unwrap_or_else(chrono::Utc::now)
|
||||||
@@ -176,7 +189,7 @@ async fn handle_propfind_session(
|
|||||||
for chunk in &listing.chunks {
|
for chunk in &listing.chunks {
|
||||||
let chunk_href = format!(
|
let chunk_href = format!(
|
||||||
"/remote.php/dav/uploads/{}/{}/{}",
|
"/remote.php/dav/uploads/{}/{}/{}",
|
||||||
user.username, upload_id, chunk.name
|
session.raw_username, upload_id, chunk.name
|
||||||
);
|
);
|
||||||
let chunk_modified = chrono::DateTime::<chrono::Utc>::from_timestamp(chunk.mtime as i64, 0)
|
let chunk_modified = chrono::DateTime::<chrono::Utc>::from_timestamp(chunk.mtime as i64, 0)
|
||||||
.unwrap_or_else(chrono::Utc::now)
|
.unwrap_or_else(chrono::Utc::now)
|
||||||
@@ -342,7 +355,18 @@ async fn handle_assemble(
|
|||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.and_then(|v| v.parse::<i64>().ok());
|
.and_then(|v| v.parse::<i64>().ok());
|
||||||
|
|
||||||
let dest_subpath = extract_files_subpath(&destination, &user.username)
|
// Strip the destination URL prefix using the SESSION's raw username
|
||||||
|
// (`admin~<drive-uuid>` on non-home drives), NOT `user.username`
|
||||||
|
// (bare `admin`). NC clients send `Destination: /remote.php/dav/files/
|
||||||
|
// {raw_username}/…` — the URL user-segment mirrors the credential
|
||||||
|
// they authenticated with. Passing bare `admin` here strips only
|
||||||
|
// `admin/` from a `admin~<uuid>/…` destination, leaving the tilde
|
||||||
|
// marker glued to the leading path segment; the write then targets
|
||||||
|
// `<drive-root>/~<uuid>/…` and fails with a parent-folder lookup
|
||||||
|
// error. Matches `webdav_handler::handle_move`'s call to
|
||||||
|
// `extract_nc_subpath_from_dest(&destination, url_user)` where
|
||||||
|
// `url_user = &session.raw_username` (webdav_handler.rs:1177).
|
||||||
|
let dest_subpath = extract_files_subpath(&destination, &session.raw_username)
|
||||||
.ok_or_else(|| AppError::bad_request("Invalid Destination URL"))?;
|
.ok_or_else(|| AppError::bad_request("Invalid Destination URL"))?;
|
||||||
|
|
||||||
// Stream the chunk parts, in order, straight into the CDC chunk store —
|
// Stream the chunk parts, in order, straight into the CDC chunk store —
|
||||||
|
|||||||
@@ -6,23 +6,19 @@
|
|||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<title>OxiCloud</title>
|
<title>OxiCloud</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
|
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
|
||||||
<script src="/js/core/theme-init.js"></script>
|
<link rel="stylesheet" href="/askama-common.css">
|
||||||
<link rel="stylesheet" href="/css/main.css">
|
|
||||||
<link rel="stylesheet" href="/css/views/auth.css">
|
|
||||||
<style>
|
<style>
|
||||||
/* Cross-browser prompt is the one magic-link page that needs a
|
/* Cross-browser prompt overrides `.magic-note` with a warning
|
||||||
"warning" callout that auth.css doesn't ship. Inline because the
|
treatment — this is the only page where the note should shout.
|
||||||
shape is unique to this surface — keep the styling local rather
|
Tokens come from the shared design system (variables.css) via
|
||||||
than adding tokens that no other page reuses. */
|
askama-common.css above; both are the canonical warning names
|
||||||
|
(`--color-warning-bg` / `--color-warning-border`), replacing the
|
||||||
|
pre-SPA-migration placeholders `--color-warning-bg-light` /
|
||||||
|
`--color-warning-text-amber` which never existed. */
|
||||||
.magic-note {
|
.magic-note {
|
||||||
background: var(--color-warning-bg-light);
|
background: var(--color-warning-bg);
|
||||||
border-left: 3px solid var(--color-warning-text-amber);
|
border-left: 3px solid var(--color-warning-border);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
padding: 0.75em 1em;
|
|
||||||
margin: 1.5em 0;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.95em;
|
|
||||||
text-align: left;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -6,9 +6,7 @@
|
|||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<title>OxiCloud</title>
|
<title>OxiCloud</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
|
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
|
||||||
<script src="/js/core/theme-init.js"></script>
|
<link rel="stylesheet" href="/askama-common.css">
|
||||||
<link rel="stylesheet" href="/css/main.css">
|
|
||||||
<link rel="stylesheet" href="/css/views/auth.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
|
|||||||
@@ -6,9 +6,7 @@
|
|||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<title>OxiCloud</title>
|
<title>OxiCloud</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
|
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
|
||||||
<script src="/js/core/theme-init.js"></script>
|
<link rel="stylesheet" href="/askama-common.css">
|
||||||
<link rel="stylesheet" href="/css/main.css">
|
|
||||||
<link rel="stylesheet" href="/css/views/auth.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
|
|||||||
@@ -6,9 +6,7 @@
|
|||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<title>OxiCloud</title>
|
<title>OxiCloud</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
|
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
|
||||||
<script src="/js/core/theme-init.js"></script>
|
<link rel="stylesheet" href="/askama-common.css">
|
||||||
<link rel="stylesheet" href="/css/main.css">
|
|
||||||
<link rel="stylesheet" href="/css/views/auth.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
|
|||||||
@@ -6,9 +6,7 @@
|
|||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<title>Choose a drive - OxiCloud</title>
|
<title>Choose a drive - OxiCloud</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
|
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
|
||||||
<script src="/js/core/theme-init.js"></script>
|
<link rel="stylesheet" href="/askama-common.css">
|
||||||
<link rel="stylesheet" href="/css/main.css">
|
|
||||||
<link rel="stylesheet" href="/css/views/auth.css">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
|
|||||||
@@ -0,0 +1,398 @@
|
|||||||
|
# =============================================================
|
||||||
|
# OxiCloud — NC multi-drive MOVE destination-prefix regressions
|
||||||
|
# =============================================================
|
||||||
|
# Regression coverage for two sibling bugs discovered 2026-07-12
|
||||||
|
# when the multi-drive `admin~{drive-uuid}` credential shape was
|
||||||
|
# rolled through the NC `/remote.php/dav/*` surface but two MOVE
|
||||||
|
# handlers were missed:
|
||||||
|
#
|
||||||
|
# uploads_handler::handle_assemble (chunked-upload MOVE)
|
||||||
|
# trashbin_handler (restore MOVE with a Destination header)
|
||||||
|
#
|
||||||
|
# Both handlers were stripping the destination-URL prefix with
|
||||||
|
# `&user.username` (bare `admin`) instead of
|
||||||
|
# `&session.raw_username` (composite `admin~{uuid}`). NC clients
|
||||||
|
# on a non-home drive send:
|
||||||
|
# Destination: /remote.php/dav/files/admin~{uuid}/<path>
|
||||||
|
# The bare-username strip left `~{uuid}/<path>` glued to the
|
||||||
|
# leading path segment; downstream lookups then targeted a
|
||||||
|
# fabricated `<drive-root>/~{uuid}/…` path and 500'd (assemble
|
||||||
|
# path) or silently missed collisions (trash path).
|
||||||
|
#
|
||||||
|
# webdav_handler::handle_move (the standard `/dav/files/…` MOVE)
|
||||||
|
# was ALREADY correct — it uses `url_user = &session.raw_username`.
|
||||||
|
# The uploads + trashbin siblings were coverage gaps: no Hurl
|
||||||
|
# tests hit them with a composite credential.
|
||||||
|
#
|
||||||
|
# Hurl gotcha: the `[BasicAuth]` block parses the username as a
|
||||||
|
# single token terminated by `:`. A raw composite like
|
||||||
|
# `{{nc_username}}~{{drive_id}}` fails to parse because Hurl
|
||||||
|
# sees the `~` between two templates and expects a line
|
||||||
|
# terminator. Workaround: alias the composite into
|
||||||
|
# `nc_basic_user` via `[Options] variable:` on a bootstrap
|
||||||
|
# request, then use `{{nc_basic_user}}` in every subsequent
|
||||||
|
# BasicAuth block.
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Setup 1 — JWT login.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "{{username}}", "password": "{{password}}" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
jwt: jsonpath "$.access_token"
|
||||||
|
admin_user_id: jsonpath "$.user.id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Setup 2 — Fetch admin's home drive root folder id.
|
||||||
|
#
|
||||||
|
# The composite `{user}~{marker}` shape sends the marker
|
||||||
|
# through `basic_auth_middleware.rs`, which resolves it as a
|
||||||
|
# **folder id** (not a drive id) via
|
||||||
|
# `folder_service.get_folder_with_perms(folder_id, user_id)` —
|
||||||
|
# the auth boundary refuses if the caller lacks Read on that
|
||||||
|
# folder.
|
||||||
|
#
|
||||||
|
# For a regression test we don't need a SECONDARY drive —
|
||||||
|
# we need any folder id the caller has Read on so the composite
|
||||||
|
# credential authenticates cleanly. Admin's own home folder is
|
||||||
|
# the trivially-authorized choice; the tilde-parsing bug in
|
||||||
|
# `handle_assemble` / trashbin restore fires the same way
|
||||||
|
# regardless of which folder id the marker points at.
|
||||||
|
#
|
||||||
|
# For the real multi-drive scenario Ed hit in production, the
|
||||||
|
# marker after `~` was the folder id of a shared drive's root
|
||||||
|
# where admin had explicit Read via role_grants. That code path
|
||||||
|
# is identical to the one exercised here — the bug is in the
|
||||||
|
# destination-URL parsing, not in what the folder id points to.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/folders
|
||||||
|
Authorization: Bearer {{jwt}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
home_folder_id: jsonpath "$[0].id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Setup 3 — Mint an app password. `username` in the response is
|
||||||
|
# just `admin`; we splice the folder id onto it in Setup 4
|
||||||
|
# below to get the composite `admin~{uuid}` shape.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/app-passwords
|
||||||
|
Authorization: Bearer {{jwt}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "label": "nc_multidrive_move_regression hurl test" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
nc_username: jsonpath "$.username"
|
||||||
|
nc_password: jsonpath "$.password"
|
||||||
|
ap_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Setup 4 — Bootstrap the composite BasicAuth username.
|
||||||
|
#
|
||||||
|
# `[Options] variable:` sets a variable whose VALUE is a
|
||||||
|
# template expanded against the current bindings, then the
|
||||||
|
# result is available to all subsequent requests. `nc_username`
|
||||||
|
# and `home_folder_id` are already captured; concatenating them
|
||||||
|
# here hides the `~` from the strict `[BasicAuth]` parser
|
||||||
|
# (which would otherwise reject `{{nc_username}}~{{home_folder_id}}`
|
||||||
|
# mid-username).
|
||||||
|
#
|
||||||
|
# `/ready` is a cheap unauthenticated 200 that gives us a
|
||||||
|
# request to hang the option on. No side effects.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/ready
|
||||||
|
[Options]
|
||||||
|
variable: nc_basic_user={{nc_username}}~{{home_folder_id}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================
|
||||||
|
# A. Chunked-upload MOVE assemble regression
|
||||||
|
# =============================================================
|
||||||
|
# `handle_assemble` in `uploads_handler.rs` was calling
|
||||||
|
# `extract_files_subpath(&destination, &user.username)`. With a
|
||||||
|
# composite Destination it treated `~{drive_uuid}/<path>` as the
|
||||||
|
# target subpath, then tried `nc_to_internal_path(chroot, …)`
|
||||||
|
# → `<drive-root>/~{drive_uuid}/<path>`. Downstream parent-folder
|
||||||
|
# lookup → 500.
|
||||||
|
#
|
||||||
|
# Fixed by binding on `&session.raw_username`. Test shape:
|
||||||
|
# A1 — MKCOL: create the chunked-upload session directory.
|
||||||
|
# A2 — MOVE `.file` (empty session → zero chunks → assemble
|
||||||
|
# writes an empty file at Destination). Pre-fix: 500 with
|
||||||
|
# "Failed to get folder at path: /<drive-root>/~<uuid>".
|
||||||
|
# Post-fix: 201 + file exists at the real Destination.
|
||||||
|
# A3 — PROPFIND on the destination path to confirm the file
|
||||||
|
# landed under the drive's root (NOT under `~<uuid>/`).
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
# A1 — MKCOL upload session.
|
||||||
|
MKCOL {{base_url}}/remote.php/dav/uploads/{{nc_basic_user}}/regression-upload-session
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_basic_user}}: {{nc_password}}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
|
||||||
|
|
||||||
|
# A2 — MOVE `.file` with a composite Destination header. Empty
|
||||||
|
# session, so the assemble step writes a zero-byte file at the
|
||||||
|
# destination path — that's fine, we're pinning the destination-
|
||||||
|
# parsing behaviour, not the byte-copying.
|
||||||
|
#
|
||||||
|
# Hurl gotcha: headers MUST come before section blocks like
|
||||||
|
# `[BasicAuth]`. `Destination:` after `[BasicAuth]` gets parsed
|
||||||
|
# as a new request's method line.
|
||||||
|
MOVE {{base_url}}/remote.php/dav/uploads/{{nc_basic_user}}/regression-upload-session/.file
|
||||||
|
Destination: {{base_url}}/remote.php/dav/files/{{nc_basic_user}}/regression-assembled.txt
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_basic_user}}: {{nc_password}}
|
||||||
|
|
||||||
|
# The regression: pre-fix this returned 500 with a
|
||||||
|
# "~<drive-uuid>" fragment in the error message; post-fix it
|
||||||
|
# writes the empty file successfully. Any 2xx status proves the
|
||||||
|
# destination-parsing path is intact.
|
||||||
|
HTTP 201
|
||||||
|
|
||||||
|
|
||||||
|
# A3 — Confirm the file exists at the real path inside the
|
||||||
|
# chroot. HTTP 207 alone is the load-bearing assertion: pre-fix,
|
||||||
|
# MOVE would have 500'd (so we'd never reach here); and even if
|
||||||
|
# it had somehow written, the file would have landed at the
|
||||||
|
# fabricated `<chroot>/~<folder_id>/…` path rather than
|
||||||
|
# `<chroot>/regression-assembled.txt` — this PROPFIND would
|
||||||
|
# then 404 rather than 207.
|
||||||
|
#
|
||||||
|
# `body not contains "~{folder_id}/..."` would be redundant AND
|
||||||
|
# wrong here: NC's PROPFIND echoes the client's request URL in
|
||||||
|
# `<d:href>`, so the composite `admin~<folder_id>` legitimately
|
||||||
|
# appears in the returned href — that's the URL prefix, not a
|
||||||
|
# leak. The empty-file-size assertion below is the concrete
|
||||||
|
# positive check: MOVE with zero chunks assembles a 0-byte
|
||||||
|
# file, so we pin that shape.
|
||||||
|
PROPFIND {{base_url}}/remote.php/dav/files/{{nc_basic_user}}/regression-assembled.txt
|
||||||
|
Depth: 0
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_basic_user}}: {{nc_password}}
|
||||||
|
|
||||||
|
HTTP 207
|
||||||
|
[Asserts]
|
||||||
|
xpath "string(//*[local-name()='getcontentlength'])" == "0"
|
||||||
|
|
||||||
|
|
||||||
|
# Cleanup — remove the assembled file so a re-run starts clean.
|
||||||
|
DELETE {{base_url}}/remote.php/dav/files/{{nc_basic_user}}/regression-assembled.txt
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_basic_user}}: {{nc_password}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================
|
||||||
|
# B. Trashbin restore MOVE — sibling handler with the same bug
|
||||||
|
# =============================================================
|
||||||
|
# `trashbin_handler.rs` line 143 has the same shape:
|
||||||
|
# extract_nc_subpath_from_dest(&dest_header, &user.username)
|
||||||
|
# The trash MOVE uses the Destination header for a collision
|
||||||
|
# pre-check, not for relocation (restore always lands at the
|
||||||
|
# original path). A buggy prefix strip therefore doesn't 500 —
|
||||||
|
# it silently miscomputes the collision path (`<drive>/~<uuid>/…`
|
||||||
|
# instead of `<drive>/<real-path>`), letting a real collision
|
||||||
|
# slip past. The response is 2xx either way.
|
||||||
|
#
|
||||||
|
# So a "5xx vs 201" assertion won't catch it. What DOES catch it:
|
||||||
|
# stage a genuine collision, restore with a Destination that
|
||||||
|
# points at it. Pre-fix: no 412 (bug misses the collision).
|
||||||
|
# Post-fix: 412 Precondition Failed.
|
||||||
|
#
|
||||||
|
# Sequence:
|
||||||
|
# B1 — Upload `regression-collision.txt` to the drive.
|
||||||
|
# B2 — DELETE it (soft-trash).
|
||||||
|
# B3 — Re-upload `regression-collision.txt` (new file at the
|
||||||
|
# same path) to stage the collision.
|
||||||
|
# B4 — Enumerate the trashbin to find the trashed item's id.
|
||||||
|
# B5 — MOVE the trash item back with Destination pointing at
|
||||||
|
# the re-created file. Pre-fix: 201/204 (collision missed).
|
||||||
|
# Post-fix: 412 Precondition Failed.
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
# B1 — Stage the file the client will trash.
|
||||||
|
PUT {{base_url}}/remote.php/dav/files/{{nc_basic_user}}/regression-collision.txt
|
||||||
|
Content-Type: text/plain
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_basic_user}}: {{nc_password}}
|
||||||
|
```
|
||||||
|
first version
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
|
||||||
|
|
||||||
|
# B2 — Soft-trash it.
|
||||||
|
DELETE {{base_url}}/remote.php/dav/files/{{nc_basic_user}}/regression-collision.txt
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_basic_user}}: {{nc_password}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
|
||||||
|
# B3 — Re-upload at the same path to stage the collision.
|
||||||
|
PUT {{base_url}}/remote.php/dav/files/{{nc_basic_user}}/regression-collision.txt
|
||||||
|
Content-Type: text/plain
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_basic_user}}: {{nc_password}}
|
||||||
|
```
|
||||||
|
second version
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
|
||||||
|
|
||||||
|
# B4 — Enumerate trashbin to find the trashed item's numeric id
|
||||||
|
# (NC identifies trash items with `oc:trashbin-filename` etc.).
|
||||||
|
# Using PROPFIND at Depth 1 on the trashbin root.
|
||||||
|
PROPFIND {{base_url}}/remote.php/dav/trashbin/{{nc_basic_user}}/trash
|
||||||
|
Depth: 1
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_basic_user}}: {{nc_password}}
|
||||||
|
|
||||||
|
HTTP 207
|
||||||
|
[Captures]
|
||||||
|
# Grab the href of the first trashed child. Fragile against
|
||||||
|
# multi-item trash but this test creates exactly one before
|
||||||
|
# reading — safe here. Local-name xpath so we don't have to
|
||||||
|
# thread the DAV namespace prefix.
|
||||||
|
trash_item_href: xpath "string((//*[local-name()='response']/*[local-name()='href'])[2])"
|
||||||
|
|
||||||
|
|
||||||
|
# B5 — MOVE the trash item back with a composite Destination.
|
||||||
|
# Pre-fix: collision check runs against a fake `<drive>/~<uuid>/…`
|
||||||
|
# path, misses the real collision, restore succeeds (201/204).
|
||||||
|
# Post-fix: collision check hits the real path, request refused
|
||||||
|
# with 412.
|
||||||
|
MOVE {{base_url}}{{trash_item_href}}
|
||||||
|
Destination: {{base_url}}/remote.php/dav/files/{{nc_basic_user}}/regression-collision.txt
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_basic_user}}: {{nc_password}}
|
||||||
|
|
||||||
|
# Post-fix expectation: 412 (collision detected). If a future
|
||||||
|
# change makes trashbin restore honour Destination for
|
||||||
|
# relocation, this assertion changes — but the collision-check
|
||||||
|
# semantics should stay collision-refusing.
|
||||||
|
HTTP 412
|
||||||
|
|
||||||
|
|
||||||
|
# Cleanup — permanently delete the trashed item so a re-run
|
||||||
|
# starts clean, and drop the live file.
|
||||||
|
DELETE {{base_url}}{{trash_item_href}}
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_basic_user}}: {{nc_password}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
|
||||||
|
DELETE {{base_url}}/remote.php/dav/files/{{nc_basic_user}}/regression-collision.txt
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_basic_user}}: {{nc_password}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================
|
||||||
|
# C. Chunked-upload PROPFIND href regression
|
||||||
|
# =============================================================
|
||||||
|
# `handle_propfind_session` in `uploads_handler.rs` was emitting
|
||||||
|
# `<d:href>` values with `user.username` (bare `admin`) instead of
|
||||||
|
# `session.raw_username` (composite `admin~<uuid>`). Same shape
|
||||||
|
# as the trashbin PROPFIND bug: NC clients doing chunked-upload
|
||||||
|
# resume PROPFIND the session, then MOVE/DELETE against the
|
||||||
|
# returned hrefs. With the bare form, every follow-up 403s at
|
||||||
|
# the `NcSession` extractor (URL `{user}` segment mismatches
|
||||||
|
# `raw_username`).
|
||||||
|
#
|
||||||
|
# Positive test: after PROPFIND-ing an upload session with a
|
||||||
|
# composite credential, the emitted hrefs MUST contain `~<uuid>`.
|
||||||
|
# Pre-fix: `<d:href>/remote.php/dav/uploads/admin/…</d:href>`.
|
||||||
|
# Post-fix: `<d:href>/remote.php/dav/uploads/admin~<uuid>/…</d:href>`.
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
# C1 — MKCOL a fresh session.
|
||||||
|
MKCOL {{base_url}}/remote.php/dav/uploads/{{nc_basic_user}}/regression-propfind-session
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_basic_user}}: {{nc_password}}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
|
||||||
|
|
||||||
|
# C2 — PUT one chunk so PROPFIND has something to enumerate
|
||||||
|
# alongside the session collection itself.
|
||||||
|
PUT {{base_url}}/remote.php/dav/uploads/{{nc_basic_user}}/regression-propfind-session/00000001
|
||||||
|
Content-Type: application/octet-stream
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_basic_user}}: {{nc_password}}
|
||||||
|
```
|
||||||
|
chunk-body
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
|
||||||
|
|
||||||
|
# C3 — PROPFIND the session with the composite credential and
|
||||||
|
# assert every emitted href carries the composite user segment.
|
||||||
|
# `contains "~{{home_folder_id}}/"` is the exact byte marker
|
||||||
|
# introduced by the fix — the bug would produce
|
||||||
|
# `/dav/uploads/admin/…` with no `~` between the surface and the
|
||||||
|
# session id.
|
||||||
|
PROPFIND {{base_url}}/remote.php/dav/uploads/{{nc_basic_user}}/regression-propfind-session
|
||||||
|
Depth: 1
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_basic_user}}: {{nc_password}}
|
||||||
|
|
||||||
|
HTTP 207
|
||||||
|
[Asserts]
|
||||||
|
# Every href for this upload surface must echo the composite user.
|
||||||
|
# Two responses expected: session collection + one chunk. Both
|
||||||
|
# hrefs share the same `/remote.php/dav/uploads/<user>/<session>/…`
|
||||||
|
# prefix, so one substring check on the body body is sufficient
|
||||||
|
# and immune to XML formatting drift.
|
||||||
|
body contains "/remote.php/dav/uploads/{{nc_basic_user}}/regression-propfind-session/"
|
||||||
|
# Belt-and-suspenders: assert the bare form is absent. The
|
||||||
|
# composite basic-user is `admin~<uuid>`; the bare form would
|
||||||
|
# render as `/dav/uploads/admin/regression-…` (no `~`).
|
||||||
|
# `not contains` here would still permit that byte sequence to
|
||||||
|
# appear inside the composite, so we anchor on the trailing `/`
|
||||||
|
# after the user segment to disambiguate: `/admin/regression-…`
|
||||||
|
# is the bug shape; the fix never produces `/admin/regression-…`
|
||||||
|
# because the composite always separates admin from the session
|
||||||
|
# with `~<uuid>`.
|
||||||
|
body not contains "/remote.php/dav/uploads/{{nc_username}}/regression-propfind-session"
|
||||||
|
|
||||||
|
|
||||||
|
# C4 — DELETE the session with a composite href. Pre-fix (bare
|
||||||
|
# href returned by C3 that the client would have followed) this
|
||||||
|
# would have been a wire-level 403 at the extractor; post-fix
|
||||||
|
# the composite href works end-to-end.
|
||||||
|
DELETE {{base_url}}/remote.php/dav/uploads/{{nc_basic_user}}/regression-propfind-session
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_basic_user}}: {{nc_password}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================
|
||||||
|
# Teardown — revoke the app password.
|
||||||
|
# =============================================================
|
||||||
|
DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}}
|
||||||
|
Authorization: Bearer {{jwt}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
@@ -187,6 +187,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
|||||||
"$API_DIR/drive_policies.hurl" \
|
"$API_DIR/drive_policies.hurl" \
|
||||||
"$API_DIR/cross_drive_move.hurl" \
|
"$API_DIR/cross_drive_move.hurl" \
|
||||||
"$API_DIR/cross_drive_copy.hurl" \
|
"$API_DIR/cross_drive_copy.hurl" \
|
||||||
|
"$API_DIR/nc_multidrive_move_regression.hurl" \
|
||||||
"$API_DIR/webdav_dead_properties.hurl" \
|
"$API_DIR/webdav_dead_properties.hurl" \
|
||||||
"$API_DIR/nc_webdav_dead_properties.hurl" \
|
"$API_DIR/nc_webdav_dead_properties.hurl" \
|
||||||
"$API_DIR/webdav_protected_properties.hurl" \
|
"$API_DIR/webdav_protected_properties.hurl" \
|
||||||
|
|||||||
Reference in New Issue
Block a user