630398fe93
- 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>
280 lines
9.2 KiB
TypeScript
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}`;
|
|
}
|