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; 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(); constructor( private readonly account: NextcloudFilesAccountService, private readonly gate: NextcloudCallGate, @Inject(NEXTCLOUD_TRANSPORT) private readonly transport: NextcloudTransport, ) {} // --- Hilfen --------------------------------------------------------------------------------- private session(tenantId: string, userId: string): Promise { return this.account.getSession(tenantId, userId); } private async fail( tenantId: string, userId: string, result: NcOutcome, existing?: ExistingView, ): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 }; }