d487a00955
- Link aendern: nicht angebotene Berechtigung bleibt unberuehrt (Aktuell: ...), Zugriff nur bei ausdruecklicher Wahl gesendet - Fehler je Abschnitt zugeordnet und geloescht; Aendern nur bei aenderbarer Freigabe; Kopiert-Anzeige laeuft ab - Freigabe-Ansichten: stabiler Rueckruf, nur das juengste Laden gilt; Weitergaben mit Hinweis - Ablaufgrenzen nach Serverdatum, Passwort-Erzeugung bis 256 Zeichen - Dateiansicht blendet Neuer Ordner, Hochladen, Ablegen, Umbenennen, Verschieben und Loeschen nach den Buchstaben der Nextcloud aus - Import-Reihenfolge nach biome check, Anleitungen und Changelog (10 statt 15 Freigaben, Suche ab einem oder zwei Zeichen) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
475 lines
16 KiB
TypeScript
475 lines
16 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;
|
|
/** Freigabearten des Eintrags (0 Person, 1 Gruppe, 3 Link ...); leer = nicht geteilt. */
|
|
shareTypes: number[];
|
|
}
|
|
|
|
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;
|
|
/**
|
|
* Berechtigungsbuchstaben des Ordners selbst (z. B. `RGDNVCK`; C Dateien anlegen, K Ordner
|
|
* anlegen). `null`/fehlend = unbekannt: dann wird nichts ausgeblendet, die Nextcloud entscheidet.
|
|
*/
|
|
permissions?: string | null;
|
|
}
|
|
|
|
/** 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}`;
|
|
}
|
|
|
|
/** 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<string, string> {
|
|
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 },
|
|
});
|
|
}
|
|
|
|
// --- Teilen (quick-261009-dkv) -------------------------------------------------------------------
|
|
|
|
export type NcShareKind = 'user' | 'group' | 'link';
|
|
/** `custom`: eine Berechtigung, die sich nicht auf Ansehen/Bearbeiten/Hochladen abbilden laesst. */
|
|
export type NcShareAccess = 'view' | 'edit' | 'upload' | 'custom';
|
|
|
|
export interface NcShare {
|
|
id: string;
|
|
kind: NcShareKind;
|
|
path: string;
|
|
name: string;
|
|
itemType: 'file' | 'folder';
|
|
mime: string | null;
|
|
itemWritable: boolean;
|
|
permissions: number;
|
|
access: NcShareAccess;
|
|
shareWith: string | null;
|
|
shareWithName: string | null;
|
|
ownerId: string | null;
|
|
ownerName: string | null;
|
|
/** Eigentuemer der Datei, nur bei Weitergaben (der Benutzer gibt etwas weiter, das ihm geteilt wurde). */
|
|
fileOwnerName?: string | null;
|
|
canEdit: boolean;
|
|
canDelete: boolean;
|
|
/** `YYYY-MM-DD` oder null. */
|
|
expiration: string | null;
|
|
label: string;
|
|
/** Nur bei eigenen Links; Tessera ruft sie nie auf. */
|
|
url: string | null;
|
|
hasPassword: boolean;
|
|
target: string;
|
|
sharedAt: string | null;
|
|
pending?: boolean;
|
|
}
|
|
|
|
export interface NcSharee {
|
|
kind: 'user' | 'group';
|
|
id: string;
|
|
label: string;
|
|
detail: string | null;
|
|
}
|
|
|
|
/** Freigaberegeln der Nextcloud fuer den angemeldeten Benutzer (frisch gelesen). */
|
|
export interface NcSharePolicy {
|
|
/** Heutiges Datum (`YYYY-MM-DD`) nach der Uhr des Servers: Grundlage fuer Ablaufgrenzen. */
|
|
today?: string;
|
|
enabled: boolean;
|
|
groupsEnabled: boolean;
|
|
links: {
|
|
enabled: boolean;
|
|
passwordRequired: boolean;
|
|
passwordSuggested: boolean;
|
|
expiryDefaultDays: number | null;
|
|
expiryEnforced: boolean;
|
|
uploadAllowed: boolean;
|
|
multipleLinks: boolean;
|
|
};
|
|
internalExpiry: { defaultDays: number | null; enforced: boolean };
|
|
minSearchLength: number;
|
|
passwordMinLength: number | null;
|
|
}
|
|
|
|
export interface NcShareList {
|
|
path: string;
|
|
shares: NcShare[];
|
|
/** Freigaben anderer Arten (E-Mail, Server ...), nur als Zahl. */
|
|
hidden: number;
|
|
truncated: boolean;
|
|
}
|
|
|
|
/**
|
|
* Neue Freigabe. Bei Links: `password` (nie in einer Adresse), `expireDate` (`YYYY-MM-DD` oder
|
|
* `''` fuer "ohne Ablauf") und `label`; bei Personen und Gruppen `shareWith`.
|
|
*/
|
|
export interface CreateShareInput {
|
|
path: string;
|
|
kind: NcShareKind;
|
|
shareWith?: string;
|
|
access: 'view' | 'edit' | 'upload';
|
|
password?: string;
|
|
expireDate?: string;
|
|
label?: string;
|
|
}
|
|
|
|
/** Leere Zeichenkette heisst: Passwort entfernen, Ablaufdatum entfernen, Bezeichnung leeren. */
|
|
export interface UpdateShareInput {
|
|
access?: 'view' | 'edit' | 'upload';
|
|
password?: string;
|
|
expireDate?: string;
|
|
label?: string;
|
|
}
|
|
|
|
/** Eigene Freigaben: Personen, Gruppen, Links. */
|
|
export interface NcMyShares {
|
|
shares: NcShare[];
|
|
hidden: number;
|
|
truncated: boolean;
|
|
}
|
|
|
|
/** Mit mir geteilt: angenommene und noch offene (`pending`) Freigaben. */
|
|
export interface NcReceivedShares extends NcMyShares {
|
|
pending: NcShare[];
|
|
}
|
|
|
|
export function getSharePolicy(): Promise<NcSharePolicy> {
|
|
return request<NcSharePolicy>('/shares/policy');
|
|
}
|
|
|
|
/** Freigaben eines Eintrags; der Pfad geht nur in der Query. */
|
|
export function listSharesForPath(path: string): Promise<NcShareList> {
|
|
return request<NcShareList>(`/shares/by-path?path=${encodeURIComponent(path)}`);
|
|
}
|
|
|
|
/** Personen und Gruppen, die die Nextcloud zum Suchbegriff kennt. */
|
|
export function searchSharees(
|
|
term: string,
|
|
itemType: 'file' | 'folder',
|
|
): Promise<{ sharees: NcSharee[] }> {
|
|
return request<{ sharees: NcSharee[] }>(
|
|
`/sharees?term=${encodeURIComponent(term)}&itemType=${encodeURIComponent(itemType)}`,
|
|
);
|
|
}
|
|
|
|
export function createShare(input: CreateShareInput): Promise<NcShare> {
|
|
return request<NcShare>('/shares', { method: 'POST', json: input });
|
|
}
|
|
|
|
export function updateShare(id: string, input: UpdateShareInput): Promise<NcShare> {
|
|
return request<NcShare>(`/shares/${encodeURIComponent(id)}`, { method: 'PUT', json: input });
|
|
}
|
|
|
|
export function deleteShare(id: string): Promise<{ deleted: true }> {
|
|
return request<{ deleted: true }>(`/shares/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
}
|
|
|
|
export function listMyShares(): Promise<NcMyShares> {
|
|
return request<NcMyShares>('/shares/mine');
|
|
}
|
|
|
|
export function listReceivedShares(): Promise<NcReceivedShares> {
|
|
return request<NcReceivedShares>('/shares/received');
|
|
}
|
|
|
|
/** Eine offene Freigabe annehmen; Ablehnen und Verlassen laufen ueber `deleteShare`. */
|
|
export function acceptShare(id: string): Promise<{ accepted: true }> {
|
|
return request<{ accepted: true }>(`/shares/${encodeURIComponent(id)}/accept`, {
|
|
method: 'POST',
|
|
});
|
|
}
|