Merge branch 'main' into rfc-4331-quota-properties
# Conflicts: # src/interfaces/nextcloud/report_handler.rs # src/interfaces/nextcloud/webdav_handler.rs # tests/api/run.sh
This commit is contained in:
@@ -105,7 +105,6 @@ FROM base AS builder-cache
|
||||
WORKDIR /app
|
||||
COPY Cargo.toml Cargo.lock build.rs ./
|
||||
COPY src src
|
||||
COPY static static
|
||||
COPY migrations migrations
|
||||
COPY templates templates
|
||||
COPY --from=frontend /static-dist ./static-dist
|
||||
|
||||
+4
-1
@@ -69,7 +69,10 @@ Most runtime variables use the `OXICLOUD_` prefix. A few build-time or allocator
|
||||
| `OXICLOUD_ENABLE_SEARCH` | `true` | Full-text and metadata search |
|
||||
| `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_ENABLE_ADMIN_INTERNAL_ENDPOINTS` | `false` | Expose `POST /api/admin/internal/trigger-sweep`, `POST /api/admin/internal/trigger-gc`, and `POST /api/admin/internal/trigger-grant-cleanup` — test-only synchronous triggers for the storage-usage reconciliation sweep, blob garbage collector, and expired-grant purge respectively. Used by the API test suite to assert convergence deterministically without waiting out the periodic tickers. Leave **off** in production: the routes return 404 even to an admin token when disabled. |
|
||||
| `OXICLOUD_GRANT_CLEANUP_ENABLED` | `true` | Background daemon that deletes expired rows from `storage.role_grants`. The authorization engine already filters expired grants out of every permission check at read time (`expires_at IS NULL OR expires_at > NOW()`), so leaving expired rows in place is a hygiene issue — not a security one. This daemon garbage-collects them daily. Set to `false` to keep every expired grant row forever (uncommon; a fresh install rarely wants this). |
|
||||
| `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` | `15` | Days past a grant's `expires_at` before the row is eligible for deletion. The grace window preserves the audit / support answer to "what happened to my access?" for a couple of weeks past expiration. Values below 1 are legal but discouraged — the recommendation is **≥ 15 days**. Values above the actual grant TTL used by clients waste index space; a few weeks is the sweet spot. |
|
||||
| `OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS` | `24` | How often the grant-cleanup daemon fires. Clamped to a minimum of 1 hour. Adjusting this doesn't change what gets deleted — only how promptly. Daily is fine for any realistic grant volume. |
|
||||
| `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
|
||||
|
||||
+13
@@ -230,6 +230,19 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud
|
||||
# Enable trash/recycle bin functionality (default: true)
|
||||
#OXICLOUD_ENABLE_TRASH=true
|
||||
|
||||
# Background daemon that deletes expired `storage.role_grants` rows.
|
||||
# The AuthZ engine already filters expired grants out of every
|
||||
# permission check at read time, so leaving expired rows in place is
|
||||
# a hygiene issue — not a security one. This purge deletes rows
|
||||
# whose `expires_at` is more than GRACE_DAYS in the past, preserving
|
||||
# the audit / support answer to "what happened to my access?" for
|
||||
# the grace window.
|
||||
#
|
||||
# Default: enabled. Recommended grace: >= 15 days.
|
||||
#OXICLOUD_GRANT_CLEANUP_ENABLED=true
|
||||
#OXICLOUD_GRANT_CLEANUP_GRACE_DAYS=15
|
||||
#OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS=24
|
||||
|
||||
# Enable search functionality (default: true)
|
||||
#OXICLOUD_ENABLE_SEARCH=true
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"postbuild": "node scripts/emit-askama-common.mjs",
|
||||
"preview": "vite preview",
|
||||
"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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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>
|
||||
<p>{view.message}</p>
|
||||
<button
|
||||
@@ -80,7 +80,13 @@
|
||||
|
||||
<style>
|
||||
.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;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -95,7 +101,12 @@
|
||||
}
|
||||
|
||||
: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 {
|
||||
|
||||
@@ -8,8 +8,12 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Mirror the legacy flow: auto-close the popup shortly after success so
|
||||
// the user is returned to their Nextcloud client without an extra click.
|
||||
// Auto-close the tab a few seconds after landing. NC clients
|
||||
// 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);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
@@ -34,7 +38,13 @@
|
||||
|
||||
<style>
|
||||
.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;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
+424
-27
@@ -53,7 +53,9 @@
|
||||
"sharedwithme": "나와 공유됨",
|
||||
"profile": "프로필",
|
||||
"shared_with_me": "나와 공유됨",
|
||||
"groups": "그룹"
|
||||
"groups": "그룹",
|
||||
"primary": "기본",
|
||||
"toggle": "탐색 메뉴 전환"
|
||||
},
|
||||
"photos": {
|
||||
"empty_state": "아직 사진이 없습니다",
|
||||
@@ -62,7 +64,21 @@
|
||||
"view_daily": "일",
|
||||
"view_monthly": "월",
|
||||
"view_yearly": "년",
|
||||
"group_by": "그룹화 기준"
|
||||
"group_by": "그룹화 기준",
|
||||
"confirm_delete": "사진 {{n}}장을 휴지통으로 이동하시겠습니까?",
|
||||
"confirm_delete_one": "{{name}}을(를) 삭제하시겠습니까?",
|
||||
"delete": "사진 삭제",
|
||||
"empty": "아직 사진이 없습니다.",
|
||||
"full_resolution": "원본 해상도",
|
||||
"trash_partial": "{{total}}개 중 {{ok}}개가 휴지통으로 이동되었습니다.",
|
||||
"trashed": "{{n}}개가 휴지통으로 이동되었습니다.",
|
||||
"layout_square": "그리드",
|
||||
"layout_justified": "맞춤형",
|
||||
"tab_moments": "순간",
|
||||
"tab_places": "장소",
|
||||
"tab_people": "인물",
|
||||
"map_loading": "지도 로딩 중…",
|
||||
"map_error": "지도를 불러올 수 없습니다"
|
||||
},
|
||||
"music": {
|
||||
"create_playlist": "재생목록 만들기",
|
||||
@@ -130,7 +146,26 @@
|
||||
"share_with_user": "User ID or email",
|
||||
"toggle_public": "Visibility",
|
||||
"track_removed": "Track removed",
|
||||
"prev": "이전"
|
||||
"prev": "이전",
|
||||
"add_selected": "선택 항목 추가",
|
||||
"create_playlist_hint": "재생목록 이름을 입력하여 새로 만드세요.",
|
||||
"created": "\"{{name}}\"이(가) 생성되었습니다.",
|
||||
"delete_playlist": "재생목록 삭제",
|
||||
"deleted": "\"{{name}}\"이(가) 삭제되었습니다.",
|
||||
"edit_description": "설명 편집",
|
||||
"empty_playlist": "이 재생목록에는 아직 트랙이 없습니다.",
|
||||
"new_playlist": "새 재생목록",
|
||||
"no_audio": "오디오 파일을 찾을 수 없습니다.",
|
||||
"now_private": "재생목록이 비공개로 전환되었습니다.",
|
||||
"now_public": "재생목록이 공개로 전환되었습니다.",
|
||||
"pick_or_create": "기존 목록: {{list}}. 추가하거나 새로 만들려면 이름을 입력하세요.",
|
||||
"rename_playlist": "재생목록 이름 변경",
|
||||
"reordered": "재생목록 순서가 변경되었습니다.",
|
||||
"seek": "탐색",
|
||||
"selected_count": "{{n}}개 선택됨",
|
||||
"share_added": "공유되었습니다.",
|
||||
"track_count": "트랙 {{n}}개",
|
||||
"tracks_added": "트랙 {{n}}개가 추가되었습니다."
|
||||
},
|
||||
"actions": {
|
||||
"search": "파일 검색...",
|
||||
@@ -181,7 +216,9 @@
|
||||
"auto": "시스템과 동일"
|
||||
},
|
||||
"manage_groups": "그룹 관리",
|
||||
"admin": "관리자"
|
||||
"admin": "관리자",
|
||||
"mit_license": "MIT 라이선스",
|
||||
"title": "사용자 메뉴"
|
||||
},
|
||||
"share": {
|
||||
"dialogTitle": "공유 링크",
|
||||
@@ -232,7 +269,40 @@
|
||||
"link_name": "Link name (optional)",
|
||||
"notifyByEmail": "이메일로 알림",
|
||||
"revoke": "Remove",
|
||||
"role_label": "역할"
|
||||
"role_label": "역할",
|
||||
"addPassword": "비밀번호 추가",
|
||||
"add_people": "사용자, 그룹 또는 이메일 추가…",
|
||||
"bad_password": "비밀번호가 올바르지 않습니다. 다시 시도해 주세요.",
|
||||
"changePassword": "비밀번호 변경",
|
||||
"create_link": "링크 생성",
|
||||
"created": "공개 링크가 생성되었습니다",
|
||||
"dialog_title": "\"{{name}}\" 공유",
|
||||
"download_zip": "ZIP 다운로드",
|
||||
"empty_folder": "이 폴더가 비어 있습니다.",
|
||||
"error": "문제가 발생했습니다. 다시 시도해 주세요.",
|
||||
"expired": "이 공유 링크는 더 이상 사용할 수 없습니다.",
|
||||
"expires_optional": "만료일 (선택 사항)",
|
||||
"expiry": "만료일",
|
||||
"invalid": "이 공유 링크가 유효하지 않습니다.",
|
||||
"link": "링크",
|
||||
"no_people": "아직 아무와도 공유되지 않았습니다.",
|
||||
"none": "아직 공개 링크가 없습니다.",
|
||||
"notify": {
|
||||
"coalesced": "{{n}}명은 최근에 이미 알림을 받았습니다.",
|
||||
"rateLimited": "{{n}}명이 속도 제한에 걸렸습니다 — 나중에 다시 시도하세요.",
|
||||
"sent": "{{n}}명에게 이메일로 알렸습니다.",
|
||||
"skipped": "{{n}}명 건너뜀 (이메일 없음 / 수신 거부)."
|
||||
},
|
||||
"passwordPrompt": "비밀번호 설정:",
|
||||
"passwordPrompt_clear": "새 비밀번호 (제거하려면 비워두세요):",
|
||||
"password_cleared": "비밀번호가 제거되었습니다",
|
||||
"password_optional": "비밀번호 (선택 사항)",
|
||||
"password_set": "비밀번호가 변경되었습니다",
|
||||
"password_title": "비밀번호 필요",
|
||||
"public_link": "공개 링크",
|
||||
"set_expiry": "만료일 설정",
|
||||
"title": "공유됨",
|
||||
"unlock": "잠금 해제"
|
||||
},
|
||||
"share_dialogTitle": "공유 링크",
|
||||
"share_linkLabel": "공유 링크:",
|
||||
@@ -365,7 +435,55 @@
|
||||
"folder": "폴더",
|
||||
"new_folder": "새 폴더",
|
||||
"share": "공유",
|
||||
"view": "보기"
|
||||
"view": "보기",
|
||||
"already_favorites": "선택한 항목이 모두 이미 즐겨찾기에 있습니다",
|
||||
"batch_delete": "선택 항목 삭제",
|
||||
"breadcrumb": "경로",
|
||||
"cancel_selection": "선택 취소",
|
||||
"col_modified": "날짜",
|
||||
"col_path": "위치",
|
||||
"confirm_batch_delete": "{{n}}개 항목을 휴지통으로 이동하시겠습니까?",
|
||||
"confirm_delete": "\"{{name}}\"을(를) 휴지통으로 이동하시겠습니까?",
|
||||
"confirm_delete_n": "{{count}}개 항목을 삭제하시겠습니까?",
|
||||
"copied": "복사됨",
|
||||
"copy_here": "여기에 복사",
|
||||
"copy_n": "{{n}}개 항목 복사",
|
||||
"copy_title": "\"{{name}}\" 복사",
|
||||
"download_zip": "ZIP으로 다운로드",
|
||||
"edit_new_tab": "새 탭에서 편집",
|
||||
"editor": "문서 편집기",
|
||||
"empty_title": "이 폴더가 비어 있습니다",
|
||||
"favorite": "즐겨찾기 추가",
|
||||
"favorited": "즐겨찾기됨",
|
||||
"grid": "그리드",
|
||||
"list": "목록",
|
||||
"more_actions": "추가 작업",
|
||||
"move": "이동",
|
||||
"move_here": "여기로 이동",
|
||||
"move_n": "{{n}}개 항목 이동",
|
||||
"move_title": "\"{{name}}\" 이동",
|
||||
"moved": "이동됨",
|
||||
"new_folder_prompt": "새 폴더 이름",
|
||||
"no_home": "홈 폴더를 사용할 수 없습니다.",
|
||||
"no_preview": "이 파일 형식은 미리보기를 지원하지 않습니다.",
|
||||
"no_subfolders": "하위 폴더가 없습니다.",
|
||||
"open": "열기",
|
||||
"open_parent": "상위 폴더 열기",
|
||||
"owner_me": "나",
|
||||
"preview_failed": "미리보기를 불러올 수 없습니다.",
|
||||
"select_all": "전체 선택",
|
||||
"selected_count": "{{count}}개 선택됨",
|
||||
"selection": "선택",
|
||||
"shared": "공유됨",
|
||||
"unfavorite": "즐겨찾기 해제",
|
||||
"uploaded": "업로드 완료",
|
||||
"uploaded_saved": "업로드 완료 — {{mb}}MB 중복 제거됨",
|
||||
"uploaded_partial": "{{ok}}개 업로드됨, {{failed}}개 실패",
|
||||
"uploaded_skipped": "{{ok}}개 업로드됨 · {{skipped}}개 건너뜀 (일반 파일 아님)",
|
||||
"upload_failed": "업로드 실패",
|
||||
"uploading": "업로드 중…",
|
||||
"uploading_file": "{{name}} 업로드 중…",
|
||||
"uploading_n": "파일 업로드 중 {{done}}/{{total}}…"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "폴더 이름 변경",
|
||||
@@ -431,7 +549,8 @@
|
||||
"group_depth_exceeded": "중첩 깊이가 허용 최대값(8)을 초과합니다.",
|
||||
"group_virtual_immutable": "«Internal» 그룹은 시스템이 관리하며 수정할 수 없습니다.",
|
||||
"group_not_found": "그룹을 찾을 수 없습니다.",
|
||||
"group_name_taken": "이 이름의 그룹이 이미 존재합니다."
|
||||
"group_name_taken": "이 이름의 그룹이 이미 존재합니다.",
|
||||
"forbidden": "파일을 불러올 수 없습니다"
|
||||
},
|
||||
"breadcrumb": {
|
||||
"home": "홈"
|
||||
@@ -451,7 +570,10 @@
|
||||
"trashed_time": "삭제 시간"
|
||||
},
|
||||
"delete": "영구 삭제",
|
||||
"empty_action": "휴지통 비우기"
|
||||
"empty_action": "휴지통 비우기",
|
||||
"confirm_delete": "이 항목을 영구적으로 삭제하시겠습니까? 되돌릴 수 없습니다.",
|
||||
"confirm_empty": "휴지통을 비우시겠습니까? 되돌릴 수 없습니다.",
|
||||
"restored": "복원됨"
|
||||
},
|
||||
"daysRemaining": {
|
||||
"expired": "만료됨",
|
||||
@@ -519,7 +641,17 @@
|
||||
"magic_hint": "비밀번호가 없으신가요? 이메일을 입력하시면 일회용 로그인 링크를 보내드립니다.",
|
||||
"magic_unavailable": "이 서버에서는 이메일 로그인을 사용할 수 없습니다.",
|
||||
"passwords_match": "Passwords match",
|
||||
"sign_in": "로그인"
|
||||
"sign_in": "로그인",
|
||||
"cookie_rejected": "로그인은 성공했지만 브라우저가 세션 쿠키를 거부했습니다. HTTP를 사용 중이라면 OXICLOUD_COOKIE_SECURE=false로 설정하거나 HTTPS를 사용하세요.",
|
||||
"login_error": "로그인 오류",
|
||||
"magic_error": "문제가 발생했습니다. 다시 시도해 주세요.",
|
||||
"magic_prompt": "비밀번호가 없으신가요? 이메일 링크로 로그인하세요",
|
||||
"magic_send": "링크 보내기",
|
||||
"magic_sent": "해당 계정이 존재하면 로그인 링크가 전송되었습니다. 받은편지함을 확인하세요.",
|
||||
"register_error": "가입 실패",
|
||||
"session_expired": "세션이 만료되었습니다. 다시 로그인해 주세요.",
|
||||
"signing_in": "로그인 중…",
|
||||
"toggle_password": "비밀번호 표시"
|
||||
},
|
||||
"storage": {
|
||||
"title": "저장소",
|
||||
@@ -531,7 +663,8 @@
|
||||
"download_file": "파일 다운로드",
|
||||
"zoom_in": "확대",
|
||||
"zoom_out": "축소",
|
||||
"zoom_reset": "줌 초기화"
|
||||
"zoom_reset": "줌 초기화",
|
||||
"zoom": "확대/축소"
|
||||
},
|
||||
"language_selector": {
|
||||
"title": "환영합니다!",
|
||||
@@ -570,7 +703,8 @@
|
||||
"accessed": "접근일",
|
||||
"empty_state": "최근 파일이 없습니다",
|
||||
"empty_hint": "열어본 파일이 여기에 표시됩니다",
|
||||
"loadMore": "더 불러오기"
|
||||
"loadMore": "더 불러오기",
|
||||
"confirm_clear": "최근 항목을 지우시겠습니까?"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "파일 이름이 변경되었습니다",
|
||||
@@ -587,7 +721,8 @@
|
||||
"link_created": "링크 생성됨",
|
||||
"share_success": "공유 링크가 성공적으로 생성되었습니다",
|
||||
"upload_files_section_title": "여기서는 업로드할 수 없습니다",
|
||||
"upload_files_section_body": "파일을 업로드하려면 파일 섹션으로 이동하세요"
|
||||
"upload_files_section_body": "파일을 업로드하려면 파일 섹션으로 이동하세요",
|
||||
"clear": "모두 지우기"
|
||||
},
|
||||
"batch": {
|
||||
"one_selected": "1개 선택됨",
|
||||
@@ -826,7 +961,132 @@
|
||||
"include_in_music_index": "음악에 포함",
|
||||
"include_in_music_index_help": "이 Drive의 오디오 파일을 음악 라이브러리에 포함합니다. 기본 개인 Drive는 자동으로 포함됩니다. 실제로 음악 컬렉션이 있는 공유 Drive에서 켜세요 (예: 「가족 음악」, 「밴드 협업」).",
|
||||
"implied_by_forbid_sharing": "이미 「리소스별 공유 금지」에 의해 적용됨."
|
||||
}
|
||||
},
|
||||
"tab_plugins": "플러그인",
|
||||
"plugins_title": "플러그인",
|
||||
"plugins_disabled": "이 서버에서는 플러그인이 비활성화되어 있습니다. WASM 플러그인을 여기서 관리하려면 OXICLOUD_ENABLE_PLUGINS=true로 설정하고 \"plugins\" 기능을 활성화하여 빌드하세요.",
|
||||
"plugins_install_title": "플러그인 설치",
|
||||
"plugins_install_intro": "plugin.toml과 컴파일된 WebAssembly 모듈(.wasm)이 포함된 플러그인 번들(.zip)을 업로드하세요. 설치 전에 매니페스트가 검증되고 모듈이 점검됩니다.",
|
||||
"plugins_bundle_label": "플러그인 번들 (.zip)",
|
||||
"plugins_install": "플러그인 설치",
|
||||
"plugins_installed_title": "설치된 플러그인",
|
||||
"plugins_col_name": "이름",
|
||||
"plugins_col_id": "ID",
|
||||
"plugins_col_version": "버전",
|
||||
"plugins_col_events": "이벤트",
|
||||
"plugins_col_status": "상태",
|
||||
"plugins_col_actions": "작업",
|
||||
"plugins_loading": "플러그인 로딩 중…",
|
||||
"plugins_none": "설치된 플러그인이 없습니다.",
|
||||
"plugins_enabled": "활성화됨",
|
||||
"plugins_disabled_badge": "비활성화됨",
|
||||
"plugins_enable": "활성화",
|
||||
"plugins_disable": "비활성화",
|
||||
"plugins_delete": "삭제",
|
||||
"plugins_confirm_delete": "플러그인 \"{{name}}\"을(를) 삭제하시겠습니까? 서버에서 관련 파일이 제거됩니다.",
|
||||
"plugins_installing": "설치 중…",
|
||||
"plugins_installed": "{{name}}이(가) 설치되었습니다.",
|
||||
"plugins_install_missing_bundle": "플러그인 번들(.zip)을 선택하세요.",
|
||||
"plugins_details": "로그 및 세부 정보",
|
||||
"plugins_back": "플러그인으로 돌아가기",
|
||||
"plugins_retention_title": "로그 보관 기간",
|
||||
"plugins_retention_intro": "보관 기간을 초과했거나 크기 상한을 넘은 로테이션된 로그 조각은 예약된 일정에 따라 정리됩니다.",
|
||||
"plugins_retention_days": "보관 기간(일)",
|
||||
"plugins_retention_max_mb": "최대 로그 크기(MB)",
|
||||
"plugins_retention_save": "보관 설정 저장",
|
||||
"plugins_retention_saved": "보관 설정이 저장되었습니다.",
|
||||
"plugins_retention_invalid": "0 이상의 숫자를 입력하세요.",
|
||||
"plugins_logs_title": "로그",
|
||||
"plugins_logs_level_all": "모든 레벨",
|
||||
"plugins_logs_search": "메시지 검색…",
|
||||
"plugins_logs_live": "실시간",
|
||||
"plugins_logs_clear": "지우기",
|
||||
"plugins_logs_confirm_clear": "이 플러그인의 모든 로그를 지우시겠습니까?",
|
||||
"plugins_logs_none": "로그 항목이 없습니다.",
|
||||
"plugins_logs_col_time": "시간",
|
||||
"plugins_logs_col_level": "레벨",
|
||||
"plugins_logs_col_kind": "종류",
|
||||
"plugins_logs_col_invocation": "호출",
|
||||
"plugins_logs_col_message": "메시지",
|
||||
"plugins_logs_showing": "{{total}}개 중 {{from}}–{{to}} 표시",
|
||||
"auth": "인증",
|
||||
"available": "사용 가능",
|
||||
"confirm_delete_plugin": "플러그인 {{name}}을(를) 삭제하시겠습니까?",
|
||||
"disable": "비활성화",
|
||||
"email_auto": "비워두면 자동으로 생성됩니다",
|
||||
"enable": "활성화",
|
||||
"env_locked": "환경 변수로 설정됨",
|
||||
"last_login": "마지막 로그인",
|
||||
"logs_all": "모든 레벨",
|
||||
"logs_empty": "로그 항목이 없습니다.",
|
||||
"logs_invocation": "호출",
|
||||
"logs_kind": "종류",
|
||||
"logs_level": "레벨",
|
||||
"logs_live": "실시간",
|
||||
"logs_message": "메시지",
|
||||
"logs_search": "검색…",
|
||||
"logs_showing": "{{total}}개 중 {{from}}–{{to}} 표시",
|
||||
"logs_time": "시간",
|
||||
"mig_eta": "약 {{min}}분 남음",
|
||||
"mig_failed": "실패한 블롭 {{n}}개",
|
||||
"mig_start": "시작",
|
||||
"mig_verify": "무결성 확인",
|
||||
"mig_verify_mismatch": "크기 불일치 {{n}}건",
|
||||
"mig_verify_missing": "누락 {{n}}건",
|
||||
"mig_verify_summary": "{{checked}}개 확인됨, 데이터베이스 총 {{total}}개",
|
||||
"migration": "스토리지 마이그레이션",
|
||||
"new_password": "새 비밀번호",
|
||||
"no_plugins": "설치된 플러그인이 없습니다.",
|
||||
"oidc": "OIDC / SSO",
|
||||
"oidc_admin_groups": "관리자 그룹",
|
||||
"oidc_auth_endpoint": "인증 엔드포인트",
|
||||
"oidc_client_secret": "클라이언트 시크릿",
|
||||
"oidc_discover": "테스트 / 검색",
|
||||
"oidc_enabled": "OIDC 로그인 활성화",
|
||||
"oidc_provider_name": "제공자 이름",
|
||||
"oidc_secret_set": "클라이언트 시크릿이 이미 구성되어 있습니다.",
|
||||
"over_80": "할당량 80% 초과 사용자 {{n}}명",
|
||||
"over_quota": "할당량 초과 사용자 {{n}}명",
|
||||
"password_reset": "비밀번호 재설정",
|
||||
"plugin": "플러그인",
|
||||
"plugin_logs": "플러그인 로그",
|
||||
"plugins": "플러그인",
|
||||
"plugins_clear_logs": "로그 지우기",
|
||||
"plugins_install_hint": "플러그인 번들(.zip)을 업로드하세요.",
|
||||
"plugins_retention": "로그 보관 기간",
|
||||
"plugins_retention_max": "최대 크기(MB)",
|
||||
"plugins_upload": ".zip 업로드",
|
||||
"quota": "스토리지 사용량",
|
||||
"quota_for": "할당량 대상",
|
||||
"registration": "회원가입",
|
||||
"registration_disabled_warning": "공개 회원가입이 비활성화되어 있습니다. 관리자만 새 계정을 만들 수 있습니다.",
|
||||
"settings_saved_ok": "설정이 저장되었습니다.",
|
||||
"smtp": "이메일 (SMTP)",
|
||||
"smtp_from": "보내는 사람",
|
||||
"smtp_host": "호스트",
|
||||
"smtp_port": "포트",
|
||||
"smtp_status": "SMTP 상태",
|
||||
"smtp_to": "recipient@example.com",
|
||||
"storage_blobs": "블롭",
|
||||
"storage_current": "현재 백엔드",
|
||||
"storage_dedup": "중복 제거 비율",
|
||||
"storage_preset": "프리셋",
|
||||
"storage_size": "저장됨",
|
||||
"storage_test": "연결 테스트",
|
||||
"time_day_ago": "{{n}}일 전",
|
||||
"time_hour_ago": "{{n}}시간 전",
|
||||
"time_just_now": "방금",
|
||||
"unchanged": "현재 값을 유지하려면 비워두세요",
|
||||
"running": "실행 중…",
|
||||
"maintenance": "유지 관리",
|
||||
"maintenance_hint": "기존 파일을 다시 스캔하여 메타데이터를 채웁니다. 여러 번 실행해도 안전하며, 전체 라이브러리를 처리하므로 시간이 걸릴 수 있습니다.",
|
||||
"reextract_audio": "오디오 메타데이터 다시 추출",
|
||||
"reextract_photos": "사진 및 동영상 촬영 날짜 다시 추출",
|
||||
"reextract_done": "{{processed}}/{{total}} 처리됨 · 실패 {{failed}}건",
|
||||
"encryption": "암호화",
|
||||
"encryption_hint": "저장 데이터(blob) 암호화를 위한 AES-256 키를 생성한 뒤, 서버 환경 변수 OXICLOUD_STORAGE_ENCRYPTION_KEY에 설정하세요.",
|
||||
"gen_key": "키 생성",
|
||||
"gen_key_warning": "이 키를 안전하게 보관하세요. 분실 시 암호화된 데이터를 복구할 수 없습니다."
|
||||
},
|
||||
"profile": {
|
||||
"page_title": "프로필",
|
||||
@@ -914,12 +1174,20 @@
|
||||
"photo_save_failed": "Failed to save photo",
|
||||
"photo_no_file": "Please select a file first",
|
||||
"photo_managed_by_oidc": "Photo managed by your identity provider.",
|
||||
"password_mismatch": "비밀번호가 일치하지 않습니다"
|
||||
"password_mismatch": "비밀번호가 일치하지 않습니다",
|
||||
"app_pw_revoke": "앱 비밀번호 취소",
|
||||
"avatar": "아바타",
|
||||
"copied": "복사됨",
|
||||
"copy_failed": "복사할 수 없습니다",
|
||||
"language": "언어",
|
||||
"language_auto": "자동",
|
||||
"saved": "프로필이 저장되었습니다"
|
||||
},
|
||||
"upload": {
|
||||
"uploading": "업로드 중...",
|
||||
"files": "파일",
|
||||
"complete": "{{count}} / {{total}} 업로드됨"
|
||||
"complete": "{{count}} / {{total}} 업로드됨",
|
||||
"files_counter": "파일 {{completed}}/{{total}}"
|
||||
},
|
||||
"storage_quota_exceeded": "저장 공간 할당량 초과",
|
||||
"sharedwithme": {
|
||||
@@ -988,7 +1256,9 @@
|
||||
"virtual_internal_explanation": "이 서버의 모든 내부 사용자",
|
||||
"create": "그룹 생성",
|
||||
"empty": "아직 그룹이 없습니다.",
|
||||
"members": "구성원"
|
||||
"members": "구성원",
|
||||
"add_member_search": "추가할 사용자 또는 그룹 검색…",
|
||||
"nested": "그룹"
|
||||
},
|
||||
"myshares": {
|
||||
"copyLink": "링크 복사",
|
||||
@@ -999,11 +1269,18 @@
|
||||
"notifyRateLimited": "이 수신자에게 알림이 너무 많습니다 — 나중에 다시 시도하세요.",
|
||||
"removeAccess": "액세스 제거",
|
||||
"resendInvitation": "초대 이메일 다시 보내기",
|
||||
"publicLinks": "Public links"
|
||||
"publicLinks": "Public links",
|
||||
"editSharing": "공유 편집",
|
||||
"emptyStateDesc": "다른 사람과 공유한 항목이 여기에 표시됩니다",
|
||||
"emptyStateTitle": "아직 공유한 항목이 없습니다",
|
||||
"manageAccess": "접근 권한 관리",
|
||||
"notifySent": "알림이 전송되었습니다.",
|
||||
"passwordLinks": "비밀번호로 보호된 링크"
|
||||
},
|
||||
"sort": {
|
||||
"asc": "ascending",
|
||||
"desc": "descending"
|
||||
"desc": "descending",
|
||||
"direction": "정렬 방향"
|
||||
},
|
||||
"notif": {
|
||||
"errorTitle": "Error",
|
||||
@@ -1077,7 +1354,15 @@
|
||||
"category": {
|
||||
"audio": "오디오",
|
||||
"code": "코드",
|
||||
"text": "텍스트"
|
||||
"text": "텍스트",
|
||||
"archives": "압축 파일",
|
||||
"documents": "문서",
|
||||
"images": "이미지",
|
||||
"installers": "설치 프로그램",
|
||||
"markdown": "마크다운",
|
||||
"presentations": "프레젠테이션",
|
||||
"spreadsheets": "스프레드시트",
|
||||
"videos": "동영상"
|
||||
},
|
||||
"common": {
|
||||
"add": "추가",
|
||||
@@ -1099,35 +1384,147 @@
|
||||
"save": "저장",
|
||||
"search": "검색",
|
||||
"yes": "있음",
|
||||
"saving": "저장 중…"
|
||||
"saving": "저장 중…",
|
||||
"copied": "클립보드에 복사되었습니다",
|
||||
"copy_failed": "복사 실패",
|
||||
"empty": "아직 아무것도 없습니다.",
|
||||
"error": "알 수 없는 오류",
|
||||
"favorite": "즐겨찾기",
|
||||
"ok": "확인",
|
||||
"optional": "선택 사항",
|
||||
"retry": "다시 시도",
|
||||
"select": "선택",
|
||||
"select_all": "전체 선택",
|
||||
"dismiss": "닫기"
|
||||
},
|
||||
"device": {
|
||||
"continue": "계속",
|
||||
"unknown": "알 수 없음"
|
||||
"unknown": "알 수 없음",
|
||||
"approve": "승인",
|
||||
"approved": "기기가 승인되었습니다. 원래 기기로 돌아가셔도 됩니다.",
|
||||
"client": "애플리케이션",
|
||||
"denied": "기기 접근이 거부되었습니다.",
|
||||
"deny": "거부",
|
||||
"enter_code": "기기에 표시된 코드를 입력하세요",
|
||||
"lookup_failed": "코드 확인에 실패했습니다. 다시 시도해 주세요.",
|
||||
"not_found": "코드를 찾을 수 없거나 만료되었습니다. 확인 후 다시 시도해 주세요.",
|
||||
"scopes": "접근 권한",
|
||||
"title": "기기 인증",
|
||||
"unauthorized": "기기를 승인하려면 로그인이 필요합니다. 먼저 로그인해 주세요."
|
||||
},
|
||||
"expiryBucket": {
|
||||
"expired": "만료됨",
|
||||
"noExpiry": "만료 없음",
|
||||
"today": "오늘",
|
||||
"tomorrow": "내일"
|
||||
"tomorrow": "내일",
|
||||
"later": "이후",
|
||||
"month": "30일 이내",
|
||||
"week": "7일 이내"
|
||||
},
|
||||
"nextcloud": {
|
||||
"error_title": "오류",
|
||||
"sign_in_with": "{{provider}}(으)로 로그인"
|
||||
"sign_in_with": "{{provider}}(으)로 로그인",
|
||||
"close_window": "창 닫기",
|
||||
"error_expired_body": "세션이 만료되었습니다. 다시 시도해 주세요.",
|
||||
"error_expired_title": "세션 만료",
|
||||
"error_generic_body": "예기치 않은 오류가 발생했습니다. 다시 시도해 주세요.",
|
||||
"error_invalid_body": "사용자 이름 또는 비밀번호가 올바르지 않습니다. 자격 증명을 확인한 후 다시 시도해 주세요.",
|
||||
"error_invalid_title": "로그인 실패",
|
||||
"error_notfound_body": "요청한 페이지를 찾을 수 없습니다.",
|
||||
"error_notfound_title": "찾을 수 없음",
|
||||
"grant": "접근 권한 부여",
|
||||
"grant_subtitle": "Nextcloud 클라이언트가 회원님의 계정에 대한 접근 권한을 요청하고 있습니다.",
|
||||
"grant_title": "접근 권한 부여",
|
||||
"invalid_token": "세션 토큰이 유효하지 않습니다.",
|
||||
"success_body": "이제 애플리케이션으로 돌아가셔도 됩니다 — 연결이 완료되었습니다.",
|
||||
"success_title": "접근 권한이 부여되었습니다"
|
||||
},
|
||||
"search": {
|
||||
"size_label": "크기",
|
||||
"title": "검색",
|
||||
"type": {
|
||||
"audio": "오디오"
|
||||
"audio": "오디오",
|
||||
"all": "모든 유형",
|
||||
"archive": "압축 파일",
|
||||
"document": "문서",
|
||||
"image": "이미지",
|
||||
"video": "동영상"
|
||||
},
|
||||
"type_label": "유형"
|
||||
"type_label": "유형",
|
||||
"clear_filters": "필터 지우기",
|
||||
"date": {
|
||||
"all": "전체 기간",
|
||||
"day": "지난 24시간",
|
||||
"month": "지난 한 달",
|
||||
"week": "지난 한 주",
|
||||
"year": "지난 한 해"
|
||||
},
|
||||
"date_label": "날짜",
|
||||
"everywhere": "모든 위치",
|
||||
"no_results": "검색 결과가 없습니다",
|
||||
"prompt": "위 검색창에 검색어를 입력하세요.",
|
||||
"results_for": "\"{{q}}\"에 대한 검색 결과",
|
||||
"scope": "범위",
|
||||
"searching_for": "\"{{q}}\" 검색 중…",
|
||||
"see_all": "모든 결과 보기",
|
||||
"size": {
|
||||
"all": "전체 크기",
|
||||
"large": "100MB 초과",
|
||||
"medium": "1–100MB",
|
||||
"small": "1MB 미만"
|
||||
},
|
||||
"sort": {
|
||||
"largest": "큰 순",
|
||||
"name_asc": "이름 오름차순",
|
||||
"name_desc": "이름 내림차순",
|
||||
"newest": "최신순",
|
||||
"oldest": "오래된 순",
|
||||
"relevance": "관련도순",
|
||||
"smallest": "작은 순"
|
||||
},
|
||||
"sort_by": "정렬 기준",
|
||||
"this_folder": "이 폴더"
|
||||
},
|
||||
"sizeBucket": {
|
||||
"folders": "폴더"
|
||||
"folders": "폴더",
|
||||
"empty": "비어 있음 (0B)",
|
||||
"huge": "5GB 초과",
|
||||
"large": "1–5GB",
|
||||
"medium": "100MB–1GB",
|
||||
"small": "1–100MB",
|
||||
"tiny": "1MB 미만"
|
||||
},
|
||||
"view": {
|
||||
"grid": "그리드 보기",
|
||||
"list": "목록 보기"
|
||||
"list": "목록 보기",
|
||||
"label": "보기 옵션"
|
||||
},
|
||||
"people": {
|
||||
"unnamed": "이름 없음",
|
||||
"empty": "아직 인물이 없습니다",
|
||||
"disabled": "얼굴 인식이 비활성화되어 있습니다",
|
||||
"rename_title": "이 인물의 이름 지정",
|
||||
"name_label": "이름",
|
||||
"back": "뒤로"
|
||||
},
|
||||
"about": {
|
||||
"description": "OxiCloud — 빠르고 셀프 호스팅 가능한 파일 저장 및 동기화 서버입니다."
|
||||
},
|
||||
"cmdk": {
|
||||
"no_results": "일치하는 명령이 없습니다",
|
||||
"placeholder": "명령을 입력하거나 검색하세요…",
|
||||
"title": "명령 팔레트",
|
||||
"toggle_theme": "테마 전환"
|
||||
},
|
||||
"errors_loadFailed": "항목을 불러오지 못했습니다",
|
||||
"settings": {
|
||||
"language": "언어"
|
||||
},
|
||||
"shared_with_me": {
|
||||
"empty": "아직 공유받은 항목이 없습니다.",
|
||||
"from": "{{who}}님이 공유함"
|
||||
},
|
||||
"sortdir": {
|
||||
"title": "정렬 방향"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Self-unregistering stub — replaces the legacy vanilla-frontend
|
||||
// service worker that shipped with OxiCloud ≤ 0.8.0.
|
||||
//
|
||||
// Browsers that installed the old SW keep it registered across upgrades
|
||||
// and it intercepts every navigation, serving a stale index.html from its
|
||||
// `oxicloud-cache*` Cache Storage. The stale shell's meta-CSP predates
|
||||
// the SvelteKit build's inline-script hashes, so hydration is blocked by
|
||||
// CSP and the app hangs on the spinner. Symptom: infinite loader on
|
||||
// fresh visits, only cleared by a hard refresh. Ref: issue #560.
|
||||
//
|
||||
// SvelteKit itself does NOT register a service worker (no `src/service-worker`
|
||||
// module exists) — this file exists solely to shepherd upgraders off the
|
||||
// legacy SW. Browsers on a clean install fetch it, install it, immediately
|
||||
// unregister it, and the URL stays a 200 for the next visitor with the
|
||||
// same stale-SW problem.
|
||||
//
|
||||
// The install/activate handlers race the browser's normal SW lifecycle;
|
||||
// `skipWaiting` + `clients.claim` fast-forward through the "waiting" and
|
||||
// "activating" states so the tab that triggered the update gets reloaded
|
||||
// with a controller-less document (no SW intercepting fetches) within
|
||||
// the same page lifetime.
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(self.skipWaiting());
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
// 1. Drop every Cache Storage bucket the legacy SW may have
|
||||
// populated. We match the `oxicloud-cache*` prefix the old
|
||||
// SW used, plus a defensive wildcard clear if that prefix
|
||||
// was ever changed in a fork/downstream build.
|
||||
if (self.caches) {
|
||||
const keys = await self.caches.keys();
|
||||
await Promise.all(keys.map((k) => self.caches.delete(k)));
|
||||
}
|
||||
|
||||
// 2. Unregister this SW. After this the browser will not
|
||||
// invoke `fetch` handlers from this registration on future
|
||||
// navigations.
|
||||
await self.registration.unregister();
|
||||
|
||||
// 3. Take control of open clients so we can reload them into
|
||||
// a controller-less state (fresh HTML, matching CSP).
|
||||
await self.clients.claim();
|
||||
const clients = await self.clients.matchAll({ type: 'window' });
|
||||
for (const client of clients) {
|
||||
// `navigate` beats `location.reload()`-in-postMessage because
|
||||
// it works even if the page's JS is CSP-blocked (the case
|
||||
// we're fixing). Same URL → same-tab reload without controller.
|
||||
try {
|
||||
await client.navigate(client.url);
|
||||
} catch {
|
||||
/* opaque redirect / cross-origin — nothing we can do */
|
||||
}
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
|
||||
// Explicit pass-through fetch handler. Without one, browsers may treat
|
||||
// the SW as controlling — with an empty handler they short-circuit to
|
||||
// the network. Belt-and-suspenders: we've already unregistered above,
|
||||
// but a race between activation and an in-flight navigation could still
|
||||
// hit this handler.
|
||||
self.addEventListener('fetch', () => {
|
||||
/* fall through to network */
|
||||
});
|
||||
@@ -3,6 +3,13 @@ import { defineConfig } from 'vitest/config';
|
||||
import istanbul from 'vite-plugin-istanbul';
|
||||
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
|
||||
// traffic here so cookies, CSRF, and the auth-refresh flow are same-origin.
|
||||
const BACKEND = process.env.OXICLOUD_BACKEND ?? 'http://localhost:8086';
|
||||
|
||||
@@ -72,6 +72,46 @@ impl std::fmt::Display for QualifiedName {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether PROPPATCH must refuse to set/remove this property as a dead
|
||||
/// property (RFC 4918 §9.2 — server MAY reject a PROPPATCH attempt on a
|
||||
/// live property; DeadPropertyStore has no business holding a value that
|
||||
/// PROPFIND / REPORT already emit from live server state).
|
||||
pub fn is_protected_property(qn: &QualifiedName) -> bool {
|
||||
match qn.namespace.as_str() {
|
||||
// RFC 4918 §15 — the DAV: namespace is server-owned in its
|
||||
// entirety. Any PROPPATCH into it either forges a live
|
||||
// property (dual-emission) or accumulates unread garbage
|
||||
// (silent litter).
|
||||
"DAV:" => true,
|
||||
|
||||
// Every name below appears verbatim in write_folder_response
|
||||
// / write_file_response in the NC handler. Adding a new
|
||||
// live emitter → add its name here.
|
||||
"http://owncloud.org/ns" => matches!(
|
||||
qn.name.as_str(),
|
||||
"favorite"
|
||||
| "fileid"
|
||||
| "id"
|
||||
| "owner-id"
|
||||
| "owner-display-name"
|
||||
| "permissions"
|
||||
| "share-types"
|
||||
| "size"
|
||||
),
|
||||
|
||||
"http://nextcloud.org/ns" => matches!(
|
||||
qn.name.as_str(),
|
||||
"has-preview" | "is-encrypted" | "mount-type" | "creation_time" | "upload_time"
|
||||
),
|
||||
|
||||
"http://open-collaboration-services.org/ns" => {
|
||||
matches!(qn.name.as_str(), "share-permissions")
|
||||
}
|
||||
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// PROPFIND request type
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum PropFindType {
|
||||
@@ -470,7 +510,12 @@ impl WebDavAdapter {
|
||||
///
|
||||
/// Written AFTER the live-property propstats inside a `<D:response>`.
|
||||
/// Only emitted when `dead_props` is non-empty.
|
||||
fn write_dead_props_propstat<W: Write>(
|
||||
///
|
||||
/// `pub(crate)` so the NextCloud-compatible handler
|
||||
/// (`interfaces::nextcloud::webdav_handler`) can append the same
|
||||
/// dead-property block to its own bespoke PROPFIND writers instead
|
||||
/// of duplicating this XML shape.
|
||||
pub(crate) fn write_dead_props_propstat<W: Write>(
|
||||
xml_writer: &mut Writer<W>,
|
||||
dead_props: &[(QualifiedName, Option<String>)],
|
||||
) -> Result<()> {
|
||||
|
||||
@@ -150,6 +150,26 @@ pub trait AuthorizationEngine: Send + Sync + 'static {
|
||||
expires_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
) -> Result<(), DomainError>;
|
||||
|
||||
/// Delete every row from `storage.role_grants` whose `expires_at` is
|
||||
/// more than `grace_days` in the past. Returns the count of rows
|
||||
/// removed.
|
||||
///
|
||||
/// The engine's `check` / `list_grants_*` paths already ignore
|
||||
/// expired rows (they filter on `expires_at > NOW()` in-query), so
|
||||
/// this is pure garbage collection — no live authorization decision
|
||||
/// changes. The grace window preserves the audit / support answer
|
||||
/// to "what happened to my access?" for a couple of weeks past
|
||||
/// expiration.
|
||||
///
|
||||
/// Grace of `0` means "delete every row whose `expires_at` is in
|
||||
/// the past, right now" — used by the admin `?force=true` trigger
|
||||
/// endpoint to enable Hurl regression testing without waiting the
|
||||
/// configured grace out.
|
||||
///
|
||||
/// Rows with `expires_at IS NULL` (permanent grants) are never
|
||||
/// touched.
|
||||
async fn purge_expired_grants(&self, grace_days: u32) -> Result<u64, DomainError>;
|
||||
|
||||
/// Revoke a single role grant by its UUID. Idempotent — returns `Ok(())`
|
||||
/// whether or not the row existed. The id comes from a prior listing
|
||||
/// or `find_grant_full_by_id` lookup.
|
||||
|
||||
@@ -931,6 +931,50 @@ pub struct FeaturesConfig {
|
||||
///
|
||||
/// Env: `OXICLOUD_WEBDAV_DRIVE_LISTING_PREFIX`.
|
||||
pub webdav_drive_listing_prefix: String,
|
||||
|
||||
/// Background purge of expired `storage.role_grants` rows.
|
||||
///
|
||||
/// The AuthZ engine already filters expired grants out of every
|
||||
/// permission check at read time (`expires_at IS NULL OR
|
||||
/// expires_at > NOW()`), so leaving the rows in place is a
|
||||
/// hygiene issue — not a security one. This purge deletes rows
|
||||
/// whose `expires_at` is more than [`GrantCleanupConfig::grace_days`]
|
||||
/// in the past, preserving the audit / support answer to
|
||||
/// "what happened to my access?" for the grace window.
|
||||
///
|
||||
/// Enabled by default: expired-auth-row cleanup is a
|
||||
/// security-hygiene default, not opt-in.
|
||||
pub grant_cleanup: GrantCleanupConfig,
|
||||
}
|
||||
|
||||
/// Config for the daily expired-grant purge (see
|
||||
/// [`FeaturesConfig::grant_cleanup`]).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GrantCleanupConfig {
|
||||
/// Master switch. Env: `OXICLOUD_GRANT_CLEANUP_ENABLED`
|
||||
/// (default `true`).
|
||||
pub enabled: bool,
|
||||
/// Days past a grant's `expires_at` before the row is eligible
|
||||
/// for deletion. Env: `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS`
|
||||
/// (default `15`).
|
||||
///
|
||||
/// The recommendation is `> 15` — enough to answer
|
||||
/// support/audit questions about recently-lapsed grants without
|
||||
/// keeping dead rows forever.
|
||||
pub grace_days: u32,
|
||||
/// How often the daemon fires, in hours. Env:
|
||||
/// `OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS` (default `24`).
|
||||
pub interval_hours: u64,
|
||||
}
|
||||
|
||||
impl Default for GrantCleanupConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
grace_days: 15,
|
||||
interval_hours: 24,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FeaturesConfig {
|
||||
@@ -954,6 +998,7 @@ impl Default for FeaturesConfig {
|
||||
// maps to the caller's default drive; drive listing is
|
||||
// reachable at `/webdav/@drive/`.
|
||||
webdav_drive_listing_prefix: "@drive".to_string(),
|
||||
grant_cleanup: GrantCleanupConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1525,6 +1570,25 @@ impl AppConfig {
|
||||
config.features.enable_admin_internal_endpoints = val;
|
||||
}
|
||||
|
||||
// Grant-cleanup daemon. Purges rows from `storage.role_grants`
|
||||
// whose `expires_at` is more than `grace_days` in the past.
|
||||
// See `GrantCleanupConfig` for defaults + rationale.
|
||||
if let Ok(v) = env::var("OXICLOUD_GRANT_CLEANUP_ENABLED").map(|v| v.parse::<bool>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.features.grant_cleanup.enabled = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_GRANT_CLEANUP_GRACE_DAYS").map(|v| v.parse::<u32>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.features.grant_cleanup.grace_days = val;
|
||||
}
|
||||
if let Ok(v) = env::var("OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS").map(|v| v.parse::<u64>())
|
||||
&& let Ok(val) = v
|
||||
{
|
||||
config.features.grant_cleanup.interval_hours = val.max(1);
|
||||
}
|
||||
|
||||
// Native WebDAV drive-picker path segment. Sanitised by
|
||||
// stripping leading/trailing slashes so operators can pass
|
||||
// `/drives/` or `drives` interchangeably; empty string means
|
||||
|
||||
@@ -1293,6 +1293,9 @@ impl AppServiceFactory {
|
||||
let places_service: Option<Arc<PlacesService>>;
|
||||
let people_service: Option<Arc<PeopleService>>;
|
||||
let storage_usage_service: Option<Arc<StorageUsageService>>;
|
||||
let grant_cleanup_service: Option<
|
||||
Arc<crate::infrastructure::services::grant_cleanup_service::GrantCleanupService>,
|
||||
>;
|
||||
let mut auth_services: Option<crate::common::di::AuthServices> = None;
|
||||
let mut nextcloud_services: Option<NextcloudServices> = None;
|
||||
// Lifted out of the database-services block so PR 9's invite
|
||||
@@ -1336,6 +1339,25 @@ impl AppServiceFactory {
|
||||
|
||||
self.start_content_index_job(&maintenance_pool, &core, content_index);
|
||||
|
||||
grant_cleanup_service = if core.config.features.grant_cleanup.enabled {
|
||||
let svc = Arc::new(
|
||||
crate::infrastructure::services::grant_cleanup_service::GrantCleanupService::new(
|
||||
authorization.clone(),
|
||||
core.config.features.grant_cleanup.grace_days,
|
||||
core.config.features.grant_cleanup.interval_hours,
|
||||
),
|
||||
);
|
||||
// First tick fires immediately inside start_cleanup_job —
|
||||
// matches the trash/storage-usage daemon shape.
|
||||
svc.clone().start_cleanup_job().await;
|
||||
Some(svc)
|
||||
} else {
|
||||
tracing::info!(
|
||||
"Grant-cleanup daemon disabled by OXICLOUD_GRANT_CLEANUP_ENABLED=false"
|
||||
);
|
||||
None
|
||||
};
|
||||
|
||||
// User-lifecycle dispatcher. Hook order is registration order;
|
||||
// document dependencies inline if/when any arise. Today:
|
||||
// 1. AuditLifecycleHook — fires first so the
|
||||
@@ -1560,6 +1582,7 @@ impl AppServiceFactory {
|
||||
places_service,
|
||||
people_service,
|
||||
storage_usage_service,
|
||||
grant_cleanup_service,
|
||||
calendar_service: None,
|
||||
calendar_use_case: None,
|
||||
addressbook_use_case: None,
|
||||
@@ -2032,6 +2055,14 @@ pub struct AppState {
|
||||
pub places_service: Option<Arc<PlacesService>>,
|
||||
pub people_service: Option<Arc<PeopleService>>,
|
||||
pub storage_usage_service: Option<Arc<StorageUsageService>>,
|
||||
/// Handle to the background daemon that purges expired
|
||||
/// `storage.role_grants` rows. `None` when the daemon is disabled
|
||||
/// via `OXICLOUD_GRANT_CLEANUP_ENABLED=false`. The admin
|
||||
/// `POST /api/admin/internal/trigger-grant-cleanup` handler uses
|
||||
/// this to invoke the purge on demand (test-only).
|
||||
pub grant_cleanup_service: Option<
|
||||
Arc<crate::infrastructure::services::grant_cleanup_service::GrantCleanupService>,
|
||||
>,
|
||||
pub calendar_service: Option<Arc<CalendarService>>,
|
||||
pub calendar_use_case: Option<Arc<CalendarService>>,
|
||||
pub addressbook_use_case: Option<Arc<ContactService>>,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Background daemon that purges expired `storage.role_grants` rows.
|
||||
//!
|
||||
//! The AuthZ engine already filters expired grants out of every
|
||||
//! permission check at read time (`expires_at IS NULL OR
|
||||
//! expires_at > NOW()` on every `check` / `list_grants_*` path in
|
||||
//! `PgAclEngine`), so expired rows never leak permission. They just
|
||||
//! accumulate. This daemon garbage-collects them once per
|
||||
//! [`GrantCleanupService::interval_hours`], with a grace window past
|
||||
//! `expires_at` that preserves the audit / support answer to "what
|
||||
//! happened to my access?" for a few weeks.
|
||||
//!
|
||||
//! Shape mirrors [`TrashCleanupService`] verbatim (fire-and-forget
|
||||
//! `tokio::spawn`, `tokio::time::interval`, first-tick-immediate). The
|
||||
//! authoritative pattern for background daemons in this codebase; see
|
||||
//! the plan doc `docs/plan/` (deferred future work: fold all daemons
|
||||
//! into a central `JobRegistry` that plugins can also register into).
|
||||
//!
|
||||
//! [`TrashCleanupService`]: crate::infrastructure::services::trash_cleanup_service::TrashCleanupService
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::time;
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
||||
|
||||
/// Daemon that periodically deletes expired grants.
|
||||
///
|
||||
/// Owns an `Arc<PgAclEngine>` (not a `dyn AuthorizationEngine`) to avoid
|
||||
/// the wrapper allocation on every SQL call — the daemon is the sole
|
||||
/// caller of `purge_expired_grants` outside of the admin trigger
|
||||
/// endpoint, both statically dispatched.
|
||||
pub struct GrantCleanupService {
|
||||
authz: Arc<PgAclEngine>,
|
||||
grace_days: u32,
|
||||
interval_hours: u64,
|
||||
}
|
||||
|
||||
impl GrantCleanupService {
|
||||
pub fn new(authz: Arc<PgAclEngine>, grace_days: u32, interval_hours: u64) -> Self {
|
||||
Self {
|
||||
authz,
|
||||
grace_days,
|
||||
// Minimum 1 hour — matches TrashCleanupService's clamp so
|
||||
// a mis-set `0` doesn't spin a hot loop.
|
||||
interval_hours: interval_hours.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
/// Grace period the daemon uses on its scheduled ticks. Exposed
|
||||
/// for the admin trigger's default-response field.
|
||||
pub fn grace_days(&self) -> u32 {
|
||||
self.grace_days
|
||||
}
|
||||
|
||||
/// Fire-and-forget the periodic purge. Never joins; killed
|
||||
/// implicitly at `tokio::runtime::shutdown`.
|
||||
pub async fn start_cleanup_job(self: Arc<Self>) {
|
||||
let interval_hours = self.interval_hours;
|
||||
let grace_days = self.grace_days;
|
||||
info!(
|
||||
"Starting grant-cleanup daemon: every {}h, grace = {}d",
|
||||
interval_hours, grace_days
|
||||
);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut interval = time::interval(Duration::from_secs(interval_hours * 60 * 60));
|
||||
// First tick fires immediately — matches TrashCleanupService.
|
||||
// Any accumulated backlog at boot gets flushed straight away.
|
||||
loop {
|
||||
interval.tick().await;
|
||||
self.run_once().await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// One scheduled pass. Also called by the admin trigger endpoint
|
||||
/// (via a shared `Arc<GrantCleanupService>` on `AppState`).
|
||||
///
|
||||
/// `grace_override`:
|
||||
/// - `None` → use the configured grace (`self.grace_days`).
|
||||
/// - `Some(n)` → override with `n`. The admin `?force=true` trigger
|
||||
/// passes `Some(0)` so Hurl regressions can hit expired grants
|
||||
/// without waiting the configured grace out.
|
||||
pub async fn purge(&self, grace_override: Option<u32>) -> u64 {
|
||||
let grace = grace_override.unwrap_or(self.grace_days);
|
||||
let start = Instant::now();
|
||||
match self.authz.purge_expired_grants(grace).await {
|
||||
Ok(count) => {
|
||||
// Audit-channel logging: bulk deletion of authorization
|
||||
// rows is security-relevant enough to keep it in the
|
||||
// audit stream even when the count is zero (proves the
|
||||
// daemon is reachable).
|
||||
info!(
|
||||
target: "audit",
|
||||
event = "grant_cleanup.purged",
|
||||
count = count,
|
||||
grace_days = grace,
|
||||
elapsed_ms = start.elapsed().as_millis() as u64,
|
||||
"👮🏻♂️ Purged {} expired grant(s) older than {} days",
|
||||
count,
|
||||
grace,
|
||||
);
|
||||
count
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
target: "audit",
|
||||
event = "grant_cleanup.failed",
|
||||
grace_days = grace,
|
||||
error = %e,
|
||||
"Grant cleanup failed"
|
||||
);
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience for the scheduled loop.
|
||||
async fn run_once(&self) {
|
||||
let _ = self.purge(None).await;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod face_indexing_service;
|
||||
pub mod ffmpeg_video_frame_service;
|
||||
pub mod file_content_cache;
|
||||
pub mod file_system_i18n_service;
|
||||
pub mod grant_cleanup_service;
|
||||
pub mod image_transcode_service;
|
||||
pub mod jwt_service;
|
||||
pub mod local_blob_backend;
|
||||
|
||||
@@ -2075,6 +2075,27 @@ impl AuthorizationEngine for PgAclEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn purge_expired_grants(&self, grace_days: u32) -> Result<u64, DomainError> {
|
||||
// Uses the partial index `idx_role_grants_expires_at` (migration
|
||||
// 20260730000000), which covers `WHERE expires_at IS NOT NULL`
|
||||
// — so this DELETE only touches indexed rows even when the
|
||||
// `role_grants` table has tens of millions of permanent grants.
|
||||
//
|
||||
// Grace days is bound as bigint and multiplied into an
|
||||
// interval — parameterised, no injection surface. u32 → i64
|
||||
// is loss-free.
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM storage.role_grants \
|
||||
WHERE expires_at IS NOT NULL \
|
||||
AND expires_at < NOW() - ($1::bigint * INTERVAL '1 day')",
|
||||
)
|
||||
.bind(grace_days as i64)
|
||||
.execute(self.pool.as_ref())
|
||||
.await
|
||||
.map_err(|e| DomainError::internal_error("PgAcl", format!("purge_expired_grants: {e}")))?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
async fn revoke(&self, grant_id: Uuid) -> Result<(), DomainError> {
|
||||
sqlx::query("DELETE FROM storage.role_grants WHERE id = $1")
|
||||
.bind(grant_id)
|
||||
|
||||
@@ -103,6 +103,10 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
|
||||
// deployments don't need a different route table.
|
||||
.route("/internal/trigger-sweep", post(internal_trigger_sweep))
|
||||
.route("/internal/trigger-gc", post(internal_trigger_gc))
|
||||
.route(
|
||||
"/internal/trigger-grant-cleanup",
|
||||
post(internal_trigger_grant_cleanup),
|
||||
)
|
||||
// Drives — admin-wide view (distinct from `/api/drives` which
|
||||
// is filtered to the caller's role grants).
|
||||
.route("/drives", get(list_all_drives))
|
||||
@@ -2160,3 +2164,93 @@ pub async fn internal_trigger_gc(
|
||||
Err(e) => AppError::internal_error(format!("gc failed: {e}")).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Query parameters for `POST /api/admin/internal/trigger-grant-cleanup`.
|
||||
///
|
||||
/// `force=true` sets the grace window to `0` for this call — deletes
|
||||
/// every row whose `expires_at` is in the past, right now. Enables
|
||||
/// Hurl regressions to plant a past-dated grant and immediately
|
||||
/// observe it purged, without waiting the configured
|
||||
/// `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` out.
|
||||
///
|
||||
/// Without `force`, the daemon's configured grace applies — the same
|
||||
/// SQL the daily loop runs.
|
||||
#[derive(Debug, serde::Deserialize, Default)]
|
||||
pub struct InternalTriggerGrantCleanupQuery {
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
/// `POST /api/admin/internal/trigger-grant-cleanup` — run the expired-
|
||||
/// grant purge synchronously.
|
||||
///
|
||||
/// Test-only. Deletes rows from `storage.role_grants` whose
|
||||
/// `expires_at` is more than `grace_days` in the past (or immediately,
|
||||
/// with `?force=true`). Same SQL as the periodic `GrantCleanupService`
|
||||
/// daemon — exposed under an admin route so Hurl can wait for it
|
||||
/// deterministically.
|
||||
///
|
||||
/// Response fields:
|
||||
/// `grants_deleted` — count of rows removed by this invocation
|
||||
/// `grace_days` — the grace window that was applied (0 when
|
||||
/// `?force=true`, otherwise the config value)
|
||||
/// `forced` — echoes the query param
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/admin/internal/trigger-grant-cleanup",
|
||||
params(("force" = Option<bool>, Query, description = "Force grace = 0 for this run (test-only)")),
|
||||
responses(
|
||||
(status = 200, description = "Purge ran"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 403, description = "Admin required"),
|
||||
(status = 404, description = "Endpoint disabled (set OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true)"),
|
||||
(status = 503, description = "Grant-cleanup daemon disabled (OXICLOUD_GRANT_CLEANUP_ENABLED=false)"),
|
||||
),
|
||||
security(("bearerAuth" = [])),
|
||||
tag = "admin"
|
||||
)]
|
||||
pub async fn internal_trigger_grant_cleanup(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Query(query): Query<InternalTriggerGrantCleanupQuery>,
|
||||
) -> axum::response::Response {
|
||||
use axum::response::IntoResponse;
|
||||
if !state.core.config.features.enable_admin_internal_endpoints {
|
||||
return internal_endpoints_disabled();
|
||||
}
|
||||
if let Err(e) = admin_guard(&state, &headers).await {
|
||||
return e.into_response();
|
||||
}
|
||||
// Daemon may be disabled by config even when the internal-endpoint
|
||||
// gate is on. Return 503 (rather than 404 or 500) so integration
|
||||
// tests can distinguish "surface not exposed" from "surface
|
||||
// exposed but backing service off".
|
||||
let svc = match state.grant_cleanup_service.as_ref() {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(serde_json::json!({
|
||||
"error": "grant_cleanup_service not available (disabled by OXICLOUD_GRANT_CLEANUP_ENABLED=false)",
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
// `force=true` collapses the grace window to zero for this run
|
||||
// only — the daemon's configured grace is untouched. Mirrors the
|
||||
// `trigger-gc?force=true` shape.
|
||||
let grace_override = if query.force { Some(0) } else { None };
|
||||
let grants_deleted = svc.purge(grace_override).await;
|
||||
let grace_days = grace_override.unwrap_or_else(|| svc.grace_days());
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(serde_json::json!({
|
||||
"ok": true,
|
||||
"grants_deleted": grants_deleted,
|
||||
"grace_days": grace_days,
|
||||
"forced": query.force,
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ use quick_xml::Writer;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::adapters::webdav_adapter::{
|
||||
LockInfo, PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter,
|
||||
LockInfo, PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, is_protected_property,
|
||||
};
|
||||
use crate::application::dtos::file_dto::FileDto;
|
||||
use crate::application::dtos::folder_dto::FolderDto;
|
||||
@@ -987,6 +987,12 @@ async fn handle_proppatch(
|
||||
let mut results: Vec<(&QualifiedName, bool)> = Vec::new();
|
||||
for op in &ops {
|
||||
match op {
|
||||
PropPatchOp::Set(pv) if is_protected_property(&pv.name) => {
|
||||
results.push((&pv.name, false));
|
||||
}
|
||||
PropPatchOp::Remove(name) if is_protected_property(name) => {
|
||||
results.push((name, false));
|
||||
}
|
||||
PropPatchOp::Set(pv) => {
|
||||
dead_props
|
||||
.set(resource_ref, pv.name.clone(), pv.value.clone())
|
||||
@@ -1341,7 +1347,11 @@ async fn resolve_or_legacy(
|
||||
/// the dead-prop lookup is broken; surfacing a 500 here would mask the
|
||||
/// resource entirely from sync clients. The legacy path-keyed lookup
|
||||
/// behaved the same way (`.unwrap_or_default()`); we preserve it.
|
||||
async fn file_dead_props(
|
||||
///
|
||||
/// `pub(crate)` — also reused by the NextCloud-compatible PROPFIND
|
||||
/// handler (`interfaces::nextcloud::webdav_handler`), which needs the
|
||||
/// same lenient fetch for its own response writers.
|
||||
pub(crate) async fn file_dead_props(
|
||||
state: &Arc<AppState>,
|
||||
file: &FileDto,
|
||||
) -> Vec<(QualifiedName, Option<String>)> {
|
||||
@@ -1356,8 +1366,9 @@ async fn file_dead_props(
|
||||
}
|
||||
|
||||
/// Same shape as `file_dead_props` but for folder rows. Used by the
|
||||
/// streaming PROPFIND walker.
|
||||
async fn folder_dead_props(
|
||||
/// streaming PROPFIND walker (and, via `pub(crate)`, by the NextCloud
|
||||
/// handler's own streaming walker).
|
||||
pub(crate) async fn folder_dead_props(
|
||||
store: &DeadPropertyStore,
|
||||
folder: &FolderDto,
|
||||
) -> Vec<(QualifiedName, Option<String>)> {
|
||||
@@ -1373,7 +1384,7 @@ async fn folder_dead_props(
|
||||
/// File-leaf variant for the streaming walker (takes a `&DeadPropertyStore`
|
||||
/// rather than the full `&Arc<AppState>` so it can be called from inside
|
||||
/// the async-stream future without cloning state).
|
||||
async fn streamed_file_dead_props(
|
||||
pub(crate) async fn streamed_file_dead_props(
|
||||
store: &DeadPropertyStore,
|
||||
file: &FileDto,
|
||||
) -> Vec<(QualifiedName, Option<String>)> {
|
||||
|
||||
@@ -225,6 +225,13 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
|
||||
handlers::admin_handler::complete_migration,
|
||||
handlers::admin_handler::verify_migration,
|
||||
handlers::admin_handler::generate_encryption_key,
|
||||
// Admin internal-trigger handlers — gated by
|
||||
// OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS (Off by default in
|
||||
// prod; on for the Hurl suite). Documented in OpenAPI so
|
||||
// integrators writing test harnesses can discover the surface.
|
||||
handlers::admin_handler::internal_trigger_sweep,
|
||||
handlers::admin_handler::internal_trigger_gc,
|
||||
handlers::admin_handler::internal_trigger_grant_cleanup,
|
||||
// Grant / ReBAC handlers (free functions)
|
||||
handlers::grant_handler::create_grant,
|
||||
handlers::grant_handler::revoke_grant,
|
||||
|
||||
@@ -332,11 +332,30 @@ async fn complete_flow(
|
||||
base_url = %base_url,
|
||||
"Login Flow v2: flow completed successfully"
|
||||
);
|
||||
let nc_url = format!(
|
||||
"nc://login/server:{}&user:{}&password:{}",
|
||||
base_url, login_name, app_password
|
||||
);
|
||||
axum::response::Redirect::to(&nc_url).into_response()
|
||||
// Redirect the browser to a visible success page. NC clients
|
||||
// that use the LFv2 poll endpoint (the standard pattern) have
|
||||
// already received the credentials server-to-server through
|
||||
// `login_flow.complete()` above — they don't need any browser
|
||||
// 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 {
|
||||
tracing::error!(
|
||||
user = %user.username,
|
||||
|
||||
@@ -21,6 +21,7 @@ use crate::application::ports::folder_ports::FolderUseCase;
|
||||
use crate::application::ports::inbound::SearchUseCase;
|
||||
use crate::common::di::AppState;
|
||||
use crate::domain::entities::file::File;
|
||||
use crate::interfaces::api::handlers::webdav_handler::{file_dead_props, folder_dead_props};
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::nextcloud::webdav_handler::{
|
||||
batch_resolve_ids, format_oc_id, nc_href, write_file_response, write_folder_response,
|
||||
@@ -178,14 +179,15 @@ async fn handle_filter_files(
|
||||
let href = nc_href(url_user, subpath);
|
||||
let fid = file_id_map.get(&file.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
let dead = file_dead_props(&state, file).await;
|
||||
write_file_response(
|
||||
&mut xml,
|
||||
file,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
(fid, oc_id.as_deref()),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
&dead,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
@@ -203,18 +205,19 @@ async fn handle_filter_files(
|
||||
let href = format!("{}/", nc_href(url_user, subpath));
|
||||
let fid = folder_id_map.get(&folder.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
let dead = folder_dead_props(&state.webdav_dead_props, folder).await;
|
||||
write_folder_response(
|
||||
&mut xml,
|
||||
folder,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
(fid, oc_id.as_deref()),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
// REPORT results are a flat filter/search listing, not a
|
||||
// PROPFIND on a specific collection — quota isn't
|
||||
// meaningful here (see `AppState::resolve_webdav_quota`).
|
||||
None,
|
||||
&dead,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
@@ -312,14 +315,15 @@ async fn handle_search(
|
||||
let href = nc_href(url_user, subpath);
|
||||
let fid = file_id_map.get(&file.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
let dead = file_dead_props(&state, file).await;
|
||||
write_file_response(
|
||||
&mut xml,
|
||||
file,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
(fid, oc_id.as_deref()),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
&dead,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
@@ -338,18 +342,19 @@ async fn handle_search(
|
||||
let href = format!("{}/", nc_href(url_user, subpath));
|
||||
let fid = folder_id_map.get(&folder.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
let dead = folder_dead_props(&state.webdav_dead_props, folder).await;
|
||||
write_folder_response(
|
||||
&mut xml,
|
||||
folder,
|
||||
&href,
|
||||
fid,
|
||||
oc_id.as_deref(),
|
||||
(fid, oc_id.as_deref()),
|
||||
&user.username,
|
||||
&favorite_ids,
|
||||
// REPORT results are a flat filter/search listing, not a
|
||||
// PROPFIND on a specific collection — quota isn't
|
||||
// meaningful here (see `AppState::resolve_webdav_quota`).
|
||||
None,
|
||||
&dead,
|
||||
)
|
||||
.map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?;
|
||||
}
|
||||
|
||||
@@ -102,8 +102,16 @@ async fn handle_propfind(
|
||||
let nc = state.nextcloud.as_ref();
|
||||
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();
|
||||
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
|
||||
.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
|
||||
// either Sabre/DAV or the NC desktop client (a live file being
|
||||
// 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
|
||||
&& 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 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)))?
|
||||
.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 =
|
||||
chrono::DateTime::<chrono::Utc>::from_timestamp(listing.session_mtime as i64, 0)
|
||||
.unwrap_or_else(chrono::Utc::now)
|
||||
@@ -176,7 +189,7 @@ async fn handle_propfind_session(
|
||||
for chunk in &listing.chunks {
|
||||
let chunk_href = format!(
|
||||
"/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)
|
||||
.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.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"))?;
|
||||
|
||||
// Stream the chunk parts, in order, straight into the CDC chunk store —
|
||||
|
||||
@@ -13,7 +13,9 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::application::adapters::webdav_adapter::{PropFindRequest, WebDavAdapter};
|
||||
use crate::application::adapters::webdav_adapter::{
|
||||
PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, is_protected_property,
|
||||
};
|
||||
use crate::application::dtos::pagination::PaginationRequestDto;
|
||||
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
||||
use crate::application::ports::favorites_ports::FavoritesUseCase;
|
||||
@@ -26,7 +28,10 @@ use crate::common::di::AppState;
|
||||
use crate::common::mime_detect::filename_from_path;
|
||||
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
||||
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
|
||||
use crate::interfaces::api::handlers::webdav_handler::PROPFIND_BATCH_SIZE;
|
||||
use crate::infrastructure::services::webdav_dead_property_store::ResourceRef;
|
||||
use crate::interfaces::api::handlers::webdav_handler::{
|
||||
PROPFIND_BATCH_SIZE, file_dead_props, folder_dead_props, streamed_file_dead_props,
|
||||
};
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::range_requests::{not_modified_response, range_response};
|
||||
use crate::interfaces::upload_ingest::ingest_body_to_cas;
|
||||
@@ -373,6 +378,8 @@ async fn handle_propfind(
|
||||
let nc = state.nextcloud.as_ref();
|
||||
let file_id_svc = nc.map(|n| &n.file_ids);
|
||||
|
||||
let dead_props = file_dead_props(&state, &file).await;
|
||||
|
||||
let mut buf = Vec::new();
|
||||
write_nc_file_multistatus(
|
||||
&mut buf,
|
||||
@@ -381,7 +388,7 @@ async fn handle_propfind(
|
||||
&user.username,
|
||||
subpath,
|
||||
file_id_svc,
|
||||
&favorite_ids,
|
||||
(&favorite_ids, &dead_props),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?;
|
||||
@@ -622,6 +629,12 @@ async fn handle_head(
|
||||
|
||||
// ──────────────────── PROPPATCH ────────────────────
|
||||
|
||||
/// The `oc:favorite` element is live server state routed through the
|
||||
/// favorites service, not a dead property — every other
|
||||
/// namespace/local-name pair PROPPATCH sends is stored verbatim via
|
||||
/// `DeadPropertyStore`.
|
||||
const OC_FAVORITE_NS: &str = "http://owncloud.org/ns";
|
||||
|
||||
async fn handle_proppatch(
|
||||
state: Arc<AppState>,
|
||||
req: Request<Body>,
|
||||
@@ -635,171 +648,139 @@ async fn handle_proppatch(
|
||||
.await
|
||||
.map_err(|e| AppError::bad_request(format!("Failed to read body: {}", e)))?;
|
||||
|
||||
let body_str = String::from_utf8_lossy(&body_bytes);
|
||||
|
||||
// Resolve the target resource once — needed for two things:
|
||||
// 1. Applying the oc:favorite mutation when the PROPPATCH body
|
||||
// carries one (`item_type` distinguishes file vs folder rows
|
||||
// in the favorites table).
|
||||
// 2. Picking the right `<d:href>` shape in the multi-status
|
||||
// Resolve the target resource — needed for three things:
|
||||
// 1. The dead-property store key is the resource id (folder_id
|
||||
// XOR file_id), so we need a `ResourceRef`.
|
||||
// 2. Applying the oc:favorite mutation (`item_type` distinguishes
|
||||
// file vs folder rows in the favorites table).
|
||||
// 3. Picking the right `<d:href>` shape in the multi-status
|
||||
// response: collection (folder) hrefs MUST end in `/` per
|
||||
// RFC 4918 §5.2 — see `nc_collection_href` for the full
|
||||
// reasoning. Without this distinction the NC desktop client
|
||||
// parser aborted on PROPFIND; PROPPATCH would hit the same
|
||||
// wall the moment the user favourited a folder.
|
||||
// reasoning.
|
||||
//
|
||||
// When the resource is missing we tolerate it for the no-op
|
||||
// PROPPATCH path (no favorite directive in the body) — matches
|
||||
// the prior behaviour. A PROPPATCH that *does* try to set
|
||||
// favorite on a missing resource still returns NotFound.
|
||||
// A missing resource is now always a 404: unlike the previous
|
||||
// favorite-only implementation (which merely re-declared success
|
||||
// without doing anything), this handler performs real writes, so
|
||||
// silently no-opping on a nonexistent path would be a foot-gun —
|
||||
// matches the native `/webdav/` handler's contract.
|
||||
let internal_path = nc_to_internal_path(chroot, subpath)?;
|
||||
|
||||
// Single-query path resolution — PROPPATCH may target either a
|
||||
// folder or a file. Post-D7 the resolver is drive-scoped, so we
|
||||
// `authz.require(Read, …)` on the returned resource before
|
||||
// reading its type. The favorite mutation below itself doesn't
|
||||
// require additional authz (favorites are per-user; the caller can
|
||||
// favourite any resource they can see).
|
||||
let resource = match nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id).await {
|
||||
Some(ResolvedResource::File(f)) => {
|
||||
let file_uuid =
|
||||
Uuid::parse_str(&f.id).map_err(|_| AppError::not_found("Resource not found"))?;
|
||||
let (resource_ref, item_id, item_type, is_collection) =
|
||||
match nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id).await {
|
||||
Some(ResolvedResource::File(file)) => {
|
||||
let id = Uuid::parse_str(&file.id)
|
||||
.map_err(|_| AppError::not_found("Resource not found"))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::File(file_uuid),
|
||||
)
|
||||
.require(Subject::User(user.id), Permission::Read, Resource::File(id))
|
||||
.await?;
|
||||
Some((f.id, "file"))
|
||||
(ResourceRef::File(id), file.id, "file", false)
|
||||
}
|
||||
Some(ResolvedResource::Folder(folder)) => {
|
||||
let folder_uuid = Uuid::parse_str(&folder.id)
|
||||
let id = Uuid::parse_str(&folder.id)
|
||||
.map_err(|_| AppError::not_found("Resource not found"))?;
|
||||
state
|
||||
.authorization
|
||||
.require(
|
||||
Subject::User(user.id),
|
||||
Permission::Read,
|
||||
Resource::Folder(folder_uuid),
|
||||
Resource::Folder(id),
|
||||
)
|
||||
.await?;
|
||||
Some((folder.id, "folder"))
|
||||
(ResourceRef::Folder(id), folder.id, "folder", true)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let is_collection = matches!(resource, Some((_, "folder")));
|
||||
|
||||
// Parse oc:favorite value from PROPPATCH XML.
|
||||
let favorite_value = parse_proppatch_favorite(&body_str);
|
||||
|
||||
if let Some(value) = favorite_value {
|
||||
let Some((item_id, item_type)) = resource else {
|
||||
return Err(AppError::not_found("Resource not found"));
|
||||
None => return Err(AppError::not_found("Resource not found")),
|
||||
};
|
||||
|
||||
let ops = WebDavAdapter::parse_proppatch(body_bytes.reader())
|
||||
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH request: {}", e)))?;
|
||||
|
||||
let dead_props = &state.webdav_dead_props;
|
||||
let mut results: Vec<(&QualifiedName, bool)> = Vec::new();
|
||||
for op in &ops {
|
||||
let is_favorite =
|
||||
|name: &QualifiedName| name.namespace == OC_FAVORITE_NS && name.name == "favorite";
|
||||
match op {
|
||||
PropPatchOp::Set(pv) if is_favorite(&pv.name) => {
|
||||
if let Some(fav_svc) = state.favorites_service.as_ref() {
|
||||
if value == 1 {
|
||||
if pv.value.as_deref().map(str::trim) == Some("1") {
|
||||
fav_svc
|
||||
.add_to_favorites(user.id, &item_id, item_type)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to add favorite: {}", e))
|
||||
AppError::internal_error(format!("Failed to add favorite: {e}"))
|
||||
})?;
|
||||
} else {
|
||||
fav_svc
|
||||
.remove_from_favorites(user.id, &item_id, item_type)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to remove favorite: {}", e))
|
||||
AppError::internal_error(format!("Failed to remove favorite: {e}"))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
results.push((&pv.name, true));
|
||||
}
|
||||
PropPatchOp::Remove(name) if is_favorite(name) => {
|
||||
if let Some(fav_svc) = state.favorites_service.as_ref() {
|
||||
fav_svc
|
||||
.remove_from_favorites(user.id, &item_id, item_type)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to remove favorite: {e}"))
|
||||
})?;
|
||||
}
|
||||
results.push((name, true));
|
||||
}
|
||||
PropPatchOp::Set(pv) if is_protected_property(&pv.name) => {
|
||||
results.push((&pv.name, false));
|
||||
}
|
||||
PropPatchOp::Remove(name) if is_protected_property(name) => {
|
||||
results.push((name, false));
|
||||
}
|
||||
PropPatchOp::Set(pv) => {
|
||||
dead_props
|
||||
.set(resource_ref, pv.name.clone(), pv.value.clone())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to store dead property: {e}"))
|
||||
})?;
|
||||
results.push((&pv.name, true));
|
||||
}
|
||||
PropPatchOp::Remove(name) => {
|
||||
dead_props.remove(resource_ref, name).await.map_err(|e| {
|
||||
AppError::internal_error(format!("Failed to remove dead property: {e}"))
|
||||
})?;
|
||||
results.push((name, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return 207 Multi-Status with success response using quick_xml
|
||||
// for safe escaping. Collection vs file href chosen by resource
|
||||
// type to satisfy the RFC 4918 §5.2 trailing-slash invariant —
|
||||
// see the comment block at the top of this function.
|
||||
// Collection vs file href chosen by resource type to satisfy the
|
||||
// RFC 4918 §5.2 trailing-slash invariant — see the comment block
|
||||
// at the top of this function.
|
||||
let href = if is_collection {
|
||||
nc_collection_href(url_user, subpath)
|
||||
} else {
|
||||
nc_href(url_user, subpath)
|
||||
};
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut xml = Writer::new(&mut buf);
|
||||
xml.write_event(Event::Text(BytesText::new(
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>",
|
||||
)))
|
||||
.map_err(|e| AppError::internal_error(format!("XML write failed: {}", e)))?;
|
||||
|
||||
let mut ms = BytesStart::new("d:multistatus");
|
||||
ms.push_attribute(("xmlns:d", "DAV:"));
|
||||
ms.push_attribute(("xmlns:oc", "http://owncloud.org/ns"));
|
||||
xml.write_event(Event::Start(ms))
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
|
||||
xml.write_event(Event::Start(BytesStart::new("d:response")))
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
write_text_element(&mut xml, "d:href", &href)
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
xml.write_event(Event::Start(BytesStart::new("d:propstat")))
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
xml.write_event(Event::Start(BytesStart::new("d:prop")))
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
xml.write_event(Event::Empty(BytesStart::new("oc:favorite")))
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
xml.write_event(Event::End(BytesEnd::new("d:prop")))
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
write_text_element(&mut xml, "d:status", "HTTP/1.1 200 OK")
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
xml.write_event(Event::End(BytesEnd::new("d:propstat")))
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
xml.write_event(Event::End(BytesEnd::new("d:response")))
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
|
||||
.map_err(|e| AppError::internal_error(format!("XML: {}", e)))?;
|
||||
}
|
||||
let mut response_body = Vec::new();
|
||||
WebDavAdapter::generate_proppatch_response(&mut response_body, &href, &results).map_err(
|
||||
|e| AppError::internal_error(format!("Failed to generate PROPPATCH response: {}", e)),
|
||||
)?;
|
||||
|
||||
Ok(Response::builder()
|
||||
.status(StatusCode::MULTI_STATUS)
|
||||
.header(header::CONTENT_TYPE, "application/xml; charset=utf-8")
|
||||
.body(Body::from(buf))
|
||||
.body(Body::from(response_body))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
/// Parse the oc:favorite value from a PROPPATCH XML body using quick_xml.
|
||||
fn parse_proppatch_favorite(body: &str) -> Option<u8> {
|
||||
use quick_xml::Reader;
|
||||
|
||||
let mut reader = Reader::from_str(body);
|
||||
let mut inside_favorite = false;
|
||||
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(Event::Start(ref e)) => {
|
||||
let local = e.local_name();
|
||||
if local.as_ref() == b"favorite" {
|
||||
inside_favorite = true;
|
||||
}
|
||||
}
|
||||
Ok(Event::Text(ref e)) if inside_favorite => {
|
||||
let text = e.decode().ok()?;
|
||||
return text.trim().parse::<u8>().ok();
|
||||
}
|
||||
Ok(Event::End(ref e)) if e.local_name().as_ref() == b"favorite" => {
|
||||
inside_favorite = false;
|
||||
}
|
||||
Ok(Event::Eof) => break,
|
||||
Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ──────────────────── PUT ────────────────────
|
||||
|
||||
/// Strip the optional `W/` weak prefix and surrounding double-quotes
|
||||
@@ -1443,6 +1424,10 @@ fn write_nc_multistatus_open<W: std::io::Write>(xml: &mut Writer<W>) -> Result<(
|
||||
|
||||
/// Generate the multistatus XML for a single-file PROPFIND. The folder
|
||||
/// case streams via [`build_nc_streaming_propfind`] instead.
|
||||
///
|
||||
/// `extras` bundles `(favorite_ids, dead_props)` — both are per-resource
|
||||
/// decorations fetched by the caller — to stay under clippy's
|
||||
/// argument-count lint.
|
||||
async fn write_nc_file_multistatus<W: std::io::Write>(
|
||||
writer: W,
|
||||
file: &FileDto,
|
||||
@@ -1450,8 +1435,9 @@ async fn write_nc_file_multistatus<W: std::io::Write>(
|
||||
username: &str,
|
||||
subpath: &str,
|
||||
file_id_svc: Option<&Arc<NextcloudFileIdService>>,
|
||||
favorite_ids: &HashSet<String>,
|
||||
extras: (&HashSet<String>, &[(QualifiedName, Option<String>)]),
|
||||
) -> Result<(), String> {
|
||||
let (favorite_ids, dead_props) = extras;
|
||||
let (file_id_map, _) =
|
||||
batch_resolve_ids(file_id_svc, std::slice::from_ref(&file.id), &[]).await;
|
||||
|
||||
@@ -1470,10 +1456,10 @@ async fn write_nc_file_multistatus<W: std::io::Write>(
|
||||
&mut xml,
|
||||
file,
|
||||
&href,
|
||||
file_id,
|
||||
oc_id.as_deref(),
|
||||
(file_id, oc_id.as_deref()),
|
||||
username,
|
||||
favorite_ids,
|
||||
dead_props,
|
||||
)?;
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:multistatus")))
|
||||
@@ -1517,6 +1503,7 @@ fn build_nc_streaming_propfind(
|
||||
};
|
||||
let (_, folder_id_map) =
|
||||
batch_resolve_ids(file_id_svc, &[], std::slice::from_ref(&folder.id)).await;
|
||||
let folder_dead = folder_dead_props(&state.webdav_dead_props, &folder).await;
|
||||
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
{
|
||||
@@ -1525,7 +1512,7 @@ fn build_nc_streaming_propfind(
|
||||
let href = nc_collection_href(&username, &subpath);
|
||||
let fid = folder_id_map.get(&folder.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
write_folder_response(&mut xml, &folder, &href, fid, oc_id.as_deref(), &username, &folder_favs, quota)
|
||||
write_folder_response(&mut xml, &folder, &href, (fid, oc_id.as_deref()), &username, &folder_favs, quota, &folder_dead)
|
||||
.map_err(std::io::Error::other)?;
|
||||
}
|
||||
yield Bytes::from(buf);
|
||||
@@ -1554,11 +1541,15 @@ fn build_nc_streaming_propfind(
|
||||
};
|
||||
let file_uuids: Vec<String> = batch.iter().map(|f| f.id.clone()).collect();
|
||||
let (file_id_map, _) = batch_resolve_ids(file_id_svc, &file_uuids, &[]).await;
|
||||
let mut file_deads = Vec::with_capacity(batch_len);
|
||||
for file in &batch {
|
||||
file_deads.push(streamed_file_dead_props(&state.webdav_dead_props, file).await);
|
||||
}
|
||||
|
||||
let mut chunk = Vec::with_capacity(batch_len * 1024);
|
||||
{
|
||||
let mut xml = Writer::new(&mut chunk);
|
||||
for file in &batch {
|
||||
for (file, dead) in batch.iter().zip(file_deads.iter()) {
|
||||
let child_sub = if subpath.is_empty() {
|
||||
file.name.clone()
|
||||
} else {
|
||||
@@ -1567,7 +1558,7 @@ fn build_nc_streaming_propfind(
|
||||
let href = nc_href(&username, &child_sub);
|
||||
let fid = file_id_map.get(&file.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
write_file_response(&mut xml, file, &href, fid, oc_id.as_deref(), &username, &favs)
|
||||
write_file_response(&mut xml, file, &href, (fid, oc_id.as_deref()), &username, &favs, dead)
|
||||
.map_err(std::io::Error::other)?;
|
||||
}
|
||||
}
|
||||
@@ -1603,11 +1594,15 @@ fn build_nc_streaming_propfind(
|
||||
};
|
||||
let folder_uuids: Vec<String> = result.items.iter().map(|sf| sf.id.clone()).collect();
|
||||
let (_, sub_id_map) = batch_resolve_ids(file_id_svc, &[], &folder_uuids).await;
|
||||
let mut sub_deads = Vec::with_capacity(result.items.len());
|
||||
for sf in &result.items {
|
||||
sub_deads.push(folder_dead_props(&state.webdav_dead_props, sf).await);
|
||||
}
|
||||
|
||||
let mut chunk = Vec::with_capacity(result.items.len() * 1024);
|
||||
{
|
||||
let mut xml = Writer::new(&mut chunk);
|
||||
for sf in &result.items {
|
||||
for (sf, dead) in result.items.iter().zip(sub_deads.iter()) {
|
||||
let child_sub = if subpath.is_empty() {
|
||||
sf.name.clone()
|
||||
} else {
|
||||
@@ -1616,7 +1611,7 @@ fn build_nc_streaming_propfind(
|
||||
let href = nc_collection_href(&username, &child_sub);
|
||||
let fid = sub_id_map.get(&sf.id).copied();
|
||||
let oc_id = fid.map(|id| format_oc_id(id, file_id_svc));
|
||||
write_folder_response(&mut xml, sf, &href, fid, oc_id.as_deref(), &username, &favs, quota)
|
||||
write_folder_response(&mut xml, sf, &href, (fid, oc_id.as_deref()), &username, &favs, quota, dead)
|
||||
.map_err(std::io::Error::other)?;
|
||||
}
|
||||
}
|
||||
@@ -1651,17 +1646,22 @@ fn build_nc_streaming_propfind(
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// `oc_ids` bundles `(file_id, oc_id)` — always fetched and passed
|
||||
/// together (`oc_id` is derived from `file_id`) — to stay under
|
||||
/// clippy's argument-count lint now that `dead_props` is also threaded
|
||||
/// through.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn write_folder_response<W: std::io::Write>(
|
||||
xml: &mut Writer<W>,
|
||||
folder: &FolderDto,
|
||||
href: &str,
|
||||
file_id: Option<i64>,
|
||||
oc_id: Option<&str>,
|
||||
oc_ids: (Option<i64>, Option<&str>),
|
||||
owner: &str,
|
||||
favorite_ids: &HashSet<String>,
|
||||
quota: Option<(i64, Option<i64>)>,
|
||||
dead_props: &[(QualifiedName, Option<String>)],
|
||||
) -> Result<(), String> {
|
||||
let (file_id, oc_id) = oc_ids;
|
||||
xml.write_event(Event::Start(BytesStart::new("d:response")))
|
||||
.xml_err()?;
|
||||
|
||||
@@ -1742,21 +1742,26 @@ pub fn write_folder_response<W: std::io::Write>(
|
||||
xml.write_event(Event::End(BytesEnd::new("d:propstat")))
|
||||
.xml_err()?;
|
||||
|
||||
WebDavAdapter::write_dead_props_propstat(xml, dead_props).xml_err()?;
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:response")))
|
||||
.xml_err()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// See `write_folder_response` for why `(file_id, oc_id)` are bundled
|
||||
/// into `oc_ids`.
|
||||
pub fn write_file_response<W: std::io::Write>(
|
||||
xml: &mut Writer<W>,
|
||||
file: &FileDto,
|
||||
href: &str,
|
||||
file_id: Option<i64>,
|
||||
oc_id: Option<&str>,
|
||||
oc_ids: (Option<i64>, Option<&str>),
|
||||
owner: &str,
|
||||
favorite_ids: &HashSet<String>,
|
||||
dead_props: &[(QualifiedName, Option<String>)],
|
||||
) -> Result<(), String> {
|
||||
let (file_id, oc_id) = oc_ids;
|
||||
xml.write_event(Event::Start(BytesStart::new("d:response")))
|
||||
.xml_err()?;
|
||||
|
||||
@@ -1831,6 +1836,8 @@ pub fn write_file_response<W: std::io::Write>(
|
||||
xml.write_event(Event::End(BytesEnd::new("d:propstat")))
|
||||
.xml_err()?;
|
||||
|
||||
WebDavAdapter::write_dead_props_propstat(xml, dead_props).xml_err()?;
|
||||
|
||||
xml.write_event(Event::End(BytesEnd::new("d:response")))
|
||||
.xml_err()?;
|
||||
|
||||
|
||||
@@ -6,23 +6,19 @@
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>OxiCloud</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
|
||||
<script src="/js/core/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/views/auth.css">
|
||||
<link rel="stylesheet" href="/askama-common.css">
|
||||
<style>
|
||||
/* Cross-browser prompt is the one magic-link page that needs a
|
||||
"warning" callout that auth.css doesn't ship. Inline because the
|
||||
shape is unique to this surface — keep the styling local rather
|
||||
than adding tokens that no other page reuses. */
|
||||
/* Cross-browser prompt overrides `.magic-note` with a warning
|
||||
treatment — this is the only page where the note should shout.
|
||||
Tokens come from the shared design system (variables.css) via
|
||||
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 {
|
||||
background: var(--color-warning-bg-light);
|
||||
border-left: 3px solid var(--color-warning-text-amber);
|
||||
background: var(--color-warning-bg);
|
||||
border-left: 3px solid var(--color-warning-border);
|
||||
color: var(--color-text);
|
||||
padding: 0.75em 1em;
|
||||
margin: 1.5em 0;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95em;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>OxiCloud</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
|
||||
<script src="/js/core/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/views/auth.css">
|
||||
<link rel="stylesheet" href="/askama-common.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>OxiCloud</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
|
||||
<script src="/js/core/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/views/auth.css">
|
||||
<link rel="stylesheet" href="/askama-common.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>OxiCloud</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
|
||||
<script src="/js/core/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/views/auth.css">
|
||||
<link rel="stylesheet" href="/askama-common.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Choose a drive - OxiCloud</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
|
||||
<script src="/js/core/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/views/auth.css">
|
||||
<link rel="stylesheet" href="/askama-common.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
# =============================================================
|
||||
# OxiCloud — Expired-grant purge (GrantCleanupService)
|
||||
# =============================================================
|
||||
# Regression coverage for the daily purge that deletes rows from
|
||||
# `storage.role_grants` whose `expires_at` is more than
|
||||
# `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` in the past.
|
||||
#
|
||||
# The engine's `check` / `list_grants_*` paths already filter
|
||||
# expired grants out at read time — this purge is pure garbage
|
||||
# collection. If the SQL were wrong (e.g. missing
|
||||
# `expires_at IS NOT NULL`, wrong sign on the interval), the
|
||||
# assertions here catch it before the daemon runs against real
|
||||
# data.
|
||||
#
|
||||
# Uses the `POST /api/admin/internal/trigger-grant-cleanup`
|
||||
# admin endpoint (gated by
|
||||
# `OXICLOUD_ENABLE_ADMIN_INTERNAL_ENDPOINTS=true`, on for the
|
||||
# api-test suite). `?force=true` collapses the grace window to
|
||||
# zero for the call so we can plant a past-dated grant and
|
||||
# immediately observe it purged, without waiting 15+ days.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — Login admin (Alice), capture home folder id.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
alice_token: jsonpath "$.access_token"
|
||||
alice_user_id: jsonpath "$.user.id"
|
||||
|
||||
|
||||
GET {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
alice_home_id: jsonpath "$[0].id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — Create a grantee user (mallory) — someone we can
|
||||
# grant Alice's resources to without polluting shared
|
||||
# state used by other test files.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/users
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"username": "gc-mallory",
|
||||
"password": "GcMalloryPassword1!",
|
||||
"email": "gc-mallory@example.com",
|
||||
"role": "user"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
mallory_user_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — Alice creates two folders: one to hold an expired
|
||||
# grant, one to hold a permanent (no-expiry) grant we
|
||||
# expect the purge to leave alone.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "gc-expired", "parent_id": "{{alice_home_id}}" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
expired_folder_id: jsonpath "$.id"
|
||||
|
||||
|
||||
POST {{base_url}}/api/folders
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{ "name": "gc-permanent", "parent_id": "{{alice_home_id}}" }
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
permanent_folder_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — Plant an expired grant. Set `expires_at` in 2020 so
|
||||
# any grace window less than several years still
|
||||
# catches it. The grant handler silently accepts past-
|
||||
# dated `expires_at` — a separate PR would reject them
|
||||
# on the create path, but here we exploit the
|
||||
# permissive behaviour as a test fixture.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{mallory_user_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{expired_folder_id}}" },
|
||||
"role": "viewer",
|
||||
"expires_at": "2020-01-01T00:00:00Z"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
expired_grant_id: jsonpath "$.grants[0].id"
|
||||
|
||||
|
||||
# Confirm the grant IS present in the listing — the engine's
|
||||
# filter is `expires_at > NOW()`, so the past-dated row is
|
||||
# already invisible to `check()` but still exists physically
|
||||
# (and thus in the list endpoint too — verified below).
|
||||
GET {{base_url}}/api/grants?resource_type=folder&resource_id={{expired_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
# Bare array, filter selector — see memory note on Hurl JSONPath
|
||||
# quirks: use `$[?(...)]` (single-match returns scalar; no `nth`).
|
||||
jsonpath "$[?(@.id=='{{expired_grant_id}}')].role" == "viewer"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — Plant a permanent grant on the other folder (no
|
||||
# `expires_at`). The purge MUST leave it alone.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/grants
|
||||
Authorization: Bearer {{alice_token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"subject": { "type": "user", "id": "{{mallory_user_id}}" },
|
||||
"resource": { "type": "folder", "id": "{{permanent_folder_id}}" },
|
||||
"role": "viewer"
|
||||
}
|
||||
|
||||
HTTP 201
|
||||
[Captures]
|
||||
permanent_grant_id: jsonpath "$.grants[0].id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — Trigger the purge with `force=true`. The endpoint
|
||||
# collapses the grace window to 0 for this call only
|
||||
# — the daemon's configured grace is untouched.
|
||||
#
|
||||
# Expect `grants_deleted >= 1` (the past-dated row),
|
||||
# `grace_days == 0`, `forced == true`.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/internal/trigger-grant-cleanup?force=true
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.ok" == true
|
||||
jsonpath "$.forced" == true
|
||||
jsonpath "$.grace_days" == 0
|
||||
# At least the expired-fixture row we just planted.
|
||||
jsonpath "$.grants_deleted" >= 1
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — The expired grant is gone. The permanent grant
|
||||
# survives.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
GET {{base_url}}/api/grants?resource_type=folder&resource_id={{expired_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
# The list is either empty or contains no row with the expired
|
||||
# grant's id — the filter must not select anything.
|
||||
jsonpath "$[*].id" not contains "{{expired_grant_id}}"
|
||||
|
||||
GET {{base_url}}/api/grants?resource_type=folder&resource_id={{permanent_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
# Permanent grant untouched.
|
||||
jsonpath "$[?(@.id=='{{permanent_grant_id}}')].role" == "viewer"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — Second trigger with `force=true` on a table that no
|
||||
# longer has any past-dated grants. Expect
|
||||
# `grants_deleted == 0`. This is the regression guard
|
||||
# on the WHERE clause — if `expires_at IS NOT NULL`
|
||||
# were missing, this would nuke the permanent grant
|
||||
# from Step 5 (any row with `NULL < NOW() - 0 days` is
|
||||
# false in SQL, so it's already correct; but a
|
||||
# mistyped predicate could regress).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/internal/trigger-grant-cleanup?force=true
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.grants_deleted" == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — Unforced trigger. Grace = configured value (15).
|
||||
# No new expired grants planted, so purge is a no-op.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/admin/internal/trigger-grant-cleanup
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.ok" == true
|
||||
jsonpath "$.forced" == false
|
||||
# Response echoes the configured grace (15 days by default).
|
||||
jsonpath "$.grace_days" == 15
|
||||
jsonpath "$.grants_deleted" == 0
|
||||
|
||||
|
||||
# Permanent grant still there after the unforced call.
|
||||
GET {{base_url}}/api/grants?resource_type=folder&resource_id={{permanent_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$[?(@.id=='{{permanent_grant_id}}')].role" == "viewer"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Cleanup — drop both folders. Cascade removes the remaining
|
||||
# grant + any children.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/folders/{{expired_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/folders/{{permanent_folder_id}}
|
||||
Authorization: Bearer {{alice_token}}
|
||||
|
||||
HTTP 204
|
||||
@@ -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
|
||||
@@ -0,0 +1,470 @@
|
||||
# =============================================================
|
||||
# OxiCloud — NextCloud WebDAV: dead-properties (RFC 4918 §4.2)
|
||||
# =============================================================
|
||||
# `tests/api/webdav_dead_properties.hurl` covers the native
|
||||
# `/webdav/` surface end-to-end. This file covers the same
|
||||
# PROPPATCH/PROPFIND contract on the NextCloud-compatible surface
|
||||
# (`/remote.php/dav/files/{user}/...`), which — until now — had NO
|
||||
# generic dead-property support: PROPPATCH only special-cased
|
||||
# `oc:favorite` via an ad hoc XML scan and silently discarded any
|
||||
# other property while still claiming `200 OK`; PROPFIND always
|
||||
# emitted a fixed hardcoded property set with no dead-property
|
||||
# lookup at all. A client (or litmus) PROPPATCHing a custom label
|
||||
# through the NextCloud mount got a false success and then never
|
||||
# saw the property again.
|
||||
#
|
||||
# Coverage:
|
||||
# 1. Setup: JWT login, mint an NC app password.
|
||||
# 2. PUT a probe file via the NC DAV surface.
|
||||
# 3. PROPPATCH set a custom property → 207.
|
||||
# 4. PROPFIND → value round-trips verbatim.
|
||||
# 5. PROPPATCH upsert (same name, new value) → PROPFIND confirms
|
||||
# overwrite, not a duplicate row.
|
||||
# 6. PROPPATCH remove → PROPFIND confirms absence.
|
||||
# 7. PROPPATCH on a nonexistent resource → 404 (the tightened
|
||||
# contract: PROPPATCH now does real work, so a previous
|
||||
# "always claim success" no-op on a missing resource would be
|
||||
# a foot-gun, not a feature).
|
||||
# 8. Re-set a property, MOVE the file → PROPFIND on the new path
|
||||
# still returns it (resource id is stable across MOVE).
|
||||
# 9. DELETE, then PUT a fresh file at the same path → PROPFIND
|
||||
# does NOT see the old marker (new resource, no leaked state).
|
||||
# 10. Regression guard: `oc:favorite` PROPPATCH/PROPFIND still
|
||||
# works, unaffected by the refactor from the ad hoc favorite
|
||||
# scanner to generic `WebDavAdapter::parse_proppatch`.
|
||||
# 11. Folder coverage: MKCOL, PROPPATCH a dead property on the
|
||||
# folder, PROPFIND confirms it, cleanup.
|
||||
#
|
||||
# XPath assertions use `local-name()` so the test is robust against
|
||||
# the server's chosen namespace prefix for dead properties (`X:`).
|
||||
#
|
||||
# NOTE: in Hurl, [BasicAuth] must be the LAST section before the
|
||||
# blank-line/body — any request headers go above it, not below.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — JWT login, then mint an NC app password (NC DAV uses
|
||||
# Basic Auth, not the JWT bearer token).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
jwt: jsonpath "$.access_token"
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/app-passwords
|
||||
Authorization: Bearer {{jwt}}
|
||||
Content-Type: application/json
|
||||
{ "label": "nc_webdav_dead_properties" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
nc_username: jsonpath "$.username"
|
||||
nc_password: jsonpath "$.password"
|
||||
ap_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — PUT a probe file through the NC DAV surface.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
hello nc dead properties
|
||||
```
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — PROPPATCH set a custom (dead) property.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<X:testlabel>hello-nc-dead-property</X:testlabel>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "200 OK"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — PROPFIND confirms the round-trip.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='testlabel'])" == "hello-nc-dead-property"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — Upsert: setting the same name again overwrites rather
|
||||
# than duplicating (ON CONFLICT DO UPDATE at the store).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<X:testlabel>updated-nc-value</X:testlabel>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='testlabel'])" == "updated-nc-value"
|
||||
xpath "count(//*[local-name()='testlabel'])" == 1
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — Remove the property; PROPFIND confirms absence.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:remove>
|
||||
<D:prop>
|
||||
<X:testlabel/>
|
||||
</D:prop>
|
||||
</D:remove>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "count(//*[local-name()='testlabel'])" == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — PROPPATCH against a nonexistent resource → 404.
|
||||
# Prior behaviour on this handler silently no-opped
|
||||
# (and still claimed success) when the body carried no
|
||||
# `oc:favorite` directive; now that PROPPATCH performs
|
||||
# real dead-property writes, a missing resource must be
|
||||
# a hard failure, matching the native `/webdav/` handler.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-does-not-exist.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<X:testlabel>should-not-be-stored</X:testlabel>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 404
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — Re-set a marker, MOVE the file, confirm the property
|
||||
# followed the resource (id-stable across MOVE — no
|
||||
# store-side rename bookkeeping needed).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<X:testlabel>survives-nc-move</X:testlabel>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
|
||||
|
||||
MOVE {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt
|
||||
Destination: {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
# Fresh destination → 201 (RFC 4918 §9.9.4).
|
||||
HTTP 201
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='testlabel'])" == "survives-nc-move"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — DELETE, then PUT a fresh file at the same path: the
|
||||
# old marker must NOT resurface (new resource, no leaked
|
||||
# dead-property state). Whether DELETE soft-deletes to
|
||||
# trash or hard-deletes, the recreated path resolves to
|
||||
# a brand-new resource id with no dead-property rows of
|
||||
# its own.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
PUT {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
fresh file at the same nc path
|
||||
```
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "count(//*[local-name()='testlabel'])" == 0
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — Regression guard: `oc:favorite` still works after the
|
||||
# PROPPATCH handler was rewritten from an ad hoc
|
||||
# favorite-only scanner to generic dead-property
|
||||
# handling with an `oc:favorite` special case.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<oc:favorite>1</oc:favorite>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "200 OK"
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='favorite'])" == "1"
|
||||
|
||||
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<oc:favorite>0</oc:favorite>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='favorite'])" == "0"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Cleanup — probe file.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 — Folder coverage: MKCOL, PROPPATCH, PROPFIND, cleanup.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
MKCOL {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-folder/
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-folder/
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<X:foldermark>nc-folder-keeps-this</X:foldermark>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-folder/
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='foldermark'])" == "nc-folder-keeps-this"
|
||||
|
||||
|
||||
DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-folder/
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Teardown — revoke the app password minted in Step 1.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}}
|
||||
Authorization: Bearer {{jwt}}
|
||||
|
||||
HTTP 200
|
||||
@@ -166,6 +166,7 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
||||
"$API_DIR/public_shares.hurl" \
|
||||
"$API_DIR/permissions.hurl" \
|
||||
"$API_DIR/grants.hurl" \
|
||||
"$API_DIR/grant_cleanup.hurl" \
|
||||
"$API_DIR/role_grants.hurl" \
|
||||
"$API_DIR/subject_groups.hurl" \
|
||||
"$API_DIR/groups_effective_members.hurl" \
|
||||
@@ -186,7 +187,10 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test
|
||||
"$API_DIR/drive_policies.hurl" \
|
||||
"$API_DIR/cross_drive_move.hurl" \
|
||||
"$API_DIR/cross_drive_copy.hurl" \
|
||||
"$API_DIR/nc_multidrive_move_regression.hurl" \
|
||||
"$API_DIR/webdav_dead_properties.hurl" \
|
||||
"$API_DIR/nc_webdav_dead_properties.hurl" \
|
||||
"$API_DIR/webdav_protected_properties.hurl" \
|
||||
"$API_DIR/webdav_quota_properties.hurl" \
|
||||
"$API_DIR/nc_webdav_quota_properties.hurl" \
|
||||
"$API_DIR/webdav_drive_root.hurl" \
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
# =============================================================
|
||||
# OxiCloud — WebDAV protected properties (RFC 4918 §9.2 / §15)
|
||||
# =============================================================
|
||||
# `DeadPropertyStore` lets a PROPPATCH set arbitrary namespace/name
|
||||
# pairs verbatim (RFC 4918 §4.2). Without a denylist, a client could
|
||||
# PROPPATCH `DAV:getetag`, `oc:fileid`, `oc:permissions`, etc. — names
|
||||
# the server ALSO emits as live state in PROPFIND/REPORT responses
|
||||
# (see `write_file_response` / `write_folder_response` in the NC
|
||||
# handler and the native PROPFIND writer). That produces either a
|
||||
# forged live property (the server would need to pick which of two
|
||||
# values to emit) or a silently stored, never-read row.
|
||||
#
|
||||
# `is_protected_property()` (src/application/adapters/webdav_adapter.rs)
|
||||
# defends the whole `DAV:` namespace plus the specific oc:/nc:/ocs:
|
||||
# names the server actually emits elsewhere. Both PROPPATCH handlers
|
||||
# (native `/webdav/` and NC `/remote.php/dav/`) consult it before
|
||||
# touching `DeadPropertyStore`, and reject with RFC 4918 §9.2's
|
||||
# per-property `403 Forbidden` inside the 207 multi-status — not a
|
||||
# blanket request failure, and not a silent no-op success.
|
||||
#
|
||||
# Coverage:
|
||||
# 1. Native /webdav/: PROPPATCH set on `D:displayname` (DAV:
|
||||
# namespace) → 207 envelope, inner 403 for that property.
|
||||
# 2. PROPFIND confirms the live displayname is unchanged — the
|
||||
# forged value never landed anywhere.
|
||||
# 3. Native /webdav/: PROPPATCH remove on `D:getetag` → same 403
|
||||
# contract on the Remove path, not just Set.
|
||||
# 4. Native /webdav/: an oc:-namespaced protected name
|
||||
# (`oc:fileid`) is blocked even on the surface that doesn't
|
||||
# normally speak NextCloud namespaces — protection is
|
||||
# namespace-global, not surface-scoped.
|
||||
# 5. Mixed request: one protected DAV: prop + one ordinary custom
|
||||
# dead property in the SAME PROPPATCH → 207 with both a 403
|
||||
# propstat block and a 200 propstat block; the custom property
|
||||
# DOES get stored (per-property granularity, not all-or-nothing
|
||||
# rejection).
|
||||
# 6. NC surface: PROPPATCH set on a protected oc: name
|
||||
# (`oc:permissions`) → 403; PROPFIND confirms it was never
|
||||
# written to the dead-property store.
|
||||
# 7. NC surface: PROPPATCH set on a protected nc: name
|
||||
# (`nc:has-preview`) → 403.
|
||||
# 8. Regression guard: `oc:favorite` is on the protected list too
|
||||
# (it's live state the NC handler emits), but the handler's
|
||||
# favorite special-case runs BEFORE the protected-property
|
||||
# check, so toggling favorite through PROPPATCH still works —
|
||||
# protection must not swallow the one oc: name that's
|
||||
# legitimately client-writable via a side channel.
|
||||
# =============================================================
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 1 — Login, capture JWT; mint an NC app password for the
|
||||
# NC-surface half of this file (NC DAV uses Basic Auth).
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
POST {{base_url}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
{ "username": "{{username}}", "password": "{{password}}" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
token: jsonpath "$.access_token"
|
||||
|
||||
|
||||
POST {{base_url}}/api/auth/app-passwords
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{ "label": "webdav_protected_properties" }
|
||||
|
||||
HTTP 200
|
||||
[Captures]
|
||||
nc_username: jsonpath "$.username"
|
||||
nc_password: jsonpath "$.password"
|
||||
ap_id: jsonpath "$.id"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 2 — PUT a probe file via native WebDAV.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: text/plain
|
||||
```
|
||||
hello protected properties
|
||||
```
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 3 — PROPPATCH set on DAV:displayname (live property) must
|
||||
# be rejected with a per-property 403, not silently
|
||||
# accepted into DeadPropertyStore.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<D:displayname>forged-name</D:displayname>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 4 — PROPFIND confirms the live displayname is untouched.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPFIND {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='displayname'])" == "protected-props-probe.txt"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 5 — PROPPATCH remove on DAV:getetag → same 403 contract
|
||||
# on the Remove path.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:">
|
||||
<D:remove>
|
||||
<D:prop>
|
||||
<D:getetag/>
|
||||
</D:prop>
|
||||
</D:remove>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 6 — Protection is namespace-global: an oc:-namespaced
|
||||
# protected name is blocked even on the native /webdav/
|
||||
# surface, which doesn't otherwise speak NextCloud
|
||||
# namespaces.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<oc:fileid>should-not-be-stored</oc:fileid>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 7 — Mixed request: one protected DAV: prop + one ordinary
|
||||
# custom dead property in the SAME PROPPATCH → per-
|
||||
# property granularity, not all-or-nothing rejection.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:X="oxi:test">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<D:resourcetype>forged</D:resourcetype>
|
||||
<X:testlabel>allowed-alongside-protected</X:testlabel>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "count(//*[local-name()='propstat'])" == 2
|
||||
xpath "string(//*[local-name()='propstat'][*[local-name()='prop']/*[local-name()='resourcetype']]/*[local-name()='status'])" contains "403"
|
||||
xpath "string(//*[local-name()='propstat'][*[local-name()='prop']/*[local-name()='testlabel']]/*[local-name()='status'])" contains "200 OK"
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='testlabel'])" == "allowed-alongside-protected"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Cleanup — native probe file.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/webdav/protected-props-probe.txt
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 8 — NC surface: PUT a probe file via the NC DAV mount.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PUT {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
Content-Type: text/plain
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
hello nc protected properties
|
||||
```
|
||||
|
||||
HTTP 201
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 9 — NC surface: PROPPATCH set on a protected oc: name
|
||||
# (`oc:permissions`, not the specially-handled favorite)
|
||||
# → 403.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<oc:permissions>forged</oc:permissions>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403"
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='permissions'])" != "forged"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 10 — NC surface: PROPPATCH set on a protected nc: name
|
||||
# (`nc:has-preview`) → 403.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:nc="http://nextcloud.org/ns">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<nc:has-preview>forged</nc:has-preview>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Step 11 — Regression guard: oc:favorite is on the protected
|
||||
# list too, but the handler's favorite special-case
|
||||
# runs before the protection check, so toggling it via
|
||||
# PROPPATCH must still work end to end.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propertyupdate xmlns:D="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<D:set>
|
||||
<D:prop>
|
||||
<oc:favorite>1</oc:favorite>
|
||||
</D:prop>
|
||||
</D:set>
|
||||
</D:propertyupdate>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "200 OK"
|
||||
|
||||
|
||||
PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
Depth: 0
|
||||
Content-Type: application/xml; charset=utf-8
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
```
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:allprop/>
|
||||
</D:propfind>
|
||||
```
|
||||
|
||||
HTTP 207
|
||||
[Asserts]
|
||||
xpath "string(//*[local-name()='favorite'])" == "1"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
# Cleanup — NC probe file, teardown app password.
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt
|
||||
[BasicAuth]
|
||||
{{nc_username}}: {{nc_password}}
|
||||
|
||||
HTTP 204
|
||||
|
||||
|
||||
DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
|
||||
HTTP 200
|
||||
Reference in New Issue
Block a user