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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:30:02 +02:00
parent 8bee65c306
commit 4d4a0b31cb
15 changed files with 3431 additions and 6 deletions
@@ -0,0 +1,121 @@
import { Transform, Type } from 'class-transformer';
import {
ArrayMaxSize,
ArrayMinSize,
IsArray,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from 'class-validator';
/**
* Eingaben der Uebertragungsrouten (quick-261008-mzu, D-J/D-K). Pfade stehen nur
* in Query oder Body, nie im URL-Pfad der Tessera-Route. Die Segmentpruefung macht
* der Dienst (`parseUserPath`, `validateSegment`); hier stehen nur grobe Grenzen.
*
* Absichtlich KEIN `@Max` auf `size`: eine zu grosse Datei soll mit 413
* `fileTooLarge` und lesbarem Text abgelehnt werden, nicht mit einem 400 der
* allgemeinen Pruefung.
*/
/** Hoechster Unix-Zeitstempel (Sekunden), den eine Aenderungszeit haben darf (Jahr 2100). */
export const MAX_MTIME_SECONDS = 4102444800;
export class StartUploadDto {
@IsString()
@IsNotEmpty()
@MaxLength(4096)
path!: string;
@IsInt()
@Min(0)
size!: number;
/** Entity-Tag der Version, die ersetzt werden soll (nach Rueckfrage beim Benutzer). */
@IsOptional()
@IsString()
@MaxLength(200)
replaceEtag?: string;
}
export class CompleteUploadDto {
@IsString()
@IsNotEmpty()
@MaxLength(4096)
path!: string;
@IsInt()
@Min(0)
size!: number;
@IsOptional()
@IsInt()
@Min(0)
@Max(MAX_MTIME_SECONDS)
mtime?: number;
@IsOptional()
@IsString()
@MaxLength(200)
replaceEtag?: string;
}
/** Query der Raw-Body-Routen `PUT uploads/file` und `PUT uploads/:uploadId/chunks/:n`. */
export class UploadQueryDto {
@IsString()
@IsNotEmpty()
@MaxLength(4096)
path!: string;
/** Gesamtgroesse der Datei in Byte. */
@Type(() => Number)
@IsInt()
@Min(0)
size!: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(MAX_MTIME_SECONDS)
mtime?: number;
@IsOptional()
@IsString()
@MaxLength(200)
replaceEtag?: string;
}
export class DownloadQueryDto {
@IsString()
@IsNotEmpty()
@MaxLength(4096)
path!: string;
/** `1`: den Ordner als ZIP laden statt einer Datei. */
@IsOptional()
@IsIn(['1'])
zip?: string;
}
export class ZipQueryDto {
/** Ordner, aus dem die gewaehlten Eintraege kommen (`/` = Wurzel). */
@IsOptional()
@IsString()
@MaxLength(4096)
dir?: string;
/** Namen der gewaehlten Eintraege; ein einzelnes `name=...` wird zur Liste. */
@Transform(({ value }) => (Array.isArray(value) ? value : value === undefined ? [] : [value]))
@IsArray()
@ArrayMinSize(1)
@ArrayMaxSize(500)
@IsString({ each: true })
@MaxLength(255, { each: true })
name!: string[];
}