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) <noreply@anthropic.com>
This commit is contained in:
+45
-2
@@ -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 /<RESHARE> 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"
|
||||
|
||||
@@ -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, unknown>, string]> = [
|
||||
[{ password: 'Neu-Pass-2026!x' }, '{"password":"Neu-Pass-2026!x"}'],
|
||||
|
||||
@@ -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<NcSession> {
|
||||
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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
'<d:quota-used-bytes>10</d:quota-used-bytes>',
|
||||
'<oc:permissions>SGDN</oc:permissions><d:quota-used-bytes>10</d:quota-used-bytes>',
|
||||
);
|
||||
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', '/');
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string, Attempt[]>();
|
||||
private readonly flowStarts = new Map<string, number[]>();
|
||||
private readonly shareCreates = new Map<string, number[]>();
|
||||
private readonly shareAttempts = new Map<string, number[]>();
|
||||
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<string, number[]>, 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) -----------------------------------------
|
||||
|
||||
@@ -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) => `<?xml version="1.0"?><d:multistatus ${NS}>
|
||||
<d:response><d:href>${PREFIX}/Projekte/</d:href><d:propstat><d:prop>
|
||||
<d:quota-used-bytes>10</d:quota-used-bytes>${letters === null ? '' : `<oc:permissions>${letters}</oc:permissions>`}
|
||||
<d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||
<d:response><d:href>${PREFIX}/Projekte/a.txt</d:href><d:propstat><d:prop>
|
||||
<oc:permissions>SG</oc:permissions><d:resourcetype/></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||
</d:multistatus>`;
|
||||
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');
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user