Files
tessera-ctl/apps/api/src/nextcloud-files/nextcloud-shares.ts
T
schalli 78f6cf358d 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>
2026-10-09 12:21:23 +02:00

525 lines
20 KiB
TypeScript

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 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;
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;
/**
* 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. */
expiration: string | null;
label: string;
/** Nur bei eigenen Links, nur http/https. */
url: string | null;
hasPassword: boolean;
/** Pfad im eigenen Baum des Aufrufers (bei eigenen Freigaben und Weitergaben gleich `path`), woertlich. */
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 {
/** 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;
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 PATH_MAX = 4096;
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)
);
}
/**
* 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());
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`. 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 (itemType === 'folder' && mask === CREATE) return 'upload';
if (mask === (itemType === 'folder' ? READ | UPDATE | CREATE | DELETE : READ | UPDATE)) {
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] : '';
}
export 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;
}
/** 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 {
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.
*
* 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);
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 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);
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);
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,
kind,
path,
name,
itemType,
mime: itemType === 'file' && mimeRaw !== '' ? mimeRaw : null,
itemWritable: (itemPermissions & (UPDATE | CREATE)) !== 0,
permissions,
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,
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;
// 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);
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`); `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, 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;
const minLen = pwPolicy ? intOf(pwPolicy.minLength) : null;
const passwordMinLength = minLen !== null && minLen >= 1 && minLen <= 256 ? minLen : null;
const off: NcSharePolicy = {
today,
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 {
today,
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,
};
}