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; 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; /** JSON-Koerper (POST/PUT); Passwoerter stehen nie in einer Adresse. */ json?: Record; /** 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 { 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(); 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, }; }