feat(nextcloud-files): Dateien auflisten, Vorschau, Ordner anlegen, umbenennen, verschieben und löschen

- PROPFIND-Auswertung (mehrere propstat, Kleinbuchstaben-Hex, Speicher -3 = unbegrenzt, nur Zeichenketten)
- WebDAV-Schicht mit Zugangsschluessel und Aufrufsperre, Destination immer aus der Basis, Overwrite: F
- mapNcFailure als einzige Fehlerabbildung (nie 401/403 an den Browser), sendUpstreamStream prueft vor dem ersten Byte
- Dateidienst und Routen files, folders, move, preview im eigenen Konto; zweiter Benutzer bekommt notConnected
- Web-API-Funktionen listFolder, createFolder, moveEntry, deleteEntry, previewUrl
- e2e-files.sh gegen die Test-Nextcloud

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:14:54 +02:00
parent d00b6ff79f
commit 8bee65c306
18 changed files with 2214 additions and 5 deletions
@@ -0,0 +1,91 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createFolder,
deleteEntry,
listFolder,
moveEntry,
NextcloudFilesRequestError,
previewUrl,
} from './nextcloud-files-api';
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
function mockFetch(status: number, body: unknown) {
const fn = vi.fn(async (..._args: unknown[]) => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
}));
vi.stubGlobal('fetch', fn);
return fn;
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('nextcloud-files-api — Dateien', () => {
it('listFolder codiert den Pfad in der Query und sendet das Cookie mit', async () => {
const fetchMock = mockFetch(200, {
path: '/Ärger & Co',
entries: [],
quota: {},
truncated: false,
});
await listFolder('/Ärger & Co');
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`${API}/modules/nextcloud-files/files?path=%2F%C3%84rger%20%26%20Co`);
expect(init.method).toBe('GET');
expect(init.credentials).toBe('include');
expect(init.cache).toBe('no-store');
});
it('Fehlerkoerper werden zu NextcloudFilesRequestError mit Code und Zusatzfeldern', async () => {
mockFetch(409, {
code: 'nameTaken',
message: 'Ein Eintrag mit diesem Namen existiert bereits.',
existing: { etag: '"a"', size: 3, mtime: null },
});
const err = await moveEntry('/a', '/b').catch((e) => e);
expect(err).toBeInstanceOf(NextcloudFilesRequestError);
expect(err.status).toBe(409);
expect(err.code).toBe('nameTaken');
expect(err.message).toBe('Ein Eintrag mit diesem Namen existiert bereits.');
expect(err.extra).toEqual({ existing: { etag: '"a"', size: 3, mtime: null } });
});
it('createFolder sendet POST /folders mit { path }', async () => {
const fetchMock = mockFetch(201, { path: '/Neu' });
await createFolder('/Neu');
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`${API}/modules/nextcloud-files/folders`);
expect(init.method).toBe('POST');
expect(init.body).toBe('{"path":"/Neu"}');
expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
});
it('moveEntry sendet POST /move mit { from, to }', async () => {
const fetchMock = mockFetch(200, { from: '/a', to: '/b' });
await moveEntry('/a', '/b');
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`${API}/modules/nextcloud-files/move`);
expect(init.method).toBe('POST');
expect(init.body).toBe('{"from":"/a","to":"/b"}');
});
it('deleteEntry sendet DELETE /files?path= (codiert), ohne Body', async () => {
const fetchMock = mockFetch(200, { deleted: true });
await deleteEntry('/Ordner/a b#c.txt');
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(`${API}/modules/nextcloud-files/files?path=%2FOrdner%2Fa%20b%23c.txt`);
expect(init.method).toBe('DELETE');
expect(init.body).toBeUndefined();
});
it('previewUrl baut die Query mit encodeURIComponent; ohne Version fehlt v', () => {
expect(previewUrl('42', '"abc 1"')).toBe(
`${API}/modules/nextcloud-files/preview?fileId=42&v=%22abc%201%22`,
);
expect(previewUrl('42', null)).toBe(`${API}/modules/nextcloud-files/preview?fileId=42`);
});
});
+64
View File
@@ -163,3 +163,67 @@ export function cancelLoginFlow(flowId: string): Promise<{ cancelled: true }> {
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}`;
}