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,142 @@
import { Inject, Injectable } from '@nestjs/common';
import type { Response } from 'express';
import { NextcloudCallGate } from './nextcloud-call-gate';
import * as dav from './nextcloud-dav';
import { type NcSession, ncErrorDefault } from './nextcloud-files.types';
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
import {
NEXTCLOUD_TRANSPORT,
type NextcloudTransport,
parseUserPath,
validateNewName,
} from './nextcloud-http';
import type { NcEntry, NcQuota } from './nextcloud-propfind';
import { mapNcFailure, type NcOutcome, sendUpstreamStream } from './nextcloud-upstream';
export interface ListingView {
path: string;
entries: NcEntry[];
quota: NcQuota;
truncated: boolean;
}
/** Vorschaubilder: hoechstens 5 MiB, nur Bilder (D-K). */
const PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
const FILE_ID_RE = /^\d{1,20}$/;
function pathOf(segments: readonly string[]): string {
return `/${segments.join('/')}`;
}
/**
* Dateiaktionen im Konto des angemeldeten Benutzers (quick-261008-mzu, D-I):
* auflisten, Ordner anlegen, verschieben/umbenennen, loeschen, Vorschau. Jede
* Methode beginnt mit `getSession(tenantId, userId)` — die Benutzerkennung
* kommt aus dem Token, nie aus der Eingabe; ein Benutzer ohne Konto bekommt
* `notConnected` und kann nie ueber das Konto eines anderen arbeiten. Dieser
* Dienst greift nicht auf die Datenbank zu. Jeder Fehler laeuft durch
* `mapNcFailure` (nie 401/403 an den Browser; ein 401 von Nextcloud markiert die
* Verbindung als abgelaufen).
*/
@Injectable()
export class NextcloudFilesService {
constructor(
private readonly account: NextcloudFilesAccountService,
private readonly gate: NextcloudCallGate,
@Inject(NEXTCLOUD_TRANSPORT) private readonly transport: NextcloudTransport,
) {}
private async fail(tenantId: string, userId: string, result: NcOutcome): Promise<never> {
throw await mapNcFailure(result, {
onExpired: () => this.account.markExpired(tenantId, userId),
});
}
private session(tenantId: string, userId: string): Promise<NcSession> {
return this.account.getSession(tenantId, userId);
}
async list(tenantId: string, userId: string, rawPath?: string): Promise<ListingView> {
const segments = parseUserPath(rawPath);
const session = await this.session(tenantId, userId);
const result = await dav.list(this.transport, this.gate, session, segments);
if (!result.ok || result.listing === null) return this.fail(tenantId, userId, result);
const { entries, quota, truncated } = result.listing;
return { path: pathOf(segments), entries, quota, truncated };
}
async createFolder(tenantId: string, userId: string, rawPath: string): Promise<{ path: string }> {
const segments = parseUserPath(rawPath);
if (segments.length === 0) throw ncErrorDefault('invalidPath');
validateNewName(segments[segments.length - 1]);
const session = await this.session(tenantId, userId);
const result = await dav.mkdir(this.transport, this.gate, session, segments);
if (!result.ok || result.status < 200 || result.status >= 300) {
return this.fail(tenantId, userId, result);
}
return { path: pathOf(segments) };
}
async move(
tenantId: string,
userId: string,
rawFrom: string,
rawTo: string,
): Promise<{ from: string; to: string }> {
const from = parseUserPath(rawFrom);
const to = parseUserPath(rawTo);
if (from.length === 0 || to.length === 0) throw ncErrorDefault('invalidPath');
validateNewName(to[to.length - 1]);
// Ziel gleich oder innerhalb der Quelle: vor jedem Aufruf ablehnen.
if (to.length >= from.length && from.every((s, i) => to[i] === s)) {
throw ncErrorDefault('moveIntoItself');
}
const session = await this.session(tenantId, userId);
const result = await dav.move(this.transport, this.gate, session, from, to);
if (!result.ok || result.status < 200 || result.status >= 300) {
return this.fail(tenantId, userId, result);
}
return { from: pathOf(from), to: pathOf(to) };
}
async remove(tenantId: string, userId: string, rawPath: string): Promise<{ deleted: true }> {
const segments = parseUserPath(rawPath);
if (segments.length === 0) throw ncErrorDefault('invalidPath');
const session = await this.session(tenantId, userId);
const result = await dav.remove(this.transport, this.gate, session, segments);
if (!result.ok || result.status < 200 || result.status >= 300) {
return this.fail(tenantId, userId, result);
}
return { deleted: true };
}
/** Vorschaubild streamen; `version` (Entity-Tag) erlaubt einen Tag Browser-Cache. */
async preview(
res: Response,
tenantId: string,
userId: string,
fileId: string,
version?: string,
): Promise<void> {
if (!FILE_ID_RE.test(fileId)) throw ncErrorDefault('invalidPath');
const session = await this.session(tenantId, userId);
// Bricht der Browser ab, wird auch der Aufruf an Nextcloud abgebrochen.
const abort = new AbortController();
res.on('close', () => {
if (!res.writableFinished) abort.abort();
});
const upstream = await dav.preview(this.transport, this.gate, session, fileId, abort.signal);
await sendUpstreamStream(res, upstream, {
extraHeaders: {
'cache-control': version ? 'private, max-age=86400' : 'private, max-age=3600',
'x-content-type-options': 'nosniff',
'content-security-policy': "default-src 'none'; sandbox",
},
accept: (contentType) => contentType.toLowerCase().startsWith('image/'),
maxBytes: PREVIEW_MAX_BYTES,
onExpired: () => this.account.markExpired(tenantId, userId),
});
}
}