feat(nextcloud-files): Modul Dateien mit Nextcloud-Adresse, Verbindungsprüfung und gesicherter Verbindungsschicht

- Migration 20261008180000: NextcloudFilesConfig (Organisation) und NextcloudFilesAccount
  (Mandant UND Benutzer per Zeilenschutz, nur verschlüsseltes App-Passwort)
- Transportschicht ncRequest: feste Pfadanfänge, Segmentcodierung, keine Weiterleitungen,
  keine Cookies, Zeitgrenzen; Aufrufsperre pausiert den Ursprung nach jedem 429 und
  sperrt Zugangsschlüssel nach dem ersten 401
- Einstellungen: Adresse speichern/prüfen, Adresswechsel läuft alle Konten ab (mit Bestätigung)
- Controller mit Verwalten nur auf Einstellungen, Seed, Modul, Registrierung in Web
  (Ladefunktion, Symbol folder, Navigation, Layout) und Reiter Einstellungen
- RLS-Inventar fortgeschrieben, Testaufbau (tessera-nc-test) und E2E-Skripte

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 17:41:46 +02:00
parent 83b842b72c
commit 960696745f
35 changed files with 3282 additions and 2 deletions
+120
View File
@@ -0,0 +1,120 @@
/**
* 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;
ncUserId: string | null;
displayName: string | null;
connectedVia: 'PASSWORD' | 'LOGIN_FLOW' | null;
}
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<string, unknown> = {},
) {
super(message);
this.name = 'NextcloudFilesRequestError';
}
}
async function failure(res: Response): Promise<NextcloudFilesRequestError> {
let message = `Request failed (${res.status})`;
let code: string | null = null;
let extra: Record<string, unknown> = {};
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<string, unknown>;
extra = rest;
}
} catch {
// Antwort ohne JSON-Koerper — Standardmeldung bleibt.
}
return new NextcloudFilesRequestError(res.status, code, message, extra);
}
async function request<T>(
path: string,
init: { method?: string; json?: unknown } = {},
): Promise<T> {
const method = init.method ?? 'GET';
const headers: Record<string, string> = {};
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 failure(res);
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export function getNextcloudFilesStatus(): Promise<NextcloudFilesStatus> {
return request<NextcloudFilesStatus>('/status');
}
export function getNextcloudFilesSettings(): Promise<NextcloudFilesSettings> {
return request<NextcloudFilesSettings>('/settings');
}
export function saveNextcloudFilesSettings(input: {
baseUrl: string;
confirmReconnect?: boolean;
}): Promise<NextcloudFilesSettings> {
return request<NextcloudFilesSettings>('/settings', { method: 'PUT', json: input });
}
export function testNextcloudFilesSettings(baseUrl: string): Promise<NextcloudFilesCheck> {
return request<NextcloudFilesCheck>('/settings/test', { method: 'POST', json: { baseUrl } });
}