feat(nextcloud-files): Hoch- und Herunterladen als Datenstrom, große Dateien in Stücken, ZIP

- Hochladen ohne Pufferung: ganze Datei oder Chunked Upload v2 in 8-MiB-Stücken, Zusammenbau im Hintergrund mit abfragbarem Zustand
- Überschreibschutz (If-None-Match / Entity-Tag-Prüfung), Download mit Range, Content-Disposition und Schutzkopfzeilen von Tessera selbst
- Ordner- und Auswahl-ZIP; jeder Nextcloud-Fehler wird vor dem ersten Byte abgebildet, nie 401/403
- Browser-Hochlader mit Fortschritt, Wiederholung, Abbruch und Aufräumen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:30:02 +02:00
parent 8bee65c306
commit 4d4a0b31cb
15 changed files with 3431 additions and 6 deletions
@@ -0,0 +1,593 @@
import { randomUUID } from 'node:crypto';
import type { Readable } from 'node:stream';
import { Inject, Injectable } from '@nestjs/common';
import { NEXTCLOUD_FILES_CHUNK_SIZE, NEXTCLOUD_FILES_MAX_CHUNKS } from '@tessera/shared';
import type { Response } from 'express';
import { NextcloudCallGate } from './nextcloud-call-gate';
import * as dav from './nextcloud-dav';
import * as transfer from './nextcloud-dav-transfer';
import { type NcSession, ncErrorDefault } from './nextcloud-files.types';
import { NextcloudFilesAccountService } from './nextcloud-files-account.service';
import {
type NcResult,
NEXTCLOUD_TRANSPORT,
type NextcloudTransport,
parseUserPath,
validateNewName,
validateSegment,
} from './nextcloud-http';
import type { NcEntry } from './nextcloud-propfind';
import { mapNcFailure, type NcOutcome, sendUpstreamStream } from './nextcloud-upstream';
/**
* Hoch- und Herunterladen im Konto des angemeldeten Benutzers
* (quick-261008-mzu, D-D/D-J/D-K). Jede Methode beginnt mit der Sitzung des
* Aufrufers (Benutzerkennung aus dem Token, nie aus der Eingabe). Dieser Dienst
* greift nicht auf die Datenbank zu.
*
* NICHTS WIRD GEPUFFERT. Die vorhandenen Upload-Routen der API (die Datei-Annahme
* mit multers Speicher im Arbeitsspeicher, Grenzen von 1 bis 5 MB) halten ganze
* Dateien im RAM — fuer Dateien in Gigabyte-Groesse ungeeignet. Hier wird der
* Anfragestrom (`req`) selbst als Koerper an Nextcloud weitergereicht und eine
* Antwort als Strom an den Browser; im Speicher liegt immer nur, was gerade
* unterwegs ist. Darum steht in diesem Modul kein multer-Interceptor.
*
* WARUM IN STUECKEN (8 MiB): der `/api-proxy`-Rewrite von Next.js puffert
* Anfragekoerper und schneidet sie bei 10 MiB STILL ab — eine groessere Datei
* kaeme verstuemmelt an. Der Browser zerlegt darum in 8-MiB-Stuecke
* (`NEXTCLOUD_FILES_CHUNK_SIZE`: unter 10 MiB, ueber den 5 MiB, die Nextcloud
* pro Stueck mindestens verlangt). Die API lehnt groessere Koerper ab.
*
* WARUM DER ZUSAMMENBAU IM HINTERGRUND LAEUFT: Nextcloud baut die Stuecke erst
* beim abschliessenden MOVE zusammen — bei Dateien in Gigabyte-Groesse dauert
* das Minuten, und Zwischenstationen (der Next.js-Proxy, der Nginx Proxy Manager
* vor Tessera) brechen Anfragen ohne Antwort nach 30 bis 60 s ab. Darum wartet
* `completeUpload` hoechstens 20 s auf das Ende und antwortet sonst 202
* `assembling`; der Browser fragt dann den Zustand ab. Der Zustand liegt im
* Arbeitsspeicher (je Mandant, Benutzer und Upload-Kennung, 10 Minuten nach dem
* Ende); ein Neustart der API verliert ihn, die Weboberflaeche meldet dann einen
* Fehler und der Benutzer wiederholt.
*
* WARUM TESSERA DEN `Content-Disposition` SELBST BAUT und stets `attachment`
* erzwingt: hochgeladene HTML- oder SVG-Dateien wuerden sonst auf der Tessera-
* Adresse im Browser ausgefuehrt (gespeichertes Cross-Site-Scripting). Der Name
* kommt aus dem angefragten Pfad, nie aus einer Nextcloud-Kopfzeile; dazu
* `nosniff` und eine `sandbox`-Richtlinie.
*
* WARUM FEHLER VOR DEM ERSTEN BYTE ABGEBILDET WERDEN: sobald ein Byte oder eine
* Kopfzeile an den Browser ging, laesst sich der Status nicht mehr aendern. Darum
* wird jede Antwort von Nextcloud zuerst geprueft (`sendUpstreamStream`) und jeder
* Fehler ueber `mapNcFailure` zu `{ code, message }` — nie 401 oder 403.
*/
const CHUNK_SIZE = NEXTCLOUD_FILES_CHUNK_SIZE;
/** Groesste uebertragbare Datei: 10000 Stuecke zu 8 MiB. */
export const MAX_UPLOAD_BYTES = NEXTCLOUD_FILES_MAX_CHUNKS * CHUNK_SIZE;
/** So lange wartet `completeUpload` auf den Zusammenbau, bevor es mit 202 antwortet. */
export const ASSEMBLY_WAIT_MS = 20_000;
/** So lange bleibt der Ausgang eines Zusammenbaus abfragbar. */
export const ASSEMBLY_STATE_TTL_MS = 10 * 60_000;
/** Obergrenze fuer gleichzeitig gemerkte Zusammenbauten (Schutz des Arbeitsspeichers). */
const ASSEMBLY_STATE_MAX = 2000;
const UPLOAD_ID_RE = /^tessera-[0-9a-f-]{36}$/;
const MAX_MTIME_SECONDS = 4102444800;
/** Was der Dienst von der Anfrage braucht: den Strom, die Kopfzeilen und ob er vollstaendig kam. */
export interface RawUploadRequest extends Readable {
headers: Record<string, string | string[] | undefined>;
complete?: boolean;
}
export type UploadStartView =
| { mode: 'single' }
| { mode: 'chunked'; uploadId: string; chunkSize: number };
export type UploadStateView =
| { state: 'assembling' }
| { state: 'done' }
| { state: 'failed'; code: string; message: string };
interface AssemblyRecord {
state: 'assembling' | 'done' | 'failed';
code?: string;
message?: string;
finishedAt?: number;
}
interface ExistingView {
etag: string | null;
size: number;
mtime: string | null;
}
/** ASCII-Rueckfallname: alles ausser druckbarem ASCII (und `"`, `\`, `%`) wird `_`. */
function asciiFallback(name: string): string {
let out = '';
for (const ch of name) {
const code = ch.codePointAt(0) ?? 0;
out += code < 0x20 || code > 0x7e || ch === '"' || ch === '\\' || ch === '%' ? '_' : ch;
}
return out;
}
/** RFC 5987: `encodeURIComponent` laesst `!'()*` stehen, die hier ebenfalls codiert werden muessen. */
function rfc5987(name: string): string {
return encodeURIComponent(name).replace(
/[!'()*]/g,
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
/** `Content-Disposition` fuer einen Download: immer `attachment`, nie aus Nextcloud uebernommen. */
export function contentDispositionFor(name: string): string {
return `attachment; filename="${asciiFallback(name)}"; filename*=UTF-8''${rfc5987(name)}`;
}
function pathOf(segments: readonly string[]): string {
return `/${segments.join('/')}`;
}
function headerOne(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
@Injectable()
export class NextcloudFilesTransferService {
private readonly assemblies = new Map<string, AssemblyRecord>();
constructor(
private readonly account: NextcloudFilesAccountService,
private readonly gate: NextcloudCallGate,
@Inject(NEXTCLOUD_TRANSPORT) private readonly transport: NextcloudTransport,
) {}
// --- Hilfen ---------------------------------------------------------------------------------
private session(tenantId: string, userId: string): Promise<NcSession> {
return this.account.getSession(tenantId, userId);
}
private async fail(
tenantId: string,
userId: string,
result: NcOutcome,
existing?: ExistingView,
): Promise<never> {
throw await mapNcFailure(result, {
onExpired: () => this.account.markExpired(tenantId, userId),
existing,
});
}
private static ok(result: NcOutcome): boolean {
return (
result.ok && typeof result.status === 'number' && result.status >= 200 && result.status < 300
);
}
private static uploadId(raw: string): string {
if (typeof raw !== 'string' || !UPLOAD_ID_RE.test(raw)) throw ncErrorDefault('invalidPath');
return raw;
}
/** Zielpfad einer Datei: nicht die Wurzel, letzter Teil ein erlaubter neuer Name. */
private static targetOf(rawPath: string): string[] {
const segments = parseUserPath(rawPath);
if (segments.length === 0) throw ncErrorDefault('invalidPath');
validateNewName(segments[segments.length - 1]);
return segments;
}
private static mtimeOf(mtime: number | undefined): number | undefined {
if (mtime === undefined) return undefined;
if (!Number.isInteger(mtime) || mtime < 0 || mtime > MAX_MTIME_SECONDS) {
throw ncErrorDefault('invalidPath');
}
return mtime;
}
private static sizeOf(size: number): number {
if (!Number.isInteger(size) || size < 0) throw ncErrorDefault('invalidPath');
if (size > MAX_UPLOAD_BYTES) throw ncErrorDefault('fileTooLarge');
return size;
}
/**
* `content-length` des Raw-Body-Aufrufs: fehlt er (auch bei chunked Transfer-Encoding)
* -> 411, ist er groesser als ein Stueck -> 413. Beides VOR jedem Nextcloud-Aufruf.
*/
private static declaredLength(req: RawUploadRequest): number {
const raw = headerOne(req.headers['content-length']);
if (raw === undefined || !/^\d{1,15}$/.test(raw)) throw ncErrorDefault('lengthRequired');
const length = Number(raw);
if (length > CHUNK_SIZE) throw ncErrorDefault('chunkTooLarge');
return length;
}
/**
* Bricht der Browser ab, bevor der Koerper vollstaendig da ist, wird auch der Aufruf an
* Nextcloud abgebrochen. `req.complete` unterscheidet das vom normalen Ende der Anfrage
* (Node meldet `close` auch nach dem vollstaendigen Lesen).
*/
private static abortOnClose(req: RawUploadRequest): { signal: AbortSignal; settle(): void } {
const abort = new AbortController();
let settled = false;
const onClose = () => {
if (!settled && !req.complete) abort.abort();
};
req.once('close', onClose);
return {
signal: abort.signal,
settle: () => {
settled = true;
req.off('close', onClose);
},
};
}
/** Vorhandener Eintrag fuer die Rueckfrage "Ersetzen?" — bestmoeglich, nie ein Fehlergrund. */
private async existingOf(
session: NcSession,
segments: readonly string[],
): Promise<ExistingView | undefined> {
try {
const res = await dav.stat(this.transport, this.gate, session, segments);
if (res.ok && res.entry) return viewOf(res.entry);
} catch {
// ohne Angaben geht es auch
}
return undefined;
}
// --- Hochladen: Start -------------------------------------------------------------------------
async startUpload(
tenantId: string,
userId: string,
dto: { path: string; size: number; replaceEtag?: string },
): Promise<UploadStartView> {
const segments = NextcloudFilesTransferService.targetOf(dto.path);
const size = NextcloudFilesTransferService.sizeOf(dto.size);
const session = await this.session(tenantId, userId);
// Schutz vor stillem Ueberschreiben: das Ziel pruefen, BEVOR irgendetwas uebertragen wird.
const probe = await dav.stat(this.transport, this.gate, session, segments);
if (!probe.ok) return this.fail(tenantId, userId, probe);
if (probe.status !== 404) {
if (!NextcloudFilesTransferService.ok(probe)) return this.fail(tenantId, userId, probe);
if (!probe.entry) return this.fail(tenantId, userId, { ok: false, kind: 'invalid-response' });
if (!dto.replaceEtag) {
throw ncErrorDefault('nameTaken', { existing: viewOf(probe.entry) });
}
if (probe.entry.etag !== dto.replaceEtag) {
throw ncErrorDefault('changedMeanwhile', { existing: viewOf(probe.entry) });
}
} else if (dto.replaceEtag) {
// Der Eintrag, den der Benutzer ersetzen wollte, ist inzwischen weg.
throw ncErrorDefault('changedMeanwhile');
}
if (size <= CHUNK_SIZE) return { mode: 'single' };
const uploadId = `tessera-${randomUUID()}`;
const made = await transfer.uploadStart(this.transport, this.gate, session, uploadId, segments);
if (!NextcloudFilesTransferService.ok(made)) return this.fail(tenantId, userId, made);
return { mode: 'chunked', uploadId, chunkSize: CHUNK_SIZE };
}
// --- Hochladen: ganze Datei -------------------------------------------------------------------
async putSingle(
tenantId: string,
userId: string,
req: RawUploadRequest,
query: { path: string; size?: number; mtime?: number; replaceEtag?: string },
): Promise<{ path: string; size: number }> {
const length = NextcloudFilesTransferService.declaredLength(req);
const segments = NextcloudFilesTransferService.targetOf(query.path);
const mtime = NextcloudFilesTransferService.mtimeOf(query.mtime);
const session = await this.session(tenantId, userId);
const guard = NextcloudFilesTransferService.abortOnClose(req);
let result: NcResult;
try {
result = await transfer.putFile(this.transport, this.gate, session, segments, req, {
size: length,
mtime,
replaceEtag: query.replaceEtag,
signal: guard.signal,
});
} finally {
guard.settle();
}
if (!NextcloudFilesTransferService.ok(result)) {
const existing =
result.ok && result.status === 412 ? await this.existingOf(session, segments) : undefined;
return this.fail(tenantId, userId, result, existing);
}
return { path: pathOf(segments), size: length };
}
// --- Hochladen: Stuecke -----------------------------------------------------------------------
async putChunk(
tenantId: string,
userId: string,
req: RawUploadRequest,
uploadId: string,
rawN: string | number,
query: { path: string; size: number },
): Promise<{ n: number; received: number }> {
NextcloudFilesTransferService.uploadId(uploadId);
const n = typeof rawN === 'number' ? rawN : /^\d{1,5}$/.test(rawN) ? Number(rawN) : Number.NaN;
if (!Number.isInteger(n) || n < 1 || n > NEXTCLOUD_FILES_MAX_CHUNKS) {
throw ncErrorDefault('invalidPath');
}
const length = NextcloudFilesTransferService.declaredLength(req);
const segments = NextcloudFilesTransferService.targetOf(query.path);
const total = NextcloudFilesTransferService.sizeOf(query.size);
const session = await this.session(tenantId, userId);
const guard = NextcloudFilesTransferService.abortOnClose(req);
let result: NcResult;
try {
result = await transfer.uploadChunk(
this.transport,
this.gate,
session,
uploadId,
n,
segments,
req,
{ size: length, totalLength: total, signal: guard.signal },
);
} finally {
guard.settle();
}
if (!NextcloudFilesTransferService.ok(result)) return this.fail(tenantId, userId, result);
return { n, received: length };
}
// --- Hochladen: Zusammenbau -------------------------------------------------------------------
private key(tenantId: string, userId: string, uploadId: string): string {
return `${tenantId}:${userId}:${uploadId}`;
}
private prune(): void {
const now = Date.now();
for (const [key, rec] of this.assemblies) {
if (rec.finishedAt !== undefined && now - rec.finishedAt > ASSEMBLY_STATE_TTL_MS) {
this.assemblies.delete(key);
}
}
// Aeltestes zuerst entfernen, falls (durch viele Aufrufe) zu viel gemerkt wird.
while (this.assemblies.size > ASSEMBLY_STATE_MAX) {
const oldest = this.assemblies.keys().next().value;
if (oldest === undefined) break;
this.assemblies.delete(oldest);
}
}
async completeUpload(
tenantId: string,
userId: string,
uploadId: string,
dto: { path: string; size: number; mtime?: number; replaceEtag?: string },
): Promise<{ state: 'done' | 'assembling' }> {
NextcloudFilesTransferService.uploadId(uploadId);
const segments = NextcloudFilesTransferService.targetOf(dto.path);
const total = NextcloudFilesTransferService.sizeOf(dto.size);
const mtime = NextcloudFilesTransferService.mtimeOf(dto.mtime);
const session = await this.session(tenantId, userId);
this.prune();
const key = this.key(tenantId, userId, uploadId);
const known = this.assemblies.get(key);
if (known?.state === 'assembling') return { state: 'assembling' };
if (known?.state === 'done') return { state: 'done' };
// Ersetzen: unmittelbar vor dem Zusammenbau noch einmal pruefen, ob noch die Version
// da ist, die der Benutzer ersetzen wollte.
if (dto.replaceEtag) {
const probe = await dav.stat(this.transport, this.gate, session, segments);
if (!probe.ok) return this.fail(tenantId, userId, probe);
if (probe.status === 404 || (NextcloudFilesTransferService.ok(probe) && !probe.entry)) {
throw ncErrorDefault('changedMeanwhile');
}
if (!NextcloudFilesTransferService.ok(probe)) return this.fail(tenantId, userId, probe);
if (probe.entry?.etag !== dto.replaceEtag) {
throw ncErrorDefault(
'changedMeanwhile',
probe.entry ? { existing: viewOf(probe.entry) } : undefined,
);
}
}
const record: AssemblyRecord = { state: 'assembling' };
this.assemblies.set(key, record);
// Der Zusammenbau gehoert nicht zur Anfrage: er laeuft weiter, auch wenn der Browser
// aufgibt, und haelt sein Ergebnis in `record` fest.
const job = this.assemble(tenantId, userId, session, uploadId, segments, {
totalLength: total,
mtime,
replace: Boolean(dto.replaceEtag),
}).then(
() => {
record.state = 'done';
record.finishedAt = Date.now();
},
(err: unknown) => {
const body = (err as { getResponse?: () => unknown }).getResponse?.() as
| { code?: string; message?: string }
| undefined;
record.state = 'failed';
record.code = body?.code ?? 'nextcloudError';
record.message = body?.message ?? ncErrorDefault('nextcloudError').message;
record.finishedAt = Date.now();
throw err;
},
);
// Ein spaeter Fehler darf nie als unbehandelt enden; der Browser fragt den Zustand ab.
job.catch(() => {});
let timer: NodeJS.Timeout | undefined;
const window = new Promise<'pending'>((resolve) => {
timer = setTimeout(() => resolve('pending'), ASSEMBLY_WAIT_MS);
});
try {
const outcome = await Promise.race([job.then(() => 'finished' as const), window]);
if (outcome === 'pending') return { state: 'assembling' };
return { state: 'done' };
} finally {
clearTimeout(timer);
}
}
private async assemble(
tenantId: string,
userId: string,
session: NcSession,
uploadId: string,
segments: readonly string[],
opts: { totalLength: number; mtime?: number; replace: boolean },
): Promise<void> {
const result = await transfer.uploadAssemble(
this.transport,
this.gate,
session,
uploadId,
segments,
opts,
);
if (NextcloudFilesTransferService.ok(result)) return;
if (result.ok && result.status === 412) {
// Das Ziel ist inzwischen vergeben (gemessen: der Upload-Ordner BLEIBT) -> aufraeumen.
const existing = await this.existingOf(session, segments);
await transfer.uploadAbort(this.transport, this.gate, session, uploadId).catch(() => {});
return this.fail(tenantId, userId, result, existing);
}
return this.fail(tenantId, userId, result);
}
uploadState(tenantId: string, userId: string, uploadId: string): UploadStateView {
NextcloudFilesTransferService.uploadId(uploadId);
this.prune();
const rec = this.assemblies.get(this.key(tenantId, userId, uploadId));
if (!rec) throw ncErrorDefault('notFound');
if (rec.state === 'failed') {
return {
state: 'failed',
code: rec.code ?? 'nextcloudError',
message: rec.message ?? ncErrorDefault('nextcloudError').message,
};
}
return { state: rec.state };
}
async abortUpload(
tenantId: string,
userId: string,
uploadId: string,
): Promise<{ aborted: boolean }> {
NextcloudFilesTransferService.uploadId(uploadId);
const session = await this.session(tenantId, userId);
const rec = this.assemblies.get(this.key(tenantId, userId, uploadId));
// Mitten im Zusammenbau wird nichts geloescht; Nextcloud raeumt selbst auf.
if (rec?.state === 'assembling') return { aborted: false };
const result = await transfer.uploadAbort(this.transport, this.gate, session, uploadId);
// 404: der Ordner ist schon weg (Zusammenbau fertig oder abgelaufen) — auch gut.
if (!NextcloudFilesTransferService.ok(result) && !(result.ok && result.status === 404)) {
return this.fail(tenantId, userId, result);
}
this.assemblies.delete(this.key(tenantId, userId, uploadId));
return { aborted: true };
}
// --- Herunterladen ----------------------------------------------------------------------------
/** Datei (oder mit `zip` ein Ordner als ZIP) als Strom an den Browser; `range` geht mit. */
async download(
res: Response,
tenantId: string,
userId: string,
rawPath: string,
opts: { zip?: boolean; range?: string } = {},
): Promise<void> {
const segments = parseUserPath(rawPath);
if (segments.length === 0 && !opts.zip) throw ncErrorDefault('invalidPath');
const session = await this.session(tenantId, userId);
const abort = this.abortOnResponseClose(res);
const upstream = opts.zip
? await transfer.downloadFolderZip(this.transport, this.gate, session, segments, abort)
: await transfer.download(this.transport, this.gate, session, segments, {
range: opts.range,
signal: abort,
});
const name = opts.zip
? segments.length > 0
? `${segments[segments.length - 1]}.zip`
: 'Dateien.zip'
: segments[segments.length - 1];
await this.send(res, upstream, name, tenantId, userId);
}
/** Nur die gewaehlten Eintraege eines Ordners als ZIP. */
async downloadZip(
res: Response,
tenantId: string,
userId: string,
rawDir: string | undefined,
names: readonly string[],
): Promise<void> {
const dir = parseUserPath(rawDir);
// Alle Namen vor dem ersten Aufruf pruefen (`..` -> 400 invalidPath).
for (const name of names) validateSegment(name);
if (names.length === 0) throw ncErrorDefault('invalidPath');
const session = await this.session(tenantId, userId);
const abort = this.abortOnResponseClose(res);
const upstream = await transfer.downloadSelectionZip(
this.transport,
this.gate,
session,
dir,
names,
abort,
);
const name = dir.length > 0 ? `${dir[dir.length - 1]}.zip` : 'Dateien.zip';
await this.send(res, upstream, name, tenantId, userId);
}
private abortOnResponseClose(res: Response): AbortSignal {
const abort = new AbortController();
res.on('close', () => {
if (!res.writableFinished) abort.abort();
});
return abort.signal;
}
private send(
res: Response,
upstream: NcResult,
fileName: string,
tenantId: string,
userId: string,
): Promise<void> {
return sendUpstreamStream(res, upstream, {
extraHeaders: {
'content-disposition': contentDispositionFor(fileName),
'x-content-type-options': 'nosniff',
'content-security-policy': "default-src 'none'; sandbox",
'cache-control': 'private, no-store',
},
onExpired: () => this.account.markExpired(tenantId, userId),
});
}
}
function viewOf(entry: NcEntry): ExistingView {
return { etag: entry.etag, size: entry.size, mtime: entry.mtime };
}