/** * Nextcloud-Dateien — API-Client (quick-261008-mzu). Konsumiert * `/modules/nextcloud-files/*`. `credentials: 'include'` fuer Cookie-Auth, * `NEXT_PUBLIC_API_URL` als Basis (Muster `nextcloud-status-api.ts`). Der * Browser spricht nie mit der Nextcloud selbst; Zugangsdaten kommen hier nie * zurueck. */ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; const BASE = '/modules/nextcloud-files'; export interface NextcloudFilesAccount { connected: boolean; expired: boolean; status: 'ACTIVE' | 'EXPIRED'; ncUserId: string | null; displayName: string | null; connectedVia: 'PASSWORD' | 'LOGIN_FLOW' | null; connectedAt: string | null; } export interface NextcloudFilesFlowStart { flowId: string; /** Link zur Anmeldeseite der Nextcloud; der Benutzer oeffnet ihn mit eigenem Klick. */ loginUrl: string; expiresAt: string; } export type NextcloudFilesFlowPoll = | { state: 'pending' } | { state: 'connected' } | { state: 'failed'; code: string; message: string }; export interface NextcloudFilesStatus { configured: boolean; serverUrl: string | null; host: string | null; account: NextcloudFilesAccount | null; } export interface NextcloudFilesCheck { ok: boolean; kind: string; message: string; version: string | null; productName: string | null; } export interface NextcloudFilesSettings { baseUrl: string | null; connectedAccounts: number; check?: NextcloudFilesCheck; } /** Fehler mit HTTP-Status, maschinenlesbarer Kennung, deutscher Servermeldung und Zusatzfeldern. */ export class NextcloudFilesRequestError extends Error { constructor( readonly status: number, readonly code: string | null, message: string, readonly extra: Record = {}, ) { super(message); this.name = 'NextcloudFilesRequestError'; } } export async function requestFailure(res: Response): Promise { let message = `Request failed (${res.status})`; let code: string | null = null; let extra: Record = {}; try { const body = await res.json(); const raw = body?.message; if (Array.isArray(raw)) message = raw.join(' '); else if (typeof raw === 'string') message = raw; if (typeof body?.code === 'string') code = body.code; if (body && typeof body === 'object') { const { code: _code, message: _message, statusCode: _statusCode, error: _error, ...rest } = body as Record; extra = rest; } } catch { // Antwort ohne JSON-Koerper — Standardmeldung bleibt. } return new NextcloudFilesRequestError(res.status, code, message, extra); } /** Vollstaendige Adresse einer Route dieses Moduls (fuer Aufrufe, die nicht ueber `request` laufen). */ export function nextcloudFilesUrl(path: string): string { return `${API_URL}${BASE}${path}`; } async function request( path: string, init: { method?: string; json?: unknown } = {}, ): Promise { const method = init.method ?? 'GET'; const headers: Record = {}; let body: string | undefined; if (init.json !== undefined) { headers['Content-Type'] = 'application/json'; body = JSON.stringify(init.json); } const res = await fetch(`${API_URL}${BASE}${path}`, { method, credentials: 'include', headers, body, ...(method === 'GET' ? { cache: 'no-store' as const } : {}), }); if (!res.ok) throw await requestFailure(res); if (res.status === 204) return undefined as T; return (await res.json()) as T; } export function getNextcloudFilesStatus(): Promise { return request('/status'); } export function getNextcloudFilesSettings(): Promise { return request('/settings'); } export function saveNextcloudFilesSettings(input: { baseUrl: string; confirmReconnect?: boolean; }): Promise { return request('/settings', { method: 'PUT', json: input }); } export function testNextcloudFilesSettings(baseUrl: string): Promise { return request('/settings/test', { method: 'POST', json: { baseUrl } }); } /** Kennung der Nextcloud fuer den Anmeldebildschirm (ohne Zugangsdaten). */ export interface NextcloudServerInfo { host: string; name: string; /** `#rrggbb` aus dem Nextcloud-Theming oder `null`. */ color: string | null; version: string | null; hasLogo: boolean; } export function getServerInfo(): Promise { return request('/server'); } /** * Adresse des Nextcloud-Logos als Bild (`` laedt mit dem Tessera-Cookie). `token` * wechselt, wenn sich die Nextcloud aendert, und umgeht so den Bildzwischenspeicher. */ export function serverLogoUrl(token?: string): string { const version = token ? `?v=${encodeURIComponent(token)}` : ''; return `${API_URL}${BASE}/server/logo${version}`; } /** * Verbinden mit Benutzername und Passwort. Das Passwort geht genau einmal an die * API und wird nirgends zwischengespeichert; die Antwort enthaelt kein Geheimnis. */ export function connectWithPassword(input: { loginName: string; password: string; }): Promise { return request('/connect/password', { method: 'POST', json: input }); } /** Startet die Browser-Anmeldung (Login Flow v2) fuer Konten mit Zwei-Faktor-Anmeldung. */ export function startLoginFlow(): Promise { return request('/connect/flow', { method: 'POST' }); } export function pollLoginFlow(flowId: string): Promise { return request(`/connect/flow/${encodeURIComponent(flowId)}`); } export function cancelLoginFlow(flowId: string): Promise<{ cancelled: true }> { return request<{ cancelled: true }>(`/connect/flow/${encodeURIComponent(flowId)}`, { method: 'DELETE', }); } /** Trennt die Verbindung; die API widerruft den Zugang bei Nextcloud. */ export function disconnectNextcloud(): Promise<{ disconnected: true }> { return request<{ disconnected: true }>('/connect', { method: 'DELETE' }); } // --- Dateien (Etappe 1, Aufgabe 3) ------------------------------------------------------------- export interface NcEntry { name: string; /** Pfad ab der Wurzel, z. B. `/Projekte/Bericht.pdf`. */ path: string; type: 'folder' | 'file'; size: number; mime: string | null; /** ISO-Zeitstempel. */ mtime: string | null; etag: string | null; fileId: string | null; /** Berechtigungsbuchstaben der Nextcloud (R teilbar, D loeschbar, N umbenennbar ...). */ permissions: string; hasPreview: boolean; favorite: boolean; /** Freigabearten des Eintrags (0 Person, 1 Gruppe, 3 Link ...); leer = nicht geteilt. */ shareTypes: number[]; } export interface NcQuota { used: number; /** null = unbegrenzt oder unbekannt. */ available: number | null; } export interface NcListing { path: string; entries: NcEntry[]; quota: NcQuota; /** true, wenn der Ordner mehr als 5000 Eintraege hat und abgeschnitten wurde. */ truncated: boolean; /** * Berechtigungsbuchstaben des Ordners selbst (z. B. `RGDNVCK`; C Dateien anlegen, K Ordner * anlegen). `null`/fehlend = unbekannt: dann wird nichts ausgeblendet, die Nextcloud entscheidet. */ permissions?: string | null; } /** Ordnerinhalt samt Speicherangaben. Der Pfad geht nur in der Query, nie im URL-Pfad. */ export function listFolder(path: string): Promise { return request(`/files?path=${encodeURIComponent(path)}`); } export function createFolder(path: string): Promise<{ path: string }> { return request<{ path: string }>('/folders', { method: 'POST', json: { path } }); } /** Verschieben oder Umbenennen; ein vorhandener Name im Ziel wird nie ueberschrieben (nameTaken). */ export function moveEntry(from: string, to: string): Promise<{ from: string; to: string }> { return request<{ from: string; to: string }>('/move', { method: 'POST', json: { from, to } }); } /** Loescht in den Papierkorb der Nextcloud. */ export function deleteEntry(path: string): Promise<{ deleted: true }> { return request<{ deleted: true }>(`/files?path=${encodeURIComponent(path)}`, { method: 'DELETE', }); } /** * Adresse des Vorschaubildes fuer ein `` (Tessera-Cookie genuegt). Die * Version (Entity-Tag) macht die Adresse je Dateiversion eindeutig und erlaubt * dem Browser, das Bild zu cachen. */ export function previewUrl(fileId: string, etag: string | null): string { const version = etag ? `&v=${encodeURIComponent(etag)}` : ''; return `${API_URL}${BASE}/preview?fileId=${encodeURIComponent(fileId)}${version}`; } // --- Herunterladen (Aufgabe 4) ------------------------------------------------------------------ /** * Adresse zum Herunterladen einer Datei — oder mit `zip` eines Ordners als ZIP. Ein * Link in `` genuegt (Tessera-Cookie); die API setzt `Content-Disposition: * attachment`, der Browser speichert also nur und zeigt nichts an. Der Pfad geht nur * in der Query. */ export function downloadUrl(path: string, opts: { zip?: boolean } = {}): string { const zip = opts.zip ? '&zip=1' : ''; return `${API_URL}${BASE}/download?path=${encodeURIComponent(path)}${zip}`; } /** Hoechstzahl gewaehlter Eintraege fuer ein Auswahl-ZIP (wie die API, WR-08). */ export const ZIP_MAX_NAMES = 1000; /** * Budget fuer Ordner und Namensliste, codiert wie in der Adresse an Nextcloud. Nextcloud nimmt * die Auswahl nur in der Adresse an, und ein Apache davor lehnt Anfragezeilen ueber 8190 * Zeichen ab; die API prueft verbindlich (413 `selectionTooLarge`), hier wird vorher und * etwas vorsichtiger gerechnet, damit der Benutzer sofort eine klare Meldung bekommt. */ export const ZIP_MAX_ENCODED_CHARS = 7000; /** * Ziel des Auswahl-ZIPs (WR-08): POST mit den Namen im KOERPER, nicht in der Adresse — * mehrere hundert Namen sprengten sonst die Grenzen von Node (431) oder des Proxys (414). */ export function zipPostUrl(): string { return `${API_URL}${BASE}/download/zip`; } /** Formularfelder des Auswahl-ZIPs: Ordner und die Namen als JSON-Liste. */ export function zipFormFields(dir: string, names: readonly string[]): Record { return { dir, names: JSON.stringify(names) }; } /** Passt die Auswahl in ein Auswahl-ZIP (Anzahl und Laenge der Adresse an Nextcloud)? */ export function zipSelectionFits(dir: string, names: readonly string[]): boolean { if (names.length === 0 || names.length > ZIP_MAX_NAMES) return false; const encoded = encodeURIComponent(dir).length + encodeURIComponent(JSON.stringify(names)).length; return encoded <= ZIP_MAX_ENCODED_CHARS; } /** * Vorabpruefung eines Downloads (IN-06): Verbindung und Eintrag. Ein Download ueber einen Link * zeigt Fehler im Browser nicht an; so kann die Ansicht sie melden. */ export function checkDownload(path: string, opts: { zip?: boolean } = {}): Promise<{ ok: true }> { const zip = opts.zip ? '&zip=1' : ''; return request<{ ok: true }>(`/download?path=${encodeURIComponent(path)}${zip}&check=1`); } /** Vorabpruefung eines Auswahl-ZIPs (IN-06): Verbindung, Ordner und die Namen selbst. */ export function checkZip(dir: string, names: readonly string[]): Promise<{ ok: true }> { return request<{ ok: true }>('/download/zip', { method: 'POST', json: { dir, names, check: true }, }); } // --- Teilen (quick-261009-dkv) ------------------------------------------------------------------- export type NcShareKind = 'user' | 'group' | 'link'; /** `custom`: eine Berechtigung, die sich nicht auf Ansehen/Bearbeiten/Hochladen abbilden laesst. */ export type NcShareAccess = 'view' | 'edit' | 'upload' | 'custom'; export interface NcShare { id: string; kind: NcShareKind; path: string; name: string; itemType: 'file' | 'folder'; mime: string | null; itemWritable: boolean; permissions: number; access: NcShareAccess; shareWith: string | null; shareWithName: string | null; ownerId: string | null; ownerName: string | null; /** Eigentuemer der Datei, nur bei Weitergaben (der Benutzer gibt etwas weiter, das ihm geteilt wurde). */ fileOwnerName?: string | null; canEdit: boolean; canDelete: boolean; /** `YYYY-MM-DD` oder null. */ expiration: string | null; label: string; /** Nur bei eigenen Links; Tessera ruft sie nie auf. */ url: string | null; hasPassword: boolean; target: string; sharedAt: string | null; pending?: boolean; } export interface NcSharee { kind: 'user' | 'group'; id: string; label: string; detail: string | null; } /** Freigaberegeln der Nextcloud fuer den angemeldeten Benutzer (frisch gelesen). */ export interface NcSharePolicy { /** Heutiges Datum (`YYYY-MM-DD`) nach der Uhr des Servers: Grundlage fuer Ablaufgrenzen. */ today?: string; enabled: boolean; groupsEnabled: boolean; links: { enabled: boolean; passwordRequired: boolean; passwordSuggested: boolean; expiryDefaultDays: number | null; expiryEnforced: boolean; uploadAllowed: boolean; multipleLinks: boolean; }; internalExpiry: { defaultDays: number | null; enforced: boolean }; minSearchLength: number; passwordMinLength: number | null; } export interface NcShareList { path: string; shares: NcShare[]; /** Freigaben anderer Arten (E-Mail, Server ...), nur als Zahl. */ hidden: number; truncated: boolean; } /** * Neue Freigabe. Bei Links: `password` (nie in einer Adresse), `expireDate` (`YYYY-MM-DD` oder * `''` fuer "ohne Ablauf") und `label`; bei Personen und Gruppen `shareWith`. */ export interface CreateShareInput { path: string; kind: NcShareKind; shareWith?: string; access: 'view' | 'edit' | 'upload'; password?: string; expireDate?: string; label?: string; } /** Leere Zeichenkette heisst: Passwort entfernen, Ablaufdatum entfernen, Bezeichnung leeren. */ export interface UpdateShareInput { access?: 'view' | 'edit' | 'upload'; password?: string; expireDate?: string; label?: string; } /** Eigene Freigaben: Personen, Gruppen, Links. */ export interface NcMyShares { shares: NcShare[]; hidden: number; truncated: boolean; } /** Mit mir geteilt: angenommene und noch offene (`pending`) Freigaben. */ export interface NcReceivedShares extends NcMyShares { pending: NcShare[]; } export function getSharePolicy(): Promise { return request('/shares/policy'); } /** Freigaben eines Eintrags; der Pfad geht nur in der Query. */ export function listSharesForPath(path: string): Promise { return request(`/shares/by-path?path=${encodeURIComponent(path)}`); } /** Personen und Gruppen, die die Nextcloud zum Suchbegriff kennt. */ export function searchSharees( term: string, itemType: 'file' | 'folder', ): Promise<{ sharees: NcSharee[] }> { return request<{ sharees: NcSharee[] }>( `/sharees?term=${encodeURIComponent(term)}&itemType=${encodeURIComponent(itemType)}`, ); } export function createShare(input: CreateShareInput): Promise { return request('/shares', { method: 'POST', json: input }); } export function updateShare(id: string, input: UpdateShareInput): Promise { return request(`/shares/${encodeURIComponent(id)}`, { method: 'PUT', json: input }); } export function deleteShare(id: string): Promise<{ deleted: true }> { return request<{ deleted: true }>(`/shares/${encodeURIComponent(id)}`, { method: 'DELETE' }); } export function listMyShares(): Promise { return request('/shares/mine'); } export function listReceivedShares(): Promise { return request('/shares/received'); } /** Eine offene Freigabe annehmen; Ablehnen und Verlassen laufen ueber `deleteShare`. */ export function acceptShare(id: string): Promise<{ accepted: true }> { return request<{ accepted: true }>(`/shares/${encodeURIComponent(id)}/accept`, { method: 'POST', }); }