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')], Antwortarray{apppassword: string}. [CITED: raw.githubusercontent.com/nextcloud/server/master/core/Controller/AppPasswordController.php] - Ursache 2FA:
Session::logClientInwirftPasswordLoginForbiddenException, wenn!$isTokenPassword && ($this->isTokenAuthEnforced() || $this->isTwoFactorEnforced($user))- und zwar VOR der Passwortpruefung;Manager::isTwoFactorAuthenticatedist 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 XMLOCA\DAV\Connector\Sabre\Exception\PasswordLoginForbidden, Hinweispassword 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/usermit 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).loginNameaus Login Flow v2 ist i. d. R. gleich, trotzdemcloud/userabfragen undncUserIdspeichern. [VERIFIED: Messung; Annahme "loginName kann abweichen" ASSUMED]
2. Login Flow v2 (2FA-Konten)
POST {base}/index.php/login/v2(leerer Body, eigenerUser-Agent, z. B.Tessera-Nextcloud-Dateien- der Name erscheint dem Benutzer auf der Nextcloud-Seite als Geraetename). Antwort200(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]- Browser des Benutzers oeffnet
loginin neuem Fenster; Benutzer meldet sich dort an (inkl. 2FA) und klickt "Zugriff gewaehren". - Tessera pollt
POST poll.endpointmit Formularfeldtoken=<poll.token>:404(Koerper[]) bis fertig; einmalig200{"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.endpointNICHT blind aufrufen (SSRF!), sondern immer{konfigurierteBasis}/index.php/login/v2/pollbenutzen; (b)loginnur 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 nurflowId+loginUrlund fragtGET connect/flow/:flowIdalle 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
initunterliegt in der Praxis nicht der Fehlversuch-Sperre (gemessen:200waehrend 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 zweitenpropstatmitHTTP/1.1 404 Not Found(z. B.getcontenttype/getcontentlengthbei Ordnern, Quota bei Dateien). Nurpropstatmit Status 200 auswerten. - Der erste
responseist der angefragte Ordner selbst (beiDepth: 1) - ueberspringen, aber seine Quota (quota-available-bytes,quota-used-bytes) fuer die Kopfzeile nutzen.quota-available-bytesist-3bei unbegrenztem Speicher (gemessen), also<0= "unbegrenzt/unbekannt". - Ordner erkennbar an
resourcetypemit<d:collection/>;oc:sizeist bei Ordnern die rekursive Groesse. Berechtigungen als Buchstaben, gemessenRGDNVCK(Ordner),RGDNVW(Datei):Rteilbar,Glesbar,Dloeschbar,Numbenennbar,Vverschiebbar,Wschreibbar (Datei),C/KDateien/Ordner anlegbar,Sgeteilt,Meingebunden. [VERIFIED: Messung; CITED: WebDAV/basic.html] oc:favorite=0/1;nc:has-preview=true/false;oc:share-typesleer oder verschachtelt<oc:share-type>3</oc:share-type>...;oc:fileidkann fuehrende Nullen/grosse Zahlen haben -> als String lassen.d:getetagkommt mit Anfuehrungszeichen ("..."), ETag so wie geliefert (mit Anfuehrungszeichen) fuerIf-Matchweiterverwenden.d:hrefist prozentcodiert, Hex teils klein (%c3%84rger%20%26,%3f). Beim ParsendecodeURIComponentauf jedes Segment, danach das Praefix/remote.php/dav/files/{uid}/(plus eventueller Unterpfad der Basis-URL) abschneiden.fast-xml-parserEinstellung (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: falseist Pflicht, sonst werden Namen wie12345oder0123zu 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: Fueberschreibt MOVE still das Ziel (gemessen204, vorhandene Datei weg). Tessera sendet IMMEROverwrite: Fund uebersetzt412in "Ein Eintrag mit diesem Namen existiert bereits". Destinationimmer 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]
MKCOL {base}/remote.php/dav/uploads/{uid}/{uploadId}mitDestination: {base}/remote.php/dav/files/{uid}/{zielpfad}->201.uploadId=tessera-<uuid>.PUT .../uploads/{uid}/{uploadId}/{nummer}je Chunk,Destinationwie 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.MOVE .../uploads/{uid}/{uploadId}/.filemitDestination,OC-Total-Length, optionalX-OC-Mtime: <unix>undOverwrite: F->201(Antwort-HeaderOC-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: Fkommt412und der Upload-Ordner BLEIBT -> danachDELETEzum Aufraeumen.- Abbruch:
DELETE .../uploads/{uid}/{uploadId}/->204(ohneDestination). 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-Lengthverschiebt Quota-Fehler auf den Zusammenbau. - Kleine Dateien (<= 8 MiB): ein einzelner
PUTdirekt auf.../dav/files/{uid}/{ziel}mitIf-None-Match: *(kein Ueberschreiben) bzw.If-Match(bewusstes Ersetzen),X-OC-MtimeausFile.lastModified. - Tessera-API-Form (zustandslos, Nextcloud haelt den Zustand):
POST uploads{path,size} -> {uploadId,chunkSize};PUT uploads/:uploadId/chunks/:n?path=(roher Body,content-lengthPflicht, sonst 411);POST uploads/:uploadId/complete{path,size,mtime};DELETE uploads/:uploadId. Jede Route prueftuploadIdgegen^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 mitpassword+expireDate=2026-12-31->200,data.url=http://.../s/<token>,data.expiration="2026-12-31 23:59:59",password/share_withkommen als"redacted"zurueck (nie anzeigen), Link mitpermissions=1wird zu17(lesen+teilen). Benutzer-Freigabe liefertid,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); AendernPUT /shares/{id}; AufhebenDELETE /shares/{id}. Die Freigaben koennen mitdata[]Felderid, share_type, permissions, path, item_type, url, token, expiration, share_with_displayname, uid_ownerenthalten (gemessen). - Sharee-Suche
GET /sharees?search=zo&itemType=file&perPage=5->data.users[]/groups[]mitlabelundvalue:{shareType,shareWith};exactundlookupignorieren. [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>mitContent-Type: text/xml;%,_im Suchtext maskieren ist [ASSUMED] noetig. (depth infinityist hier korrekt - es ist ein Suchbereich, kein PROPFIND-Depth). Unified Search (/ocs/v2.php/search/providers/files/search?term=&limit=) bleibt Fallback. - Berechtigungsbuchstabe
Ram 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(nichtfetch) nehmen: nimmt Node-Streams ohneduplex-Tricks, folgt nie Weiterleitungen, liefertbodyals Readable fuerstream.pipeline.fetchmitReadable.toWeb(...)+duplex: 'half'funktionierte ebenfalls (gemessen), ist aber umstaendlicher. Das Projekt nutzt sonstfetch as undiciFetch(nextcloud-status-fetch.ts) - hier bewusstrequest.- Zeitlimits: Verbindungs-/Header-Timeout 30 s,
bodyTimeoutfuer Chunk-PUT 120 s, fuer Download 0 (Leerlauf-Timeout reicht:bodyTimeout: 60_000ist Leerlaufzeit in undici). Beireq.on('aborted'/'close')die Nextcloud-Anfrage perAbortControllerabbrechen.Content-Dispositionselbst bilden (attachment; filename*=UTF-8''<encodeURIComponent(name)>), Name nicht aus Nextcloud-Kopfzeile uebernehmen;X-Content-Type-Options: nosniffsetzen, und Inhalte, die der Browser ausfuehren kann (text/html,image/svg+xml), nur alsattachmentausliefern. - Guards/Interceptors: die API-Anmeldung liest das Cookie; kein Interceptor darf den Body lesen.
requestLogMiddlewareprotokolliert 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.middlewareClientMaxBodySizeNICHT erhoehen (haelt Puffer klein). - Downloads laufen als Antwort-Stream durch dieselbe Proxy-Strecke (
http-proxy, kein Puffer);proxyTimeoutist 30 s (Leerlauf der Proxy-Verbindung,experimental.proxyTimeout: undefinedin config-shared.js, Vorgabe in proxy-request.jsproxyTimeout || 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-openerschluckttarget=_blank-Links; Web-Helfer lauscht aufdocument).
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:
- Eine einzige Basis-URL je Organisation, gespeichert im Normalformat von
normalizeCloudUrl()(exportiert ausnextcloud-status-fetch.ts: nur http/https, keine Zugangsdaten im URL, kein Query/Anker, ohne/status.php//index.php, ohne Schraegstrich am Ende). NurVerwaltendarf sie aendern; Aenderung erzwingt "Verbindung pruefen" (GET {base}/status.php, bestehendefetchNextcloudStatus: 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. - 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. - Keine Weiterleitungen zur Laufzeit:
undici.requestfolgt 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. - Aus Nextcloud-Antworten werden nie URLs aufgerufen (
poll.endpointverwerfen, Share-urlnur als Text anzeigen, Unified-Search-resourceUrlnicht aufrufen). - Zeit- und Groessendeckel auf jeder Anfrage (OCS/PROPFIND-Antworten <= 8 MiB lesen, 15 s; Streams haben eigene Grenzen).
- 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 setztNODE_EXTRA_CA_CERTSfuer den api-Container (derzeit nirgends in Compose/Dockerfile gesetzt, grep leer) -> in der Betriebsanleitung beschreiben. [VERIFIED: grep ueber yml/ts/Dockerfile/md] - 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.
- Der Trusted-Domain-Check der Nextcloud gehoert zum Erreichen dazu: eine Host-Kopfzeile, die nicht in
trusted_domainssteht, bekommt400mit HTML-Fehlerseite - gemessen auch fuer/status.php,/ocs/...und WebDAV. Der Verbindungstest meldet das als "Nextcloud lehnt diese Adresse ab (vertrauenswuerdige Domains)". [VERIFIED: Messung mitHost: 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 }) }(Musternextcloud-status, Zeile ~62).apps/web/src/lib/module-identity.ts:ICONS['nextcloud-files'].ModuleIconIdist 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 incomponents/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.tsxmit<ModuleAccessGate moduleSlug="nextcloud-files">+ Eintrag inmodule-layouts.test.tsx(Zeile ~47).- Seite:
PageHeader(@/components/layout/page-header), Tabs wiemodules/domains/page.tsx("Dateien" / "Einstellungen"); Tab "Einstellungen" nur wennuseCanManageModule('nextcloud-files')(apps/web/src/lib/use-module-capability.ts) mitSettingsSection(components/control-center/settings-section.tsx) wiemodules/domains/components/SettingsTab.tsx.ControlCenterNavgehoert zum Administrationsbereich (/admin/...,/settings) und ist hier NICHT einzubauen; der Einstellungs-Tab im Modul ist der Weg (so macht esdomains). [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.tsnachnextcloud-status-api.ts(credentials: 'include',NEXT_PUBLIC_API_URL). Chunk-Upload mitXMLHttpRequest(Fortschrittupload.onprogress) oderfetch;Blob.slice(i*8MiB, ...). - Texte
apps/web/src/messages/de.jsonUNDen.json(Paritaetstests), neuer NamensraumnextcloudFiles; deutsche Texte mit echten Umlauten und Sie-Form;apps/web/src/messages/umlaut-guard.spec.tsprueft de.json gegenumlaut-dictionary.ts- neue Wortformen ggf. dort eintragen. Keine "Mandant"-Woerter. - Kein Dashboard-Widget in Etappe 1 (
WIDGET_MODULE_SLUGSinpackages/shared/src/index.tsunveraendert). - Verbindungs-Fenster (Login Flow): kein
window.opennach asynchronem Aufruf (Popup-Blocker, Tauri-Opener schluckttarget=_blankbei Script-Oeffnung, siehe Memory). Ablauf: Klick auf "Im Browser anmelden" -> APIPOST 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 aufdocument) und startet das Abfragen; Text "Warte auf Ihre Bestaetigung in Nextcloud ..." mit Abbrechen. Alternativ im selben Klickwindow.open('', '_blank')synchron vorab oeffnen und spaeterlocationsetzen - 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
- Brute-Force-Sperre trifft alle Benutzer. Gemessen: Fehlversuche 1-5 liefen mit wachsender Verzoegerung (~0,6 s je Aufruf), ab dem 11. Versuch
429fuer die ganze IP, auch fuer PROPFIND mit gueltigem App-Passwort. Jede 2FA-Anmeldung per Passwort zaehlt als Fehlversuch (vor der Passwortpruefung abgelehnt, aberhandleLoginFailedzaehlt). 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: danachocc security:bruteforce:attempts <ip>->bypass-listed: true; wirkt ueber die Appbruteforcesettings), 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 muessentrusted_proxiesstimmen, sonst zaehlt Nextcloud die Proxy-Adresse. [CITED: admin_manual/configuration_server/bruteforce_configuration.html] - 401 ist mehrdeutig (falsches Passwort vs. 2FA) - siehe Abschnitt 1; Fehlertext darf keine Aussage "Passwort falsch" machen.
- App-Passwort ist kein Passwort.
getapppasswordmit App-Passwort -> 403; Tessera darf nie versuchen, mit einem App-Passwort ein weiteres zu holen. - Cookies: Nextcloud setzt bei jeder Antwort mehrere Session-Cookies (
oc...,nc_sameSite...). Keinen Cookie-Speicher mitfuehren (undici hat keinen) und Antwortkopfzeilenset-cookienie an den Browser weiterreichen (Download-/Vorschau-Antworten nur ueber eine Positivliste: content-type, content-length, content-range, accept-ranges, etag, last-modified, cache-control). - 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). Named:displaynamenicht verwenden, den Namen aus demhrefnehmen. - ETag/Konflikte: Ersetzen einer Datei nur mit
If-Match, Neuanlage mitIf-None-Match: *, MOVE/COPY mitOverwrite: F;412-> "wurde zwischenzeitlich geaendert/existiert bereits". Liste nach jeder Schreibaktion neu laden (ETag/Groesse der Ordner aendern sich). - Quota:
quota-available-bytesdes Ordners vor Upload pruefen (-3/negativ = unbegrenzt); Upload mitOC-Total-Lengthstartet die Pruefung serverseitig -> bei507 Insufficient StorageMeldung "Speicherplatz in Nextcloud erschoepft". - Chunk-Aufraeumen: abgebrochene Uploads (Tab geschlossen) lassen Ordner unter
uploads/{uid}/(24 h Ablauf). Bei Fehlern im BrowserDELETE uploads/:idausloesen; bei412im finalen MOVE ebenfalls (gemessen: Ordner bleibt). - Weiterleitungen: http->https-Umleitung der Nextcloud macht aus PUT/PROPFIND einen Fehler -> Adresse im Einstellungs-Tab mit https eintragen; Verbindungstest meldet die endgueltige Adresse.
- Nextcloud-Unterpfad (
https://host/nextcloud): Basis enthaelt den Unterpfad; beim Dekodieren derhrefdas Praefix<unterpfad>/remote.php/dav/files/{uid}/abziehen. - Wartung:
status.phpmeldetmaintenance: true-> WebDAV liefert503; als "Nextcloud ist im Wartungsmodus" melden. - Benutzer wird in Tessera geloescht/deaktiviert: Konto-Zeile faellt per
onDelete: Cascadeweg; 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). - Streams und Fehler: Wenn der Nextcloud-Upload mittendrin abbricht,
reqperreq.destroy()beenden; bei Download-Abbruch beide Seiten zerstoeren (pipelineerledigt es), sonst haengen Verbindungen. - Routen-Reihenfolge Nest:
files/search,files/zip,preview,uploadsvor 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/SettingsSectionfuer 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-12345im Tessera-Formular -> verbunden. 2FA-Weg:zoe/User2-Pass-12345im Formular -> 401 -> Oberflaeche bietet "Im Browser anmelden" -> Link oeffnen, bei Nextcloud als zoe anmelden, dort wird nach dem TOTP-Code gefragt (Code mitpython3 -I /tmp/totp.py $SECRET), "Zugriff gewaehren" klicken -> Tessera meldet "Verbunden". Login Flow v2 laesst sich auch mitannapruefen (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 dannhttp://nc-test(Name steht inNEXTCLOUD_TRUSTED_DOMAINS). Alternativhttp://172.17.0.1:18080(Host-Gateway; dann steht172.17.0.1in 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 pruefenPROPFIND /remote.php/dav/trashbin/anna/trash; Freigabenocchat keinen Befehl, im Browser unterhttp://localhost:18080als 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
- Welche Nextcloud-Version laeuft in der Firma, und ist die
bruteforcesettings-App aktiv? Wirkt auf Whitelist-Hinweis und 429-Verhalten; Einstellungs-Tab zeigtversionStringausstatus.php. - Hat die Firmen-Nextcloud ein oeffentliches Zertifikat? Sonst
NODE_EXTRA_CA_CERTSfuer den api-Container (Betriebsentscheidung, nicht Code). - Soll "Ordner herunterladen (ZIP)" in Etappe 1? Kostet eine Route (
?accept=zip, gemessen), Empfehlung: ja. - 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:stable34.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_totplib/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), Migration20260929140000_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.19dist/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)