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:
2026-10-09 12:21:23 +02:00
parent 0585fd17c4
commit 78f6cf358d
13 changed files with 543 additions and 67 deletions
@@ -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