fix(quick-261009-dkv): Pfade woertlich, Zaehler und Serverdatum, Rechte des Ordners (Schnittstelle)

- Pfade, Ziele und Empfaengerkennungen der Freigaben bleiben woertlich (nur Anzeigetexte werden bereinigt)
- Weitergaben als eigene Freigaben mit eigenem Pfad; accessOf nach Eintragsart
- Begrenzung: 10 neue Freigaben je 10 Minuten, 40 Versuche je 10 Minuten vor den Abfragen an die Nextcloud, leere Zaehler werden entfernt
- Freigaberegeln nennen das Serverdatum, die Ordnerliste die Berechtigungsbuchstaben des Ordners selbst
- Live-Test: Ablehnen offener Freigaben, Weitergabe, Buchstaben des Ordners

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 12:21:23 +02:00
parent 0585fd17c4
commit 78f6cf358d
13 changed files with 543 additions and 67 deletions
@@ -37,7 +37,10 @@ export type NcItemType = 'file' | 'folder';
export interface NcShareView {
id: string;
kind: NcShareKind;
/** Pfad im Bereich des Eigentuemers (bei eigenen Freigaben im eigenen Konto). */
/**
* Pfad im Baum des Aufrufers, woertlich wie von der Nextcloud geliefert (nie bereinigt: er geht
* zurueck an die Nextcloud, ein zusammengefasstes Leerzeichen waere ein anderer Eintrag).
*/
path: string;
name: string;
itemType: NcItemType;
@@ -49,8 +52,14 @@ export interface NcShareView {
/** Kennung von Person/Gruppe; bei Links null. */
shareWith: string | null;
shareWithName: string | null;
/**
* Wer die Freigabe erstellt hat (`uid_owner` der Nextcloud heisst dort "Freigebender", nicht
* "Eigentuemer der Datei"; gemessen). Bei eigenen Freigaben ist das der Benutzer selbst.
*/
ownerId: string | null;
ownerName: string | null;
/** Anzeigename des Dateieigentuemers, nur wenn er ein anderer ist (Weitergabe, siehe `parseShare`). */
fileOwnerName: string | null;
canEdit: boolean;
canDelete: boolean;
/** `YYYY-MM-DD` oder null. */
@@ -59,7 +68,7 @@ export interface NcShareView {
/** Nur bei eigenen Links, nur http/https. */
url: string | null;
hasPassword: boolean;
/** Pfad im eigenen Baum des Empfaengers (bei eigenen Freigaben der eigene Pfad). */
/** Pfad im eigenen Baum des Aufrufers (bei eigenen Freigaben und Weitergaben gleich `path`), woertlich. */
target: string;
sharedAt: string | null;
/** Noch nicht angenommen (nur eingehende). */
@@ -74,6 +83,8 @@ export interface NcSharee {
}
export interface NcSharePolicy {
/** Heutiges Datum (`YYYY-MM-DD`) nach der Uhr des Tessera-Servers: Grundlage fuer Ablaufgrenzen. */
today: string;
/** Freigabe-Schnittstelle der Nextcloud an. */
enabled: boolean;
groupsEnabled: boolean;
@@ -108,6 +119,7 @@ const SHARE_TIMEOUT_MS = 15_000;
const MESSAGE_MAX = 300;
const DISPLAY_MAX = 255;
const URL_MAX = 2048;
const PATH_MAX = 4096;
const SHARE_ID_RE = /^\d{1,20}$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
@@ -133,6 +145,18 @@ export function cleanText(value: unknown, max: number): string {
);
}
/**
* Pfade und Kennungen (Freigabe-Pfad, Ziel, Empfaenger) gehen unveraendert an die Nextcloud
* zurueck. Sie werden darum NIE zusammengefasst oder gekuerzt wie Anzeigetexte: ein doppeltes
* oder nachgestelltes Leerzeichen gehoert zum Namen. Enthalten sie Steuerzeichen oder sind sie
* zu lang, gelten sie als unbrauchbar (leere Zeichenkette).
*/
export function verbatimId(value: unknown, max: number): string {
if (typeof value !== 'string' || value.length > max) return '';
// biome-ignore lint/suspicious/noControlCharactersInRegex: Steuerzeichen sind hier gerade der Pruefstoff
return /[\u0000-\u001f\u007f]/.test(value) ? '' : value;
}
function intOf(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value);
if (typeof value === 'string' && /^-?\d{1,15}$/.test(value.trim())) return Number(value.trim());
@@ -238,12 +262,20 @@ export function permissionsFor(
return itemType === 'folder' ? CREATE : null;
}
/** Umkehrung: aus der Maske (Bit 16 wird ignoriert) die einfache Auswahl oder `custom`. */
export function accessOf(permissions: number): NcShareAccess {
/**
* Umkehrung: aus der Maske (Bit 16 wird ignoriert) die einfache Auswahl oder `custom`. Nur die
* Masken, die `permissionsFor` selbst vergibt, gelten als Auswahl: Ansehen 1; Bearbeiten 15
* (Ordner) bzw. 3 (Datei); Nur hochladen 4 (nur Ordner). Alles andere (z. B. Lesen plus Loeschen
* oder Lesen plus Anlegen) ist eine eigene Berechtigung und wird so angezeigt, nie als
* Bearbeiten ausgegeben.
*/
export function accessOf(permissions: number, itemType: NcItemType): NcShareAccess {
const mask = permissions & 15;
if (mask === READ) return 'view';
if (mask === CREATE) return 'upload';
if ((mask & READ) !== 0 && (mask & (UPDATE | CREATE | DELETE)) !== 0) return 'edit';
if (itemType === 'folder' && mask === CREATE) return 'upload';
if (mask === (itemType === 'folder' ? READ | UPDATE | CREATE | DELETE : READ | UPDATE)) {
return 'edit';
}
return 'custom';
}
@@ -260,6 +292,12 @@ export function isRealDate(value: string): boolean {
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === value;
}
/** Datum `YYYY-MM-DD` nach der Uhr dieses Servers (nicht des Browsers): Grundlage der Ablaufgrenzen. */
export function serverDate(now: Date = new Date()): string {
const pad = (n: number, width = 2) => String(n).padStart(width, '0');
return `${pad(now.getFullYear(), 4)}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
}
function publicUrl(value: unknown): string | null {
if (typeof value !== 'string' || value.length === 0 || value.length > URL_MAX) return null;
try {
@@ -270,7 +308,15 @@ function publicUrl(value: unknown): string | null {
}
}
/** `null` fuer nicht unterstuetzte Arten (E-Mail, Server, Talk ...) und fehlerhafte Eintraege. */
/**
* `null` fuer nicht unterstuetzte Arten (E-Mail, Server, Talk ...) und fehlerhafte Eintraege.
*
* Eingehend/eigen (gemessen, Nextcloud 34): `uid_owner` ist der FREIGEBENDE, `uid_file_owner`
* der Eigentuemer der Datei. Eine Weitergabe (Ben gibt einen Ordner weiter, den Anna ihm
* geteilt hat) hat `uid_owner = ben`, `uid_file_owner = anna`; `path` ist immer der Pfad im
* Baum des Aufrufers, `file_target` der im Baum des Empfaengers. Weitergaben gelten daher als
* eigene Freigaben mit eigenem Pfad (`target` = `path`), nicht als eingehende.
*/
export function parseShare(raw: unknown, selfId: string): NcShareView | null {
if (!isDict(raw)) return null;
const type = intOf(raw.share_type);
@@ -282,11 +328,15 @@ export function parseShare(raw: unknown, selfId: string): NcShareView | null {
const ownerId = cleanText(raw.uid_owner, DISPLAY_MAX) || null;
const received = ownerId !== null && ownerId !== selfId;
const target = cleanText(raw.file_target, 4096);
const path = cleanText(raw.path, 4096) || target;
const rawTarget = verbatimId(raw.file_target, PATH_MAX);
const rawPath = verbatimId(raw.path, PATH_MAX);
// Ohne brauchbaren Pfad laesst sich nichts damit tun: wie eine unbekannte Art nur gezaehlt.
if (rawPath === '' && rawTarget === '') return null;
const path = rawPath || rawTarget;
const target = received ? rawTarget || rawPath : path;
const itemType: NcItemType =
raw.item_type === 'folder' || raw.mimetype === 'httpd/unix-directory' ? 'folder' : 'file';
const name = lastSegment(received ? target || path : path || target);
const name = lastSegment(received ? target : path);
const permissions = intOf(raw.permissions) ?? 0;
const itemPermissions = intOf(raw.item_permissions) ?? permissions;
const expirationRaw = typeof raw.expiration === 'string' ? raw.expiration.slice(0, 10) : '';
@@ -296,6 +346,9 @@ export function parseShare(raw: unknown, selfId: string): NcShareView | null {
? new Date(stime * 1000).toISOString()
: null;
const mimeRaw = cleanText(raw.mimetype, DISPLAY_MAX);
const fileOwnerId = cleanText(raw.uid_file_owner, DISPLAY_MAX);
const reshare = !received && fileOwnerId !== '' && fileOwnerId !== selfId;
const shareWith = kind === 'link' ? '' : verbatimId(raw.share_with, DISPLAY_MAX);
return {
id,
@@ -306,12 +359,15 @@ export function parseShare(raw: unknown, selfId: string): NcShareView | null {
mime: itemType === 'file' && mimeRaw !== '' ? mimeRaw : null,
itemWritable: (itemPermissions & (UPDATE | CREATE)) !== 0,
permissions,
access: accessOf(permissions),
shareWith: kind === 'link' ? null : cleanText(raw.share_with, DISPLAY_MAX) || null,
access: accessOf(permissions, itemType),
shareWith: shareWith === '' ? null : shareWith,
shareWithName:
kind === 'link' ? null : cleanText(raw.share_with_displayname, DISPLAY_MAX) || null,
ownerId,
ownerName: cleanText(raw.displayname_owner, DISPLAY_MAX) || null,
fileOwnerName: reshare
? cleanText(raw.displayname_file_owner, DISPLAY_MAX) || fileOwnerId
: null,
canEdit: raw.can_edit === true,
canDelete: raw.can_delete === true,
expiration: isRealDate(expirationRaw) ? expirationRaw : null,
@@ -356,7 +412,8 @@ function shareeList(value: unknown, wanted: 0 | 1): NcSharee[] {
for (const item of value) {
if (!isDict(item) || !isDict(item.value)) continue;
if (intOf(item.value.shareType) !== wanted) continue;
const id = cleanText(item.value.shareWith, DISPLAY_MAX);
// Die Kennung geht unveraendert zurueck an die Nextcloud (Anlegen): nie bereinigen.
const id = verbatimId(item.value.shareWith, DISPLAY_MAX);
if (id === '') continue;
const label = cleanText(item.label, DISPLAY_MAX) || id;
const unique = cleanText(item.shareWithDisplayNameUnique, DISPLAY_MAX);
@@ -398,11 +455,12 @@ function days(value: unknown): number | null {
}
/**
* Freigaberegeln aus `cloud/capabilities` (Antwort-`data` oder dessen `capabilities`).
* Freigaberegeln aus `cloud/capabilities` (Antwort-`data` oder dessen `capabilities`); `today`
* ist das Serverdatum (`serverDate`) und wird unveraendert in die Regeln uebernommen.
* Fehlende Schluessel heissen "keine Regel", kein Fehler: ohne `expire_date.enabled` gibt
* es keine Tage; sind Links aus, steht unter `public` nur `enabled: false`.
*/
export function parseSharePolicy(data: unknown): NcSharePolicy {
export function parseSharePolicy(data: unknown, today: string): NcSharePolicy {
const caps = isDict(data) && isDict(data.capabilities) ? data.capabilities : data;
const files = isDict(caps) && isDict(caps.files_sharing) ? caps.files_sharing : null;
const pwPolicy = isDict(caps) && isDict(caps.password_policy) ? caps.password_policy : null;
@@ -410,6 +468,7 @@ export function parseSharePolicy(data: unknown): NcSharePolicy {
const passwordMinLength = minLen !== null && minLen >= 1 && minLen <= 256 ? minLen : null;
const off: NcSharePolicy = {
today,
enabled: false,
groupsEnabled: false,
links: {
@@ -444,6 +503,7 @@ export function parseSharePolicy(data: unknown): NcSharePolicy {
const internal = expiry(pub.expire_date_internal);
return {
today,
enabled: true,
groupsEnabled: files.group_sharing !== false,
links: linksEnabled