From 78f6cf358dbd3526d8d5eb6a05cd82af9f95d26c Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 9 Oct 2026 12:21:23 +0200 Subject: [PATCH] fix(quick-261009-dkv): Pfade woertlich, Zaehler und Serverdatum, Rechte des Ordners (Schnittstelle) - Pfade, Ziele und Empfaengerkennungen der Freigaben bleiben woertlich (nur Anzeigetexte werden bereinigt) - Weitergaben als eigene Freigaben mit eigenem Pfad; accessOf nach Eintragsart - Begrenzung: 10 neue Freigaben je 10 Minuten, 40 Versuche je 10 Minuten vor den Abfragen an die Nextcloud, leere Zaehler werden entfernt - Freigaberegeln nennen das Serverdatum, die Ordnerliste die Berechtigungsbuchstaben des Ordners selbst - Live-Test: Ablehnen offener Freigaben, Weitergabe, Buchstaben des Ordners Co-Authored-By: Claude Opus 5.5 (1M context) --- .../e2e/e2e-shares.sh | 47 ++++- .../nextcloud-files-shares.service.spec.ts | 85 ++++++++- .../nextcloud-files-shares.service.ts | 14 +- .../nextcloud-files.controller.ts | 10 +- .../nextcloud-files/nextcloud-files.module.ts | 2 +- .../nextcloud-files.service.spec.ts | 10 ++ .../nextcloud-files.service.ts | 12 +- .../nextcloud-login-guard.spec.ts | 68 ++++++- .../nextcloud-files/nextcloud-login-guard.ts | 77 +++++++- .../nextcloud-propfind.spec.ts | 17 ++ .../src/nextcloud-files/nextcloud-propfind.ts | 10 +- .../nextcloud-files/nextcloud-shares.spec.ts | 168 ++++++++++++++++-- .../src/nextcloud-files/nextcloud-shares.ts | 90 ++++++++-- 13 files changed, 543 insertions(+), 67 deletions(-) diff --git a/.planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/e2e/e2e-shares.sh b/.planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/e2e/e2e-shares.sh index 37a02c0..bc315b4 100644 --- a/.planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/e2e/e2e-shares.sh +++ b/.planning/quick/261009-dkv-modul-dateien-etappe-2a-teilen-von-datei/e2e/e2e-shares.sh @@ -10,7 +10,7 @@ # Die Test-Nextcloud bekommt den Benutzer ben (Passwort User3-Pass-12345, ohne Zwei-Faktor) und die # Gruppe tessera-team (mit ben). Die Ratenbegrenzung der Nextcloud wird NUR fuer diesen Lauf # ausgeschaltet (sonst koennte wiederholtes Testen deren 429 ausloesen, das die Aufrufsperre fuer -# den ganzen Ursprung anhaelt). Die Begrenzung von Tessera (15 in 10 Minuten) beweisen die +# den ganzen Ursprung anhaelt). Die Begrenzung von Tessera (10 in 10 Minuten) beweisen die # Einheitstests. Alles, was das Skript anfasst, wird im trap zurueckgesetzt. set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -28,6 +28,8 @@ EPOCH=$(date +%s) FIX="Tessera-Teilen-$EPOCH" BENFIX="Ben-Ordner-$EPOCH" BENBOX="Ben-Briefkasten-$EPOCH" +BENDECL="Ben-Ablehnen-$EPOCH" +RESHARE="Weitergabe-$EPOCH" START_ISO=$(date -u +%Y-%m-%dT%H:%M:%SZ) REPO="$(cd "$HERE/../../../.." && pwd)" @@ -38,6 +40,8 @@ cleanup() { curl -s -o /dev/null -u "$ANNA" -X DELETE "$NC_DAV/files/anna/$FIX" 2>/dev/null curl -s -o /dev/null -u "$BEN" -X DELETE "$NC_DAV/files/ben/$BENFIX" 2>/dev/null curl -s -o /dev/null -u "$BEN" -X DELETE "$NC_DAV/files/ben/$BENBOX" 2>/dev/null + curl -s -o /dev/null -u "$BEN" -X DELETE "$NC_DAV/files/ben/$BENDECL" 2>/dev/null + curl -s -o /dev/null -u "$ANNA" -X DELETE "$NC_DAV/files/anna/$RESHARE" 2>/dev/null # Papierkorb leeren, damit abgelehnte oder verlassene Ordner nicht liegen bleiben NC_OCC trashbin:cleanup ben >/dev/null 2>&1 NC_OCC config:system:delete ratelimit.protection.enabled >/dev/null 2>&1 @@ -392,7 +396,7 @@ section_links() { } section_received() { - local code id sfx_letters pid + local code id sfx_letters pid folder_letters pid2 # Eingehend und angenommen (Vorgabe der Nextcloud: sofort angenommen) NC_OCC user:setting --delete anna files_sharing default_accept >/dev/null 2>&1 || true @@ -410,6 +414,15 @@ section_received() { echo "gemessen: Berechtigungsbuchstaben des eingehenden Ordners: $sfx_letters" case "$sfx_letters" in *S*) ;; *) e2e_fail "eingehender Eintrag ohne Buchstaben S ($sfx_letters): Kennzeichen auf nc:mount-type umstellen" ;; esac + # Die Liste nennt die Buchstaben des Ordners selbst: eigene Wurzel darf anlegen, ein nur zum + # Ansehen geteilter Ordner nicht (Dateiansicht blendet "Neuer Ordner" und "Hochladen" damit aus). + e2e_expect True "$(jq_py 'all(c in (d.get("permissions") or "") for c in "CK")')" "Liste der Wurzel: Buchstaben C und K" + code=$(qget files path "/$BENFIX") + e2e_expect 200 "$code" "eingehenden Ordner auflisten" + folder_letters=$(jq_py 'd.get("permissions")') + echo "gemessen: Buchstaben des geoeffneten Nur-Ansehen-Ordners: $folder_letters" + e2e_expect True "$(jq_py '"C" not in (d.get("permissions") or "x") and "K" not in (d.get("permissions") or "x") and d.get("permissions") is not None')" "Nur-Ansehen-Ordner: weder C noch K" + code=$(call GET shares/mine) e2e_expect 200 "$code" "Eigene Freigaben" e2e_expect True "$(jq_py 'not any(s["target"]=="/'"$BENFIX"'" or s["path"]=="/'"$BENFIX"'" for s in d["shares"])')" "mine enthaelt die eingehende Freigabe nicht" @@ -442,6 +455,36 @@ print(len(d), "Freigaben", [(s.get("share_type"), s.get("share_with"), s.get("pe code=$(call DELETE "shares/$pid") e2e_expect 200 "$code" "angenommene Freigabe verlassen" + + # Offene Freigabe ablehnen: gemessen (Nextcloud 34.0.4) geht das ueber DELETE shares/{id} + # (200, danach weder offen noch angenommen); DELETE shares/pending/{id} ergibt dort 405. + code=$(ben_share_with_anna "$BENDECL") + e2e_expect 200 "$code" "ben teilt einen Ordner zum Ablehnen" + code=$(call GET shares/received) + e2e_expect True "$(jq_py 'any(s["name"].startswith("'"$BENDECL"'") and s.get("pending") is True for s in d["pending"])')" "received: Ablehnen-Ordner ist offen" + pid2=$(jq_py '[s["id"] for s in d["pending"] if s["name"].startswith("'"$BENDECL"'")][0]') + code=$(call DELETE "shares/$pid2") + e2e_expect 200 "$code" "offene Freigabe ablehnen" + code=$(call GET shares/received) + e2e_expect True "$(jq_py 'not any(s["name"].startswith("'"$BENDECL"'") for s in d["shares"]+d["pending"])')" "received: abgelehnte Freigabe ist weg" + curl -s -u "$BEN" -H 'OCS-APIRequest: true' -H 'Accept: application/json' -G \ + --data-urlencode "path=/$BENDECL" "$NC_OCS/shares" -o "$E2E_TMP/ben-decl.out" + echo "gemessen: bens Liste nach dem Ablehnen: $(python3 -I -c 'import json,sys +print(len(json.load(open(sys.argv[1]))["ocs"]["data"]), "Freigaben")' "$E2E_TMP/ben-decl.out")" + + # Weitergabe und Pfade mit doppeltem Leerzeichen: anna teilt / an ben (mit Weitergabe- + # recht), ben gibt den Unterordner "Sub X" an die Gruppe weiter. Fuer anna (Dateieigentuemerin) + # nennt die Pfadliste die Weitergabe von Ben, der Pfad bleibt woertlich (zwei Leerzeichen). + curl -s -o /dev/null -u "$ANNA" -X MKCOL "$NC_DAV/files/anna/$RESHARE" + curl -s -o /dev/null -u "$ANNA" -X MKCOL "$NC_DAV/files/anna/$RESHARE/Sub%20%20X" + curl -s -o /dev/null -u "$ANNA" -H 'OCS-APIRequest: true' -H 'Accept: application/json' -X POST \ + --data-urlencode "path=/$RESHARE" -d shareType=0 -d shareWith=ben -d permissions=31 "$NC_OCS/shares" + code=$(curl -s -o /dev/null -w '%{http_code}' -u "$BEN" -H 'OCS-APIRequest: true' -H 'Accept: application/json' -X POST \ + --data-urlencode "path=/$RESHARE/Sub X" -d shareType=1 -d shareWith=tessera-team -d permissions=1 "$NC_OCS/shares") + e2e_expect 200 "$code" "ben gibt den Unterordner weiter" + code=$(qget shares/by-path path "/$RESHARE/Sub X") + e2e_expect 200 "$code" "Pfadliste des Unterordners mit zwei Leerzeichen" + e2e_expect True "$(jq_py 'len(d["shares"])==1 and d["shares"][0]["path"]=="/'"$RESHARE"'/Sub X" and d["shares"][0]["ownerId"]=="ben" and d["shares"][0]["kind"]=="group"')" "Weitergabe: Pfad woertlich, Freigebender ben" code=$(call POST "shares/abc/accept") e2e_expect 404 "$code" "annehmen mit ungueltiger Kennung" e2e_contains "$E2E_TMP/body.out" '"code":"shareNotFound"' "accept abc: shareNotFound" diff --git a/apps/api/src/nextcloud-files/nextcloud-files-shares.service.spec.ts b/apps/api/src/nextcloud-files/nextcloud-files-shares.service.spec.ts index 35e153b..c258a75 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files-shares.service.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files-shares.service.spec.ts @@ -297,18 +297,18 @@ describe('NextcloudFilesSharesService — create', () => { }); describe('NextcloudFilesSharesService — Begrenzung neuer Freigaben', () => { - it('15 in 10 Minuten gehen durch, die 16. ist 429 tooManyShares ohne Aufruf, nach 10 Minuten geht es wieder', async () => { + it('10 in 10 Minuten gehen durch, die 11. ist 429 tooManyShares ohne POST, nach 10 Minuten geht es wieder', async () => { const replies: Reply[] = []; - for (let i = 0; i < 15; i++) replies.push(...createReplies()); - // der abgewiesene 16. Versuch verbraucht nur die Vorpruefungen, nie den POST + for (let i = 0; i < 10; i++) replies.push(...createReplies()); + // der abgewiesene 11. Versuch verbraucht nur die Vorpruefungen, nie den POST replies.push(...createReplies().slice(0, 3)); replies.push(...createReplies()); const { service, calls, clock } = setup(replies); - for (let i = 0; i < 15; i++) { + for (let i = 0; i < 10; i++) { await service.create('t1', 'u1', { ...INPUT, shareWith: `ben${i}` }); } const before = calls.length; - const e = await failure(service.create('t1', 'u1', { ...INPUT, shareWith: 'ben15' })); + const e = await failure(service.create('t1', 'u1', { ...INPUT, shareWith: 'ben10' })); expect(codeOf(e)).toBe('tooManyShares'); expect(statusOf(e)).toBe(429); expect(bodyOf(e).retryAfterSeconds).toBe(600); @@ -317,11 +317,11 @@ describe('NextcloudFilesSharesService — Begrenzung neuer Freigaben', () => { clock.t += 10 * 60 * 1000; await expect( - service.create('t1', 'u1', { ...INPUT, shareWith: 'ben15' }), + service.create('t1', 'u1', { ...INPUT, shareWith: 'ben10' }), ).resolves.toBeTruthy(); }); - it('eine durch Vorpruefung abgelehnte Eingabe zaehlt nicht mit', async () => { + it('eine durch Vorpruefung abgelehnte Eingabe verbraucht kein Kontingent fuer angelegte Freigaben', async () => { const replies: Reply[] = []; for (let i = 0; i < 3; i++) { replies.push( @@ -330,13 +330,48 @@ describe('NextcloudFilesSharesService — Begrenzung neuer Freigaben', () => { }).slice(0, 3), ); } - for (let i = 0; i < 15; i++) replies.push(...createReplies()); + for (let i = 0; i < 10; i++) replies.push(...createReplies()); const { service } = setup(replies); for (let i = 0; i < 3; i++) { const e = await failure(service.create('t1', 'u1', { ...INPUT, shareWith: 'x' })); expect(codeOf(e)).toBe('shareAlreadyExists'); } - for (let i = 0; i < 15; i++) await service.create('t1', 'u1', { ...INPUT, shareWith: `b${i}` }); + for (let i = 0; i < 10; i++) await service.create('t1', 'u1', { ...INPUT, shareWith: `b${i}` }); + }); + + it('wiederholte abgelehnte Versuche sind begrenzt: der 41. ist 429 und ruft die Nextcloud nicht mehr an (WR-03)', async () => { + const dup = createReplies({ + list: { status: 200, text: ocs([shareJson({ share_with: 'x' })]) }, + }).slice(0, 3); + const replies: Reply[] = []; + for (let i = 0; i < 40; i++) replies.push(...dup); + const { service, calls, queue, clock } = setup(replies); + for (let i = 0; i < 40; i++) { + const e = await failure(service.create('t1', 'u1', { ...INPUT, shareWith: 'x' })); + expect(codeOf(e)).toBe('shareAlreadyExists'); + } + expect(queue).toHaveLength(0); + const before = calls.length; + const blocked = await failure(service.create('t1', 'u1', { ...INPUT, shareWith: 'x' })); + expect(codeOf(blocked)).toBe('tooManyShares'); + expect(statusOf(blocked)).toBe(429); + expect(calls).toHaveLength(before); + // ein anderer Benutzer ist nicht betroffen + queue.push(...createReplies()); + await expect(service.create('t1', 'u2', INPUT)).resolves.toBeTruthy(); + // nach dem Fenster geht es wieder + clock.t += 10 * 60 * 1000; + queue.push(...createReplies()); + await expect(service.create('t1', 'u1', INPUT)).resolves.toBeTruthy(); + }); + + it('Eingaben, die schon vor der Nextcloud scheitern (kein Pfad, kein Empfaenger), zaehlen nicht als Versuch', async () => { + const { service, guard } = setup([]); + for (let i = 0; i < 60; i++) { + await failure(service.create('t1', 'u1', { ...INPUT, path: '/' })); + await failure(service.create('t1', 'u1', { ...INPUT, shareWith: '' })); + } + expect(guard.heldKeys().shareAttempts).toBe(0); }); }); @@ -512,6 +547,17 @@ describe('NextcloudFilesSharesService — remove, lesen, Richtlinie', () => { expect(second.groupsEnabled).toBe(false); }); + it('Richtlinie: enthaelt das Serverdatum (nicht das des Browsers), bei jedem Aufruf frisch (IN-01)', async () => { + const { service } = setup([ + { status: 200, text: CAPS() }, + { status: 200, text: CAPS() }, + ]); + service.clock = () => new Date(2026, 9, 9, 23, 59); + expect((await service.policy('t1', 'u1')).today).toBe('2026-10-09'); + service.clock = () => new Date(2026, 9, 10, 0, 1); + expect((await service.policy('t1', 'u1')).today).toBe('2026-10-10'); + }); + it('nie 401 oder 403 nach aussen, auch nicht bei 403 aus der Suche', async () => { const { service } = setup([{ status: 403, text: ocs([], 403, 'nope') }]); const e = await failure(service.sharees('t1', 'u1', 'be', 'file')); @@ -774,6 +820,27 @@ describe('NextcloudFilesSharesService — Link aendern', () => { text: LINK_CAPS(pub), }); + it('eine eigene Berechtigung bleibt bei reiner Bezeichnungsaenderung unberuehrt; wird sie gewaehlt, wird sie gesetzt (WR-01)', async () => { + // Lesen plus Anlegen auf einem Ordner (5) ist keine der drei Auswahlen + const custom = { permissions: 5, item_type: 'folder' }; + const label = setup([ + linkWith(custom), + caps(), + { status: 200, text: ocs(linkJson({ ...custom, label: 'Neu' })) }, + ]); + const updated = await label.service.update('t1', 'u1', '31', { label: 'Neu' }); + expect(label.calls[2].body).toBe('{"label":"Neu"}'); + expect(updated.access).toBe('custom'); + + const same = setup([linkWith(custom), caps()]); + await same.service.update('t1', 'u1', '31', {}); + expect(same.calls).toHaveLength(1); + + const view = setup([linkWith(custom), caps(), { status: 200, text: ocs(linkJson()) }]); + await view.service.update('t1', 'u1', '31', { access: 'view' }); + expect(view.calls[2].body).toBe('{"permissions":1}'); + }); + it('sendet nur die geaenderten Felder', async () => { const cases: Array<[Record, string]> = [ [{ password: 'Neu-Pass-2026!x' }, '{"password":"Neu-Pass-2026!x"}'], diff --git a/apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts b/apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts index 3b14668..defeb20 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files-shares.service.ts @@ -11,19 +11,20 @@ import { isRealDate, isShareId, type NcItemType, - type NcShareKind, - type NcShareView, type NcSharee, + type NcShareKind, type NcSharePolicy, + type NcShareView, OCS_CAPABILITIES_MAX_BYTES, type OcsShareOptions, ocsShareRequest, + parseSharees, parseShareList, parseSharePolicy, - parseSharees, permissionsFor, SHAREE_SEGMENTS, SHARES_BASE_SEGMENTS, + serverDate, } from './nextcloud-shares'; import { mapNcFailure } from './nextcloud-upstream'; @@ -91,6 +92,9 @@ export class NextcloudFilesSharesService { private readonly guard: NextcloudLoginGuard, ) {} + /** Uhr fuer das Serverdatum der Regeln; Tests ersetzen sie. */ + clock: () => Date = () => new Date(); + private session(tenantId: string, userId: string): Promise { return this.account.getSession(tenantId, userId); } @@ -196,7 +200,7 @@ export class NextcloudFilesSharesService { { method: 'GET', segments: CAPABILITIES_SEGMENTS, maxBytes: OCS_CAPABILITIES_MAX_BYTES }, 'other', ); - return parseSharePolicy(data); + return parseSharePolicy(data, serverDate(this.clock())); } private async listByPath(tenantId: string, userId: string, session: NcSession, path: string) { @@ -311,6 +315,8 @@ export class NextcloudFilesSharesService { if (segments.length === 0) throw ncErrorDefault('invalidPath'); const isLink = input.kind === 'link'; if (!isLink && !input.shareWith) throw ncErrorDefault('shareRecipientInvalid'); + // Jeder Versuch ruft gleich mehrmals die Nextcloud ab; auch abgelehnte zaehlen hier (WR-03). + this.guard.checkShareAttempt(userId); const session = await this.session(tenantId, userId); // Art und Schreibbarkeit kommen aus der eigenen Abfrage, nie aus dem Browser (T-dkv-01). diff --git a/apps/api/src/nextcloud-files/nextcloud-files.controller.ts b/apps/api/src/nextcloud-files/nextcloud-files.controller.ts index 2a335b2..7ea27cf 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.controller.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.controller.ts @@ -26,16 +26,16 @@ import { PathQueryDto, PreviewQueryDto, } from './dto/nextcloud-files-ops.dto'; +import { + SaveNextcloudFilesSettingsDto, + TestNextcloudFilesSettingsDto, +} from './dto/nextcloud-files-settings.dto'; import { CreateShareDto, ShareByPathQueryDto, ShareeQueryDto, UpdateShareDto, } from './dto/nextcloud-files-shares.dto'; -import { - SaveNextcloudFilesSettingsDto, - TestNextcloudFilesSettingsDto, -} from './dto/nextcloud-files-settings.dto'; import { CompleteUploadDto, DownloadQueryDto, @@ -45,8 +45,8 @@ import { } from './dto/nextcloud-files-transfer.dto'; import { NextcloudFilesService } from './nextcloud-files.service'; import { NextcloudFilesAccountService } from './nextcloud-files-account.service'; -import { NextcloudFilesSharesService } from './nextcloud-files-shares.service'; import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service'; +import { NextcloudFilesSharesService } from './nextcloud-files-shares.service'; import { NextcloudFilesTransferService, type RawUploadRequest, diff --git a/apps/api/src/nextcloud-files/nextcloud-files.module.ts b/apps/api/src/nextcloud-files/nextcloud-files.module.ts index b22a314..2fa1989 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.module.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.module.ts @@ -6,8 +6,8 @@ import { NextcloudFilesController } from './nextcloud-files.controller'; import { seedNextcloudFilesModule } from './nextcloud-files.seed'; import { NextcloudFilesService } from './nextcloud-files.service'; import { NextcloudFilesAccountService } from './nextcloud-files-account.service'; -import { NextcloudFilesSharesService } from './nextcloud-files-shares.service'; import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service'; +import { NextcloudFilesSharesService } from './nextcloud-files-shares.service'; import { NextcloudFilesTransferService } from './nextcloud-files-transfer.service'; import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http'; import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard'; diff --git a/apps/api/src/nextcloud-files/nextcloud-files.service.spec.ts b/apps/api/src/nextcloud-files/nextcloud-files.service.spec.ts index 59d089d..d91285e 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.service.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.service.spec.ts @@ -69,6 +69,16 @@ const done = (res: Writable) => const codeOf = (e: unknown) => (e as { response: { code: string } }).response.code; describe('NextcloudFilesService — list', () => { + it('liefert die Berechtigungsbuchstaben des Ordners selbst (EXTRA-1), null wenn Nextcloud sie nicht nennt', async () => { + expect((await setup().service.list('t1', 'u1', '/')).permissions).toBeNull(); + const withLetters = LIST_XML.replace( + '10', + 'SGDN10', + ); + const { service } = setup({ status: 207, text: withLetters }); + expect((await service.list('t1', 'u1', '/')).permissions).toBe('SGDN'); + }); + it('holt die Sitzung des Aufrufers und liefert Pfad, Eintraege, Speicher', async () => { const { service, account, calls } = setup(); const out = await service.list('t1', 'u1', '/'); diff --git a/apps/api/src/nextcloud-files/nextcloud-files.service.ts b/apps/api/src/nextcloud-files/nextcloud-files.service.ts index 6cd8a59..67bcdbd 100644 --- a/apps/api/src/nextcloud-files/nextcloud-files.service.ts +++ b/apps/api/src/nextcloud-files/nextcloud-files.service.ts @@ -18,6 +18,8 @@ export interface ListingView { entries: NcEntry[]; quota: NcQuota; truncated: boolean; + /** Berechtigungsbuchstaben des Ordners selbst (z. B. `RGDNVCK`); null, wenn Nextcloud sie nicht nennt. */ + permissions: string | null; } /** Vorschaubilder: hoechstens 5 MiB, nur Bilder (D-K). */ @@ -61,8 +63,14 @@ export class NextcloudFilesService { const session = await this.session(tenantId, userId); const result = await dav.list(this.transport, this.gate, session, segments); if (!result.ok || result.listing === null) return this.fail(tenantId, userId, result); - const { entries, quota, truncated } = result.listing; - return { path: pathOf(segments), entries, quota, truncated }; + const { entries, quota, truncated, folderPermissions } = result.listing; + return { + path: pathOf(segments), + entries, + quota, + truncated, + permissions: folderPermissions, + }; } async createFolder(tenantId: string, userId: string, rawPath: string): Promise<{ path: string }> { diff --git a/apps/api/src/nextcloud-files/nextcloud-login-guard.spec.ts b/apps/api/src/nextcloud-files/nextcloud-login-guard.spec.ts index 349ee51..f79c155 100644 --- a/apps/api/src/nextcloud-files/nextcloud-login-guard.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-login-guard.spec.ts @@ -152,33 +152,33 @@ describe('NextcloudLoginGuard — Start der Browser-Anmeldung', () => { }); describe('NextcloudLoginGuard — neue Freigaben (quick-261009-dkv, D-09)', () => { - it('15 in 10 Minuten gehen durch, die 16. ist 429 tooManyShares mit Wartezeit bis zum Fensterende', () => { + it('10 in 10 Minuten gehen durch, die 11. ist 429 tooManyShares mit Wartezeit bis zum Fensterende', () => { const { guard, clock } = makeGuard(); - for (let i = 0; i < 15; i++) { + for (let i = 0; i < 10; i++) { guard.checkShareCreate('u1'); clock.t += 1000; } - // aelteste Freigabe liegt 15 s zurueck -> 600 - 15 = 585 s + // aelteste Freigabe liegt 10 s zurueck -> 600 - 10 = 590 s const blocked = codeOf(() => guard.checkShareCreate('u1')); expect(blocked.status).toBe(429); expect(blocked.body.code).toBe('tooManyShares'); - expect(blocked.body.retryAfterSeconds).toBe(585); + expect(blocked.body.retryAfterSeconds).toBe(590); }); it('Benutzer B ist von Benutzer A unabhaengig', () => { const { guard } = makeGuard(); - for (let i = 0; i < 15; i++) guard.checkShareCreate('a'); + for (let i = 0; i < 10; i++) guard.checkShareCreate('a'); expect(codeOf(() => guard.checkShareCreate('a')).status).toBe(429); expect(codeOf(() => guard.checkShareCreate('b')).status).toBeUndefined(); }); it('nach 10 Minuten darf der Benutzer wieder; eine abgewiesene Freigabe zaehlt nicht mit', () => { const { guard, clock } = makeGuard(); - for (let i = 0; i < 15; i++) guard.checkShareCreate('a'); + for (let i = 0; i < 10; i++) guard.checkShareCreate('a'); expect(codeOf(() => guard.checkShareCreate('a')).status).toBe(429); expect(codeOf(() => guard.checkShareCreate('a')).status).toBe(429); clock.t += 10 * MIN; - for (let i = 0; i < 15; i++) { + for (let i = 0; i < 10; i++) { expect(codeOf(() => guard.checkShareCreate('a')).status).toBeUndefined(); } expect(codeOf(() => guard.checkShareCreate('a')).status).toBe(429); @@ -186,12 +186,64 @@ describe('NextcloudLoginGuard — neue Freigaben (quick-261009-dkv, D-09)', () = it('beruehrt weder Fehlerzaehler noch Flow-Starts', () => { const { guard } = makeGuard(); - for (let i = 0; i < 15; i++) guard.checkShareCreate('u1'); + for (let i = 0; i < 10; i++) guard.checkShareCreate('u1'); expect(codeOf(() => guard.checkPasswordAttempt('u1')).status).toBeUndefined(); expect(codeOf(() => guard.checkFlowStart('u1')).status).toBeUndefined(); }); }); +describe('NextcloudLoginGuard — Versuche, Freigaben anzulegen (WR-03)', () => { + it('40 Versuche in 10 Minuten gehen durch, der 41. ist 429 tooManyShares', () => { + const { guard, clock } = makeGuard(); + for (let i = 0; i < 40; i++) { + guard.checkShareAttempt('u1'); + clock.t += 1000; + } + const blocked = codeOf(() => guard.checkShareAttempt('u1')); + expect(blocked.status).toBe(429); + expect(blocked.body.code).toBe('tooManyShares'); + expect(blocked.body.retryAfterSeconds).toBe(560); + expect(codeOf(() => guard.checkShareAttempt('u2')).status).toBeUndefined(); + clock.t += 10 * MIN; + expect(codeOf(() => guard.checkShareAttempt('u1')).status).toBeUndefined(); + }); + + it('zaehlt getrennt von den angelegten Freigaben', () => { + const { guard } = makeGuard(); + for (let i = 0; i < 40; i++) guard.checkShareAttempt('u1'); + expect(codeOf(() => guard.checkShareCreate('u1')).status).toBeUndefined(); + for (let i = 0; i < 9; i++) guard.checkShareCreate('u2'); + expect(codeOf(() => guard.checkShareAttempt('u2')).status).toBeUndefined(); + }); +}); + +describe('NextcloudLoginGuard — leere Eintraege werden entfernt (WR-03)', () => { + it('Eintraege abgelaufener Benutzer verschwinden aus allen drei Maps', () => { + const { guard, clock } = makeGuard(); + for (let i = 0; i < 50; i++) { + guard.checkFlowStart(`u${i}`); + guard.checkShareAttempt(`u${i}`); + guard.checkShareCreate(`u${i}`); + } + expect(guard.heldKeys()).toEqual({ flowStarts: 50, shareCreates: 50, shareAttempts: 50 }); + clock.t += 11 * MIN; + // Ein Aufruf eines anderen Benutzers genuegt: der Wartungslauf raeumt alle ab. + guard.checkShareCreate('neu'); + expect(guard.heldKeys()).toEqual({ flowStarts: 0, shareCreates: 1, shareAttempts: 0 }); + }); + + it('Eintraege noch laufender Fenster bleiben', () => { + const { guard, clock } = makeGuard(); + guard.checkShareCreate('alt'); + clock.t += 5 * MIN; + guard.checkShareCreate('neu'); + clock.t += 6 * MIN; + guard.checkShareCreate('neu'); + // 'alt' ist ueber 10 Minuten her (weg), 'neu' hat noch einen Eintrag im Fenster. + expect(guard.heldKeys().shareCreates).toBe(1); + }); +}); + describe('LoginFlowStore', () => { function makeStore() { const store = new LoginFlowStore(); diff --git a/apps/api/src/nextcloud-files/nextcloud-login-guard.ts b/apps/api/src/nextcloud-files/nextcloud-login-guard.ts index 614fa4f..3f56cca 100644 --- a/apps/api/src/nextcloud-files/nextcloud-login-guard.ts +++ b/apps/api/src/nextcloud-files/nextcloud-login-guard.ts @@ -38,13 +38,28 @@ export const SERVER_FAILURE_WINDOW_MS = 30 * 60 * 1000; export const FLOW_START_LIMIT = 10; export const FLOW_START_WINDOW_MS = 10 * 60 * 1000; /** - * Neue Freigaben je Benutzer (quick-261009-dkv, D-09): Nextcloud erlaubt 20 in 10 - * Minuten (`UserRateLimit`); deren 429 haette keinen `Retry-After` und legte die - * Aufrufsperre ueber den ganzen Ursprung (alle Benutzer, 15 Minuten). Tessera - * bremst deshalb schon bei 15. + * Neue Freigaben je Benutzer (quick-261009-dkv, D-09): Nextcloud erlaubt 20 in 10 Minuten + * (`UserRateLimit`); deren 429 haette keinen `Retry-After` und legte die Aufrufsperre ueber den + * ganzen Ursprung (alle Benutzer, 15 Minuten). Tessera bremst deshalb schon bei 10. + * + * Ehrlich eingegrenzt: Der Zaehler liegt im Arbeitsspeicher EINES Prozesses. Er gilt also nur, + * solange der Prozess nicht neu startet (danach beginnt er bei null) und nur fuer Freigaben, die + * ueber Tessera entstehen. Freigaben, die der Benutzer direkt in der Nextcloud anlegt, zaehlen + * gegen deren 20, ohne dass Tessera davon weiss. 10 statt 20 laesst deshalb Luft fuer beides. + * Eine harte Zusicherung gibt es nicht; es ist eine Bremse, die den Normalfall (ein Benutzer, + * ein Prozess) sicher unter der Grenze der Nextcloud haelt. */ -export const SHARE_CREATE_LIMIT = 15; +export const SHARE_CREATE_LIMIT = 10; export const SHARE_CREATE_WINDOW_MS = 10 * 60 * 1000; +/** + * Versuche, eine Freigabe anzulegen (auch abgelehnte): jeder Versuch ruft vor dem eigentlichen + * Anlegen die Nextcloud ab (Eintrag, Regeln, vorhandene Freigaben). Ohne diese Grenze koennten + * wiederholte, abgelehnte Anfragen (z. B. "gibt es schon") die Nextcloud beliebig oft belasten. + */ +export const SHARE_ATTEMPT_LIMIT = 40; +export const SHARE_ATTEMPT_WINDOW_MS = 10 * 60 * 1000; +/** Einmal je Minute werden Eintraege entfernt, deren Zeiten alle abgelaufen sind. */ +const SWEEP_INTERVAL_MS = 60 * 1000; /** Ein gezaehlter Versuch; `inFlight` = Nextcloud hat noch nicht geantwortet. */ interface Attempt { @@ -95,6 +110,22 @@ export class NextcloudLoginGuard { private readonly serverFailures = new Map(); private readonly flowStarts = new Map(); private readonly shareCreates = new Map(); + private readonly shareAttempts = new Map(); + private lastSweep = 0; + + /** Entfernt Eintraege ganz abgelaufener Zaehler, damit die Maps nicht mit Benutzern wachsen. */ + private sweep(now: number): void { + if (now - this.lastSweep < SWEEP_INTERVAL_MS) return; + this.lastSweep = now; + const sweepTimes = (map: Map, windowMs: number) => { + for (const [key, times] of map) { + if (pruneTimes(times, now, windowMs).length === 0) map.delete(key); + } + }; + sweepTimes(this.flowStarts, FLOW_START_WINDOW_MS); + sweepTimes(this.shareCreates, SHARE_CREATE_WINDOW_MS); + sweepTimes(this.shareAttempts, SHARE_ATTEMPT_WINDOW_MS); + } /** * Darf dieser Benutzer jetzt eine Passwort-Anmeldung versuchen? Wirft 429 @@ -174,6 +205,7 @@ export class NextcloudLoginGuard { /** Zaehlt einen Start der Browser-Anmeldung; der 11. in 10 Minuten wird abgewiesen. */ checkFlowStart(userId: string): void { const now = this.now(); + this.sweep(now); const starts = pruneTimes(this.flowStarts.get(userId) ?? [], now, FLOW_START_WINDOW_MS); if (starts.length >= FLOW_START_LIMIT) { this.flowStarts.set(userId, starts); @@ -184,12 +216,32 @@ export class NextcloudLoginGuard { } /** - * Zaehlt eine neue Freigabe (15 je Benutzer in 10 Minuten); die 16. wird mit 429 - * `tooManyShares` abgewiesen, OHNE Nextcloud anzusprechen. Der Aufrufer ruft das erst - * unmittelbar vor dem POST auf, nach jeder Vorpruefung — abgelehnte Eingaben zaehlen nicht. + * Zaehlt einen Versuch, eine Freigabe anzulegen (40 je Benutzer in 10 Minuten, auch + * abgelehnte); der 41. wird mit 429 `tooManyShares` abgewiesen, OHNE Nextcloud anzusprechen. + * Der Aufrufer ruft das vor den Abfragen an die Nextcloud auf, damit wiederholte, abgelehnte + * Anfragen sie nicht belasten koennen. + */ + checkShareAttempt(userId: string): void { + const now = this.now(); + this.sweep(now); + const times = pruneTimes(this.shareAttempts.get(userId) ?? [], now, SHARE_ATTEMPT_WINDOW_MS); + if (times.length >= SHARE_ATTEMPT_LIMIT) { + this.shareAttempts.set(userId, times); + throw tooManyShares(times[0] + SHARE_ATTEMPT_WINDOW_MS - now); + } + times.push(now); + this.shareAttempts.set(userId, times); + } + + /** + * Zaehlt eine neue Freigabe (10 je Benutzer in 10 Minuten, Grenzen und Vorbehalt siehe + * `SHARE_CREATE_LIMIT`); die 11. wird mit 429 `tooManyShares` abgewiesen, OHNE Nextcloud + * anzusprechen. Der Aufrufer ruft das erst unmittelbar vor dem POST auf, nach jeder + * Vorpruefung — abgelehnte Eingaben verbrauchen davon nichts (sie zaehlen als Versuch). */ checkShareCreate(userId: string): void { const now = this.now(); + this.sweep(now); const times = pruneTimes(this.shareCreates.get(userId) ?? [], now, SHARE_CREATE_WINDOW_MS); if (times.length >= SHARE_CREATE_LIMIT) { this.shareCreates.set(userId, times); @@ -198,6 +250,15 @@ export class NextcloudLoginGuard { times.push(now); this.shareCreates.set(userId, times); } + + /** Nur fuer Tests: Anzahl der gehaltenen Benutzereintraege (flowStarts, shareCreates, shareAttempts). */ + heldKeys(): { flowStarts: number; shareCreates: number; shareAttempts: number } { + return { + flowStarts: this.flowStarts.size, + shareCreates: this.shareCreates.size, + shareAttempts: this.shareAttempts.size, + }; + } } // --- Browser-Anmeldung (Login Flow v2) ----------------------------------------- diff --git a/apps/api/src/nextcloud-files/nextcloud-propfind.spec.ts b/apps/api/src/nextcloud-files/nextcloud-propfind.spec.ts index 40c6c97..a9cb861 100644 --- a/apps/api/src/nextcloud-files/nextcloud-propfind.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-propfind.spec.ts @@ -181,6 +181,22 @@ describe('parsePropfind', () => { expect(listing.entries[0].etag).toBeNull(); }); + it('Berechtigungsbuchstaben des Ordners selbst (EXTRA-1): eigener und eingehender Nur-Ansehen-Ordner', () => { + const folder = (letters: string | null) => ` + ${PREFIX}/Projekte/ + 10${letters === null ? '' : `${letters}`} + HTTP/1.1 200 OK + ${PREFIX}/Projekte/a.txt + SGHTTP/1.1 200 OK + `; + expect(parsePropfind(folder('RGDNVCK'), CTX).folderPermissions).toBe('RGDNVCK'); + // gemessen: eingehender Nur-Ansehen-Ordner, selbst geoeffnet + expect(parsePropfind(folder('SGDN'), CTX).folderPermissions).toBe('SGDN'); + expect(parsePropfind(folder('SGDN'), CTX).entries[0].permissions).toBe('SG'); + // nennt die Antwort keine Buchstaben, bleibt es unbekannt (nie "alles verboten") + expect(parsePropfind(folder(null), CTX).folderPermissions).toBeNull(); + }); + it('mehr als 5000 Eintraege: die ersten 5000 und truncated', () => { const rows = Array.from( { length: MAX_LIST_ENTRIES + 3 }, @@ -201,6 +217,7 @@ describe('parsePropfind', () => { entries: [], quota: { used: 0, available: null }, truncated: false, + folderPermissions: null, }); // Basis ohne Unterpfad: das Praefix des href darf dann keinen /nc enthalten. const flat = XML.replaceAll('/nc/remote.php', '/remote.php'); diff --git a/apps/api/src/nextcloud-files/nextcloud-propfind.ts b/apps/api/src/nextcloud-files/nextcloud-propfind.ts index 88347d1..7e3f4cd 100644 --- a/apps/api/src/nextcloud-files/nextcloud-propfind.ts +++ b/apps/api/src/nextcloud-files/nextcloud-propfind.ts @@ -61,6 +61,12 @@ export interface ParsedListing { entries: NcEntry[]; quota: NcQuota; truncated: boolean; + /** + * Berechtigungsbuchstaben des angefragten Ordners selbst (C Dateien anlegen, K Ordner anlegen, + * D, N, V ...), oder null, wenn die Antwort sie nicht nennt. Gemessen: eigener Ordner + * `RGDNVCK`, eingehender Nur-Ansehen-Ordner `SGDN` (kein C/K). + */ + folderPermissions: string | null; } export interface ParsePropfindContext { @@ -241,6 +247,7 @@ export function parsePropfind(xml: string, ctx: ParsePropfindContext): ParsedLis const entries: NcEntry[] = []; let quota: NcQuota = { used: 0, available: null }; let truncated = false; + let folderPermissions: string | null = null; for (const response of responses) { if (!isDict(response)) continue; @@ -257,6 +264,7 @@ export function parsePropfind(xml: string, ctx: ParsePropfindContext): ParsedLis used: used !== null && used >= 0 ? used : 0, available: available !== null && available >= 0 ? available : null, }; + folderPermissions = text(props.permissions); continue; } // Nur direkte Kinder des angefragten Ordners (Depth 1). @@ -272,5 +280,5 @@ export function parsePropfind(xml: string, ctx: ParsePropfindContext): ParsedLis entries.push(buildEntry(rel, props)); } - return { entries, quota, truncated }; + return { entries, quota, truncated, folderPermissions }; } diff --git a/apps/api/src/nextcloud-files/nextcloud-shares.spec.ts b/apps/api/src/nextcloud-files/nextcloud-shares.spec.ts index aeb1eb0..a877e73 100644 --- a/apps/api/src/nextcloud-files/nextcloud-shares.spec.ts +++ b/apps/api/src/nextcloud-files/nextcloud-shares.spec.ts @@ -8,14 +8,19 @@ import { isRealDate, OCS_OK_MAX_BYTES, ocsShareRequest, + parseSharePolicy as parsePolicy, parseShare, - parseShareList, - parseSharePolicy, parseSharees, + parseShareList, permissionsFor, SHARES_BASE_SEGMENTS, + serverDate, + verbatimId, } from './nextcloud-shares'; +const TODAY = '2026-10-09'; +const parseSharePolicy = (data: unknown) => parsePolicy(data, TODAY); + const SESSION: NcSession = { baseUrl: 'https://cloud.example/nc', ncUserId: 'anna', @@ -349,6 +354,7 @@ describe('parseShare', () => { shareWithName: 'Zwei Faktor', ownerId: 'anna', ownerName: 'Anna Müller', + fileOwnerName: null, canEdit: true, canDelete: true, expiration: '2026-12-31', @@ -434,18 +440,155 @@ describe('parseShare', () => { }); }); +describe('parseShare — Pfade und Kennungen bleiben woertlich (CR-01)', () => { + it('doppeltes und nachgestelltes Leerzeichen im Pfad bleiben erhalten, Name und Ziel ebenso', () => { + const share = parseShare( + { + ...USER_SHARE, + path: '/Kunden/Müller GmbH ', + file_target: '/Kunden/Müller GmbH ', + }, + 'anna', + ); + expect(share?.path).toBe('/Kunden/Müller GmbH '); + expect(share?.target).toBe('/Kunden/Müller GmbH '); + expect(share?.name).toBe('Müller GmbH '); + }); + + it('eingehend: Ziel und Name kommen woertlich aus dem eigenen Baum', () => { + const share = parseShare( + { ...USER_SHARE, uid_owner: 'ben', path: '/Q/a b', file_target: ' /Ben Ordner' }, + 'anna', + ); + expect(share?.target).toBe(' /Ben Ordner'); + expect(share?.name).toBe('Ben Ordner'); + }); + + it('die Empfaengerkennung (share_with) bleibt woertlich, der Anzeigename wird bereinigt', () => { + const share = parseShare( + { ...USER_SHARE, share_with: 'team eins ', share_with_displayname: 'Team eins ' }, + 'anna', + ); + expect(share?.shareWith).toBe('team eins '); + expect(share?.shareWithName).toBe('Team eins'); + }); + + it('ein Pfad mit Steuerzeichen oder ueber 4096 Zeichen ist unbrauchbar: die Freigabe zaehlt als versteckt', () => { + expect( + parseShare({ ...USER_SHARE, path: '/a\u0007b', file_target: '/a\nb' }, 'anna'), + ).toBeNull(); + const longPath = `/${'a'.repeat(4096)}`; + expect(parseShare({ ...USER_SHARE, path: longPath, file_target: longPath }, 'anna')).toBeNull(); + // ein brauchbarer Rueckfall genuegt + expect(parseShare({ ...USER_SHARE, path: '/a\u0007b' }, 'anna')?.path).toBe('/Projekte'); + expect(parseShare({ ...USER_SHARE, share_with: 'z\u0000oe' }, 'anna')?.shareWith).toBeNull(); + const list = parseShareList( + [USER_SHARE, { ...USER_SHARE, id: '9', path: '\u0001', file_target: '' }], + 'anna', + ); + expect(list.shares).toHaveLength(1); + expect(list.hidden).toBe(1); + }); + + it('verbatimId: unveraendert, ausser Steuerzeichen oder Ueberlaenge', () => { + expect(verbatimId(' a b ', 10)).toBe(' a b '); + expect(verbatimId('a\tb', 10)).toBe(''); + expect(verbatimId('abcdef', 5)).toBe(''); + expect(verbatimId(42, 5)).toBe(''); + }); + + it('Empfaenger der Suche behalten ihre Kennung woertlich', () => { + const sharees = parseSharees({ + users: [ + { + label: 'Zwei Faktor', + value: { shareType: 0, shareWith: 'zoe x ' }, + shareWithDisplayNameUnique: 'zoe', + }, + { label: 'Steuer', value: { shareType: 0, shareWith: 'a\u0001b' } }, + ], + }); + expect(sharees).toEqual([{ kind: 'user', id: 'zoe x ', label: 'Zwei Faktor', detail: 'zoe' }]); + }); +}); + +describe('parseShare — Weitergaben (IN-05, gemessen an Nextcloud 34.0.4)', () => { + /** Ben gibt den Unterordner "Sub X" weiter, den Anna ihm in /rs-test geteilt hat. */ + const RESHARE = { + ...USER_SHARE, + id: '73', + share_type: 1, + uid_owner: 'ben', + displayname_owner: 'Ben Beispiel', + uid_file_owner: 'anna', + displayname_file_owner: 'Anna Müller', + permissions: 1, + item_permissions: 11, + path: '/rs-test/Sub X', + file_target: '/Sub X', + share_with: 'tessera-team', + share_with_displayname: 'tessera-team', + }; + + it('fuer Ben (Freigebender) ist es eine eigene Freigabe mit Pfad in Bens Baum, nicht das Ziel des Empfaengers', () => { + const share = parseShare(RESHARE, 'ben'); + expect(share).toMatchObject({ + kind: 'group', + path: '/rs-test/Sub X', + target: '/rs-test/Sub X', + name: 'Sub X', + ownerId: 'ben', + fileOwnerName: 'Anna Müller', + canEdit: true, + }); + }); + + it('fuer Anna (Dateieigentuemerin) steht die Weitergabe als fremde Freigabe von Ben da', () => { + const share = parseShare(RESHARE, 'anna'); + expect(share).toMatchObject({ + ownerId: 'ben', + ownerName: 'Ben Beispiel', + path: '/rs-test/Sub X', + fileOwnerName: null, + }); + }); + + it('eine eigene Freigabe der eigenen Datei hat keinen fremden Eigentuemer', () => { + expect(parseShare(USER_SHARE, 'anna')?.fileOwnerName).toBeNull(); + }); +}); + +describe('serverDate', () => { + it('Datum nach der Uhr des Servers, mit fuehrenden Nullen', () => { + expect(serverDate(new Date(2026, 0, 5, 23, 59))).toBe('2026-01-05'); + expect(serverDate(new Date(2026, 11, 31, 0, 0))).toBe('2026-12-31'); + }); +}); + describe('accessOf / permissionsFor', () => { it.each([ - [1, 'view'], - [17, 'view'], - [4, 'upload'], - [3, 'edit'], - [15, 'edit'], - [31, 'edit'], - [0, 'custom'], - [16, 'custom'], - ])('accessOf(%i) = %s', (permissions, access) => { - expect(accessOf(permissions)).toBe(access); + [1, 'folder', 'view'], + [17, 'folder', 'view'], + [1, 'file', 'view'], + [4, 'folder', 'upload'], + [20, 'folder', 'upload'], + [15, 'folder', 'edit'], + [31, 'folder', 'edit'], + [3, 'file', 'edit'], + [19, 'file', 'edit'], + // alles, was permissionsFor nicht selbst vergibt, ist eine eigene Berechtigung (IN-03) + [3, 'folder', 'custom'], + [9, 'folder', 'custom'], + [5, 'folder', 'custom'], + [7, 'folder', 'custom'], + [13, 'folder', 'custom'], + [15, 'file', 'custom'], + [7, 'file', 'custom'], + [4, 'file', 'custom'], + [0, 'folder', 'custom'], + [16, 'file', 'custom'], + ] as const)('accessOf(%i, %s) = %s', (permissions, itemType, access) => { + expect(accessOf(permissions, itemType)).toBe(access); }); it('permissionsFor nach Eintragsart', () => { @@ -501,6 +644,7 @@ describe('parseSharees', () => { describe('parseSharePolicy', () => { it('die lebenden Faehigkeiten', () => { expect(parseSharePolicy(CAPS)).toEqual({ + today: TODAY, enabled: true, groupsEnabled: true, links: { diff --git a/apps/api/src/nextcloud-files/nextcloud-shares.ts b/apps/api/src/nextcloud-files/nextcloud-shares.ts index bce24ff..ae17734 100644 --- a/apps/api/src/nextcloud-files/nextcloud-shares.ts +++ b/apps/api/src/nextcloud-files/nextcloud-shares.ts @@ -37,7 +37,10 @@ export type NcItemType = 'file' | 'folder'; export interface NcShareView { id: string; kind: NcShareKind; - /** Pfad im Bereich des Eigentuemers (bei eigenen Freigaben im eigenen Konto). */ + /** + * Pfad im Baum des Aufrufers, woertlich wie von der Nextcloud geliefert (nie bereinigt: er geht + * zurueck an die Nextcloud, ein zusammengefasstes Leerzeichen waere ein anderer Eintrag). + */ path: string; name: string; itemType: NcItemType; @@ -49,8 +52,14 @@ export interface NcShareView { /** Kennung von Person/Gruppe; bei Links null. */ shareWith: string | null; shareWithName: string | null; + /** + * Wer die Freigabe erstellt hat (`uid_owner` der Nextcloud heisst dort "Freigebender", nicht + * "Eigentuemer der Datei"; gemessen). Bei eigenen Freigaben ist das der Benutzer selbst. + */ ownerId: string | null; ownerName: string | null; + /** Anzeigename des Dateieigentuemers, nur wenn er ein anderer ist (Weitergabe, siehe `parseShare`). */ + fileOwnerName: string | null; canEdit: boolean; canDelete: boolean; /** `YYYY-MM-DD` oder null. */ @@ -59,7 +68,7 @@ export interface NcShareView { /** Nur bei eigenen Links, nur http/https. */ url: string | null; hasPassword: boolean; - /** Pfad im eigenen Baum des Empfaengers (bei eigenen Freigaben der eigene Pfad). */ + /** Pfad im eigenen Baum des Aufrufers (bei eigenen Freigaben und Weitergaben gleich `path`), woertlich. */ target: string; sharedAt: string | null; /** Noch nicht angenommen (nur eingehende). */ @@ -74,6 +83,8 @@ export interface NcSharee { } export interface NcSharePolicy { + /** Heutiges Datum (`YYYY-MM-DD`) nach der Uhr des Tessera-Servers: Grundlage fuer Ablaufgrenzen. */ + today: string; /** Freigabe-Schnittstelle der Nextcloud an. */ enabled: boolean; groupsEnabled: boolean; @@ -108,6 +119,7 @@ const SHARE_TIMEOUT_MS = 15_000; const MESSAGE_MAX = 300; const DISPLAY_MAX = 255; const URL_MAX = 2048; +const PATH_MAX = 4096; const SHARE_ID_RE = /^\d{1,20}$/; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; @@ -133,6 +145,18 @@ export function cleanText(value: unknown, max: number): string { ); } +/** + * Pfade und Kennungen (Freigabe-Pfad, Ziel, Empfaenger) gehen unveraendert an die Nextcloud + * zurueck. Sie werden darum NIE zusammengefasst oder gekuerzt wie Anzeigetexte: ein doppeltes + * oder nachgestelltes Leerzeichen gehoert zum Namen. Enthalten sie Steuerzeichen oder sind sie + * zu lang, gelten sie als unbrauchbar (leere Zeichenkette). + */ +export function verbatimId(value: unknown, max: number): string { + if (typeof value !== 'string' || value.length > max) return ''; + // biome-ignore lint/suspicious/noControlCharactersInRegex: Steuerzeichen sind hier gerade der Pruefstoff + return /[\u0000-\u001f\u007f]/.test(value) ? '' : value; +} + function intOf(value: unknown): number | null { if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value); if (typeof value === 'string' && /^-?\d{1,15}$/.test(value.trim())) return Number(value.trim()); @@ -238,12 +262,20 @@ export function permissionsFor( return itemType === 'folder' ? CREATE : null; } -/** Umkehrung: aus der Maske (Bit 16 wird ignoriert) die einfache Auswahl oder `custom`. */ -export function accessOf(permissions: number): NcShareAccess { +/** + * Umkehrung: aus der Maske (Bit 16 wird ignoriert) die einfache Auswahl oder `custom`. Nur die + * Masken, die `permissionsFor` selbst vergibt, gelten als Auswahl: Ansehen 1; Bearbeiten 15 + * (Ordner) bzw. 3 (Datei); Nur hochladen 4 (nur Ordner). Alles andere (z. B. Lesen plus Loeschen + * oder Lesen plus Anlegen) ist eine eigene Berechtigung und wird so angezeigt, nie als + * Bearbeiten ausgegeben. + */ +export function accessOf(permissions: number, itemType: NcItemType): NcShareAccess { const mask = permissions & 15; if (mask === READ) return 'view'; - if (mask === CREATE) return 'upload'; - if ((mask & READ) !== 0 && (mask & (UPDATE | CREATE | DELETE)) !== 0) return 'edit'; + if (itemType === 'folder' && mask === CREATE) return 'upload'; + if (mask === (itemType === 'folder' ? READ | UPDATE | CREATE | DELETE : READ | UPDATE)) { + return 'edit'; + } return 'custom'; } @@ -260,6 +292,12 @@ export function isRealDate(value: string): boolean { return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value; } +/** Datum `YYYY-MM-DD` nach der Uhr dieses Servers (nicht des Browsers): Grundlage der Ablaufgrenzen. */ +export function serverDate(now: Date = new Date()): string { + const pad = (n: number, width = 2) => String(n).padStart(width, '0'); + return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; +} + function publicUrl(value: unknown): string | null { if (typeof value !== 'string' || value.length === 0 || value.length > URL_MAX) return null; try { @@ -270,7 +308,15 @@ function publicUrl(value: unknown): string | null { } } -/** `null` fuer nicht unterstuetzte Arten (E-Mail, Server, Talk ...) und fehlerhafte Eintraege. */ +/** + * `null` fuer nicht unterstuetzte Arten (E-Mail, Server, Talk ...) und fehlerhafte Eintraege. + * + * Eingehend/eigen (gemessen, Nextcloud 34): `uid_owner` ist der FREIGEBENDE, `uid_file_owner` + * der Eigentuemer der Datei. Eine Weitergabe (Ben gibt einen Ordner weiter, den Anna ihm + * geteilt hat) hat `uid_owner = ben`, `uid_file_owner = anna`; `path` ist immer der Pfad im + * Baum des Aufrufers, `file_target` der im Baum des Empfaengers. Weitergaben gelten daher als + * eigene Freigaben mit eigenem Pfad (`target` = `path`), nicht als eingehende. + */ export function parseShare(raw: unknown, selfId: string): NcShareView | null { if (!isDict(raw)) return null; const type = intOf(raw.share_type); @@ -282,11 +328,15 @@ export function parseShare(raw: unknown, selfId: string): NcShareView | null { const ownerId = cleanText(raw.uid_owner, DISPLAY_MAX) || null; const received = ownerId !== null && ownerId !== selfId; - const target = cleanText(raw.file_target, 4096); - const path = cleanText(raw.path, 4096) || target; + const rawTarget = verbatimId(raw.file_target, PATH_MAX); + const rawPath = verbatimId(raw.path, PATH_MAX); + // Ohne brauchbaren Pfad laesst sich nichts damit tun: wie eine unbekannte Art nur gezaehlt. + if (rawPath === '' && rawTarget === '') return null; + const path = rawPath || rawTarget; + const target = received ? rawTarget || rawPath : path; const itemType: NcItemType = raw.item_type === 'folder' || raw.mimetype === 'httpd/unix-directory' ? 'folder' : 'file'; - const name = lastSegment(received ? target || path : path || target); + const name = lastSegment(received ? target : path); const permissions = intOf(raw.permissions) ?? 0; const itemPermissions = intOf(raw.item_permissions) ?? permissions; const expirationRaw = typeof raw.expiration === 'string' ? raw.expiration.slice(0, 10) : ''; @@ -296,6 +346,9 @@ export function parseShare(raw: unknown, selfId: string): NcShareView | null { ? new Date(stime * 1000).toISOString() : null; const mimeRaw = cleanText(raw.mimetype, DISPLAY_MAX); + const fileOwnerId = cleanText(raw.uid_file_owner, DISPLAY_MAX); + const reshare = !received && fileOwnerId !== '' && fileOwnerId !== selfId; + const shareWith = kind === 'link' ? '' : verbatimId(raw.share_with, DISPLAY_MAX); return { id, @@ -306,12 +359,15 @@ export function parseShare(raw: unknown, selfId: string): NcShareView | null { mime: itemType === 'file' && mimeRaw !== '' ? mimeRaw : null, itemWritable: (itemPermissions & (UPDATE | CREATE)) !== 0, permissions, - access: accessOf(permissions), - shareWith: kind === 'link' ? null : cleanText(raw.share_with, DISPLAY_MAX) || null, + access: accessOf(permissions, itemType), + shareWith: shareWith === '' ? null : shareWith, shareWithName: kind === 'link' ? null : cleanText(raw.share_with_displayname, DISPLAY_MAX) || null, ownerId, ownerName: cleanText(raw.displayname_owner, DISPLAY_MAX) || null, + fileOwnerName: reshare + ? cleanText(raw.displayname_file_owner, DISPLAY_MAX) || fileOwnerId + : null, canEdit: raw.can_edit === true, canDelete: raw.can_delete === true, expiration: isRealDate(expirationRaw) ? expirationRaw : null, @@ -356,7 +412,8 @@ function shareeList(value: unknown, wanted: 0 | 1): NcSharee[] { for (const item of value) { if (!isDict(item) || !isDict(item.value)) continue; if (intOf(item.value.shareType) !== wanted) continue; - const id = cleanText(item.value.shareWith, DISPLAY_MAX); + // Die Kennung geht unveraendert zurueck an die Nextcloud (Anlegen): nie bereinigen. + const id = verbatimId(item.value.shareWith, DISPLAY_MAX); if (id === '') continue; const label = cleanText(item.label, DISPLAY_MAX) || id; const unique = cleanText(item.shareWithDisplayNameUnique, DISPLAY_MAX); @@ -398,11 +455,12 @@ function days(value: unknown): number | null { } /** - * Freigaberegeln aus `cloud/capabilities` (Antwort-`data` oder dessen `capabilities`). + * Freigaberegeln aus `cloud/capabilities` (Antwort-`data` oder dessen `capabilities`); `today` + * ist das Serverdatum (`serverDate`) und wird unveraendert in die Regeln uebernommen. * Fehlende Schluessel heissen "keine Regel", kein Fehler: ohne `expire_date.enabled` gibt * es keine Tage; sind Links aus, steht unter `public` nur `enabled: false`. */ -export function parseSharePolicy(data: unknown): NcSharePolicy { +export function parseSharePolicy(data: unknown, today: string): NcSharePolicy { const caps = isDict(data) && isDict(data.capabilities) ? data.capabilities : data; const files = isDict(caps) && isDict(caps.files_sharing) ? caps.files_sharing : null; const pwPolicy = isDict(caps) && isDict(caps.password_policy) ? caps.password_policy : null; @@ -410,6 +468,7 @@ export function parseSharePolicy(data: unknown): NcSharePolicy { const passwordMinLength = minLen !== null && minLen >= 1 && minLen <= 256 ? minLen : null; const off: NcSharePolicy = { + today, enabled: false, groupsEnabled: false, links: { @@ -444,6 +503,7 @@ export function parseSharePolicy(data: unknown): NcSharePolicy { const internal = expiry(pub.expire_date_internal); return { + today, enabled: true, groupsEnabled: files.group_sharing !== false, links: linksEnabled