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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user