Files
tessera-ctl/.planning/quick/261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/261008-mzu-RESEARCH.md
T
schalli 71b3f32c98
Tessera CI/CD / Lint & Type Check (push) Successful in 50s
Tessera CI/CD / Tests (push) Successful in 1m46s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 20s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m44s
docs(quick-261008-mzu): Modul Dateien (Nextcloud-Client)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 22:59:16 +02:00

58 KiB

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 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 (&quot;...&quot;), 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):

// 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: `
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.

# 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"):

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)