/** * 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; } 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; } /** 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 }, }); }