docs(quick-261008-mzu): Modul Dateien (Nextcloud-Client)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
|
||||
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
|
||||
Plan: 6 of 6
|
||||
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
|
||||
Last activity: 2026-10-08 - Quick 261008-j9f Nextcloud-Logo per http
|
||||
Last activity: 2026-10-08 - Quick 261008-mzu Modul Dateien (Nextcloud)
|
||||
|
||||
Progress: [██████████] 99%
|
||||
|
||||
@@ -500,6 +500,7 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
|
||||
| 261008-dts | Modul Domains: AutoDNS-Anbindung (Zugang Demo/Live, Kunden, Kontakte, Domainliste, Registrieren ohne Doppelbestellung, Aufträge) — Needs Review (Demo-Prüfung offen) | 2026-10-08 | 53b73dd | [261008-dts-modul-domains-autodns-anbindung-kontakte](.planning/quick/261008-dts-modul-domains-autodns-anbindung-kontakte/) |
|
||||
| 261008-h3t | Domains: Standard-Nameserver aus AutoDNS-Profil statt Tessera-Einstellung (nur Anzeige, Sperre wenn keine) — Demo-Prüfung offen | 2026-10-08 | 2176f8e | [261008-h3t-domains-nameserver-aus-autodns-statt-tes](.planning/quick/261008-h3t-domains-nameserver-aus-autodns-statt-tes/) |
|
||||
| 261008-j9f | Nextcloud-Status: http-Logo-Adresse wird einmalig abgeholt (nur Internet, SSRF-Schutz), Formular nur deutsche Meldungen | 2026-10-08 | 166a6fc | [261008-j9f-nextcloud-status-logo-per-http-adresse-h](.planning/quick/261008-j9f-nextcloud-status-logo-per-http-adresse-h/) |
|
||||
| 261008-mzu | Neues Modul „Dateien“ (Nextcloud-Client): Admin setzt Adresse, Login Passwort→App-Passwort bzw. Login Flow v2 (2FA), Dateien/Raster/Vorschau/Upload in 8-MiB-Stücken/Download+ZIP/Ordner-Ops, Sperr- und Brute-Force-Schutz, Design „Mosaik-Ablage“ — Review 18 Funde behoben; Needs Review (echte Umgebung) | 2026-10-08 | HEAD | [261008-mzu-modul-nextcloud-dateien-eigenstaendiger-](.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/) |
|
||||
|
||||
## Deferred Items
|
||||
|
||||
|
||||
+572
@@ -0,0 +1,572 @@
|
||||
---
|
||||
phase: quick-261008-mzu
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
quick_id: 261008-mzu
|
||||
description: "Neues Modul Nextcloud-Dateien (Etappe 1): eine Nextcloud je Organisation, eigenes Konto je Benutzer (App-Passwort oder Login Flow v2 fuer Zwei-Faktor), Dateien durchblaettern, hoch- und herunterladen, anlegen, umbenennen, verschieben, loeschen"
|
||||
date: 2026-10-08
|
||||
revision: 2
|
||||
files_modified:
|
||||
# Task 1 — DB, transport with call gate, settings (API + Einstellungen tab), module registration
|
||||
- apps/api/prisma/schema.prisma
|
||||
- apps/api/prisma/migrations/20261008180000_nextcloud_files/migration.sql
|
||||
- apps/api/src/nextcloud-files/nextcloud-files.types.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-http.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-http.spec.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-call-gate.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-call-gate.spec.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-files-settings.service.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-files-settings.service.spec.ts
|
||||
- apps/api/src/nextcloud-files/dto/nextcloud-files-settings.dto.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-files.controller.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-files.seed.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-files.module.ts
|
||||
- apps/api/src/app.module.ts
|
||||
- apps/api/src/module-registry/module-manage-handlers.spec.ts
|
||||
- docs/mandantentrennung-zugriffsklassifikation.md
|
||||
- apps/web/src/lib/nextcloud-files-api.ts
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/layout.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/SettingsTab.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx
|
||||
- apps/web/src/app/(portal)/modules/module-layouts.test.tsx
|
||||
- apps/web/src/lib/module-loader.ts
|
||||
- apps/web/src/lib/module-identity.ts
|
||||
- apps/web/src/components/modules/module-tile.tsx
|
||||
- apps/web/src/lib/stores/nav-store.ts
|
||||
- apps/web/src/messages/de.json
|
||||
- apps/web/src/messages/en.json
|
||||
- apps/web/src/messages/umlaut-dictionary.ts
|
||||
- .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/nc-test-setup.sh
|
||||
- .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/totp.php
|
||||
- .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/totp.py
|
||||
- .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh
|
||||
- .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-settings.sh
|
||||
# Task 2 — connect by password and Login Flow v2, guards, disconnect/revoke, connect screen
|
||||
- apps/api/src/nextcloud-files/nextcloud-auth-client.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-auth-client.spec.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-login-guard.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-login-guard.spec.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-files-account.service.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-files-account.service.spec.ts
|
||||
- apps/api/src/nextcloud-files/dto/nextcloud-files-connect.dto.ts
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/ConnectPanel.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/AccountBar.tsx
|
||||
- .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-connect.sh
|
||||
# Task 3 — file API: listing with quota, preview, mkdir, move/rename, delete
|
||||
- apps/api/src/nextcloud-files/nextcloud-propfind.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-propfind.spec.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-dav.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-dav.spec.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-upstream.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-upstream.spec.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-files.service.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-files.service.spec.ts
|
||||
- apps/api/src/nextcloud-files/dto/nextcloud-files-ops.dto.ts
|
||||
- apps/web/src/lib/nextcloud-files-api.test.ts
|
||||
- .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-files.sh
|
||||
# Task 4 — transfer API (download stream, ZIP, chunked upload with async assembly) + browser uploader
|
||||
- packages/shared/src/index.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-dav-transfer.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-dav-transfer.spec.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-files-transfer.service.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-files-transfer.service.spec.ts
|
||||
- apps/api/src/nextcloud-files/dto/nextcloud-files-transfer.dto.ts
|
||||
- apps/web/src/lib/nextcloud-files-upload.ts
|
||||
- apps/web/src/lib/nextcloud-files-upload.test.ts
|
||||
- .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-transfer.sh
|
||||
# Task 5 — file browser UI "Mosaik-Ablage"
|
||||
- apps/web/src/app/globals.css
|
||||
- apps/web/src/components/nextcloud-files/file-types.ts
|
||||
- apps/web/src/components/nextcloud-files/file-types.test.ts
|
||||
- apps/web/src/components/nextcloud-files/file-type-contrast.test.ts
|
||||
- apps/web/src/components/nextcloud-files/file-format.ts
|
||||
- apps/web/src/components/nextcloud-files/file-format.test.ts
|
||||
- apps/web/src/components/nextcloud-files/selection.ts
|
||||
- apps/web/src/components/nextcloud-files/selection.test.ts
|
||||
- apps/web/src/components/nextcloud-files/paths.ts
|
||||
- apps/web/src/components/nextcloud-files/paths.test.ts
|
||||
- apps/web/src/components/nextcloud-files/drop-entries.ts
|
||||
- apps/web/src/components/nextcloud-files/use-transfers.ts
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/Toolbar.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/SelectionBar.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/Breadcrumb.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/FileList.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/FileGrid.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/TypeTile.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/EntryMenu.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/DropOverlay.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/TransferBar.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/TransferBar.test.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/NameDialog.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/MoveDialog.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/DeleteDialog.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/QuotaMeter.tsx
|
||||
# Task 6 — server identity on the connect screen, registration check, docs, changelog, full gates, screenshots
|
||||
- apps/api/src/nextcloud-files/nextcloud-server-info.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-server-info.spec.ts
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/ServerIdentity.tsx
|
||||
- CHANGELOG.md
|
||||
- docs/anleitung-anwender.md
|
||||
- docs/anleitung-administration.md
|
||||
- docs/anleitung-betrieb.md
|
||||
autonomous: true
|
||||
requirements: [QUICK-261008-mzu]
|
||||
|
||||
estimate:
|
||||
tokens: 380000
|
||||
raw_tokens: 380000
|
||||
tasks: 6
|
||||
confidence: low
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "An administrator or a user with Verwalten stores exactly one Nextcloud address for the organisation in the module tab Einstellungen, checks it with 'Verbindung prüfen' (status.php: version or a plain German reason), and changing the address after users connected requires an explicit confirmation and marks every existing connection as expired; Benutzen-only users get 403 on GET/PUT settings and POST settings/test"
|
||||
- "A user without two-factor login connects with Nextcloud user name and password in the Tessera form; the API exchanges them once via GET /ocs/v2.php/core/getapppassword, stores only the app password AES-encrypted via CryptoService together with the Nextcloud uid from /ocs/v2.php/cloud/user, discards the real password, and no API response, log line or error ever contains either secret"
|
||||
- "A user with two-factor login who tries the password form gets the code credentialsOrTwoFactor with a plain explanation and the switch 'Im Browser anmelden'; Tessera starts Login Flow v2, shows a real link (target _blank, opened by the user's click) to the flow page on the configured host, polls only {configured base}/index.php/login/v2/poll server-side every 2 s while showing 'Warten auf Bestätigung in Nextcloud …' with Abbrechen, and switches to the connected state after 'Zugriff gewähren' — proven in the browser against the local test Nextcloud with a TOTP account"
|
||||
- "Tessera never lets the shared server IP run into or prolong the Nextcloud brute-force lock: at most 3 failed password logins per user in 15 minutes and 8 in 30 minutes for the whole server; any Nextcloud 429 on any call pauses ALL calls to that Nextcloud for the lock period without a single request reaching it; after the first 401 for a stored app password every further and in-flight call with that credential is stopped before or while reaching Nextcloud and the account is marked expired; the Einstellungen tab and the administration guide explain the Nextcloud whitelist for the Tessera server IP"
|
||||
- "Abmelden revokes the app password at Nextcloud (DELETE /ocs/v2.php/core/apppassword, the Tessera device entry disappears from the Nextcloud account) and deletes the local row; a reconnect revokes the previous app password first; a freshly issued app password that could not be stored is revoked at once; an app password is never sent to any host other than the one it was issued for"
|
||||
- "A user can never see or act on another user's Nextcloud account or files: every account access is bound to tenant AND user (RLS policy plus forTenant with the token's user id), and a second Tessera user of the same organisation gets 409 notConnected on the file routes while the first one is connected"
|
||||
- "A connected user browses folders with a breadcrumb, switches list/grid (remembered per user), sees type tiles per file family and real previews through the Tessera proxy, sees used/available storage (-3 means unlimited), creates folders, renames, moves via a folder picker and deletes to the Nextcloud trash after a confirmation, single or multi-selected, with mouse, row menu, right click and keyboard (Enter, Backspace/Alt+Up, Entf, F2, Ctrl+A)"
|
||||
- "Uploads by drag and drop (onto the folder area or onto a folder row) and by file chooser work for files over 10 MiB through the Next.js /api-proxy in 8 MiB chunks via Nextcloud Chunked Upload v2, show per-file progress, cancel and plain error text in the docked Übertragungsleiste, and never overwrite silently: a name clash shows 'Ersetzen', 'Beide behalten', 'Überspringen'"
|
||||
- "Downloads stream without buffering (file as attachment with nosniff, folder or multi-selection as ZIP); every non-2xx Nextcloud status on a transfer route is mapped to the shared error contract before a single byte is piped and never reaches the browser as 401 or 403; every Nextcloud request goes only to the configured base URL with fixed path prefixes and segment-encoded paths, follows no redirect, carries no cookie, and never calls a URL taken from a Nextcloud response"
|
||||
artifacts:
|
||||
- path: "apps/api/prisma/migrations/20261008180000_nextcloud_files/migration.sql"
|
||||
provides: "NextcloudFilesConfig (tenant policy) and NextcloudFilesAccount with tenant_isolation_policy bound to tenant AND user (current_user_id), ENABLE/FORCE RLS"
|
||||
contains: "current_user_id()"
|
||||
- path: "apps/api/src/nextcloud-files/nextcloud-http.ts"
|
||||
provides: "single transport wrapper: base-URL-bound URL builder, segment validation/encoding, call-gate checks, no redirects, no cookies, timeouts, capped reads, failure kinds"
|
||||
exports: ["buildNcUrl", "parseUserPath", "validateSegment", "encodeSegments", "ncRequest", "readCappedText"]
|
||||
- path: "apps/api/src/nextcloud-files/nextcloud-call-gate.ts"
|
||||
provides: "server-wide pause per Nextcloud origin after any 429; per-credential short-circuit and abort after the first 401"
|
||||
exports: ["NextcloudCallGate"]
|
||||
- path: "apps/api/src/nextcloud-files/nextcloud-auth-client.ts"
|
||||
provides: "getAppPassword, getCurrentUser, revokeAppPassword, startLoginFlow, pollLoginFlow (fixed poll path), ocsRequest"
|
||||
- path: "apps/api/src/nextcloud-files/nextcloud-files-account.service.ts"
|
||||
provides: "status, password connect with login guard, Login Flow start/poll/cancel, orphan revoke, disconnect with revoke, getSession (tenant+user bound, credential gate), markExpired"
|
||||
- path: "apps/api/src/nextcloud-files/nextcloud-upstream.ts"
|
||||
provides: "mapNcFailure (one error contract, never 401/403) and sendUpstreamStream (header allowlist, map before pipe)"
|
||||
exports: ["mapNcFailure", "sendUpstreamStream"]
|
||||
- path: "apps/api/src/nextcloud-files/nextcloud-files-transfer.service.ts"
|
||||
provides: "streaming upload (single + chunks + async assembly state), streaming download/zip"
|
||||
- path: "apps/web/src/lib/nextcloud-files-upload.ts"
|
||||
provides: "browser uploader: 8 MiB Blob.slice chunks, XHR progress, retries, abort with cleanup, assembly polling"
|
||||
- path: "apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx"
|
||||
provides: "file browser with list/grid, selection bar, keyboard, row menu, dialogs, drop overlay, transfer bar"
|
||||
- path: "apps/web/src/app/(portal)/modules/nextcloud-files/components/ConnectPanel.tsx"
|
||||
provides: "connect screen: server identity, password form, Login Flow v2 waiting state"
|
||||
key_links:
|
||||
- from: "apps/api/src/nextcloud-files/nextcloud-files-account.service.ts"
|
||||
to: "CryptoService.encrypt / decrypt"
|
||||
via: "only the app password column, never the user password"
|
||||
pattern: "crypto\\.encrypt\\("
|
||||
- from: "apps/api/src/nextcloud-files/nextcloud-auth-client.ts pollLoginFlow"
|
||||
to: "{base}/index.php/login/v2/poll"
|
||||
via: "fixed path built from the configured base, poll.endpoint from the response is discarded"
|
||||
pattern: "/index\\.php/login/v2/poll"
|
||||
- from: "apps/api/src/nextcloud-files/nextcloud-http.ts ncRequest"
|
||||
to: "NextcloudCallGate"
|
||||
via: "isPaused/isDead checked before every transport call; 429 pauses the origin, 401 with a credential key kills that credential and aborts its in-flight calls"
|
||||
pattern: "gate\\.(isPaused|isDead|pause|markDead)"
|
||||
- from: "apps/api/src/nextcloud-files/nextcloud-files-account.service.ts getSession"
|
||||
to: "forTenant(prisma, tenantId, userId)"
|
||||
via: "account read bound to tenant AND the token's user id"
|
||||
pattern: "forTenant\\(this\\.prisma, tenantId, userId\\)"
|
||||
- from: "apps/api/src/nextcloud-files/nextcloud-files-transfer.service.ts"
|
||||
to: "mapNcFailure"
|
||||
via: "non-2xx upstream mapped before sendUpstreamStream pipes any byte"
|
||||
pattern: "mapNcFailure\\("
|
||||
- from: "apps/api/src/nextcloud-files/nextcloud-dav.ts move"
|
||||
to: "Nextcloud MOVE"
|
||||
via: "Overwrite: F unless an etag-checked replace was requested"
|
||||
pattern: "Overwrite"
|
||||
- from: "apps/api/src/nextcloud-files/nextcloud-files.controller.ts"
|
||||
to: "ModuleGuard"
|
||||
via: "class UseModule('nextcloud-files'); ModuleManage only on GET/PUT settings and POST settings/test"
|
||||
pattern: "@ModuleManage\\('nextcloud-files'\\)"
|
||||
- from: "apps/web/src/lib/nextcloud-files-upload.ts"
|
||||
to: "NEXTCLOUD_FILES_CHUNK_SIZE in @tessera/shared"
|
||||
via: "Blob.slice per chunk, below the 10 MiB Next.js proxy clone limit"
|
||||
pattern: "NEXTCLOUD_FILES_CHUNK_SIZE"
|
||||
- from: "apps/web/src/app/(portal)/modules/nextcloud-files/components/ConnectPanel.tsx"
|
||||
to: "Login Flow v2 page"
|
||||
via: "anchor with target _blank clicked by the user (DesktopExternalLinks handles it in the desktop app)"
|
||||
pattern: "target=\"_blank\""
|
||||
---
|
||||
|
||||
<objective>
|
||||
New Tessera module "Nextcloud-Dateien" (slug `nextcloud-files`), Etappe 1: each Tessera user connects their own account of the ONE company Nextcloud and works with their files inside Tessera — browse, preview, upload (also large files), download, create folders, rename, move, delete. Sharing and search are Etappe 2 (separate quick task); the architecture stays open for them.
|
||||
|
||||
Six tasks, executed strictly in order by one executor each (revision 2 after plan check). Every task is self-contained: it names its files, rebuilds what it needs, and proves its slice with specs plus an e2e script against the local test Nextcloud.
|
||||
|
||||
Locked decisions from the request (cited below as L-xx — NON-NEGOTIABLE):
|
||||
- L-01 Exactly ONE Nextcloud per organisation; an administrator or a user with "Verwalten" sets its address in the module settings. The address may be internal. SSRF containment per research: only this base URL, relative segment-encoded paths, no redirects, never call URLs from Nextcloud answers, an address change marks all connections expired.
|
||||
- L-02 Every Tessera user connects their own account: user name + password in the Tessera form → `getapppassword` → only the app password is stored, AES-encrypted via CryptoService; the real password is discarded immediately.
|
||||
- L-03 Two-factor login is used at the company and MUST work: after a 401 offer Login Flow v2; the link is opened by a real user click (no script-opened window after an async call); Tessera polls `{Basis}/index.php/login/v2/poll` server-side and never `poll.endpoint` from the answer. The desktop app opens `target=_blank` links through its document-level helper (memory: Web-Helfer lauscht auf document).
|
||||
- L-04 Brute force: own failed-attempt limit per user in Tessera; never repeat on 429; understandable message; admin documentation of the Nextcloud whitelist for the Tessera server IP.
|
||||
- L-05 Abmelden: revoke the app password at Nextcloud + delete it locally.
|
||||
- L-06 Etappe-1 functions: folder browsing (breadcrumb), list/grid switch (remembered per user), previews through a Tessera proxy, upload via drag & drop and file chooser incl. large files (browser chunks 8 MiB → API → Nextcloud Chunked Upload v2; respect the 10 MiB Next proxy limit), download (streaming, folders as ZIP), create folder, rename, move (target folder picker), delete (trash, with confirmation), multi-selection, storage display (quota; -3 = unlimited), overwrite protection (Overwrite: F / If-None-Match) with an understandable conflict message.
|
||||
- L-07 Rights: using the module = "Benutzen" (everyone only their own account); setting the Nextcloud address = administrator or "Verwalten".
|
||||
- L-08 No dashboard widget in Etappe 1 (`WIDGET_MODULE_SLUGS` unchanged).
|
||||
- L-09 Design "Mosaik-Ablage" (user: "streng dich beim Design an, kein 0815", but within Mosaik — existing tokens, font, PageHeader; no new font, no new primary colour): type tiles per file family (rounded square in a muted family colour, short code like "PDF"/"XLS", families Text/Dokument, Tabelle, Präsentation, PDF, Bild, Audio/Video, Archiv, Code, Sonstiges; colours from tokens or derived harmonically in OKLCH, light AND dark checked); images show real previews; folders = tile in a muted accent-yellow. ONE bold element: drag & drop + transfer — dragging files over the window tints the folder area with a subtle diagonal accent stripe pattern and shows the target folder name large ("In „Projekte“ ablegen"); folder rows/tiles are drop targets themselves; running transfers appear in a bottom-docked, collapsible "Übertragungsleiste" with an accent progress bar per file, cancel button and plain-language error state. Everything else calm and disciplined. Dense list like a real file view (no card per file): type tile, name, size right-aligned tabular-nums, modified relative ("vor 3 Std.") with absolute tooltip, subtle row hover, context actions via row menu (⋯) and right click. Grid: preview surfaces with the name below. Selection bar replaces the toolbar ("3 ausgewählt" + actions); motion only in response to user action; prefers-reduced-motion respected. Keyboard: Enter open, Backspace/Alt+↑ up, Entf delete, F2 rename, Strg+A all; visible focus. Connect screen not a stock form: name/logo of the Nextcloud (theming via status.php/capabilities, else host), short everyday explanation (password is not stored), form; for 2FA a clear switch to "Im Browser anmelden" with waiting state and Abbrechen. Empty folders/errors give instructions ("Dateien hierher ziehen oder hochladen"). No ALL-CAPS labels (file-type short codes are the requested exception), no arrow buttons, no middle-dot meta strings, no decorative numbering. Texts formal Sie, German, no tenant/licence words. Mobile: list, actions via row menu, transfer bar at the bottom.
|
||||
- L-10 Browser check in dark AND light mode with screenshots against a real local Nextcloud; real E2E of both login paths (Login Flow v2 incl. "Zugriff gewähren" via Playwright), upload > 10 MiB (chunks), download, rename/move/delete. Test container name `tessera-nc-test`.
|
||||
- L-11 Project rules: static NestJS routes before `:id` routes; the API TS lib lacks `Object.hasOwn`; local DB only via the db container IP; rebuild with `docker compose up -d --build api web`; never read .env files; de/en keys identical; umlaut guard (placeholder names like `{query}` trip it — use e.g. `{term}`); local admin admin/admin123; CHANGELOG under "## Unveröffentlicht" → "### Neu"; extend the Anwender- and Administrationsanleitung.
|
||||
|
||||
Claude's discretion (decided here, apply as written):
|
||||
- D-A Identity: slug `nextcloud-files`, registry name "Dateien" (clear, short, as suggested; the Nextcloud name appears on the connect screen and in the account bar), version '1.0.0', category `infrastructure` (next to Nextcloud-Status; admins can move it), description de "Dateien Ihrer Nextcloud ansehen, hochladen, herunterladen und ordnen" / en "View, upload, download and organise the files in your Nextcloud", isSystem true. New `ModuleIconId` `folder` (lucide folder path `M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z`) so it differs from Nextcloud-Status' cloud. Messages namespace `nextcloudFiles`, title "Dateien" / "Files".
|
||||
- D-B Data model, migration `20261008180000_nextcloud_files` (both tables in Task 1): enums `NextcloudFilesAccountStatus { ACTIVE EXPIRED }`, `NextcloudFilesConnectMethod { PASSWORD LOGIN_FLOW }`; `NextcloudFilesConfig` (id uuid, `tenantId String @unique`, `baseUrl String`, createdAt, updatedAt, `@@index([tenantId])`) with `tenant_isolation_policy` on tenant only; `NextcloudFilesAccount` (id uuid, tenantId, `userId` → `User` `onDelete: Cascade` with back-relation `nextcloudFilesAccounts` on User, `baseUrl String` = the address the app password was issued for, `ncUserId String`, `ncDisplayName String?`, `encryptedAppPassword String`, `status NextcloudFilesAccountStatus @default(ACTIVE)`, `connectedVia NextcloudFilesConnectMethod`, createdAt, updatedAt, `@@unique([tenantId, userId])`, `@@index([tenantId])`) with the Reminder-form policy (tenant AND `current_user_id() IS NULL OR "userId" = current_user_id()`), NO `system_read_policy` (no background reader). An account counts as expired when `status = EXPIRED` OR `account.baseUrl !== config.baseUrl`.
|
||||
- D-C Transport (`nextcloud-http.ts`): `undici.request` (not fetch: Node streams as body, no redirect following, body as Readable), injected as `NextcloudTransport` (Nest token `NEXTCLOUD_TRANSPORT`, default = undici wrapper) so every spec runs on a fake. Every URL = `${baseUrl}${fixedPrefix}${encodeSegments(segments)}` — fixed prefixes only `/status.php`, `/ocs/v2.php/`, `/index.php/login/v2`, `/index.php/login/v2/poll`, `/index.php/core/preview`, `/remote.php/dav/files/{uid}/`, `/remote.php/dav/uploads/{uid}/`, `/index.php/apps/theming/image/logo`, `/core/img/logo/logo.svg`. Segments validated by `validateSegment` and encoded one by one with `encodeURIComponent`; `{uid}` encoded the same way. Any 3xx = failure kind `redirect`. No cookie header ever sent, `set-cookie` never forwarded. User-Agent `Tessera (Nextcloud-Dateien)` on every call (Nextcloud shows it as the device name). OCS calls add `OCS-APIRequest: true` and `Accept: application/json`. Timeouts: headers 15 s / body 15 s for OCS, PROPFIND and small calls; chunk PUT headers 120 s, body idle 30 s; assembly MOVE headers 30 min; downloads headers 30 s, body idle 60 s. Text reads capped: OCS 1 MiB, PROPFIND 32 MiB, status/capabilities 256 KiB. TLS certificates are always verified (no switch to turn verification off; internal CA via `NODE_EXTRA_CA_CERTS`, documented). The shared `isPublicHttpUrl` guard is intentionally NOT used (internal clouds are allowed, L-01) — say so in the header comment.
|
||||
- D-D Error contract — the API never answers 401 or 403 for a Nextcloud-side failure (the web treats those as a Tessera session/permission problem); every error body is `{ code, message }` with a German message, the web maps codes to i18n texts and falls back to `message`:
|
||||
`notConfigured` 409 · `notConnected` 409 · `connectionExpired` 409 (stored app password got 401 or its credential is already dead → account marked EXPIRED) · `accountBroken` 500 (decrypt failed; loud, logged without value) · `credentialsOrTwoFactor` 422 · `useBrowserLogin` 422 (Nextcloud 403 on getapppassword) · `tooManyAttempts` 429 with `retryAfterSeconds` (Tessera's own limiter) · `nextcloudLocked` 503 with `retryAfterSeconds` (Nextcloud answered 429 now or the origin is paused) · `nextcloudMaintenance` 503 · `nextcloudRedirect` 502 · `nextcloudUnavailable` 504 (timeout/network/tls) · `nextcloudError` 502 · `notFound` 404 · `nameTaken` 409 (405 on MKCOL, 412 on MOVE/PUT/assembly) with optional `existing { etag, size, mtime }` · `changedMeanwhile` 409 (etag no longer matches) · `pathConflict` 409 (Nextcloud 409: parent missing / folder into itself) · `moveIntoItself` 400 · `locked` 409 (423) · `notAllowed` 422 (Nextcloud 403 on a file op) · `invalidName` 400 (Nextcloud 400/415 or own name check) · `invalidPath` 400 · `quotaExceeded` 507 · `lengthRequired` 411 · `chunkTooLarge` 413 · `fileTooLarge` 413 · `flowExpired` 410 · `tooManyFlows` 503 · `invalidUrl` 400 · `confirmReconnect` 409 with `connectedAccounts`. One function `mapNcFailure` (Task 3) applies it for every file and transfer route.
|
||||
- D-E Login guard (`nextcloud-login-guard.ts`, process-wide, injectable clock): failed password logins (Nextcloud 401 on getapppassword) are counted per Tessera user (max 3 per 15 min) and for the whole server (max 8 per 30 min — the server shares ONE IP and Nextcloud locks at 10 per 30 min); when a limit is reached the next attempt gets 429 `tooManyAttempts` (retryAfterSeconds = until the oldest counted failure leaves the window) WITHOUT calling Nextcloud; a successful login clears only that user's failures. Login Flow starts are limited to 10 per user per 10 min (429) — flow init is not counted by Nextcloud's brute-force protection (measured), so it never touches the failure counters. Nextcloud 429 handling lives in the call gate (D-O), not here.
|
||||
- D-F Login Flow state (class `LoginFlowStore` in the guard file): in memory `Map<flowId, { tenantId, userId, baseUrl, pollToken, expiresAt, lastPollAt }>`, flowId = `randomUUID()`, TTL 20 min, at most one flow per user (a new start replaces the old one), at most 200 flows in total (503 `tooManyFlows`), expired entries pruned on every access, the Nextcloud poll is called at most once per 1.5 s per flow (faster browser polls get `pending` without a Nextcloud call). The poll token never leaves the server. The browser link is rebuilt as `${baseUrl}/index.php/login/v2/flow/${token}` where token is taken from the returned `login` path with `/login\/v2\/flow\/([A-Za-z0-9]{32,256})$/`; the returned `poll.endpoint` and the origin of `login` are discarded. An API restart loses open flows — the user simply starts again. `clearTenant(tenantId)` drops a tenant's flows on an address change.
|
||||
- D-G Settings: `PUT settings { baseUrl, confirmReconnect? }` normalises with the existing `normalizeCloudUrl` (exported from `apps/api/src/nextcloud-status/nextcloud-status-fetch.ts`), invalid → 400 `invalidUrl`; unchanged → returns the view; changed while ACTIVE accounts exist and `confirmReconnect !== true` → 409 `confirmReconnect` with `connectedAccounts`; on save upsert the config, `updateMany` all accounts of the tenant to EXPIRED, notify the address-change listeners (Task 2 registers `LoginFlowStore.clearTenant`, Task 6 the server-info cache), then run the status check and return `{ baseUrl, connectedAccounts, check }`. `POST settings/test { baseUrl }` runs the existing `fetchNextcloudStatus` (status.php only, no credentials) unless the origin is paused by the call gate (then `{ ok: false, kind: 'paused' }` without a request), and maps to `{ ok, kind, message, version, productName }`; `http-status` with 400 → "Nextcloud lehnt diese Adresse ab. Bitte nehmen Sie den Rechnernamen in die vertrauenswürdigen Domains (trusted_domains) der Nextcloud auf."; a successful check that only worked after redirects is reported as a hint to enter the final https address (runtime requests never follow redirects). `GET settings` → `{ baseUrl, connectedAccounts }`. `GET status` → `{ configured, serverUrl, host, account }` (account from Task 2).
|
||||
- D-H Paths: the browser sends paths only in query/JSON (never in the Tessera URL path). `validateSegment(s)` rejects empty, '.', '..', '/', '\\', NUL or control characters (U+0000–U+001F, U+007F) and more than 255 UTF-8 bytes; `parseUserPath(raw)` → string[]: '' or '/' = root; one leading and one trailing '/' stripped; every segment through `validateSegment`; more than 100 segments or more than 4096 characters → 400 `invalidPath`. New names (mkdir, rename target) additionally must not end with '.part' and must not be blank → 400 `invalidName`. Names are passed through unchanged (no Unicode normalisation). Entry names come from the decoded `href` segments, never from `displayname`.
|
||||
- D-I File API (Task 3; all Benutzen, all under the caller's own account): `GET files?path=` → `{ path, entries, quota: { used, available | null }, truncated }` (max 5000 entries, folder itself skipped, its quota used, negative available → null = unlimited); entry `{ name, path, type: 'folder'|'file', size, mime, mtime, etag, fileId, permissions, hasPreview, favorite }`; `POST folders { path }` (MKCOL); `POST move { from, to }` (MOVE with `Overwrite: F`; `to` equal to or inside `from` → 400 `moveIntoItself` before any call); `DELETE files?path=` (DELETE → Nextcloud trash); `GET preview?fileId=&v=`. Etappe 2 stays open: the DAV layer keeps a generic `davRequest`, the auth client an `ocsRequest`, entries keep the permission letters (R = shareable), the row menu takes an action list.
|
||||
- D-J Upload protocol (Task 4; stateless towards Nextcloud, which holds the chunks): `POST uploads { path, size, replaceEtag? }` → PROPFIND Depth 0 on the target: exists and no replaceEtag → 409 `nameTaken` with `existing`; replaceEtag given but the current etag differs → 409 `changedMeanwhile`; size > `NEXTCLOUD_FILES_MAX_CHUNKS` × chunk → 413 `fileTooLarge`; size ≤ chunk → `{ mode: 'single' }`; else MKCOL `/remote.php/dav/uploads/{uid}/tessera-<uuid>` with `Destination` → `{ mode: 'chunked', uploadId, chunkSize }`. `PUT uploads/file?path=&size=&mtime=&replaceEtag=` → raw body streamed to `PUT /remote.php/dav/files/{uid}/<path>` with `If-None-Match: *` (or `If-Match: <replaceEtag>`), `X-OC-Mtime`. `PUT uploads/:uploadId/chunks/:n?path=&size=` → n 1..10000 sent as five-digit name (`00001`), `Destination` + `OC-Total-Length`. `POST uploads/:uploadId/complete { path, size, mtime?, replaceEtag? }` → MOVE `.file` with `Destination`, `OC-Total-Length`, `X-OC-Mtime`, `Overwrite: F` (with replaceEtag: re-check the etag by PROPFIND Depth 0 immediately before, then `Overwrite: T`); the MOVE runs in the background: the route waits up to 20 s and answers `{ state: 'done' }` or 202 `{ state: 'assembling' }`; `GET uploads/:uploadId/state` answers done / assembling / failed `{ code, message }` (state kept in memory per tenant+user+uploadId for 10 min after the end; reason: the Next.js proxy and Nginx Proxy Manager cut idle requests after 30–60 s while Nextcloud assembles large files); on 412 the upload folder is deleted. `DELETE uploads/:uploadId` → DELETE the upload folder. Raw-body routes: `content-length` required (411), > `NEXTCLOUD_FILES_CHUNK_SIZE` → 413 `chunkTooLarge`, the Express request stream is handed to the transport unread (no multer interceptor of any kind, nothing buffered); client abort destroys the upstream request. uploadId must match `^tessera-[0-9a-f-]{36}$`; mtime integer 0..4102444800; size integer 0..(10000 × chunk).
|
||||
- D-K Download/preview headers (Tasks 3–4): every upstream answer is checked first — any non-2xx (and any gate failure) goes through `mapNcFailure` and becomes a JSON error BEFORE `sendUpstreamStream` writes a header or pipes a byte. Only an allowlist goes to the browser — content-type, content-length, content-range, accept-ranges, etag, last-modified; downloads always get `Content-Disposition: attachment; filename="<ASCII fallback>"; filename*=UTF-8''<encodeURIComponent(name)>` built by Tessera, `X-Content-Type-Options: nosniff`, `Content-Security-Policy: default-src 'none'; sandbox`, `Cache-Control: private, no-store`. ZIP name = folder name + ".zip" (root or selection in root: "Dateien.zip"). Multi-selection ZIP: every name runs through `validateSegment` (400 `invalidPath` before any call); Task 4 first measures against the test container whether `GET .../dav/files/{uid}/<dir>/?accept=zip&files=<JSON array of names>` returns a ZIP with exactly the chosen entries; if yes `download/zip` uses it, if not the route answers 404 `notFound` and the web downloads each selected item on its own (files directly, folders as ZIP, 400 ms apart) — the measured result goes into the SUMMARY. Preview: `GET /index.php/core/preview?fileId=<id>&x=256&y=256&a=1&forceIcon=0` (Nextcloud rounds to size steps; CSS scales), fileId `^\d{1,20}$`, only `image/*` passed (else 404), cap 5 MiB, `Cache-Control: private, max-age=86400` when `v` (the etag) is present, else `private, max-age=3600`; no preview → 404 and the UI shows the type tile.
|
||||
- D-L UI architecture: page = PageHeader (moduleSlug, title, description, `actions` = AccountBar when connected) + TabBar (Dateien / Einstellungen) only for managers, plain content for Benutzen users; states: not configured → hint card; not connected or expired → ConnectPanel; connected → FileBrowser. Folder path lives in component state and is mirrored to `?path=` with `window.history.replaceState` (reload keeps the folder; no `useSearchParams` — avoids the Next 15 Suspense build error on a prerendered route). View mode per user in localStorage `tessera:nextcloud-files:view:<userId>` (precedent `sort-clouds.ts`, user id from `useAuthStore`). Type-family colours are CSS tokens `--ft-<family>-bg`/`--ft-<family>-fg` in `:root` and `.dark` of `globals.css` (exact values in Task 5), checked for ≥ 4.5:1 contrast in both modes by a test.
|
||||
- D-M Test harness: helper scripts in `.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/` (committed with the task commits; test-only values from the research; they never read .env). Nextcloud address for local tests `http://172.17.0.1:18080` — reachable from the api container (host gateway, verified 2026-10-08 against another host port) AND from the host browser used by Playwright, so Login Flow v2 works end-to-end. Brute-force whitelist `172.16.0.0/12` on the test Nextcloud so repeated test runs never lock the IP; 429 behaviour is covered by unit specs. `e2e-lib.sh` holds the shared helpers (admin login with cookie jar, HTTP status helper, module activation, address setup, anna connect, second Tessera user).
|
||||
- D-N Rights and route order (`@Controller('modules/nextcloud-files')`, class `@UseModule('nextcloud-files')`): Verwalten (`@ModuleManage('nextcloud-files')`, never with a role decorator) = GET settings, PUT settings, POST settings/test. Benutzen (class guard only) = everything else: GET status, GET server, GET server/logo, POST connect/password, POST connect/flow, DELETE connect, GET files, DELETE files, POST folders, POST move, GET preview, GET download, GET download/zip, POST uploads, PUT uploads/file; then the parameter routes at the END: GET connect/flow/:flowId, DELETE connect/flow/:flowId, PUT uploads/:uploadId/chunks/:n, POST uploads/:uploadId/complete, GET uploads/:uploadId/state, DELETE uploads/:uploadId. Each task inserts its statics before the parameter block and its parameter routes at the end; the controller spec asserts the declaration order.
|
||||
- D-O Call gate (`nextcloud-call-gate.ts`, one process-wide `@Injectable() NextcloudCallGate`, injectable clock), enforced inside `ncRequest` so no caller can bypass it: (a) server-wide pause per Nextcloud origin — ANY 429 on ANY call (login, flow, OCS, DAV, preview, transfer, status check) pauses that origin for the lock period (15 min, or the answer's `Retry-After` seconds when present, capped at 60 min); while paused every `ncRequest` to that origin returns `{ ok: false, kind: 'paused', retryAfterSeconds }` WITHOUT calling the transport, mapped to 503 `nextcloudLocked`; a different origin is unaffected. (b) per-credential short-circuit — calls made with a stored app password carry a `credentialKey` (first 16 hex chars of sha256 over the encrypted value, computed by `getSession`); the first 401 on such a call marks the key dead (kept 24 h, max 10 000 keys) and aborts every in-flight request of that key (the gate holds one AbortController per live key; `ncRequest` combines it with its own signal via `AbortSignal.any`); later calls with a dead key return kind 'credential-dead' WITHOUT calling the transport; `getSession` checks the key first and marks the account EXPIRED (409 `connectionExpired`). A 401 on a call without credentialKey (password login) marks nothing.
|
||||
- D-P App-password hygiene (Task 2): a freshly issued app password (password path or Login Flow) that is not stored — `cloud/user` fails, encryption or the upsert throws, the flow was cancelled meanwhile — is revoked at once, best effort (DELETE `/ocs/v2.php/core/apppassword` with that password, 10 s, errors only logged without value). On reconnect with an existing row whose baseUrl equals the current address and whose value decrypts, the OLD app password is revoked (best effort) before the new row is written; a row for another address is never revoked (never send it to a different host).
|
||||
|
||||
Output: migration + two models, API module (transport + call gate, auth client, login guard, settings/account/files/transfer services, upstream mapper, server info, controller, seed), web module (settings, connect screen, file browser), uploader, tests, e2e scripts, docs, changelog, rebuilt local stack, screenshots light + dark. Six atomic commits on main, NOT pushed.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/gsd-core/workflows/execute-plan.md
|
||||
@~/.claude/gsd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/STATE.md
|
||||
@./CLAUDE.md
|
||||
@.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/261008-mzu-RESEARCH.md
|
||||
|
||||
Discovered facts the executor can rely on (verified during planning on 2026-10-08):
|
||||
- Closest module template is `domains` (built today, quick-261008-dts): `apps/api/src/domains/{domains.controller.ts, domains.module.ts, domains.seed.ts, domains-settings.service.ts, domains.types.ts}` (class `@UseModule`, `requireTenantId(req)` from `req.tenantId`, `@ModuleManage` on manage handlers, header comment with the rights table and the route-order rule, errors as `{ code, message }` exceptions, seed with try/catch logging 'Domains module seeded in registry'), `apps/web/src/app/(portal)/modules/domains/{page.tsx, layout.tsx, components/SettingsTab.tsx, domains-page.test.tsx}` (TabBar from `@/components/accounting/tab-bar`, `SettingsSection` from `@/components/control-center/settings-section` with title/description/actions/footer/flush, `PageHeader` from `@/components/layout/page-header` with title/description/actions/moduleSlug, `useCanManageModule` from `@/lib/use-module-capability` — null while loading → treat as false; page test renders with `NextIntlClientProvider locale="de" messages={de}` and mocks the api module via `vi.mock(..., importOriginal)`).
|
||||
- Nextcloud-Status (`apps/api/src/nextcloud-status/nextcloud-status-fetch.ts`) exports `normalizeCloudUrl(raw): string | null` and `fetchNextcloudStatus(baseUrl, { fetchImpl?, timeoutMs? })` → `{ reachable, maintenance, versionString, productName, errorKind ('timeout'|'network'|'tls'|'http-status'|'not-nextcloud'|'too-large'|'redirect'), errorDetail }` — reuse both. Its logo route (`GET instances/:id/logo` with `@Res()`) is the precedent for an `<img>`-loaded API route authenticated by the Tessera cookie.
|
||||
- `CryptoService` (`apps/api/src/crypto/crypto.service.ts`) comes from the GLOBAL CryptoModule — inject, do not import a module; `encrypt(plain)` → `iv:authTag:ciphertext`, `decrypt` throws on bad input. `PrismaService` is global. `forTenant(prisma, tenantId, userId?)` from `apps/api/src/prisma/prisma-tenant.extension.ts` sets `app.current_tenant` and `app.current_user` per operation — pass the userId for every per-user account access; settings-side access (count, expire-all) binds only the tenant. Never use `include:` or relation `select:` in this module (rls inventory). One `const tenantPrisma = forTenant(...)` per method so the inventory detector sees it.
|
||||
- RLS gates: `apps/api/src/prisma/rls-coverage.spec.ts` needs ENABLE + FORCE + `tenant_isolation_policy` per new table; the user-dimension policy form is in `apps/api/prisma/migrations/20260929140000_reminder/migration.sql`. `apps/api/src/prisma/rls-access-inventory.spec.ts` compares every (file, model) Prisma access against the Fundstellentabelle in `docs/mandantentrennung-zugriffsklassifikation.md` (Bereichszeile like `| domains | 0 | 30 | 0 | ... |` around line 184, Paarzählung paragraph around line 420, Fundstellen rows like the `domains-settings.service.ts` row around line 901). Recount with the Gate-Schleife, never copy numbers.
|
||||
- Latest migration: `20261008160000_domains_drop_default_nameservers`. Local DB has no host port: `IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1)`, `DATABASE_URL="postgresql://tessera:tessera_dev@$IP:5432/tessera"` for `pnpm --filter @tessera/api exec prisma migrate deploy|status|diff`. The api container also runs migrate deploy on start.
|
||||
- `apps/api/src/main.ts` registers only cookieParser, ValidationPipe (whitelist, transform) and CORS — no raw/large body parser; an `application/octet-stream` request body stays an unread stream on `req`. The request log (`apps/api/src/common/request-log.ts`) logs the path without the query string — Tessera routes therefore keep file paths in query/body only. `undici` 7.28.0 and `fast-xml-parser` 5.10.1 are already API dependencies — no new packages (no package legitimacy gate needed). Node 24 provides `AbortSignal.any`.
|
||||
- Next.js rewrite `/api-proxy/:path*` → `API_INTERNAL_URL` (`apps/web/next.config.ts`); the web image is built with `NEXT_PUBLIC_API_URL=/api-proxy` (`apps/web/Dockerfile` line 28), so the browser at http://localhost:3000 goes through the proxy, which silently cuts request bodies after 10 MiB and has a 30 s idle timeout (research, Next 15.5.19 source). `experimental.middlewareClientMaxBodySize` stays untouched.
|
||||
- `@tessera/shared` is consumed from source (`packages/shared/package.json` main `src/index.ts`) — a new export needs no build step.
|
||||
- Web registration points: `apps/web/src/lib/module-loader.ts` (dynamic import, ssr false, e.g. `domains` around line 80), `apps/web/src/lib/module-identity.ts` (`ModuleIconId` union currently `'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'shopping-bag' | 'cloud' | 'earth' | 'tile'` + ICONS map), `apps/web/src/components/modules/module-tile.tsx` (GLYPHS keyed by ModuleIconId), `apps/web/src/lib/stores/nav-store.ts` (`MODULE_TITLE_KEYS`), `apps/web/src/app/(portal)/modules/module-layouts.test.tsx` (it.each of slug + layout). Layout = `ModuleAccessGate` (`@/components/modules/module-access-gate`).
|
||||
- Desktop app: `apps/web/src/components/desktop/` `DesktopExternalLinks` listens on `document` and opens `target=_blank` anchors itself — a plain anchor clicked by the user works in browser AND desktop. Desktop downloads of same-origin `<a download>` links are handled by `on_download` in `apps/desktop/src-tauri/src/lib.rs` — not re-verified here, listed for the user check in the SUMMARY.
|
||||
- Per-user browser preference precedent: `apps/web/src/components/nextcloud-status/sort-clouds.ts` (`readSortPreference/writeSortPreference(userId)`, key `tessera:nextcloud-status:sort:<userId>`, try/catch around localStorage) with `useAuthStore((s) => s.user?.id ?? null)` from `@/lib/stores/auth-store`.
|
||||
- Design tokens (`apps/web/src/app/globals.css`): `--primary` is the Mosaik yellow `oklch(0.91 0.19 102)` in both modes, `--primary-strong`, `--card` (light `oklch(1 0 0)`, dark `oklch(0.245 0.01 260)`), `--well`, `--tile`, `--muted-foreground`, status tokens `--status-ok|warn|down|idle` with `-fg` variants (Tailwind classes `bg-status-warn/12 text-status-warn-fg`), global button classes `.btn .btn-primary .btn-secondary .btn-subtle .btn-icon`, `.surface`, `.cc-section*`, reduced-motion media queries already present; font stack is `--font-sans` — no new font. `apps/web/src/lib/color.ts` exports `relativeLuminance(hex)` and `readableOnAccent(hex)`. Tailwind 4.3.1 (has `motion-safe:`, `motion-reduce:`, `pointer-coarse:` variants). No CSS modules in the project — new tokens go into globals.css.
|
||||
- Dialog/menu precedent: custom dialogs with `role="dialog"` (e.g. `apps/web/src/app/(portal)/modules/nextcloud-status/components/CloudForm.tsx`); there is no shared menu component — build `EntryMenu` with `role="menu"`/`menuitem`, Escape closes, focus returns to the trigger.
|
||||
- Category `infrastructure` exists in `MODULE_CATEGORIES` (`packages/shared/src/index.ts`), German label "Infrastruktur" (renameable by admins).
|
||||
- Users: `POST /users` (admin only, body `{ username, email, password (min 8), displayName?, role? }`, roles SUPER_ADMIN/ADMIN/USER) — new users usually carry `mustChangePassword`, which `ForcePasswordChangeInterceptor` enforces (only `POST /auth/change-password` with `{ currentPassword, newPassword }`, `POST /auth/logout`, `GET /auth/me` pass). Admins pass the ModuleGuard of an activated module without a Freigabe. The executor reads `apps/api/src/user/user.controller.ts`, `apps/api/src/user/dto/create-user.dto.ts` and `apps/api/src/auth/auth.controller.ts` before writing the second-user helper.
|
||||
- Local stack running: web :3000 (production build through /api-proxy), api :3001, db, mailhog; `admin`/`admin123` logs in at `POST http://localhost:3001/auth/login` (JSON `{ username, password }`, cookie jar; cookies ignore the port, so the same jar works for `http://localhost:3000/api-proxy/...`); `GET /modules/catalog` → `[{ id, slug, isActiveForTenant }]`; `POST /modules/<id>/activate`; `GET /health`. Rebuild with `docker compose up -d --build api web` (plain `up` does not rebuild). The api container reaches host ports via `172.17.0.1` (measured: `http://172.17.0.1:3002` → 200), the host has `172.17.0.1` on docker0. Image `nextcloud:stable` (34.0.4) is present locally; no Nextcloud test container is running yet. Playwright MCP writes screenshots under `.playwright-mcp/` (gitignored).
|
||||
- Pitfall from STATE.md ("Tautologischer Test"): specs against an external system assert the literal shape of the outgoing call (method, exact URL, exact header set, exact body written out in the test), never values rebuilt with the production helper. Literals: `anna:geheim` → `Basic YW5uYTpnZWhlaW0=`; `anna:app-pw-123` → `Basic YW5uYTphcHAtcHctMTIz`; `encodeURIComponent('Ärger & Ölpreis 100%.txt')` = `%C3%84rger%20%26%20%C3%96lpreis%20100%25.txt`; `a b#c?d.txt` → `a%20b%23c%3Fd.txt`; `50%25.txt` → `50%2525.txt`.
|
||||
- Commits: German subject, prefix `feat(nextcloud-files):`, body ends with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`. Never push (the user bundles pushes). PLAN/SUMMARY/STATE are committed by the orchestrator; the e2e scripts go into the task commits. No deploy to the test server. Never read .env files.
|
||||
|
||||
@apps/api/src/domains/domains.controller.ts
|
||||
@apps/api/src/domains/domains-settings.service.ts
|
||||
@apps/api/src/nextcloud-status/nextcloud-status-fetch.ts
|
||||
@apps/api/src/nextcloud-status/nextcloud-status.controller.ts
|
||||
@apps/api/src/crypto/crypto.service.ts
|
||||
@apps/api/prisma/migrations/20260929140000_reminder/migration.sql
|
||||
@apps/web/src/app/(portal)/modules/domains/page.tsx
|
||||
@apps/web/src/app/(portal)/modules/domains/components/SettingsTab.tsx
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Foundation slice — a manager sets and checks the Nextcloud address in the new module (DB, transport with call gate, settings service, controller, module registration, Einstellungen tab) against the local test Nextcloud</name>
|
||||
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261008180000_nextcloud_files/migration.sql, apps/api/src/nextcloud-files/nextcloud-files.types.ts, apps/api/src/nextcloud-files/nextcloud-http.ts, apps/api/src/nextcloud-files/nextcloud-http.spec.ts, apps/api/src/nextcloud-files/nextcloud-call-gate.ts, apps/api/src/nextcloud-files/nextcloud-call-gate.spec.ts, apps/api/src/nextcloud-files/nextcloud-files-settings.service.ts, apps/api/src/nextcloud-files/nextcloud-files-settings.service.spec.ts, apps/api/src/nextcloud-files/dto/nextcloud-files-settings.dto.ts, apps/api/src/nextcloud-files/nextcloud-files.controller.ts, apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts, apps/api/src/nextcloud-files/nextcloud-files.seed.ts, apps/api/src/nextcloud-files/nextcloud-files.module.ts, apps/api/src/app.module.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-files-api.ts, apps/web/src/app/(portal)/modules/nextcloud-files/layout.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/SettingsTab.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx, apps/web/src/app/(portal)/modules/module-layouts.test.tsx, apps/web/src/lib/module-loader.ts, apps/web/src/lib/module-identity.ts, apps/web/src/components/modules/module-tile.tsx, apps/web/src/lib/stores/nav-store.ts, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/nc-test-setup.sh, .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/totp.php, .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/totp.py, .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh, .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-settings.sh</files>
|
||||
<precondition>The local stack (db, api, web) is running, `admin`/`admin123` logs in at http://localhost:3001/auth/login, the image `nextcloud:stable` is present (`docker image inspect nextcloud:stable`) and host port 18080 is free or already used by `tessera-nc-test`.</precondition>
|
||||
<behavior>
|
||||
- nextcloud-http: `buildNcUrl('https://cloud.example/nc', '/remote.php/dav/files/', ['anna', 'Ärger & Ölpreis 100%.txt'])` = `https://cloud.example/nc/remote.php/dav/files/anna/%C3%84rger%20%26%20%C3%96lpreis%20100%25.txt`; segments `a b#c?d.txt` and `50%25.txt` encode to the literals in context; a fixed prefix outside the allowed list throws before any call; `parseUserPath('')` and `('/')` → []; `('/Projekte/2026/')` → ['Projekte','2026']; '/a//b', '/a/../b', '/./a', 'a\\b', a NUL, U+0007, a 256-byte segment, 101 segments → BadRequest code invalidPath; `validateSegment('..')` throws invalidPath; `ncRequest` (fake transport) sends `user-agent: Tessera (Nextcloud-Dateien)`, never a cookie header, and the Authorization literal `Basic YW5uYTpnZWhlaW0=` for anna/geheim; a 302 answer → failure kind 'redirect' and the transport is called exactly once (no follow); a hanging transport with a 20 ms timeout → 'timeout'; ENOTFOUND → 'network'; CERT_HAS_EXPIRED → 'tls'; `readCappedText` over its cap → 'too-large'; JSON.stringify of any failure never contains 'Basic ' or the password.
|
||||
- Call gate + ncRequest (fake clock, fake transport): a 429 from `https://cloud.example` → the next ncRequest to any path of that origin returns kind 'paused' with retryAfterSeconds 900 and the transport is NOT called; a request to `https://other.example` still goes out; `Retry-After: 120` → pause of 120 s; `Retry-After: 99999` → capped at 3600 s; after the pause ends calls go out again; a 401 on a call with credentialKey 'k1' → a second call with 'k1' that is still pending gets its transport signal aborted and resolves as 'credential-dead', a later call with 'k1' returns 'credential-dead' without a transport call, a call with 'k2' goes out; a 401 on a call without credentialKey marks nothing; dead keys expire after 24 h and the store never holds more than 10 000 keys.
|
||||
- Settings service (mocked prisma via forTenant, mocked status fetcher, real gate): GET settings without row → `{ baseUrl: null, connectedAccounts: 0 }`; PUT ' https://Cloud.Example/nc/index.php ' stores `https://Cloud.Example/nc` per normalizeCloudUrl; 'ftp://x' → 400 invalidUrl; same address again → no write; a new address with 2 ACTIVE accounts and no confirmReconnect → 409 confirmReconnect with connectedAccounts 2 and no write; with confirmReconnect true → config upserted, accounts updateMany to EXPIRED for the tenant, every registered address-change listener called with the tenant id; the test endpoint maps a reachable status to ok true with version, 'http-status' + 'HTTP 400' to the trusted-domains text, timeout to a German timeout text; a paused origin → `{ ok: false, kind: 'paused' }` and the status fetcher is NOT called; getStatus → `{ configured, serverUrl, host, account: null }`.
|
||||
- Controller metadata: class MODULE_SLUG_KEY 'nextcloud-files'; getSettings, saveSettings, testSettings have MODULE_MANAGE_KEY true and no ROLES_KEY; getStatus has no MODULE_MANAGE_KEY; a reusable assertion "every handler whose path contains ':' is declared after all static handlers" (index check over Object.getOwnPropertyNames of the prototype) passes and is kept for later tasks.
|
||||
- Web (page test with mocked `@/lib/nextcloud-files-api` and `@/lib/use-module-capability`): not configured + manager → hint with a button to Einstellungen; not configured + Benutzen → hint to ask an administrator, no Einstellungen tab and no TabBar; manager sees the tabs Dateien and Einstellungen; SettingsTab: "Verbindung prüfen" calls testNextcloudFilesSettings once per click (disabled while running) and shows the message; saving a changed address with connected users shows the confirmation with the number of users and only the confirmed save sends `confirmReconnect: true`; the hints card shows the whitelist command.
|
||||
</behavior>
|
||||
<action>
|
||||
**Test Nextcloud first (D-M, L-10).** Write the helper scripts under `.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/` with the Write tool (no heredocs). `nc-test-setup.sh` (bash, `set -euo pipefail`, idempotent): when container `tessera-nc-test` does not exist, `docker run -d --name tessera-nc-test -p 18080:80 -e SQLITE_DATABASE=nextcloud -e NEXTCLOUD_ADMIN_USER=admin -e NEXTCLOUD_ADMIN_PASSWORD='Admin-Pass-12345' -e NEXTCLOUD_TRUSTED_DOMAINS='localhost 127.0.0.1 172.17.0.1 tessera-nc-test' nextcloud:stable`; start it if stopped; wait (max 180 s) until `curl -s http://localhost:18080/status.php` contains `"installed":true`; then idempotently: users `anna` ('User1-Pass-12345', display name "Anna Müller") and `zoe` ('User2-Pass-12345', display name "Zwei Faktor") via `occ user:add --password-from-env` (skip when `occ user:info` succeeds), group `twofa` with zoe and `occ twofactorauth:enforce --on --group=twofa`, TOTP for zoe via `totp.php` (copied into the container with `docker cp`; research snippet: `secret` mode prints a new secret, code mode enables) and `totp.py` (research snippet, run with `python3 -I`), storing the secret inside the container at `/var/www/zoe-totp-secret` (skip when the file exists and `occ twofactorauth:state zoe` lists totp), brute-force whitelist `occ config:app:set bruteForce whitelist_0 --value=172.16.0.0/12`, and finally prove reachability from the api container with `docker compose exec -T api node -e` fetching `http://172.17.0.1:18080/status.php` (exit non-zero if not 200). Print `nc test ready`. `e2e-lib.sh` (sourced, no top-level side effects): `API=http://localhost:3001`, `WEB=http://localhost:3000/api-proxy`, `NC_BASE=http://172.17.0.1:18080`, `E2E_DIR`, functions `e2e_login <jar> [user] [password]`, `e2e_status <jar> <method> <url> [json-body] [outfile]` (prints the HTTP status, body to the outfile), `e2e_activate <jar>` (catalog lookup + activate when inactive), `e2e_set_address <jar>` (PUT settings with `NC_BASE` and `confirmReconnect: true`). Run `nc-test-setup.sh` now.
|
||||
|
||||
**Schema + migration (D-B).** In `apps/api/prisma/schema.prisma` after the Domains models add a German comment block (quick-261008-mzu; one Nextcloud per organisation, own account per user, only the app password encrypted, RLS like Reminder) with the two enums and two models exactly as in D-B, plus the back-relation on `User`. Get the exact DDL with `prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --script` against the local DB, then hand-write `apps/api/prisma/migrations/20261008180000_nextcloud_files/migration.sql`: German header (purpose of both tables; why the account row stores the issuing baseUrl — an app password is never sent to another host, an address change expires all rows; config policy tenant-only, account policy tenant AND user in the Reminder form — this is what keeps one user from ever reading another user's account; no system_read_policy because nothing reads across tenants; rights via ALTER DEFAULT PRIVILEGES; switch-is-off note like the Reminder header), the enums, tables, indexes, FK, then per table ENABLE + FORCE ROW LEVEL SECURITY and the policy. `prisma generate`, apply locally via the container IP, `migrate status` up to date, `migrate diff ... --exit-code` exits 0.
|
||||
|
||||
**Transport, call gate, types (D-C, D-D, D-H, D-O).** `nextcloud-files.types.ts`: the error-code union of D-D, `ncError(code, httpStatus, message, extra?)` building the Nest HttpException with body `{ code, message, ...extra }`, the German default messages, `NcFailureKind` ('redirect' | 'timeout' | 'network' | 'tls' | 'too-large' | 'invalid-response' | 'http' | 'paused' | 'credential-dead'), shared view types (`NextcloudFilesStatusView`, `NextcloudFilesSettingsView`, `NcSession { baseUrl, ncUserId, authorization, credentialKey }`). `nextcloud-call-gate.ts`: `@Injectable() NextcloudCallGate` with `isPaused(origin)` → `{ paused, retryAfterSeconds }`, `pause(origin, retryAfterHeader?)`, `isDead(key)`, `markDead(key)` (aborts the key's controller), `signalFor(key)`; German comment: the brute-force lock of Nextcloud is per IP and hits every Tessera user, every further request during the lock prolongs or wastes it, so the gate stops traffic instead of retrying; a revoked app password must not hammer Nextcloud (each 401 counts as a failed login there). `nextcloud-http.ts`, framework-free apart from the gate type: `NextcloudTransport` type and `undiciTransport` default (wraps `request` from undici, passes `headersTimeout`/`bodyTimeout`/`signal`, returns `{ statusCode, headers, body }`), the allowed prefix list, `buildNcUrl`, `validateSegment`, `encodeSegments`, `parseUserPath`, `validateNewName`, `basicAuth(user, secret)`, `ncRequest(transport, gate, opts)` that checks the gate before the transport, updates it after the answer (429 → pause, 401 with credentialKey → markDead), never throws for network/HTTP problems (returns `{ ok: true, status, headers, body }` or `{ ok: false, kind, status, retryAfterSeconds? }`; certificate error codes copied from `apps/api/src/proxmox/proxmox-client.service.ts`; the upstream body of a failure is drained/destroyed), and `readCappedText(body, maxBytes)`. German header comment: why internal addresses are allowed and how SSRF is contained (L-01: one base URL, fixed prefixes, segment encoding, no redirects, no response URLs, address change expires), why undici `request`, no cookies, never log headers. Specs `nextcloud-call-gate.spec.ts` and `nextcloud-http.spec.ts` per `<behavior>`.
|
||||
|
||||
**Settings service, DTO, controller, seed, module (D-A, D-G, D-N, L-01, L-07).** `dto/nextcloud-files-settings.dto.ts`: `SaveNextcloudFilesSettingsDto { baseUrl (IsString, IsNotEmpty, MaxLength 2048); confirmReconnect? (IsBoolean) }`, `TestNextcloudFilesSettingsDto { baseUrl }`. `nextcloud-files-settings.service.ts` (inject PrismaService, NextcloudCallGate, an injectable status fetcher token defaulting to `fetchNextcloudStatus`): `getBaseUrl(tenantId)`, `getStatus(tenantId)`, `getSettings`, `saveSettings`, `testAddress`, `onAddressChange(listener)` per D-G — the only file touching `nextcloudFilesConfig`, plus tenant-bound `nextcloudFilesAccount.count`/`updateMany` for the reconnect logic; each method its own `forTenant(this.prisma, tenantId)` client. `nextcloud-files.controller.ts`: `@Controller('modules/nextcloud-files')`, class `@UseModule('nextcloud-files')`, `requireTenantId` like DomainsController; handlers `@Get('status') getStatus`; `@Get('settings') @ModuleManage('nextcloud-files') getSettings`; `@Put('settings') @ModuleManage(...) saveSettings`; `@Post('settings/test') @HttpCode(200) @ModuleManage(...) testSettings`. German header comment with the full rights table of D-N, the route-order rule (later tasks add statics before the parameter block and parameter routes at the end), never a role decorator on manage handlers, never 401/403 for a Nextcloud failure (D-D). `nextcloud-files.seed.ts` per D-A (pattern domains.seed.ts). `nextcloud-files.module.ts` imports ModuleRegistryModule, provides the settings service, NextcloudCallGate, `{ provide: NEXTCLOUD_TRANSPORT, useValue: undiciTransport }` and the status-fetcher token; OnModuleInit seeds with try/catch and logs 'Nextcloud files module seeded in registry'. Register in `apps/api/src/app.module.ts` next to DomainsModule. Controller spec per `<behavior>`; `module-manage-handlers.spec.ts`: a `NextcloudFilesController` manage it.each (`getSettings`, `saveSettings`, `testSettings`) and a Benutzen-level it.each (`getStatus`) that later tasks extend.
|
||||
|
||||
**RLS doc.** Run `pnpm --filter @tessera/api exec vitest run rls-coverage rls-access-inventory`; add the Bereichszeile `nextcloud-files`, update Summenzeile and Paarzählung, and add Fundstellen rows for `nextcloud-files-settings.service.ts` / `nextcloudFilesConfig` and / `nextcloudFilesAccount` (tenant-bound admin operations: count and expire-all), `muss-mandantengebunden` / `gebunden`, German explanations; counted with the Gate-Schleife; both specs green.
|
||||
|
||||
**Web (L-01, L-07, D-A, D-L).** `apps/web/src/lib/nextcloud-files-api.ts` (pattern `apps/web/src/lib/nextcloud-status-api.ts`: `NEXT_PUBLIC_API_URL`, `credentials: 'include'`, `cache: 'no-store'` on GETs): class `NextcloudFilesRequestError(status, code, message, extra)`, types, `getNextcloudFilesStatus`, `getNextcloudFilesSettings`, `saveNextcloudFilesSettings`, `testNextcloudFilesSettings`. `layout.tsx` = ModuleAccessGate "nextcloud-files". `page.tsx` ('use client'; `max-w-6xl` wrapper): `canManage = useCanManageModule('nextcloud-files') === true`, loads status, PageHeader (moduleSlug, title, description), TabBar only for managers ('files' | 'settings'); Dateien tab: not configured → SettingsSection hint (manager: "Zu den Einstellungen"; others: "Bitte wenden Sie sich an einen Administrator oder an jemanden mit der Freigabestufe Verwalten."), configured → the section `data-testid="nextcloud-files-main"` into which Task 2 mounts the connect screen. `components/SettingsTab.tsx` (SettingsSection cards): "Nextcloud-Adresse" (input, hint "Tragen Sie die Adresse so ein, wie Ihre Benutzer die Nextcloud im Browser aufrufen, zum Beispiel https://cloud.ihre-firma.de. Interne Adressen sind erlaubt.", footer "Verbindung prüfen" + "Speichern", result line, connected-users line, the reconnect confirmation "{count} Benutzer sind verbunden. Nach dem Wechsel müssen sich alle neu anmelden." / "Adresse ändern" / "Abbrechen"), "Hinweise für die Nextcloud-Administration" (whitelist of the Tessera server IP with `occ config:app:set bruteForce whitelist_0 --value=<IP-Adresse des Tessera-Servers>` in a code element and why: all Tessera users share one IP; https recommended; the host must be in trusted_domains; two-factor accounts connect via the browser). Registrations: module-loader `nextcloud-files`; module-identity `folder` in the union + `'nextcloud-files': 'folder'`; module-tile GLYPHS `folder` (D-A path); nav-store `'nextcloud-files': 'nextcloudFiles.title'`; module-layouts test entry. Messages: namespace `nextcloudFiles` in de.json (Sie, real umlauts, no `{query}` placeholder names) and en.json with identical keys; run the umlaut guard, allowlist only correct tokens. Page test per `<behavior>`.
|
||||
|
||||
**Run.** Biome-lint the touched files (`pnpm exec biome lint <files>` from the repo root; `biome check --write` only on new files). Rebuild `docker compose up -d --build api web`, wait for `curl -sf http://localhost:3001/health`, check `docker compose logs api` for 'Nextcloud files module seeded in registry'. Write `e2e/e2e-settings.sh` (bash, `set -euo pipefail`, sources e2e-lib.sh): admin login; activate; PUT settings with `NC_BASE` + confirmReconnect → 200 and `check.ok` true; POST settings/test `NC_BASE` → 200, `"ok":true` and a version starting with `34.`; POST settings/test `http://172.17.0.1:1` → 200 with `"ok":false`; PUT `ftp://x` → 400 with `invalidUrl`; GET status → `"configured":true` and host `172.17.0.1:18080`; GET settings → contains `"baseUrl":"http://172.17.0.1:18080"`; print `e2e settings ok`. Run the `<verify>` command. Commit `feat(nextcloud-files): Modul Dateien mit Nextcloud-Adresse, Verbindungsprüfung und gesicherter Verbindungsschicht` (attribution line). Do not push.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-files rls-coverage rls-access-inventory module-manage-handlers && pnpm --filter @tessera/web exec vitest run modules/nextcloud-files module-layouts src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -nE '^\s*@Roles\(' apps/api/src/nextcloud-files/nextcloud-files.controller.ts)" && bash .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/nc-test-setup.sh && bash .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-settings.sh && echo "task1 ok"</automated>
|
||||
<fails_when>a spec (transport, call gate, settings, controller, rls, manage handlers, page, layouts, messages) fails, a tsc run fails, the controller carries a role decorator, the test Nextcloud cannot be set up or reached from the api container, or any e2e-settings.sh step fails (activation, save, check with version 34, unreachable address, invalid address, status, settings)</fails_when>
|
||||
</verify>
|
||||
<done>Migration applied locally without drift (both tables, account policy bound to tenant and user); transport, call gate, settings and controller specs green; module seeded, activatable and registered in loader/icon/nav/layouts; a manager saves and checks the address of the real test Nextcloud through the rebuilt stack; RLS gates green; commit on main, not pushed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Connect slice — a user connects their own Nextcloud account by password or, with two-factor login, by Login Flow v2 in the browser, and disconnects with revoke (auth client, login guard, account service with credential gate and app-password hygiene, connect screen) proven against the TOTP account</name>
|
||||
<files>apps/api/src/nextcloud-files/nextcloud-auth-client.ts, apps/api/src/nextcloud-files/nextcloud-auth-client.spec.ts, apps/api/src/nextcloud-files/nextcloud-login-guard.ts, apps/api/src/nextcloud-files/nextcloud-login-guard.spec.ts, apps/api/src/nextcloud-files/nextcloud-files-account.service.ts, apps/api/src/nextcloud-files/nextcloud-files-account.service.spec.ts, apps/api/src/nextcloud-files/dto/nextcloud-files-connect.dto.ts, apps/api/src/nextcloud-files/nextcloud-files-settings.service.ts, apps/api/src/nextcloud-files/nextcloud-files-settings.service.spec.ts, apps/api/src/nextcloud-files/nextcloud-files.controller.ts, apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts, apps/api/src/nextcloud-files/nextcloud-files.module.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/lib/nextcloud-files-api.ts, apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/ConnectPanel.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/AccountBar.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts, .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-connect.sh</files>
|
||||
<precondition>Task 1 is committed: the module is seeded, `nc-test-setup.sh` prints `nc test ready`, and the test Nextcloud address is saved in the module settings.</precondition>
|
||||
<behavior>
|
||||
- nextcloud-auth-client (fake transport, real gate, base `http://cloud.example`): getAppPassword sends exactly GET `http://cloud.example/ocs/v2.php/core/getapppassword` with Authorization `Basic YW5uYTpnZWhlaW0=`, `ocs-apirequest: true`, `accept: application/json`; 200 `{ocs:{data:{apppassword:'app-pw-123'}}}` → `{ ok: true, appPassword: 'app-pw-123' }`; 401 → kind 'credentials' (the gate marks nothing); 403 → 'app-password-given'; 429 → 'locked' and the gate pauses the origin; getCurrentUser GET `/ocs/v2.php/cloud/user` with `Basic YW5uYTphcHAtcHctMTIz` → `{ id: 'anna', displayName: 'Anna Müller' }` (reads `display-name`, falls back to `displayname`); revokeAppPassword sends DELETE `http://cloud.example/ocs/v2.php/core/apppassword`; startLoginFlow sends POST `http://cloud.example/index.php/login/v2` and, for an answer whose `login` is `http://evil.example/login/v2/flow/<128 alnum>` and `poll.endpoint` `http://evil.example/login/v2/poll`, returns loginUrl `http://cloud.example/index.php/login/v2/flow/<same token>` and the poll token; a `login` without a matching token → kind 'invalid-response'; pollLoginFlow sends POST to exactly `http://cloud.example/index.php/login/v2/poll` with body `token=<token>` and content-type `application/x-www-form-urlencoded` — no request in the whole spec ever targets evil.example; 404 → `{ state: 'pending' }`; 200 `{server:'http://evil.example', loginName:'zoe', appPassword:'x'}` → `{ state: 'granted', loginName: 'zoe', appPassword: 'x' }` (server ignored).
|
||||
- Login guard (fake clock): 3 recorded failures of user u1 → 4th check throws 429 tooManyAttempts with retryAfterSeconds > 0, user u2 still allowed; after 15 min u1 allowed again; 8 failures spread over users within 30 min → every user blocked; recordSuccess(u1) clears only u1; the 11th flow start of a user within 10 min → 429. LoginFlowStore: create returns a uuid flowId; a second create for the same user removes the first; get by another user or tenant → undefined; after 20 min → expired; 201st flow → 503 tooManyFlows; `shouldPoll` is false within 1.5 s of the last poll; clearTenant drops only that tenant's flows.
|
||||
- Account service (fake transport, real gate, CryptoService mock encrypt → 'enc(<plain>)', decrypt reverses): connectWithPassword(anna/geheim) → exactly two calls (getapppassword, cloud/user), upsert with `encryptedAppPassword: 'enc(app-pw-123)'`, ncUserId 'anna', connectedVia PASSWORD, status ACTIVE, baseUrl = config; the response and JSON.stringify of every call argument except the upsert payload contain neither 'geheim' nor 'app-pw-123'; Nextcloud 401 → 422 credentialsOrTwoFactor and a recorded failure; 4th failure for the same user → 429 without any transport call; Nextcloud 429 → 503 nextcloudLocked and the next attempt (any user) → 503 without a transport call; not configured → 409 notConfigured. App-password hygiene (D-P): cloud/user failing after a successful getapppassword → exactly one DELETE apppassword with `Basic YW5uYTphcHAtcHctMTIz`, no upsert, error mapped; upsert throwing → the same DELETE, error rethrown; reconnect with an existing ACTIVE row for the same baseUrl holding 'enc(old-pw)' → DELETE apppassword with the old credential BEFORE the upsert of the new one; existing row for another baseUrl → no DELETE to any host, row overwritten. Flow: startFlow → `{ flowId, loginUrl, expiresAt }` without the poll token; pollFlow pending → `{ state: 'pending' }`; granted → getCurrentUser with the granted credentials, upsert with LOGIN_FLOW, flow removed, `{ state: 'connected' }`; granted but cloud/user failing → revoke of the granted password, `{ state: 'failed', code }`; cancelFlow after Nextcloud already granted on the next poll is not possible (flow removed first) — a granted answer for a flow that was cancelled meanwhile revokes the password; pollFlow for a flowId of another user → 404 notFound; expired flow → 410 flowExpired. Disconnect: ACTIVE account → DELETE apppassword with `Basic YW5uYTphcHAtcHctMTIz` to the account's baseUrl, then deleteMany where tenantId + userId; revoke failing (500 or timeout) → row still deleted; account.baseUrl different from the current config → no transport call at all, row deleted; decrypt throwing → no transport call, row deleted, error logged without value; no row → 409 notConnected. getSession: user B (no row) → 409 notConnected while user A has a row, and the prisma mock proves forTenant was called with B's user id and a where containing userId B; EXPIRED row or baseUrl mismatch → 409 connectionExpired; a row whose credentialKey the gate already marked dead → status set to EXPIRED via updateMany + 409 connectionExpired, no transport call; decrypt failure → 500 accountBroken; success → NcSession with credentialKey = first 16 hex chars of sha256('enc(app-pw-123)'). getStatus maps EXPIRED or a baseUrl mismatch to account.status 'EXPIRED'. Every account prisma call uses forTenant with the caller's userId (spy).
|
||||
- Settings service: on an address change the LoginFlowStore of that tenant is cleared (registered listener).
|
||||
- Controller metadata: connectPassword, startFlow, pollFlow, cancelFlow, disconnect have no MODULE_MANAGE_KEY; pollFlow and cancelFlow are declared after all static handlers.
|
||||
- Web: configured + no account → ConnectPanel with fields Benutzername, Passwort, button "Anmelden" and the secondary action "Im Browser anmelden"; submit calls connectWithPassword once with the trimmed user name and clears the password field afterwards; error code credentialsOrTwoFactor shows the explanation and makes "Im Browser anmelden" the primary action; clicking it calls startLoginFlow once and then renders an anchor with href = loginUrl, target `_blank`, rel `noopener noreferrer`, while `window.open` is never called (spy); with fake timers pollLoginFlow is called every 2000 ms, stops on 'connected' (status reloaded) and on Abbrechen (cancelLoginFlow called); tooManyAttempts shows the waiting time in minutes; nextcloudLocked shows its own text; EXPIRED account shows the expiry notice above the form; connected → AccountBar "Angemeldet als Anna Müller" with Abmelden, which asks for confirmation and then calls disconnectNextcloud.
|
||||
</behavior>
|
||||
<action>
|
||||
**Auth client (L-02, L-03, L-05).** `nextcloud-auth-client.ts` on top of `ncRequest` (all calls pass through the gate): `getAppPassword(transport, gate, base, loginName, password)`, `getCurrentUser(...)`, `revokeAppPassword(...)` (10 s), `startLoginFlow(...)`, `pollLoginFlow(...)`, plus a generic `ocsRequest` helper (Etappe 2 reuses it for shares). German comment block: 401 is ambiguous (wrong password OR two-factor — measured, Nextcloud rejects 2FA accounts before checking the password), a 429 must never be repeated (the gate pauses everything), the poll endpoint and the login origin from the answer are discarded (they are built from the request's Host header — SSRF), an app password can not fetch another app password (403). Spec per `<behavior>`.
|
||||
|
||||
**Login guard (D-E, D-F, L-04).** `nextcloud-login-guard.ts`: `@Injectable() NextcloudLoginGuard` (checkPasswordAttempt, recordFailure, recordSuccess, checkFlowStart) and `@Injectable() LoginFlowStore` (create, get, remove, shouldPoll, markPolled, clearTenant), both with an injectable `now`. German comment: one shared server IP, Nextcloud locks at 10 failures / 30 min for ALL users, why 3/15 min and 8/30 min, memory only (a restart forgets counters — acceptable, Nextcloud's own protection and the call gate stay). Spec per `<behavior>`.
|
||||
|
||||
**Account service + DTO (D-B, D-D, D-F, D-O, D-P, L-02, L-03, L-05).** `dto/nextcloud-files-connect.dto.ts`: `ConnectPasswordDto { loginName (IsString, IsNotEmpty, MaxLength 200); password (IsString, IsNotEmpty, MaxLength 500) }`. `nextcloud-files-account.service.ts` (inject PrismaService, CryptoService, NextcloudFilesSettingsService, NextcloudLoginGuard, LoginFlowStore, NextcloudCallGate, `NEXTCLOUD_TRANSPORT`): `getStatus(tenantId, userId)` → settings status plus `account: null | { status, ncUserId, displayName, connectedVia, connectedAt }`; `connectWithPassword` (guard check → getapppassword → 401 recordFailure + 422 credentialsOrTwoFactor, 403 → 422 useBrowserLogin, 429/paused → 503 nextcloudLocked, redirect 502, timeout/network/tls 504, other 502 → cloud/user with the NEW app password → private `storeAppPassword(tenantId, userId, base, ncUser, appPassword, method)` implementing D-P (revoke old same-host credential, encrypt with `this.crypto.encrypt(appPassword)`, upsert on `tenantId_userId`, on any failure revoke the new one and rethrow) → recordSuccess; the password variable is never stored, logged or returned); `startFlow`, `pollFlow`, `cancelFlow` per D-F and `<behavior>`; `disconnect` per `<behavior>` (revoke only when the row's baseUrl equals the current address, errors only logged, then deleteMany where tenantId + userId); `getSession(tenantId, userId)` → NcSession `{ baseUrl, ncUserId, authorization, credentialKey }` after the checks in `<behavior>` (dead key → markExpired); `markExpired(tenantId, userId)`. Every method its own `forTenant(this.prisma, tenantId, userId)` client — the user id always from the token, never from input. In the settings service register `LoginFlowStore.clearTenant` as an address-change listener (module wiring or constructor injection; extend its spec). Specs per `<behavior>`.
|
||||
|
||||
**Controller + RLS doc (D-N).** Add statics before the parameter block: `@Post('connect/password') @HttpCode(200) connectPassword`, `@Post('connect/flow') startFlow`, `@Delete('connect') disconnect`; at the end `@Get('connect/flow/:flowId') pollFlow` and `@Delete('connect/flow/:flowId') cancelFlow` (ParseUUIDPipe); `getStatus` now delegates to the account service. Provide the new services in the module. Extend the controller spec and the Benutzen-level it.each in `module-manage-handlers.spec.ts` (`connectPassword`, `startFlow`, `pollFlow`, `cancelFlow`, `disconnect`). RLS doc: add the Fundstellen row `nextcloud-files-account.service.ts` / `nextcloudFilesAccount` (bound to tenant AND user; `muss-mandantengebunden` / `gebunden`), update Bereichszeile, Summenzeile, Paarzählung with the Gate-Schleife.
|
||||
|
||||
**Web (L-02, L-03, L-05, L-09).** Extend `nextcloud-files-api.ts`: `connectWithPassword`, `startLoginFlow`, `pollLoginFlow`, `cancelLoginFlow`, `disconnectNextcloud`. `page.tsx`: inside `nextcloud-files-main`, not connected or expired → ConnectPanel; connected → AccountBar in the PageHeader actions and the section `data-testid="nextcloud-files-browser"` into which Task 5 mounts the file browser. `components/AccountBar.tsx`: "Angemeldet als {name}" with the Nextcloud host as secondary text, button "Abmelden" with an inline confirmation ("Verbindung zu Nextcloud trennen? Tessera vergisst den Zugang. Ihre Dateien in Nextcloud bleiben unverändert." / "Trennen" / "Abbrechen"). `components/ConnectPanel.tsx` (centered card, `max-w-lg`): a header slot for the server identity (Task 6 adds logo/name; here the host), the explanation "Melden Sie sich mit Ihrem Nextcloud-Konto an. Tessera speichert Ihr Passwort nicht, sondern lässt sich von Nextcloud einen eigenen Zugang ausstellen, den Sie jederzeit widerrufen können.", form (Benutzername oder E-Mail, Passwort with `autoComplete="current-password"`, "Anmelden", busy state), divider "oder", "Im Browser anmelden" with the hint "Für Konten mit Zwei-Faktor-Anmeldung"; after a failure clear the password field; error texts per code (credentialsOrTwoFactor: "Die Anmeldung hat nicht geklappt. Entweder stimmen Benutzername oder Passwort nicht, oder Ihr Konto nutzt die Zwei-Faktor-Anmeldung. Dann melden Sie sich bitte im Browser an." and the browser action becomes primary; tooManyAttempts: "Zu viele Fehlversuche. Bitte warten Sie {minutes} Minuten."; nextcloudLocked: "Nextcloud sperrt Anfragen vom Tessera-Server vorübergehend. Bitte versuchen Sie es in {minutes} Minuten erneut."). Browser path: click → startLoginFlow → waiting view with the sentence "Öffnen Sie die Anmeldung bei Nextcloud, melden Sie sich dort an und bestätigen Sie mit „Zugriff gewähren“.", an anchor styled as primary button "Anmeldung bei Nextcloud öffnen" (`href={loginUrl} target="_blank" rel="noopener noreferrer"` — the user's own click opens it; never open a window from script after the async call, L-03), the status line "Warten auf Bestätigung in Nextcloud …" with a small pulsing dot (`motion-safe:animate-pulse`), button "Abbrechen"; poll every 2000 ms via setInterval plus an immediate poll on `visibilitychange` to visible; keep polling while the tab is hidden (the user is in the Nextcloud tab); stop on connected (reload status), flowExpired ("Die Anmeldung ist abgelaufen. Bitte starten Sie sie neu."), failed, Abbrechen (cancelLoginFlow) and unmount. Expired account: notice "Ihre Verbindung zu Nextcloud ist abgelaufen oder wurde in Nextcloud widerrufen. Bitte melden Sie sich neu an." above the form. Messages de + en, umlaut guard green. Page-test cases per `<behavior>`.
|
||||
|
||||
**Run.** Biome-lint touched files. Rebuild `docker compose up -d --build api web`, wait for /health, `nc-test-setup.sh`. Write `e2e/e2e-connect.sh` (bash, `set -euo pipefail`, sources e2e-lib.sh): admin login; activate; `e2e_set_address`; DELETE connect (200 or 409 notConnected accepted) for a clean start; POST connect/password anna/User1-Pass-12345 → 200, body contains `"ncUserId":"anna"` and neither `User1-Pass` nor `ncrypted`; GET status → `"status":"ACTIVE"`; `docker exec -u www-data tessera-nc-test php occ user:auth-tokens:list anna` lists exactly one token named like `Tessera (Nextcloud-Dateien)` (if the command does not exist in this Nextcloud version, query `oc_authtoken` in the container's SQLite file `/var/www/html/data/nextcloud.db` with `php -r` + PDO instead — decide once, note it in the SUMMARY); POST connect/password anna again (reconnect) → 200 and still exactly one Tessera token for anna (the old one was revoked, D-P); POST connect/password zoe/User2-Pass-12345 → 422 with `credentialsOrTwoFactor` (every zoe attempt counts as a failure in Tessera's own guard, so repeated runs within 15 minutes can legitimately hit the per-user limit: when the answer is 429 `tooManyAttempts`, the script runs `docker compose restart api`, waits for /health, logs in again and repeats the zoe attempt once, which must then answer 422 — the in-memory counters start fresh after a restart); POST connect/flow → 200 with a `loginUrl` starting with `http://172.17.0.1:18080/index.php/login/v2/flow/` and no `token`/`poll` field; GET connect/flow/<id> → `"state":"pending"`; DELETE connect/flow/<id> → 200; DELETE connect → 200 and no Tessera token is left in anna's token list; GET status → `"account":null`; print `e2e connect ok`. Browser proof with Playwright MCP (L-10, dark mode via the theme button): log in at http://localhost:3000 as admin, open the module, (a) connect anna by password → "Angemeldet als Anna Müller", Abmelden; (b) enter zoe → the two-factor explanation → "Im Browser anmelden" → click "Anmeldung bei Nextcloud öffnen" (new tab) → log in as zoe → enter the code from `python3 -I .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/totp.py "$(docker exec tessera-nc-test cat /var/www/zoe-totp-secret)"` → "Zugriff gewähren" → back in the Tessera tab "Angemeldet als Zwei Faktor" appears without reload; Abmelden. Save screenshots as `.playwright-mcp/nextcloud-files/t2-password-connected.png`, `t2-flow-waiting.png`, `t2-flow-connected.png` (copy them there if the MCP writes elsewhere). Run the `<verify>` command. Commit `feat(nextcloud-files): Anmeldung per Passwort und im Browser (Zwei-Faktor), Abmelden mit Widerruf` (attribution line). Do not push.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-files rls-coverage rls-access-inventory module-manage-handlers && pnpm --filter @tessera/web exec vitest run modules/nextcloud-files src/messages && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -nE '^\s*@Roles\(' apps/api/src/nextcloud-files/nextcloud-files.controller.ts)" && ! grep -v '^\s*\(//\|\*\|/\*\)' "apps/web/src/app/(portal)/modules/nextcloud-files/components/ConnectPanel.tsx" | grep -q 'window\.open(' && bash .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/nc-test-setup.sh && bash .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-connect.sh && test -f .playwright-mcp/nextcloud-files/t2-flow-connected.png && echo "task2 ok"</automated>
|
||||
<fails_when>an auth-client, guard, account, settings or controller spec or a web test fails, a tsc run fails, the role-decorator or script-opened-window gate trips, any e2e-connect.sh step fails (password connect, token in Nextcloud, reconnect leaves exactly one token, 2FA answer 422, flow start/pending/cancel, revoke on disconnect), or the Login Flow v2 browser proof screenshot is missing</fails_when>
|
||||
</verify>
|
||||
<done>Users connect by password or via Login Flow v2 (TOTP account proven in the browser), only the encrypted app password is stored, failures are limited per user and server-wide, a stored credential that gets a 401 stops all its traffic, fresh or replaced app passwords never stay orphaned, Abmelden removes the Nextcloud token; specs and e2e green; commit on main, not pushed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: File API slice — listing with quota, previews, new folder, rename/move and delete into the trash under the caller's own account, with one error contract, plus the web API functions; a second Tessera user gets notConnected</name>
|
||||
<files>apps/api/src/nextcloud-files/nextcloud-propfind.ts, apps/api/src/nextcloud-files/nextcloud-propfind.spec.ts, apps/api/src/nextcloud-files/nextcloud-dav.ts, apps/api/src/nextcloud-files/nextcloud-dav.spec.ts, apps/api/src/nextcloud-files/nextcloud-upstream.ts, apps/api/src/nextcloud-files/nextcloud-upstream.spec.ts, apps/api/src/nextcloud-files/nextcloud-files.service.ts, apps/api/src/nextcloud-files/nextcloud-files.service.spec.ts, apps/api/src/nextcloud-files/dto/nextcloud-files-ops.dto.ts, apps/api/src/nextcloud-files/nextcloud-files.controller.ts, apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts, apps/api/src/nextcloud-files/nextcloud-files.module.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, apps/web/src/lib/nextcloud-files-api.ts, apps/web/src/lib/nextcloud-files-api.test.ts, .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh, .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-files.sh</files>
|
||||
<precondition>Task 2 is committed: `e2e-connect.sh` prints `e2e connect ok` against the running stack.</precondition>
|
||||
<behavior>
|
||||
- parsePropfind (fixture XML written out in the spec, modelled on the research measurement: root folder response first with quota-used 1234 and quota-available -3; a folder 'Ärger & Co' whose href uses lower-case hex `%c3%84rger%20%26%20Co/` with a second propstat '404 Not Found' carrying getcontenttype/getcontentlength; a file '12345' with getcontentlength 42, getetag `"abc"`, fileid '00042', permissions 'RGDNVW', has-preview 'true', favorite '1'; a file '50%25.txt' with href `50%2525.txt`; base `https://cloud.example/nc`, uid 'anna', requested path ['Projekte']) → quota { used: 1234, available: null }; entries exactly [{ name: 'Ärger & Co', path: '/Projekte/Ärger & Co', type: 'folder', size from oc:size, mime: null, ... }, { name: '12345' (string, not number), type 'file', size 42, etag '"abc"', fileId '00042', permissions 'RGDNVW', hasPreview true, favorite true }, { name: '50%25.txt', ... }]; mtime as ISO string; a response whose href is outside the user's files prefix is skipped; more than 5000 entries → first 5000 and truncated true.
|
||||
- nextcloud-dav (fake transport, real gate, session base `https://cloud.example/nc`, uid 'anna', auth `Basic YW5uYTphcHAtcHctMTIz`, credentialKey 'k1'): list sends PROPFIND to `https://cloud.example/nc/remote.php/dav/files/anna/Projekte/` with `depth: 1`, content-type `application/xml` and the research propfind body; stat sends PROPFIND depth 0; mkdir sends MKCOL; move sends MOVE with `destination: https://cloud.example/nc/remote.php/dav/files/anna/Ziel/%C3%84rger%20%26%20%C3%96lpreis%20100%25.txt` and `overwrite: F`; delete sends DELETE; preview sends GET `https://cloud.example/nc/index.php/core/preview?fileId=42&x=256&y=256&a=1&forceIcon=0`; every call carries credentialKey 'k1' to the gate.
|
||||
- mapNcFailure (it.each over upstream 401, 403, 404, 405, 409, 412, 415, 423, 429, 500, 502, 503, 507 and the kinds paused, credential-dead, redirect, timeout, network, tls, too-large): 401 and credential-dead → onExpired callback + 409 connectionExpired; 403 → 422 notAllowed; 404 → 404 notFound; 405 and 412 → 409 nameTaken; 409 → 409 pathConflict; 400/415 → 400 invalidName; 423 → 409 locked; 429 and paused → 503 nextcloudLocked with retryAfterSeconds; 503 → 503 nextcloudMaintenance; 507 → 507 quotaExceeded; 500/502 → 502 nextcloudError; redirect → 502 nextcloudRedirect; timeout/network/tls → 504 nextcloudUnavailable; no input ever yields HTTP 401 or 403. sendUpstreamStream: for a 2xx upstream it writes only the allowlisted headers plus the given extra headers and pipes the body (an upstream `set-cookie` never appears); with an `accept` predicate (image/*) a text/html upstream is mapped to 404 without piping; over `maxBytes` the stream is cut and the response destroyed.
|
||||
- Files service (mocked account.getSession + dav): list maps failures through mapNcFailure (401 → markExpired called + 409 connectionExpired); mkdir 'x.part' → 400 invalidName without a call; move from '/A' to '/A/B' → 400 moveIntoItself without a call; delete 204 → ok; preview with fileId 'abc' → 400 without a call, content-type text/html → 404, image/png → streamed with `Cache-Control: private, max-age=86400` when `v` is set; getSession throwing notConnected for a second user → the route answers 409 notConnected.
|
||||
- Controller: list, remove, createFolder, move, preview have no MODULE_MANAGE_KEY; order assertion still passes.
|
||||
- Web api client: listFolder('/Ärger & Co') calls `<API>/modules/nextcloud-files/files?path=%2F%C3%84rger%20%26%20Co` with credentials include; error bodies become NextcloudFilesRequestError with code and extra; createFolder/moveEntry/deleteEntry send the documented method and body; previewUrl builds the query with encodeURIComponent.
|
||||
</behavior>
|
||||
<action>
|
||||
**PROPFIND parser (D-H, D-I).** `nextcloud-propfind.ts`: `PROPFIND_BODY` (research XML, exact), `parsePropfind(xml, { baseUrl, ncUserId, requested: string[] })` with `new XMLParser({ removeNSPrefix: true, parseTagValue: false, parseAttributeValue: false, isArray: (n) => ['response','propstat','share-type'].includes(n) })`; only propstat with status 200 counts; names from the href segments decoded one by one (strip the base sub-path + `/remote.php/dav/files/<uid>/` by comparing decoded segments, skip anything outside); first response = the folder itself (quota only); sizes as numbers, ids/etags/names as strings, `getlastmodified` → ISO; cap 5000 entries. German header comment on the measured quirks (multiple propstat, lower-case hex, -3 quota, string-only parsing). Spec per `<behavior>`.
|
||||
|
||||
**DAV layer (D-C, D-I).** `nextcloud-dav.ts`, framework-free on top of `ncRequest` (gate + session credentialKey on every call): generic `davRequest(transport, gate, session, method, prefix, segments, headers, body?, timeouts)` (Etappe 2: SEARCH/PROPPATCH fit here), `list`, `stat` (Depth 0, also used by Task 4), `mkdir`, `move` (`Destination` always built with `buildNcUrl` from the session base, never from user input; `Overwrite: F` unless the caller explicitly asks for a checked replace), `remove`, `preview`. Spec per `<behavior>` with literal URLs and header sets.
|
||||
|
||||
**Upstream mapping (D-D, D-K).** `nextcloud-upstream.ts`: `mapNcFailure(result, { onExpired })` → the Nest exception per D-D (the single place that turns Nextcloud answers into browser errors; German comment: never 401/403 to the browser, why) and `sendUpstreamStream(res, upstream, { extraHeaders, accept?, maxBytes? })` → maps a non-2xx or rejected content type through mapNcFailure BEFORE writing anything, else writes the allowlisted headers + extras and `pipeline(upstream.body, res)` (client abort destroys both sides). Spec per `<behavior>`.
|
||||
|
||||
**Files service + DTOs (D-D, D-H, D-I).** `dto/nextcloud-files-ops.dto.ts`: `PathQueryDto { path? (IsString, MaxLength 4096) }`, `CreateFolderDto { path }`, `MoveDto { from, to }`, `PreviewQueryDto { fileId (Matches ^\d{1,20}$), v? (MaxLength 200) }`. `nextcloud-files.service.ts` (inject NextcloudFilesAccountService, NextcloudCallGate, `NEXTCLOUD_TRANSPORT`): `list`, `createFolder`, `move`, `remove`, `preview(res, ...)`; every method starts with `getSession(tenantId, userId)` (user id from the token) and maps every failure through `mapNcFailure` with `onExpired = () => account.markExpired(tenantId, userId)`. Spec per `<behavior>`.
|
||||
|
||||
**Controller (D-N).** Add statics before the parameter block: `@Get('files') list`, `@Delete('files') remove`, `@Post('folders') createFolder`, `@Post('move') @HttpCode(200) move`, `@Get('preview') preview` (`@Res()`). Provide the service in the module. Extend the controller spec and the Benutzen-level it.each in `module-manage-handlers.spec.ts`. RLS doc unchanged unless the Gate-Schleife shows new pairs (this service has no Prisma access).
|
||||
|
||||
**Web (L-06).** Extend `nextcloud-files-api.ts`: types `NcEntry`, `NcListing`, `NcQuota`; `listFolder(path)`, `createFolder(path)`, `moveEntry(from, to)`, `deleteEntry(path)`, `previewUrl(fileId, etag)`. Test `nextcloud-files-api.test.ts` per `<behavior>`.
|
||||
|
||||
**Run.** Biome-lint touched files. Rebuild the api (`docker compose up -d --build api`), wait for /health, `nc-test-setup.sh`. Extend `e2e-lib.sh` with `e2e_second_user <jar>`: as admin create the Tessera user `nc-e2e-zweit` (role ADMIN, e-mail `nc-e2e-zweit@tessera.local`; HTTP 409/400 for an existing user accepted), log in with the known password, and when `GET /auth/me` reports `mustChangePassword` change it once via `POST /auth/change-password` to a second fixed password and log in again (try the second password first on later runs); and `e2e_connect_anna <jar>` (DELETE connect then password connect). Write `e2e/e2e-files.sh` (bash, `set -euo pipefail`, temp dir via mktemp, `trap` cleanup, sources e2e-lib.sh): admin login, `e2e_set_address`, `e2e_connect_anna`; F="/Tessera-E2E-$(date +%s)"; fixtures directly in Nextcloud with anna's password against `http://localhost:18080/remote.php/dav/files/anna/` (MKCOL F, PUT 'Ärger & Ölpreis 100%.txt', PUT a 1×1 PNG decoded from a base64 literal); GET files?path=/ → 200 with `entries` and `quota`; GET files?path=F → contains the exact umlaut name and the PNG with `"hasPreview":true`; GET preview?fileId=<png id> → 200 `image/png`; POST folders F/Ziel → 200/201, again → 409 nameTaken; POST move of the umlaut file into F/Ziel → 200; a second fixture with the same name in F, then move it into F/Ziel → 409 nameTaken; rename F/Ziel to F/Ziel2 → 200; move F into F/Ziel2 → 400 moveIntoItself; GET files?path=/../x → 400 invalidPath; second Tessera user (`e2e_second_user`, separate jar): GET status → `"account":null`, GET files → 409 with `notConnected`, while admin's GET files still answers 200 (no cross-user access); DELETE files?path=F → 200 and a PROPFIND on `http://localhost:18080/remote.php/dav/trashbin/anna/trash` (anna's password) lists F's name; DELETE connect; print `e2e files ok`. Run the `<verify>` command. Commit `feat(nextcloud-files): Dateien auflisten, Vorschau, Ordner anlegen, umbenennen, verschieben und löschen` (attribution line). Do not push.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-files rls-coverage rls-access-inventory module-manage-handlers && pnpm --filter @tessera/web exec vitest run src/lib/nextcloud-files && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -nE '^\s*@Roles\(' apps/api/src/nextcloud-files/nextcloud-files.controller.ts)" && bash .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/nc-test-setup.sh && bash .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-files.sh && echo "task3 ok"</automated>
|
||||
<fails_when>a parser, dav, upstream, files-service or controller spec or the web api test fails, a tsc run fails, the controller carries a role decorator, or any e2e-files.sh step fails (listing with quota, umlaut name, preview, mkdir conflict, move and its conflict, rename, move into itself, invalid path, second user notConnected, delete into the Nextcloud trash)</fails_when>
|
||||
</verify>
|
||||
<done>Listing, previews, new folder, rename/move and delete work through the API under the caller's own account with one error contract that never answers 401/403; a second Tessera user sees no account and gets notConnected; e2e green against the test Nextcloud; commit on main, not pushed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 4: Transfer slice — streaming download and ZIP, chunked upload v2 with conflict protection and asynchronous assembly, plus the 8 MiB browser uploader, proven with 30 MB through the Next.js proxy</name>
|
||||
<files>packages/shared/src/index.ts, apps/api/src/nextcloud-files/nextcloud-dav-transfer.ts, apps/api/src/nextcloud-files/nextcloud-dav-transfer.spec.ts, apps/api/src/nextcloud-files/nextcloud-files-transfer.service.ts, apps/api/src/nextcloud-files/nextcloud-files-transfer.service.spec.ts, apps/api/src/nextcloud-files/dto/nextcloud-files-transfer.dto.ts, apps/api/src/nextcloud-files/nextcloud-files.controller.ts, apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts, apps/api/src/nextcloud-files/nextcloud-files.module.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, apps/web/src/lib/nextcloud-files-api.ts, apps/web/src/lib/nextcloud-files-api.test.ts, apps/web/src/lib/nextcloud-files-upload.ts, apps/web/src/lib/nextcloud-files-upload.test.ts, .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-transfer.sh</files>
|
||||
<precondition>Task 3 is committed: `e2e-files.sh` prints `e2e files ok`; `mapNcFailure`, `sendUpstreamStream` and `stat` exist.</precondition>
|
||||
<behavior>
|
||||
- nextcloud-dav-transfer (fake transport, real gate, session base `https://cloud.example/nc`, uid 'anna', credentialKey 'k1'): putFile sends PUT `https://cloud.example/nc/remote.php/dav/files/anna/<path>` with `if-none-match: *`, `content-length` from the argument, `x-oc-mtime`, and passes the given Readable as body (identity check: the transport receives the same object); with replaceEtag → `if-match: "abc"` and no if-none-match; upload start → MKCOL `.../remote.php/dav/uploads/anna/tessera-<uuid>` with destination; chunk 3 → PUT `.../uploads/anna/tessera-<uuid>/00003` with destination and `oc-total-length`; assemble → MOVE `.../uploads/anna/tessera-<uuid>/.file` with destination, oc-total-length, x-oc-mtime, overwrite F (T only when the caller passes replace); abort → DELETE of the upload folder without destination; download → GET with Range passed through; zip of a folder → GET `.../files/anna/Projekte/?accept=zip`; multi-selection zip (measured variant) with names validated by validateSegment — a name '..' throws invalidPath before any call.
|
||||
- Transfer service: start for size 1000 on a free target (stat 404) → { mode: 'single' }; existing target → 409 nameTaken with existing { etag, size, mtime }; replaceEtag equal → single; different → 409 changedMeanwhile; size 30000000 → MKCOL uploads + { mode: 'chunked', uploadId matching ^tessera-[0-9a-f-]{36}$, chunkSize 8388608 }; size above 10000 × 8388608 → 413 fileTooLarge; chunk with content-length 8388609 → 413 chunkTooLarge and the transport is never called; missing content-length → 411 lengthRequired; uploadId 'tessera-../x' → 400; n 0 or 10001 → 400; complete with a fast MOVE → { state: 'done' }; with a MOVE that resolves after the 20 s window (fake timers) → 202 { state: 'assembling' } and later state 'done'; MOVE 412 → failed nameTaken and a DELETE of the upload folder; MOVE 507 → failed quotaExceeded; request close before the upstream finishes → the upstream AbortSignal is aborted; download sets content-disposition `attachment; filename="_rger & Co.txt"; filename*=UTF-8''%C3%84rger%20%26%20Co.txt` for 'Ärger & Co.txt', x-content-type-options nosniff, the CSP sandbox header, passes only the header allowlist (an upstream set-cookie never reaches the response), passes Range and answers 206 with content-range.
|
||||
- Error contract on every transfer route (it.each over download, downloadZip, putSingle, putChunk, completeUpload, startUpload × upstream 401, 403, 404, 409, 412, 423, 429, 507, 500, 502, 503): the response status is never 401 or 403, the body is the mapped `{ code, message }`, and no upstream byte was written (the fake response records `write`/`pipe` calls: none) and no allowlisted upstream header was set before the mapping; 401 additionally calls markExpired; 429 pauses the origin so the next transfer call returns 503 without a transport call.
|
||||
- Controller: every new handler has no MODULE_MANAGE_KEY; uploads/:uploadId/* handlers are declared after all static handlers.
|
||||
- Web api client: downloadUrl(path, { zip }) and zipUrl(dir, names) build query strings with encodeURIComponent and repeated `name` keys.
|
||||
- Uploader (fake XHR factory + fetch mock): a 5 MB file → start returns single → one XHR PUT `uploads/file?path=...&size=...&mtime=...` with the file as body, onProgress reaches 1; a 30 MB file → four chunk PUTs with bodies of 8388608, 8388608, 8388608 and 4834176 bytes (Blob.slice) in order 1..4, then complete; a network error on chunk 2 is retried after 1 s and 3 s (fake timers) and succeeds on the third try; a 409 nameTaken from start rejects with code nameTaken and existing data, no chunk sent; a 503 nextcloudLocked on a chunk is NOT retried; abort during chunk 3 aborts the XHR, sends DELETE uploads/<id> once and rejects with code 'aborted'; complete answering assembling → polls state every 2 s until done; chunk 413 or any 4xx → DELETE uploads/<id> and reject.
|
||||
</behavior>
|
||||
<action>
|
||||
**Shared constant (L-06).** In `packages/shared/src/index.ts` add `NEXTCLOUD_FILES_CHUNK_SIZE = 8 * 1024 * 1024` and `NEXTCLOUD_FILES_MAX_CHUNKS = 10000` with a German comment: below the 10 MiB body clone limit of the Next.js `/api-proxy` rewrite, above Nextcloud's 5 MiB chunk minimum; the API rejects larger bodies.
|
||||
|
||||
**Measure the multi-selection ZIP first (D-K).** With anna's password against `http://localhost:18080`: create two files in a test folder, request `?accept=zip&files=<url-encoded JSON array of one name>` and list the result with `unzip -l`; record whether only the chosen entry is inside; implement `downloadZip` accordingly and note the result for the SUMMARY.
|
||||
|
||||
**DAV transfer layer (D-C, D-J, D-K).** `nextcloud-dav-transfer.ts` on top of `davRequest` (gate + credentialKey on every call): `putFile`, `uploadStart`, `uploadChunk`, `uploadAssemble`, `uploadAbort`, `download`, `downloadFolderZip`, `downloadSelectionZip` (names through `validateSegment`), with the D-C timeouts. Spec per `<behavior>` with literal URLs and header sets.
|
||||
|
||||
**Transfer service + DTO (D-D, D-J, D-K).** `dto/nextcloud-files-transfer.dto.ts`: `StartUploadDto { path; size (IsInt, Min 0); replaceEtag? (IsString, MaxLength 200) }`, `CompleteUploadDto { path; size; mtime? (IsInt, Min 0, Max 4102444800); replaceEtag? }`, `UploadQueryDto { path; size (Type Number, IsInt); mtime?; replaceEtag? }`, `DownloadQueryDto { path; zip? }`, `ZipQueryDto { dir; name (string or string[], each ≤ 255) }`. `nextcloud-files-transfer.service.ts` (inject NextcloudFilesAccountService, NextcloudCallGate, `NEXTCLOUD_TRANSPORT`): `startUpload`, `putSingle`, `putChunk`, `completeUpload`, `uploadState`, `abortUpload`, `download`, `downloadZip`; raw-body handlers read `req.headers['content-length']` (411/413 before touching Nextcloud), pass `req` itself as the upstream body, abort upstream on `req` 'close' when not finished; every upstream answer goes through `mapNcFailure` (onExpired → markExpired) before `sendUpstreamStream` writes anything; the in-memory assembly map `Map<string, { state, code?, message?, finishedAt }>` keyed `${tenantId}:${userId}:${uploadId}` with pruning after 10 min. German comment blocks: why nothing is buffered (the existing multer-based upload routes keep whole files in RAM — name multer's memory storage, not the interceptor class), why the browser chunks at 8 MiB, why assembly is asynchronous (proxy idle timeouts), why Tessera builds Content-Disposition itself and forces attachment (stored XSS through uploaded HTML/SVG), why errors are mapped before the first byte. Spec per `<behavior>` including the it.each error-contract matrix.
|
||||
|
||||
**Controller (D-N).** Add statics before the parameter block: `@Get('download') download` (`@Res()`), `@Get('download/zip') downloadZip` (`@Res()`), `@Post('uploads') startUpload`, `@Put('uploads/file') putSingle` (`@Req()` raw stream); at the end after the Task 2 parameter routes: `@Put('uploads/:uploadId/chunks/:n') putChunk`, `@Post('uploads/:uploadId/complete') completeUpload` (sets 202 when assembling), `@Get('uploads/:uploadId/state') uploadState`, `@Delete('uploads/:uploadId') abortUpload`. Provide the service in the module. Extend the controller spec (order + no manage key) and the Benutzen-level it.each in `module-manage-handlers.spec.ts`.
|
||||
|
||||
**Web (L-06, D-J).** Extend `nextcloud-files-api.ts`: `downloadUrl(path, { zip? })`, `zipUrl(dir, names)` (+ cases in its test). New `apps/web/src/lib/nextcloud-files-upload.ts`: `uploadFile(file, targetPath, { onProgress(fraction, bytes), signal, replaceEtag?, xhrFactory?, fetchImpl?, sleep? })` per `<behavior>` — start, single or chunked (`file.slice(i * NEXTCLOUD_FILES_CHUNK_SIZE, ...)`, chunk numbers 1..N, mtime = `Math.floor(file.lastModified / 1000)`), XHR with `withCredentials` and `upload.onprogress`, retries only for network errors, 500, 502 nextcloudUnavailable/nextcloudError and 504 (waits 1 s, 3 s, 9 s; never for nextcloudLocked or any 4xx), assembly polling every 2 s up to 30 min, cleanup DELETE on abort and on final failure, rejection with `NextcloudFilesRequestError` (code 'aborted' for user aborts). Test `nextcloud-files-upload.test.ts` per `<behavior>`.
|
||||
|
||||
**Run.** Biome-lint touched files. Rebuild the api (`docker compose up -d --build api`), wait for /health, `nc-test-setup.sh`. Write `e2e/e2e-transfer.sh` (bash, `set -euo pipefail`, temp dir via mktemp, `trap` cleanup, sources e2e-lib.sh): admin login, `e2e_set_address`, `e2e_connect_anna`; F="/Tessera-E2E-T-$(date +%s)" created via POST folders; small upload of 'Ärger & Ölpreis 100%.txt' (start single + PUT uploads/file) → listing of F contains that exact name; the same name again → POST uploads 409 nameTaken with `existing`; 30 MB random file: POST uploads → chunked, `split -b 8388608` and PUT every chunk through `http://localhost:3000/api-proxy/modules/nextcloud-files/...` (the Next.js proxy), complete (poll state if 202), download through the proxy and `cmp` identical, Range `bytes=0-99` → 206 with 100 bytes, response headers contain `content-disposition: attachment`, `x-content-type-options: nosniff` and no `set-cookie`; a 9 MB chunk → 413; a PUT without content-length (`-H 'Transfer-Encoding: chunked'`) → 411; download of a missing path → 404 JSON with `notFound` (no 401/403); folder ZIP of F starts with bytes `PK`; multi-selection ZIP per the measured variant; `download/zip?dir=F&name=..` → 400 invalidPath; DELETE files?path=F; DELETE connect; print `e2e transfer ok`. Run the `<verify>` command. Commit `feat(nextcloud-files): Hoch- und Herunterladen als Datenstrom, große Dateien in Stücken, ZIP` (attribution line). Do not push.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api exec vitest run src/nextcloud-files module-manage-handlers && pnpm --filter @tessera/web exec vitest run src/lib/nextcloud-files && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -nE '^\s*@Roles\(' apps/api/src/nextcloud-files/nextcloud-files.controller.ts)" && test -z "$(grep -rn 'FileInterceptor' apps/api/src/nextcloud-files --include=*.ts)" && bash .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/nc-test-setup.sh && bash .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-transfer.sh && echo "task4 ok"</automated>
|
||||
<fails_when>a dav-transfer, transfer-service (incl. the error-contract matrix), controller spec or a web lib test fails, a tsc run fails, the module carries a role decorator or a multer interceptor, or any e2e-transfer.sh step fails (umlaut upload, upload conflict, 30 MB chunked upload over the Next.js proxy with identical download and Range, header checks, 413, 411, 404 mapping, folder and selection ZIP, invalid ZIP name)</fails_when>
|
||||
</verify>
|
||||
<done>Uploads (single and chunked with async assembly) and downloads (file, folder ZIP, selection ZIP or measured fallback) stream through the API without buffering, every upstream failure is mapped before the first byte and never reaches the browser as 401/403, a 30 MB file travels through the Next.js proxy in 8 MiB chunks and comes back byte-identical; the browser uploader is tested; commit on main, not pushed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 5: File browser "Mosaik-Ablage" — list/grid with type tiles and previews, breadcrumb, selection bar, keyboard, row and right-click menu, dialogs, quota, drop overlay with folder targets and the Übertragungsleiste</name>
|
||||
<files>apps/web/src/app/globals.css, apps/web/src/components/nextcloud-files/file-types.ts, apps/web/src/components/nextcloud-files/file-types.test.ts, apps/web/src/components/nextcloud-files/file-type-contrast.test.ts, apps/web/src/components/nextcloud-files/file-format.ts, apps/web/src/components/nextcloud-files/file-format.test.ts, apps/web/src/components/nextcloud-files/selection.ts, apps/web/src/components/nextcloud-files/selection.test.ts, apps/web/src/components/nextcloud-files/paths.ts, apps/web/src/components/nextcloud-files/paths.test.ts, apps/web/src/components/nextcloud-files/drop-entries.ts, apps/web/src/components/nextcloud-files/use-transfers.ts, apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.test.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/Toolbar.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/SelectionBar.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/Breadcrumb.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/FileList.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/FileGrid.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/TypeTile.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/EntryMenu.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/DropOverlay.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/TransferBar.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/TransferBar.test.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/NameDialog.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/MoveDialog.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/DeleteDialog.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/QuotaMeter.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/messages/umlaut-dictionary.ts</files>
|
||||
<precondition>Task 4 is committed: `e2e-transfer.sh` prints `e2e transfer ok`; the web API client exports listFolder, createFolder, moveEntry, deleteEntry, previewUrl, downloadUrl, zipUrl and the uploader exports uploadFile.</precondition>
|
||||
<behavior>
|
||||
- file-types: 'Bericht.pdf' → family pdf, code 'PDF'; 'Tabelle.xlsx' → sheet 'XLSX'; 'Folien.odp' → slides 'ODP'; 'Foto.JPG' → image 'JPG'; 'Film.mp4' → media 'MP4'; 'Archiv.tar.gz' → archive 'GZ'; 'skript.ts' → code 'TS'; 'Notiz.md' → doc 'MD'; 'README' (no extension) with mime text/plain → doc 'TXT'; unknown 'daten.xyz1234' → other with code cut to 4 chars 'XYZ1'; a folder → family folder; mime takes precedence when the extension is unknown.
|
||||
- file-type-contrast: reads `apps/web/src/app/globals.css`, finds `--ft-<family>-bg` and `--ft-<family>-fg` for all ten families in `:root` AND `.dark`, converts OKLCH to sRGB (OKLab matrices in the test), and asserts a WCAG contrast of at least 4.5:1 for fg on bg in both modes, and that each light bg differs from `--card` white and each dark bg from `oklch(0.245 0.01 260)` by at least 1.15:1.
|
||||
- file-format: formatSize(0) '0 B', 1536 → '1,5 KB' (de) / '1.5 KB' (en), 12 * 1024 * 1024 → '12 MB'; formatRelative(now − 3 h, 'de') → 'vor 3 Std.'; < 60 s → 'gerade eben' / 'just now'; older than 30 days → date '08.09.2026' (de); formatAbsolute gives date and time for the tooltip; quota text: used 4.2 GB of 10 GB → '4,2 GB von 10 GB belegt', available null → '4,2 GB belegt'.
|
||||
- selection: click selects only that entry and sets the anchor; ctrl/meta click toggles; shift click selects the range from the anchor in the CURRENT sort order; selectAll; clear; entries removed from the listing drop out of the selection; input arrays not mutated.
|
||||
- paths: join('/', 'a') '/a'; parent('/a/b') '/a', parent('/') '/'; segments; validName rejects '', '.', '..', names with '/' or '\\', names ending with '.part', longer than 250 chars; freeName('Bericht.pdf', taken ['Bericht.pdf','Bericht (2).pdf']) → 'Bericht (3).pdf'; 'Archiv.tar.gz' → 'Archiv (2).tar.gz'; isInside('/a', '/a/b') true, ('/a', '/ab') false.
|
||||
- FileBrowser (mocked api + uploader, real de messages): renders folders first then files sorted with German collation and numeric order, size column right-aligned with tabular-nums, modified relative with absolute title; clicking a folder name loads that folder and updates the breadcrumb (first crumb "Alle Dateien", last crumb not a button); Backspace and Alt+ArrowUp go up one level; Enter on a focused folder opens it and on a file starts its download (anchor with the download URL); selecting two rows (ctrl click) replaces the toolbar by "2 ausgewählt" with Herunterladen, Verschieben, Löschen, Auswahl aufheben; Ctrl+A selects all; Escape clears; Entf opens the delete dialog naming the count and the trash; confirming calls deleteEntry per path and reloads; F2 opens the rename dialog with the name and the base name preselected, a nameTaken answer shows "Ein Eintrag mit diesem Namen existiert bereits." inside the dialog; the move dialog lists only folders, disables "Hierher verschieben" for the current parent and for a moved folder itself/inside, and calls moveEntry per item; right click on a row opens the same menu as the ⋯ button (role menu, Escape closes, focus returns); list/grid toggle writes `tessera:nextcloud-files:view:<userId>` and restores it on mount; grid shows `<img>` previews only for hasPreview entries and the type tile otherwise (also after an img error); empty folder shows "Dieser Ordner ist leer." and "Dateien hierher ziehen oder hochladen."; connectionExpired from listFolder calls onExpired (page goes back to the connect screen with the expiry notice); quota shows used/available.
|
||||
- Drop: a dragenter with Files on the window shows the overlay with "In „<current folder or Alle Dateien>“ ablegen"; dragover on a folder row switches the text to that folder and marks the row; dragleave back to zero hides it; drop calls the transfer queue with the target folder; drop of a non-file drag (text) is ignored.
|
||||
- TransferBar + use-transfers: queued uploads run at most two at a time; each row shows type tile, name, a progressbar (role progressbar with aria-valuenow) and "x MB von y MB"; Abbrechen aborts that upload only; an error row shows the plain-language text for its code and "Erneut versuchen"; a nameTaken row offers "Ersetzen" (re-run with replaceEtag), "Beide behalten" (re-run with freeName) and "Überspringen"; the bar header shows "Übertragungen" with a count of running items and can be collapsed and expanded; "Erledigte entfernen" removes finished rows; the listing reloads when an upload into the current folder finishes; a file larger than the available quota is rejected before upload with the quota text.
|
||||
</behavior>
|
||||
<action>
|
||||
**Design tokens (L-09, D-L).** In `apps/web/src/app/globals.css` add a commented block "Nextcloud-Dateien: Typfarben je Dateifamilie" with `--ft-<family>-bg`/`--ft-<family>-fg` in `:root` and `.dark` and matching `@theme inline` entries (`--color-ft-<family>-bg: var(--ft-<family>-bg)` etc.) for the families folder, doc, sheet, slides, pdf, image, media, archive, code, other. Light: bg `oklch(0.93 0.035 H)`, fg `oklch(0.46 0.11 H)`; dark: bg `oklch(0.33 0.045 H)`, fg `oklch(0.84 0.09 H)` with H doc 255, sheet 150, slides 45, pdf 27, image 305, media 340, archive 75 (chroma halved), code 200, other 260 (chroma 0.01); folder light bg `oklch(0.94 0.09 100)` fg `oklch(0.45 0.10 95)`, dark bg `oklch(0.36 0.07 100)` fg `oklch(0.88 0.14 100)` — the muted Mosaik yellow. Also `.nc-drop-stripes` = `repeating-linear-gradient(135deg, color-mix(in oklab, var(--primary) 22%, transparent) 0 10px, transparent 10px 20px)` with a 2 px dashed `var(--primary-strong)` outline. Adjust lightness in steps of 0.02 only if the contrast test fails; no new font, no change to `--primary`.
|
||||
|
||||
**Pure helpers (tests first).** `apps/web/src/components/nextcloud-files/`: `file-types.ts` (extension/mime map → `{ family, code }`, codes max 4 chars; uppercase short codes are the requested exception to the no-caps rule), `file-format.ts` (`formatSize(bytes, locale)` base 1024 with B/KB/MB/GB/TB and `Intl.NumberFormat` max one decimal; `formatRelative(date, now, locale)` with `Intl.RelativeTimeFormat(locale, { style: 'short', numeric: 'auto' })`; `formatAbsolute`; `quotaText`), `selection.ts` (pure reducer), `paths.ts`, `drop-entries.ts` (collects File objects from a drop: plain files, and dropped folders via `webkitGetAsEntry` recursion into `{ file, relativeDir }` items — at most 2000 files and depth 20, beyond that a clear message; the queue creates missing sub-folders with createFolder and treats nameTaken on a folder as "already there"), `use-transfers.ts` (React hook: queue, concurrency 2, per-item AbortController, statuses queued/running/assembling/done/error/conflict/skipped, retry, conflict resolution per `<behavior>`, quota pre-check, callback when an upload into a folder finishes). Tests per `<behavior>` (the contrast test reads the CSS file with `node:fs` relative to the repo).
|
||||
|
||||
**Components (L-06, L-09).** Under `apps/web/src/app/(portal)/modules/nextcloud-files/components/`:
|
||||
- `TypeTile.tsx`: rounded square (`rounded-md`), sizes 'sm' 32 px (list) and 'lg' 72 px (grid), classes from literal maps per family (`bg-ft-pdf-bg text-ft-pdf-fg` …), short code centered in semibold 10 px / 15 px with slight letter spacing; folder = folder glyph (D-A path) in `text-ft-folder-fg` on `bg-ft-folder-bg`; for hasPreview images in the list the tile shows the 32 px thumbnail (`object-cover`, `loading="lazy"`, `decoding="async"`, onError back to the code).
|
||||
- `Breadcrumb.tsx`: "Alle Dateien" then the segments; every crumb a button except the last (`aria-current="page"`); long paths collapse the middle into "…" with a menu.
|
||||
- `Toolbar.tsx`: breadcrumb left; right "Neuer Ordner", "Hochladen" (hidden `<input type="file" multiple>`), view toggle (two icon buttons with aria-pressed, labels "Liste"/"Raster", hidden below `sm` where the list is always used), sort select (Name, Größe, Geändert; direction toggle). `SelectionBar.tsx`: replaces the toolbar while something is selected: "{count} ausgewählt", Herunterladen, Verschieben, Löschen, "Auswahl aufheben" (X with aria-label); swap with a 150 ms fade only under `motion-safe:`.
|
||||
- `FileList.tsx`: dense table (`role="grid"` with rows `role="row"`, header row sticky inside the card): checkbox column (visible on hover/focus/selected, always on `pointer-coarse:`), TypeTile sm, name (folder names are buttons, file names are download anchors), Größe right-aligned `tabular-nums`, Geändert (relative, `title` absolute; hidden below `sm`), ⋯ button (always visible on touch, subdued otherwise); row height ~40 px; hover `bg-accent/60`, selected `bg-primary/12`, focus-visible inset ring; roving tabindex; right click opens EntryMenu at the pointer (preventDefault). Header "Name" checkbox selects all.
|
||||
- `FileGrid.tsx`: responsive grid (`grid-cols-[repeat(auto-fill,minmax(9.5rem,1fr))]`), each item a 4:3 surface (`bg-well`, rounded) with the preview image (`object-cover`) or a TypeTile lg, name below (two-line clamp) and size muted; selected ring `ring-2 ring-primary-strong` plus a check badge; same menu/keyboard model.
|
||||
- `EntryMenu.tsx`: items Öffnen (folders) or Herunterladen (files), "Als ZIP herunterladen" (folders), Umbenennen, Verschieben, "In Nextcloud öffnen" (anchor to `${serverUrl}/index.php/f/${fileId}`, target _blank, rel noopener noreferrer), Löschen (destructive colour, last); built from an action list so Etappe 2 can add Teilen.
|
||||
- `NameDialog.tsx` (new folder / rename; validation from paths.ts; API errors inline), `MoveDialog.tsx` (own folder navigation with breadcrumb using listFolder filtered to folders, "Hierher verschieben", disabled rules per `<behavior>`), `DeleteDialog.tsx` ("„{name}“ löschen?" / "{count} Elemente löschen?", text "Die Einträge kommen in den Papierkorb Ihrer Nextcloud und lassen sich dort wiederherstellen.", buttons "In den Papierkorb verschieben" / "Abbrechen"); all `role="dialog"` with `aria-modal`, focus trap, Escape closes, focus returns.
|
||||
- `DropOverlay.tsx`: absolute over the file card, `pointer-events-none`, `.nc-drop-stripes` tint, centered large label "In „{target}“ ablegen" in a card-coloured pill; fades in only under `motion-safe:`.
|
||||
- `TransferBar.tsx`: `sticky bottom-0` at the bottom of the page column (full width on mobile), card surface with top border and shadow; header "Übertragungen" with "{running} laufen" and overall progress, collapse/expand button, "Erledigte entfernen"; rows per `<behavior>` with a 4 px progress bar in `bg-primary` on `bg-muted`, done state with a check in `text-status-ok-fg`, error text in `text-status-down-fg`; appears with a short slide-up only under `motion-safe:` when the first transfer starts.
|
||||
- `QuotaMeter.tsx`: footer of the file card: "{count} Elemente" left; right a 120 px bar plus the quota text (unlimited: text only); the bar turns `bg-status-warn` above 90 %.
|
||||
- `FileBrowser.tsx`: state container (path mirrored with `window.history.replaceState` to `?path=`, initial path read from `window.location.search` on mount; listing; sort; view from localStorage; selection; focus index; dialogs; error state "Nextcloud ist gerade nicht erreichbar." + "Erneut versuchen"; notFound on a folder → back to "Alle Dateien" with "Der Ordner existiert nicht mehr."; nextcloudLocked → its text with the minutes; 5 skeleton rows with `motion-safe:animate-pulse` while loading; truncated hint "Es werden die ersten 5000 Einträge angezeigt."), keyboard map of L-09 bound on the browser container (ignored while focus is in an input or a dialog), window-level drag listeners with a depth counter that only react to `dataTransfer.types` containing 'Files' and always preventDefault on dragover/drop to keep the browser from opening files, status line after actions ("3 Elemente in den Papierkorb verschoben."), multi-download per the measured D-K result from Task 4.
|
||||
- `page.tsx`: mount `FileBrowser` in the `nextcloud-files-browser` section with `onExpired` (reload status) and the server URL for "In Nextcloud öffnen".
|
||||
Texts per L-09: Sie, German, instruction-style empty/error states, no ALL-CAPS labels, no arrow characters on buttons, no middle-dot meta strings, no decorative numbering, no tenant/licence words. Messages for every new text in `nextcloudFiles.*` de + en (ICU plurals for counts), umlaut guard green. Tests per `<behavior>`: `FileBrowser.test.tsx`, `TransferBar.test.tsx`, page-test additions (connected state renders the browser; expired switches back).
|
||||
|
||||
**Run.** Biome-lint touched files. Rebuild `docker compose up -d --build web`, `nc-test-setup.sh`, then a dark-mode Playwright smoke at http://localhost:3000 as admin with anna connected: list, grid, three selected, the drop overlay (dragenter dispatched with a DataTransfer carrying a File via the evaluate tool, hovering a folder row), a 30 MB upload through the file chooser with the Übertragungsleiste, the move dialog; save `.playwright-mcp/nextcloud-files/t5-dark-list.png`, `t5-dark-grid.png`, `t5-dark-drop.png`, `t5-dark-transfers.png`; look at them against L-09 and fix what is off. Run the `<verify>` command. Commit `feat(nextcloud-files): Dateiansicht mit Ablage-Kacheln, Ziehen und Ablegen und Übertragungsleiste` (attribution line). Do not push.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/web exec vitest run modules/nextcloud-files components/nextcloud-files src/lib/nextcloud-files src/messages && pnpm --filter @tessera/web exec tsc --noEmit && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const w=(o,p,r)=>{for(const[k,v]of Object.entries(o||{})){const q=p+"."+k;if(v&&typeof v==="object")w(v,q,r);else r[q]=v}return r};const a=w(de.nextcloudFiles,"n",{}),b=w(en.nextcloudFiles,"n",{});if(Object.keys(a).length===0||Object.keys(a).sort().join()!==Object.keys(b).sort().join()){console.error("key mismatch");process.exit(1)}for(const v of [...Object.values(a),...Object.values(b)])if(/mandant|tenant|lizenz|licens|→|·/i.test(String(v))){console.error("bad text",v);process.exit(1)}' && test -f .playwright-mcp/nextcloud-files/t5-dark-drop.png && test -f .playwright-mcp/nextcloud-files/t5-dark-transfers.png && echo "task5 ok"</automated>
|
||||
<fails_when>a helper, contrast, FileBrowser, TransferBar, page, web lib or messages test fails, the web tsc run fails, de/en keys of nextcloudFiles differ or a text contains tenant/licence wording, an arrow or a middle dot, or the dark smoke screenshots of the drop overlay and the Übertragungsleiste are missing</fails_when>
|
||||
</verify>
|
||||
<done>Connected users work with their files in a calm, dense Mosaik file view with family type tiles (contrast-tested in both modes), real previews, list/grid remembered per user, selection bar, keyboard and row/right-click menus, dialogs and quota; drag & drop shows the striped target overlay incl. folder rows as targets; the Übertragungsleiste shows progress, cancel, errors and conflict choices; dark smoke reviewed; commit on main, not pushed.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 6: Server identity on the connect screen, registration check, changelog and guides, full gates on the rebuilt stack, all e2e scripts, screenshots in dark and light</name>
|
||||
<files>apps/api/src/nextcloud-files/nextcloud-server-info.ts, apps/api/src/nextcloud-files/nextcloud-server-info.spec.ts, apps/api/src/nextcloud-files/nextcloud-files.controller.ts, apps/api/src/nextcloud-files/nextcloud-files.controller.spec.ts, apps/api/src/nextcloud-files/nextcloud-files.module.ts, apps/api/src/nextcloud-files/nextcloud-files-settings.service.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, apps/web/src/lib/nextcloud-files-api.ts, apps/web/src/app/(portal)/modules/nextcloud-files/components/ServerIdentity.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/ConnectPanel.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/components/AccountBar.tsx, apps/web/src/app/(portal)/modules/nextcloud-files/nextcloud-files-page.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md, docs/anleitung-betrieb.md, docs/mandantentrennung-zugriffsklassifikation.md</files>
|
||||
<precondition>Task 5 is committed: the file browser renders in the connected state of the module page.</precondition>
|
||||
<behavior>
|
||||
- Server info (fake transport, real gate, fake clock): `getServerInfo` returns `{ host, name, color, version, hasLogo }` with name from status.php productname (fallback host), color from anonymous `GET {base}/ocs/v2.php/cloud/capabilities` `ocs.data.capabilities.theming.color` only when it matches `^#[0-9a-fA-F]{6}$` (else null), cached 10 min per tenant + baseUrl and cleared on an address change; capabilities failing → color null, no throw; a paused origin → host-only info without any request; logo tries exactly `{base}/index.php/apps/theming/image/logo` then `{base}/core/img/logo/logo.svg` (no URL from any answer), accepts PNG/JPEG/GIF/WebP by magic bytes and SVG by a leading `<svg`/`<?xml` text, max 512 KiB, else hasLogo false; the logo response carries `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; sandbox`, nosniff and `Cache-Control: private, max-age=3600`.
|
||||
- Controller: getServer and getServerLogo have no MODULE_MANAGE_KEY and are declared before the parameter block.
|
||||
- Web: ConnectPanel shows the Nextcloud name and host with the logo tile in the theming colour; with a logo error the first letter of the name appears; AccountBar shows the small identity.
|
||||
</behavior>
|
||||
<action>
|
||||
**Server identity (L-09 connect screen).** `nextcloud-server-info.ts` + spec per `<behavior>` (fixed paths only through `ncRequest`, no redirect, 10 s, caps; cache keyed `${tenantId}:${baseUrl}`; registered as an address-change listener in the settings service). Controller: `@Get('server') getServer` and `@Get('server/logo') getServerLogo` (`@Res()`) — Benutzen, static, before the parameter block; extend the controller spec and the Benutzen-level list in `module-manage-handlers.spec.ts`; provide it in the module. Web api: `getServerInfo()`, `serverLogoUrl()`. `components/ServerIdentity.tsx`: a 48 px rounded tile in the theming colour (fallback `bg-tile`) with the logo (`<img>`, `object-contain`, onError → first letter of the name in a readable colour via `readableOnAccent` from `@/lib/color`), the Nextcloud name as heading and the host as muted text; used at the top of ConnectPanel and as the small identity in AccountBar. Page-test cases per `<behavior>`; messages de + en.
|
||||
|
||||
**Registration check (D-A).** Confirm the registrations from Task 1 are intact (module-loader, module-identity `folder`, module-tile glyph, nav-store title, layouts test, seed, app.module) and that `WIDGET_MODULE_SLUGS` in `packages/shared/src/index.ts` has no `nextcloud-files` entry (L-08); fix anything missing.
|
||||
|
||||
**Changelog + guides (L-04, L-11).** `CHANGELOG.md` under "## Unveröffentlicht" → "### Neu" add user-facing German bullets in simple words: new module „Dateien“ (group Infrastruktur) for the company Nextcloud, activation in the Marktplatz plus Freigabe; Administratoren/Verwalten set the address and can check it; everyone connects their own account — Tessera keeps only a revocable app password, not the password; two-factor accounts connect in the browser; browse, list/grid, previews, upload by dragging or choosing (also large files, progress in the Übertragungsleiste, cancel), download (folders as ZIP), new folder, rename, move, delete into the Nextcloud trash, multi-selection and keyboard; no silent overwriting; Abmelden removes the access in Nextcloud. `docs/anleitung-anwender.md`: section "### Dateien (Nextcloud)" after "### Nextcloud-Status" plus the table-of-contents entry (connect by password or in the browser, what Tessera stores, working with files, drag & drop and the Übertragungsleiste, name conflicts, keyboard shortcuts table, storage display, trash, Abmelden, what "Verbindung abgelaufen" means, what to do when Nextcloud pauses requests). `docs/anleitung-administration.md`: subsection "### Dateien: Nextcloud anbinden" after "### Nextcloud-Status: Clouds eintragen" (address as users open it in the browser, https, trusted_domains, brute-force whitelist for the Tessera server IP with the occ command and why — Tessera pauses all requests to the Nextcloud for up to 15 minutes when Nextcloud answers "zu viele Anfragen", address change → everyone reconnects, rights Benutzen/Verwalten, deleting a Tessera user leaves the app password in the user's Nextcloud device list — remove it there). `docs/anleitung-betrieb.md`: in "## 3. Konfiguration" a subsection "### Dateien (Nextcloud)" (outbound access from the api container to the Nextcloud, internal CA via `NODE_EXTRA_CA_CERTS` on the api container, Nginx Proxy Manager for the Tessera address: `client_max_body_size` at least `10m` and read/send timeouts of at least 120 s, upload chunks 8 MiB, unfinished uploads are removed by Nextcloud after 24 h, open browser logins and upload states live in the api process memory — a restart asks users to start again) and one row in "### Fehlerbilder" (upload stops at the first 8 MB chunk → proxy body limit). No tenant or licensing wording.
|
||||
|
||||
**Final gates + browser proof (L-10).** Recount the RLS doc with the Gate-Schleife (only if pairs changed). Full `pnpm --filter @tessera/api test` and `pnpm --filter @tessera/web test`, both tsc, biome lint on every file touched by the six tasks. Rebuild `docker compose up -d --build api web`, wait for /health, check the seed log line and the mapped `/modules/nextcloud-files/...` routes (statics before parameter routes), rerun `nc-test-setup.sh`, `e2e-settings.sh`, `e2e-connect.sh`, `e2e-files.sh`, `e2e-transfer.sh`. Playwright MCP at http://localhost:3000 as admin, connect anna by password, build a demo folder via the UI (folders "Projekte" and "Rechnungen", files of several families incl. a JPG/PNG with preview, a PDF, an XLSX, a ZIP, a 30 MB file uploaded through the file chooser), then capture in DARK and in LIGHT mode (theme button): `t6-<mode>-connect.png` (after Abmelden, with server identity), `t6-<mode>-list.png`, `t6-<mode>-grid.png`, `t6-<mode>-selection.png` (three selected), `t6-<mode>-drop-overlay.png` (dragenter dispatched with a DataTransfer carrying a File, hovering a folder row), `t6-<mode>-transfers.png` (running or finished 30 MB upload plus a nameTaken row), `t6-<mode>-move-dialog.png`, `t6-<mode>-empty.png`, and once `t6-dark-mobile.png` at 390×844 — all under `.playwright-mcp/nextcloud-files/`. Exercise in the browser: rename, move into "Rechnungen", delete with confirmation (then visible in the Nextcloud trash), download a file and a folder ZIP, keyboard Enter/Backspace/F2/Entf/Ctrl+A. Look at every screenshot against L-09 (calm list, stripes only during drag, readable type tiles in both modes, no caps labels, no middle dots) and fix what is off before committing. Commit `feat(nextcloud-files): Nextcloud-Kennung auf der Anmeldeseite, Anleitungen und Changelog` (attribution line). Do not push.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -nE '^\s*@Roles\(' apps/api/src/nextcloud-files/nextcloud-files.controller.ts)" && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const w=(o,p,r)=>{for(const[k,v]of Object.entries(o||{})){const q=p+"."+k;if(v&&typeof v==="object")w(v,q,r);else r[q]=v}return r};const a=w(de.nextcloudFiles,"n",{}),b=w(en.nextcloudFiles,"n",{});if(Object.keys(a).length===0||Object.keys(a).sort().join()!==Object.keys(b).sort().join()){console.error("key mismatch");process.exit(1)}for(const v of [...Object.values(a),...Object.values(b)])if(/mandant|tenant|lizenz|licens|→|·/i.test(String(v))){console.error("bad text",v);process.exit(1)}' && test -z "$(awk '/export const WIDGET_MODULE_SLUGS/,/^};/' packages/shared/src/index.ts | grep -v '^\s*//' | grep -F "nextcloud-files")" && grep -q "Nextcloud" CHANGELOG.md && grep -q "^### Dateien (Nextcloud)" docs/anleitung-anwender.md && grep -q "^### Dateien: Nextcloud anbinden" docs/anleitung-administration.md && grep -q "^### Dateien (Nextcloud)" docs/anleitung-betrieb.md && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web && docker compose logs api 2>&1 | grep -q "Nextcloud files module seeded in registry" && E=.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e && bash $E/nc-test-setup.sh && bash $E/e2e-settings.sh && bash $E/e2e-connect.sh && bash $E/e2e-files.sh && bash $E/e2e-transfer.sh && test "$(ls .playwright-mcp/nextcloud-files/t6-dark-*.png 2>/dev/null | wc -l)" -ge 8 && test "$(ls .playwright-mcp/nextcloud-files/t6-light-*.png 2>/dev/null | wc -l)" -ge 8 && echo "final gates ok"</automated>
|
||||
<fails_when>any api or web test (incl. contrast, rls, umlaut guard, module-layouts), a tsc run, the role-decorator gate, the de/en parity or wording check, a dashboard-widget entry for the module, a changelog/guide grep, the running-container or seed checks, any of the four e2e scripts on the rebuilt stack, or fewer than eight dark and eight light screenshots</fails_when>
|
||||
</verify>
|
||||
<done>The connect screen shows the Nextcloud identity; registrations confirmed and no dashboard widget; changelog and three guides updated; full suites, tsc, biome and all e2e scripts green on the rebuilt stack; screenshots in dark and light reviewed against L-09; commit on main, not pushed.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser → API (`/modules/nextcloud-files/*`) | untrusted caller; tenant and user only from the validated session; rights via ModuleGuard; paths, names, ids, sizes untrusted |
|
||||
| API → Nextcloud (admin-chosen, possibly internal address) | outbound HTTP(S) with the user's app password; every answer untrusted (headers, XML, JSON, file bytes, URLs) |
|
||||
| user password in transit | only in memory of one API request, sent once to getapppassword |
|
||||
| DB at rest (`NextcloudFilesAccount`) | encrypted app passwords per user |
|
||||
| Nextcloud content → browser | file names, previews, downloads (possibly HTML/SVG) rendered or saved by the browser |
|
||||
| shared server IP ↔ Nextcloud brute-force protection | failures of one user can lock out everyone |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-mzu-01 | Tampering / SSRF | nextcloud-http, auth client, dav, server info | high | mitigate | one admin-set base URL per organisation, fixed path prefixes, segment-wise validation and encoding, no redirects followed (3xx = error), `poll.endpoint`/`login` origin/capability URLs never called, ids and uploadIds regex-checked, Destination built from the base; specs assert literal URLs and that evil.example is never requested |
|
||||
| T-mzu-02 | Information Disclosure | user password, app password | high | mitigate | password only in request memory, never stored/logged/returned; app password AES-256-GCM via CryptoService; responses carry no secret (spec with JSON.stringify); request log without query/body; decrypt failure loud (accountBroken) |
|
||||
| T-mzu-03 | Information Disclosure | app password sent to a wrong host | high | mitigate | account stores its issuing baseUrl; requests, revoke and session use only that base; address change expires all accounts and is confirmed in the UI; spec: mismatch → no transport call |
|
||||
| T-mzu-04 | Denial of Service | Nextcloud brute-force lock of the shared IP | high | mitigate | Tessera limits 3 failures/user/15 min and 8/server/30 min below Nextcloud's 10/30 min; call gate pauses ALL calls to an origin after any 429 (Retry-After honoured, capped); a credential is dead after its first 401 and its in-flight calls are aborted; 2FA users steered to the browser path upfront; admin docs for the whitelist |
|
||||
| T-mzu-05 | Elevation of Privilege | settings routes | high | mitigate | `@ModuleManage('nextcloud-files')` on GET/PUT settings and POST settings/test, no role decorator; controller + module-manage-handlers specs; verify grep |
|
||||
| T-mzu-06 | Information Disclosure / EoP | other users' accounts and files | high | mitigate | user id only from the token; every account access via forTenant(tenant, user); RLS tenant AND user on NextcloudFilesAccount; file operations only with the caller's own app password against `/dav/files/<own uid>/` and `/dav/uploads/<own uid>/`; foreign flowIds → 404; spec + e2e: a second Tessera user gets notConnected |
|
||||
| T-mzu-07 | Tampering | path traversal / foreign paths | medium | mitigate | parseUserPath/validateSegment reject '.', '..', empty, slash/backslash/control characters, length caps — also for ZIP selection names; paths never in the Tessera URL path; Nextcloud enforces account scope additionally |
|
||||
| T-mzu-08 | Tampering (stored XSS) | downloads, previews, logo | high | mitigate | downloads always attachment with Tessera-built Content-Disposition, nosniff, CSP sandbox, header allowlist without set-cookie; previews only image/* with size cap; logo by magic bytes, SVG only with CSP sandbox; names rendered as React text |
|
||||
| T-mzu-09 | Denial of Service | upload memory / proxy | medium | mitigate | streaming without buffering, content-length required, 8 MiB chunk cap (413), total size cap, abort propagation, async assembly with bounded in-memory state, concurrency 2 in the browser |
|
||||
| T-mzu-10 | Tampering | silent overwrite | medium | mitigate | `If-None-Match: *` on new files, `Overwrite: F` on MOVE and assembly, replace only with a matching etag (`If-Match` / re-checked etag), conflict choices in the UI |
|
||||
| T-mzu-11 | Spoofing | Login Flow hijack | medium | mitigate | poll token server-only, flow bound to tenant+user, one flow per user, 20-min TTL, 200 flows max, starts rate-limited; the browser link points only to the configured host |
|
||||
| T-mzu-12 | Elevation of Privilege | route shadowing | medium | mitigate | static routes before parameter routes, declaration-order assertion in the controller spec |
|
||||
| T-mzu-13 | Information Disclosure | TLS | medium | mitigate | certificates always verified, no off switch; internal CA via NODE_EXTRA_CA_CERTS (operations guide) |
|
||||
| T-mzu-14 | Information Disclosure / Spoofing | orphaned app passwords | medium | mitigate | fresh app passwords that are not stored are revoked at once; reconnect revokes the previous same-host credential first; disconnect revokes; residual: deleting a Tessera user leaves the token in Nextcloud (documented) |
|
||||
| T-mzu-15 | Information Disclosure | error passthrough on streams | medium | mitigate | every non-2xx upstream mapped by mapNcFailure before any header or byte is written; it.each matrix asserts no 401/403 and no piped bytes |
|
||||
| T-mzu-16 | Repudiation / Residual | admin-chosen internal address probing | low | accept | only Verwalten can set the address; responses reach the browser only as parsed fields after a successful Nextcloud login (same accepted window as Nextcloud-Status) |
|
||||
| T-mzu-SC | Tampering | npm/pip/cargo installs | low | accept | no new packages (undici, fast-xml-parser, class-validator already present) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Each task's `<automated>` command passes; Tasks 1–4 each prove their slice against the real test Nextcloud with an e2e script (settings, connect incl. Playwright TOTP proof, files incl. the second-user check, transfer incl. 30 MB through the Next.js proxy); Task 5 adds a dark smoke in the browser; Task 6 runs the full suites, reruns all four e2e scripts on the rebuilt stack and checks the light/dark screenshots.
|
||||
- `prisma migrate status` up to date locally; `migrate diff --exit-code` exits 0.
|
||||
- Source coverage audit:
|
||||
|
||||
| Source item | Covered by |
|
||||
|-------------|------------|
|
||||
| GOAL: module Nextcloud-Dateien Etappe 1, own account per user, file work inside Tessera | Tasks 1–6 |
|
||||
| L-01 one Nextcloud, address by admin/Verwalten, internal allowed, SSRF containment, address change expires | Task 1 (settings, transport), Tasks 3–4 (dav), Task 6 (server info) |
|
||||
| L-02 password → getapppassword → only encrypted app password, password discarded | Task 2 |
|
||||
| L-03 2FA via Login Flow v2, real-click link, fixed poll path, desktop opener | Task 2 (API + ConnectPanel + Playwright with TOTP) |
|
||||
| L-04 own failure limit, never repeat 429, clear message, whitelist docs | Task 1 (call gate, settings hint), Task 2 (guard, UI), Task 6 (admin + operations guides) |
|
||||
| L-05 Abmelden revokes + deletes | Task 2 (+ e2e token check, reconnect hygiene) |
|
||||
| L-06 browse/breadcrumb, list/grid per user, previews, DnD + chooser + large chunked uploads, download/ZIP, mkdir, rename, move picker, delete to trash with confirm, multi-select, quota, overwrite protection | Task 3 (file API), Task 4 (transfer API + uploader), Task 5 (UI) |
|
||||
| L-07 rights Benutzen vs. Verwalten | Tasks 1–4, 6 controller + manage-handlers specs, web gating |
|
||||
| L-08 no dashboard widget; Etappe 2 open | Task 6 gate; generic davRequest/ocsRequest, permission letters, menu action list |
|
||||
| L-09 design Mosaik-Ablage incl. tokens, stripes, transfer bar, selection bar, keyboard, connect screen, texts, mobile | Task 5 (+ contrast test, dark smoke), Task 6 (server identity, light + dark screenshots) |
|
||||
| L-10 browser check light + dark against real Nextcloud, both login paths, >10 MiB upload, download, rename/move/delete | Task 2 (login paths), Tasks 3–4 (e2e), Tasks 5–6 (Playwright) |
|
||||
| L-11 project rules (route order, no Object.hasOwn, DB via IP, rebuild, no .env, de/en parity, umlaut guard, changelog, guides) | Tasks 1–6 |
|
||||
| Checker W1 shared failure guard (401 short-circuit per account, 429 pause for all calls) | Task 1 (call gate in ncRequest + specs), Task 2 (getSession + spec), Task 3 (mapNcFailure onExpired) |
|
||||
| Checker W2 orphaned app passwords (revoke unstored fresh one, revoke old on reconnect) | Task 2 (D-P + specs + e2e single token) |
|
||||
| Checker W3 transfer routes map non-2xx before piping, no 401/403 | Task 4 (it.each matrix), Task 3 (sendUpstreamStream spec) |
|
||||
| Checker W4 cross-user isolation truth + RLS artifact + second-user check | Task 1 (migration policy), Task 2 (getSession spec), Task 3 (e2e second user) |
|
||||
| RESEARCH: 401 ambiguity → credentialsOrTwoFactor | Task 2 |
|
||||
| RESEARCH: cloud/user uid for DAV paths | Task 2 |
|
||||
| RESEARCH: PROPFIND quirks (multiple propstat, lower-case hex, -3 quota, string parsing) | Task 3 |
|
||||
| RESEARCH: path encoding per segment | Tasks 1, 3, 4 |
|
||||
| RESEARCH: Overwrite F / If-None-Match / If-Match, 412 handling | Tasks 3–4 |
|
||||
| RESEARCH: chunked upload v2 (5-digit chunk names, OC-Total-Length, .file MOVE, cleanup on 412) | Task 4 |
|
||||
| RESEARCH: Next.js 10 MiB clone limit + 30 s proxy timeout | Task 4 (8 MiB chunks, async assembly), Task 6 (operations guide) |
|
||||
| RESEARCH: preview via fileId, 256 px, image/* only | Task 3 |
|
||||
| RESEARCH: header allowlist, no cookies, attachment + nosniff | Tasks 3–4 |
|
||||
| RESEARCH: trusted_domains 400 message, maintenance 503 | Tasks 1, 3 |
|
||||
| RESEARCH: TLS verified, NODE_EXTRA_CA_CERTS | Task 1 (transport), Task 6 (operations guide) |
|
||||
| RESEARCH open question 3 (folder ZIP in stage 1) | decided yes (Task 4) |
|
||||
| RESEARCH open question 4 (Tessera user deletion leaves app password) | documented in the admin guide (Task 6) |
|
||||
| RESEARCH Etappe 2 (shares, sharees, SEARCH) | excluded by the user; architecture hooks only |
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Two new tables with RLS (tenant; tenant + user); migration applied locally without drift.
|
||||
- All new specs and tests pass; full api + web suites, both tsc runs and biome on touched files are green.
|
||||
- Against the local `tessera-nc-test` Nextcloud: address check (Task 1), password connect (anna) and Login Flow v2 with TOTP (zoe) in the browser with revoke on Abmelden and no orphaned tokens (Task 2), file operations and the second-user isolation (Task 3), 30 MB chunked upload through the Next.js proxy with identical download and mapped errors (Task 4).
|
||||
- The call gate stops all traffic to a Nextcloud after a 429 and all traffic of a credential after its first 401 (specs).
|
||||
- The file view matches L-09 in dark and light mode (screenshots reviewed), works on a 390 px viewport, respects reduced motion and the keyboard map.
|
||||
- CHANGELOG, Anwender-, Administrations- and Betriebsanleitung describe the module; six commits on main, nothing pushed.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/261008-mzu-SUMMARY.md` when done (not committed by the executor). Besides the measured gate table per task, deviations and threat status it must contain: (1) the measured multi-selection ZIP result (D-K) and which token-list method the e2e script uses; (2) the screenshot list with paths; (3) a checklist for the user's real environment — each item closes a research assumption or open question: company Nextcloud version and whether the `bruteforcesettings` whitelist is set for the Tessera server IP (Q1, A6); public certificate or `NODE_EXTRA_CA_CERTS` needed (Q2); Nginx Proxy Manager lets 8 MiB requests and long downloads through on the real Tessera address — upload a 30 MB file there (A4); the Login Flow link opens on the user's machine with the configured address (A2); desktop app: "Anmeldung bei Nextcloud öffnen" opens the system browser and a download is saved (A5); a two-factor account connects via the browser; Abmelden removes the device entry in Nextcloud.
|
||||
</output>
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
# Quick 261008-mzu: Modul "Nextcloud-Dateien" (`nextcloud-files`) - Research
|
||||
|
||||
**Researched:** 2026-10-08
|
||||
**Domain:** Nextcloud Client-APIs (OCS, Login Flow v2, WebDAV, Chunked Upload v2, Share API) + Tessera-Modulmuster + Streaming durch NestJS/Next.js
|
||||
**Confidence:** HIGH. Alle Protokollangaben wurden am 2026-10-08 gegen ein echtes `nextcloud:stable` (Version 34.0.4, SQLite) nachgestellt; Abweichungen von der Doku sind unten markiert. Nicht pruefbar: der komplette Login-Flow-v2-Durchlauf im Browser (Anmelden + Zugriff gewaehren) und https/Reverse-Proxy-Verhalten.
|
||||
|
||||
Keine CONTEXT.md. Es wird **kein neues npm-Paket** benoetigt: `undici` 7.28.0 (HTTP/Streaming), `fast-xml-parser` 5.10.1 (PROPFIND-XML) stehen schon in `apps/api/package.json`. [VERIFIED: apps/api/package.json Zeilen `"fast-xml-parser": "^5.10.1"`, `"undici": "7.28.0"`; installierte Version per `require(...).version` = 5.10.1 / 7.28.0]. Daher entfaellt das Package Legitimacy Audit. `tsdav` ist ebenfalls installiert (Kalender), wird hier NICHT genutzt: duenner eigener WebDAV-Client ist kleiner und streamt kontrolliert.
|
||||
|
||||
## Project Constraints (aus CLAUDE.md / Memory)
|
||||
- UI-Texte Deutsch, siezen; Gespraech duzen. Keine "Mandant"-Begriffe in neuen UI-Texten, Lizenzierung/Mandantenfaehigkeit nicht ansprechen.
|
||||
- Neue Module: `@UseModule(slug)` auf Klassenebene; Schreib-/Einstellungsrouten zusaetzlich `@ModuleManage(slug)`; NIE Rollen-Decorator auf Verwalten-Handlern; statische Routen VOR `:id`-Routen (Unit-Tests fangen es nicht, Reihenfolge im Controller-Spec festschreiben).
|
||||
- Zugangsdaten nie an den Client zurueckgeben (Maske `'********'` bzw. `hasPassword`/`connected: boolean`).
|
||||
- Keine firmenspezifischen Werte hart codieren (Nextcloud-Adresse nur als Einstellung).
|
||||
- Kein Docker-Deploy auf Testserver durch Claude; lokale Pruefung im Browser bevorzugt dunkel; Alles ueber einen GSD-Workflow.
|
||||
- Abkuerzung "Mosaik": neue Seiten mit `PageHeader`/`SettingsSection`; Dateien in Tessera heissen "Administration" nicht "Verwaltung".
|
||||
|
||||
## Summary
|
||||
|
||||
Das Protokoll ist unkompliziert, hat aber drei Fallen, die den Entwurf bestimmen: (1) **Brute-Force-Sperre pro IP**: alle Tessera-Benutzer kommen von EINER Server-IP; zehn Fehlanmeldungen in 30 Minuten (auch von Zwei-Faktor-Konten, siehe unten) liefern danach **HTTP 429 fuer jede Anfrage dieser IP, selbst mit gueltigem App-Passwort** (gemessen). Gegenmassnahmen: Whitelist auf der Nextcloud + eigene Fehlversuch-Begrenzung in Tessera. (2) **Zwei-Faktor-Konten antworten auf `getapppassword` mit 401, identisch zu falschem Passwort** - der Server kann beides nicht unterscheiden, also muss die Oberflaeche nach dem ersten 401 den Browser-Weg (Login Flow v2) anbieten. (3) **Next.js-Rewrite `/api-proxy` kappt Anfragekoerper nach 10 MiB** (Quelltext gelesen) - Browser-Chunks muessen kleiner sein; 8 MiB sind passend.
|
||||
|
||||
Architektur: Browser spricht nur mit der Tessera-API (`/api-proxy/modules/nextcloud-files/*`); die API haelt pro Benutzer das AES-verschluesselte App-Passwort und ruft Nextcloud mit `Authorization: Basic base64(ncUserId:appPassword)` je Anfrage, ohne Cookies. Uploads/Downloads werden als Node-Streams durchgereicht (`undici.request` mit Readable-Body + explizitem `content-length`), nichts wird gepuffert. Grosse Dateien laufen als Chunked-Upload-v2: der Browser zerlegt in 8-MiB-Stuecke, die API gibt jedes 1:1 als Nextcloud-Chunk weiter.
|
||||
|
||||
**Primary recommendation:** Neues Nest-Modul `nextcloud-files` nach dem Muster `domains` (Singleton-Einstellung) + `nextcloud-status` (Basis-URL-Normalisierung, Seed), eine pro-Benutzer-Tabelle mit Mandanten- UND Benutzer-RLS (Muster `Reminder`), eigener duenner DAV/OCS-Client mit festem Basis-URL-Praefix, **ohne Weiterleitungen**, ohne Cookies, 8-MiB-Browser-Chunks.
|
||||
|
||||
## Architectural Responsibility Map
|
||||
|
||||
| Capability | Primary Tier | Secondary | Rationale |
|
||||
|---|---|---|---|
|
||||
| Nextcloud-Adresse festlegen | API + DB (Singleton je Organisation) | Browser (Einstellungs-Tab) | nur Verwalten; Adresse normalisieren, nie aus dem Request-Body durchreichen |
|
||||
| Anmelden/App-Passwort holen, widerrufen | API | Browser (Formular, Fenster fuer Login Flow) | echtes Passwort nur im Arbeitsspeicher der API, nie gespeichert/geloggt |
|
||||
| App-Passwort speichern | DB (verschluesselt) | API (`CryptoService`) | pro Benutzer, RLS Mandant+Benutzer |
|
||||
| Login-Flow-Zustand (Poll-Token) | API (Arbeitsspeicher, 20 min) | - | Poll-Token verlaesst den Server nie |
|
||||
| Dateiliste/Metadaten (PROPFIND) | API | Browser (Darstellung, Sortierung) | XML wird serverseitig zu JSON, Browser sieht keine Nextcloud-URLs |
|
||||
| Upload/Download | API (Stream-Proxy) | Browser (Chunking, Fortschritt) | Browser darf Nextcloud nicht direkt erreichen (Zugangsdaten, interne Adresse) |
|
||||
| Vorschaubilder | API (Proxy, Cache-Header) | Browser (`<img>` mit Cookie-Anmeldung) | `<img>` kann keine Header setzen, Tessera-Cookie reicht |
|
||||
| Berechtigung | API (`ModuleGuard`) | Browser (`useCanManageModule`, nur Anzeige) | bindend ist nur die API |
|
||||
|
||||
## Nextcloud-Protokoll (am 2026-10-08 gegen nextcloud:stable 34.0.4 gemessen)
|
||||
|
||||
Allgemein: jede OCS-Anfrage `OCS-APIRequest: true` und `Accept: application/json` (sonst XML). OCS-v2-URLs liefern echte HTTP-Codes, Antworthuelle `{"ocs":{"meta":{"status","statuscode","message"},"data":...}}`. [VERIFIED: Messung; CITED: docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-api-overview.html]
|
||||
|
||||
### 1. Anmeldung mit Passwort: `GET /ocs/v2.php/core/getapppassword`
|
||||
| Fall | Gemessene Antwort |
|
||||
|---|---|
|
||||
| Richtiges Passwort, kein 2FA | `200` `{"ocs":{"meta":{"status":"ok","statuscode":200,"message":"OK"},"data":{"apppassword":"<72 Zeichen>"}}}` |
|
||||
| Falsches Passwort | `401` mit `WWW-Authenticate: Basic realm="Authorisation Required"`; ohne Accept-Header leerer/XML-Koerper |
|
||||
| Konto mit erzwungener oder aktiver 2FA (egal ob Passwort stimmt) | `401` `{"ocs":{"meta":{"status":"failure","statuscode":997,"message":"Unauthorised"},"data":[]}}` - **gleiche Antwort wie falsches Passwort** |
|
||||
| Aufruf mit einem App-Passwort statt Passwort | `403` `{"...statuscode":403,"message":"Password confirmation is required"}` (Doku nennt nur "403") |
|
||||
| Brute-Force-Grenze erreicht (>=10 Fehlversuche/30 min/IP) | `429` `{"ocs":{"meta":{"status":"failure","statuscode":429,"message":"Reached maximum delay"},"data":[]}}` (Antwort nach ~25 ms; auch bei richtigem Passwort) |
|
||||
- Quelle Controller: `#[NoAdminRequired] #[PasswordConfirmationRequired] #[ApiRoute(verb: 'GET', url: '/getapppassword', root: '/core')]`, Antwort `array{apppassword: string}`. [CITED: raw.githubusercontent.com/nextcloud/server/master/core/Controller/AppPasswordController.php]
|
||||
- Ursache 2FA: `Session::logClientIn` wirft `PasswordLoginForbiddenException`, wenn `!$isTokenPassword && ($this->isTokenAuthEnforced() || $this->isTwoFactorEnforced($user))` - und zwar VOR der Passwortpruefung; `Manager::isTwoFactorAuthenticated` ist wahr bei erzwungener 2FA oder aktivem Provider (Backup-Codes allein zaehlen nicht). [CITED: lib/private/User/Session.php, lib/private/Authentication/TwoFactorAuth/Manager.php]. Auf WebDAV zeigt sich dasselbe als XML `OCA\DAV\Connector\Sabre\Exception\PasswordLoginForbidden`, Hinweis `password login forbidden`. [VERIFIED: Messung]
|
||||
- **Folge fuer den Entwurf:** 401 = "Zugangsdaten falsch ODER Zwei-Faktor aktiv". API liefert dem Browser einen eigenen Code (z. B. `credentialsOrTwoFactor`), die Oberflaeche zeigt "Anmeldung nicht moeglich. Wenn Ihr Konto Zwei-Faktor nutzt, melden Sie sich bitte ueber den Browser an" mit Knopf "Im Browser anmelden". Das ist der "automatische" Umschalter (siehe Popup-Falle in Pitfalls). 429 bekommt eine eigene Meldung ("Nextcloud sperrt Anmeldungen vom Tessera-Server voruebergehend").
|
||||
- Danach sofort die Benutzerkennung holen: `GET /ocs/v2.php/cloud/user` mit dem NEUEN App-Passwort -> `data.id` (gemessen `"id":"anna"`, `"display-name":"Anna Müller"`). **Der WebDAV-Pfad braucht diese Kennung (uid), nicht den eingegebenen Anmeldenamen** (kann E-Mail sein). `loginName` aus Login Flow v2 ist i. d. R. gleich, trotzdem `cloud/user` abfragen und `ncUserId` speichern. [VERIFIED: Messung; Annahme "loginName kann abweichen" ASSUMED]
|
||||
|
||||
### 2. Login Flow v2 (2FA-Konten)
|
||||
1. `POST {base}/index.php/login/v2` (leerer Body, eigener `User-Agent`, z. B. `Tessera-Nextcloud-Dateien` - der Name erscheint dem Benutzer auf der Nextcloud-Seite als Geraetename). Antwort `200` (gemessen):
|
||||
`{"poll":{"token":"<128 Zeichen>","endpoint":"http://localhost:18080/login/v2/poll"},"login":"http://localhost:18080/login/v2/flow/<128 Zeichen>"}` [VERIFIED: Messung; CITED: developer_manual/client_apis/LoginFlow/index.html]
|
||||
2. Browser des Benutzers oeffnet `login` in neuem Fenster; Benutzer meldet sich dort an (inkl. 2FA) und klickt "Zugriff gewaehren".
|
||||
3. Tessera pollt `POST poll.endpoint` mit Formularfeld `token=<poll.token>`: `404` (Koerper `[]`) bis fertig; einmalig `200` `{"server":"...","loginName":"...","appPassword":"..."}`, danach wieder 404. Token gilt 20 Minuten. [CITED: LoginFlow-Doku; 404 mit falschem Token VERIFIED]
|
||||
- **Die Adressen in der Antwort baut Nextcloud aus der Host-Kopfzeile der Anfrage** (gemessen: `localhost:18080`). Deshalb: (a) `poll.endpoint` NICHT blind aufrufen (SSRF!), sondern immer `{konfigurierteBasis}/index.php/login/v2/poll` benutzen; (b) `login` nur an den Browser geben, wenn Schema http/https und Host == konfigurierter Host; sonst Ursprung durch die konfigurierte Basis ersetzen. Weicht die intern erreichbare Adresse von der Browser-Adresse ab (Overwrite-Einstellungen der Nextcloud fehlen), ist das ein Betriebsthema -> Hinweis im Einstellungs-Tab: "Adresse so eintragen, wie sie auch im Browser Ihrer Benutzer funktioniert". [ASSUMED: Verhalten hinter Proxy mit overwritehost]
|
||||
- Architektur: Server haelt `Map<flowId, {userId, tenantId, pollToken, expiresAt}>` (Arbeitsspeicher, hoechstens ein Flow je Benutzer, 20 min, Aufraeumen beim Zugriff). Browser bekommt nur `flowId` + `loginUrl` und fragt `GET connect/flow/:flowId` alle 2 s; die API ruft erst dann Nextcloud-poll. Bei 200 sofort verschluesselt speichern (kommt nur EINMAL), Flow loeschen. API-Neustart verliert offene Flows - akzeptabel (Benutzer startet neu). Poll-Token nie ans Frontend/Log.
|
||||
- Login Flow v2 `init` unterliegt in der Praxis nicht der Fehlversuch-Sperre (gemessen: `200` waehrend die IP bereits 429 bekam) [VERIFIED: Messung].
|
||||
|
||||
### 3. Abmelden: `DELETE /ocs/v2.php/core/apppassword`
|
||||
`Basic ncUserId:appPassword`, `OCS-APIRequest: true` -> `200` `{"ocs":{"meta":{"status":"ok","statuscode":200,...},"data":[]}}`; danach liefert dasselbe App-Passwort `401` ("Unauthorised"). [VERIFIED: Messung]. Doku: bei Nicht-200 trotzdem lokal loeschen. [CITED: LoginFlow-Doku] -> Reihenfolge: erst Widerruf versuchen (Zeitlimit 10 s, Fehler nur loggen), danach Zeile loeschen. Wird das App-Passwort in Nextcloud von Hand widerrufen, antworten alle Aufrufe `401` -> Konto in Tessera als "Verbindung abgelaufen" markieren und erneut verbinden lassen (nicht blind loeschen).
|
||||
|
||||
### 4. Verzeichnis lesen: `PROPFIND {base}/remote.php/dav/files/{ncUserId}/{pfad}`
|
||||
Header `Depth: 1` (nur `0` oder `1` zulassen; `infinity` NIE), `Content-Type: application/xml`. Gemessener Body, der alle Wunschfelder liefert:
|
||||
```xml
|
||||
<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||
<d:prop>
|
||||
<d:getlastmodified/><d:getetag/><d:getcontenttype/><d:getcontentlength/><d:resourcetype/>
|
||||
<oc:fileid/><oc:permissions/><oc:size/><oc:favorite/><oc:owner-display-name/><oc:share-types/>
|
||||
<nc:has-preview/><d:quota-available-bytes/><d:quota-used-bytes/>
|
||||
</d:prop>
|
||||
</d:propfind>
|
||||
```
|
||||
Antwort `207`. Gemessene Eigenheiten, die der Parser abdecken muss:
|
||||
- Je Eintrag **mehrere `d:propstat`**: Felder, die es nicht gibt, stehen in einem zweiten `propstat` mit `HTTP/1.1 404 Not Found` (z. B. `getcontenttype`/`getcontentlength` bei Ordnern, Quota bei Dateien). Nur `propstat` mit Status 200 auswerten.
|
||||
- Der erste `response` ist der angefragte Ordner selbst (bei `Depth: 1`) - ueberspringen, aber **seine** Quota (`quota-available-bytes`, `quota-used-bytes`) fuer die Kopfzeile nutzen. `quota-available-bytes` ist `-3` bei unbegrenztem Speicher (gemessen), also `<0` = "unbegrenzt/unbekannt".
|
||||
- Ordner erkennbar an `resourcetype` mit `<d:collection/>`; `oc:size` ist bei Ordnern die rekursive Groesse. Berechtigungen als Buchstaben, gemessen `RGDNVCK` (Ordner), `RGDNVW` (Datei): `R` teilbar, `G` lesbar, `D` loeschbar, `N` umbenennbar, `V` verschiebbar, `W` schreibbar (Datei), `C`/`K` Dateien/Ordner anlegbar, `S` geteilt, `M` eingebunden. [VERIFIED: Messung; CITED: WebDAV/basic.html]
|
||||
- `oc:favorite` = `0`/`1`; `nc:has-preview` = `true`/`false`; `oc:share-types` leer oder verschachtelt `<oc:share-type>3</oc:share-type>...`; `oc:fileid` kann fuehrende Nullen/grosse Zahlen haben -> als String lassen.
|
||||
- `d:getetag` kommt mit Anfuehrungszeichen (`"..."`), ETag so wie geliefert (mit Anfuehrungszeichen) fuer `If-Match` weiterverwenden.
|
||||
- `d:href` ist prozentcodiert, **Hex teils klein** (`%c3%84rger%20%26`, `%3f`). Beim Parsen `decodeURIComponent` auf jedes Segment, danach das Praefix `/remote.php/dav/files/{uid}/` (plus eventueller Unterpfad der Basis-URL) abschneiden.
|
||||
- `fast-xml-parser` Einstellung (gemessen mit v5.10.1 an einem Beispiel mit mehreren propstat/leerem Element): `new XMLParser({ removeNSPrefix: true, parseTagValue: false, parseAttributeValue: false, isArray: (n) => ['response','propstat','share-type'].includes(n) })`. `parseTagValue: false` ist Pflicht, sonst werden Namen wie `12345` oder `0123` zu Zahlen. [VERIFIED: Testlauf im Scratchpad]
|
||||
|
||||
**Pfad-Codierung (Falle Nr. 1 bei Dateinamen):** Pfad segmentweise mit `encodeURIComponent` codieren und mit `/` verbinden (nie den ganzen Pfad). Gemessen funktionieren so Namen wie `Ärger & Ölpreis 100%.txt`, `a b#c?d.txt` und `50%25.txt` (Antwort-href `50%2525.txt` entspricht dem Namen `50%25.txt`). Der Pfad vom Browser kommt als Query-Parameter bzw. JSON-Feld (nicht als URL-Pfad der Tessera-Route!), wird in Segmente zerlegt; Segmente `.`, `..`, leer oder mit `/`, `\`, NUL, Steuerzeichen werden abgelehnt (Nextcloud antwortet auf `..` mit 403, aber nicht darauf verlassen). Dateiname-Verbot von Nextcloud (`\`, `/`, Endung `.part`, reservierte Namen) gibt 400/415 -> Fehler durchreichen.
|
||||
|
||||
### 5. Anlegen / Umbenennen / Verschieben / Kopieren / Loeschen (alle gemessen)
|
||||
| Aktion | Request | Antworten |
|
||||
|---|---|---|
|
||||
| Ordner anlegen | `MKCOL .../dav/files/{uid}/{pfad}` | `201`; existiert schon `405`; Elternordner fehlt `409` |
|
||||
| Umbenennen/Verschieben | `MOVE <quelle>` + `Destination: {base}/remote.php/dav/files/{uid}/{ziel}` + **`Overwrite: F`** | `201` neu / `412` Ziel existiert / `404` Quelle fehlt / `409` Ordner in sich selbst oder Elternordner fehlt |
|
||||
| Kopieren | `COPY` analog | wie MOVE |
|
||||
| Loeschen | `DELETE <pfad>` | `204`, Datei landet im Papierkorb (`/remote.php/dav/trashbin/{uid}/trash/<name>.d<zeit>`, `files_trashbin` ist aktiv); nicht vorhanden `404`. Ordner rekursiv |
|
||||
| Favorit | `PROPPATCH` Body `<d:propertyupdate ...><d:set><d:prop><oc:favorite>1</oc:favorite></d:prop></d:set></d:propertyupdate>` | `207` mit Status 200 im propstat |
|
||||
| Neue Datei ohne Ueberschreiben | `PUT` mit `If-None-Match: *` | `201`, bei vorhandener Datei `412` |
|
||||
| Datei ueberschreiben mit Konfliktschutz | `PUT` mit `If-Match: "<etag>"` | falscher ETag `412` |
|
||||
- **Ohne `Overwrite: F` ueberschreibt MOVE still das Ziel** (gemessen `204`, vorhandene Datei weg). Tessera sendet IMMER `Overwrite: F` und uebersetzt `412` in "Ein Eintrag mit diesem Namen existiert bereits".
|
||||
- `Destination` immer aus der konfigurierten Basis + codierter Pfad bauen (absolute URL), nie aus Benutzereingabe.
|
||||
- Ordner-Download als ZIP: `GET .../dav/files/{uid}/{ordner}/?accept=zip` -> `200`, `application/zip`, `Content-Disposition: attachment; filename="Photos.zip"` (gemessen). Fuer Etappe 1 optional, kostet fast nichts. [VERIFIED: Messung]
|
||||
|
||||
### 6. Chunked Upload v2 (grosse Dateien)
|
||||
Ablauf (Doku und Messung stimmen ueberein): [CITED: developer_manual/client_apis/WebDAV/chunking.html]
|
||||
1. `MKCOL {base}/remote.php/dav/uploads/{uid}/{uploadId}` mit `Destination: {base}/remote.php/dav/files/{uid}/{zielpfad}` -> `201`. `uploadId` = `tessera-<uuid>`.
|
||||
2. `PUT .../uploads/{uid}/{uploadId}/{nummer}` je Chunk, `Destination` wie oben, `OC-Total-Length: <Gesamtgroesse>` (loest sofortige Quota-Pruefung aus), **Chunk-Name = Zahl 1..10000, fuenfstellig fuehrend aufgefuellt (`00001`) verwendet und gemessen**; `201`. Chunks werden in Namensreihenfolge zusammengesetzt.
|
||||
3. `MOVE .../uploads/{uid}/{uploadId}/.file` mit `Destination`, `OC-Total-Length`, optional `X-OC-Mtime: <unix>` und **`Overwrite: F`** -> `201` (Antwort-Header `OC-ETag`, `OC-FileId`; `X-OC-MTime: accepted`). Gemessen: Zusammenbau von 12 000 000 Byte (5+5+2 MiB-Chunks) ergab genau 12 000 000 Byte; bei vorhandenem Ziel + `Overwrite: F` kommt `412` und der Upload-Ordner BLEIBT -> danach `DELETE` zum Aufraeumen.
|
||||
4. Abbruch: `DELETE .../uploads/{uid}/{uploadId}/` -> `204` (ohne `Destination`). Nextcloud verwirft Upload-Ordner nach 24 h Inaktivitaet.
|
||||
- Grenzen: Chunk **5 MB bis 5 GB** (letzter darf kleiner sein), **Namen 1..10000**. [CITED: chunking.html] Messung: Nextcloud 34 nahm auch 1-MB-Chunks mittendrin an; sich NICHT darauf verlassen, Mindestgroesse 5 MiB einhalten. Mit 8-MiB-Chunks sind 10000 Chunks = ca. 78 GiB Obergrenze; darueber im Browser ablehnen. Fehlende `OC-Total-Length` verschiebt Quota-Fehler auf den Zusammenbau.
|
||||
- Kleine Dateien (<= 8 MiB): ein einzelner `PUT` direkt auf `.../dav/files/{uid}/{ziel}` mit `If-None-Match: *` (kein Ueberschreiben) bzw. `If-Match` (bewusstes Ersetzen), `X-OC-Mtime` aus `File.lastModified`.
|
||||
- Tessera-API-Form (zustandslos, Nextcloud haelt den Zustand): `POST uploads` {path,size} -> {uploadId,chunkSize}; `PUT uploads/:uploadId/chunks/:n?path=` (roher Body, `content-length` Pflicht, sonst 411); `POST uploads/:uploadId/complete` {path,size,mtime}; `DELETE uploads/:uploadId`. Jede Route prueft `uploadId` gegen `^tessera-[0-9a-f-]{36}$` und baut die Nextcloud-URL selbst.
|
||||
|
||||
### 7. Vorschaubilder
|
||||
`GET {base}/index.php/core/preview?fileId={id}&x=256&y=256&a=1&forceIcon=0` (Basic-Auth). Gemessen: `200`, `image/png`, `Cache-Control: private, max-age=86400, immutable`, ETag; **angefragte 128x128 lieferte 256x256** (Nextcloud rundet auf Groessenstufen -> `x=y=256` fest verwenden, im Browser per CSS skalieren). Ordner oder nicht vorhandene `fileId`: `404`. Parameter laut Controller: `fileId`, `x`/`y` (Vorgabe 32), `a` (Seitenverhaeltnis erhalten), `forceIcon` (Vorgabe true), `mode`, `mimeFallback`; Ergebnis `200/303/400/403/404`. [CITED: raw.githubusercontent.com/nextcloud/server/master/core/Controller/PreviewController.php]. Alternative `/core/preview.png?file=/pfad` existiert; die `fileId`-Variante nehmen (kein Pfad-Encoding). Nur anfragen, wenn `nc:has-preview = true`. Die Tessera-Route (`GET preview?fileId=&size=`) ist wegen `<img>` nur mit Cookie-Anmeldung erreichbar: `fileId` strikt `^\d{1,12}$`, Antwort mit `Cache-Control: private, max-age=3600` und durchgereichtem `etag` + `content-type`, nur `image/*` durchlassen, Groessendeckel (z. B. 5 MiB).
|
||||
|
||||
### 8. Teilen und Suchen (Etappe 2)
|
||||
Basis `{base}/ocs/v2.php/apps/files_sharing/api/v1`. [CITED: OCS/ocs-share-api.html; Messungen]
|
||||
- Anlegen `POST /shares` (Formularfelder): `path` (z. B. `/Readme.md`), `shareType` (0 Benutzer, 1 Gruppe, 3 Link, 4 E-Mail), `shareWith`, `permissions` (Bitmaske 1 lesen, 2 aendern, 4 anlegen, 8 loeschen, 16 teilen, 31 alles), `password`, `expireDate` (`YYYY-MM-DD`), `label`. Gemessen: Link mit `password` + `expireDate=2026-12-31` -> `200`, `data.url` = `http://.../s/<token>`, `data.expiration` = `"2026-12-31 23:59:59"`, **`password`/`share_with` kommen als `"redacted"` zurueck** (nie anzeigen), Link mit `permissions=1` wird zu `17` (lesen+teilen). Benutzer-Freigabe liefert `id`, `share_type`, `permissions`, `file_target`, `item_type`.
|
||||
- Lesen `GET /shares` (= von mir geteilt), `?shared_with_me=true` (mit mir geteilt), `?path=/x&reshares=true` (Freigaben eines Eintrags); Aendern `PUT /shares/{id}`; Aufheben `DELETE /shares/{id}`. Die Freigaben koennen mit `data[]` Felder `id, share_type, permissions, path, item_type, url, token, expiration, share_with_displayname, uid_owner` enthalten (gemessen).
|
||||
- Sharee-Suche `GET /sharees?search=zo&itemType=file&perPage=5` -> `data.users[]`/`groups[]` mit `label` und `value:{shareType,shareWith}`; `exact` und `lookup` ignorieren. [VERIFIED: Messung]
|
||||
- Suche: **WebDAV `SEARCH {base}/remote.php/dav/` ist besser als Unified Search**, weil dieselben Properties wie PROPFIND zurueckkommen (Unified Search liefert nur Titel, `fileId`, `path`, kein Size/Typ). Body gemessen funktionsfaehig:
|
||||
`<d:searchrequest xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"><d:basicsearch><d:select><d:prop>{PROPFIND-Felder + d:displayname}</d:prop></d:select><d:from><d:scope><d:href>/files/{uid}</d:href><d:depth>infinity</d:depth></d:scope></d:from><d:where><d:like><d:prop><d:displayname/></d:prop><d:literal>%suchtext%</d:literal></d:like></d:where><d:orderby>...</d:orderby><d:limit><d:nresults>50</d:nresults></d:limit></d:basicsearch></d:searchrequest>` mit `Content-Type: text/xml`; `%`, `_` im Suchtext maskieren ist [ASSUMED] noetig.
|
||||
(`depth infinity` ist hier korrekt - es ist ein Suchbereich, kein PROPFIND-Depth). Unified Search (`/ocs/v2.php/search/providers/files/search?term=&limit=`) bleibt Fallback.
|
||||
- Berechtigungsbuchstabe `R` am Eintrag (aus PROPFIND) zeigt, ob "Teilen" angeboten wird.
|
||||
|
||||
## Streaming in NestJS 11 / Express 5 und der Next.js-Proxy
|
||||
|
||||
**Body-Limits in der API:** `apps/api/src/main.ts` registriert nur `cookieParser()`, `ValidationPipe`, CORS - kein `express.raw`, kein eigenes `json({limit})`. [VERIFIED: apps/api/src/main.ts gelesen, Zeilen 11-35]. NestJS' Standard-Parser lesen nur JSON/urlencoded; ein Anfragekoerper mit `Content-Type: application/octet-stream` bleibt also **ungelesener Stream** in `req` und kann direkt weitergepipet werden. Alle vorhandenen Uploads (`FileInterceptor` in `kantine-datev`, `cert-manager`, `user`, `favorites`, `nextcloud-status`) nutzen multer-Speicher mit Grenzen von 1-5 MB und sind als Vorbild fuer **grosse** Dateien ungeeignet (komplett im RAM). [VERIFIED: grep in apps/api/src; `UploadedFileLike`-Kommentar in auth/types/auth-user.ts: "multers Voreinstellung memoryStorage"]. Fuer dieses Modul KEIN `FileInterceptor`.
|
||||
|
||||
**Streaming-Muster (gemessen mit undici 7.28.0 gegen Nextcloud, 12-MB-Datei, `cmp` identisch, RSS-Zuwachs beim Download 11 MB):**
|
||||
```ts
|
||||
// Upload: Readable aus req, Laenge aus Header (Pflicht -> kein chunked Transfer-Encoding zu PHP)
|
||||
const len = Number(req.headers['content-length']); // fehlt/NaN -> 411
|
||||
const { statusCode, body } = await request(url, {
|
||||
method: 'PUT', headers: { authorization, 'content-length': String(len), 'oc-total-length': String(total) },
|
||||
body: req, // Node-Readable direkt; maxRedirections: 0 ist bei request() Standard
|
||||
headersTimeout: 30_000, bodyTimeout: 0, signal: abort.signal,
|
||||
});
|
||||
await body.dump(); // Antwortkoerper verwerfen
|
||||
// Download: pipeline statt Puffer; eigener Content-Disposition, Range durchreichen
|
||||
const r = await request(url, { headers: { authorization, ...(range && { range }) } });
|
||||
res.status(r.statusCode); /* content-type, content-length, content-range, accept-ranges, etag durchreichen */
|
||||
await pipeline(r.body, res); // bei Client-Abbruch r.body.destroy()
|
||||
```
|
||||
- `undici.request` (nicht `fetch`) nehmen: nimmt Node-Streams ohne `duplex`-Tricks, folgt nie Weiterleitungen, liefert `body` als Readable fuer `stream.pipeline`. `fetch` mit `Readable.toWeb(...)` + `duplex: 'half'` funktionierte ebenfalls (gemessen), ist aber umstaendlicher. Das Projekt nutzt sonst `fetch as undiciFetch` (nextcloud-status-fetch.ts) - hier bewusst `request`.
|
||||
- Zeitlimits: Verbindungs-/Header-Timeout 30 s, `bodyTimeout` fuer Chunk-PUT 120 s, fuer Download 0 (Leerlauf-Timeout reicht: `bodyTimeout: 60_000` ist Leerlaufzeit in undici). Bei `req.on('aborted'/'close')` die Nextcloud-Anfrage per `AbortController` abbrechen. `Content-Disposition` selbst bilden (`attachment; filename*=UTF-8''<encodeURIComponent(name)>`), Name nicht aus Nextcloud-Kopfzeile uebernehmen; `X-Content-Type-Options: nosniff` setzen, und Inhalte, die der Browser ausfuehren kann (`text/html`, `image/svg+xml`), nur als `attachment` ausliefern.
|
||||
- Guards/Interceptors: die API-Anmeldung liest das Cookie; kein Interceptor darf den Body lesen. `requestLogMiddleware` protokolliert nur Pfad/Status/Dauer (laut Domains-Recherche), Pfade mit Dateinamen stehen NICHT im URL-Pfad der Tessera-Routen, sondern in Query/Body - Query-Strings trotzdem nicht loggen lassen (pruefen).
|
||||
|
||||
**Next.js-Rewrite (`/api-proxy` -> `API_INTERNAL_URL`):** `apps/web/next.config.ts` hat `rewrites()` mit `source: '/api-proxy/:path*'`, `destination: ${apiUrl}/:path*`; Browser nutzt `NEXT_PUBLIC_API_URL=/api-proxy` (apps/web/Dockerfile Zeile 28). [VERIFIED: next.config.ts gelesen; Dockerfile-grep]. In Next 15.5.19 baut `router-server.js` (Zeile ~349) den Proxy-Aufruf so: `proxyRequest(req, res, parsedUrl, undefined, getRequestMeta(req,'clonableBody')?.cloneBodyStream(), config.experimental.proxyTimeout)`. `cloneBodyStream()` begrenzt auf `DEFAULT_BODY_CLONE_SIZE_LIMIT = 10 * 1024 * 1024 // 10MB` (`next/dist/server/body-streams.js:30`) und schneidet darueber hinaus **still ab** ("Only the first 10 MB will be available", dann `p1.push(null)`) - der Upstream bekaeme einen abgeschnittenen Koerper bei falschem `content-length`. [VERIFIED: next 15.5.19 Quelltext in apps/web/node_modules/next/dist/server gelesen]. Config-Schluessel zum Anheben: `experimental.middlewareClientMaxBodySize` (Vorgabe 10485760 in config-shared.js). Die `middleware.ts` greift fuer `/api-proxy` nicht (Matcher `'/((?!api|_next/static|_next/image|.*\\.png$).*)'` schliesst Pfade mit Praefix `api` aus) - das aendert aber nichts am Klonen im Rewrite-Pfad. Das Klonen puffert zusaetzlich bis zur Grenze im Speicher (zwei PassThrough ohne Gegendruck).
|
||||
- **Empfehlung:** Browser-Chunks **8 MiB (8 388 608 Byte)** - unter 10 MiB mit Reserve, ueber dem Nextcloud-Minimum von 5 MiB. Konstante in `@tessera/shared` (`NEXTCLOUD_FILES_CHUNK_SIZE`), API liefert sie beim Upload-Start mit; die API lehnt Chunks > 8 MiB + 1 mit 413 ab. `experimental.middlewareClientMaxBodySize` NICHT erhoehen (haelt Puffer klein).
|
||||
- Downloads laufen als Antwort-Stream durch dieselbe Proxy-Strecke (`http-proxy`, kein Puffer); `proxyTimeout` ist 30 s (Leerlauf der Proxy-Verbindung, `experimental.proxyTimeout: undefined` in config-shared.js, Vorgabe in proxy-request.js `proxyTimeout || 30000`) - bei laufendem Datenstrom kein Problem, aber ein Chunk-PUT, dessen Weitergabe an Nextcloud laenger als 30 s dauert, wird gekappt. 8 MiB brauchen im Normalfall Sekunden; bei langsamer Nextcloud Fehler "Zeitueberschreitung" mit Wiederholknopf je Chunk (Chunk-PUT ist idempotent: gleiche Nummer ueberschreibt).
|
||||
- **Nginx Proxy Manager davor:** `client_max_body_size`-Vorgabe von nginx ist 1 MB; wie NPM fuer Tessera eingestellt ist, ist nicht pruefbar (laut Betriebsanleitung Zeile 743 sind Grenzen/Zeitlimits des Proxys ein bekannter Stolperstein bei grossen Downloads). [ASSUMED: Upload-Grenze fuer 8-MiB-Anfragen ausreichend] -> im Betriebshandbuch eintragen: "client_max_body_size mindestens 10m" fuer die Tessera-Adresse, und im Test eine 8-MiB-Datei ueber die echte Adresse (nicht nur localhost) pruefen. Wiederaufnahme: Browser merkt sich fertige Chunk-Nummern, bei Fehler Wiederholung (3 Versuche, Wartezeit steigend) nur des fehlenden Chunks.
|
||||
- Desktop-App (Tauri) zeigt dieselben Server-Seiten -> kein Sonderweg. Datei-Download im Desktop: `<a href download>` auf die Tessera-Route; ob Tauri-WebView Downloads ohne Plugin speichert, ist [ASSUMED] ungeprueft (Memory: `tauri-plugin-opener` schluckt `target=_blank`-Links; Web-Helfer lauscht auf `document`).
|
||||
|
||||
## SSRF-Eingrenzung (interne Adresse erlaubt, weil vom Administrator gesetzt)
|
||||
|
||||
`isPublicHttpUrl` (apps/api/src/common/public-url-guard.ts) lehnt private, Loopback- und Link-Local-Bereiche ab - fuer dieses Modul **bewusst NICHT verwenden**, genau wie `nextcloud-status-fetch.ts` es beschreibt ("interne Adressen sind mit Absicht erlaubt (Clouds stehen oft im Haus)"). [VERIFIED: public-url-guard.ts und nextcloud-status-fetch.ts gelesen]. Stattdessen:
|
||||
1. **Eine einzige Basis-URL** je Organisation, gespeichert im Normalformat von `normalizeCloudUrl()` (exportiert aus `nextcloud-status-fetch.ts`: nur http/https, keine Zugangsdaten im URL, kein Query/Anker, ohne `/status.php`//`index.php`, ohne Schraegstrich am Ende). Nur `Verwalten` darf sie aendern; Aenderung erzwingt "Verbindung pruefen" (`GET {base}/status.php`, bestehende `fetchNextcloudStatus`: Weiterleitungen max. 3, 10 s, 64 KiB) und trennt/loescht NICHT automatisch bestehende Konten, zeigt aber Warnung "N Benutzer sind verbunden; sie muessen sich neu anmelden" (App-Passwoerter gelten nur fuer die alte Nextcloud). Besser: bei Adresswechsel alle Konten als "Verbindung abgelaufen" markieren.
|
||||
2. **Pfade nur relativ**: jede Nextcloud-URL wird als `${basis}${festerPfad}${codierteSegmente}` zusammengesetzt; Benutzereingaben sind nur Pfadsegmente (siehe oben), IDs sind regex-validiert. Kein Request-Parameter enthaelt je eine URL.
|
||||
3. **Keine Weiterleitungen** zur Laufzeit: `undici.request` folgt keinen; 3xx wird als Fehler "Nextcloud leitet um - bitte Adresse (https?) im Einstellungs-Tab korrigieren" gemeldet. Nur im Verbindungstest (`status.php`) sind Sprünge erlaubt, und dort wird die endgueltige Adresse nur angezeigt, nicht still uebernommen. Basic-Auth-Kopf darf nie an einen anderen Host gehen.
|
||||
4. Aus Nextcloud-Antworten werden nie URLs aufgerufen (`poll.endpoint` verwerfen, Share-`url` nur als Text anzeigen, Unified-Search-`resourceUrl` nicht aufrufen).
|
||||
5. Zeit- und Groessendeckel auf jeder Anfrage (OCS/PROPFIND-Antworten <= 8 MiB lesen, 15 s; Streams haben eigene Grenzen).
|
||||
6. TLS: Zertifikate werden **geprueft** (wie nextcloud-status: "Zertifikate werden geprueft (kein Abschalten, D-H)"); es werden App-Passwoerter gesendet, kein `rejectUnauthorized: false`. Proxmox/Favoriten haben Ausnahmen (`proxmox-auth.ts:84`, `icon-discovery.service.ts:62`), die hier nicht uebernommen werden. Interne CA: Betrieb setzt `NODE_EXTRA_CA_CERTS` fuer den api-Container (derzeit nirgends in Compose/Dockerfile gesetzt, grep leer) -> in der Betriebsanleitung beschreiben. [VERIFIED: grep ueber yml/ts/Dockerfile/md]
|
||||
7. Restfenster wie bei Logo-Abruf: ein Verwalter kann Tessera auf eine interne Adresse zeigen lassen; Antworten gelangen nicht an den Browser ausser fest ausgewerteten Feldern (Dateiliste nur nach erfolgreichem Nextcloud-Login). Dokumentieren.
|
||||
8. Der Trusted-Domain-Check der Nextcloud gehoert zum Erreichen dazu: eine Host-Kopfzeile, die nicht in `trusted_domains` steht, bekommt `400` mit HTML-Fehlerseite - gemessen auch fuer `/status.php`, `/ocs/...` und WebDAV. Der Verbindungstest meldet das als "Nextcloud lehnt diese Adresse ab (vertrauenswuerdige Domains)". [VERIFIED: Messung mit `Host: evil.example`]
|
||||
|
||||
## Codebase-Integration (Dateien, gelesen)
|
||||
|
||||
### Backend
|
||||
| Zweck | Vorlage / Ort | Hinweis |
|
||||
|---|---|---|
|
||||
| Modul + Seed | `apps/api/src/domains/domains.module.ts` (+ `domains.seed.ts`), `apps/api/src/nextcloud-status/nextcloud-status.seed.ts` | `seedModule({slug:'nextcloud-files', name, version:'1.0.0', category, description:{de,en}, isSystem:true})`, `onModuleInit` mit try/catch. Kategorie `'infrastructure'` (wie nextcloud-status) oder `'domain-tools'`; Kategorien sind in der Verwaltung umbenennbar (module-categories.service.spec.ts listet `domain-tools, security-tools, fleet, infrastructure, procurement, accounting, custom-modules`). |
|
||||
| Registrierung | `apps/api/src/app.module.ts` (Importliste, `NextcloudStatusModule` Zeile 32 als Muster) | `CryptoService` kommt aus dem globalen `CryptoModule`, `PrismaService` ist global (steht so im Kommentar von `domains.module.ts`). |
|
||||
| Controller | `apps/api/src/nextcloud-status/nextcloud-status.controller.ts` | `@Controller('modules/nextcloud-files')` + Klassen-`@UseModule('nextcloud-files')`; `requireTenantId(req)` aus `req.tenantId`; Benutzer via `@CurrentUser()`; **nur** Einstellungsrouten (`PUT settings`, `POST settings/test`) mit `@ModuleManage('nextcloud-files')`. Alle Datei-/Verbindungsrouten sind Benutzer-Routen (nur Klassen-`@UseModule`). Statische Routen (`connect/...`, `settings`, `uploads`, `preview`, `files/search`) VOR `:id`. |
|
||||
| Pflicht-Tests | `apps/api/src/module-registry/module-manage-handlers.spec.ts` | `expectManage(Controller, name, slug)` fuer jede Verwalten-Route, und die Gegenprobe "Leseroute ohne Manage" (Zeilen ~102, ~47) erweitern. |
|
||||
| Verschluesselung | `apps/api/src/crypto/crypto.service.ts` | `encrypt(plain)` -> `iv:authTag:ciphertext` (AES-256-GCM, `TESSERA_ENCRYPTION_KEY`, 64 Hex). Entschluesselungsfehler nicht schlucken (kein stiller Wechsel zu "nicht verbunden" - Konto als defekt melden, Fehler loggen ohne Wert), LDAP-Muster `decryptBindPassword`. |
|
||||
| Einstellungs-Singleton | `apps/api/src/domains/domains-settings.service.ts`, `prisma/schema.prisma` `model DomainsConfig` (`tenantId String @unique`) | Eine Zeile je Organisation: `NextcloudFilesConfig { id, tenantId @unique, baseUrl, createdAt, updatedAt }`. |
|
||||
| Pro-Benutzer-Konto | `prisma/schema.prisma` `model Reminder` (`userId` + `user @relation(... onDelete: Cascade)`, `@@index([tenantId, userId, ...])`) | `NextcloudFilesAccount { id, tenantId, userId, ncUserId, encryptedAppPassword, status ('ACTIVE'/'EXPIRED'), connectedVia ('PASSWORD'/'LOGIN_FLOW'), baseUrl (die, fuer die das Passwort ausgestellt wurde), createdAt, lastUsedAt, @@unique([tenantId, userId]) }`. `baseUrl` mitspeichern, damit nach Adresswechsel erkennbar ist, dass das Konto veraltet ist. |
|
||||
| Migration | Zeitstempel > `20261008160000` (letzter Ordner `20261008160000_domains_drop_default_nameservers`), Vorlage `20260929140000_reminder` | Handgeschrieben mit Kopfkommentar. RLS-Block fuer die Benutzertabelle woertlich wie bei Reminder (ohne `system_read_policy`, es gibt keinen Systemleser): `ALTER TABLE "NextcloudFilesAccount" ENABLE ROW LEVEL SECURITY; ... FORCE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_policy ON "NextcloudFilesAccount" USING ("tenantId" = current_tenant_id() AND (current_user_id() IS NULL OR "userId" = current_user_id()));`. Konfig-Tabelle: Policy nur `USING ("tenantId" = current_tenant_id())` wie `DomainsConfig`. Sonst faellt `rls-coverage.spec.ts`. Rechte fuer `tessera_app` kommen ueber `ALTER DEFAULT PRIVILEGES` (steht so im Kopf der Domains-Migration). |
|
||||
| Zugriff | `forTenant(this.prisma, tenantId, userId)` aus `prisma/prisma-tenant.extension.ts` | Dritter Parameter `userId` setzt `app.current_user`; ohne ihn ist er Leerstring. Benutzer-ID IMMER aus dem Token. |
|
||||
| RLS-Inventar | `docs/mandantentrennung-zugriffsklassifikation.md` + `apps/api/src/prisma/rls-access-inventory.spec.ts` | Je neuer Service-Datei und Modell eine Zeile in der Fundstellentabelle (Form: `| apps/api/src/nextcloud-files/<datei>.ts | nextcloudFilesAccount | muss-mandantengebunden | gebunden | ... |`), Summenzeile fortschreiben; Spec rechnet Fundstellen aus dem Quelltext nach und scheitert bei fehlenden/ueberzaehligen Eintraegen. Pro Methode ein eigener Klient (`const tenantPrisma = forTenant(...)`), sonst erkennt der Detektor es nicht. |
|
||||
| Fehlversuch-Begrenzung | neu, im Dienst | In-Memory-Zaehler je (userId) und global: max. 3 Passwort-Anmeldungen je Benutzer / 10 min und max. 5 je 10 min insgesamt vom Server, danach eigener Fehlercode ohne Nextcloud-Aufruf. Schuetzt die geteilte IP vor der 429-Sperre. Vorbild fuer Zaehler: Domains-Rate-Limiter `domains-cache.ts`/autodns-client. |
|
||||
|
||||
### Frontend (alle Punkte noetig, sonst erscheint das Modul nicht)
|
||||
- `apps/web/src/lib/module-loader.ts`: Eintrag `'nextcloud-files': { component: dynamic(() => import('@/app/(portal)/modules/nextcloud-files/page'), { ssr: false }) }` (Muster `nextcloud-status`, Zeile ~62).
|
||||
- `apps/web/src/lib/module-identity.ts`: `ICONS['nextcloud-files']`. `ModuleIconId` ist eine feste Union (`'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'shopping-bag' | 'cloud' | 'earth' | 'tile'`); entweder `'cloud'` wiederverwenden oder neues Symbol `'folder'` in der Union UND in `components/modules/module-tile.tsx` (dort ist `'shopping-bag'` ein Zweig, Zeile 53) ergaenzen. Empfehlung: neues Symbol `'folder'`, sonst sind die zwei Nextcloud-Module nicht unterscheidbar.
|
||||
- `apps/web/src/lib/stores/nav-store.ts`: `MODULE_TITLE_KEYS['nextcloud-files'] = 'nextcloudFiles.title'` (Muster Zeile 28).
|
||||
- `apps/web/src/app/(portal)/modules/nextcloud-files/layout.tsx` mit `<ModuleAccessGate moduleSlug="nextcloud-files">` + Eintrag in `module-layouts.test.tsx` (Zeile ~47).
|
||||
- Seite: `PageHeader` (`@/components/layout/page-header`), Tabs wie `modules/domains/page.tsx` ("Dateien" / "Einstellungen"); Tab "Einstellungen" nur wenn `useCanManageModule('nextcloud-files')` (apps/web/src/lib/use-module-capability.ts) mit `SettingsSection` (`components/control-center/settings-section.tsx`) wie `modules/domains/components/SettingsTab.tsx`. `ControlCenterNav` gehoert zum Administrationsbereich (`/admin/...`, `/settings`) und ist hier NICHT einzubauen; der Einstellungs-Tab im Modul ist der Weg (so macht es `domains`). [VERIFIED: Dateiliste domains/, grep SettingsSection/ControlCenterNav]. Das Konto-Verbinden (Benutzer-Formular) gehoert als Zustand "Nicht verbunden" in den Tab "Dateien" (Karte mit Formular) plus kleiner Eintrag "Abmelden" im Seitenkopf.
|
||||
- API-Client `apps/web/src/lib/nextcloud-files-api.ts` nach `nextcloud-status-api.ts` (`credentials: 'include'`, `NEXT_PUBLIC_API_URL`). Chunk-Upload mit `XMLHttpRequest` (Fortschritt `upload.onprogress`) oder `fetch`; `Blob.slice(i*8MiB, ...)`.
|
||||
- Texte `apps/web/src/messages/de.json` UND `en.json` (Paritaetstests), neuer Namensraum `nextcloudFiles`; deutsche Texte mit echten Umlauten und Sie-Form; `apps/web/src/messages/umlaut-guard.spec.ts` prueft de.json gegen `umlaut-dictionary.ts` - neue Wortformen ggf. dort eintragen. Keine "Mandant"-Woerter.
|
||||
- Kein Dashboard-Widget in Etappe 1 (`WIDGET_MODULE_SLUGS` in `packages/shared/src/index.ts` unveraendert).
|
||||
- Verbindungs-Fenster (Login Flow): **kein `window.open` nach asynchronem Aufruf** (Popup-Blocker, Tauri-Opener schluckt `target=_blank` bei Script-Oeffnung, siehe Memory). Ablauf: Klick auf "Im Browser anmelden" -> API `POST connect/flow` -> Antwort zeigt echten Link `<a href={loginUrl} target="_blank" rel="noopener noreferrer">Bei Nextcloud anmelden</a>` (Benutzerklick; der Web-Helfer fuer Opener-Links lauscht auf `document`) und startet das Abfragen; Text "Warte auf Ihre Bestaetigung in Nextcloud ..." mit Abbrechen. Alternativ im selben Klick `window.open('', '_blank')` synchron vorab oeffnen und spaeter `location` setzen - im Desktop-Client unsicher, daher Link bevorzugt.
|
||||
|
||||
## Don't Hand-Roll
|
||||
| Problem | Nicht bauen | Stattdessen | Warum |
|
||||
|---|---|---|---|
|
||||
| Verschluesselung App-Passwort | eigenes Krypto | `CryptoService` | AES-256-GCM, gemeinsamer Schluessel |
|
||||
| XML-Antwort | Regex | `fast-xml-parser` 5.10.1 (schon da) | mehrere propstat, Entities, leere Elemente |
|
||||
| HTTP-Streaming | eigener Socket-Code | `undici.request` + `stream/promises.pipeline` | Gegendruck, Abbruch |
|
||||
| Berechtigung | eigene Rollenpruefung | `@UseModule` + `@ModuleManage` | Gruppen-/Direktfreigaben |
|
||||
| Mandanten-/Benutzerschutz | `WHERE userId` von Hand | `forTenant(prisma, tenantId, userId)` + RLS-Migration | RLS-Tests erzwingen es |
|
||||
| Adress-Normalisierung | eigene Regex | `normalizeCloudUrl()` | gleiche Regeln wie nextcloud-status |
|
||||
| Chunk-Zusammenbau | eigene Protokolle | Nextcloud Chunked Upload v2 (`.file`-MOVE) | Quota-Pruefung, Atomizitaet, Mtime |
|
||||
| Vorschaubilder erzeugen | Bildverarbeitung | `/core/preview` der Nextcloud | Formate (PDF, Video, HEIC) kennt nur sie |
|
||||
| TOTP-Berechnung im Test | eigene Kryptobibliothek | 15-Zeilen-Python (stdlib) siehe Testaufbau | nur Testhilfe |
|
||||
|
||||
## Common Pitfalls
|
||||
1. **Brute-Force-Sperre trifft alle Benutzer.** Gemessen: Fehlversuche 1-5 liefen mit wachsender Verzoegerung (~0,6 s je Aufruf), ab dem 11. Versuch `429` fuer die ganze IP, **auch fuer PROPFIND mit gueltigem App-Passwort**. Jede 2FA-Anmeldung per Passwort zaehlt als Fehlversuch (vor der Passwortpruefung abgelehnt, aber `handleLoginFailed` zaehlt). Gegenmassnahmen: (a) Tessera-eigene Begrenzung (siehe Tabelle), (b) Nextcloud-Administrator traegt die IP/das Netz des Tessera-Servers in die Whitelist ein: `occ config:app:set bruteForce whitelist_0 --value=<IP oder CIDR>` (gemessen: danach `occ security:bruteforce:attempts <ip>` -> `bypass-listed: true`; wirkt ueber die App `bruteforcesettings`), im Einstellungs-Tab als Hinweis, (c) Notfall: `occ security:bruteforce:reset <ip>`. 429-Antworten NIE wiederholen (verlaengert die Sperre), mit eigener Meldung zeigen. Hinter Reverse-Proxy muessen `trusted_proxies` stimmen, sonst zaehlt Nextcloud die Proxy-Adresse. [CITED: admin_manual/configuration_server/bruteforce_configuration.html]
|
||||
2. **401 ist mehrdeutig** (falsches Passwort vs. 2FA) - siehe Abschnitt 1; Fehlertext darf keine Aussage "Passwort falsch" machen.
|
||||
3. **App-Passwort ist kein Passwort.** `getapppassword` mit App-Passwort -> 403; Tessera darf nie versuchen, mit einem App-Passwort ein weiteres zu holen.
|
||||
4. **Cookies:** Nextcloud setzt bei jeder Antwort mehrere Session-Cookies (`oc...`, `nc_sameSite...`). Keinen Cookie-Speicher mitfuehren (undici hat keinen) und Antwortkopfzeilen `set-cookie` nie an den Browser weiterreichen (Download-/Vorschau-Antworten nur ueber eine Positivliste: content-type, content-length, content-range, accept-ranges, etag, last-modified, cache-control).
|
||||
5. **Dateinamen:** siehe Pfad-Codierung; zusaetzlich Unicode-Normalisierung (macOS-NFD vs NFC) nicht aendern, Namen unveraendert durchreichen; `href`-Dekodierung pro Segment; Namen mit `%` brauchen doppelte Beachtung (`50%25.txt`). Name `d:displayname` nicht verwenden, den Namen aus dem `href` nehmen.
|
||||
6. **ETag/Konflikte:** Ersetzen einer Datei nur mit `If-Match`, Neuanlage mit `If-None-Match: *`, MOVE/COPY mit `Overwrite: F`; `412` -> "wurde zwischenzeitlich geaendert/existiert bereits". Liste nach jeder Schreibaktion neu laden (ETag/Groesse der Ordner aendern sich).
|
||||
7. **Quota:** `quota-available-bytes` des Ordners vor Upload pruefen (`-3`/negativ = unbegrenzt); Upload mit `OC-Total-Length` startet die Pruefung serverseitig -> bei `507 Insufficient Storage` Meldung "Speicherplatz in Nextcloud erschoepft".
|
||||
8. **Chunk-Aufraeumen:** abgebrochene Uploads (Tab geschlossen) lassen Ordner unter `uploads/{uid}/` (24 h Ablauf). Bei Fehlern im Browser `DELETE uploads/:id` ausloesen; bei `412` im finalen MOVE ebenfalls (gemessen: Ordner bleibt).
|
||||
9. **Weiterleitungen:** http->https-Umleitung der Nextcloud macht aus PUT/PROPFIND einen Fehler -> Adresse im Einstellungs-Tab mit https eintragen; Verbindungstest meldet die endgueltige Adresse.
|
||||
10. **Nextcloud-Unterpfad** (`https://host/nextcloud`): Basis enthaelt den Unterpfad; beim Dekodieren der `href` das Praefix `<unterpfad>/remote.php/dav/files/{uid}/` abziehen.
|
||||
11. **Wartung:** `status.php` meldet `maintenance: true` -> WebDAV liefert `503`; als "Nextcloud ist im Wartungsmodus" melden.
|
||||
12. **Benutzer wird in Tessera geloescht/deaktiviert:** Konto-Zeile faellt per `onDelete: Cascade` weg; das App-Passwort bleibt in Nextcloud bestehen (Geraete-Liste) - beim Loeschen durch Admin nicht erreichbar (kein Klartext-Zugriff noetig: man koennte entschluesseln und widerrufen; fuer Etappe 1 dokumentieren, nicht bauen).
|
||||
13. **Streams und Fehler:** Wenn der Nextcloud-Upload mittendrin abbricht, `req` per `req.destroy()` beenden; bei Download-Abbruch beide Seiten zerstoeren (`pipeline` erledigt es), sonst haengen Verbindungen.
|
||||
14. **Routen-Reihenfolge Nest:** `files/search`, `files/zip`, `preview`, `uploads` vor Parameterrouten; Tessera-Routen haben keine Dateipfade im URL-Pfad (nur Query/Body), daher kaum Shadowing, trotzdem Spec-Test fuer die Reihenfolge.
|
||||
|
||||
## UI-Hinweise (aus Nextclouds Dateien-App, kurz)
|
||||
- Brotkruemelleiste (Home > Ordner > Unterordner, jeder Teil klickbar, letzter Teil nicht), Tabelle mit Spalten Name / Groesse / Geaendert, Sortierung nach Name/Groesse/Datum, Ordner immer vor Dateien; Umschalter Liste/Raster (Raster nutzt Vorschaubilder, Liste kleine Symbole; Auswahl pro Benutzer im Browser merken, z. B. `localStorage`).
|
||||
- Mehrfachauswahl per Haken/Shift-Klick mit Aktionsleiste ("N ausgewaehlt": Herunterladen, Verschieben, Loeschen); Einzelaktionen im Drei-Punkte-Menue (Umbenennen, Verschieben, Favorit, Teilen in Etappe 2, Loeschen).
|
||||
- Hochladen: Knopf "Hochladen" UND Drag&Drop auf die Liste (Ordner-Overlay "Dateien hier ablegen"), Fortschrittsliste mit Abbrechen je Datei, Konflikt bei vorhandenem Namen: "Ersetzen / Beide behalten / Ueberspringen".
|
||||
- Loeschen mit Hinweis "In den Papierkorb der Nextcloud verschoben" (Wiederherstellen dort), Favoriten oben/als Filter, Leerzustand pro Ordner, Ladezustand mit Skeletten, Fehlerzustand "Nextcloud nicht erreichbar" mit Wiederholen.
|
||||
- Mosaik: `PageHeader`, Karten/`SettingsSection` fuer Einstellungen, dunkel und hell pruefen (Memory: Browser-Pruefungen bevorzugt dunkel).
|
||||
|
||||
## Lokaler Testaufbau (nextcloud:stable, am 2026-10-08 so durchgespielt)
|
||||
|
||||
Alle Befehle liefen so; Abbild `nextcloud:stable` (34.0.4, rund 1 GB) liegt jetzt lokal (der Test-Container `nc-research` ist wieder entfernt). Installation dauert unter 1 Minute.
|
||||
```bash
|
||||
# 1) Container mit SQLite, Admin und vertrauenswuerdigen Domains (Auto-Installation ueber Umgebungsvariablen)
|
||||
docker run -d --name nc-test -p 18080:80 \
|
||||
-e SQLITE_DATABASE=nextcloud -e NEXTCLOUD_ADMIN_USER=admin -e NEXTCLOUD_ADMIN_PASSWORD='Admin-Pass-12345' \
|
||||
-e NEXTCLOUD_TRUSTED_DOMAINS='localhost 127.0.0.1 172.17.0.1 nc-test' nextcloud:stable
|
||||
until curl -s localhost:18080/status.php | grep -q '"installed":true'; do sleep 5; done
|
||||
|
||||
# 2) Weitere trusted domain nachtraeglich (Index 5 ist der naechste freie, vorher Liste ansehen)
|
||||
docker exec -u www-data nc-test php occ config:system:get trusted_domains
|
||||
docker exec -u www-data nc-test php occ config:system:set trusted_domains 5 --value=host.docker.internal
|
||||
|
||||
# 3) Zwei Benutzer: anna (ohne 2FA), zoe (mit 2FA)
|
||||
docker exec -u www-data -e OC_PASS='User1-Pass-12345' nc-test php occ user:add --password-from-env --display-name="Anna Müller" anna
|
||||
docker exec -u www-data -e OC_PASS='User2-Pass-12345' nc-test php occ user:add --password-from-env --display-name="Zwei Faktor" zoe
|
||||
|
||||
# 4) 2FA nur fuer zoe erzwingen (Gruppe), anna bleibt ohne
|
||||
docker exec -u www-data nc-test php occ group:add twofa
|
||||
docker exec -u www-data nc-test php occ group:adduser twofa zoe
|
||||
docker exec -u www-data nc-test php occ twofactorauth:enforce --on --group=twofa
|
||||
# -> getapppassword fuer zoe liefert jetzt 401 (gemessen), fuer anna 200.
|
||||
# (twofactor_totp 16.0.0, twofactor_backupcodes sind im Abbild schon aktiviert, kein app:install noetig; occ twofactorauth:enable/disable/state/enforce/cleanup existieren)
|
||||
```
|
||||
**TOTP fuer zoe ohne Browser einrichten** (`occ` hat dafuer keinen Befehl; die Nextcloud-PHP-Klassen schon, gemessen `bool(true)`; danach `occ twofactorauth:state zoe` -> "Enabled providers: - totp"):
|
||||
```bash
|
||||
cat > /tmp/totp.php <<'EOF'
|
||||
<?php
|
||||
require_once '/var/www/html/lib/base.php';
|
||||
\OC_App::loadApp('twofactor_totp');
|
||||
$totp = \OC::$server->get(\OCA\TwoFactorTOTP\Service\ITotp::class);
|
||||
$user = \OC::$server->get(\OCP\IUserManager::class)->get($argv[1]);
|
||||
if ($argv[2] === 'secret') { echo $totp->createSecret($user), "\n"; } else { var_dump($totp->enable($user, $argv[2])); }
|
||||
EOF
|
||||
cat > /tmp/totp.py <<'EOF'
|
||||
import sys, base64, hmac, hashlib, struct, time
|
||||
k = base64.b32decode(sys.argv[1].upper() + '=' * (-len(sys.argv[1]) % 8))
|
||||
h = hmac.new(k, struct.pack('>Q', int(time.time()) // 30), hashlib.sha1).digest()
|
||||
o = h[-1] & 15
|
||||
print('%06d' % ((struct.unpack('>I', h[o:o+4])[0] & 0x7fffffff) % 1000000))
|
||||
EOF
|
||||
docker cp /tmp/totp.php nc-test:/tmp/totp.php
|
||||
SECRET=$(docker exec -u www-data nc-test php /tmp/totp.php zoe secret | tail -1)
|
||||
docker exec -u www-data nc-test php /tmp/totp.php zoe "$(python3 -I /tmp/totp.py $SECRET)" # -> bool(true)
|
||||
echo "$SECRET" # fuer spaetere Codes: python3 -I /tmp/totp.py $SECRET
|
||||
```
|
||||
- Test **Passwort-Weg**: `anna` / `User1-Pass-12345` im Tessera-Formular -> verbunden. **2FA-Weg**: `zoe` / `User2-Pass-12345` im Formular -> 401 -> Oberflaeche bietet "Im Browser anmelden" -> Link oeffnen, bei Nextcloud als zoe anmelden, dort wird nach dem TOTP-Code gefragt (Code mit `python3 -I /tmp/totp.py $SECRET`), "Zugriff gewaehren" klicken -> Tessera meldet "Verbunden". Login Flow v2 laesst sich auch mit `anna` pruefen (ohne Code), falls der TOTP-Dialog stoert.
|
||||
- Die 2FA-Abweisung selbst laesst sich ohne Browser per `curl -u zoe:... -H 'OCS-APIRequest: true' http://localhost:18080/ocs/v2.php/core/getapppassword` (401) pruefen; die Brute-Force-Zaehler dabei im Auge behalten.
|
||||
- **Erreichbarkeit aus dem API-Container:** `docker network connect tessera-ctl_backend-net nc-test` (Netzname gemessen: `tessera-ctl_backend-net`, api-Container-IP dort 172.21.0.2); Nextcloud-Adresse in Tessera dann `http://nc-test` (Name steht in `NEXTCLOUD_TRUSTED_DOMAINS`). Alternativ `http://172.17.0.1:18080` (Host-Gateway; dann steht `172.17.0.1` in den vertrauenswuerdigen Domains - es ist oben enthalten).
|
||||
- **Fehlversuch-Zaehler zuruecksetzen**, wenn Tests die IP gesperrt haben: `docker exec -u www-data nc-test php occ security:bruteforce:reset <ip-des-api-containers>`; Whitelist fuer Dauertests: `docker exec -u www-data nc-test php occ config:app:set bruteForce whitelist_0 --value=172.21.0.0/16` (gemessen mit anderem Netz: `bypass-listed: true`). Fuer die Pruefung der 429-Behandlung die Whitelist bewusst NICHT setzen und 11 falsche Anmeldungen senden.
|
||||
- Aufraeumen: `docker rm -f nc-test` (Daten liegen im Container, kein Volume).
|
||||
- Weitere nuetzliche Stellen: Grossdatei fuer Chunk-Test `head -c 30000000 /dev/urandom > big.bin`; Papierkorb pruefen `PROPFIND /remote.php/dav/trashbin/anna/trash`; Freigaben `occ` hat keinen Befehl, im Browser unter `http://localhost:18080` als anna.
|
||||
|
||||
## Validation Architecture
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| Framework | Vitest 3.2.6 (apps/api), 4.1.9 (apps/web) [VERIFIED: CLAUDE.md Stack-Tabelle] |
|
||||
| Quick run | `pnpm --filter api exec vitest run src/nextcloud-files` / `pnpm --filter web exec vitest run src/app/\(portal\)/modules/nextcloud-files` |
|
||||
| Nextcloud-Mock | Client hinter Schnittstelle (`NextcloudFilesClient`), HTTP-Schicht mit `undici` `MockAgent` pruefen: Header (`Authorization`, `OCS-APIRequest`, `Overwrite: F`, `Destination`, `content-length`), URL-Aufbau mit Umlauten/`%`/Leerzeichen, 3xx -> Fehler, kein Redirect-Folgen |
|
||||
| Pflicht-Tests (API) | Controller-Reihenfolge; `module-manage-handlers.spec.ts` (Einstellungsrouten verlangen Verwalten, Dateiwege nicht); Passwort/App-Passwort nie in Antwort oder Log; 401 -> Code `credentialsOrTwoFactor`; 429 -> eigener Code, KEIN Wiederholen; Fehlversuch-Begrenzung; `poll.endpoint` wird ignoriert; Pfadvalidierung (`..`, leere Segmente, Steuerzeichen); PROPFIND-Parser mit Fixture (mehrere propstat, `%c3%84`, leere Elemente, Ordner/Datei); Upload-Streaming ohne Pufferung (Quelle `Readable` mit 20 MiB, Speicherzuwachs begrenzt) und Abbruch; Chunk > 8 MiB -> 413; `content-length` fehlt -> 411; RLS-Spec (`rls-coverage.spec.ts`, `rls-access-inventory.spec.ts`); Benutzer A sieht Konto von B nicht |
|
||||
| Pflicht-Tests (Web) | Leerzustand "Nicht verbunden", Formular-Fehlermeldungen, Umschalten auf Browser-Weg, Raster/Liste-Umschalter, Mehrfachauswahl, Chunking (`Blob.slice` 8 MiB, Wiederholung), de/en-Paritaet, `module-layouts.test.tsx` |
|
||||
| Echtprobe | Gegen den Test-Container oben: beide Anmeldewege, Upload 30 MB (Chunk) UND ueber die echte Tessera-Adresse mit Next-Proxy (nicht nur API direkt), Download mit Range, Umlaut-Datei, MOVE auf vorhandenen Namen (Fehlermeldung), Papierkorb, Abmelden -> 401 bei altem App-Passwort |
|
||||
|
||||
## Security Domain
|
||||
| ASVS | Gilt | Kontrolle |
|
||||
|---|---|---|
|
||||
| V2 Authentifizierung | ja | Tessera-Anmeldung unveraendert; Nextcloud-Zugang nur ueber `getapppassword`/Login Flow v2, echtes Passwort nur im Arbeitsspeicher, nicht geloggt; Fehlversuch-Begrenzung |
|
||||
| V3 Sitzung | ja | keine Nextcloud-Cookies halten, App-Passwort je Anfrage |
|
||||
| V4 Zugriffskontrolle | ja | `@UseModule`, `@ModuleManage` nur Einstellungen; `tenantId`/`userId` aus Token; RLS Mandant+Benutzer; jede Datei-Operation nur im Nextcloud-Konto des Aufrufers |
|
||||
| V5 Eingabevalidierung | ja | `class-validator`-DTOs; Pfadsegmente, `uploadId`, `fileId` per Regex; Groessen/Anzahl begrenzen |
|
||||
| V6 Kryptografie | ja | `CryptoService`, nichts Eigenes |
|
||||
| V9 Kommunikation | ja | https empfohlen, Zertifikate geprueft, nur Basis-URL, keine Weiterleitungen |
|
||||
| V12 Dateien | ja | Download immer `attachment`, `nosniff`, Content-Type nicht blind uebernehmen; Vorschau nur `image/*` mit Groessendeckel; SVG nicht inline |
|
||||
|
||||
| Bedrohung | STRIDE | Gegenmassnahme |
|
||||
|---|---|---|
|
||||
| SSRF ueber Nextcloud-Antworten oder Adresse | Tampering | feste Basis, relative Pfade, keine Redirects, Antwort-URLs ignorieren |
|
||||
| Aussperren aller Benutzer durch Brute-Force-Sperre | DoS | Whitelist + eigene Begrenzung + keine Wiederholung bei 429 |
|
||||
| App-Passwort-Abfluss | Info Disclosure | AES-GCM, nie in Antworten/Logs, Entschluesselungsfehler laut |
|
||||
| Pfad-Traversal / Fremdzugriff | Tampering/EoP | Segmentpruefung, Nextcloud prueft zusaetzlich mit dem Benutzerkonto |
|
||||
| Gespeichertes XSS ueber Dateiinhalt/-namen | Tampering | Inhalte nur als Download/`image/*`; Namen in React escaped; `Content-Disposition` selbst gebaut |
|
||||
| Speicherueberlauf bei Upload | DoS | Streaming, Chunk-Grenze 8 MiB, `content-length` Pflicht, Gesamtgroesse-Obergrenze |
|
||||
|
||||
## Assumptions Log
|
||||
| # | Annahme | Abschnitt | Risiko wenn falsch |
|
||||
|---|---|---|---|
|
||||
| A1 | `loginName` aus Login Flow / Eingabe kann von der Nextcloud-Benutzerkennung abweichen (E-Mail-Anmeldung) | 1 | gering: `cloud/user` wird ohnehin abgefragt |
|
||||
| A2 | Login-Flow-Adressen (`login`, `poll.endpoint`) nutzen die Host-Kopfzeile der Anfrage; hinter Proxy mit `overwritehost` anders | 2 | Browser kann `login`-URL nicht oeffnen -> Origin-Ersetzung durch konfigurierte Basis |
|
||||
| A3 | `%`/`_` im Suchtext des WebDAV-SEARCH muessen maskiert werden | 8 | falsche Treffer bei Sonderzeichen |
|
||||
| A4 | NPM-`client_max_body_size` erlaubt 8-MiB-Anfragen fuer die Tessera-Adresse | Streaming | Chunk-Upload scheitert mit 413 -> Betriebsanleitung/Test ueber echte Adresse |
|
||||
| A5 | Tauri-WebView speichert `<a download>`-Downloads ohne Zusatz-Plugin | Streaming | Desktop-Download schlaegt fehl -> eigener Pfad noetig (im Browser-Weg ok) |
|
||||
| A6 | Whitelist-Schluessel `bruteForce` / `whitelist_N` ueber `occ config:app:set` (gemessen wirksam in 34.0.4); aeltere Versionen koennen anders heissen | Pitfalls | Hinweistext im Einstellungs-Tab anpassen |
|
||||
| A7 | Verhalten fuer Nextcloud-Versionen <34 (z. B. 28-31) entspricht den Messungen; Chunk-Mindestgroesse 5 MB dort evtl. durchgesetzt | 6 | Chunk-Groesse bleibt 8 MiB, also unkritisch |
|
||||
| A8 | Nach Adresswechsel verlieren alle App-Passwoerter ihre Gueltigkeit (andere Nextcloud) | SSRF 1 | Konten werden als abgelaufen markiert, Benutzer verbindet neu |
|
||||
|
||||
## Open Questions
|
||||
1. **Welche Nextcloud-Version laeuft in der Firma, und ist die `bruteforcesettings`-App aktiv?** Wirkt auf Whitelist-Hinweis und 429-Verhalten; Einstellungs-Tab zeigt `versionString` aus `status.php`.
|
||||
2. **Hat die Firmen-Nextcloud ein oeffentliches Zertifikat?** Sonst `NODE_EXTRA_CA_CERTS` fuer den api-Container (Betriebsentscheidung, nicht Code).
|
||||
3. **Soll "Ordner herunterladen (ZIP)" in Etappe 1?** Kostet eine Route (`?accept=zip`, gemessen), Empfehlung: ja.
|
||||
4. **Konten-Aufraeumen beim Loeschen eines Tessera-Benutzers** (App-Passwort in Nextcloud widerrufen): nicht noetig fuer Etappe 1, aber dokumentieren.
|
||||
|
||||
## Environment Availability
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|---|---|---|---|---|
|
||||
| Docker | Test-Nextcloud | ✓ | 29.8.2 | — |
|
||||
| `nextcloud:stable` Abbild | End-zu-End-Test | ✓ (lokal gezogen) | 34.0.4 | — |
|
||||
| Node / undici / fast-xml-parser | Entwicklung | ✓ | 24.16.0 / 7.28.0 / 5.10.1 | — |
|
||||
| python3 | TOTP-Code im Test | ✓ | 3.x (`/bin/python3`) | `oathtool` ist NICHT installiert |
|
||||
| Playwright MCP | Browser-Pruefung Login Flow | ✓ laut Memory | — | manuell durch User |
|
||||
| Freier Plattenplatz | Abbild + Container | ✓ | 71 GB frei | — |
|
||||
|
||||
Fehlende Abhaengigkeiten ohne Ersatz: keine.
|
||||
|
||||
## Sources
|
||||
### Primary (HIGH)
|
||||
- Messungen gegen `nextcloud:stable` 34.0.4 am 2026-10-08 (curl, Node/undici-Skript, occ): getapppassword (200/401/403/429), 2FA-Erzwingung, Login Flow init/poll, apppassword DELETE, PROPFIND/MKCOL/MOVE/DELETE/PROPPATCH, Chunk-Upload v2, Preview, Shares/Sharees/SEARCH, Host-Kopfzeile, Brute-Force-Zaehler, TOTP-Einrichtung per PHP
|
||||
- Nextcloud Entwicklerhandbuch: `docs.nextcloud.com/server/latest/developer_manual/client_apis/` LoginFlow/index.html, WebDAV/basic.html, WebDAV/chunking.html, WebDAV/search.html, OCS/ocs-share-api.html, OCS/ocs-api-overview.html
|
||||
- Nextcloud Quelltext (raw.githubusercontent.com/nextcloud/server/master): `core/Controller/AppPasswordController.php`, `core/Controller/ClientFlowLoginV2Controller.php`, `core/Controller/PreviewController.php`, `lib/private/User/Session.php`, `lib/private/Authentication/TwoFactorAuth/Manager.php`, `core/Command/TwoFactorAuth/Enforce.php`; `nextcloud/twofactor_totp` `lib/Controller/SettingsController.php`, `lib/Service/ITotp.php`
|
||||
- Admin-Handbuch Brute-Force: docs.nextcloud.com/server/latest/admin_manual/configuration_server/bruteforce_configuration.html
|
||||
- Docker-Abbild: github.com/nextcloud/docker README (Umgebungsvariablen `SQLITE_DATABASE`, `NEXTCLOUD_ADMIN_USER/PASSWORD`, `NEXTCLOUD_TRUSTED_DOMAINS`, `docker exec --user www-data ... php occ`)
|
||||
- Codebase gelesen: `apps/api/src/main.ts`, `common/public-url-guard.ts`, `nextcloud-status/{nextcloud-status-fetch.ts,nextcloud-status.module.ts,nextcloud-status.seed.ts,nextcloud-status.controller.ts,nextcloud-logo-fetch.ts}`, `crypto/crypto.service.ts`, `domains/domains.module.ts`, `prisma/schema.prisma` (DomainsConfig, Reminder), Migration `20260929140000_reminder`, `prisma/rls-access-inventory.spec.ts`, `auth/types/auth-user.ts`; `apps/web/next.config.ts`, `src/middleware.ts`, `src/lib/{module-loader,module-identity,use-module-capability}.ts`, `stores/nav-store.ts`, `modules/nextcloud-status/layout.tsx`, `modules/domains/*`; Next 15.5.19 `dist/server/{lib/router-server.js,lib/router-utils/proxy-request.js,body-streams.js,config-shared.js}`
|
||||
- Vorgaengerrecherche `.planning/quick/261008-dts-*/261008-dts-RESEARCH.md` (Integrationsliste, gleiche Struktur)
|
||||
### Secondary (MEDIUM)
|
||||
- Nextcloud-Forum-Treffer zu `PasswordLoginForbiddenException`/401 bei 2FA (bestaetigt durch eigene Messung)
|
||||
### Tertiary (LOW)
|
||||
- keine
|
||||
|
||||
## Metadata
|
||||
**Confidence:** Protokoll HIGH (gemessen), Streaming/Proxy HIGH (Quelltext + Messung), Codebase-Integration HIGH (Dateien gelesen, Muster aus gleichzeitiger Domains-Arbeit), Browser-Ende-zu-Ende des Login Flow MEDIUM (nicht durchgespielt), NPM-Grenzen LOW
|
||||
**Research date:** 2026-10-08; gueltig ca. 30 Tage (Nextcloud-Hauptversionen aendern Verhalten selten, Next.js-Proxy-Verhalten vor Upgrades neu pruefen)
|
||||
+370
@@ -0,0 +1,370 @@
|
||||
---
|
||||
phase: 261008-mzu-modul-nextcloud-dateien-eigenstaendiger-
|
||||
reviewed: 2026-10-08T20:18:02Z
|
||||
depth: standard
|
||||
files_reviewed: 54
|
||||
files_reviewed_list:
|
||||
- apps/api/prisma/migrations/20261008180000_nextcloud_files/migration.sql
|
||||
- apps/api/prisma/migrations/20261008183000_nextcloud_files_login_name/migration.sql
|
||||
- apps/api/src/nextcloud-files/dto/nextcloud-files-connect.dto.ts
|
||||
- apps/api/src/nextcloud-files/dto/nextcloud-files-ops.dto.ts
|
||||
- apps/api/src/nextcloud-files/dto/nextcloud-files-settings.dto.ts
|
||||
- apps/api/src/nextcloud-files/dto/nextcloud-files-transfer.dto.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-auth-client.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-call-gate.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-dav-transfer.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-dav.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-files-account.service.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-files.controller.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-files.module.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-files.seed.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-files.service.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-files-settings.service.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-files-transfer.service.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-files.types.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-http.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-login-guard.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-propfind.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-server-info.ts
|
||||
- apps/api/src/nextcloud-files/nextcloud-upstream.ts
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/layout.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/page.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/AccountBar.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/Breadcrumb.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/ConnectPanel.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/DeleteDialog.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/Dialog.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/DropOverlay.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/EntryMenu.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/FileGrid.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/FileList.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/icons.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/MoveDialog.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/NameDialog.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/QuotaMeter.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/SelectionBar.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/ServerIdentity.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/SettingsTab.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/Toolbar.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/TransferBar.tsx
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/components/TypeTile.tsx
|
||||
- apps/web/src/components/nextcloud-files/drop-entries.ts
|
||||
- apps/web/src/components/nextcloud-files/error-text.ts
|
||||
- apps/web/src/components/nextcloud-files/file-format.ts
|
||||
- apps/web/src/components/nextcloud-files/file-types.ts
|
||||
- apps/web/src/components/nextcloud-files/paths.ts
|
||||
- apps/web/src/components/nextcloud-files/selection.ts
|
||||
- apps/web/src/components/nextcloud-files/use-transfers.ts
|
||||
- apps/web/src/lib/nextcloud-files-api.ts
|
||||
- apps/web/src/lib/nextcloud-files-upload.ts
|
||||
findings:
|
||||
critical: 2
|
||||
warning: 9
|
||||
info: 7
|
||||
total: 18
|
||||
status: issues_found
|
||||
fix_status: all_fixed
|
||||
fixed_at: 2026-10-08
|
||||
---
|
||||
|
||||
# Quick 261008-mzu: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-10-08T20:18:02Z
|
||||
**Depth:** standard (focus areas 1 to 3 traced in full; presentational web components only skimmed)
|
||||
**Files Reviewed:** 54
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
The transport containment is solid. Every Nextcloud call goes through `ncRequest`, which builds the URL from the stored base, a fixed list of path prefixes and segments that are validated and encoded one by one. It never follows a redirect and never calls `poll.endpoint` or `login` from a Login Flow answer. Cookies are never sent. `Destination` is always built from the session base. `..`, `/`, `\`, NUL and control characters are rejected per segment, and an encoded slash cannot get through because each segment goes through `encodeURIComponent`.
|
||||
|
||||
Secrets are handled correctly:
|
||||
- The real password lives in exactly one call.
|
||||
- The app password is stored with AES-GCM encryption and is decrypted only in `getSession`.
|
||||
- No view, log line or error body carries a secret.
|
||||
- RLS on `NextcloudFilesAccount` includes the user dimension, and every account query is bound to the tenant and user from the token.
|
||||
- 401 and 403 from Nextcloud are always mapped to 409 or 422.
|
||||
- Streamed downloads use a header allowlist and add their own `Content-Disposition: attachment`, `nosniff` and a `sandbox` CSP.
|
||||
- The logo is identified from its first bytes and served with a sandbox CSP.
|
||||
- File names and theming values are rendered as React text, and the theming color is limited to `#rrggbb`.
|
||||
- Routes without a parameter are declared before the routes that take one.
|
||||
|
||||
The problems are in concurrency and authorization scope:
|
||||
1. **Brute-force limits can be bypassed with parallel requests (CR-01).** The password-attempt limits are checked before the Nextcloud call and recorded after it. Parallel requests therefore get past both the per-user and the server-wide limit, which defeats the control that is supposed to keep Nextcloud from locking out the shared Tessera IP.
|
||||
2. **Non-admin managers control where passwords are sent (CR-02).** Anyone with the module grant "Verwalten" can point the module at any host. Every user must then reconnect, and they type their real password into a screen showing a name and logo that the attacker controls.
|
||||
3. **Smaller gaps:** Several issues weaken app-password hygiene (no revoke during a pause), conditional-replace semantics (TOCTOU on chunked replace, wrong 412 mapping) and the upload and ZIP paths.
|
||||
|
||||
## Fix Summary (2026-10-08, gsd-code-fixer)
|
||||
|
||||
All 18 findings are fixed; IN-02 was handled as the trivial variant (no SVG previews). Twelve commits on `main`, not pushed:
|
||||
|
||||
| Commit | Findings |
|
||||
|---|---|
|
||||
| `5c2d327` | CR-01 |
|
||||
| `e78e059` | CR-02 |
|
||||
| `74e086f` | WR-01 |
|
||||
| `c0b8283` | IN-03 |
|
||||
| `ddae940` | WR-02, IN-04 |
|
||||
| `07474bd` | WR-03 |
|
||||
| `ee05131` | WR-04, WR-05, WR-06, IN-01 |
|
||||
| `08abfef` | WR-09, IN-02 |
|
||||
| `486819f` | WR-07 |
|
||||
| `8679668` | WR-08, IN-06 |
|
||||
| `f0f3718` | IN-05 |
|
||||
| `49eebde` | IN-07 |
|
||||
|
||||
Verification ran in the main checkout (`workflow.use_worktrees=false`):
|
||||
- Tests: `pnpm --filter @tessera/api test` passed 2961/2961; `pnpm --filter @tessera/web test` passed 1648/1648.
|
||||
- Type checks: `tsc --noEmit` is clean in api and in web.
|
||||
- Lint: biome on all touched files shows only 4 pre-existing `noNonNullAssertion` warnings in old spec lines.
|
||||
- Stack: `docker compose up -d --build api web` succeeded; the api had freshly restarted before the e2e runs.
|
||||
- End-to-end: all four scripts pass against tessera-nc-test (after `nc-test-setup.sh`): `e2e-settings.sh`, `e2e-connect.sh`, `e2e-files.sh`, `e2e-transfer.sh`.
|
||||
- Browser: a Chromium (Playwright) check of the real UI confirmed the selection ZIP via form POST into a hidden iframe through `/api-proxy`, and the download precheck message.
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: Password-attempt limits are check-then-act across an await and can be bypassed with parallel requests
|
||||
|
||||
**Fix status:** fixed in `5c2d327`. `beginPasswordAttempt` reserves the attempt synchronously in both the per-user and the server-wide window before the Nextcloud call. `release` frees the slot on success or on a non-credential failure. `fail` keeps it for a 401 or a timeout. Connect and store run serialized per tenant and user (`withUserLock`, also around the Login Flow store), so the second of two parallel successful connects revokes the first app password as the "previous" one. Specs use concurrent `Promise.all`: 5 parallel wrong passwords → 3 reach Nextcloud and 2 get 429; 10 users → 8 reach Nextcloud; two successful connects → one row and exactly the unstored password revoked.
|
||||
|
||||
**File:** `apps/api/src/nextcloud-files/nextcloud-files-account.service.ts:160-169`, `apps/api/src/nextcloud-files/nextcloud-login-guard.ts:179-204`
|
||||
|
||||
**Issue:** `connectWithPassword` calls `guard.checkPasswordAttempt(userId, scope)` synchronously, then `await getAppPassword(...)`, and only after the Nextcloud answer calls `guard.recordFailure`. While a request is still waiting for Nextcloud, nothing counts it. Any authenticated module user can send N parallel `POST connect/password` with a wrong password, and all N pass the check:
|
||||
- The per-user limit (3 in 15 minutes) does not hold.
|
||||
- The server-wide limit (8 in 30 minutes) does not hold.
|
||||
- All N attempts reach Nextcloud as failed logins from the single Tessera IP.
|
||||
|
||||
That is exactly the lockout for all users that the guard exists to prevent (L-04). The same race lets two concurrent successful connects both run `revokePrevious` on the same old row. Each then writes its own row, so one freshly issued app password is never stored and never revoked.
|
||||
|
||||
**Fix:** Reserve the slot before the await and release it on success. In-flight attempts then count against both limits.
|
||||
```ts
|
||||
// login-guard
|
||||
beginPasswordAttempt(userId: string, scope = ''): () => void {
|
||||
this.checkPasswordAttempt(userId, scope); // throws 429 if full
|
||||
const now = this.now();
|
||||
const mine = this.userFailures.get(userId) ?? []; mine.push(now); this.userFailures.set(userId, mine);
|
||||
const server = this.serverFailures.get(scope) ?? []; server.push(now); this.serverFailures.set(scope, server);
|
||||
return () => { // call on success or on a non-credential failure
|
||||
remove(mine, now); remove(server, now);
|
||||
};
|
||||
}
|
||||
// account service
|
||||
const release = this.guard.beginPasswordAttempt(userId, scope);
|
||||
const issued = await getAppPassword(...);
|
||||
if (!issued.ok && issued.kind === 'credentials') { /* keep the reservation */ throw ... }
|
||||
release();
|
||||
```
|
||||
Additionally, serialize `connectWithPassword` and the store part of `pollFlow` per user (an in-process `Map<userId, Promise>`), so that `revokePrevious` and `upsert` cannot interleave.
|
||||
|
||||
### CR-02: A non-admin with the "Verwalten" grant can redirect every user's Nextcloud password to a host they control
|
||||
|
||||
**Fix status:** fixed in `e78e059`. `PUT settings` now carries `@Roles(ADMIN, SUPER_ADMIN)` without `@ModuleManage`, the same pattern as `TendersController.getSourceConfig`. `POST settings/test` stays at Verwalten, but non-admins may only test the *saved* address; any other address returns 403. The web SettingsTab is read-only for non-admins: no Save button and a short note. The "not configured" hint points non-admins to an administrator. Updated: metadata spec (`module-manage-handlers`), controller spec, page tests, `e2e-settings.sh` (MANAGE user gets 403 on PUT and on testing a different address, 200 on testing the saved one), Anleitung Administration/Anwender and CHANGELOG. No audit event was added; that part was optional.
|
||||
|
||||
**File:** `apps/api/src/nextcloud-files/nextcloud-files.controller.ts:132-144`, `apps/api/src/nextcloud-files/nextcloud-files-settings.service.ts:115-147`
|
||||
|
||||
**Issue:** `PUT settings` is protected only by `@ModuleManage('nextcloud-files')`. The guard grants that to administrators and to any user with the module grant level MANAGE (`module.guard.ts:111`). Saving a new `baseUrl`:
|
||||
1. Accepts any http or https host (internal addresses are allowed by design).
|
||||
2. Marks every account EXPIRED, so all users are pushed back to the connect screen with the "connection expired" notice.
|
||||
3. Shows the new server's `productname`, theming color and logo there (`nextcloud-server-info.ts`), which the attacker controls.
|
||||
|
||||
The next `POST connect/password` sends `Basic base64(loginName:realPassword)` to that host (`nextcloud-auth-client.ts:210-214`). In this deployment, Nextcloud passwords are typically the AD/LDAP passwords. So a delegated module manager (not a Tessera admin) can collect the directory credentials of every colleague who reconnects. The review brief assumes the base URL is "admin-set", but the code does not enforce that.
|
||||
|
||||
**Fix:** Restrict changing the address to Tessera administrators. Keep `GET settings` and `POST settings/test` at MANAGE if wanted, but put a role check on `PUT settings`. The class comment explains why `@Roles` must not be added (the global RolesGuard would lock out managers), so do the check inside the handler:
|
||||
```ts
|
||||
@Put('settings')
|
||||
@ModuleManage('nextcloud-files')
|
||||
async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: SaveNextcloudFilesSettingsDto) {
|
||||
if (req.user?.role !== 'ADMIN') throw ncErrorDefault('notAllowed'); // 422, not 403
|
||||
return this.settings.saveSettings(this.requireTenantId(req), dto);
|
||||
}
|
||||
```
|
||||
Also consider recording an audit event with the old and new host when the address changes.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: The address test follows up to 3 redirects to arbitrary hosts and returns HTTP status details, which works as an internal SSRF probe
|
||||
|
||||
**Fix status:** fixed in `74e086f`. `testAddress` now calls `status.php` through `ncRequest`: no redirects, call gate applies, no credentials. Results are coarse only: `ok`, `maintenance`, `paused`, `locked`, `redirect`, `not-nextcloud`, `unreachable`. They carry no HTTP status, error code or redirect target. `NEXTCLOUD_STATUS_FETCHER` was removed. Together with CR-02, only admins can probe new addresses.
|
||||
|
||||
**File:** `apps/api/src/nextcloud-files/nextcloud-files-settings.service.ts:157-187, 245-248`; `apps/api/src/nextcloud-status/nextcloud-status-fetch.ts:221-247`
|
||||
|
||||
**Issue:** `testAddress` (called by `POST settings/test` and after every `saveSettings`) reuses `fetchNextcloudStatus`. That function follows up to `MAX_REDIRECTS = 3` `Location` headers to any http or https host. It does not go through `ncRequest` or the call gate. This contradicts the module rule "never follow redirects, never call URLs from a Nextcloud response". The response also separates `timeout`, `network`, `tls`, `not-nextcloud` and ``http-status (HTTP xxx)``, so a MANAGE user (see CR-02) can scan internal hosts and ports, including through redirects from an external host they control.
|
||||
|
||||
**Fix:** For the module's own test, call `/status.php` through `ncRequest` (`prefix: '/status.php'`; any 3xx gives `redirect`). If the "address redirects" hint is wanted, report `kind: 'redirect'` together with the `Location` origin as text, without fetching it. Return a coarse result (`ok`, `notNextcloud`, `unreachable`) instead of the raw HTTP status.
|
||||
|
||||
### WR-02: A freshly issued or old app password is not revoked when the origin is paused or the network fails
|
||||
|
||||
**Fix status:** fixed in `ddae940`. Revokes that fail temporarily (paused/429, network, timeout, maintenance, 5xx) go into an in-memory queue capped at 200 entries and 6 attempts. They are retried 1 s after the pause ends, or after 60 s. Secrets are never logged and are only sent to their own base URL. Fresh, previous and disconnect revokes all go through this queue. Specs cover a 429 on `cloud/user`, a network error on the old password, giving up after 6 attempts, and no retry on 401.
|
||||
|
||||
**File:** `apps/api/src/nextcloud-files/nextcloud-files-account.service.ts:178-181, 254-256, 386-388, 393-414, 416-445`; `apps/api/src/nextcloud-files/nextcloud-http.ts:317-320`
|
||||
|
||||
**Issue:** `revokeFresh` and `revokeBestEffort` call `ncRequest`, and `ncRequest` returns `paused` before the transport whenever the origin is on the call gate. The most likely reason for `getCurrentUser` to fail right after `getAppPassword` succeeded is a 429, which pauses the origin. The revoke is then never sent. The result is an app password that is valid in Nextcloud and stored nowhere, which contradicts D-P ("wird sofort widerrufen"). The same applies to `revokePrevious` during a pause or a network error: the old password is overwritten in the database but stays valid in Nextcloud. Failures are only logged as a warning.
|
||||
|
||||
**Fix:** Let revoke calls bypass the origin pause (a single DELETE with a known-good credential does not feed brute-force detection). Alternatively, put failed revokes into a small in-memory retry queue that is drained after the pause ends. At minimum, do not overwrite the old row while its revoke failed; keep the encrypted old value in a `pendingRevoke` column.
|
||||
|
||||
### WR-03: The dead-credential short-circuit does not stop the first wave of parallel 401s
|
||||
|
||||
**Fix status:** fixed in `07474bd`. The gate holds at most 4 concurrent calls per credential key until the response headers arrive (`acquireSlot`). Waiting calls re-check dead/paused before sending, and an abort while waiting returns `aborted`. Chunk PUT, single PUT and assembly are `unthrottled`, because their headers only arrive after the full body or after minutes. Spec: 20 parallel previews with a revoked key → at most 4 transport calls. Web: previews were already `loading="lazy"`. A failed preview now triggers a quiet re-list at most every 10 s, and on `connectionExpired` the page returns to the connect screen.
|
||||
|
||||
**File:** `apps/api/src/nextcloud-files/nextcloud-http.ts:314-316, 394-398`; `apps/web/src/app/(portal)/modules/nextcloud-files/components/TypeTile.tsx:66-70`; `apps/web/src/app/(portal)/modules/nextcloud-files/components/FileGrid.tsx:24-27`
|
||||
|
||||
**Issue:** `markDead` runs only after the first 401 has come back. The list and grid views render one `<img src=preview>` per image row, and many of them load in parallel when the user scrolls or opens a folder (HTTP/2 through the proxy imposes no per-host limit of 6). If the app password is revoked mid-session (for example, deleted in the Nextcloud device list), every preview request in flight reaches Nextcloud before the first 401 returns. Each one counts as a failed login for the shared Tessera IP; the code comment itself says Nextcloud counts every 401 this way. One user scrolling a photo folder can therefore push the server towards the lockout of 10 failures in 30 minutes.
|
||||
|
||||
**Fix:** Allow only one outstanding Nextcloud request per credential key until a key has been proven alive once in the process (record the first 2xx per key). Alternatively, use a per-key semaphore (for example 4) for `preview` only, or let `preview` wait for a single in-flight "probe" per key.
|
||||
|
||||
### WR-04: The chunked "replace" is check-then-act: the ETag probe and the `MOVE` with `Overwrite: T` are not atomic
|
||||
|
||||
**Fix status:** fixed in `ee05131`. Measured against Nextcloud 34 (tessera-nc-test): `If-Match` on the chunked-v2 assembly `MOVE` is evaluated against the source `.file`, so every If-Match, including the correct ETag, returns 412. It cannot be used. Fallback: the ETag re-check runs inside the assembly job immediately before the `MOVE` and fails with `changedMeanwhile`. A residual window of milliseconds between the PROPFIND and the MOVE remains; this is documented in code.
|
||||
|
||||
**File:** `apps/api/src/nextcloud-files/nextcloud-files-transfer.service.ts:393-406, 413-417`; `apps/api/src/nextcloud-files/nextcloud-dav-transfer.ts:411-416`
|
||||
|
||||
**Issue:** For a replace, `completeUpload` checks the ETag with `PROPFIND` and then sends `MOVE .file` with `Overwrite: T` and without `If-Match`. If another client changes the target between the probe and the move (and assembly of large files can take minutes), that version is silently overwritten. This is exactly what "replace only the version the user saw" is supposed to prevent. The single-PUT path does it correctly with `If-Match`.
|
||||
|
||||
**Fix:** Send `If-Match: <replaceEtag>` on the assembly `MOVE` as well (Nextcloud evaluates the conditional headers against the destination on chunked-v2 assembly) and map a 412 to `changedMeanwhile`. Keep the probe only as an early check.
|
||||
|
||||
### WR-05: A concurrent or retried `complete` with `replaceEtag` runs the assembly twice and can report a successful upload as failed
|
||||
|
||||
**Fix status:** fixed in `ee05131`. The assembly record is set synchronously before the first await after the dedup check. A second or concurrent `complete` replays the stored result: `assembling`, `done`, or the same error with the same status. It never re-assembles. Only failures before the MOVE was sent (precheck error, paused) are retryable. Specs cover parallel completes, replay after a 507, and a retry after a precheck network error.
|
||||
|
||||
**File:** `apps/api/src/nextcloud-files/nextcloud-files-transfer.service.ts:385-409`
|
||||
|
||||
**Issue:** The deduplication check (`known?.state === 'assembling'`) runs before `await dav.stat(...)`, but the `assembling` record is created only after it. Two `complete` calls for the same `uploadId` with `replaceEtag` (a client retry after a network error, or a second tab) both get past the check and both send the `MOVE`. The second `MOVE` fails (404, the upload folder has already been consumed). Its fresh record replaces the first one in the map. `uploadState` then reports `failed` although the file was written, and the web shows an error.
|
||||
|
||||
**Fix:** Write the `assembling` record before the first `await` that follows the dedup check, and remove it if the replace probe rejects:
|
||||
```ts
|
||||
const record: AssemblyRecord = { state: 'assembling' };
|
||||
this.assemblies.set(key, record);
|
||||
try { if (dto.replaceEtag) { /* probe */ } } catch (e) { this.assemblies.delete(key); throw e; }
|
||||
```
|
||||
|
||||
### WR-06: A 412 on a single PUT with `If-Match` is reported as `nameTaken`, so "Replace" can loop
|
||||
|
||||
**Fix status:** fixed in `ee05131`. A 412 with `replaceEtag` maps to `changedMeanwhile` (with `existing`) on the single PUT and on the assembly MOVE.
|
||||
|
||||
**File:** `apps/api/src/nextcloud-files/nextcloud-files-transfer.service.ts:304-307`; `apps/api/src/nextcloud-files/nextcloud-upstream.ts:494-495`
|
||||
|
||||
**Issue:** `codeForStatus(412)` always returns `nameTaken`. With `replaceEtag` set, a 412 means "the version changed meanwhile", not "the name is taken". The web shows the conflict prompt again. If the user chooses "Replace" again, the process repeats with the newer ETag. That ETag may differ from what the user meant to replace, which silently weakens the safeguard.
|
||||
|
||||
**Fix:** In `putSingle`, if `query.replaceEtag` is set and the status is 412, throw `changedMeanwhile` with `existing`. The web already clears `replaceEtag` on `changedMeanwhile` retry (`use-transfers.ts:271`).
|
||||
|
||||
### WR-07: The folder-creation cache in the transfer queue never expires and skips MKCOL for folders that were deleted later
|
||||
|
||||
**Fix status:** fixed in `486819f`. The created-folders cache is cleared on a new drop while nothing is active, by `forgetFolders()` after delete/move/rename in FileBrowser, and when an upload fails with `pathConflict`/`notFound` ("Erneut versuchen" then re-creates the folder). Within one batch each folder is still created only once. A new hook test covers this (`use-transfers.test.ts`).
|
||||
|
||||
**File:** `apps/web/src/components/nextcloud-files/use-transfers.ts:105, 116-143`
|
||||
|
||||
**Issue:** `folders.current` keeps the resolved promise of every created path for the whole lifetime of the component. Consider this sequence:
|
||||
1. The user drops folder `A`.
|
||||
2. The user deletes or renames `A` in the browser.
|
||||
3. The user drops a folder named `A` again.
|
||||
|
||||
`ensureFolders` then finds the cached promise and does not send `MKCOL`. Every file upload into `/A/...` fails with `pathConflict` (409).
|
||||
|
||||
**Fix:** Cache only in-flight promises: delete the entry in a `finally` once it settles. Alternatively, clear `folders.current` in `enqueue` whenever no transfer is active, and after any delete or move action.
|
||||
|
||||
### WR-08: Multi-selection ZIP puts every name into the query string and breaks on realistic selections
|
||||
|
||||
**Fix status:** fixed in `8679668`. `POST download/zip` takes the names in the body: JSON, or the web's hidden form with `names` as JSON text. At most 1000 names. The web submits a form into a hidden iframe, so the stream goes straight to disk and cookie auth and Content-Disposition are kept. Verified in Chromium through `/api-proxy`: the ZIP is downloaded and the page stays. If everything in the folder is selected, the web downloads the whole-folder ZIP. Additional finding: Nextcloud accepts the selection only in its own URL (`files=`), and Apache in front of it rejects request lines over 8190 chars with 414 (measured: about 6150 chars pass, 8200 fail). The API therefore returns 413 `selectionTooLarge` when the upstream URL would exceed 8000 chars, also on the precheck. The web warns before submitting (encoded budget 7000), with a clear message. In practice this caps a selection at roughly 100–200 names, not 1000. `e2e-transfer.sh` was moved to POST (JSON, form via `/api-proxy`, 300 long names → 413, old GET → 404).
|
||||
|
||||
**File:** `apps/web/src/lib/nextcloud-files-api.ts:276-279`; `apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx:438-451`; `apps/api/src/nextcloud-files/dto/nextcloud-files-transfer.dto.ts:203-209`
|
||||
|
||||
**Issue:** `zipUrl` encodes each selected name as `name=...`. The API allows up to 500 names of 255 characters each, and the UI allows selecting up to 5000 entries. Typical selections fail in the following ways:
|
||||
- A few hundred files with ordinary names already exceed Node's 16 KiB request-header limit (431) or the URI buffer of Nginx Proxy Manager (414).
|
||||
- More than 500 names produce a 400 from the ValidationPipe.
|
||||
|
||||
Because the download is started from a hidden `<a>` (`triggerDownload`), the user gets a failed download or a JSON error file and no message.
|
||||
|
||||
**Fix:** Cap the selection client-side (for example 200 names or 8 KiB of query string) with a clear message. Better, add a `POST download/zip-token` that stores the selection server-side for 60 s and returns a short id for `GET download/zip?t=<id>`. If everything in a folder is selected, fall back to the whole-folder ZIP.
|
||||
|
||||
### WR-09: A lone UTF-16 surrogate in a path makes `encodeURIComponent` throw and returns an unmapped 500
|
||||
|
||||
**Fix status:** fixed in `08abfef`. `validateSegment` rejects lone UTF-16 surrogates, so the result is 400 `invalidPath`/`invalidName` instead of a URIError 500. Valid surrogate pairs (emoji) still work. The web never retried 4xx; a test now pins `isRetryable(400)` to false.
|
||||
|
||||
**File:** `apps/api/src/nextcloud-files/nextcloud-http.ts:111-129, 167-182`
|
||||
|
||||
**Issue:** JSON bodies (`POST folders`, `POST move`, `POST uploads`, `POST uploads/:id/complete`) can carry `"\uD800"`. `validateSegment` accepts it, and `encodeSegments` then throws `URIError: URI malformed` inside `buildNcUrl`. Nothing catches this, so Nest answers with a generic 500 instead of the `{ code: 'invalidPath' }` contract. A 500 without `code` also counts as retryable in `nextcloud-files-upload.ts:90-93`, so the client retries three times for nothing.
|
||||
|
||||
**Fix:** Reject lone surrogates in `validateSegment`:
|
||||
```ts
|
||||
if (!segment.isWellFormed()) throw ncErrorDefault('invalidPath'); // in validateSegment
|
||||
```
|
||||
(Node 24 has `String.prototype.isWellFormed`.)
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: `replaceEtag` is not validated before it is used as an `If-Match` header
|
||||
|
||||
**Fix status:** fixed in `ee05131`. `@Matches(ETAG_PATTERN)` was added on `replaceEtag` in StartUploadDto, CompleteUploadDto and UploadQueryDto. A value with CR/LF, spaces, non-ASCII or over 128 chars → 400.
|
||||
|
||||
**File:** `apps/api/src/nextcloud-files/dto/nextcloud-files-transfer.dto.ts:128-132, 177-180`; `apps/api/src/nextcloud-files/nextcloud-dav-transfer.ts:324`
|
||||
|
||||
**Issue:** A value containing CR, LF or other invalid header characters makes undici throw. `classifyTransportError` then reports it as `network`, which surfaces as 504 `nextcloudUnavailable` (misleading).
|
||||
|
||||
**Fix:** Add `@Matches(/^(W\/)?"?[\x21\x23-\x7e]{1,128}"?$/)` to the DTO.
|
||||
|
||||
### IN-02: Previews are served inline and accept `image/svg+xml`
|
||||
|
||||
**Fix status:** fixed (trivial) in `08abfef`. Previews accept raster `image/*` only; `image/svg+xml` → `notFound`, so the web falls back to the type tile. CSP sandbox and nosniff stay. `Cross-Origin-Resource-Policy: same-origin` was deliberately not added: in the dev setup the web (:3000) loads previews from the API (:3001), which is cross-origin, and that header would break previews there.
|
||||
|
||||
**File:** `apps/api/src/nextcloud-files/nextcloud-files.service.ts:393-402`
|
||||
|
||||
**Issue:** The `default-src 'none'; sandbox` CSP and `nosniff` neutralize scripts, so this is defense-in-depth only.
|
||||
|
||||
**Fix:** Reject `image/svg+xml` in `accept`, or add `Content-Disposition: inline; filename="preview"` together with `Cross-Origin-Resource-Policy: same-origin`.
|
||||
|
||||
### IN-03: http base URLs are accepted silently
|
||||
|
||||
**Fix status:** fixed in `c0b8283`. The settings show a visible warning box whenever the address starts with `http://`: passwords and app passwords travel unencrypted. Anleitung Administration mentions it. The address is still accepted, by design.
|
||||
|
||||
**File:** `apps/api/src/nextcloud-files/nextcloud-files-settings.service.ts:119`
|
||||
|
||||
**Issue:** With an `http://` address, both the real password (`getapppassword`) and every app password travel as cleartext Basic auth.
|
||||
|
||||
**Fix:** Show a warning in the settings check when the scheme is `http:` and the host is not loopback.
|
||||
|
||||
### IN-04: Cancelling a browser login does not stop Nextcloud from issuing the token
|
||||
|
||||
**Fix status:** fixed in `ddae940`. Cancelled, replaced and address-change flows stay as `cancelled` tombstones until they expire. They do not count against the 200 open flows and are capped at 500. The server polls them every 10 s (`sweepCancelledFlows`, unref'd timer). If the login is granted after all, the issued app password is revoked immediately and never stored. A poll that sees an address change now cancels instead of removing.
|
||||
|
||||
**File:** `apps/api/src/nextcloud-files/nextcloud-files-account.service.ts:274-279`
|
||||
|
||||
**Issue:** If the user finishes the browser login after pressing "Cancel", Nextcloud still creates an app password, which is never polled and never revoked.
|
||||
|
||||
**Fix:** Keep a cancelled entry as a tombstone and keep polling it until it expires. If it is granted, revoke the password.
|
||||
|
||||
### IN-05: Browser login can report "expired" although the account was connected
|
||||
|
||||
**Fix status:** fixed in `f0f3718`. On a 404/410 from the poll, ConnectPanel first reloads the status through `onConnected` and only then shows `flowExpired`. If the account is in fact connected, the files view appears instead.
|
||||
|
||||
**File:** `apps/api/src/nextcloud-files/nextcloud-files-account.service.ts:237-239`; `apps/web/src/app/(portal)/modules/nextcloud-files/components/ConnectPanel.tsx:183-186`
|
||||
|
||||
**Issue:** The flow entry is removed before `getCurrentUser` and the store step. If the response that carries `connected` is lost (proxy timeout during slow Nextcloud calls), the next poll gets 404. The UI then shows `flowExpired` while the account is in fact connected.
|
||||
|
||||
**Fix:** On 404 or 410, call `onConnected()` (which reloads the status) before showing the error.
|
||||
|
||||
### IN-06: Download errors are invisible
|
||||
|
||||
**Fix status:** fixed in `8679668`. Every download (file, folder ZIP, selection ZIP, and clicking a file name) first runs a cheap precheck: `check=1` / `check: true`, one PROPFIND Depth 0. Errors appear in the status line ("Herunterladen nicht möglich: …"); `connectionExpired` returns to the connect screen. Verified in Chromium against the stack: a deleted file shows "Herunterladen nicht möglich: Der Eintrag existiert nicht mehr."
|
||||
|
||||
**File:** `apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx:74-83`
|
||||
|
||||
**Issue:** Downloads use a hidden anchor. `connectionExpired`, `nextcloudLocked` and `notFound` end in a failed browser download with no message, and an expired connection does not bring the connect screen back.
|
||||
|
||||
**Fix:** Optionally run a cheap `HEAD` or `stat` before downloading, or accept this as a known limitation.
|
||||
|
||||
### IN-07: The window-level drop handler ignores open dialogs, menus and `defaultPrevented`
|
||||
|
||||
**Fix status:** fixed in `49eebde`. While a dialog or menu is open, or the target is inside `[role=dialog|alertdialog|menu]`, the window-level handler shows no overlay and uploads nothing. It still calls `preventDefault`, so the browser does not open the file itself. Drops that were already `defaultPrevented` are ignored.
|
||||
|
||||
**File:** `apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx:401-414`
|
||||
|
||||
**Issue:** Dropping a file onto the rename input or onto an open dialog backdrop starts an upload into the current folder.
|
||||
|
||||
**Fix:** Return early when `dialog !== null`, when `e.defaultPrevented` is set, or when the target is inside `[role="dialog"]`.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-10-08T20:18:02Z_
|
||||
_Reviewer: Claude (gsd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
---
|
||||
phase: quick-261008-mzu
|
||||
plan: 01
|
||||
subsystem: nextcloud-files
|
||||
tags: [nextcloud, webdav, login-flow-v2, chunked-upload, file-browser, nestjs, nextjs, rls]
|
||||
requires:
|
||||
- module-registry (UseModule, ModuleManage, Freigabestufen)
|
||||
- nextcloud-status (normalizeCloudUrl, parseNextcloudStatus)
|
||||
- CryptoService (AES-256-GCM)
|
||||
provides:
|
||||
- Modul "Dateien" (slug nextcloud-files, Gruppe Infrastruktur): eine Nextcloud je Organisation, ein Konto je Benutzer
|
||||
- Anmeldung per Passwort (App-Passwort) und per Login Flow v2 (Zwei-Faktor), Abmelden mit Widerruf
|
||||
- Dateiansicht (Liste/Raster, Vorschau, Quota), Anlegen/Umbenennen/Verschieben/Loeschen in den Papierkorb
|
||||
- Hochladen als Datenstrom (8-MiB-Stuecke, Chunked Upload v2), Herunterladen, ZIP
|
||||
- Nextcloud-Kennung (Name, Logo, Farbe) auf dem Anmeldebildschirm
|
||||
affects: [web-ui, api, docs, changelog]
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- einzige Transportschicht ncRequest (feste Pfadanfaenge, Segmentcodierung, keine Weiterleitungen, keine Cookies)
|
||||
- prozessweite Aufrufsperre (429 haelt den Ursprung an, erstes 401 toetet den Zugangsschluessel)
|
||||
- mapNcFailure als einzige Fehlerabbildung, nie 401/403 an den Browser
|
||||
- RLS auf Mandant UND Benutzer (current_user_id)
|
||||
key-files:
|
||||
created:
|
||||
- apps/api/src/nextcloud-files/ (Transport, Aufrufsperre, Anmelde-Client, Kontodienst, Dateidienst, Transferdienst, Serverkennung)
|
||||
- apps/api/prisma/migrations/20261008180000_nextcloud_files/migration.sql
|
||||
- apps/api/prisma/migrations/20261008183000_*/migration.sql
|
||||
- apps/web/src/app/(portal)/modules/nextcloud-files/ (Seite, Komponenten)
|
||||
- apps/web/src/lib/nextcloud-files-api.ts, nextcloud-files-upload.ts
|
||||
- apps/web/src/components/nextcloud-files/ (Dateitypen, Auswahl, Pfade, Formate, Uebertragungen)
|
||||
- .planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/ (Testaufbau und vier e2e-Skripte)
|
||||
modified:
|
||||
- CHANGELOG.md, docs/anleitung-anwender.md, docs/anleitung-administration.md, docs/anleitung-betrieb.md
|
||||
- apps/web/src/messages/de.json, en.json, apps/web/src/app/globals.css
|
||||
decisions:
|
||||
- "Eine Nextcloud-Adresse je Organisation (Verwalten); interne Adressen erlaubt, dafuer enge Eingrenzung statt Public-URL-Pruefung (SSRF-Schutz ueber feste Pfade, keine Weiterleitungen, nie URL aus Antworten)"
|
||||
- "App-Passwort gilt nur fuer den Anmeldenamen der Ausstellung (Spalte ncLoginName, gemessen: E-Mail-Anmeldung gegen Nextcloud 34)"
|
||||
- "401 auf getapppassword ist doppeldeutig (falsches Passwort oder Zwei-Faktor) und heisst credentialsOrTwoFactor mit Weg ueber den Browser"
|
||||
- "Login Flow: poll.endpoint und login-Ursprung der Antwort werden verworfen; Abfrage immer auf {Basis}/index.php/login/v2/poll, Link aus Basis und Token neu gebaut"
|
||||
- "Eigene Anmeldebremse 3/15 min je Benutzer und 8/30 min je Server unter Nextclouds 10/30; jedes 429 pausiert alle Aufrufe an den Ursprung"
|
||||
- "Hochladen in 8-MiB-Stuecken wegen 10-MiB-Klonlimit und 30-s-Proxyzeit von Next.js; Zusammenbau asynchron mit abfragbarem Zustand"
|
||||
- "Serverkennung: Name aus status.php, Farbe aus anonymen capabilities (nur #rrggbb), Logo nur von zwei festen Pfaden, Bildtyp aus den Bytes, SVG nur mit CSP-Sandbox, 10 Minuten Zwischenspeicher je Organisation und Adresse"
|
||||
- "Kein Dashboard-Widget (L-08); Etappe 2 bleibt offen"
|
||||
metrics:
|
||||
duration: "Task 6: etwa 70 Minuten; gesamt ueber sechs Aufgaben am 2026-10-08"
|
||||
completed: 2026-10-08
|
||||
status: complete
|
||||
commits: 6
|
||||
plan_head_before: 83b842b72cdd2be7d6e70286708753ab22038271
|
||||
plan_head_after: 630398fe93596e96a65891381b3844a91c4ed49a
|
||||
actuals:
|
||||
tokens: 191000
|
||||
tasks: 6
|
||||
commits: 6
|
||||
---
|
||||
|
||||
# Phase quick-261008-mzu Plan 01: Modul Nextcloud-Dateien (Etappe 1) Summary
|
||||
|
||||
Neues Modul „Dateien“: Jeder Benutzer arbeitet in seiner eigenen Nextcloud (Konto per Passwort oder Browser-Anmeldung mit Zwei-Faktor), mit Dateiansicht, Hoch- und Herunterladen großer Dateien durch den Next.js-Proxy, Papierkorb-Löschen, und einem gestalteten Anmeldebildschirm mit Name, Logo und Farbe der Nextcloud.
|
||||
|
||||
## Commits
|
||||
|
||||
| Aufgabe | Commit | Inhalt |
|
||||
|---|---|---|
|
||||
| 1 | 9606967 | Modul, Migration (Config + Account mit Zeilenschutz Mandant UND Benutzer), Transportschicht, Aufrufsperre, Einstellungen, Registrierung |
|
||||
| 2 | d00b6ff | Anmeldung per Passwort und Login Flow v2, Anmeldebremse, Abmelden mit Widerruf, Verbindungsbildschirm, Spalte ncLoginName |
|
||||
| 3 | 8bee65c | PROPFIND, WebDAV-Schicht, Dateidienst (list, preview, folders, move, delete), mapNcFailure, sendUpstreamStream |
|
||||
| 4 | 4d4a0b3 | Hochladen als Datenstrom (einzeln und in Stücken), Download mit Range, ZIP, Browser-Uploader |
|
||||
| 5 | 86fbe8f | Dateiansicht (Liste/Raster, Typkacheln, Auswahl, Tastatur, Menüs, Dialoge, Ziehen und Ablegen, Übertragungsleiste) |
|
||||
| 6 | 630398f | Serverkennung (API + Web), gestaltete Anmeldekarte, Anleitungen, Changelog, Fokusfang, e2e wiederholbar |
|
||||
|
||||
## Aufgabe 6 im Detail
|
||||
|
||||
- API: `nextcloud-server-info.ts` (`NextcloudServerInfoService`) mit `GET server` und `GET server/logo` (Benutzen-Ebene, statisch vor dem Parameterblock, Controller- und Manage-Handler-Spec erweitert). 21 neue Tests (fake Transport, echte Aufrufsperre, fake Uhr): feste Pfade, Farbfilter, Logoerkennung nach Bytes, 512-KiB-Grenze, Zwischenspeicher 10 min, Adresswechsel leert, gesperrter Ursprung = keine Anfrage, 429 mitten in der Abfrage wird nicht gemerkt, Antwortköpfe des Logos.
|
||||
- Web: `ServerIdentity.tsx` (`ServerTile`, `ServerIdentity`), Anmeldekarte neu gestaltet (Kennungskopf mit Akzentlinie in der Themenfarbe, Hinweis zum Passwort am Kartenfuß mit Schloss), kleine Kachel in der Kontoleiste; 6 neue Seitentests; Texte de + en.
|
||||
- Registrierungen geprüft (app.module, module-loader, module-identity `folder`, nav-store, layouts-Test, Seed); `WIDGET_MODULE_SLUGS` ohne `nextcloud-files`.
|
||||
- Doku: CHANGELOG (vier Einträge unter „Neu“), Anwenderanleitung „Dateien (Nextcloud)“ mit Tastaturtabelle, Administrationshandbuch „Dateien: Nextcloud anbinden“ (Brute-Force-Ausnahme mit occ-Befehl, Adresswechsel, verwaiste App-Passwörter nach Benutzer-Löschung), Betriebshandbuch „Dateien (Nextcloud)“ in Kapitel 3 (NODE_EXTRA_CA_CERTS, Proxy-Grenzen, Zustand im Arbeitsspeicher) plus Fehlerbild-Zeile.
|
||||
|
||||
## Verifikation
|
||||
|
||||
- api-Suite 151 Dateien / 2922 Tests grün, web-Suite 143 Dateien / 1633 Tests grün (nach der letzten Änderung erneut), beide `tsc --noEmit` ohne Fehler, `biome lint` ohne Fehler (nur schon vorhandene Warnungen).
|
||||
- Rollen-Decorator-Grep, de/en-Parität und Wortprüfung, Widget-Prüfung, Changelog-/Anleitungs-Greps grün.
|
||||
- Stack neu gebaut (`docker compose up -d --build api web`), /health 200, Seed-Zeile „Nextcloud files module seeded in registry“, Routen `server` und `server/logo` vor den Parameterrouten abgebildet.
|
||||
- Alle e2e-Skripte auf dem neu gebauten Stack grün und zweimal hintereinander wiederholbar: nc-test-setup, e2e-settings (inkl. Serverkennung: Name, Rechnername, Version, Logo mit CSP/nosniff), e2e-connect, e2e-files, e2e-transfer.
|
||||
- Browserprüfung (playwright-core, Dunkel und Hell, echte Test-Nextcloud): Kennung mit Name „Nextcloud“, Logo und Themenfarbe rgb(0,103,158); 2FA-Wartezustand mit Link auf die Nextcloud; Fehler credentialsOrTwoFactor mit Browser als Hauptknopf; 30-MB-Upload über den Dateiauswahldialog mit Fortschritt und Namenskonflikt-Zeile (Überspringen); Umbenennen per F2, Verschieben nach „Rechnungen & Belege“, Löschen mit Rückfrage (in der Nextcloud-Papierkorb nachgewiesen), Datei-Download (PDF) und Ordner-ZIP („PK“), Enter/Rücktaste/Strg+A.
|
||||
- Screenshots unter `.playwright-mcp/nextcloud-files/`: dunkel `t6-dark-{connect,connect-waiting,connect-error,list,grid,selection,drop-overlay,transfers,move-dialog,empty,delete-dialog,mobile,mobile-connect,mobile-list}.png`, hell `t6-light-{connect,connect-waiting,list,grid,selection,drop-overlay,transfers,move-dialog,empty}.png`. Gegen L-09 geprüft: ruhige Liste, Streifen nur beim Ziehen, lesbare Typkacheln in beiden Modi, keine Großbuchstaben-Beschriftungen, keine Mittelpunkte.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] Rücktaste ging in leeren Ordnern verloren**
|
||||
- **Found during:** Aufgabe 6 (Browserprüfung, Screenshot „empty“)
|
||||
- **Issue:** In einem leeren Ordner gibt es keine Zeile, die den Fokus aufnimmt; der Fokus fiel auf die Seite zurück und die Tastenbelegung der Dateiansicht griff nicht mehr.
|
||||
- **Fix:** Die Dateikarte ist ein Fokusfang (`tabIndex={-1}`, Klasse `nc-files-root`); in globals.css ohne Rahmen, im dunklen Modus bleibt die Haarlinie erhalten.
|
||||
- **Files modified:** FileBrowser.tsx, globals.css
|
||||
- **Commit:** 630398f
|
||||
|
||||
**2. [Rule 1 - Bug] e2e-Skripte hingen vom Anfangszustand ab**
|
||||
- **Found during:** Aufgabe 6 (Wiederholung auf dem neu gebauten Stack)
|
||||
- **Issue:** `e2e-settings.sh` erwartete eine noch nicht gespeicherte Adresse (ein unveränderter Wert antwortet bewusst ohne Prüfung); `e2e-connect.sh` scheiterte an übrig gebliebenen Tessera-Zugängen in der Nextcloud (ein Adresswechsel lässt Konten ablaufen, ohne zu widerrufen, siehe Administrationshandbuch).
|
||||
- **Fix:** e2e-settings stellt erst auf eine andere Adresse; e2e-lib bekommt `e2e_nc_purge_tokens`, e2e-connect räumt vor dem Start auf. Dazu Prüfung der Serverkennung in e2e-settings.
|
||||
- **Commit:** 630398f
|
||||
|
||||
**3. [Rule 2 - Missing critical] Doku zum Brute-Force-Befehl vorsichtig formuliert**
|
||||
- Der occ-Befehl `config:app:set bruteForce whitelist_0` und `security:bruteforce:reset` stehen in den Handbüchern; Aussagen über die Oberfläche der Nextcloud zur Ermittlung der Absenderadresse wurden bewusst weggelassen (nicht gemessen), stattdessen der Hinweis auf das Zugriffsprotokoll des Webservers.
|
||||
|
||||
### Hinweise ohne Abweichung
|
||||
|
||||
- Bildschirmaufnahmen mit playwright-core statt Playwright MCP (mit Absprache aus der Übergabe von Aufgabe 5); gleiche Prüfungen, gleiche Ablage.
|
||||
- Das Projekt-`biome format` weist in Dateien aus früheren Aufgaben (module-loader.ts, packages/shared) Formatabweichungen aus; Projektvorgabe ist `biome lint`, daher unverändert gelassen.
|
||||
- Die RLS-Dokumentation musste in Aufgabe 6 nicht neu gezählt werden (keine neuen Tabellen oder Richtlinien).
|
||||
|
||||
## Known Stubs
|
||||
|
||||
Keine. (Das Modul hat bewusst kein Dashboard-Widget; Etappe 2 ist offen und nicht Teil dieses Plans.)
|
||||
|
||||
## Threat Flags
|
||||
|
||||
Keine neuen Flächen gegenüber dem Bedrohungsmodell. Die Serverkennung (T-mzu-01/T-mzu-08) ist enthalten: feste Pfade, keine Weiterleitung, Logo nach Bytes erkannt, SVG nur mit CSP-Sandbox.
|
||||
|
||||
## Prüfliste für die echte Umgebung (vom Benutzer)
|
||||
|
||||
1. **Desktop-App:** Neue Fassung herunterladen und prüfen, dass der Link „Anmeldung bei Nextcloud öffnen“ in der Desktop-App den System-Browser öffnet (Opener-Skript fängt `target=_blank`), und dass nach „Zugriff gewähren“ die Dateiansicht erscheint.
|
||||
2. **Nginx Proxy Manager (Tessera-Adresse):** `client_max_body_size` mindestens `10m` (besser `64m`) und Lese-/Sendezeitlimits mindestens 120 s für die Tessera-Adresse (alpha und live) setzen, dann eine Datei über 10 MB hochladen. Ohne das bricht der Upload beim ersten 8-MiB-Stück ab (Betriebshandbuch, Fehlerbild).
|
||||
3. **Nextcloud Brute-Force-Ausnahme:** Auf der echten Nextcloud die Adresse des Tessera-Servers eintragen: `occ config:app:set bruteForce whitelist_0 --value=<IP des Tessera-Servers>` (bei Proxy dessen Adresse; die tatsächlich ankommende Adresse im Zugriffsprotokoll prüfen). Danach in Tessera unter Dateien → Einstellungen die Adresse eintragen und „Verbindung prüfen“ klicken.
|
||||
4. **Zertifikat:** Nutzt die echte Nextcloud eine interne Zertifizierungsstelle, `NODE_EXTRA_CA_CERTS` im api-Container setzen (Betriebshandbuch Kapitel 3), sonst meldet die Prüfung ein Zertifikatsproblem.
|
||||
5. **Zwei-Faktor-Anmeldung:** Mit einem Konto mit Zwei-Faktor-Anmeldung „Im Browser anmelden“ durchspielen; ein Konto ohne Zwei-Faktor per Passwort anmelden; danach „Abmelden“ und in der Nextcloud unter Sicherheit prüfen, dass der Eintrag „Tessera“ verschwunden ist.
|
||||
6. **Modul freischalten:** Im Marktplatz „Dateien“ aktivieren und die Freigabe erteilen (Benutzen für alle, Verwalten für die, die die Adresse pflegen). Beim Deployen daran denken: `up` baut nicht neu, `--build` bzw. neue Abbilder ziehen; die Migrationen laufen beim Start der API.
|
||||
7. **Ablage-Kachel mit echtem Logo:** Prüfen, dass Name, Logo und Farbe der echten Nextcloud auf dem Anmeldebildschirm stimmen (bei dunklem Logo auf dunkler Themenfarbe ggf. den Eindruck bewerten).
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- Dateien vorhanden: nextcloud-server-info.ts, nextcloud-server-info.spec.ts, ServerIdentity.tsx, Screenshots t6-dark (14) und t6-light (9).
|
||||
- Commits in der Historie: 9606967, d00b6ff, 8bee65c, 4d4a0b3, 86fbe8f, 630398f.
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
---
|
||||
phase: quick-261008-mzu
|
||||
verified: 2026-10-08T20:20:00Z
|
||||
status: human_needed
|
||||
score: 9/9 must-haves verified
|
||||
covered_files:
|
||||
- ".planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/261008-mzu-PLAN.md"
|
||||
- ".planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/261008-mzu-SUMMARY.md"
|
||||
- "apps/api/prisma/migrations/20261008180000_nextcloud_files/migration.sql"
|
||||
- "apps/api/src/nextcloud-files/nextcloud-files-account.service.ts"
|
||||
- "apps/api/src/nextcloud-files/nextcloud-files.controller.ts"
|
||||
- "apps/api/src/nextcloud-files/nextcloud-http.ts"
|
||||
- "apps/api/src/nextcloud-files/nextcloud-login-guard.ts"
|
||||
- "apps/api/src/nextcloud-files/nextcloud-upstream.ts"
|
||||
- "apps/web/src/app/(portal)/modules/nextcloud-files/components/FileBrowser.tsx"
|
||||
covered_digest: "v3:sha256:324f20c51c56325030b045e7413ef53d5aa7c3d742f7aac80c85bcbe0aa91f6a"
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
human_verification:
|
||||
- test: "Desktop-App: Link 'Anmeldung bei Nextcloud öffnen' (target=_blank) und Rückkehr nach 'Zugriff gewähren'"
|
||||
expected: "System-Browser öffnet sich, danach zeigt die Desktop-App die Dateiansicht"
|
||||
why_human: "Tauri-Desktop-App (Opener-Skript) ist im lokalen Stack nicht prüfbar"
|
||||
- test: "Nginx Proxy Manager vor alpha/live: client_max_body_size >= 10m und Zeitlimits >= 120 s, dann Datei > 10 MB über die echte Tessera-Adresse hochladen"
|
||||
expected: "8-MiB-Stücke laufen durch, kein 413"
|
||||
why_human: "NPM-Grenzen existieren nur in der echten Umgebung; lokal läuft der Upload nur durch den Next.js-Proxy (belegt)"
|
||||
- test: "Echte Firmen-Nextcloud: Brute-Force-Ausnahme für die Tessera-Server-IP, interne CA (NODE_EXTRA_CA_CERTS), Adresse speichern, 'Verbindung prüfen', Passwort-Anmeldung und Zwei-Faktor-Anmeldung, Abmelden und Eintrag 'Tessera' in der Nextcloud-Geräteliste prüfen"
|
||||
expected: "Anmeldung klappt, Abmelden entfernt den App-Passwort-Eintrag, Name/Logo/Farbe der echten Nextcloud stimmen auf der Anmeldekarte"
|
||||
why_human: "Echte Nextcloud-Instanz (Version, Theming, Zertifikat, Absenderadresse) ist nur beim Benutzer verfügbar"
|
||||
---
|
||||
|
||||
# Quick 261008-mzu: Modul Dateien (nextcloud-files), Etappe 1 - Verification Report
|
||||
|
||||
**Goal:** Neues Modul "Dateien" (Nextcloud, Etappe 1): eine Firmen-Nextcloud, eigenes Konto je Benutzer (Passwort -> App-Passwort verschlüsselt, oder Login Flow v2 bei Zwei-Faktor), Abmelden widerruft, Dateien durchblättern/Vorschau/Hoch- und Herunterladen/ZIP/Ordner-Operationen/Mehrfachauswahl/Quota/Overwrite-Schutz/Brute-Force-Schutz/Benutzertrennung, ausgeprägtes Design, Doku + CHANGELOG.
|
||||
**Verified:** 2026-10-08
|
||||
**Status:** human_needed (alle automatisch prüfbaren Punkte erfüllt; drei Punkte brauchen die echte Umgebung)
|
||||
**Re-verification:** Nein, Erstprüfung
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | Eine Adresse je Organisation unter Verwalten; Prüfen per status.php; Adresswechsel mit Bestätigung lässt Konten ablaufen; Benutzen-only bekommt 403 | VERIFIED | Controller: `GET/PUT settings`, `POST settings/test` mit `@ModuleManage`, kein Rollen-Decorator. `settings.service.ts` Z.117-141: `confirmReconnect` Pflicht bei verbundenen Konten, danach `status: 'EXPIRED'`. `e2e-settings.sh` läuft grün (frisch ausgeführt). |
|
||||
| 2 | Passwort-Anmeldung: einmaliger Austausch gegen App-Passwort, nur dieses AES-verschlüsselt (`crypto.encrypt`), echtes Passwort verworfen, nichts in Antwort/Log | VERIFIED | `account.service.ts` `connectWithPassword`/`storeAppPassword` (Z.154-194, 366-390); Spalte `encryptedAppPassword`, kein Passwortfeld im Schema. Logs enthalten nur Kennungen/Mandant. e2e-connect grün (Anna per Passwort und per E-Mail). |
|
||||
| 3 | 2FA: `credentialsOrTwoFactor`, Login Flow v2, Poll nur gegen `{Basis}/index.php/login/v2/poll`, Wartezustand mit Abbrechen, Wechsel auf verbunden | VERIFIED | `auth-client.ts` `pollLoginFlow` mit festem Präfix (Z.317-328, `poll.endpoint` verworfen). e2e-connect: zoe -> 422 `credentialsOrTwoFactor`, Flow Start (loginUrl auf konfigurierte Basis), pending, Abbruch. Screenshots `t2-flow-waiting.png`, `t2-flow-connected.png`, `t6-dark-connect-waiting.png` zeigen Wartezustand und verbundenen Zustand gegen die Test-Nextcloud. |
|
||||
| 4 | Brute-Force-Schutz: 3/15 min je Benutzer, 8/30 min je Server; 429 pausiert alle Aufrufe; erstes 401 tötet Zugangsschlüssel inkl. laufender Aufrufe; Whitelist-Hinweis in Einstellungen/Handbuch | VERIFIED | `login-guard.ts` Z.26-29 (3/15, 8/30). `http.ts` `ncRequest`: `gate.isDead/isPaused` vor Transport, 429 -> `gate.pause`, 401 mit Schlüssel -> `markDead`, `signalFor` bricht laufende Aufrufe ab. Live bestätigt: wiederholte e2e-Läufe lieferten HTTP 429 `tooManyAttempts` (retryAfter 817 s). Handbuch Administration Z.359ff mit occ-Befehl. Call-Gate/Guard-Specs grün. |
|
||||
| 5 | Abmelden widerruft App-Passwort und löscht Zeile; Wiederverbinden widerruft altes; frisch ausgestelltes, nicht speicherbares wird widerrufen; nie an fremden Host | VERIFIED | `disconnect` (nur wenn ACTIVE und Adresse gleich), `revokePrevious`, `revokeFresh` im catch von `storeAppPassword`. e2e-connect belegt Token-Zählung in der echten Nextcloud: 1 nach Wiederverbinden, 0 nach Trennen (auch bei E-Mail-Anmeldename). |
|
||||
| 6 | Benutzertrennung: Mandant UND Benutzer (RLS + forTenant mit Token-User), zweiter Benutzer 409 notConnected | VERIFIED | Migration: `FORCE RLS`, Policy `tenantId = current_tenant_id() AND (current_user_id() IS NULL OR userId = current_user_id())`. Jede Methode nutzt `forTenant(this.prisma, tenantId, userId)`, `userId` nur aus `req.user.id`. e2e-connect: e2euser sieht `account:null`, Trennen -> 409, Flow-IDs fremder Benutzer -> 404. rls-coverage-Spec grün, Inventar-Doku Z.185 nachgeführt. |
|
||||
| 7 | Durchblättern (Breadcrumb, Liste/Raster pro Benutzer gemerkt), Typkacheln, Vorschau, Quota (-3 = unbegrenzt), Ordner anlegen/umbenennen/verschieben/löschen (Papierkorb), Mehrfachauswahl, Maus/Menü/Rechtsklick/Tastatur | VERIFIED | `FileBrowser.tsx`: localStorage je `userId`, Tasten Enter/Backspace/Delete/F2/Strg+A (Z.607-714). `QuotaMeter` zeigt bei unbegrenzt nur belegten Platz (Screenshot "3,6 MB belegt"). e2e-files grün. 169 Web-Tests grün (FileBrowser, TransferBar, Seite, Upload). Screenshots dunkel/hell: Liste, Raster, Auswahl, Verschieben-Dialog, Löschen-Dialog, leer, mobil. |
|
||||
| 8 | Upload per Drag&Drop/Dateiwahl auch > 10 MiB in 8-MiB-Stücken durch /api-proxy, Fortschritt, Abbruch, Fehlertext, nie stilles Überschreiben ("Ersetzen", "Beide behalten", "Überspringen") | VERIFIED | `dav-transfer.ts`: `If-None-Match: *`, Ersetzen nur mit `If-Match`-Etag. e2e-transfer grün: 30 MB in Stücken durch Next.js-Proxy (:3000/api-proxy), byteidentischer Download, 409 `nameTaken`, 409 `changedMeanwhile`, 413 `chunkTooLarge`, 411, Abbruch räumt Upload-Ordner auf. Screenshot `t6-dark-transfers.png`: Übertragungsleiste mit Fortschritt und den drei Konfliktknöpfen. |
|
||||
| 9 | Downloads ungepuffert (nosniff, ZIP für Ordner/Auswahl); jede Nextcloud-Antwort wird vor dem Pipen abgebildet, nie 401/403; nur Basis-URL, feste Pfade, keine Weiterleitung, keine Cookies, nie URL aus Antwort | VERIFIED | `upstream.ts`: `sendUpstreamStream` ruft `mapNcFailure` vor `pipeline`. `http.ts`: `ALLOWED_PREFIXES`, `buildNcUrl` mit Segment-Kodierung, 3xx -> `redirect`-Fehler, Header `cookie/host/authorization` gesperrt. e2e-transfer: Range 206, ZIP (Ordner, Auswahl, zwei Namen), `..` -> 400 `invalidPath`, fehlende Datei -> 404 `notFound`, ohne Anmeldung 401 nur von Tessera. |
|
||||
|
||||
**Score:** 9/9 Truths verified (0 behavior-unverified)
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Status | Details |
|
||||
|----------|--------|---------|
|
||||
| `migration.sql` (+ `..._login_name`) | VERIFIED | zwei Tabellen, ENABLE/FORCE RLS, Policy mit `current_user_id()` |
|
||||
| `nextcloud-http.ts` / `nextcloud-call-gate.ts` | VERIFIED | alle genannten Exporte, im Transport verdrahtet |
|
||||
| `nextcloud-auth-client.ts`, `...account.service.ts` | VERIFIED | substantiell, in Modul und Controller verdrahtet |
|
||||
| `nextcloud-upstream.ts`, `...transfer.service.ts` | VERIFIED | `mapNcFailure`, `sendUpstreamStream`, 593 Zeilen Transferdienst |
|
||||
| `nextcloud-files-upload.ts` (Web) | VERIFIED | Uploader mit Tests (503 Zeilen), von `use-transfers.ts` genutzt |
|
||||
| `FileBrowser.tsx`, `ConnectPanel.tsx` | VERIFIED | in `page.tsx` eingebunden, Screenshots belegen Rendering mit echten Daten |
|
||||
| Registrierung (app.module, module-loader, nav-store, module-identity, Seed) | VERIFIED | per grep bestätigt |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
| From | To | Status |
|
||||
|------|----|--------|
|
||||
| account.service -> `crypto.encrypt(` | nur App-Passwort | WIRED |
|
||||
| auth-client `pollLoginFlow` -> `/index.php/login/v2/poll` | fester Pfad | WIRED |
|
||||
| `ncRequest` -> `NextcloudCallGate` | isPaused/isDead/pause/markDead | WIRED |
|
||||
| `getSession` -> `forTenant(this.prisma, tenantId, userId)` | via `findAccount` | WIRED |
|
||||
| transfer.service -> `mapNcFailure(` | vor dem Pipen | WIRED |
|
||||
| dav `move` -> `Overwrite: F` | außer Etag-geprüftes Ersetzen | WIRED |
|
||||
|
||||
### Behavioral Spot-Checks (frisch ausgeführt)
|
||||
|
||||
| Check | Ergebnis | Status |
|
||||
|-------|----------|--------|
|
||||
| API-Tests `src/nextcloud-files` + `src/module-registry` | 19 Dateien, 513 Tests grün | PASS |
|
||||
| Web-Tests Modul, Lib, Komponenten, Layouts | 11 Dateien, 169 Tests grün | PASS |
|
||||
| `tsc --noEmit` apps/api und apps/web | ohne Fehler | PASS |
|
||||
| rls-coverage-Spec | 5 Tests grün | PASS |
|
||||
| `e2e-settings.sh`, `e2e-connect.sh`, `e2e-files.sh`, `e2e-transfer.sh` gegen Test-Nextcloud 34.0.4 | alle "ok" (nach API-Neustart) | PASS |
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
Keine TBD/FIXME/XXX/TODO/HACK in den Modulverzeichnissen. Keine Stubs: Dateigrößen und Datenfluss (echte PROPFIND/DAV-Antworten bis in die Oberfläche, Screenshots mit echten Nextcloud-Dateien) belegen es.
|
||||
|
||||
| Beobachtung | Schwere | Auswirkung |
|
||||
|-------------|---------|------------|
|
||||
| `e2e-connect.sh` ist innerhalb von 15 Minuten nach einem früheren Lauf nicht wiederholbar: der Zwei-Faktor-Versuch zählt als Fehlversuch für `admin`, der nächste Lauf scheitert schon beim Schritt "anna verbinden" mit 429 (Neustart der API dann nötig). Das Skript startet die API nur beim 2FA-Schritt neu. Die Aussage der SUMMARY, die Skripte seien "zweimal hintereinander wiederholbar", gilt deshalb nur mit dazwischenliegendem Neustart. | Info | Nur Testwerkzeug; es zeigt zugleich, dass der Brute-Force-Schutz wirkt. |
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
QUICK-261008-mzu: SATISFIED für alle im Zielsatz genannten Punkte (Konto, Dateien, Transfer, Schutzmaßnahmen, Design, Doku). CHANGELOG (vier Einträge unter "Neu"), Anwender-, Administrations- und Betriebshandbuch vorhanden und inhaltlich passend.
|
||||
|
||||
### Design
|
||||
|
||||
Screenshots (dunkel und hell, desktop und mobil) zeigen ein eigenständiges Erscheinungsbild: Typkacheln je Dateifamilie mit Farbcodierung, Kennungskopf der Nextcloud (Name, Logo, Themenfarbe) auf der Anmeldekarte, angedockte Übertragungsleiste, Mosaik-Gelb als Akzent. Nicht generisch. Die subjektive Bewertung bleibt beim Benutzer.
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
Diese Punkte brauchen die echte Umgebung und sind keine Lücken:
|
||||
|
||||
1. **Desktop-App:** Link "Anmeldung bei Nextcloud öffnen" öffnet den System-Browser, nach "Zugriff gewähren" erscheint die Dateiansicht. Grund: Tauri-Opener nur in der installierten App prüfbar.
|
||||
2. **Nginx Proxy Manager:** `client_max_body_size` >= 10m, Zeitlimits >= 120 s für alpha/live, dann Datei > 10 MB hochladen. Grund: NPM-Grenzen nur in der echten Umgebung.
|
||||
3. **Echte Firmen-Nextcloud:** Brute-Force-Ausnahme für die Tessera-IP, ggf. `NODE_EXTRA_CA_CERTS`, Adresse eintragen, Passwort- und 2FA-Anmeldung, Abmelden und Geräteliste prüfen, Name/Logo/Farbe der Anmeldekarte beurteilen.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
Keine Lücken. Alle neun Truths sind gegen den Code, frisch ausgeführte Tests und die End-zu-End-Skripte gegen eine echte Test-Nextcloud belegt. Der Gesamtstatus ist `human_needed` nur wegen der drei Punkte, die die Produktionsumgebung erfordern.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-08_
|
||||
_Verifier: Claude (gsd-verifier)_
|
||||
Reference in New Issue
Block a user