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