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:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user