# 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 (`` mit Cookie-Anmeldung) | `` 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=`: `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` (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 ``` 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 ``; `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 `3...`; `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 ` + `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 ` | `204`, Datei landet im Papierkorb (`/remote.php/dav/trashbin/{uid}/trash/.d`, `files_trashbin` ist aktiv); nicht vorhanden `404`. Ordner rekursiv | | Favorit | `PROPPATCH` Body `1` | `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: ""` | 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-`. 2. `PUT .../uploads/{uid}/{uploadId}/{nummer}` je Chunk, `Destination` wie oben, `OC-Total-Length: ` (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: ` 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 `` 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/`, `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: `{PROPFIND-Felder + d:displayname}/files/{uid}infinity%suchtext%...50` 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''`), 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: `` 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/.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 `` + 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 `Bei Nextcloud anmelden` (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=` (gemessen: danach `occ security:bruteforce:attempts ` -> `bypass-listed: true`; wirkt ueber die App `bruteforcesettings`), im Einstellungs-Tab als Hinweis, (c) Notfall: `occ security:bruteforce:reset `. 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 `/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' 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 `; 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 ``-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)