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
|
# 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
|
# 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
|
# 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.
|
# Einheitstests. Alles, was das Skript anfasst, wird im trap zurueckgesetzt.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
@@ -28,6 +28,8 @@ EPOCH=$(date +%s)
|
|||||||
FIX="Tessera-Teilen-$EPOCH"
|
FIX="Tessera-Teilen-$EPOCH"
|
||||||
BENFIX="Ben-Ordner-$EPOCH"
|
BENFIX="Ben-Ordner-$EPOCH"
|
||||||
BENBOX="Ben-Briefkasten-$EPOCH"
|
BENBOX="Ben-Briefkasten-$EPOCH"
|
||||||
|
BENDECL="Ben-Ablehnen-$EPOCH"
|
||||||
|
RESHARE="Weitergabe-$EPOCH"
|
||||||
START_ISO=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
START_ISO=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||||
REPO="$(cd "$HERE/../../../.." && pwd)"
|
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 "$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/$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/$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
|
# Papierkorb leeren, damit abgelehnte oder verlassene Ordner nicht liegen bleiben
|
||||||
NC_OCC trashbin:cleanup ben >/dev/null 2>&1
|
NC_OCC trashbin:cleanup ben >/dev/null 2>&1
|
||||||
NC_OCC config:system:delete ratelimit.protection.enabled >/dev/null 2>&1
|
NC_OCC config:system:delete ratelimit.protection.enabled >/dev/null 2>&1
|
||||||
@@ -392,7 +396,7 @@ section_links() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
section_received() {
|
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)
|
# Eingehend und angenommen (Vorgabe der Nextcloud: sofort angenommen)
|
||||||
NC_OCC user:setting --delete anna files_sharing default_accept >/dev/null 2>&1 || true
|
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"
|
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
|
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)
|
code=$(call GET shares/mine)
|
||||||
e2e_expect 200 "$code" "Eigene Freigaben"
|
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"
|
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")
|
code=$(call DELETE "shares/$pid")
|
||||||
e2e_expect 200 "$code" "angenommene Freigabe verlassen"
|
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")
|
code=$(call POST "shares/abc/accept")
|
||||||
e2e_expect 404 "$code" "annehmen mit ungueltiger Kennung"
|
e2e_expect 404 "$code" "annehmen mit ungueltiger Kennung"
|
||||||
e2e_contains "$E2E_TMP/body.out" '"code":"shareNotFound"' "accept abc: shareNotFound"
|
e2e_contains "$E2E_TMP/body.out" '"code":"shareNotFound"' "accept abc: shareNotFound"
|
||||||
|
|||||||
@@ -297,18 +297,18 @@ describe('NextcloudFilesSharesService — create', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('NextcloudFilesSharesService — Begrenzung neuer Freigaben', () => {
|
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[] = [];
|
const replies: Reply[] = [];
|
||||||
for (let i = 0; i < 15; i++) replies.push(...createReplies());
|
for (let i = 0; i < 10; i++) replies.push(...createReplies());
|
||||||
// der abgewiesene 16. Versuch verbraucht nur die Vorpruefungen, nie den POST
|
// der abgewiesene 11. Versuch verbraucht nur die Vorpruefungen, nie den POST
|
||||||
replies.push(...createReplies().slice(0, 3));
|
replies.push(...createReplies().slice(0, 3));
|
||||||
replies.push(...createReplies());
|
replies.push(...createReplies());
|
||||||
const { service, calls, clock } = setup(replies);
|
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}` });
|
await service.create('t1', 'u1', { ...INPUT, shareWith: `ben${i}` });
|
||||||
}
|
}
|
||||||
const before = calls.length;
|
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(codeOf(e)).toBe('tooManyShares');
|
||||||
expect(statusOf(e)).toBe(429);
|
expect(statusOf(e)).toBe(429);
|
||||||
expect(bodyOf(e).retryAfterSeconds).toBe(600);
|
expect(bodyOf(e).retryAfterSeconds).toBe(600);
|
||||||
@@ -317,11 +317,11 @@ describe('NextcloudFilesSharesService — Begrenzung neuer Freigaben', () => {
|
|||||||
|
|
||||||
clock.t += 10 * 60 * 1000;
|
clock.t += 10 * 60 * 1000;
|
||||||
await expect(
|
await expect(
|
||||||
service.create('t1', 'u1', { ...INPUT, shareWith: 'ben15' }),
|
service.create('t1', 'u1', { ...INPUT, shareWith: 'ben10' }),
|
||||||
).resolves.toBeTruthy();
|
).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[] = [];
|
const replies: Reply[] = [];
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
replies.push(
|
replies.push(
|
||||||
@@ -330,13 +330,48 @@ describe('NextcloudFilesSharesService — Begrenzung neuer Freigaben', () => {
|
|||||||
}).slice(0, 3),
|
}).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);
|
const { service } = setup(replies);
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
const e = await failure(service.create('t1', 'u1', { ...INPUT, shareWith: 'x' }));
|
const e = await failure(service.create('t1', 'u1', { ...INPUT, shareWith: 'x' }));
|
||||||
expect(codeOf(e)).toBe('shareAlreadyExists');
|
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);
|
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 () => {
|
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 { service } = setup([{ status: 403, text: ocs([], 403, 'nope') }]);
|
||||||
const e = await failure(service.sharees('t1', 'u1', 'be', 'file'));
|
const e = await failure(service.sharees('t1', 'u1', 'be', 'file'));
|
||||||
@@ -774,6 +820,27 @@ describe('NextcloudFilesSharesService — Link aendern', () => {
|
|||||||
text: LINK_CAPS(pub),
|
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 () => {
|
it('sendet nur die geaenderten Felder', async () => {
|
||||||
const cases: Array<[Record<string, unknown>, string]> = [
|
const cases: Array<[Record<string, unknown>, string]> = [
|
||||||
[{ password: 'Neu-Pass-2026!x' }, '{"password":"Neu-Pass-2026!x"}'],
|
[{ password: 'Neu-Pass-2026!x' }, '{"password":"Neu-Pass-2026!x"}'],
|
||||||
|
|||||||
@@ -11,19 +11,20 @@ import {
|
|||||||
isRealDate,
|
isRealDate,
|
||||||
isShareId,
|
isShareId,
|
||||||
type NcItemType,
|
type NcItemType,
|
||||||
type NcShareKind,
|
|
||||||
type NcShareView,
|
|
||||||
type NcSharee,
|
type NcSharee,
|
||||||
|
type NcShareKind,
|
||||||
type NcSharePolicy,
|
type NcSharePolicy,
|
||||||
|
type NcShareView,
|
||||||
OCS_CAPABILITIES_MAX_BYTES,
|
OCS_CAPABILITIES_MAX_BYTES,
|
||||||
type OcsShareOptions,
|
type OcsShareOptions,
|
||||||
ocsShareRequest,
|
ocsShareRequest,
|
||||||
|
parseSharees,
|
||||||
parseShareList,
|
parseShareList,
|
||||||
parseSharePolicy,
|
parseSharePolicy,
|
||||||
parseSharees,
|
|
||||||
permissionsFor,
|
permissionsFor,
|
||||||
SHAREE_SEGMENTS,
|
SHAREE_SEGMENTS,
|
||||||
SHARES_BASE_SEGMENTS,
|
SHARES_BASE_SEGMENTS,
|
||||||
|
serverDate,
|
||||||
} from './nextcloud-shares';
|
} from './nextcloud-shares';
|
||||||
import { mapNcFailure } from './nextcloud-upstream';
|
import { mapNcFailure } from './nextcloud-upstream';
|
||||||
|
|
||||||
@@ -91,6 +92,9 @@ export class NextcloudFilesSharesService {
|
|||||||
private readonly guard: NextcloudLoginGuard,
|
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> {
|
private session(tenantId: string, userId: string): Promise<NcSession> {
|
||||||
return this.account.getSession(tenantId, userId);
|
return this.account.getSession(tenantId, userId);
|
||||||
}
|
}
|
||||||
@@ -196,7 +200,7 @@ export class NextcloudFilesSharesService {
|
|||||||
{ method: 'GET', segments: CAPABILITIES_SEGMENTS, maxBytes: OCS_CAPABILITIES_MAX_BYTES },
|
{ method: 'GET', segments: CAPABILITIES_SEGMENTS, maxBytes: OCS_CAPABILITIES_MAX_BYTES },
|
||||||
'other',
|
'other',
|
||||||
);
|
);
|
||||||
return parseSharePolicy(data);
|
return parseSharePolicy(data, serverDate(this.clock()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async listByPath(tenantId: string, userId: string, session: NcSession, path: string) {
|
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');
|
if (segments.length === 0) throw ncErrorDefault('invalidPath');
|
||||||
const isLink = input.kind === 'link';
|
const isLink = input.kind === 'link';
|
||||||
if (!isLink && !input.shareWith) throw ncErrorDefault('shareRecipientInvalid');
|
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);
|
const session = await this.session(tenantId, userId);
|
||||||
|
|
||||||
// Art und Schreibbarkeit kommen aus der eigenen Abfrage, nie aus dem Browser (T-dkv-01).
|
// Art und Schreibbarkeit kommen aus der eigenen Abfrage, nie aus dem Browser (T-dkv-01).
|
||||||
|
|||||||
@@ -26,16 +26,16 @@ import {
|
|||||||
PathQueryDto,
|
PathQueryDto,
|
||||||
PreviewQueryDto,
|
PreviewQueryDto,
|
||||||
} from './dto/nextcloud-files-ops.dto';
|
} from './dto/nextcloud-files-ops.dto';
|
||||||
|
import {
|
||||||
|
SaveNextcloudFilesSettingsDto,
|
||||||
|
TestNextcloudFilesSettingsDto,
|
||||||
|
} from './dto/nextcloud-files-settings.dto';
|
||||||
import {
|
import {
|
||||||
CreateShareDto,
|
CreateShareDto,
|
||||||
ShareByPathQueryDto,
|
ShareByPathQueryDto,
|
||||||
ShareeQueryDto,
|
ShareeQueryDto,
|
||||||
UpdateShareDto,
|
UpdateShareDto,
|
||||||
} from './dto/nextcloud-files-shares.dto';
|
} from './dto/nextcloud-files-shares.dto';
|
||||||
import {
|
|
||||||
SaveNextcloudFilesSettingsDto,
|
|
||||||
TestNextcloudFilesSettingsDto,
|
|
||||||
} from './dto/nextcloud-files-settings.dto';
|
|
||||||
import {
|
import {
|
||||||
CompleteUploadDto,
|
CompleteUploadDto,
|
||||||
DownloadQueryDto,
|
DownloadQueryDto,
|
||||||
@@ -45,8 +45,8 @@ import {
|
|||||||
} from './dto/nextcloud-files-transfer.dto';
|
} from './dto/nextcloud-files-transfer.dto';
|
||||||
import { NextcloudFilesService } from './nextcloud-files.service';
|
import { NextcloudFilesService } from './nextcloud-files.service';
|
||||||
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
||||||
import { NextcloudFilesSharesService } from './nextcloud-files-shares.service';
|
|
||||||
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
|
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
|
||||||
|
import { NextcloudFilesSharesService } from './nextcloud-files-shares.service';
|
||||||
import {
|
import {
|
||||||
NextcloudFilesTransferService,
|
NextcloudFilesTransferService,
|
||||||
type RawUploadRequest,
|
type RawUploadRequest,
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { NextcloudFilesController } from './nextcloud-files.controller';
|
|||||||
import { seedNextcloudFilesModule } from './nextcloud-files.seed';
|
import { seedNextcloudFilesModule } from './nextcloud-files.seed';
|
||||||
import { NextcloudFilesService } from './nextcloud-files.service';
|
import { NextcloudFilesService } from './nextcloud-files.service';
|
||||||
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
|
||||||
import { NextcloudFilesSharesService } from './nextcloud-files-shares.service';
|
|
||||||
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
|
import { NextcloudFilesSettingsService } from './nextcloud-files-settings.service';
|
||||||
|
import { NextcloudFilesSharesService } from './nextcloud-files-shares.service';
|
||||||
import { NextcloudFilesTransferService } from './nextcloud-files-transfer.service';
|
import { NextcloudFilesTransferService } from './nextcloud-files-transfer.service';
|
||||||
import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http';
|
import { NEXTCLOUD_TRANSPORT, undiciTransport } from './nextcloud-http';
|
||||||
import { LoginFlowStore, NextcloudLoginGuard } from './nextcloud-login-guard';
|
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;
|
const codeOf = (e: unknown) => (e as { response: { code: string } }).response.code;
|
||||||
|
|
||||||
describe('NextcloudFilesService — list', () => {
|
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 () => {
|
it('holt die Sitzung des Aufrufers und liefert Pfad, Eintraege, Speicher', async () => {
|
||||||
const { service, account, calls } = setup();
|
const { service, account, calls } = setup();
|
||||||
const out = await service.list('t1', 'u1', '/');
|
const out = await service.list('t1', 'u1', '/');
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface ListingView {
|
|||||||
entries: NcEntry[];
|
entries: NcEntry[];
|
||||||
quota: NcQuota;
|
quota: NcQuota;
|
||||||
truncated: boolean;
|
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). */
|
/** Vorschaubilder: hoechstens 5 MiB, nur Bilder (D-K). */
|
||||||
@@ -61,8 +63,14 @@ export class NextcloudFilesService {
|
|||||||
const session = await this.session(tenantId, userId);
|
const session = await this.session(tenantId, userId);
|
||||||
const result = await dav.list(this.transport, this.gate, session, segments);
|
const result = await dav.list(this.transport, this.gate, session, segments);
|
||||||
if (!result.ok || result.listing === null) return this.fail(tenantId, userId, result);
|
if (!result.ok || result.listing === null) return this.fail(tenantId, userId, result);
|
||||||
const { entries, quota, truncated } = result.listing;
|
const { entries, quota, truncated, folderPermissions } = result.listing;
|
||||||
return { path: pathOf(segments), entries, quota, truncated };
|
return {
|
||||||
|
path: pathOf(segments),
|
||||||
|
entries,
|
||||||
|
quota,
|
||||||
|
truncated,
|
||||||
|
permissions: folderPermissions,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async createFolder(tenantId: string, userId: string, rawPath: string): Promise<{ path: string }> {
|
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)', () => {
|
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();
|
const { guard, clock } = makeGuard();
|
||||||
for (let i = 0; i < 15; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
guard.checkShareCreate('u1');
|
guard.checkShareCreate('u1');
|
||||||
clock.t += 1000;
|
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'));
|
const blocked = codeOf(() => guard.checkShareCreate('u1'));
|
||||||
expect(blocked.status).toBe(429);
|
expect(blocked.status).toBe(429);
|
||||||
expect(blocked.body.code).toBe('tooManyShares');
|
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', () => {
|
it('Benutzer B ist von Benutzer A unabhaengig', () => {
|
||||||
const { guard } = makeGuard();
|
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('a')).status).toBe(429);
|
||||||
expect(codeOf(() => guard.checkShareCreate('b')).status).toBeUndefined();
|
expect(codeOf(() => guard.checkShareCreate('b')).status).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('nach 10 Minuten darf der Benutzer wieder; eine abgewiesene Freigabe zaehlt nicht mit', () => {
|
it('nach 10 Minuten darf der Benutzer wieder; eine abgewiesene Freigabe zaehlt nicht mit', () => {
|
||||||
const { guard, clock } = makeGuard();
|
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);
|
||||||
expect(codeOf(() => guard.checkShareCreate('a')).status).toBe(429);
|
expect(codeOf(() => guard.checkShareCreate('a')).status).toBe(429);
|
||||||
clock.t += 10 * MIN;
|
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).toBeUndefined();
|
||||||
}
|
}
|
||||||
expect(codeOf(() => guard.checkShareCreate('a')).status).toBe(429);
|
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', () => {
|
it('beruehrt weder Fehlerzaehler noch Flow-Starts', () => {
|
||||||
const { guard } = makeGuard();
|
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.checkPasswordAttempt('u1')).status).toBeUndefined();
|
||||||
expect(codeOf(() => guard.checkFlowStart('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', () => {
|
describe('LoginFlowStore', () => {
|
||||||
function makeStore() {
|
function makeStore() {
|
||||||
const store = new LoginFlowStore();
|
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_LIMIT = 10;
|
||||||
export const FLOW_START_WINDOW_MS = 10 * 60 * 1000;
|
export const FLOW_START_WINDOW_MS = 10 * 60 * 1000;
|
||||||
/**
|
/**
|
||||||
* Neue Freigaben je Benutzer (quick-261009-dkv, D-09): Nextcloud erlaubt 20 in 10
|
* Neue Freigaben je Benutzer (quick-261009-dkv, D-09): Nextcloud erlaubt 20 in 10 Minuten
|
||||||
* Minuten (`UserRateLimit`); deren 429 haette keinen `Retry-After` und legte die
|
* (`UserRateLimit`); deren 429 haette keinen `Retry-After` und legte die Aufrufsperre ueber den
|
||||||
* Aufrufsperre ueber den ganzen Ursprung (alle Benutzer, 15 Minuten). Tessera
|
* ganzen Ursprung (alle Benutzer, 15 Minuten). Tessera bremst deshalb schon bei 10.
|
||||||
* bremst deshalb schon bei 15.
|
*
|
||||||
|
* 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;
|
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. */
|
/** Ein gezaehlter Versuch; `inFlight` = Nextcloud hat noch nicht geantwortet. */
|
||||||
interface Attempt {
|
interface Attempt {
|
||||||
@@ -95,6 +110,22 @@ export class NextcloudLoginGuard {
|
|||||||
private readonly serverFailures = new Map<string, Attempt[]>();
|
private readonly serverFailures = new Map<string, Attempt[]>();
|
||||||
private readonly flowStarts = new Map<string, number[]>();
|
private readonly flowStarts = new Map<string, number[]>();
|
||||||
private readonly shareCreates = 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
|
* 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. */
|
/** Zaehlt einen Start der Browser-Anmeldung; der 11. in 10 Minuten wird abgewiesen. */
|
||||||
checkFlowStart(userId: string): void {
|
checkFlowStart(userId: string): void {
|
||||||
const now = this.now();
|
const now = this.now();
|
||||||
|
this.sweep(now);
|
||||||
const starts = pruneTimes(this.flowStarts.get(userId) ?? [], now, FLOW_START_WINDOW_MS);
|
const starts = pruneTimes(this.flowStarts.get(userId) ?? [], now, FLOW_START_WINDOW_MS);
|
||||||
if (starts.length >= FLOW_START_LIMIT) {
|
if (starts.length >= FLOW_START_LIMIT) {
|
||||||
this.flowStarts.set(userId, starts);
|
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
|
* Zaehlt einen Versuch, eine Freigabe anzulegen (40 je Benutzer in 10 Minuten, auch
|
||||||
* `tooManyShares` abgewiesen, OHNE Nextcloud anzusprechen. Der Aufrufer ruft das erst
|
* abgelehnte); der 41. wird mit 429 `tooManyShares` abgewiesen, OHNE Nextcloud anzusprechen.
|
||||||
* unmittelbar vor dem POST auf, nach jeder Vorpruefung — abgelehnte Eingaben zaehlen nicht.
|
* 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 {
|
checkShareCreate(userId: string): void {
|
||||||
const now = this.now();
|
const now = this.now();
|
||||||
|
this.sweep(now);
|
||||||
const times = pruneTimes(this.shareCreates.get(userId) ?? [], now, SHARE_CREATE_WINDOW_MS);
|
const times = pruneTimes(this.shareCreates.get(userId) ?? [], now, SHARE_CREATE_WINDOW_MS);
|
||||||
if (times.length >= SHARE_CREATE_LIMIT) {
|
if (times.length >= SHARE_CREATE_LIMIT) {
|
||||||
this.shareCreates.set(userId, times);
|
this.shareCreates.set(userId, times);
|
||||||
@@ -198,6 +250,15 @@ export class NextcloudLoginGuard {
|
|||||||
times.push(now);
|
times.push(now);
|
||||||
this.shareCreates.set(userId, times);
|
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) -----------------------------------------
|
// --- Browser-Anmeldung (Login Flow v2) -----------------------------------------
|
||||||
|
|||||||
@@ -181,6 +181,22 @@ describe('parsePropfind', () => {
|
|||||||
expect(listing.entries[0].etag).toBeNull();
|
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', () => {
|
it('mehr als 5000 Eintraege: die ersten 5000 und truncated', () => {
|
||||||
const rows = Array.from(
|
const rows = Array.from(
|
||||||
{ length: MAX_LIST_ENTRIES + 3 },
|
{ length: MAX_LIST_ENTRIES + 3 },
|
||||||
@@ -201,6 +217,7 @@ describe('parsePropfind', () => {
|
|||||||
entries: [],
|
entries: [],
|
||||||
quota: { used: 0, available: null },
|
quota: { used: 0, available: null },
|
||||||
truncated: false,
|
truncated: false,
|
||||||
|
folderPermissions: null,
|
||||||
});
|
});
|
||||||
// Basis ohne Unterpfad: das Praefix des href darf dann keinen /nc enthalten.
|
// Basis ohne Unterpfad: das Praefix des href darf dann keinen /nc enthalten.
|
||||||
const flat = XML.replaceAll('/nc/remote.php', '/remote.php');
|
const flat = XML.replaceAll('/nc/remote.php', '/remote.php');
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ export interface ParsedListing {
|
|||||||
entries: NcEntry[];
|
entries: NcEntry[];
|
||||||
quota: NcQuota;
|
quota: NcQuota;
|
||||||
truncated: boolean;
|
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 {
|
export interface ParsePropfindContext {
|
||||||
@@ -241,6 +247,7 @@ export function parsePropfind(xml: string, ctx: ParsePropfindContext): ParsedLis
|
|||||||
const entries: NcEntry[] = [];
|
const entries: NcEntry[] = [];
|
||||||
let quota: NcQuota = { used: 0, available: null };
|
let quota: NcQuota = { used: 0, available: null };
|
||||||
let truncated = false;
|
let truncated = false;
|
||||||
|
let folderPermissions: string | null = null;
|
||||||
|
|
||||||
for (const response of responses) {
|
for (const response of responses) {
|
||||||
if (!isDict(response)) continue;
|
if (!isDict(response)) continue;
|
||||||
@@ -257,6 +264,7 @@ export function parsePropfind(xml: string, ctx: ParsePropfindContext): ParsedLis
|
|||||||
used: used !== null && used >= 0 ? used : 0,
|
used: used !== null && used >= 0 ? used : 0,
|
||||||
available: available !== null && available >= 0 ? available : null,
|
available: available !== null && available >= 0 ? available : null,
|
||||||
};
|
};
|
||||||
|
folderPermissions = text(props.permissions);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Nur direkte Kinder des angefragten Ordners (Depth 1).
|
// 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));
|
entries.push(buildEntry(rel, props));
|
||||||
}
|
}
|
||||||
|
|
||||||
return { entries, quota, truncated };
|
return { entries, quota, truncated, folderPermissions };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,19 @@ import {
|
|||||||
isRealDate,
|
isRealDate,
|
||||||
OCS_OK_MAX_BYTES,
|
OCS_OK_MAX_BYTES,
|
||||||
ocsShareRequest,
|
ocsShareRequest,
|
||||||
|
parseSharePolicy as parsePolicy,
|
||||||
parseShare,
|
parseShare,
|
||||||
parseShareList,
|
|
||||||
parseSharePolicy,
|
|
||||||
parseSharees,
|
parseSharees,
|
||||||
|
parseShareList,
|
||||||
permissionsFor,
|
permissionsFor,
|
||||||
SHARES_BASE_SEGMENTS,
|
SHARES_BASE_SEGMENTS,
|
||||||
|
serverDate,
|
||||||
|
verbatimId,
|
||||||
} from './nextcloud-shares';
|
} from './nextcloud-shares';
|
||||||
|
|
||||||
|
const TODAY = '2026-10-09';
|
||||||
|
const parseSharePolicy = (data: unknown) => parsePolicy(data, TODAY);
|
||||||
|
|
||||||
const SESSION: NcSession = {
|
const SESSION: NcSession = {
|
||||||
baseUrl: 'https://cloud.example/nc',
|
baseUrl: 'https://cloud.example/nc',
|
||||||
ncUserId: 'anna',
|
ncUserId: 'anna',
|
||||||
@@ -349,6 +354,7 @@ describe('parseShare', () => {
|
|||||||
shareWithName: 'Zwei Faktor',
|
shareWithName: 'Zwei Faktor',
|
||||||
ownerId: 'anna',
|
ownerId: 'anna',
|
||||||
ownerName: 'Anna Müller',
|
ownerName: 'Anna Müller',
|
||||||
|
fileOwnerName: null,
|
||||||
canEdit: true,
|
canEdit: true,
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
expiration: '2026-12-31',
|
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', () => {
|
describe('accessOf / permissionsFor', () => {
|
||||||
it.each([
|
it.each([
|
||||||
[1, 'view'],
|
[1, 'folder', 'view'],
|
||||||
[17, 'view'],
|
[17, 'folder', 'view'],
|
||||||
[4, 'upload'],
|
[1, 'file', 'view'],
|
||||||
[3, 'edit'],
|
[4, 'folder', 'upload'],
|
||||||
[15, 'edit'],
|
[20, 'folder', 'upload'],
|
||||||
[31, 'edit'],
|
[15, 'folder', 'edit'],
|
||||||
[0, 'custom'],
|
[31, 'folder', 'edit'],
|
||||||
[16, 'custom'],
|
[3, 'file', 'edit'],
|
||||||
])('accessOf(%i) = %s', (permissions, access) => {
|
[19, 'file', 'edit'],
|
||||||
expect(accessOf(permissions)).toBe(access);
|
// 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', () => {
|
it('permissionsFor nach Eintragsart', () => {
|
||||||
@@ -501,6 +644,7 @@ describe('parseSharees', () => {
|
|||||||
describe('parseSharePolicy', () => {
|
describe('parseSharePolicy', () => {
|
||||||
it('die lebenden Faehigkeiten', () => {
|
it('die lebenden Faehigkeiten', () => {
|
||||||
expect(parseSharePolicy(CAPS)).toEqual({
|
expect(parseSharePolicy(CAPS)).toEqual({
|
||||||
|
today: TODAY,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
groupsEnabled: true,
|
groupsEnabled: true,
|
||||||
links: {
|
links: {
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ export type NcItemType = 'file' | 'folder';
|
|||||||
export interface NcShareView {
|
export interface NcShareView {
|
||||||
id: string;
|
id: string;
|
||||||
kind: NcShareKind;
|
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;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
itemType: NcItemType;
|
itemType: NcItemType;
|
||||||
@@ -49,8 +52,14 @@ export interface NcShareView {
|
|||||||
/** Kennung von Person/Gruppe; bei Links null. */
|
/** Kennung von Person/Gruppe; bei Links null. */
|
||||||
shareWith: string | null;
|
shareWith: string | null;
|
||||||
shareWithName: 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;
|
ownerId: string | null;
|
||||||
ownerName: string | null;
|
ownerName: string | null;
|
||||||
|
/** Anzeigename des Dateieigentuemers, nur wenn er ein anderer ist (Weitergabe, siehe `parseShare`). */
|
||||||
|
fileOwnerName: string | null;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
/** `YYYY-MM-DD` oder null. */
|
/** `YYYY-MM-DD` oder null. */
|
||||||
@@ -59,7 +68,7 @@ export interface NcShareView {
|
|||||||
/** Nur bei eigenen Links, nur http/https. */
|
/** Nur bei eigenen Links, nur http/https. */
|
||||||
url: string | null;
|
url: string | null;
|
||||||
hasPassword: boolean;
|
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;
|
target: string;
|
||||||
sharedAt: string | null;
|
sharedAt: string | null;
|
||||||
/** Noch nicht angenommen (nur eingehende). */
|
/** Noch nicht angenommen (nur eingehende). */
|
||||||
@@ -74,6 +83,8 @@ export interface NcSharee {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface NcSharePolicy {
|
export interface NcSharePolicy {
|
||||||
|
/** Heutiges Datum (`YYYY-MM-DD`) nach der Uhr des Tessera-Servers: Grundlage fuer Ablaufgrenzen. */
|
||||||
|
today: string;
|
||||||
/** Freigabe-Schnittstelle der Nextcloud an. */
|
/** Freigabe-Schnittstelle der Nextcloud an. */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
groupsEnabled: boolean;
|
groupsEnabled: boolean;
|
||||||
@@ -108,6 +119,7 @@ const SHARE_TIMEOUT_MS = 15_000;
|
|||||||
const MESSAGE_MAX = 300;
|
const MESSAGE_MAX = 300;
|
||||||
const DISPLAY_MAX = 255;
|
const DISPLAY_MAX = 255;
|
||||||
const URL_MAX = 2048;
|
const URL_MAX = 2048;
|
||||||
|
const PATH_MAX = 4096;
|
||||||
|
|
||||||
const SHARE_ID_RE = /^\d{1,20}$/;
|
const SHARE_ID_RE = /^\d{1,20}$/;
|
||||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
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 {
|
function intOf(value: unknown): number | null {
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value);
|
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());
|
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;
|
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;
|
const mask = permissions & 15;
|
||||||
if (mask === READ) return 'view';
|
if (mask === READ) return 'view';
|
||||||
if (mask === CREATE) return 'upload';
|
if (itemType === 'folder' && mask === CREATE) return 'upload';
|
||||||
if ((mask & READ) !== 0 && (mask & (UPDATE | CREATE | DELETE)) !== 0) return 'edit';
|
if (mask === (itemType === 'folder' ? READ | UPDATE | CREATE | DELETE : READ | UPDATE)) {
|
||||||
|
return 'edit';
|
||||||
|
}
|
||||||
return 'custom';
|
return 'custom';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,6 +292,12 @@ export function isRealDate(value: string): boolean {
|
|||||||
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
|
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 {
|
function publicUrl(value: unknown): string | null {
|
||||||
if (typeof value !== 'string' || value.length === 0 || value.length > URL_MAX) return null;
|
if (typeof value !== 'string' || value.length === 0 || value.length > URL_MAX) return null;
|
||||||
try {
|
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 {
|
export function parseShare(raw: unknown, selfId: string): NcShareView | null {
|
||||||
if (!isDict(raw)) return null;
|
if (!isDict(raw)) return null;
|
||||||
const type = intOf(raw.share_type);
|
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 ownerId = cleanText(raw.uid_owner, DISPLAY_MAX) || null;
|
||||||
const received = ownerId !== null && ownerId !== selfId;
|
const received = ownerId !== null && ownerId !== selfId;
|
||||||
const target = cleanText(raw.file_target, 4096);
|
const rawTarget = verbatimId(raw.file_target, PATH_MAX);
|
||||||
const path = cleanText(raw.path, 4096) || target;
|
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 =
|
const itemType: NcItemType =
|
||||||
raw.item_type === 'folder' || raw.mimetype === 'httpd/unix-directory' ? 'folder' : 'file';
|
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 permissions = intOf(raw.permissions) ?? 0;
|
||||||
const itemPermissions = intOf(raw.item_permissions) ?? permissions;
|
const itemPermissions = intOf(raw.item_permissions) ?? permissions;
|
||||||
const expirationRaw = typeof raw.expiration === 'string' ? raw.expiration.slice(0, 10) : '';
|
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()
|
? new Date(stime * 1000).toISOString()
|
||||||
: null;
|
: null;
|
||||||
const mimeRaw = cleanText(raw.mimetype, DISPLAY_MAX);
|
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 {
|
return {
|
||||||
id,
|
id,
|
||||||
@@ -306,12 +359,15 @@ export function parseShare(raw: unknown, selfId: string): NcShareView | null {
|
|||||||
mime: itemType === 'file' && mimeRaw !== '' ? mimeRaw : null,
|
mime: itemType === 'file' && mimeRaw !== '' ? mimeRaw : null,
|
||||||
itemWritable: (itemPermissions & (UPDATE | CREATE)) !== 0,
|
itemWritable: (itemPermissions & (UPDATE | CREATE)) !== 0,
|
||||||
permissions,
|
permissions,
|
||||||
access: accessOf(permissions),
|
access: accessOf(permissions, itemType),
|
||||||
shareWith: kind === 'link' ? null : cleanText(raw.share_with, DISPLAY_MAX) || null,
|
shareWith: shareWith === '' ? null : shareWith,
|
||||||
shareWithName:
|
shareWithName:
|
||||||
kind === 'link' ? null : cleanText(raw.share_with_displayname, DISPLAY_MAX) || null,
|
kind === 'link' ? null : cleanText(raw.share_with_displayname, DISPLAY_MAX) || null,
|
||||||
ownerId,
|
ownerId,
|
||||||
ownerName: cleanText(raw.displayname_owner, DISPLAY_MAX) || null,
|
ownerName: cleanText(raw.displayname_owner, DISPLAY_MAX) || null,
|
||||||
|
fileOwnerName: reshare
|
||||||
|
? cleanText(raw.displayname_file_owner, DISPLAY_MAX) || fileOwnerId
|
||||||
|
: null,
|
||||||
canEdit: raw.can_edit === true,
|
canEdit: raw.can_edit === true,
|
||||||
canDelete: raw.can_delete === true,
|
canDelete: raw.can_delete === true,
|
||||||
expiration: isRealDate(expirationRaw) ? expirationRaw : null,
|
expiration: isRealDate(expirationRaw) ? expirationRaw : null,
|
||||||
@@ -356,7 +412,8 @@ function shareeList(value: unknown, wanted: 0 | 1): NcSharee[] {
|
|||||||
for (const item of value) {
|
for (const item of value) {
|
||||||
if (!isDict(item) || !isDict(item.value)) continue;
|
if (!isDict(item) || !isDict(item.value)) continue;
|
||||||
if (intOf(item.value.shareType) !== wanted) 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;
|
if (id === '') continue;
|
||||||
const label = cleanText(item.label, DISPLAY_MAX) || id;
|
const label = cleanText(item.label, DISPLAY_MAX) || id;
|
||||||
const unique = cleanText(item.shareWithDisplayNameUnique, DISPLAY_MAX);
|
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
|
* Fehlende Schluessel heissen "keine Regel", kein Fehler: ohne `expire_date.enabled` gibt
|
||||||
* es keine Tage; sind Links aus, steht unter `public` nur `enabled: false`.
|
* 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 caps = isDict(data) && isDict(data.capabilities) ? data.capabilities : data;
|
||||||
const files = isDict(caps) && isDict(caps.files_sharing) ? caps.files_sharing : null;
|
const files = isDict(caps) && isDict(caps.files_sharing) ? caps.files_sharing : null;
|
||||||
const pwPolicy = isDict(caps) && isDict(caps.password_policy) ? caps.password_policy : 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 passwordMinLength = minLen !== null && minLen >= 1 && minLen <= 256 ? minLen : null;
|
||||||
|
|
||||||
const off: NcSharePolicy = {
|
const off: NcSharePolicy = {
|
||||||
|
today,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
groupsEnabled: false,
|
groupsEnabled: false,
|
||||||
links: {
|
links: {
|
||||||
@@ -444,6 +503,7 @@ export function parseSharePolicy(data: unknown): NcSharePolicy {
|
|||||||
const internal = expiry(pub.expire_date_internal);
|
const internal = expiry(pub.expire_date_internal);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
today,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
groupsEnabled: files.group_sharing !== false,
|
groupsEnabled: files.group_sharing !== false,
|
||||||
links: linksEnabled
|
links: linksEnabled
|
||||||
|
|||||||
Reference in New Issue
Block a user