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:
@@ -2,10 +2,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createFolder,
|
||||
deleteEntry,
|
||||
downloadUrl,
|
||||
listFolder,
|
||||
moveEntry,
|
||||
NextcloudFilesRequestError,
|
||||
previewUrl,
|
||||
zipUrl,
|
||||
} from './nextcloud-files-api';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
@@ -89,3 +91,24 @@ describe('nextcloud-files-api — Dateien', () => {
|
||||
expect(previewUrl('42', null)).toBe(`${API}/modules/nextcloud-files/preview?fileId=42`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextcloud-files-api — Herunterladen', () => {
|
||||
it('downloadUrl codiert den Pfad in der Query; mit zip kommt zip=1 dazu', () => {
|
||||
expect(downloadUrl('/Ärger & Co/a b.txt')).toBe(
|
||||
`${API}/modules/nextcloud-files/download?path=%2F%C3%84rger%20%26%20Co%2Fa%20b.txt`,
|
||||
);
|
||||
expect(downloadUrl('/Projekte', { zip: true })).toBe(
|
||||
`${API}/modules/nextcloud-files/download?path=%2FProjekte&zip=1`,
|
||||
);
|
||||
expect(downloadUrl('/Projekte', { zip: false })).toBe(
|
||||
`${API}/modules/nextcloud-files/download?path=%2FProjekte`,
|
||||
);
|
||||
});
|
||||
|
||||
it('zipUrl: Ordner in dir, jeder Name als eigener wiederholter name-Schluessel, einzeln codiert', () => {
|
||||
expect(zipUrl('/Projekte', ['a.txt', 'Ärger & Co', '100%'])).toBe(
|
||||
`${API}/modules/nextcloud-files/download/zip?dir=%2FProjekte&name=a.txt&name=%C3%84rger%20%26%20Co&name=100%25`,
|
||||
);
|
||||
expect(zipUrl('/', ['x'])).toBe(`${API}/modules/nextcloud-files/download/zip?dir=%2F&name=x`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ export class NextcloudFilesRequestError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function failure(res: Response): Promise<NextcloudFilesRequestError> {
|
||||
export async function requestFailure(res: Response): Promise<NextcloudFilesRequestError> {
|
||||
let message = `Request failed (${res.status})`;
|
||||
let code: string | null = null;
|
||||
let extra: Record<string, unknown> = {};
|
||||
@@ -91,6 +91,11 @@ async function failure(res: Response): Promise<NextcloudFilesRequestError> {
|
||||
return new NextcloudFilesRequestError(res.status, code, message, extra);
|
||||
}
|
||||
|
||||
/** Vollstaendige Adresse einer Route dieses Moduls (fuer Aufrufe, die nicht ueber `request` laufen). */
|
||||
export function nextcloudFilesUrl(path: string): string {
|
||||
return `${API_URL}${BASE}${path}`;
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
init: { method?: string; json?: unknown } = {},
|
||||
@@ -109,7 +114,7 @@ async function request<T>(
|
||||
body,
|
||||
...(method === 'GET' ? { cache: 'no-store' as const } : {}),
|
||||
});
|
||||
if (!res.ok) throw await failure(res);
|
||||
if (!res.ok) throw await requestFailure(res);
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
@@ -227,3 +232,25 @@ export function previewUrl(fileId: string, etag: string | null): string {
|
||||
const version = etag ? `&v=${encodeURIComponent(etag)}` : '';
|
||||
return `${API_URL}${BASE}/preview?fileId=${encodeURIComponent(fileId)}${version}`;
|
||||
}
|
||||
|
||||
// --- Herunterladen (Aufgabe 4) ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Adresse zum Herunterladen einer Datei — oder mit `zip` eines Ordners als ZIP. Ein
|
||||
* Link in `<a href>` genuegt (Tessera-Cookie); die API setzt `Content-Disposition:
|
||||
* attachment`, der Browser speichert also nur und zeigt nichts an. Der Pfad geht nur
|
||||
* in der Query.
|
||||
*/
|
||||
export function downloadUrl(path: string, opts: { zip?: boolean } = {}): string {
|
||||
const zip = opts.zip ? '&zip=1' : '';
|
||||
return `${API_URL}${BASE}/download?path=${encodeURIComponent(path)}${zip}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adresse zum Herunterladen mehrerer Eintraege eines Ordners als ein ZIP. Jeder Name
|
||||
* steht als eigener `name`-Parameter (wiederholter Schluessel), einzeln codiert.
|
||||
*/
|
||||
export function zipUrl(dir: string, names: readonly string[]): string {
|
||||
const list = names.map((n) => `name=${encodeURIComponent(n)}`).join('&');
|
||||
return `${API_URL}${BASE}/download/zip?dir=${encodeURIComponent(dir)}&${list}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
import { NEXTCLOUD_FILES_CHUNK_SIZE } from '@tessera/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { NextcloudFilesRequestError } from './nextcloud-files-api';
|
||||
import { isRetryable, type UploadXhr, uploadFile } from './nextcloud-files-upload';
|
||||
|
||||
const API = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
const BASE = `${API}/modules/nextcloud-files`;
|
||||
const UP = 'tessera-8f0c4b1e-3a5d-4c2e-9b7a-1d2e3f4a5b6c';
|
||||
const PATH = '/Projekte/Ärger.bin';
|
||||
const PATH_ENC = '%2FProjekte%2F%C3%84rger.bin';
|
||||
const MTIME_MS = 1760000000123;
|
||||
|
||||
function makeFile(size: number): File {
|
||||
return new File([new Uint8Array(size)], 'Ärger.bin', { lastModified: MTIME_MS });
|
||||
}
|
||||
|
||||
class FakeXhr implements UploadXhr {
|
||||
method = '';
|
||||
url = '';
|
||||
headers: Record<string, string> = {};
|
||||
body: Blob | null = null;
|
||||
aborted = false;
|
||||
withCredentials = false;
|
||||
timeout = 0;
|
||||
status = 0;
|
||||
responseText = '';
|
||||
upload: UploadXhr['upload'] = { onprogress: null };
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
onabort: (() => void) | null = null;
|
||||
ontimeout: (() => void) | null = null;
|
||||
constructor(
|
||||
private readonly script: (x: FakeXhr, index: number) => void,
|
||||
readonly index: number,
|
||||
) {}
|
||||
open(method: string, url: string) {
|
||||
this.method = method;
|
||||
this.url = url;
|
||||
}
|
||||
setRequestHeader(name: string, value: string) {
|
||||
this.headers[name.toLowerCase()] = value;
|
||||
}
|
||||
send(body: Blob) {
|
||||
this.body = body;
|
||||
// Nach dem Senden antwortet das Skript (asynchron wie ein echter Browser).
|
||||
queueMicrotask(() => this.script(this, this.index));
|
||||
}
|
||||
abort() {
|
||||
this.aborted = true;
|
||||
this.onabort?.();
|
||||
}
|
||||
progress(loaded: number) {
|
||||
this.upload.onprogress?.({ loaded, total: this.body?.size ?? 0 });
|
||||
}
|
||||
respond(status: number, json?: unknown) {
|
||||
this.status = status;
|
||||
this.responseText = json === undefined ? '' : JSON.stringify(json);
|
||||
this.onload?.();
|
||||
}
|
||||
fail() {
|
||||
this.onerror?.();
|
||||
}
|
||||
}
|
||||
|
||||
interface FetchCall {
|
||||
method: string;
|
||||
url: string;
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
function harness(opts: {
|
||||
xhr?: (x: FakeXhr, index: number) => void;
|
||||
fetch?: (call: FetchCall, n: number) => { status: number; json?: unknown };
|
||||
}) {
|
||||
const xhrs: FakeXhr[] = [];
|
||||
const fetches: FetchCall[] = [];
|
||||
const sleeps: number[] = [];
|
||||
const xhrFactory = () => {
|
||||
const x = new FakeXhr(opts.xhr ?? ((self) => self.respond(201, {})), xhrs.length);
|
||||
xhrs.push(x);
|
||||
return x;
|
||||
};
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const call: FetchCall = {
|
||||
method: init?.method ?? 'GET',
|
||||
url: String(input),
|
||||
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
||||
};
|
||||
fetches.push(call);
|
||||
const out = (opts.fetch ?? defaultFetch)(call, fetches.length) ?? { status: 200, json: {} };
|
||||
return {
|
||||
ok: out.status >= 200 && out.status < 300,
|
||||
status: out.status,
|
||||
json: async () => out.json,
|
||||
} as Response;
|
||||
}) as unknown as typeof fetch;
|
||||
const sleep = async (ms: number) => {
|
||||
sleeps.push(ms);
|
||||
};
|
||||
return { xhrs, fetches, sleeps, xhrFactory, fetchImpl, sleep };
|
||||
}
|
||||
|
||||
function defaultFetch(call: FetchCall): { status: number; json?: unknown } {
|
||||
if (call.method === 'POST' && call.url === `${BASE}/uploads`) {
|
||||
const size = (call.body as { size: number }).size;
|
||||
return {
|
||||
status: 201,
|
||||
json:
|
||||
size <= NEXTCLOUD_FILES_CHUNK_SIZE
|
||||
? { mode: 'single' }
|
||||
: { mode: 'chunked', uploadId: UP, chunkSize: NEXTCLOUD_FILES_CHUNK_SIZE },
|
||||
};
|
||||
}
|
||||
if (call.url.endsWith('/complete')) return { status: 200, json: { state: 'done' } };
|
||||
if (call.method === 'DELETE') return { status: 200, json: { aborted: true } };
|
||||
return { status: 200, json: {} };
|
||||
}
|
||||
|
||||
const deletes = (h: { fetches: FetchCall[] }) => h.fetches.filter((f) => f.method === 'DELETE');
|
||||
|
||||
describe('uploadFile — kleine Datei', () => {
|
||||
it('5 MB: start liefert single, genau ein PUT uploads/file mit der Datei als Koerper', async () => {
|
||||
const h = harness({
|
||||
xhr: (x) => {
|
||||
x.progress(2_500_000);
|
||||
x.respond(200, { path: PATH, size: 5_000_000 });
|
||||
},
|
||||
});
|
||||
const file = makeFile(5_000_000);
|
||||
const progress: Array<[number, number]> = [];
|
||||
const out = await uploadFile(file, PATH, {
|
||||
xhrFactory: h.xhrFactory,
|
||||
fetchImpl: h.fetchImpl,
|
||||
sleep: h.sleep,
|
||||
onProgress: (f, b) => progress.push([f, b]),
|
||||
});
|
||||
expect(out).toEqual({ path: PATH, size: 5_000_000 });
|
||||
expect(h.fetches).toHaveLength(1);
|
||||
expect(h.fetches[0]).toMatchObject({
|
||||
method: 'POST',
|
||||
url: `${BASE}/uploads`,
|
||||
body: { path: PATH, size: 5_000_000 },
|
||||
});
|
||||
expect(h.xhrs).toHaveLength(1);
|
||||
expect(h.xhrs[0].method).toBe('PUT');
|
||||
expect(h.xhrs[0].url).toBe(
|
||||
`${BASE}/uploads/file?path=${PATH_ENC}&size=5000000&mtime=1760000000`,
|
||||
);
|
||||
expect(h.xhrs[0].body).toBe(file);
|
||||
expect(h.xhrs[0].withCredentials).toBe(true);
|
||||
expect(h.xhrs[0].headers['content-type']).toBe('application/octet-stream');
|
||||
expect(progress[progress.length - 1]).toEqual([1, 5_000_000]);
|
||||
expect(progress.map((p) => p[0])).toContain(0.5);
|
||||
expect(deletes(h)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('mit replaceEtag: im Start-Koerper und in der Query', async () => {
|
||||
const h = harness({});
|
||||
await uploadFile(makeFile(10), PATH, {
|
||||
xhrFactory: h.xhrFactory,
|
||||
fetchImpl: h.fetchImpl,
|
||||
sleep: h.sleep,
|
||||
replaceEtag: '"abc"',
|
||||
});
|
||||
expect(h.fetches[0].body).toEqual({ path: PATH, size: 10, replaceEtag: '"abc"' });
|
||||
expect(h.xhrs[0].url).toContain('&replaceEtag=%22abc%22');
|
||||
});
|
||||
|
||||
it('ein Fehler bei der kleinen Datei wird nicht wiederholt und raeumt nichts auf (kein Upload-Ordner)', async () => {
|
||||
const h = harness({ xhr: (x) => x.fail() });
|
||||
const err = await uploadFile(makeFile(10), PATH, {
|
||||
xhrFactory: h.xhrFactory,
|
||||
fetchImpl: h.fetchImpl,
|
||||
sleep: h.sleep,
|
||||
}).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(NextcloudFilesRequestError);
|
||||
expect(err.code).toBe('network');
|
||||
expect(h.xhrs).toHaveLength(1);
|
||||
expect(deletes(h)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadFile — grosse Datei in Stuecken', () => {
|
||||
it('30 MB: vier Stuecke zu 8388608, 8388608, 8388608 und 4834176 Byte der Reihe nach, dann complete', async () => {
|
||||
const h = harness({});
|
||||
const file = makeFile(30_000_000);
|
||||
const out = await uploadFile(file, PATH, {
|
||||
xhrFactory: h.xhrFactory,
|
||||
fetchImpl: h.fetchImpl,
|
||||
sleep: h.sleep,
|
||||
});
|
||||
expect(out.size).toBe(30_000_000);
|
||||
expect(h.xhrs.map((x) => x.body?.size)).toEqual([8388608, 8388608, 8388608, 4834176]);
|
||||
expect(h.xhrs.map((x) => x.url)).toEqual(
|
||||
[1, 2, 3, 4].map((n) => `${BASE}/uploads/${UP}/chunks/${n}?path=${PATH_ENC}&size=30000000`),
|
||||
);
|
||||
expect(h.xhrs.every((x) => x.method === 'PUT' && x.withCredentials)).toBe(true);
|
||||
expect(h.xhrs.every((x) => (x.body?.size ?? 0) <= NEXTCLOUD_FILES_CHUNK_SIZE)).toBe(true);
|
||||
expect(h.fetches.map((f) => `${f.method} ${f.url.replace(BASE, '')}`)).toEqual([
|
||||
'POST /uploads',
|
||||
`POST /uploads/${UP}/complete`,
|
||||
]);
|
||||
expect(h.fetches[1].body).toEqual({ path: PATH, size: 30_000_000, mtime: 1760000000 });
|
||||
expect(h.sleeps).toEqual([]);
|
||||
});
|
||||
|
||||
it('Fortschritt ist der Anteil an der GANZEN Datei und endet bei 1', async () => {
|
||||
const h = harness({
|
||||
xhr: (x) => {
|
||||
x.progress((x.body?.size ?? 0) / 2);
|
||||
x.respond(201, {});
|
||||
},
|
||||
});
|
||||
const seen: number[] = [];
|
||||
await uploadFile(makeFile(30_000_000), PATH, {
|
||||
xhrFactory: h.xhrFactory,
|
||||
fetchImpl: h.fetchImpl,
|
||||
sleep: h.sleep,
|
||||
onProgress: (f) => seen.push(f),
|
||||
});
|
||||
expect(seen[seen.length - 1]).toBe(1);
|
||||
for (let i = 1; i < seen.length; i++) expect(seen[i]).toBeGreaterThanOrEqual(seen[i - 1]);
|
||||
expect(seen.every((f) => f >= 0 && f <= 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('Netzfehler bei Stueck 2: Wiederholung nach 1 s und 3 s, gelingt im dritten Versuch', async () => {
|
||||
let failures = 0;
|
||||
const h = harness({
|
||||
xhr: (x) => {
|
||||
if (x.url.includes('/chunks/2?') && failures < 2) {
|
||||
failures++;
|
||||
x.fail();
|
||||
} else x.respond(201, {});
|
||||
},
|
||||
});
|
||||
await uploadFile(makeFile(30_000_000), PATH, {
|
||||
xhrFactory: h.xhrFactory,
|
||||
fetchImpl: h.fetchImpl,
|
||||
sleep: h.sleep,
|
||||
});
|
||||
expect(h.sleeps).toEqual([1000, 3000]);
|
||||
expect(h.xhrs.filter((x) => x.url.includes('/chunks/2?'))).toHaveLength(3);
|
||||
expect(h.xhrs).toHaveLength(6);
|
||||
expect(deletes(h)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('nach drei Wiederholungen (1 s, 3 s, 9 s) gibt es auf: DELETE uploads/<id> und der Fehler', async () => {
|
||||
const h = harness({
|
||||
xhr: (x) => (x.url.includes('/chunks/1?') ? x.fail() : x.respond(201, {})),
|
||||
});
|
||||
const err = await uploadFile(makeFile(30_000_000), PATH, {
|
||||
xhrFactory: h.xhrFactory,
|
||||
fetchImpl: h.fetchImpl,
|
||||
sleep: h.sleep,
|
||||
}).catch((e) => e);
|
||||
expect(err.code).toBe('network');
|
||||
expect(h.sleeps).toEqual([1000, 3000, 9000]);
|
||||
expect(h.xhrs).toHaveLength(4);
|
||||
expect(deletes(h)).toHaveLength(1);
|
||||
expect(deletes(h)[0].url).toBe(`${BASE}/uploads/${UP}`);
|
||||
});
|
||||
|
||||
it('504 und 500 ohne eigenen Code werden wiederholt', async () => {
|
||||
let n = 0;
|
||||
const h = harness({
|
||||
xhr: (x) => {
|
||||
n++;
|
||||
if (n === 1) x.respond(504, { code: 'nextcloudUnavailable', message: 'x' });
|
||||
else if (n === 2) x.respond(500, { message: 'Internal' });
|
||||
else x.respond(201, {});
|
||||
},
|
||||
});
|
||||
await uploadFile(makeFile(30_000_000), PATH, {
|
||||
xhrFactory: h.xhrFactory,
|
||||
fetchImpl: h.fetchImpl,
|
||||
sleep: h.sleep,
|
||||
});
|
||||
expect(h.sleeps).toEqual([1000, 3000]);
|
||||
});
|
||||
|
||||
it('503 nextcloudLocked wird NICHT wiederholt: DELETE einmal und Ablehnung mit dem Code', async () => {
|
||||
const h = harness({
|
||||
xhr: (x) =>
|
||||
x.respond(503, { code: 'nextcloudLocked', message: 'gesperrt', retryAfterSeconds: 900 }),
|
||||
});
|
||||
const err = await uploadFile(makeFile(30_000_000), PATH, {
|
||||
xhrFactory: h.xhrFactory,
|
||||
fetchImpl: h.fetchImpl,
|
||||
sleep: h.sleep,
|
||||
}).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(NextcloudFilesRequestError);
|
||||
expect(err.code).toBe('nextcloudLocked');
|
||||
expect(err.extra).toEqual({ retryAfterSeconds: 900 });
|
||||
expect(h.xhrs).toHaveLength(1);
|
||||
expect(h.sleeps).toEqual([]);
|
||||
expect(deletes(h)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[413, 'chunkTooLarge'],
|
||||
[400, 'invalidPath'],
|
||||
[409, 'pathConflict'],
|
||||
[507, 'quotaExceeded'],
|
||||
[404, 'notFound'],
|
||||
])('Stueck mit %i %s: keine Wiederholung, DELETE uploads/<id>, Ablehnung', async (status, code) => {
|
||||
const h = harness({ xhr: (x) => x.respond(status, { code, message: 'm' }) });
|
||||
const err = await uploadFile(makeFile(30_000_000), PATH, {
|
||||
xhrFactory: h.xhrFactory,
|
||||
fetchImpl: h.fetchImpl,
|
||||
sleep: h.sleep,
|
||||
}).catch((e) => e);
|
||||
expect(err.status).toBe(status);
|
||||
expect(err.code).toBe(code);
|
||||
expect(h.xhrs).toHaveLength(1);
|
||||
expect(h.sleeps).toEqual([]);
|
||||
expect(deletes(h)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('409 nameTaken beim Start: Ablehnung mit existing, kein Stueck gesendet, nichts zum Aufraeumen', async () => {
|
||||
const existing = { etag: '"e1"', size: 3, mtime: '2026-10-06T08:00:00.000Z' };
|
||||
const h = harness({
|
||||
fetch: () => ({ status: 409, json: { code: 'nameTaken', message: 'vergeben', existing } }),
|
||||
});
|
||||
const err = await uploadFile(makeFile(30_000_000), PATH, {
|
||||
xhrFactory: h.xhrFactory,
|
||||
fetchImpl: h.fetchImpl,
|
||||
sleep: h.sleep,
|
||||
}).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(NextcloudFilesRequestError);
|
||||
expect(err.code).toBe('nameTaken');
|
||||
expect(err.extra).toEqual({ existing });
|
||||
expect(h.xhrs).toHaveLength(0);
|
||||
expect(h.fetches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Abbruch waehrend Stueck 3: XHR abgebrochen, DELETE einmal, Ablehnung mit Code aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
const h = harness({
|
||||
xhr: (x) => {
|
||||
if (x.url.includes('/chunks/3?')) controller.abort();
|
||||
else x.respond(201, {});
|
||||
},
|
||||
});
|
||||
const err = await uploadFile(makeFile(30_000_000), PATH, {
|
||||
xhrFactory: h.xhrFactory,
|
||||
fetchImpl: h.fetchImpl,
|
||||
sleep: h.sleep,
|
||||
signal: controller.signal,
|
||||
}).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(NextcloudFilesRequestError);
|
||||
expect(err.code).toBe('aborted');
|
||||
expect(h.xhrs).toHaveLength(3);
|
||||
expect(h.xhrs[2].aborted).toBe(true);
|
||||
expect(deletes(h)).toHaveLength(1);
|
||||
expect(deletes(h)[0].url).toBe(`${BASE}/uploads/${UP}`);
|
||||
expect(h.sleeps).toEqual([]);
|
||||
});
|
||||
|
||||
it('Abbruch vor dem Start: nichts wird gesendet', async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const h = harness({});
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new DOMException('abgebrochen', 'AbortError');
|
||||
}) as unknown as typeof fetch;
|
||||
const err = await uploadFile(makeFile(10), PATH, {
|
||||
xhrFactory: h.xhrFactory,
|
||||
fetchImpl,
|
||||
sleep: h.sleep,
|
||||
signal: controller.signal,
|
||||
}).catch((e) => e);
|
||||
expect(err.code).toBe('aborted');
|
||||
expect(h.xhrs).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadFile — Zusammenbau', () => {
|
||||
const completeAssembling = (states: unknown[]) => {
|
||||
let polls = 0;
|
||||
return (call: FetchCall): { status: number; json?: unknown } => {
|
||||
if (call.url.endsWith('/complete')) return { status: 202, json: { state: 'assembling' } };
|
||||
if (call.url.endsWith('/state'))
|
||||
return { status: 200, json: states[Math.min(polls++, states.length - 1)] };
|
||||
return defaultFetch(call);
|
||||
};
|
||||
};
|
||||
|
||||
it('202 assembling: fragt alle 2 s den Zustand ab, bis done', async () => {
|
||||
const h = harness({
|
||||
fetch: completeAssembling([
|
||||
{ state: 'assembling' },
|
||||
{ state: 'assembling' },
|
||||
{ state: 'done' },
|
||||
]),
|
||||
});
|
||||
const out = await uploadFile(makeFile(30_000_000), PATH, {
|
||||
xhrFactory: h.xhrFactory,
|
||||
fetchImpl: h.fetchImpl,
|
||||
sleep: h.sleep,
|
||||
});
|
||||
expect(out.size).toBe(30_000_000);
|
||||
expect(h.sleeps).toEqual([2000, 2000, 2000]);
|
||||
expect(h.fetches.filter((f) => f.url.endsWith('/state'))).toHaveLength(3);
|
||||
expect(deletes(h)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('Zustand failed: Ablehnung mit dem Code der API', async () => {
|
||||
const h = harness({
|
||||
fetch: completeAssembling([
|
||||
{ state: 'assembling' },
|
||||
{ state: 'failed', code: 'quotaExceeded', message: 'Speicher voll' },
|
||||
]),
|
||||
});
|
||||
const err = await uploadFile(makeFile(30_000_000), PATH, {
|
||||
xhrFactory: h.xhrFactory,
|
||||
fetchImpl: h.fetchImpl,
|
||||
sleep: h.sleep,
|
||||
}).catch((e) => e);
|
||||
expect(err).toBeInstanceOf(NextcloudFilesRequestError);
|
||||
expect(err.code).toBe('quotaExceeded');
|
||||
expect(err.message).toBe('Speicher voll');
|
||||
expect(h.sleeps).toEqual([2000, 2000]);
|
||||
});
|
||||
|
||||
it('der Zustand ist weg (404, etwa nach einem Neustart der API): Ablehnung', async () => {
|
||||
const h = harness({
|
||||
fetch: (call) =>
|
||||
call.url.endsWith('/complete')
|
||||
? { status: 202, json: { state: 'assembling' } }
|
||||
: call.url.endsWith('/state')
|
||||
? { status: 404, json: { code: 'notFound', message: 'weg' } }
|
||||
: defaultFetch(call),
|
||||
});
|
||||
const err = await uploadFile(makeFile(30_000_000), PATH, {
|
||||
xhrFactory: h.xhrFactory,
|
||||
fetchImpl: h.fetchImpl,
|
||||
sleep: h.sleep,
|
||||
}).catch((e) => e);
|
||||
expect(err.code).toBe('notFound');
|
||||
});
|
||||
|
||||
it('complete mit 409 nameTaken (Ziel inzwischen vergeben): Ablehnung und DELETE', async () => {
|
||||
const h = harness({
|
||||
fetch: (call) =>
|
||||
call.url.endsWith('/complete')
|
||||
? {
|
||||
status: 409,
|
||||
json: {
|
||||
code: 'nameTaken',
|
||||
message: 'vergeben',
|
||||
existing: { etag: null, size: 1, mtime: null },
|
||||
},
|
||||
}
|
||||
: defaultFetch(call),
|
||||
});
|
||||
const err = await uploadFile(makeFile(30_000_000), PATH, {
|
||||
xhrFactory: h.xhrFactory,
|
||||
fetchImpl: h.fetchImpl,
|
||||
sleep: h.sleep,
|
||||
}).catch((e) => e);
|
||||
expect(err.code).toBe('nameTaken');
|
||||
expect(deletes(h)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Abbruch waehrend des Wartens auf den Zusammenbau: aborted und DELETE einmal', async () => {
|
||||
const controller = new AbortController();
|
||||
const h = harness({
|
||||
fetch: completeAssembling([{ state: 'assembling' }]),
|
||||
});
|
||||
let sleepCalls = 0;
|
||||
const sleep = async () => {
|
||||
if (++sleepCalls === 2) controller.abort();
|
||||
};
|
||||
const err = await uploadFile(makeFile(30_000_000), PATH, {
|
||||
xhrFactory: h.xhrFactory,
|
||||
fetchImpl: h.fetchImpl,
|
||||
sleep,
|
||||
signal: controller.signal,
|
||||
}).catch((e) => e);
|
||||
expect(err.code).toBe('aborted');
|
||||
expect(deletes(h)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRetryable', () => {
|
||||
const e = (status: number, code: string | null) =>
|
||||
new NextcloudFilesRequestError(status, code, 'x');
|
||||
it('wiederholt nur Netzfehler und 500/502/504 ohne eigene Bedeutung', () => {
|
||||
expect(isRetryable(e(0, 'network'))).toBe(true);
|
||||
expect(isRetryable(e(504, 'nextcloudUnavailable'))).toBe(true);
|
||||
expect(isRetryable(e(502, 'nextcloudError'))).toBe(true);
|
||||
expect(isRetryable(e(502, null))).toBe(true);
|
||||
expect(isRetryable(e(500, null))).toBe(true);
|
||||
expect(isRetryable(e(502, 'nextcloudRedirect'))).toBe(false);
|
||||
expect(isRetryable(e(500, 'accountBroken'))).toBe(false);
|
||||
expect(isRetryable(e(503, 'nextcloudLocked'))).toBe(false);
|
||||
expect(isRetryable(e(503, 'nextcloudMaintenance'))).toBe(false);
|
||||
expect(isRetryable(e(409, 'nameTaken'))).toBe(false);
|
||||
expect(isRetryable(e(413, 'chunkTooLarge'))).toBe(false);
|
||||
expect(isRetryable(e(0, 'aborted'))).toBe(false);
|
||||
expect(isRetryable(new Error('x'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
import { NEXTCLOUD_FILES_CHUNK_SIZE } from '@tessera/shared';
|
||||
import {
|
||||
NextcloudFilesRequestError,
|
||||
nextcloudFilesUrl,
|
||||
requestFailure,
|
||||
} from './nextcloud-files-api';
|
||||
|
||||
/**
|
||||
* Hochladen im Browser (quick-261008-mzu, L-06/D-J): die Datei geht NIE direkt an die
|
||||
* Nextcloud, sondern ueber die Tessera-API, die sie als Datenstrom weiterreicht.
|
||||
*
|
||||
* - Kleine Dateien (bis ein Stueck gross): ein einziger Aufruf `PUT uploads/file`.
|
||||
* - Grosse Dateien: in Stuecken zu 8 MiB (`NEXTCLOUD_FILES_CHUNK_SIZE`, `Blob.slice`).
|
||||
* Der `/api-proxy` von Next.js schneidet Anfragekoerper ueber 10 MiB STILL ab —
|
||||
* ein groesseres Stueck kaeme verstuemmelt an. Nextcloud verlangt mindestens 5 MiB
|
||||
* je Stueck (ausser dem letzten); 8 MiB passen zu beidem.
|
||||
* - Der Zusammenbau der Stuecke bei Nextcloud kann bei grossen Dateien Minuten dauern;
|
||||
* die API antwortet dann 202 "assembling" und wir fragen den Zustand ab, statt eine
|
||||
* Anfrage minutenlang offen zu halten (Zwischenstationen brechen nach 30 bis 60 s ab).
|
||||
*
|
||||
* Gesendet wird mit `XMLHttpRequest`, weil nur er den Fortschritt des HOCHLADENS meldet
|
||||
* (`upload.onprogress`). Wiederholt wird nur, was sich lohnt: Netzfehler und 500/502/504
|
||||
* (nach 1, 3 und 9 s) — nie ein 4xx und nie `nextcloudLocked` (503), das die Sperre der
|
||||
* Nextcloud nur verlaengern wuerde. Bei Abbruch oder endgueltigem Fehler wird der
|
||||
* angefangene Upload bei Nextcloud aufgeraeumt (`DELETE uploads/<id>`).
|
||||
*/
|
||||
|
||||
export const RETRY_DELAYS_MS = [1000, 3000, 9000] as const;
|
||||
export const ASSEMBLY_POLL_MS = 2000;
|
||||
/** Hoechstens 30 Minuten auf den Zusammenbau warten. */
|
||||
export const ASSEMBLY_MAX_POLLS = (30 * 60_000) / ASSEMBLY_POLL_MS;
|
||||
/** Ein Stueck, das nach so langer Zeit nicht fertig ist, gilt als Netzfehler. */
|
||||
const XHR_TIMEOUT_MS = 180_000;
|
||||
|
||||
/** Der Teil von `XMLHttpRequest`, den der Hochlader braucht (Tests setzen eine Attrappe ein). */
|
||||
export interface UploadXhr {
|
||||
open(method: string, url: string, async?: boolean): void;
|
||||
setRequestHeader(name: string, value: string): void;
|
||||
send(body: Blob): void;
|
||||
abort(): void;
|
||||
withCredentials: boolean;
|
||||
timeout: number;
|
||||
status: number;
|
||||
responseText: string;
|
||||
upload: { onprogress: ((e: { loaded: number; total: number }) => void) | null };
|
||||
onload: (() => void) | null;
|
||||
onerror: (() => void) | null;
|
||||
onabort: (() => void) | null;
|
||||
ontimeout: (() => void) | null;
|
||||
}
|
||||
export type UploadXhrFactory = () => UploadXhr;
|
||||
|
||||
export interface UploadOptions {
|
||||
/** `fraction` 0..1 des GESAMTEN Uploads, `bytes` bereits uebertragene Byte. */
|
||||
onProgress?: (fraction: number, bytes: number) => void;
|
||||
signal?: AbortSignal;
|
||||
/** Entity-Tag der Version, die ersetzt werden soll (nach Rueckfrage "Ersetzen"). */
|
||||
replaceEtag?: string;
|
||||
xhrFactory?: UploadXhrFactory;
|
||||
fetchImpl?: typeof fetch;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface UploadResult {
|
||||
path: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
type StartReply = { mode: 'single' } | { mode: 'chunked'; uploadId: string; chunkSize: number };
|
||||
type CompleteReply = { state: 'done' | 'assembling' };
|
||||
type StateReply =
|
||||
| { state: 'done' }
|
||||
| { state: 'assembling' }
|
||||
| { state: 'failed'; code: string; message: string };
|
||||
|
||||
const defaultSleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function abortedError(): NextcloudFilesRequestError {
|
||||
return new NextcloudFilesRequestError(0, 'aborted', 'Der Upload wurde abgebrochen.');
|
||||
}
|
||||
|
||||
function networkError(): NextcloudFilesRequestError {
|
||||
return new NextcloudFilesRequestError(0, 'network', 'Die Verbindung wurde unterbrochen.');
|
||||
}
|
||||
|
||||
/** Ob ein Fehler eine Wiederholung lohnt: Netz weg oder ein Serverfehler ohne eigene Bedeutung. */
|
||||
export function isRetryable(err: unknown): boolean {
|
||||
if (!(err instanceof NextcloudFilesRequestError)) return false;
|
||||
if (err.code === 'network') return true;
|
||||
if (err.status === 500 || err.status === 502 || err.status === 504) {
|
||||
return (
|
||||
err.code === null || err.code === 'nextcloudUnavailable' || err.code === 'nextcloudError'
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
interface XhrReply {
|
||||
status: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function replyError(reply: XhrReply): Promise<NextcloudFilesRequestError> {
|
||||
// `requestFailure` liest nur Status und JSON-Koerper der Antwort.
|
||||
return requestFailure({
|
||||
status: reply.status,
|
||||
json: async () => JSON.parse(reply.text),
|
||||
} as unknown as Response);
|
||||
}
|
||||
|
||||
/** Eine Anfrage mit Fortschritt; lehnt mit `network` oder `aborted` ab, liefert sonst die Antwort. */
|
||||
function sendXhr(
|
||||
factory: UploadXhrFactory,
|
||||
method: string,
|
||||
url: string,
|
||||
body: Blob,
|
||||
signal: AbortSignal | undefined,
|
||||
onLoaded: (loaded: number) => void,
|
||||
): Promise<XhrReply> {
|
||||
return new Promise<XhrReply>((resolve, reject) => {
|
||||
if (signal?.aborted) {
|
||||
reject(abortedError());
|
||||
return;
|
||||
}
|
||||
const xhr = factory();
|
||||
const abort = () => xhr.abort();
|
||||
const done = () => signal?.removeEventListener('abort', abort);
|
||||
xhr.open(method, url, true);
|
||||
xhr.withCredentials = true;
|
||||
xhr.timeout = XHR_TIMEOUT_MS;
|
||||
// Der Koerper ist roh; ohne diese Angabe wuerde der Browser den Typ der Datei senden
|
||||
// (zum Beispiel application/json), und die API wuerde den Strom als JSON lesen wollen.
|
||||
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
||||
xhr.upload.onprogress = (e) => onLoaded(e.loaded);
|
||||
xhr.onload = () => {
|
||||
done();
|
||||
resolve({ status: xhr.status, text: xhr.responseText });
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
done();
|
||||
reject(networkError());
|
||||
};
|
||||
xhr.ontimeout = xhr.onerror;
|
||||
xhr.onabort = () => {
|
||||
done();
|
||||
reject(abortedError());
|
||||
};
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
xhr.send(body);
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadFile(
|
||||
file: File | Blob,
|
||||
targetPath: string,
|
||||
options: UploadOptions = {},
|
||||
): Promise<UploadResult> {
|
||||
const { signal, replaceEtag } = options;
|
||||
const xhrFactory: UploadXhrFactory =
|
||||
options.xhrFactory ?? (() => new XMLHttpRequest() as unknown as UploadXhr);
|
||||
const doFetch: typeof fetch = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
||||
const sleep = options.sleep ?? defaultSleep;
|
||||
const size = file.size;
|
||||
const lastModified = (file as File).lastModified;
|
||||
const mtime =
|
||||
typeof lastModified === 'number' && Number.isFinite(lastModified) && lastModified > 0
|
||||
? Math.floor(lastModified / 1000)
|
||||
: undefined;
|
||||
const report = (bytes: number) => {
|
||||
const sent = Math.min(bytes, size);
|
||||
options.onProgress?.(size === 0 ? 0 : sent / size, sent);
|
||||
};
|
||||
|
||||
/** Wartet `ms`; ein Abbruch beendet das Warten sofort. */
|
||||
const pause = async (ms: number): Promise<void> => {
|
||||
if (signal?.aborted) throw abortedError();
|
||||
let onAbort: (() => void) | undefined;
|
||||
const aborted = new Promise<never>((_, reject) => {
|
||||
onAbort = () => reject(abortedError());
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
aborted.catch(() => {});
|
||||
try {
|
||||
await Promise.race([sleep(ms), aborted]);
|
||||
} finally {
|
||||
if (onAbort) signal?.removeEventListener('abort', onAbort);
|
||||
}
|
||||
if (signal?.aborted) throw abortedError();
|
||||
};
|
||||
|
||||
/** Wiederholt nur Netzfehler und Serverfehler; Wartezeiten 1 s, 3 s, 9 s. */
|
||||
async function withRetry<T>(attempt: () => Promise<T>): Promise<T> {
|
||||
for (let i = 0; ; i++) {
|
||||
try {
|
||||
return await attempt();
|
||||
} catch (err) {
|
||||
if (signal?.aborted) throw abortedError();
|
||||
if (i >= RETRY_DELAYS_MS.length || !isRetryable(err)) throw err;
|
||||
await pause(RETRY_DELAYS_MS[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function call<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
json?: unknown,
|
||||
): Promise<{ status: number; data: T }> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await doFetch(nextcloudFilesUrl(path), {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: json === undefined ? {} : { 'Content-Type': 'application/json' },
|
||||
body: json === undefined ? undefined : JSON.stringify(json),
|
||||
signal,
|
||||
});
|
||||
} catch {
|
||||
throw signal?.aborted ? abortedError() : networkError();
|
||||
}
|
||||
if (!res.ok) throw await requestFailure(res);
|
||||
return { status: res.status, data: (await res.json()) as T };
|
||||
}
|
||||
|
||||
/** Ein Stueck oder die ganze Datei senden und bei Erfolg die Byte melden. */
|
||||
async function put(path: string, body: Blob, base: number): Promise<void> {
|
||||
report(base);
|
||||
const reply = await sendXhr(
|
||||
xhrFactory,
|
||||
'PUT',
|
||||
nextcloudFilesUrl(path),
|
||||
body,
|
||||
signal,
|
||||
(loaded) => report(base + loaded),
|
||||
);
|
||||
if (reply.status < 200 || reply.status >= 300) throw await replyError(reply);
|
||||
report(base + body.size);
|
||||
}
|
||||
|
||||
/** Fragt alle 2 s den Zustand des Zusammenbaus ab (hoechstens 30 Minuten). */
|
||||
async function waitForAssembly(id: string): Promise<void> {
|
||||
for (let i = 0; i < ASSEMBLY_MAX_POLLS; i++) {
|
||||
await pause(ASSEMBLY_POLL_MS);
|
||||
let state: StateReply;
|
||||
try {
|
||||
state = (await call<StateReply>('GET', `/uploads/${q(id)}/state`)).data;
|
||||
} catch (err) {
|
||||
// Ein kurzer Netzausfall beim Nachfragen beendet den Zusammenbau nicht.
|
||||
if (err instanceof NextcloudFilesRequestError && err.code === 'network') continue;
|
||||
throw err;
|
||||
}
|
||||
if (state.state === 'done') return;
|
||||
if (state.state === 'failed') {
|
||||
throw new NextcloudFilesRequestError(409, state.code, state.message);
|
||||
}
|
||||
}
|
||||
throw new NextcloudFilesRequestError(
|
||||
504,
|
||||
'nextcloudUnavailable',
|
||||
'Nextcloud braucht zu lange, um die Datei zusammenzusetzen.',
|
||||
);
|
||||
}
|
||||
|
||||
const q = encodeURIComponent;
|
||||
let uploadId: string | null = null;
|
||||
let cleaned = false;
|
||||
const cleanup = async () => {
|
||||
if (!uploadId || cleaned) return;
|
||||
cleaned = true;
|
||||
try {
|
||||
// Bewusst ohne `signal`: nach einem Abbruch soll das Aufraeumen trotzdem laufen.
|
||||
await doFetch(nextcloudFilesUrl(`/uploads/${q(uploadId)}`), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
} catch {
|
||||
// Nextcloud verwirft Upload-Ordner nach 24 Stunden ohnehin.
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const { data: start } = await withRetry(() =>
|
||||
call<StartReply>('POST', '/uploads', {
|
||||
path: targetPath,
|
||||
size,
|
||||
...(replaceEtag ? { replaceEtag } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
if (start.mode === 'single') {
|
||||
const params = [`path=${q(targetPath)}`, `size=${size}`];
|
||||
if (mtime !== undefined) params.push(`mtime=${mtime}`);
|
||||
if (replaceEtag) params.push(`replaceEtag=${q(replaceEtag)}`);
|
||||
// Ohne Wiederholung: ein Netzfehler NACH dem Schreiben wuerde sonst als "Name vergeben" enden.
|
||||
await put(`/uploads/file?${params.join('&')}`, file, 0);
|
||||
options.onProgress?.(1, size);
|
||||
return { path: targetPath, size };
|
||||
}
|
||||
|
||||
uploadId = start.uploadId;
|
||||
const chunkSize = start.chunkSize || NEXTCLOUD_FILES_CHUNK_SIZE;
|
||||
const chunks = Math.ceil(size / chunkSize);
|
||||
for (let i = 0; i < chunks; i++) {
|
||||
const from = i * chunkSize;
|
||||
const piece = file.slice(from, Math.min(from + chunkSize, size));
|
||||
const path = `/uploads/${q(uploadId)}/chunks/${i + 1}?path=${q(targetPath)}&size=${size}`;
|
||||
await withRetry(() => put(path, piece, from));
|
||||
}
|
||||
|
||||
const { status, data } = await withRetry(() =>
|
||||
call<CompleteReply>('POST', `/uploads/${q(uploadId as string)}/complete`, {
|
||||
path: targetPath,
|
||||
size,
|
||||
...(mtime !== undefined ? { mtime } : {}),
|
||||
...(replaceEtag ? { replaceEtag } : {}),
|
||||
}),
|
||||
);
|
||||
if (status === 202 || data.state === 'assembling') {
|
||||
await waitForAssembly(uploadId);
|
||||
}
|
||||
options.onProgress?.(1, size);
|
||||
return { path: targetPath, size };
|
||||
} catch (err) {
|
||||
await cleanup();
|
||||
if (signal?.aborted) throw abortedError();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user