4d4a0b31cb
- 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>
594 lines
22 KiB
TypeScript
594 lines
22 KiB
TypeScript
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 };
|
|
}
|