feat(quick-261009-dkv): Teilen mit Personen und Gruppen - Durchstich, Schnittstelle

- Freigaben-Schicht (nextcloud-shares.ts): eigener OCS-Aufruf, der Fehlertexte liest,
  Parser fuer Freigaben, Empfaenger und Freigaberegeln, Berechtigungsabbildung
- Dienst mit Vorpruefung aus den Faehigkeiten, Dublettenpruefung und Fehlermatrix
  (nie 401/403 nach aussen), Begrenzung auf 15 neue Freigaben in 10 Minuten
- Routen shares/policy, shares/by-path, sharees, POST/PUT/DELETE shares
- oc:share-types als shareTypes an jedem Eintrag
- Live-Test e2e-shares.sh (Abschnitt people)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 10:40:39 +02:00
parent 43a6a83347
commit 207d37b8d6
15 changed files with 2584 additions and 5 deletions
@@ -0,0 +1,231 @@
#!/usr/bin/env bash
# quick-261009-dkv: Teilen-Schnittstelle gegen die Test-Nextcloud (tessera-nc-test).
# Aufruf: e2e-shares.sh [people|links|received|version|all] (Vorgabe: all)
# Aufgabe 1 liefert Aufbau, Aufraeumen und den Abschnitt "people"; links und received
# folgen in Aufgabe 2, version in Aufgabe 3.
# Setzt einen laufenden Stack (api neu gebaut: docker compose up -d --build api) und
# nc-test-setup.sh voraus. Nur Testwerte; liest keine .env-Dateien.
#
# Die Test-Nextcloud bekommt den Benutzer ben (Passwort User3-Pass-12345, ohne Zwei-Faktor) und die
# Gruppe tessera-team (mit ben). Die Ratenbegrenzung der Nextcloud wird NUR fuer diesen Lauf
# ausgeschaltet (sonst koennte wiederholtes Testen deren 429 ausloesen, das die Aufrufsperre fuer
# den ganzen Ursprung anhaelt). Die Begrenzung von Tessera (15 in 10 Minuten) beweisen die
# Einheitstests. Alles, was das Skript anfasst, wird im trap zurueckgesetzt.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$HERE/../../261008-mzu-modul-nextcloud-dateien-eigenstaendiger-/e2e/e2e-lib.sh"
SECTION=${1:-all}
case "$SECTION" in people | links | received | version | all) ;; *) e2e_fail "unbekannter Abschnitt: $SECTION" ;; esac
FILES="$API/modules/nextcloud-files"
NC_DAV="http://localhost:18080/remote.php/dav"
NC_OCS="http://localhost:18080/ocs/v2.php/apps/files_sharing/api/v1"
ANNA="anna:User1-Pass-12345"
BEN="ben:User3-Pass-12345"
EPOCH=$(date +%s)
FIX="Tessera-Teilen-$EPOCH"
START_ISO=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# --- Aufraeumen: alles zuruecksetzen, was das Skript angefasst hat ----------------------------
cleanup() {
local rc=$?
set +e
curl -s -o /dev/null -u "$ANNA" -X DELETE "$NC_DAV/files/anna/$FIX" 2>/dev/null
NC_OCC config:system:delete ratelimit.protection.enabled >/dev/null 2>&1
for key in shareapi_enforce_links_password shareapi_default_expire_date \
shareapi_enforce_expire_date shareapi_expire_after_n_days; do
NC_OCC config:app:delete core "$key" >/dev/null 2>&1
done
NC_OCC user:setting --delete anna files_sharing default_accept >/dev/null 2>&1
rm -rf "$E2E_TMP"
exit $rc
}
trap cleanup EXIT
# --- Hilfen -------------------------------------------------------------------------------------
ADMIN="$E2E_TMP/admin.jar"
SEEN="$E2E_TMP/seen.txt"
: >"$SEEN"
# call <methode> <pfad-nach-/modules/nextcloud-files/> [json] — gibt den Status aus und merkt ihn in
# $SEEN fuer die Pruefung "nie 401/403" (Datei, weil $(...) in einer Unterschale laeuft). Antwortkoerper: $E2E_TMP/body.out
call() {
local code
code=$(e2e_status "$ADMIN" "$1" "$FILES/$2" "${3:-}")
echo "$code" >>"$SEEN"
echo "$code"
}
# qget <endpoint> <schluessel> <wert> [<schluessel> <wert> ...] — GET mit sauber codierter Query.
qget() {
local endpoint=$1 args=() code
shift
while [ $# -gt 0 ]; do
args+=(--data-urlencode "$1=$2")
shift 2
done
code=$(curl -s -o "$E2E_TMP/body.out" -w '%{http_code}' -b "$ADMIN" -G "${args[@]}" "$FILES/$endpoint")
echo "$code" >>"$SEEN"
echo "$code"
}
# jq_py <ausdruck> — wertet einen Python-Ausdruck auf der JSON-Datei $E2E_TMP/body.out aus (d = Daten).
jq_py() {
python3 -I -c 'import json,sys
d=json.load(open(sys.argv[1]))
print(eval(sys.argv[2]))' "$E2E_TMP/body.out" "$1"
}
# Direkt bei der Nextcloud: eingehende Freigaben von ben (JSON in $E2E_TMP/ben.out)
ben_shares() {
curl -s -u "$BEN" -H 'OCS-APIRequest: true' -H 'Accept: application/json' \
"$NC_OCS/shares?shared_with_me=true" -o "$E2E_TMP/ben.out"
}
# ben_count <ordnername> — Anzahl der eingehenden Freigaben von ben mit diesem Ziel
ben_count() {
ben_shares
python3 -I -c 'import json,sys
d=json.load(open(sys.argv[1]))["ocs"]["data"]
print(len([s for s in d if s.get("file_target","").lstrip("/")==sys.argv[2]]))' "$E2E_TMP/ben.out" "$1"
}
ben_perm() {
ben_shares
python3 -I -c 'import json,sys
d=json.load(open(sys.argv[1]))["ocs"]["data"]
print([s["permissions"] for s in d if s.get("file_target","").lstrip("/")==sys.argv[2]][0])' "$E2E_TMP/ben.out" "$1"
}
# --- Aufbau --------------------------------------------------------------------------------------
e2e_wait_health
e2e_login "$ADMIN"
e2e_activate "$ADMIN"
e2e_set_address "$ADMIN"
code=$(e2e_connect_anna "$ADMIN")
e2e_expect 200 "$code" "anna verbinden"
code=$(call GET shares/policy)
[ "$code" = "200" ] || e2e_fail "GET shares/policy -> $code. API-Container neu bauen: docker compose up -d --build api"
# ben und die Gruppe (idempotent)
if ! NC_OCC user:info ben >/dev/null 2>&1; then
docker exec -u www-data -e OC_PASS='User3-Pass-12345' tessera-nc-test \
php occ user:add --password-from-env --display-name="Ben Beispiel" ben >/dev/null
fi
GROUPS_NOW=$(NC_OCC group:list 2>/dev/null || true)
case "$GROUPS_NOW" in *tessera-team*) ;; *) NC_OCC group:add tessera-team >/dev/null ;; esac
NC_OCC group:adduser tessera-team ben >/dev/null 2>&1 || true
NC_OCC config:system:set ratelimit.protection.enabled --value=false --type=boolean >/dev/null
# Ordner mit Datei und Unterordner in annas Konto
code=$(curl -s -o /dev/null -w '%{http_code}' -u "$ANNA" -X MKCOL "$NC_DAV/files/anna/$FIX")
e2e_expect 201 "$code" "Fixture-Ordner"
code=$(curl -s -o /dev/null -w '%{http_code}' -u "$ANNA" -X PUT --data-binary 'Bericht' "$NC_DAV/files/anna/$FIX/Bericht.txt")
e2e_expect 201 "$code" "Fixture Bericht.txt"
code=$(curl -s -o /dev/null -w '%{http_code}' -u "$ANNA" -X MKCOL "$NC_DAV/files/anna/$FIX/Briefkasten")
e2e_expect 201 "$code" "Fixture Briefkasten"
# --- Abschnitt people ----------------------------------------------------------------------------
section_people() {
local code share_id group_id
code=$(call GET shares/policy)
e2e_expect 200 "$code" "Richtlinie lesen"
e2e_expect True "$(jq_py 'd["enabled"] is True and d["links"]["passwordRequired"] is False')" "Richtlinie: enabled, Passwort nicht erzwungen"
code=$(qget sharees term ben itemType folder)
e2e_expect 200 "$code" "Empfaengersuche ben"
e2e_expect True "$(jq_py 'any(s["kind"]=="user" and s["id"]=="ben" for s in d["sharees"])')" "Suche ben findet die Person"
code=$(qget sharees term tessera itemType folder)
e2e_expect 200 "$code" "Empfaengersuche tessera"
e2e_expect True "$(jq_py 'any(s["kind"]=="group" and s["id"]=="tessera-team" for s in d["sharees"])')" "Suche tessera findet die Gruppe"
code=$(qget sharees term '' itemType folder)
e2e_expect 200 "$code" "leere Suche"
e2e_expect '[]' "$(jq_py 'd["sharees"]')" "leere Suche liefert nichts"
# Personenfreigabe: Ordner an ben, Bearbeiten
code=$(call POST shares "{\"path\":\"/$FIX\",\"kind\":\"user\",\"shareWith\":\"ben\",\"access\":\"edit\"}")
e2e_expect 201 "$code" "Personenfreigabe anlegen"
e2e_expect True "$(jq_py 'd["kind"]=="user" and d["access"]=="edit" and d["permissions"]==15 and d["shareWith"]=="ben"')" "Freigabe: Person, Bearbeiten, 15"
share_id=$(jq_py 'd["id"]')
e2e_expect 1 "$(ben_count "$FIX")" "ben sieht die Freigabe"
e2e_expect 15 "$(ben_perm "$FIX")" "ben: Berechtigung 15"
# Dieselbe Freigabe nochmal: abgelehnt, kein zweiter Eintrag
code=$(call POST shares "{\"path\":\"/$FIX\",\"kind\":\"user\",\"shareWith\":\"ben\",\"access\":\"edit\"}")
e2e_expect 409 "$code" "doppelte Freigabe"
e2e_contains "$E2E_TMP/body.out" '"code":"shareAlreadyExists"' "doppelt: shareAlreadyExists"
e2e_expect 1 "$(ben_count "$FIX")" "ben sieht weiterhin genau eine Freigabe"
# Hochladen ist fuer Personen nicht moeglich, und die Berechtigung kommt nur als Auswahl
code=$(call POST shares "{\"path\":\"/$FIX/Briefkasten\",\"kind\":\"user\",\"shareWith\":\"ben\",\"access\":\"upload\"}")
e2e_expect 400 "$code" "Hochladen fuer eine Person"
e2e_contains "$E2E_TMP/body.out" '"code":"shareAccessInvalid"' "upload: shareAccessInvalid"
code=$(call POST shares "{\"path\":\"/$FIX/Briefkasten\",\"kind\":\"user\",\"shareWith\":\"ben\",\"access\":\"abc\"}")
e2e_expect 400 "$code" "unbekannte Auswahl"
# Auf Ansehen aendern
code=$(call PUT "shares/$share_id" '{"access":"view"}')
e2e_expect 200 "$code" "auf Ansehen aendern"
e2e_expect True "$(jq_py 'd["access"]=="view" and d["permissions"]==1')" "Freigabe: Ansehen, 1"
e2e_expect 1 "$(ben_perm "$FIX")" "ben: Berechtigung 1"
# Gruppenfreigabe einer Datei
code=$(call POST shares "{\"path\":\"/$FIX/Bericht.txt\",\"kind\":\"group\",\"shareWith\":\"tessera-team\",\"access\":\"view\"}")
e2e_expect 201 "$code" "Gruppenfreigabe anlegen"
e2e_expect True "$(jq_py 'd["kind"]=="group" and d["itemType"]=="file" and d["permissions"]==1')" "Gruppenfreigabe: Gruppe, Datei, 1"
group_id=$(jq_py 'd["id"]')
# Freigaben des Ordners: genau die Personenfreigabe
code=$(qget shares/by-path path "/$FIX")
e2e_expect 200 "$code" "Freigaben des Ordners"
e2e_expect True "$(jq_py 'len(d["shares"])==1 and d["shares"][0]["id"]=="'"$share_id"'"')" "by-path: genau die Personenfreigabe"
# Kennzeichen in der Dateiliste
code=$(qget files path /)
e2e_expect 200 "$code" "Wurzel auflisten"
e2e_expect True "$(jq_py 'any(e["name"]=="'"$FIX"'" and 0 in e["shareTypes"] for e in d["entries"])')" "Wurzel: Ordner traegt shareTypes mit 0"
code=$(qget files path "/$FIX")
e2e_expect 200 "$code" "Ordner auflisten"
e2e_expect True "$(jq_py 'any(e["name"]=="Bericht.txt" and 1 in e["shareTypes"] for e in d["entries"])')" "Ordner: Bericht.txt traegt shareTypes mit 1"
e2e_expect True "$(jq_py 'any(e["name"]=="Briefkasten" and e["shareTypes"]==[] for e in d["entries"])')" "Ordner: Briefkasten ist nicht geteilt"
# Entfernen
code=$(call DELETE "shares/$share_id")
e2e_expect 200 "$code" "Personenfreigabe entfernen"
e2e_expect 0 "$(ben_count "$FIX")" "ben sieht die Freigabe nicht mehr"
code=$(qget shares/by-path path "/$FIX")
e2e_expect 200 "$code" "Freigaben des Ordners nach dem Entfernen"
e2e_expect True "$(jq_py 'd["shares"]==[]')" "by-path: leer"
code=$(call DELETE "shares/$group_id")
e2e_expect 200 "$code" "Gruppenfreigabe entfernen"
code=$(call DELETE "shares/$share_id")
e2e_expect 404 "$code" "bereits entfernte Freigabe"
e2e_contains "$E2E_TMP/body.out" '"code":"shareNotFound"' "entfernt: shareNotFound"
code=$(call DELETE shares/abc)
e2e_expect 404 "$code" "ungueltige Kennung"
e2e_contains "$E2E_TMP/body.out" '"code":"shareNotFound"' "abc: shareNotFound"
echo "e2e shares people ok"
}
section_links() { echo "e2e shares links: folgt in Aufgabe 2"; }
section_received() { echo "e2e shares received: folgt in Aufgabe 2"; }
section_version() { echo "e2e shares version: folgt in Aufgabe 3"; }
case "$SECTION" in
people) section_people ;;
links) section_links ;;
received) section_received ;;
version) section_version ;;
all)
section_people
section_links
section_received
section_version
;;
esac
# Nie 401 oder 403 an den Browser (Fehlervertrag D-D)
if grep -qE '^(401|403)$' "$SEEN"; then e2e_fail "ein Aufruf endete mit 401 oder 403"; fi
: "$START_ISO"
@@ -164,6 +164,12 @@ describe('Umgestellte Handler (Verwalten)', () => {
'completeUpload', 'completeUpload',
'uploadState', 'uploadState',
'abortUpload', 'abortUpload',
'getSharePolicy',
'listSharesForPath',
'searchSharees',
'createShare',
'updateShare',
'deleteShare',
])('NextcloudFilesController.%s bleibt auf Benutzen-Ebene', (name) => { ])('NextcloudFilesController.%s bleibt auf Benutzen-Ebene', (name) => {
const fn = handler(NextcloudFilesController, name); const fn = handler(NextcloudFilesController, name);
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined(); expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
@@ -0,0 +1,60 @@
import { IsIn, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
/**
* Eingaben der Freigaberouten (quick-261009-dkv, D-11). Der Browser schickt nur
* die Aufzaehlungen `kind` und `access`, nie eine Freigabeart-Zahl oder eine
* Berechtigungsmaske; die API bildet sie selbst ab. Die globale ValidationPipe
* laeuft mit `whitelist: true` — unbekannte Felder (z. B. `permissions`) werden
* entfernt. Pfade laufen im Dienst durch `parseUserPath`.
*/
/** Kein Steuerzeichen (auch kein Zeilenumbruch) in Namen, die an Nextcloud gehen. */
// biome-ignore lint/suspicious/noControlCharactersInRegex: Steuerzeichen sind hier gerade der Pruefstoff
const NO_CONTROL = /^[^\u0000-\u001f\u007f]*$/;
export const SHARE_KINDS_PEOPLE = ['user', 'group'] as const;
export const SHARE_ACCESS = ['view', 'edit', 'upload'] as const;
export const ITEM_TYPES = ['file', 'folder'] as const;
export class ShareByPathQueryDto {
@IsString()
@IsNotEmpty()
@MaxLength(4096)
path!: string;
}
export class ShareeQueryDto {
/** Leer ist erlaubt (der Dienst ruft dann Nextcloud nicht auf). */
@IsString()
@MaxLength(100)
@Matches(NO_CONTROL)
term!: string;
@IsIn(ITEM_TYPES)
itemType!: (typeof ITEM_TYPES)[number];
}
export class CreateShareDto {
@IsString()
@IsNotEmpty()
@MaxLength(4096)
path!: string;
@IsIn(SHARE_KINDS_PEOPLE)
kind!: (typeof SHARE_KINDS_PEOPLE)[number];
@IsString()
@IsNotEmpty()
@MaxLength(255)
@Matches(NO_CONTROL)
shareWith!: string;
@IsIn(SHARE_ACCESS)
access!: (typeof SHARE_ACCESS)[number];
}
export class UpdateShareDto {
@IsOptional()
@IsIn(SHARE_ACCESS)
access?: (typeof SHARE_ACCESS)[number];
}
@@ -0,0 +1,519 @@
import { Readable } from 'node:stream';
import { describe, expect, it, vi } from 'vitest';
import { NextcloudCallGate } from './nextcloud-call-gate';
import { type NcSession } from './nextcloud-files.types';
import { NextcloudFilesSharesService } from './nextcloud-files-shares.service';
import type { NcTransportRequest, NextcloudTransport } from './nextcloud-http';
import { NextcloudLoginGuard } from './nextcloud-login-guard';
const SESSION: NcSession = {
baseUrl: 'https://cloud.example/nc',
ncUserId: 'anna',
authorization: 'Basic YW5uYTphcHAtcHctMTIz',
credentialKey: 'k1',
};
const OCS = 'https://cloud.example/nc/ocs/v2.php';
const SHARES = `${OCS}/apps/files_sharing/api/v1/shares`;
const BY_PATH = `${SHARES}?path=%2FProjekte&reshares=true`;
const CAPS_URL = `${OCS}/cloud/capabilities`;
const DAV = 'https://cloud.example/nc/remote.php/dav/files/anna';
const NS = 'xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns"';
function statXml(name: string, folder: boolean, letters: string): string {
return `<?xml version="1.0"?><d:multistatus ${NS}><d:response>
<d:href>/nc/remote.php/dav/files/anna/${name}</d:href><d:propstat><d:prop>
<d:resourcetype>${folder ? '<d:collection/>' : ''}</d:resourcetype>
<oc:fileid>7</oc:fileid><oc:permissions>${letters}</oc:permissions>
${folder ? '' : '<d:getcontentlength>5</d:getcontentlength><d:getcontenttype>text/plain</d:getcontenttype>'}
</d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response></d:multistatus>`;
}
const ocs = (data: unknown, status = 200, message = 'OK') =>
JSON.stringify({ ocs: { meta: { statuscode: status, message }, data } });
const CAPS = (over: Record<string, unknown> = {}) =>
ocs({
capabilities: {
password_policy: { minLength: 10 },
files_sharing: {
api_enabled: true,
group_sharing: true,
public: { enabled: true, upload: true },
sharee: { minSearchStringLength: 0 },
...over,
},
},
});
function shareJson(over: Record<string, unknown> = {}) {
return {
id: '17',
share_type: 0,
uid_owner: 'anna',
displayname_owner: 'Anna Müller',
permissions: 15,
can_edit: true,
can_delete: true,
stime: 1791532378,
path: '/Projekte',
item_type: 'folder',
item_permissions: 31,
mimetype: 'httpd/unix-directory',
file_target: '/Projekte',
share_with: 'ben',
share_with_displayname: 'Ben Beispiel',
...over,
};
}
type Reply = { status: number; text?: string; headers?: Record<string, string> };
function setup(replies: Reply[]) {
const calls: NcTransportRequest[] = [];
const queue = [...replies];
const transport: NextcloudTransport = async (req) => {
calls.push(req);
const reply = queue.shift();
if (!reply) throw new Error(`unerwarteter Aufruf ${req.method} ${req.url}`);
return {
statusCode: reply.status,
headers: reply.headers ?? {},
body: Readable.from(reply.text !== undefined ? [Buffer.from(reply.text)] : []),
};
};
const account = {
getSession: vi.fn(async (..._a: unknown[]) => SESSION),
markExpired: vi.fn(async (..._a: unknown[]) => {}),
};
const guard = new NextcloudLoginGuard();
const clock = { t: 1_000_000 };
guard.now = () => clock.t;
const service = new NextcloudFilesSharesService(
account as never,
new NextcloudCallGate(),
transport,
guard,
);
return { service, account, calls, queue, clock, guard };
}
const codeOf = (e: unknown) => (e as { response: { code: string } }).response.code;
const bodyOf = (e: unknown) => (e as { response: Record<string, unknown> }).response;
const statusOf = (e: unknown) => (e as { getStatus(): number }).getStatus();
async function failure(promise: Promise<unknown>): Promise<unknown> {
try {
await promise;
} catch (e) {
return e;
}
throw new Error('es wurde kein Fehler geworfen');
}
/** Antworten fuer ein erfolgreiches create(): stat, Faehigkeiten, Liste des Pfads, POST. */
function createReplies(
over: { stat?: Reply; caps?: Reply; list?: Reply; post?: Reply } = {},
): Reply[] {
return [
over.stat ?? { status: 207, text: statXml('Projekte', true, 'RGDNVCK') },
over.caps ?? { status: 200, text: CAPS() },
over.list ?? { status: 200, text: ocs([]) },
over.post ?? { status: 200, text: ocs(shareJson()) },
];
}
const INPUT = { path: '/Projekte', kind: 'user', shareWith: 'ben', access: 'edit' } as const;
describe('NextcloudFilesSharesService — create', () => {
it('sendet PROPFIND, Faehigkeiten, Liste des Pfads, POST in dieser Reihenfolge und liefert die Freigabe', async () => {
const { service, account, calls } = setup(createReplies());
const share = await service.create('t1', 'u1', INPUT);
expect(account.getSession).toHaveBeenCalledWith('t1', 'u1');
expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([
`PROPFIND ${DAV}/Projekte`,
`GET ${CAPS_URL}`,
`GET ${BY_PATH}`,
`POST ${SHARES}`,
]);
expect(calls[0].headers.depth).toBe('0');
expect(calls[3].headers['content-type']).toBe('application/json');
expect(calls[3].body).toBe(
'{"path":"/Projekte","shareType":0,"shareWith":"ben","permissions":15}',
);
expect(share).toMatchObject({ id: '17', kind: 'user', shareWith: 'ben', access: 'edit' });
});
it('Gruppe: shareType 1', async () => {
const { service, calls } = setup(
createReplies({
post: { status: 200, text: ocs(shareJson({ share_type: 1, share_with: 'team' })) },
}),
);
const share = await service.create('t1', 'u1', { ...INPUT, kind: 'group', shareWith: 'team' });
expect(calls[3].body).toBe(
'{"path":"/Projekte","shareType":1,"shareWith":"team","permissions":15}',
);
expect(share.kind).toBe('group');
});
it('Datei: Bearbeiten sendet 3, Ansehen sendet 1', async () => {
for (const [access, permissions] of [
['edit', 3],
['view', 1],
] as const) {
const { service, calls } = setup([
{ status: 207, text: statXml('Bericht.txt', false, 'RGDNVW') },
{ status: 200, text: CAPS() },
{ status: 200, text: ocs([]) },
{
status: 200,
text: ocs(shareJson({ path: '/Bericht.txt', item_type: 'file', permissions })),
},
]);
await service.create('t1', 'u1', { ...INPUT, path: '/Bericht.txt', access });
expect(calls[2].url).toBe(`${SHARES}?path=%2FBericht.txt&reshares=true`);
expect(calls[3].body).toBe(
`{"path":"/Bericht.txt","shareType":0,"shareWith":"ben","permissions":${permissions}}`,
);
}
});
it('Hochladen fuer eine Person, oder Bearbeiten bei Buchstaben RG: 400 shareAccessInvalid, kein POST', async () => {
const upload = setup(createReplies());
const e1 = await failure(upload.service.create('t1', 'u1', { ...INPUT, access: 'upload' }));
expect(codeOf(e1)).toBe('shareAccessInvalid');
expect(statusOf(e1)).toBe(400);
expect(upload.calls.some((c) => c.method === 'POST')).toBe(false);
const readonly = setup(
createReplies({ stat: { status: 207, text: statXml('Projekte', true, 'RG') } }),
);
const e2 = await failure(readonly.service.create('t1', 'u1', INPUT));
expect(codeOf(e2)).toBe('shareAccessInvalid');
expect(readonly.calls.some((c) => c.method === 'POST')).toBe(false);
});
it('der Empfaenger hat schon eine Freigabe: 409 shareAlreadyExists, kein POST', async () => {
const { service, calls } = setup(
createReplies({ list: { status: 200, text: ocs([shareJson()]) } }),
);
const e = await failure(service.create('t1', 'u1', INPUT));
expect(codeOf(e)).toBe('shareAlreadyExists');
expect(statusOf(e)).toBe(409);
expect(calls.some((c) => c.method === 'POST')).toBe(false);
});
it('gleiche Kennung als Gruppe zaehlt nicht als vorhandene Personenfreigabe', async () => {
const { service } = setup(
createReplies({
list: { status: 200, text: ocs([shareJson({ share_type: 1, share_with: 'ben' })]) },
}),
);
await expect(service.create('t1', 'u1', INPUT)).resolves.toMatchObject({ id: '17' });
});
it('Gruppen ausgeschaltet: 409 sharingDisabled mit der Gruppenmeldung, kein POST', async () => {
const { service, calls } = setup(
createReplies({ caps: { status: 200, text: CAPS({ group_sharing: false }) } }),
);
const e = await failure(
service.create('t1', 'u1', { ...INPUT, kind: 'group', shareWith: 'team' }),
);
expect(codeOf(e)).toBe('sharingDisabled');
expect(statusOf(e)).toBe(409);
expect(bodyOf(e).message).toBe('Teilen mit Gruppen ist in Ihrer Nextcloud ausgeschaltet.');
expect(calls.some((c) => c.method === 'POST')).toBe(false);
});
it('Freigabe-Schnittstelle aus: 409 sharingDisabled', async () => {
const { service, calls } = setup(
createReplies({ caps: { status: 200, text: CAPS({ api_enabled: false }) } }),
);
const e = await failure(service.create('t1', 'u1', INPUT));
expect(codeOf(e)).toBe('sharingDisabled');
expect(bodyOf(e).message).toBe('Teilen ist in Ihrer Nextcloud ausgeschaltet.');
expect(calls.some((c) => c.method === 'POST')).toBe(false);
});
it('leerer Pfad oder Wurzel: 400 invalidPath ohne jeden Aufruf', async () => {
for (const path of ['', '/']) {
const { service, account, calls } = setup([]);
const e = await failure(service.create('t1', 'u1', { ...INPUT, path }));
expect(codeOf(e)).toBe('invalidPath');
expect(calls).toHaveLength(0);
expect(account.getSession).not.toHaveBeenCalled();
}
});
it('Eintrag nicht vorhanden: 404 notFound, danach kein Freigabe-Aufruf', async () => {
const { service, calls } = setup([{ status: 404 }]);
const e = await failure(service.create('t1', 'u1', INPUT));
expect(codeOf(e)).toBe('notFound');
expect(calls).toHaveLength(1);
});
it.each([
[404, undefined, 422, 'shareRecipientInvalid'],
[403, 'You cannot share a folder that contains other shares', 422, 'shareRejected'],
[400, undefined, 422, 'shareRejected'],
[500, undefined, 502, 'nextcloudError'],
[503, undefined, 503, 'nextcloudMaintenance'],
])('POST antwortet %i -> %i %s', async (nc, message, http, code) => {
const { service } = setup(
createReplies({
post: {
status: nc,
text: JSON.stringify({
ocs: { meta: { statuscode: nc, message: message ?? 'x' }, data: [] },
}),
},
}),
);
const e = await failure(service.create('t1', 'u1', INPUT));
expect(codeOf(e)).toBe(code);
expect(statusOf(e)).toBe(http);
expect([401, 403]).not.toContain(statusOf(e));
if (code === 'shareRejected' && message) expect(bodyOf(e).ncMessage).toBe(message);
});
it('POST antwortet 401: 409 connectionExpired und die Verbindung ist als abgelaufen markiert', async () => {
const { service, account } = setup(createReplies({ post: { status: 401 } }));
const e = await failure(service.create('t1', 'u1', INPUT));
expect(codeOf(e)).toBe('connectionExpired');
expect(statusOf(e)).toBe(409);
expect(account.markExpired).toHaveBeenCalledTimes(1);
expect(account.markExpired).toHaveBeenCalledWith('t1', 'u1');
});
it('POST antwortet 429: 503 nextcloudLocked', async () => {
const { service } = setup(
createReplies({ post: { status: 429, headers: { 'retry-after': '60' } } }),
);
const e = await failure(service.create('t1', 'u1', INPUT));
expect(codeOf(e)).toBe('nextcloudLocked');
expect(statusOf(e)).toBe(503);
});
});
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 () => {
const replies: Reply[] = [];
for (let i = 0; i < 15; i++) replies.push(...createReplies());
// der abgewiesene 16. 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++) {
await service.create('t1', 'u1', { ...INPUT, shareWith: `ben${i}` });
}
const before = calls.length;
const e = await failure(service.create('t1', 'u1', { ...INPUT, shareWith: 'ben15' }));
expect(codeOf(e)).toBe('tooManyShares');
expect(statusOf(e)).toBe(429);
expect(bodyOf(e).retryAfterSeconds).toBe(600);
// Vorpruefungen laufen, aber der POST wird nicht gesendet.
expect(calls.slice(before).some((c) => c.method === 'POST')).toBe(false);
clock.t += 10 * 60 * 1000;
await expect(
service.create('t1', 'u1', { ...INPUT, shareWith: 'ben15' }),
).resolves.toBeTruthy();
});
it('eine durch Vorpruefung abgelehnte Eingabe zaehlt nicht mit', async () => {
const replies: Reply[] = [];
for (let i = 0; i < 3; i++) {
replies.push(
...createReplies({
list: { status: 200, text: ocs([shareJson({ share_with: 'x' })]) },
}).slice(0, 3),
);
}
for (let i = 0; i < 15; 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}` });
});
});
describe('NextcloudFilesSharesService — update', () => {
const GET17 = `GET ${SHARES}/17`;
it('Ansehen: GET der Freigabe, dann PUT nur mit der neuen Berechtigung', async () => {
const { service, calls } = setup([
{ status: 200, text: ocs([shareJson()]) },
{ status: 200, text: ocs(shareJson({ permissions: 1 })) },
]);
const share = await service.update('t1', 'u1', '17', { access: 'view' });
expect(calls.map((c) => `${c.method} ${c.url}`)).toEqual([GET17, `PUT ${SHARES}/17`]);
expect(calls[1].body).toBe('{"permissions":1}');
expect(share.access).toBe('view');
});
it('Freigabe nicht aenderbar (can_edit false): 422 shareRejected ohne PUT', async () => {
const { service, calls } = setup([
{ status: 200, text: ocs([shareJson({ can_edit: false })]) },
]);
const e = await failure(service.update('t1', 'u1', '17', { access: 'view' }));
expect(codeOf(e)).toBe('shareRejected');
expect(statusOf(e)).toBe(422);
expect(calls).toHaveLength(1);
});
it('PUT 404 -> 404 shareNotFound', async () => {
const { service } = setup([
{ status: 200, text: ocs([shareJson()]) },
{ status: 404, text: ocs([], 404, 'Wrong share ID, share does not exist') },
]);
const e = await failure(service.update('t1', 'u1', '17', { access: 'view' }));
expect(codeOf(e)).toBe('shareNotFound');
expect(statusOf(e)).toBe(404);
});
it('PUT 400 und 403 -> 422 shareRejected mit Meldung der Nextcloud', async () => {
for (const nc of [400, 403]) {
const { service } = setup([
{ status: 200, text: ocs([shareJson()]) },
{ status: nc, text: ocs([], nc, 'Failed to update share.') },
]);
const e = await failure(service.update('t1', 'u1', '17', { access: 'view' }));
expect(codeOf(e)).toBe('shareRejected');
expect(statusOf(e)).toBe(422);
expect(bodyOf(e).ncMessage).toBe('Failed to update share.');
}
});
it("Kennung 'abc' und eine 21-stellige Kennung: 404 shareNotFound ohne Aufruf", async () => {
for (const id of ['abc', '1'.repeat(21), '../x', '']) {
const { service, calls } = setup([]);
const e = await failure(service.update('t1', 'u1', id, { access: 'view' }));
expect(codeOf(e)).toBe('shareNotFound');
expect(calls).toHaveLength(0);
}
});
it('ohne Aenderung kommt die aktuelle Freigabe zurueck, es gibt kein PUT', async () => {
const { service, calls } = setup([{ status: 200, text: ocs([shareJson()]) }]);
const share = await service.update('t1', 'u1', '17', {});
expect(share.id).toBe('17');
expect(calls).toHaveLength(1);
});
it('gleiche Berechtigung wie bisher: kein PUT', async () => {
const { service, calls } = setup([{ status: 200, text: ocs([shareJson()]) }]);
await service.update('t1', 'u1', '17', { access: 'edit' });
expect(calls).toHaveLength(1);
});
it('Bearbeiten bei einem Eintrag ohne Schreibrecht und Hochladen sind shareAccessInvalid', async () => {
const ro = setup([
{ status: 200, text: ocs([shareJson({ permissions: 1, item_permissions: 17 })]) },
]);
expect(codeOf(await failure(ro.service.update('t1', 'u1', '17', { access: 'edit' })))).toBe(
'shareAccessInvalid',
);
const up = setup([{ status: 200, text: ocs([shareJson()]) }]);
expect(codeOf(await failure(up.service.update('t1', 'u1', '17', { access: 'upload' })))).toBe(
'shareAccessInvalid',
);
});
it('GET der Freigabe 404 -> shareNotFound; 401 markiert die Verbindung', async () => {
const gone = setup([{ status: 404, text: ocs([], 404, 'Wrong share ID') }]);
expect(codeOf(await failure(gone.service.update('t1', 'u1', '17', { access: 'view' })))).toBe(
'shareNotFound',
);
const dead = setup([{ status: 401 }]);
const e = await failure(dead.service.update('t1', 'u1', '17', { access: 'view' }));
expect(codeOf(e)).toBe('connectionExpired');
expect(dead.account.markExpired).toHaveBeenCalledTimes(1);
});
});
describe('NextcloudFilesSharesService — remove, lesen, Richtlinie', () => {
it('remove: DELETE auf die Kennung', async () => {
const { service, calls } = setup([{ status: 200, text: ocs([]) }]);
await expect(service.remove('t1', 'u1', '17')).resolves.toEqual({ deleted: true });
expect(calls[0].method).toBe('DELETE');
expect(calls[0].url).toBe(`${SHARES}/17`);
expect(calls[0].body).toBeNull();
});
it('remove: 404 -> shareNotFound, 403 -> 422 shareRejected, falsche Kennung ohne Aufruf', async () => {
const nf = setup([{ status: 404, text: ocs([], 404, 'Wrong share ID') }]);
expect(codeOf(await failure(nf.service.remove('t1', 'u1', '17')))).toBe('shareNotFound');
const forbidden = setup([{ status: 403, text: ocs([], 403, 'Could not delete share') }]);
const e = await failure(forbidden.service.remove('t1', 'u1', '17'));
expect(codeOf(e)).toBe('shareRejected');
expect(statusOf(e)).toBe(422);
expect(bodyOf(e).ncMessage).toBe('Could not delete share');
const bad = setup([]);
expect(codeOf(await failure(bad.service.remove('t1', 'u1', 'abc')))).toBe('shareNotFound');
expect(bad.calls).toHaveLength(0);
});
it('Freigaben eines Pfads: Pfad normalisiert, Wurzel ist invalidPath', async () => {
const { service, calls } = setup([
{ status: 200, text: ocs([shareJson(), { share_type: 4, id: '9' }]) },
]);
const out = await service.sharesForPath('t1', 'u1', '/Projekte/');
expect(calls[0].url).toBe(BY_PATH);
expect(out.path).toBe('/Projekte');
expect(out.shares.map((s) => s.id)).toEqual(['17']);
expect(out.hidden).toBe(1);
const root = setup([]);
expect(codeOf(await failure(root.service.sharesForPath('t1', 'u1', '/')))).toBe('invalidPath');
const nf = setup([{ status: 404, text: ocs([], 404, 'Wrong path') }]);
expect(codeOf(await failure(nf.service.sharesForPath('t1', 'u1', '/x')))).toBe('notFound');
});
it('Empfaengersuche: genau die erwartete Adresse; leerer Suchbegriff ruft nichts auf', async () => {
const { service, calls } = setup([
{
status: 200,
text: ocs({
users: [
{
label: 'Ben Beispiel',
value: { shareType: 0, shareWith: 'ben' },
shareWithDisplayNameUnique: 'ben',
},
],
groups: [],
}),
},
]);
const out = await service.sharees('t1', 'u1', ' ben ', 'folder');
expect(calls[0].url).toBe(
`${OCS}/apps/files_sharing/api/v1/sharees?search=ben&itemType=folder&perPage=20&shareType%5B0%5D=0&shareType%5B1%5D=1`,
);
expect(out.sharees).toEqual([
{ kind: 'user', id: 'ben', label: 'Ben Beispiel', detail: 'ben' },
]);
const empty = setup([]);
await expect(empty.service.sharees('t1', 'u1', ' ', 'file')).resolves.toEqual({ sharees: [] });
expect(empty.calls).toHaveLength(0);
});
it('Richtlinie: zwei Aufrufe -> zwei Anfragen an die Faehigkeiten (kein Zwischenspeicher)', async () => {
const { service, calls } = setup([
{ status: 200, text: CAPS() },
{ status: 200, text: CAPS({ group_sharing: false }) },
]);
const first = await service.policy('t1', 'u1');
const second = await service.policy('t1', 'u1');
expect(calls.map((c) => c.url)).toEqual([CAPS_URL, CAPS_URL]);
expect(first.groupsEnabled).toBe(true);
expect(second.groupsEnabled).toBe(false);
});
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'));
expect([401, 403]).not.toContain(statusOf(e));
});
});
@@ -0,0 +1,377 @@
import { Inject, Injectable } from '@nestjs/common';
import { NextcloudCallGate } from './nextcloud-call-gate';
import * as dav from './nextcloud-dav';
import { type NcSession, ncErrorDefault } from './nextcloud-files.types';
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
import { NEXTCLOUD_TRANSPORT, type NextcloudTransport, parseUserPath } from './nextcloud-http';
import { NextcloudLoginGuard } from './nextcloud-login-guard';
import type { NcEntry } from './nextcloud-propfind';
import {
CAPABILITIES_SEGMENTS,
isShareId,
type NcItemType,
type NcShareKind,
type NcShareView,
type NcSharee,
type NcSharePolicy,
OCS_CAPABILITIES_MAX_BYTES,
type OcsShareOptions,
ocsShareRequest,
parseShareList,
parseSharePolicy,
parseSharees,
permissionsFor,
SHAREE_SEGMENTS,
SHARES_BASE_SEGMENTS,
} from './nextcloud-shares';
import { mapNcFailure } from './nextcloud-upstream';
/**
* Freigaben im Konto des angemeldeten Benutzers (quick-261009-dkv, D-11 bis D-16).
*
* Rechte: jede Methode beginnt mit `getSession(tenantId, userId)` — Mandant und
* Benutzer kommen aus dem Token, jeder Aufruf geht mit dem eigenen App-Passwort des
* Aufrufers an Nextcloud. Nextcloud bleibt die entscheidende Stelle (Eigentum,
* Teilen-Recht, Richtlinien); die API prueft vorher nur, was sie aus den
* Faehigkeiten und dem Eintrag selbst wissen kann, damit nie eine unlesbare
* Nextcloud-Meldung entsteht (Nextcloud antwortet beim Aendern auf fast alles mit
* "Failed to update share."). Dieser Dienst greift nicht auf die Datenbank zu.
*
* Fehlervertrag wie in Etappe 1: nie 401/403 an den Browser; Nextcloud-Meldungen
* (uebersetzt, nie auswerten!) hoechstens als `ncMessage` in einer zweiten Zeile.
* Nie loggen: Koerper, Passwoerter, Kennungen, Link-Adressen.
*/
/** Eingabe fuer eine neue Freigabe an Person oder Gruppe (Links folgen in Aufgabe 2). */
export interface CreateShareInput {
path: string;
kind: 'user' | 'group';
shareWith: string;
access: 'view' | 'edit' | 'upload';
}
export interface UpdateShareInput {
access?: 'view' | 'edit' | 'upload';
}
/** Welche Felder gesendet wurden: bestimmt, wie ein Fehler gedeutet wird (D-15). */
interface SentFields {
kind?: NcShareKind;
passwordNonEmpty?: boolean;
expireDateSent?: boolean;
expireDateNonEmpty?: boolean;
}
type Operation = 'create' | 'update' | 'remove' | 'accept' | 'read-path' | 'read-id' | 'other';
const GROUPS_OFF = 'Teilen mit Gruppen ist in Ihrer Nextcloud ausgeschaltet.';
function pathOf(segments: readonly string[]): string {
return `/${segments.join('/')}`;
}
@Injectable()
export class NextcloudFilesSharesService {
constructor(
private readonly account: NextcloudFilesAccountService,
private readonly gate: NextcloudCallGate,
@Inject(NEXTCLOUD_TRANSPORT) private readonly transport: NextcloudTransport,
private readonly guard: NextcloudLoginGuard,
) {}
private session(tenantId: string, userId: string): Promise<NcSession> {
return this.account.getSession(tenantId, userId);
}
private async transportFail(
tenantId: string,
userId: string,
result: { ok: boolean; kind?: string; status?: number; retryAfterSeconds?: number },
): Promise<never> {
throw await mapNcFailure(result, {
onExpired: () => this.account.markExpired(tenantId, userId),
});
}
/**
* D-15: ein nicht erfolgreicher Aufruf wird zu genau einem deutschen Fehler. Transportfehler,
* abgelaufene Zugaenge, 429 und jeder Status ab 500 gehen unveraendert durch `mapNcFailure`
* (Etappe-1-Vertrag). Danach zaehlt nur (Aufruf, Status, gesendete Felder) — nie der Text.
*/
private async mapShareFailure(
tenantId: string,
userId: string,
operation: Operation,
result: {
ok: boolean;
kind?: string;
status?: number;
message?: string | null;
retryAfterSeconds?: number;
},
sent: SentFields,
): Promise<never> {
const status = result.status;
if (!result.ok || status === undefined || status >= 500 || status === 401 || status === 429) {
return this.transportFail(tenantId, userId, result);
}
if (status < 400) return this.transportFail(tenantId, userId, { ok: true, status: 502 });
const extra = result.message ? { ncMessage: result.message } : undefined;
const rejected = () => ncErrorDefault('shareRejected', extra);
switch (operation) {
case 'create':
if (status === 400 && sent.passwordNonEmpty) {
throw ncErrorDefault('sharePasswordRejected', extra);
}
if (status === 404 && sent.expireDateNonEmpty) {
throw ncErrorDefault('shareExpiryInvalid', extra);
}
if (status === 404) {
throw sent.kind === 'link'
? ncErrorDefault('notFound')
: ncErrorDefault('shareRecipientInvalid', extra);
}
throw rejected();
case 'update':
if (status === 400 && sent.passwordNonEmpty) {
throw ncErrorDefault('sharePasswordRejected', extra);
}
if ((status === 400 || status === 404) && sent.expireDateSent) {
throw ncErrorDefault('shareExpiryInvalid', extra);
}
if (status === 404) throw ncErrorDefault('shareNotFound');
throw rejected();
case 'remove':
case 'accept':
case 'read-id':
if (status === 404) throw ncErrorDefault('shareNotFound');
throw rejected();
case 'read-path':
if (status === 404) throw ncErrorDefault('notFound');
throw rejected();
default:
throw rejected();
}
}
/** Ruft Nextcloud auf und liefert die Daten einer erfolgreichen (2xx) Antwort, sonst wirft es. */
private async run(
tenantId: string,
userId: string,
session: NcSession,
opts: OcsShareOptions,
operation: Operation,
sent: SentFields = {},
): Promise<unknown> {
const result = await ocsShareRequest(this.transport, this.gate, session, opts);
if (!result.ok || result.status < 200 || result.status >= 300) {
return this.mapShareFailure(tenantId, userId, operation, result, sent);
}
return result.data;
}
/** D-13: Richtlinie bei jeder Nutzung frisch lesen (je Benutzer verschieden, nie zwischengespeichert). */
private async loadPolicy(
tenantId: string,
userId: string,
session: NcSession,
): Promise<NcSharePolicy> {
const data = await this.run(
tenantId,
userId,
session,
{ method: 'GET', segments: CAPABILITIES_SEGMENTS, maxBytes: OCS_CAPABILITIES_MAX_BYTES },
'other',
);
return parseSharePolicy(data);
}
private async listByPath(tenantId: string, userId: string, session: NcSession, path: string) {
const data = await this.run(
tenantId,
userId,
session,
{
method: 'GET',
segments: SHARES_BASE_SEGMENTS,
query: { path, reshares: 'true' },
},
'read-path',
);
return parseShareList(data, session.ncUserId);
}
private async loadShare(
tenantId: string,
userId: string,
session: NcSession,
id: string,
): Promise<NcShareView> {
const data = await this.run(
tenantId,
userId,
session,
{ method: 'GET', segments: [...SHARES_BASE_SEGMENTS, id] },
'read-id',
);
const share = parseShareList(data, session.ncUserId).shares[0];
// Eine Freigabe anderer Art (E-Mail, Server ...) gibt es fuer Tessera nicht.
if (!share) throw ncErrorDefault('shareNotFound');
return share;
}
// --- Lesen ----------------------------------------------------------------------------------
async policy(tenantId: string, userId: string): Promise<NcSharePolicy> {
const session = await this.session(tenantId, userId);
return this.loadPolicy(tenantId, userId, session);
}
async sharesForPath(tenantId: string, userId: string, rawPath: string) {
const segments = parseUserPath(rawPath);
if (segments.length === 0) throw ncErrorDefault('invalidPath');
const session = await this.session(tenantId, userId);
const path = pathOf(segments);
const list = await this.listByPath(tenantId, userId, session, path);
return { path, ...list };
}
async sharees(
tenantId: string,
userId: string,
term: string,
itemType: NcItemType,
): Promise<{ sharees: NcSharee[] }> {
const search = term.trim();
if (search.length < 1) return { sharees: [] };
const session = await this.session(tenantId, userId);
const data = await this.run(
tenantId,
userId,
session,
{
method: 'GET',
segments: SHAREE_SEGMENTS,
query: {
search,
itemType,
perPage: '20',
'shareType[0]': '0',
'shareType[1]': '1',
},
},
'other',
);
return { sharees: parseSharees(data) };
}
// --- Schreiben ------------------------------------------------------------------------------
/** Darf dieser Eintrag laut Nextcloud-Buchstaben geaendert oder befuellt werden (W, C oder K)? */
private entryWritable(entry: NcEntry): boolean {
return /[WCK]/.test(entry.permissions);
}
async create(tenantId: string, userId: string, input: CreateShareInput): Promise<NcShareView> {
const segments = parseUserPath(input.path);
if (segments.length === 0) throw ncErrorDefault('invalidPath');
const session = await this.session(tenantId, userId);
// Art und Schreibbarkeit kommen aus der eigenen Abfrage, nie aus dem Browser (T-dkv-01).
const stat = await dav.stat(this.transport, this.gate, session, segments);
if (!stat.ok) return this.transportFail(tenantId, userId, stat);
if (stat.entry === null) {
if (stat.status === 404) throw ncErrorDefault('notFound');
return this.transportFail(tenantId, userId, { ok: true, status: stat.status });
}
const entry = stat.entry;
const policy = await this.loadPolicy(tenantId, userId, session);
if (!policy.enabled) throw ncErrorDefault('sharingDisabled');
if (input.kind === 'group' && !policy.groupsEnabled) {
throw ncErrorDefault('sharingDisabled', undefined, GROUPS_OFF);
}
if (!entry.permissions.includes('R')) throw ncErrorDefault('shareRejected');
if (input.access === 'upload' || (input.access === 'edit' && !this.entryWritable(entry))) {
throw ncErrorDefault('shareAccessInvalid');
}
const permissions = permissionsFor(input.access, entry.type);
if (permissions === null) throw ncErrorDefault('shareAccessInvalid');
const path = pathOf(segments);
const existing = await this.listByPath(tenantId, userId, session, path);
if (existing.shares.some((s) => s.kind === input.kind && s.shareWith === input.shareWith)) {
throw ncErrorDefault('shareAlreadyExists');
}
// Erst nach jeder Vorpruefung zaehlen: abgelehnte Eingaben verbrauchen kein Kontingent.
this.guard.checkShareCreate(userId);
const data = await this.run(
tenantId,
userId,
session,
{
method: 'POST',
segments: SHARES_BASE_SEGMENTS,
json: {
path,
shareType: input.kind === 'user' ? 0 : 1,
shareWith: input.shareWith,
permissions,
},
},
'create',
{ kind: input.kind },
);
const created = parseShareList(data, session.ncUserId).shares[0];
if (!created) throw ncErrorDefault('nextcloudError');
return created;
}
async update(
tenantId: string,
userId: string,
id: string,
input: UpdateShareInput,
): Promise<NcShareView> {
if (!isShareId(id)) throw ncErrorDefault('shareNotFound');
const session = await this.session(tenantId, userId);
const current = await this.loadShare(tenantId, userId, session, id);
if (!current.canEdit) throw ncErrorDefault('shareRejected');
if (input.access === undefined) return current;
if (current.kind === 'link') throw ncErrorDefault('shareAccessInvalid');
if (input.access === 'upload' || (input.access === 'edit' && !current.itemWritable)) {
throw ncErrorDefault('shareAccessInvalid');
}
const permissions = permissionsFor(input.access, current.itemType);
if (permissions === null) throw ncErrorDefault('shareAccessInvalid');
if (current.access === input.access) return current;
const data = await this.run(
tenantId,
userId,
session,
{ method: 'PUT', segments: [...SHARES_BASE_SEGMENTS, id], json: { permissions } },
'update',
{ kind: current.kind },
);
const updated = parseShareList(data, session.ncUserId).shares[0];
if (!updated) throw ncErrorDefault('nextcloudError');
return updated;
}
async remove(tenantId: string, userId: string, id: string): Promise<{ deleted: true }> {
if (!isShareId(id)) throw ncErrorDefault('shareNotFound');
const session = await this.session(tenantId, userId);
await this.run(
tenantId,
userId,
session,
{ method: 'DELETE', segments: [...SHARES_BASE_SEGMENTS, id] },
'remove',
);
return { deleted: true };
}
}
@@ -16,6 +16,15 @@ const ADMIN_HANDLERS = ['saveSettings'];
/** Hoch- und Herunterladen (quick-261008-mzu, Aufgabe 4): alle auf Benutzen-Ebene. */ /** Hoch- und Herunterladen (quick-261008-mzu, Aufgabe 4): alle auf Benutzen-Ebene. */
const TRANSFER_STATIC = ['download', 'downloadZip', 'startUpload', 'putSingle']; const TRANSFER_STATIC = ['download', 'downloadZip', 'startUpload', 'putSingle'];
const TRANSFER_PARAM = ['putChunk', 'completeUpload', 'uploadState', 'abortUpload']; const TRANSFER_PARAM = ['putChunk', 'completeUpload', 'uploadState', 'abortUpload'];
/** Teilen (quick-261009-dkv): alle auf Benutzen-Ebene. */
const SHARE_HANDLERS = [
'getSharePolicy',
'listSharesForPath',
'searchSharees',
'createShare',
'updateShare',
'deleteShare',
];
const TRANSFER_HANDLERS = [...TRANSFER_STATIC, ...TRANSFER_PARAM]; const TRANSFER_HANDLERS = [...TRANSFER_STATIC, ...TRANSFER_PARAM];
/** Alle Handler mit Routenpfad, in Deklarationsreihenfolge. */ /** Alle Handler mit Routenpfad, in Deklarationsreihenfolge. */
@@ -109,6 +118,34 @@ describe('NextcloudFilesController — Metadaten', () => {
expect(route('abortUpload')).toEqual([3, 'uploads/:uploadId']); expect(route('abortUpload')).toEqual([3, 'uploads/:uploadId']);
expect(route('pollFlow')).toEqual([0, 'connect/flow/:flowId']); expect(route('pollFlow')).toEqual([0, 'connect/flow/:flowId']);
expect(route('cancelFlow')).toEqual([3, 'connect/flow/:flowId']); expect(route('cancelFlow')).toEqual([3, 'connect/flow/:flowId']);
// Teilen (quick-261009-dkv)
expect(route('getSharePolicy')).toEqual([0, 'shares/policy']);
expect(route('listSharesForPath')).toEqual([0, 'shares/by-path']);
expect(route('searchSharees')).toEqual([0, 'sharees']);
expect(route('createShare')).toEqual([1, 'shares']);
expect(route('updateShare')).toEqual([2, 'shares/:id']);
expect(route('deleteShare')).toEqual([3, 'shares/:id']);
});
it('die Teilen-Handler tragen weder Verwalten noch einen Rollen-Decorator', () => {
for (const name of SHARE_HANDLERS) {
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined();
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
}
});
it('die beiden :id-Handler des Teilens stehen nach allen statischen Handlern', () => {
const names = routeHandlers();
for (const name of ['getSharePolicy', 'listSharesForPath', 'searchSharees', 'createShare']) {
expect(names.indexOf(name), name).toBeLessThan(names.indexOf('pollFlow'));
}
const staticLast = Math.max(
...names
.filter((n) => !String(Reflect.getMetadata('path', proto[n])).includes(':'))
.map((n) => names.indexOf(n)),
);
expect(names.indexOf('updateShare')).toBeGreaterThan(staticLast);
expect(names.indexOf('deleteShare')).toBeGreaterThan(staticLast);
}); });
it('keiner der Anmelde-Handler traegt Verwalten oder einen Rollen-Decorator', () => { it('keiner der Anmelde-Handler traegt Verwalten oder einen Rollen-Decorator', () => {
@@ -246,6 +283,17 @@ describe('NextcloudFilesController — Delegation', () => {
}; };
} }
function makeShares() {
return {
policy: vi.fn(async (..._a: unknown[]) => ({ enabled: true })),
sharesForPath: vi.fn(async (..._a: unknown[]) => ({ shares: [] })),
sharees: vi.fn(async (..._a: unknown[]) => ({ sharees: [] })),
create: vi.fn(async (..._a: unknown[]) => ({ id: '1' })),
update: vi.fn(async (..._a: unknown[]) => ({ id: '1' })),
remove: vi.fn(async (..._a: unknown[]) => ({ deleted: true })),
};
}
function makeFiles() { function makeFiles() {
return { return {
list: vi.fn(async (..._a: unknown[]) => ({ entries: [] })), list: vi.fn(async (..._a: unknown[]) => ({ entries: [] })),
@@ -264,6 +312,7 @@ describe('NextcloudFilesController — Delegation', () => {
files as any, files as any,
makeTransfer() as any, makeTransfer() as any,
makeServerInfo() as any, makeServerInfo() as any,
makeShares() as any,
); );
const r = userReq('t1', 'u1'); const r = userReq('t1', 'u1');
const res = { end: vi.fn() } as any; const res = { end: vi.fn() } as any;
@@ -289,6 +338,7 @@ describe('NextcloudFilesController — Delegation', () => {
makeFiles() as any, makeFiles() as any,
transfer as any, transfer as any,
makeServerInfo() as any, makeServerInfo() as any,
makeShares() as any,
); );
const r = userReq('t1', 'u1'); const r = userReq('t1', 'u1');
(r as any).headers = { range: 'bytes=0-99' }; (r as any).headers = { range: 'bytes=0-99' };
@@ -363,6 +413,7 @@ describe('NextcloudFilesController — Delegation', () => {
makeFiles() as any, makeFiles() as any,
transfer as any, transfer as any,
makeServerInfo() as any, makeServerInfo() as any,
makeShares() as any,
); );
const r = userReq('t1', undefined); const r = userReq('t1', undefined);
const res = {} as any; const res = {} as any;
@@ -378,6 +429,51 @@ describe('NextcloudFilesController — Delegation', () => {
expect(transfer.uploadState).not.toHaveBeenCalled(); expect(transfer.uploadState).not.toHaveBeenCalled();
}); });
it('Teilen-Handler: Mandant und Benutzer aus dem Token, Eingaben aus Query und Body, Kennung als Zeichenkette', async () => {
const shares = makeShares();
const controller = new NextcloudFilesController(
makeSettings() as any,
makeAccount() as any,
makeFiles() as any,
makeTransfer() as any,
makeServerInfo() as any,
shares as any,
);
const r = userReq('t1', 'u1');
await controller.getSharePolicy(r);
await controller.listSharesForPath(r, { path: '/Projekte' } as any);
await controller.searchSharees(r, { term: 'ben', itemType: 'folder' } as any);
const dto = { path: '/Projekte', kind: 'user', shareWith: 'ben', access: 'edit' } as any;
await controller.createShare(r, dto);
await controller.updateShare(r, '17', { access: 'view' } as any);
await controller.deleteShare(r, '17');
expect(shares.policy).toHaveBeenCalledWith('t1', 'u1');
expect(shares.sharesForPath).toHaveBeenCalledWith('t1', 'u1', '/Projekte');
expect(shares.sharees).toHaveBeenCalledWith('t1', 'u1', 'ben', 'folder');
expect(shares.create).toHaveBeenCalledWith('t1', 'u1', dto);
expect(shares.update).toHaveBeenCalledWith('t1', 'u1', '17', { access: 'view' });
expect(shares.remove).toHaveBeenCalledWith('t1', 'u1', '17');
});
it('Teilen-Handler ohne Benutzer im Token: ForbiddenException, kein Dienstaufruf', async () => {
const shares = makeShares();
const controller = new NextcloudFilesController(
makeSettings() as any,
makeAccount() as any,
makeFiles() as any,
makeTransfer() as any,
makeServerInfo() as any,
shares as any,
);
const r = userReq('t1', undefined);
await expect(controller.getSharePolicy(r)).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.createShare(r, {} as any)).rejects.toBeInstanceOf(ForbiddenException);
await expect(controller.deleteShare(r, '1')).rejects.toBeInstanceOf(ForbiddenException);
const noTenant = userReq(undefined, 'u1');
await expect(controller.getSharePolicy(noTenant)).rejects.toBeInstanceOf(ForbiddenException);
for (const fn of Object.values(shares)) expect(fn).not.toHaveBeenCalled();
});
it('Datei-Handler ohne Benutzer im Token: ForbiddenException, kein Dienstaufruf', async () => { it('Datei-Handler ohne Benutzer im Token: ForbiddenException, kein Dienstaufruf', async () => {
const files = makeFiles(); const files = makeFiles();
const controller = new NextcloudFilesController( const controller = new NextcloudFilesController(
@@ -386,6 +482,7 @@ describe('NextcloudFilesController — Delegation', () => {
files as any, files as any,
makeTransfer() as any, makeTransfer() as any,
makeServerInfo() as any, makeServerInfo() as any,
makeShares() as any,
); );
const r = userReq('t1', undefined); const r = userReq('t1', undefined);
await expect(controller.list(r, {} as any)).rejects.toBeInstanceOf(ForbiddenException); await expect(controller.list(r, {} as any)).rejects.toBeInstanceOf(ForbiddenException);
@@ -405,6 +502,7 @@ describe('NextcloudFilesController — Delegation', () => {
makeFiles() as any, makeFiles() as any,
makeTransfer() as any, makeTransfer() as any,
makeServerInfo() as any, makeServerInfo() as any,
makeShares() as any,
); );
await controller.getStatus(userReq('t1', 'u1')); await controller.getStatus(userReq('t1', 'u1'));
const admin = { tenantId: 't1', user: { id: 'a1', role: 'ADMIN' } } as any; const admin = { tenantId: 't1', user: { id: 'a1', role: 'ADMIN' } } as any;
@@ -439,6 +537,7 @@ describe('NextcloudFilesController — Delegation', () => {
makeFiles() as any, makeFiles() as any,
makeTransfer() as any, makeTransfer() as any,
makeServerInfo() as any, makeServerInfo() as any,
makeShares() as any,
); );
const manager = { tenantId: 't1', user: { id: 'm1', role: 'USER' } } as any; const manager = { tenantId: 't1', user: { id: 'm1', role: 'USER' } } as any;
await controller.testSettings(manager, { baseUrl: 'https://CLOUD.example/' } as any); await controller.testSettings(manager, { baseUrl: 'https://CLOUD.example/' } as any);
@@ -463,6 +562,7 @@ describe('NextcloudFilesController — Delegation', () => {
makeFiles() as any, makeFiles() as any,
makeTransfer() as any, makeTransfer() as any,
makeServerInfo() as any, makeServerInfo() as any,
makeShares() as any,
); );
await expect(controller.getStatus(userReq(undefined, 'u1'))).rejects.toBeInstanceOf( await expect(controller.getStatus(userReq(undefined, 'u1'))).rejects.toBeInstanceOf(
ForbiddenException, ForbiddenException,
@@ -481,6 +581,7 @@ describe('NextcloudFilesController — Delegation', () => {
makeFiles() as any, makeFiles() as any,
makeTransfer() as any, makeTransfer() as any,
serverInfo as any, serverInfo as any,
makeShares() as any,
); );
const res = { end: vi.fn() } as any; const res = { end: vi.fn() } as any;
await controller.getServer(userReq('t1', 'u1')); await controller.getServer(userReq('t1', 'u1'));
@@ -500,6 +601,7 @@ describe('NextcloudFilesController — Delegation', () => {
makeFiles() as any, makeFiles() as any,
makeTransfer() as any, makeTransfer() as any,
makeServerInfo() as any, makeServerInfo() as any,
makeShares() as any,
); );
await expect(controller.getStatus(userReq('t1', undefined))).rejects.toBeInstanceOf( await expect(controller.getStatus(userReq('t1', undefined))).rejects.toBeInstanceOf(
ForbiddenException, ForbiddenException,
@@ -26,6 +26,12 @@ import {
PathQueryDto, PathQueryDto,
PreviewQueryDto, PreviewQueryDto,
} from './dto/nextcloud-files-ops.dto'; } from './dto/nextcloud-files-ops.dto';
import {
CreateShareDto,
ShareByPathQueryDto,
ShareeQueryDto,
UpdateShareDto,
} from './dto/nextcloud-files-shares.dto';
import { import {
SaveNextcloudFilesSettingsDto, SaveNextcloudFilesSettingsDto,
TestNextcloudFilesSettingsDto, TestNextcloudFilesSettingsDto,
@@ -39,6 +45,7 @@ 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 { import {
NextcloudFilesTransferService, NextcloudFilesTransferService,
@@ -64,10 +71,12 @@ import { NextcloudServerInfoService } from './nextcloud-server-info';
* GET server/logo, POST connect/password, POST connect/flow, * GET server/logo, POST connect/password, POST connect/flow,
* DELETE connect, GET files, DELETE files, POST folders, POST move, * DELETE connect, GET files, DELETE files, POST folders, POST move,
* GET preview, GET download, POST download/zip, POST uploads, PUT * GET preview, GET download, POST download/zip, POST uploads, PUT
* uploads/file; danach die Parameterrouten am ENDE: GET/DELETE * uploads/file, GET shares/policy, GET shares/by-path, GET sharees,
* connect/flow/:flowId, PUT uploads/:uploadId/chunks/:n, POST * POST shares (quick-261009-dkv: Teilen); danach die Parameterrouten am
* uploads/:uploadId/complete, GET uploads/:uploadId/state, DELETE * ENDE: GET/DELETE connect/flow/:flowId, PUT uploads/:uploadId/chunks/:n,
* uploads/:uploadId. Jeder Benutzer arbeitet nur im eigenen Konto. * POST uploads/:uploadId/complete, GET uploads/:uploadId/state, DELETE
* uploads/:uploadId, PUT/DELETE shares/:id. Jeder Benutzer arbeitet nur
* im eigenen Konto.
* Auf Verwalten-Handlern steht NIE ein Rollen-Decorator — der globale * Auf Verwalten-Handlern steht NIE ein Rollen-Decorator — der globale
* RolesGuard wuerde Verwalter sonst aussperren. Der reine Administrator-Handler * RolesGuard wuerde Verwalter sonst aussperren. Der reine Administrator-Handler
* traegt dafuer kein `@ModuleManage` (Muster wie `TendersController.getSourceConfig`). * traegt dafuer kein `@ModuleManage` (Muster wie `TendersController.getSourceConfig`).
@@ -104,6 +113,7 @@ export class NextcloudFilesController {
private readonly files: NextcloudFilesService, private readonly files: NextcloudFilesService,
private readonly transfer: NextcloudFilesTransferService, private readonly transfer: NextcloudFilesTransferService,
private readonly serverInfo: NextcloudServerInfoService, private readonly serverInfo: NextcloudServerInfoService,
private readonly shares: NextcloudFilesSharesService,
) {} ) {}
private requireTenantId(req: AuthenticatedRequest): string { private requireTenantId(req: AuthenticatedRequest): string {
@@ -290,6 +300,39 @@ export class NextcloudFilesController {
); );
} }
// --- Teilen (statisch; Benutzen; Freigaben gibt es nur im eigenen Konto) ----------------
/** Freigaberegeln der Nextcloud des Aufrufers (frisch gelesen, nie zwischengespeichert). */
@Get('shares/policy')
async getSharePolicy(@Req() req: AuthenticatedRequest) {
return this.shares.policy(this.requireTenantId(req), this.requireUserId(req));
}
@Get('shares/by-path')
async listSharesForPath(@Req() req: AuthenticatedRequest, @Query() query: ShareByPathQueryDto) {
return this.shares.sharesForPath(
this.requireTenantId(req),
this.requireUserId(req),
query.path,
);
}
/** Empfaengersuche (Personen und Gruppen) ueber die Nextcloud. */
@Get('sharees')
async searchSharees(@Req() req: AuthenticatedRequest, @Query() query: ShareeQueryDto) {
return this.shares.sharees(
this.requireTenantId(req),
this.requireUserId(req),
query.term,
query.itemType,
);
}
@Post('shares')
async createShare(@Req() req: AuthenticatedRequest, @Body() dto: CreateShareDto) {
return this.shares.create(this.requireTenantId(req), this.requireUserId(req), dto);
}
// --- Parameterrouten: IMMER am Ende der Klasse (Reihenfolge-Regel oben) --------------- // --- Parameterrouten: IMMER am Ende der Klasse (Reihenfolge-Regel oben) ---------------
@Get('connect/flow/:flowId') @Get('connect/flow/:flowId')
@@ -353,4 +396,19 @@ export class NextcloudFilesController {
async abortUpload(@Req() req: AuthenticatedRequest, @Param('uploadId') uploadId: string) { async abortUpload(@Req() req: AuthenticatedRequest, @Param('uploadId') uploadId: string) {
return this.transfer.abortUpload(this.requireTenantId(req), this.requireUserId(req), uploadId); return this.transfer.abortUpload(this.requireTenantId(req), this.requireUserId(req), uploadId);
} }
/** Die Kennung prueft der Dienst (nur Ziffern); die Berechtigung kommt als Auswahl, nie als Maske. */
@Put('shares/:id')
async updateShare(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Body() dto: UpdateShareDto,
) {
return this.shares.update(this.requireTenantId(req), this.requireUserId(req), id, dto);
}
@Delete('shares/:id')
async deleteShare(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.shares.remove(this.requireTenantId(req), this.requireUserId(req), id);
}
} }
@@ -6,6 +6,7 @@ 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 { 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';
@@ -27,6 +28,7 @@ import { NextcloudServerInfoService } from './nextcloud-server-info';
NextcloudFilesAccountService, NextcloudFilesAccountService,
NextcloudFilesService, NextcloudFilesService,
NextcloudFilesTransferService, NextcloudFilesTransferService,
NextcloudFilesSharesService,
NextcloudServerInfoService, NextcloudServerInfoService,
NextcloudLoginGuard, NextcloudLoginGuard,
LoginFlowStore, LoginFlowStore,
@@ -41,7 +41,19 @@ export type NcErrorCode =
| 'flowExpired' | 'flowExpired'
| 'tooManyFlows' | 'tooManyFlows'
| 'invalidUrl' | 'invalidUrl'
| 'confirmReconnect'; | 'confirmReconnect'
| 'sharingDisabled'
| 'linkSharingDisabled'
| 'shareAccessInvalid'
| 'shareRecipientInvalid'
| 'shareAlreadyExists'
| 'sharePasswordRequired'
| 'sharePasswordRejected'
| 'shareExpiryRequired'
| 'shareExpiryInvalid'
| 'shareRejected'
| 'shareNotFound'
| 'tooManyShares';
interface ErrorDefault { interface ErrorDefault {
status: number; status: number;
@@ -179,6 +191,56 @@ export const NC_ERROR_DEFAULTS: Record<NcErrorCode, ErrorDefault> = {
message: message:
'Bei einem Wechsel der Adresse müssen sich alle verbundenen Benutzer neu anmelden. Bitte bestätigen Sie den Wechsel.', 'Bei einem Wechsel der Adresse müssen sich alle verbundenen Benutzer neu anmelden. Bitte bestätigen Sie den Wechsel.',
}, },
sharingDisabled: {
status: 409,
message: 'Teilen ist in Ihrer Nextcloud ausgeschaltet.',
},
linkSharingDisabled: {
status: 409,
message: 'Öffentliche Links sind in Ihrer Nextcloud ausgeschaltet.',
},
shareAccessInvalid: {
status: 400,
message: 'Diese Berechtigung ist für diesen Eintrag nicht möglich.',
},
shareRecipientInvalid: {
status: 422,
message: 'Diese Person oder Gruppe kennt Ihre Nextcloud nicht.',
},
shareAlreadyExists: {
status: 409,
message: 'Der Eintrag ist schon so geteilt. Ändern Sie die vorhandene Freigabe.',
},
sharePasswordRequired: {
status: 400,
message: 'Ihre Nextcloud verlangt für Links ein Passwort.',
},
sharePasswordRejected: {
status: 400,
message:
'Nextcloud lehnt dieses Passwort ab. Bitte wählen Sie ein längeres oder weniger gebräuchliches Passwort.',
},
shareExpiryRequired: {
status: 400,
message: 'Ihre Nextcloud verlangt für Links ein Ablaufdatum.',
},
shareExpiryInvalid: {
status: 400,
message:
'Dieses Ablaufdatum lässt Ihre Nextcloud nicht zu. Es darf nicht in der Vergangenheit und nicht nach dem erlaubten Höchstdatum liegen.',
},
shareRejected: {
status: 422,
message: 'Nextcloud hat diese Freigabe abgelehnt.',
},
shareNotFound: {
status: 404,
message: 'Diese Freigabe gibt es nicht mehr.',
},
tooManyShares: {
status: 429,
message: 'Sie haben in kurzer Zeit viele Freigaben angelegt. Bitte warten Sie einige Minuten.',
},
}; };
/** Baut die HttpException mit dem Koerper `{ code, message, ...extra }`. */ /** Baut die HttpException mit dem Koerper `{ code, message, ...extra }`. */
@@ -151,6 +151,47 @@ 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', () => {
const { guard, clock } = makeGuard();
for (let i = 0; i < 15; i++) {
guard.checkShareCreate('u1');
clock.t += 1000;
}
// aelteste Freigabe liegt 15 s zurueck -> 600 - 15 = 585 s
const blocked = codeOf(() => guard.checkShareCreate('u1'));
expect(blocked.status).toBe(429);
expect(blocked.body.code).toBe('tooManyShares');
expect(blocked.body.retryAfterSeconds).toBe(585);
});
it('Benutzer B ist von Benutzer A unabhaengig', () => {
const { guard } = makeGuard();
for (let i = 0; i < 15; 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');
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++) {
expect(codeOf(() => guard.checkShareCreate('a')).status).toBeUndefined();
}
expect(codeOf(() => guard.checkShareCreate('a')).status).toBe(429);
});
it('beruehrt weder Fehlerzaehler noch Flow-Starts', () => {
const { guard } = makeGuard();
for (let i = 0; i < 15; i++) guard.checkShareCreate('u1');
expect(codeOf(() => guard.checkPasswordAttempt('u1')).status).toBeUndefined();
expect(codeOf(() => guard.checkFlowStart('u1')).status).toBeUndefined();
});
});
describe('LoginFlowStore', () => { describe('LoginFlowStore', () => {
function makeStore() { function makeStore() {
const store = new LoginFlowStore(); const store = new LoginFlowStore();
@@ -37,6 +37,14 @@ export const SERVER_FAILURE_LIMIT = 8;
export const SERVER_FAILURE_WINDOW_MS = 30 * 60 * 1000; 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
* 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.
*/
export const SHARE_CREATE_LIMIT = 15;
export const SHARE_CREATE_WINDOW_MS = 10 * 60 * 1000;
/** Ein gezaehlter Versuch; `inFlight` = Nextcloud hat noch nicht geantwortet. */ /** Ein gezaehlter Versuch; `inFlight` = Nextcloud hat noch nicht geantwortet. */
interface Attempt { interface Attempt {
@@ -66,6 +74,12 @@ function removeAttempt(list: Attempt[] | undefined, attempt: Attempt): void {
if (i >= 0) list.splice(i, 1); if (i >= 0) list.splice(i, 1);
} }
function tooManyShares(retryAfterMs: number) {
return ncErrorDefault('tooManyShares', {
retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)),
});
}
function tooMany(retryAfterMs: number) { function tooMany(retryAfterMs: number) {
return ncErrorDefault('tooManyAttempts', { return ncErrorDefault('tooManyAttempts', {
retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)), retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)),
@@ -80,6 +94,7 @@ export class NextcloudLoginGuard {
private readonly userFailures = new Map<string, Attempt[]>(); private readonly userFailures = new Map<string, Attempt[]>();
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[]>();
/** /**
* Darf dieser Benutzer jetzt eine Passwort-Anmeldung versuchen? Wirft 429 * Darf dieser Benutzer jetzt eine Passwort-Anmeldung versuchen? Wirft 429
@@ -167,6 +182,22 @@ export class NextcloudLoginGuard {
starts.push(now); starts.push(now);
this.flowStarts.set(userId, starts); this.flowStarts.set(userId, starts);
} }
/**
* 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.
*/
checkShareCreate(userId: string): void {
const now = this.now();
const times = pruneTimes(this.shareCreates.get(userId) ?? [], now, SHARE_CREATE_WINDOW_MS);
if (times.length >= SHARE_CREATE_LIMIT) {
this.shareCreates.set(userId, times);
throw tooManyShares(times[0] + SHARE_CREATE_WINDOW_MS - now);
}
times.push(now);
this.shareCreates.set(userId, times);
}
} }
// --- Browser-Anmeldung (Login Flow v2) ----------------------------------------- // --- Browser-Anmeldung (Login Flow v2) -----------------------------------------
@@ -41,6 +41,7 @@ const XML = `<?xml version="1.0"?>
<oc:permissions>RGDNVCK</oc:permissions> <oc:permissions>RGDNVCK</oc:permissions>
<oc:size>5000</oc:size> <oc:size>5000</oc:size>
<oc:favorite>0</oc:favorite> <oc:favorite>0</oc:favorite>
<oc:share-types/>
<nc:has-preview>false</nc:has-preview> <nc:has-preview>false</nc:has-preview>
</d:prop> </d:prop>
<d:status>HTTP/1.1 200 OK</d:status> <d:status>HTTP/1.1 200 OK</d:status>
@@ -63,6 +64,7 @@ const XML = `<?xml version="1.0"?>
<oc:permissions>RGDNVW</oc:permissions> <oc:permissions>RGDNVW</oc:permissions>
<oc:size>42</oc:size> <oc:size>42</oc:size>
<oc:favorite>1</oc:favorite> <oc:favorite>1</oc:favorite>
<oc:share-types><oc:share-type>3</oc:share-type><oc:share-type>0</oc:share-type><oc:share-type>3</oc:share-type><oc:share-type>abc</oc:share-type></oc:share-types>
<nc:has-preview>true</nc:has-preview> <nc:has-preview>true</nc:has-preview>
</d:prop> </d:prop>
<d:status>HTTP/1.1 200 OK</d:status> <d:status>HTTP/1.1 200 OK</d:status>
@@ -113,6 +115,7 @@ describe('parsePropfind', () => {
permissions: 'RGDNVCK', permissions: 'RGDNVCK',
hasPreview: false, hasPreview: false,
favorite: false, favorite: false,
shareTypes: [],
}, },
{ {
name: '12345', name: '12345',
@@ -126,6 +129,7 @@ describe('parsePropfind', () => {
permissions: 'RGDNVW', permissions: 'RGDNVW',
hasPreview: true, hasPreview: true,
favorite: true, favorite: true,
shareTypes: [0, 3],
}, },
{ {
name: '50%25.txt', name: '50%25.txt',
@@ -139,10 +143,18 @@ describe('parsePropfind', () => {
permissions: 'RGDNVW', permissions: 'RGDNVW',
hasPreview: false, hasPreview: false,
favorite: false, favorite: false,
shareTypes: [],
}, },
]); ]);
}); });
it('Freigabearten (oc:share-types): doppelte und Nichtzahlen entfallen, leeres Element ist leer (quick-261009-dkv)', () => {
const entries = parsePropfind(XML, CTX).entries;
expect(entries.find((e) => e.name === '12345')?.shareTypes).toEqual([0, 3]);
expect(entries.find((e) => e.name === 'Ärger & Co')?.shareTypes).toEqual([]);
expect(entries.find((e) => e.name === '50%25.txt')?.shareTypes).toEqual([]);
});
it('der Name bleibt eine Zeichenkette (12345), nie eine Zahl', () => { it('der Name bleibt eine Zeichenkette (12345), nie eine Zahl', () => {
const entry = parsePropfind(XML, CTX).entries.find((e) => e.fileId === '00042'); const entry = parsePropfind(XML, CTX).entries.find((e) => e.fileId === '00042');
expect(typeof entry?.name).toBe('string'); expect(typeof entry?.name).toBe('string');
@@ -44,6 +44,11 @@ export interface NcEntry {
permissions: string; permissions: string;
hasPreview: boolean; hasPreview: boolean;
favorite: boolean; favorite: boolean;
/**
* Freigabearten, in denen der Eintrag geteilt ist (`oc:share-types`: 0 Person, 1 Gruppe,
* 3 Link ...), aufsteigend und ohne Doppelte; leer = nicht (selbst) geteilt (quick-261009-dkv).
*/
shareTypes: number[];
} }
export interface NcQuota { export interface NcQuota {
@@ -173,6 +178,17 @@ function isoOf(value: unknown): string | null {
return Number.isFinite(time) ? new Date(time).toISOString() : null; return Number.isFinite(time) ? new Date(time).toISOString() : null;
} }
/** `<oc:share-types><oc:share-type>0</oc:share-type>...` -> ganze Zahlen 0..99, aufsteigend, ohne Doppelte. */
function shareTypesOf(value: unknown): number[] {
if (!isDict(value)) return [];
const found = new Set<number>();
for (const item of asArray(value['share-type'] as unknown)) {
const n = numberOf(item);
if (n !== null && Number.isInteger(n) && n >= 0 && n <= 99) found.add(n);
}
return [...found].sort((a, b) => a - b);
}
function buildEntry(rel: readonly string[], props: Dict): NcEntry { function buildEntry(rel: readonly string[], props: Dict): NcEntry {
const folder = isFolder(props); const folder = isFolder(props);
const sizeRaw = folder const sizeRaw = folder
@@ -191,6 +207,7 @@ function buildEntry(rel: readonly string[], props: Dict): NcEntry {
permissions: text(props.permissions) ?? '', permissions: text(props.permissions) ?? '',
hasPreview: text(props['has-preview']) === 'true', hasPreview: text(props['has-preview']) === 'true',
favorite: text(props.favorite) === '1', favorite: text(props.favorite) === '1',
shareTypes: shareTypesOf(props['share-types']),
}; };
} }
@@ -0,0 +1,597 @@
import { Readable } from 'node:stream';
import { describe, expect, it } from 'vitest';
import { NextcloudCallGate } from './nextcloud-call-gate';
import type { NcSession } from './nextcloud-files.types';
import type { NcTransportRequest, NextcloudTransport } from './nextcloud-http';
import {
accessOf,
OCS_OK_MAX_BYTES,
ocsShareRequest,
parseShare,
parseShareList,
parseSharePolicy,
parseSharees,
permissionsFor,
SHARES_BASE_SEGMENTS,
} from './nextcloud-shares';
const SESSION: NcSession = {
baseUrl: 'https://cloud.example/nc',
ncUserId: 'anna',
authorization: 'Basic YW5uYTphcHAtcHctMTIz',
credentialKey: 'k1',
};
const SHARES = 'https://cloud.example/nc/ocs/v2.php/apps/files_sharing/api/v1/shares';
type Reply = { status: number; text?: string; headers?: Record<string, string> };
function setup(reply: Reply) {
const calls: NcTransportRequest[] = [];
const gate = new NextcloudCallGate();
const transport: NextcloudTransport = async (req) => {
calls.push(req);
return {
statusCode: reply.status,
headers: reply.headers ?? {},
body: Readable.from(reply.text !== undefined ? [Buffer.from(reply.text)] : []),
};
};
return { calls, gate, transport };
}
const ok = (data: unknown, message = 'OK') =>
JSON.stringify({ ocs: { meta: { status: 'ok', statuscode: 200, message }, data } });
/** Lebende Antwort (Research, Nextcloud 34.0.4): Ordner /Projekte an zoe. */
const USER_SHARE = {
id: '1',
share_type: 0,
uid_owner: 'anna',
displayname_owner: 'Anna Müller',
permissions: 31,
can_edit: true,
can_delete: true,
stime: 1791532378,
parent: null,
expiration: '2026-12-31 23:59:59',
token: null,
uid_file_owner: 'anna',
note: '',
label: '',
displayname_file_owner: 'Anna Müller',
path: '/Projekte',
item_type: 'folder',
item_permissions: 31,
'is-mount-root': false,
'mount-type': '',
mimetype: 'httpd/unix-directory',
has_preview: false,
storage_id: 'home::anna',
storage: 3,
item_source: 294,
file_source: 294,
file_parent: 93,
file_target: '/Projekte',
item_size: 6810,
item_mtime: 1791489905,
share_with: 'zoe',
share_with_displayname: 'Zwei Faktor',
share_with_displayname_unique: 'zoe',
mail_send: 1,
hide_download: 0,
attributes: null,
};
const LINK_SHARE = {
...USER_SHARE,
id: '5',
share_type: 3,
path: '/Projekte/Angebot.pdf',
file_target: '/Angebot.pdf',
item_type: 'file',
mimetype: 'application/pdf',
item_permissions: 19,
permissions: 1,
expiration: null,
token: 'AbC123',
url: 'http://cloud.example/nc/index.php/s/AbC123',
password: 'redacted',
share_with: 'redacted',
share_with_displayname: '(Geteilter Link)',
label: 'Kunde',
};
const SHAREES = {
exact: {
users: [],
groups: [
{
label: 'twofa',
value: { shareType: 1, shareWith: 'twofa' },
},
],
remotes: [],
emails: [],
},
users: [
{
label: 'Zwei Faktor',
subline: '',
icon: 'icon-user',
value: { shareType: 0, shareWith: 'zoe' },
shareWithDisplayNameUnique: 'zoe',
status: [],
},
],
groups: [{ label: 'twofa', value: { shareType: 1, shareWith: 'twofa' } }],
remotes: [{ label: 'x', value: { shareType: 6, shareWith: 'x@y' } }],
emails: [{ label: 'm', value: { shareType: 4, shareWith: 'm@y' } }],
lookup: [],
};
const CAPS = {
version: { major: 34 },
capabilities: {
password_policy: { minLength: 10, enforceNonCommonPassword: true },
files_sharing: {
api_enabled: true,
public: {
enabled: true,
password: { enforced: false, askForOptionalPassword: false },
expire_date: { enabled: false },
expire_date_internal: { enabled: false },
upload: true,
upload_files_drop: true,
multiple_links: true,
},
resharing: true,
group_sharing: true,
default_permissions: 31,
sharee: { minSearchStringLength: 0 },
},
},
};
describe('ocsShareRequest — genaue Aufrufe', () => {
it('Freigaben eines Pfads: GET mit codiertem Pfad, Auth- und OCS-Kopfzeilen, ohne Cookie', async () => {
const { calls, gate, transport } = setup({ status: 200, text: ok([]) });
await ocsShareRequest(transport, gate, SESSION, {
method: 'GET',
segments: SHARES_BASE_SEGMENTS,
query: { path: '/Projekte/Ärger 100%', reshares: 'true' },
});
expect(calls).toHaveLength(1);
expect(calls[0].method).toBe('GET');
expect(calls[0].url).toBe(`${SHARES}?path=%2FProjekte%2F%C3%84rger%20100%25&reshares=true`);
expect(calls[0].headers.authorization).toBe('Basic YW5uYTphcHAtcHctMTIz');
expect(calls[0].headers['ocs-apirequest']).toBe('true');
expect(calls[0].headers.accept).toBe('application/json');
expect(calls[0].headers.cookie).toBeUndefined();
expect(calls[0].body).toBeNull();
});
it('Empfaengersuche: genau die erwartete Adresse mit durchnummerierten shareType-Schluesseln', async () => {
const { calls, gate, transport } = setup({ status: 200, text: ok({}) });
await ocsShareRequest(transport, gate, SESSION, {
method: 'GET',
segments: ['apps', 'files_sharing', 'api', 'v1', 'sharees'],
query: {
search: 'ben',
itemType: 'folder',
perPage: '20',
'shareType[0]': '0',
'shareType[1]': '1',
},
});
expect(calls[0].url).toBe(
'https://cloud.example/nc/ocs/v2.php/apps/files_sharing/api/v1/sharees?search=ben&itemType=folder&perPage=20&shareType%5B0%5D=0&shareType%5B1%5D=1',
);
});
it('Anlegen: POST mit JSON-Kopfzeile und dem Koerper als Zeichenkette', async () => {
const { calls, gate, transport } = setup({ status: 200, text: ok(USER_SHARE) });
await ocsShareRequest(transport, gate, SESSION, {
method: 'POST',
segments: SHARES_BASE_SEGMENTS,
json: { path: '/Projekte', shareType: 0, shareWith: 'ben', permissions: 15 },
});
expect(calls[0].method).toBe('POST');
expect(calls[0].url).toBe(SHARES);
expect(calls[0].headers['content-type']).toBe('application/json');
expect(calls[0].body).toBe(
'{"path":"/Projekte","shareType":0,"shareWith":"ben","permissions":15}',
);
});
it('Aendern und Loeschen: Kennung als eigenes Segment, DELETE ohne Koerper', async () => {
const put = setup({ status: 200, text: ok(USER_SHARE) });
await ocsShareRequest(put.transport, put.gate, SESSION, {
method: 'PUT',
segments: [...SHARES_BASE_SEGMENTS, '17'],
json: { permissions: 1 },
});
expect(put.calls[0].method).toBe('PUT');
expect(put.calls[0].url).toBe(`${SHARES}/17`);
expect(put.calls[0].body).toBe('{"permissions":1}');
const del = setup({ status: 200, text: ok([]) });
await ocsShareRequest(del.transport, del.gate, SESSION, {
method: 'DELETE',
segments: [...SHARES_BASE_SEGMENTS, '17'],
});
expect(del.calls[0].method).toBe('DELETE');
expect(del.calls[0].url).toBe(`${SHARES}/17`);
expect(del.calls[0].body).toBeNull();
expect(del.calls[0].headers['content-type']).toBeUndefined();
});
it('Faehigkeiten: GET cloud/capabilities', async () => {
const { calls, gate, transport } = setup({ status: 200, text: ok(CAPS) });
const res = await ocsShareRequest(transport, gate, SESSION, {
method: 'GET',
segments: ['cloud', 'capabilities'],
});
expect(calls[0].url).toBe('https://cloud.example/nc/ocs/v2.php/cloud/capabilities');
expect(res.ok && res.data).toEqual(CAPS);
});
});
describe('ocsShareRequest — Antworten', () => {
const NOT_FOUND =
'{"ocs":{"meta":{"status":"failure","statuscode":404,"message":"Wrong share ID, share does not exist"},"data":[]}}';
it('404 mit OCS-Text kommt als ok:true mit Status und Text', async () => {
const { gate, transport } = setup({ status: 404, text: NOT_FOUND });
const res = await ocsShareRequest(transport, gate, SESSION, {
method: 'GET',
segments: [...SHARES_BASE_SEGMENTS, '9'],
});
expect(res).toMatchObject({
ok: true,
status: 404,
message: 'Wrong share ID, share does not exist',
});
});
it('der Text wird von Steuerzeichen befreit und auf 300 Zeichen gekuerzt', async () => {
const dirty = `a\u0000b\u001b[31m${'x'.repeat(500)}`;
const { gate, transport } = setup({ status: 400, text: ok([], dirty) });
const res = await ocsShareRequest(transport, gate, SESSION, {
method: 'GET',
segments: SHARES_BASE_SEGMENTS,
});
expect(res.ok && res.message).not.toBeNull();
const message = res.ok ? (res.message as string) : '';
expect(message).toHaveLength(300);
expect(message.startsWith('a b [31m')).toBe(true);
// biome-ignore lint/suspicious/noControlCharactersInRegex: Pruefung auf Steuerzeichen
expect(/[\u0000-\u001f\u007f]/.test(message)).toBe(false);
});
it('kein JSON bei 2xx -> invalid-response; bei 4xx nur ohne Text', async () => {
const good = setup({ status: 200, text: '<html>' });
expect(
await ocsShareRequest(good.transport, good.gate, SESSION, {
method: 'GET',
segments: SHARES_BASE_SEGMENTS,
}),
).toEqual({ ok: false, kind: 'invalid-response' });
const bad = setup({ status: 400, text: '<html>' });
expect(
await ocsShareRequest(bad.transport, bad.gate, SESSION, {
method: 'GET',
segments: SHARES_BASE_SEGMENTS,
}),
).toEqual({ ok: true, status: 400, message: null, data: null });
});
it('eine 2xx-Antwort ueber 8 MiB ist too-large', async () => {
const big = `${ok('x')}${' '.repeat(OCS_OK_MAX_BYTES)}`;
const { gate, transport } = setup({ status: 200, text: big });
const res = await ocsShareRequest(transport, gate, SESSION, {
method: 'GET',
segments: SHARES_BASE_SEGMENTS,
});
expect(res).toMatchObject({ ok: false, kind: 'too-large' });
});
it('eine Fehlerantwort ueber 64 KiB bleibt ein Fehlerstatus ohne Text', async () => {
const big = `${NOT_FOUND}${' '.repeat(70 * 1024)}`;
const { gate, transport } = setup({ status: 404, text: big });
const res = await ocsShareRequest(transport, gate, SESSION, {
method: 'GET',
segments: SHARES_BASE_SEGMENTS,
});
expect(res).toEqual({ ok: true, status: 404, message: null, data: null });
});
it('429 haelt den Ursprung an: der naechste Aufruf wird nicht gesendet', async () => {
const { calls, gate, transport } = setup({ status: 429, headers: { 'retry-after': '30' } });
const first = await ocsShareRequest(transport, gate, SESSION, {
method: 'POST',
segments: SHARES_BASE_SEGMENTS,
json: { path: '/a' },
});
expect(first).toMatchObject({ ok: false, kind: 'http', status: 429 });
const second = await ocsShareRequest(transport, gate, SESSION, {
method: 'GET',
segments: SHARES_BASE_SEGMENTS,
});
expect(second).toMatchObject({ ok: false, kind: 'paused' });
expect(calls).toHaveLength(1);
});
it('401 -> credential-dead', async () => {
const { gate, transport } = setup({ status: 401 });
const res = await ocsShareRequest(transport, gate, SESSION, {
method: 'GET',
segments: SHARES_BASE_SEGMENTS,
});
expect(res).toMatchObject({ ok: false, kind: 'credential-dead' });
});
});
describe('parseShare', () => {
it('die lebende Personenfreigabe wird zu genau der kleinen Ansicht', () => {
expect(parseShare(USER_SHARE, 'anna')).toEqual({
id: '1',
kind: 'user',
path: '/Projekte',
name: 'Projekte',
itemType: 'folder',
mime: null,
itemWritable: true,
permissions: 31,
access: 'edit',
shareWith: 'zoe',
shareWithName: 'Zwei Faktor',
ownerId: 'anna',
ownerName: 'Anna Müller',
canEdit: true,
canDelete: true,
expiration: '2026-12-31',
label: '',
url: null,
hasPassword: false,
target: '/Projekte',
sharedAt: '2026-10-09T07:52:58.000Z',
});
});
it('kopiert keine unbekannten Felder', () => {
const json = JSON.stringify(parseShare(USER_SHARE, 'anna'));
for (const word of ['storage_id', 'attributes', 'mail_send', 'item_source', 'token']) {
expect(json).not.toContain(word);
}
});
it('Link des Eigentuemers: Adresse bleibt, Passwort nur als hasPassword, nie "redacted"', () => {
const own = parseShare(LINK_SHARE, 'anna');
expect(own).toMatchObject({
kind: 'link',
hasPassword: true,
url: 'http://cloud.example/nc/index.php/s/AbC123',
shareWith: null,
shareWithName: null,
itemType: 'file',
mime: 'application/pdf',
label: 'Kunde',
name: 'Angebot.pdf',
});
expect(JSON.stringify(own)).not.toContain('redacted');
expect(JSON.stringify(own)).not.toContain('"token"');
});
it('dieselbe Freigabe fuer einen anderen Betrachter hat keine Adresse', () => {
expect(parseShare(LINK_SHARE, 'zoe')?.url).toBeNull();
});
it('eine Adresse mit anderem Schema wird verworfen', () => {
expect(parseShare({ ...LINK_SHARE, url: 'javascript:alert(1)' }, 'anna')?.url).toBeNull();
expect(parseShare({ ...LINK_SHARE, url: 'ftp://x.example/s/1' }, 'anna')?.url).toBeNull();
});
it('andere Arten und fehlerhafte Kennungen ergeben null', () => {
expect(parseShare({ ...USER_SHARE, share_type: 4 }, 'anna')).toBeNull();
expect(parseShare({ ...USER_SHARE, share_type: 6 }, 'anna')).toBeNull();
expect(parseShare({ ...USER_SHARE, id: 'abc' }, 'anna')).toBeNull();
expect(parseShare('x', 'anna')).toBeNull();
});
it('eingehende Freigabe: Name aus dem Ziel im eigenen Baum, Eigentuemer fremd', () => {
const incoming = parseShare(
{
...USER_SHARE,
uid_owner: 'ben',
displayname_owner: 'Ben Beispiel',
path: '/Ben-Ordner/Quelle',
file_target: '/Ben-Ordner',
permissions: 1,
item_permissions: 1,
can_edit: false,
},
'anna',
);
expect(incoming).toMatchObject({
name: 'Ben-Ordner',
target: '/Ben-Ordner',
ownerId: 'ben',
ownerName: 'Ben Beispiel',
access: 'view',
itemWritable: false,
canEdit: false,
});
});
it('Anzeigetexte: Steuerzeichen raus', () => {
const share = parseShare(
{ ...USER_SHARE, share_with_displayname: 'Z\u0007oe\n Faktor' },
'anna',
);
expect(share?.shareWithName).toBe('Z oe Faktor');
});
});
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);
});
it('permissionsFor nach Eintragsart', () => {
expect(permissionsFor('edit', 'folder')).toBe(15);
expect(permissionsFor('edit', 'file')).toBe(3);
expect(permissionsFor('view', 'folder')).toBe(1);
expect(permissionsFor('view', 'file')).toBe(1);
expect(permissionsFor('upload', 'folder')).toBe(4);
expect(permissionsFor('upload', 'file')).toBeNull();
});
});
describe('parseShareList', () => {
it('nimmt ein Feld (GET) und ein Objekt (POST/PUT), zaehlt andere Arten als versteckt', () => {
const list = parseShareList(
[USER_SHARE, { ...USER_SHARE, id: '2', share_type: 4 }, LINK_SHARE],
'anna',
);
expect(list.shares.map((s) => s.id)).toEqual(['1', '5']);
expect(list.hidden).toBe(1);
expect(list.truncated).toBe(false);
expect(parseShareList(USER_SHARE, 'anna').shares).toHaveLength(1);
expect(parseShareList(null, 'anna')).toEqual({ shares: [], hidden: 0, truncated: false });
});
it('behaelt hoechstens 2000 und meldet truncated', () => {
const many = Array.from({ length: 2003 }, (_, i) => ({ ...USER_SHARE, id: String(i + 1) }));
const list = parseShareList(many, 'anna');
expect(list.shares).toHaveLength(2000);
expect(list.truncated).toBe(true);
});
});
describe('parseSharees', () => {
it('fuehrt genaue und weitere Treffer zusammen, ohne Doppelte, nur Personen und Gruppen', () => {
expect(parseSharees(SHAREES)).toEqual([
{ kind: 'user', id: 'zoe', label: 'Zwei Faktor', detail: 'zoe' },
{ kind: 'group', id: 'twofa', label: 'twofa', detail: null },
]);
});
it('hoechstens 25 Treffer; Unsinn ergibt eine leere Liste', () => {
const users = Array.from({ length: 40 }, (_, i) => ({
label: `U${i}`,
value: { shareType: 0, shareWith: `u${i}` },
}));
expect(parseSharees({ users })).toHaveLength(25);
expect(parseSharees(null)).toEqual([]);
expect(parseSharees({ users: 'x' })).toEqual([]);
});
});
describe('parseSharePolicy', () => {
it('die lebenden Faehigkeiten', () => {
expect(parseSharePolicy(CAPS)).toEqual({
enabled: true,
groupsEnabled: true,
links: {
enabled: true,
passwordRequired: false,
passwordSuggested: false,
expiryDefaultDays: null,
expiryEnforced: false,
uploadAllowed: true,
multipleLinks: true,
},
internalExpiry: { defaultDays: null, enforced: false },
minSearchLength: 0,
passwordMinLength: 10,
});
});
it('Links aus (public = { enabled: false }) schaltet jede Linkregel ab', () => {
const caps = {
capabilities: { files_sharing: { api_enabled: true, public: { enabled: false } } },
};
const policy = parseSharePolicy(caps);
expect(policy.enabled).toBe(true);
expect(policy.links).toEqual({
enabled: false,
passwordRequired: false,
passwordSuggested: false,
expiryDefaultDays: null,
expiryEnforced: false,
uploadAllowed: false,
multipleLinks: false,
});
});
it('Ablauf erzwungen mit Tagen als Zeichenkette; ungueltige Tage ergeben null', () => {
const withExpiry = (expire_date: unknown) => ({
capabilities: {
files_sharing: { api_enabled: true, public: { enabled: true, expire_date } },
},
});
const enforced = parseSharePolicy(withExpiry({ enabled: true, days: '7', enforced: true }));
expect(enforced.links.expiryDefaultDays).toBe(7);
expect(enforced.links.expiryEnforced).toBe(true);
expect(
parseSharePolicy(withExpiry({ enabled: true, days: 'abc', enforced: true })).links
.expiryDefaultDays,
).toBeNull();
expect(
parseSharePolicy(withExpiry({ enabled: true, days: '0' })).links.expiryDefaultDays,
).toBeNull();
// ohne enabled gibt es keine Regel, auch wenn Tage dastehen
const disabled = parseSharePolicy(withExpiry({ enabled: false, days: '7', enforced: true }));
expect(disabled.links.expiryDefaultDays).toBeNull();
expect(disabled.links.expiryEnforced).toBe(false);
});
it('Passwort erzwungen / empfohlen', () => {
const withPw = (password: unknown) => ({
capabilities: {
files_sharing: { api_enabled: true, public: { enabled: true, password } },
},
});
expect(parseSharePolicy(withPw({ enforced: true })).links.passwordRequired).toBe(true);
const suggested = parseSharePolicy(withPw({ enforced: false, askForOptionalPassword: true }));
expect(suggested.links.passwordRequired).toBe(false);
expect(suggested.links.passwordSuggested).toBe(true);
});
it('keine files_sharing-Faehigkeit oder api_enabled false -> ausgeschaltet', () => {
expect(parseSharePolicy({ capabilities: {} }).enabled).toBe(false);
expect(parseSharePolicy(null).enabled).toBe(false);
const off = parseSharePolicy({
capabilities: { files_sharing: { api_enabled: false, public: { enabled: true } } },
});
expect(off.enabled).toBe(false);
expect(off.links.enabled).toBe(false);
});
it('keine Gruppenfreigabe, Mindestlaenge der Suche', () => {
const policy = parseSharePolicy({
capabilities: {
files_sharing: {
api_enabled: true,
group_sharing: false,
public: { enabled: true, multiple_links: false },
sharee: { minSearchStringLength: '3' },
},
},
});
expect(policy.groupsEnabled).toBe(false);
expect(policy.links.multipleLinks).toBe(false);
expect(policy.minSearchLength).toBe(3);
expect(policy.passwordMinLength).toBeNull();
});
});
@@ -0,0 +1,464 @@
import type { NextcloudCallGate } from './nextcloud-call-gate';
import type { NcSession } from './nextcloud-files.types';
import {
discardBody,
type NcFailure,
type NextcloudTransport,
ncRequest,
readCappedText,
} from './nextcloud-http';
/**
* Freigaben-Schicht des Moduls "Nextcloud-Dateien" (quick-261009-dkv, D-11/D-12):
* die OCS-Schnittstelle fuer Freigaben, Empfaengersuche und die Freigaberegeln
* (`cloud/capabilities`) auf Basis von `ncRequest`. Es gelten unveraendert die
* Regeln der Etappe 1: fester Pfadanfang `/ocs/v2.php/`, Segmente einzeln codiert,
* keine Weiterleitungen, keine Cookies, und es wird NIE eine Adresse aus einer
* Nextcloud-Antwort aufgerufen (weder `url` einer Freigabe noch `api.generate`
* aus den Faehigkeiten).
*
* Warum nicht `ocsRequest` aus `nextcloud-auth-client.ts`: der Anmeldecode
* verlaesst sich darauf, dass ein 403 als `app-password-given` gilt und der Text
* einer Fehlerantwort verworfen wird. Bei Freigaben steht genau dort die
* Begruendung (`ocs.meta.message`), und ein 403 heisst "nicht erlaubt". Darum ein
* eigener Aufruf, der den Koerper bei JEDEM Status liest (Erfolg: bis 8 MiB,
* Fehler: bis 64 KiB, Faehigkeiten: bis 1 MiB).
*
* Nie loggen oder weitergeben: Passwoerter, Kennungen (`token`) und Link-Adressen.
* Die Parser bauen kleine eigene Ansichten; unbekannte Felder (Speicherkennungen,
* `attributes`, `mail_send` ...) werden nie kopiert.
*/
export type NcShareKind = 'user' | 'group' | 'link';
export type NcShareAccess = 'view' | 'edit' | 'upload' | 'custom';
export type NcItemType = 'file' | 'folder';
/** Eine Freigabe, wie der Browser sie sieht. */
export interface NcShareView {
id: string;
kind: NcShareKind;
/** Pfad im Bereich des Eigentuemers (bei eigenen Freigaben im eigenen Konto). */
path: string;
name: string;
itemType: NcItemType;
mime: string | null;
/** Der Eintrag selbst erlaubt Aendern/Anlegen (Berechtigungsbits 2 oder 4). */
itemWritable: boolean;
permissions: number;
access: NcShareAccess;
/** Kennung von Person/Gruppe; bei Links null. */
shareWith: string | null;
shareWithName: string | null;
ownerId: string | null;
ownerName: string | null;
canEdit: boolean;
canDelete: boolean;
/** `YYYY-MM-DD` oder null. */
expiration: string | null;
label: string;
/** Nur bei eigenen Links, nur http/https. */
url: string | null;
hasPassword: boolean;
/** Pfad im eigenen Baum des Empfaengers (bei eigenen Freigaben der eigene Pfad). */
target: string;
sharedAt: string | null;
/** Noch nicht angenommen (nur eingehende). */
pending?: boolean;
}
export interface NcSharee {
kind: 'user' | 'group';
id: string;
label: string;
detail: string | null;
}
export interface NcSharePolicy {
/** Freigabe-Schnittstelle der Nextcloud an. */
enabled: boolean;
groupsEnabled: boolean;
links: {
enabled: boolean;
passwordRequired: boolean;
passwordSuggested: boolean;
/** Tage des Standard-Ablaufs (nur wenn die Nextcloud einen vorgibt). */
expiryDefaultDays: number | null;
expiryEnforced: boolean;
uploadAllowed: boolean;
multipleLinks: boolean;
};
/** Regeln fuer Personen- und Gruppenfreigaben (nur zur Anzeige). */
internalExpiry: { defaultDays: number | null; enforced: boolean };
minSearchLength: number;
passwordMinLength: number | null;
}
// --- Grenzen -------------------------------------------------------------------------------
export const SHARES_BASE_SEGMENTS = ['apps', 'files_sharing', 'api', 'v1', 'shares'] as const;
export const SHAREE_SEGMENTS = ['apps', 'files_sharing', 'api', 'v1', 'sharees'] as const;
export const CAPABILITIES_SEGMENTS = ['cloud', 'capabilities'] as const;
export const OCS_OK_MAX_BYTES = 8 * 1024 * 1024;
export const OCS_ERROR_MAX_BYTES = 64 * 1024;
export const OCS_CAPABILITIES_MAX_BYTES = 1024 * 1024;
export const MAX_SHARES = 2000;
export const MAX_SHAREES = 25;
const SHARE_TIMEOUT_MS = 15_000;
const MESSAGE_MAX = 300;
const DISPLAY_MAX = 255;
const URL_MAX = 2048;
const SHARE_ID_RE = /^\d{1,20}$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
// --- Hilfen ----------------------------------------------------------------------------------
type Dict = Record<string, unknown>;
function isDict(value: unknown): value is Dict {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/** Steuerzeichen entfernen, Leerraum zusammenfassen, kuerzen — fuer alles, was angezeigt wird. */
export function cleanText(value: unknown, max: number): string {
if (typeof value !== 'string') return '';
return (
value
// biome-ignore lint/suspicious/noControlCharactersInRegex: Steuerzeichen sind hier gerade der Pruefstoff
.replace(/[\u0000-\u001f\u007f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, max)
);
}
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());
return null;
}
export function isShareId(value: unknown): value is string {
return typeof value === 'string' && SHARE_ID_RE.test(value);
}
// --- Aufruf -----------------------------------------------------------------------------------
export interface OcsShareOptions {
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
segments: readonly string[];
query?: Record<string, string>;
/** JSON-Koerper (POST/PUT); Passwoerter stehen nie in einer Adresse. */
json?: Record<string, unknown>;
/** Obergrenze fuer eine erfolgreiche Antwort (Standard 8 MiB). */
maxBytes?: number;
}
export type OcsShareResult =
| { ok: true; status: number; message: string | null; data: unknown }
| NcFailure;
/**
* OCS-Aufruf mit dem Zugang der Sitzung. Liefert fuer JEDEN HTTP-Status
* `{ ok: true, status, message, data }` (auch 4xx/5xx); Transportfehler, 401
* (`credential-dead`) und 429 (Sperre) kommen wie bei `ncRequest` als Fehlerergebnis.
*/
export async function ocsShareRequest(
transport: NextcloudTransport,
gate: NextcloudCallGate,
session: NcSession,
opts: OcsShareOptions,
): Promise<OcsShareResult> {
const res = await ncRequest(transport, gate, {
baseUrl: session.baseUrl,
prefix: '/ocs/v2.php/',
segments: opts.segments,
query: opts.query,
method: opts.method,
ocs: true,
authorization: session.authorization,
credentialKey: session.credentialKey,
...(opts.json !== undefined
? { headers: { 'content-type': 'application/json' }, body: JSON.stringify(opts.json) }
: {}),
headersTimeoutMs: SHARE_TIMEOUT_MS,
bodyTimeoutMs: SHARE_TIMEOUT_MS,
});
if (!res.ok) return res;
const success = res.status >= 200 && res.status < 300;
const text = await readCappedText(
res.body,
success ? (opts.maxBytes ?? OCS_OK_MAX_BYTES) : OCS_ERROR_MAX_BYTES,
);
if (!text.ok) {
discardBody(res.body);
// Eine zu grosse Fehlerantwort bleibt ein Fehlerstatus, nur ohne Text.
if (!success && text.kind === 'too-large') {
return { ok: true, status: res.status, message: null, data: null };
}
return { ok: false, kind: text.kind, detail: text.detail };
}
let ocs: Dict | null = null;
try {
const parsed: unknown = JSON.parse(text.text);
if (isDict(parsed) && isDict(parsed.ocs)) ocs = parsed.ocs;
} catch {
// kein JSON
}
if (ocs === null) {
if (success) return { ok: false, kind: 'invalid-response' };
return { ok: true, status: res.status, message: null, data: null };
}
const meta = isDict(ocs.meta) ? ocs.meta : {};
const message = cleanText(meta.message, MESSAGE_MAX);
return { ok: true, status: res.status, message: message === '' ? null : message, data: ocs.data };
}
// --- Berechtigungen -----------------------------------------------------------------------------
const READ = 1;
const UPDATE = 2;
const CREATE = 4;
const DELETE = 8;
/**
* Berechtigungsmaske fuer die einfache Auswahl (D-11): Ansehen 1; Bearbeiten Ordner 15 /
* Datei 3; Nur hochladen 4 (nur Ordner, sonst null). Das Teilen-Bit (16) wird nie gesendet.
*/
export function permissionsFor(
access: 'view' | 'edit' | 'upload',
itemType: NcItemType,
): number | null {
if (access === 'view') return READ;
if (access === 'edit')
return itemType === 'folder' ? READ | UPDATE | CREATE | DELETE : READ | UPDATE;
return itemType === 'folder' ? CREATE : null;
}
/** Umkehrung: aus der Maske (Bit 16 wird ignoriert) die einfache Auswahl oder `custom`. */
export function accessOf(permissions: number): 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';
return 'custom';
}
// --- Parser -------------------------------------------------------------------------------------
function lastSegment(path: string): string {
const parts = path.split('/').filter((p) => p !== '');
return parts.length > 0 ? parts[parts.length - 1] : '';
}
function isRealDate(value: string): boolean {
if (!DATE_RE.test(value)) return false;
const d = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
}
function publicUrl(value: unknown): string | null {
if (typeof value !== 'string' || value.length === 0 || value.length > URL_MAX) return null;
try {
const u = new URL(value);
return u.protocol === 'http:' || u.protocol === 'https:' ? value : null;
} catch {
return null;
}
}
/** `null` fuer nicht unterstuetzte Arten (E-Mail, Server, Talk ...) und fehlerhafte Eintraege. */
export function parseShare(raw: unknown, selfId: string): NcShareView | null {
if (!isDict(raw)) return null;
const type = intOf(raw.share_type);
const kind: NcShareKind | null =
type === 0 ? 'user' : type === 1 ? 'group' : type === 3 ? 'link' : null;
if (kind === null) return null;
const id = typeof raw.id === 'number' ? String(raw.id) : raw.id;
if (!isShareId(id)) return 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 itemType: NcItemType =
raw.item_type === 'folder' || raw.mimetype === 'httpd/unix-directory' ? 'folder' : 'file';
const name = lastSegment(received ? target || path : path || target);
const permissions = intOf(raw.permissions) ?? 0;
const itemPermissions = intOf(raw.item_permissions) ?? permissions;
const expirationRaw = typeof raw.expiration === 'string' ? raw.expiration.slice(0, 10) : '';
const stime = intOf(raw.stime);
const sharedAt =
stime !== null && stime > 0 && Number.isFinite(new Date(stime * 1000).getTime())
? new Date(stime * 1000).toISOString()
: null;
const mimeRaw = cleanText(raw.mimetype, DISPLAY_MAX);
return {
id,
kind,
path,
name,
itemType,
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,
shareWithName:
kind === 'link' ? null : cleanText(raw.share_with_displayname, DISPLAY_MAX) || null,
ownerId,
ownerName: cleanText(raw.displayname_owner, DISPLAY_MAX) || null,
canEdit: raw.can_edit === true,
canDelete: raw.can_delete === true,
expiration: isRealDate(expirationRaw) ? expirationRaw : null,
label: cleanText(raw.label, DISPLAY_MAX),
url: kind === 'link' && ownerId === selfId ? publicUrl(raw.url) : null,
// Nextcloud sendet beim Passwort nie den Wert, nur den Text "redacted" — hier zaehlt nur, DASS eins gesetzt ist.
hasPassword: kind === 'link' && typeof raw.password === 'string' && raw.password !== '',
target,
sharedAt,
};
}
export interface ParsedShares {
shares: NcShareView[];
/** Eintraege anderer Arten (E-Mail, Server ...), nur als Zahl. */
hidden: number;
truncated: boolean;
}
/** `data` ist bei Listen ein Feld, bei POST/PUT ein einzelnes Objekt. */
export function parseShareList(data: unknown, selfId: string): ParsedShares {
const items = Array.isArray(data) ? data : isDict(data) ? [data] : [];
const shares: NcShareView[] = [];
let hidden = 0;
let truncated = false;
for (const item of items) {
const share = parseShare(item, selfId);
if (share === null) {
hidden += 1;
} else if (shares.length >= MAX_SHARES) {
truncated = true;
} else {
shares.push(share);
}
}
return { shares, hidden, truncated };
}
function shareeList(value: unknown, wanted: 0 | 1): NcSharee[] {
if (!Array.isArray(value)) return [];
const out: 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);
if (id === '') continue;
const label = cleanText(item.label, DISPLAY_MAX) || id;
const unique = cleanText(item.shareWithDisplayNameUnique, DISPLAY_MAX);
out.push({
kind: wanted === 0 ? 'user' : 'group',
id,
label,
detail: wanted === 0 ? unique || id : null,
});
}
return out;
}
/** Treffer der Empfaengersuche: nur Personen und Gruppen, genaue und weitere Treffer zusammen, ohne Doppelte. */
export function parseSharees(data: unknown): NcSharee[] {
if (!isDict(data)) return [];
const exact = isDict(data.exact) ? data.exact : {};
const all = [
...shareeList(exact.users, 0),
...shareeList(data.users, 0),
...shareeList(exact.groups, 1),
...shareeList(data.groups, 1),
];
const seen = new Set<string>();
const out: NcSharee[] = [];
for (const s of all) {
const key = `${s.kind}:${s.id}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(s);
if (out.length >= MAX_SHAREES) break;
}
return out;
}
function days(value: unknown): number | null {
const n = intOf(value);
return n !== null && n >= 1 ? Math.min(n, 3650) : null;
}
/**
* Freigaberegeln aus `cloud/capabilities` (Antwort-`data` oder dessen `capabilities`).
* 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 {
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;
const minLen = pwPolicy ? intOf(pwPolicy.minLength) : null;
const passwordMinLength = minLen !== null && minLen >= 1 && minLen <= 256 ? minLen : null;
const off: NcSharePolicy = {
enabled: false,
groupsEnabled: false,
links: {
enabled: false,
passwordRequired: false,
passwordSuggested: false,
expiryDefaultDays: null,
expiryEnforced: false,
uploadAllowed: false,
multipleLinks: false,
},
internalExpiry: { defaultDays: null, enforced: false },
minSearchLength: 0,
passwordMinLength,
};
if (files === null || files.api_enabled === false) return off;
const sharee = isDict(files.sharee) ? files.sharee : {};
const min = intOf(sharee.minSearchStringLength);
const minSearchLength = min !== null ? Math.min(Math.max(min, 0), 32) : 0;
const pub = isDict(files.public) ? files.public : {};
const linksEnabled = pub.enabled === true;
const expiry = (value: unknown): { defaultDays: number | null; enforced: boolean } => {
const e = isDict(value) ? value : {};
if (e.enabled !== true) return { defaultDays: null, enforced: false };
return { defaultDays: days(e.days), enforced: e.enforced === true };
};
const password = isDict(pub.password) ? pub.password : {};
const passwordRequired = linksEnabled && password.enforced === true;
const link = expiry(pub.expire_date);
const internal = expiry(pub.expire_date_internal);
return {
enabled: true,
groupsEnabled: files.group_sharing !== false,
links: linksEnabled
? {
enabled: true,
passwordRequired,
passwordSuggested: !passwordRequired && password.askForOptionalPassword === true,
expiryDefaultDays: link.defaultDays,
expiryEnforced: link.enforced,
uploadAllowed: pub.upload === true,
multipleLinks: pub.multiple_links !== false,
}
: off.links,
internalExpiry: internal,
minSearchLength,
passwordMinLength,
};
}