Files
tessera-ctl/apps/web/src/lib/nextcloud-files-api.ts
T
schalli 630398fe93 feat(nextcloud-files): Nextcloud-Kennung auf der Anmeldeseite, Anleitungen und Changelog
- Anmeldebildschirm zeigt Name, Logo und Themenfarbe der Nextcloud (GET server, server/logo)
- Kennung wird 10 Minuten zwischengespeichert, Adresswechsel leert sie, Logo nur nach Bytes erkannt und mit CSP-Sandbox ausgeliefert
- Anmeldekarte neu gestaltet (Kennungskopf, Hinweis zum Passwort am Fuss), Kontoleiste mit kleiner Kachel
- Fokusfang in leeren Ordnern, damit die Rücktaste dort funktioniert
- Changelog, Anwender-, Administrations- und Betriebshandbuch (Brute-Force-Ausnahme, Proxy-Grenzen)
- e2e-Skripte wiederholbar gemacht

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 22:08:57 +02:00

280 lines
9.2 KiB
TypeScript

/**
* 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<string, unknown> = {},
) {
super(message);
this.name = 'NextcloudFilesRequestError';
}
}
export async function requestFailure(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);
}
/** 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<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 requestFailure(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 } });
}
/** 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<NextcloudServerInfo> {
return request<NextcloudServerInfo>('/server');
}
/**
* Adresse des Nextcloud-Logos als Bild (`<img>` 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<NextcloudFilesStatus> {
return request<NextcloudFilesStatus>('/connect/password', { method: 'POST', json: input });
}
/** Startet die Browser-Anmeldung (Login Flow v2) fuer Konten mit Zwei-Faktor-Anmeldung. */
export function startLoginFlow(): Promise<NextcloudFilesFlowStart> {
return request<NextcloudFilesFlowStart>('/connect/flow', { method: 'POST' });
}
export function pollLoginFlow(flowId: string): Promise<NextcloudFilesFlowPoll> {
return request<NextcloudFilesFlowPoll>(`/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<NcListing> {
return request<NcListing>(`/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 `<img>` (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 `<a href>` 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}`;
}
/**
* Adresse zum Herunterladen mehrerer Eintraege eines Ordners als ein ZIP. Jeder Name
* steht als eigener `name`-Parameter (wiederholter Schluessel), einzeln codiert.
*/
export function zipUrl(dir: string, names: readonly string[]): string {
const list = names.map((n) => `name=${encodeURIComponent(n)}`).join('&');
return `${API_URL}${BASE}/download/zip?dir=${encodeURIComponent(dir)}&${list}`;
}