import { Transform, Type } from 'class-transformer'; import { ArrayMaxSize, ArrayMinSize, IsArray, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Matches, 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; /** * Form eines Entity-Tags (IN-01): optional `W/`, optional in Anfuehrungszeichen, sonst nur * sichtbare ASCII-Zeichen. Der Wert geht als `If-Match`-Kopfzeile an Nextcloud; CR, LF oder * andere Steuerzeichen liessen den Aufruf sonst scheitern und als "nicht erreichbar" enden. */ export const ETAG_PATTERN = /^(W\/)?"?[\x21\x23-\x7e]{1,128}"?$/; 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) @Matches(ETAG_PATTERN) 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) @Matches(ETAG_PATTERN) 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) @Matches(ETAG_PATTERN) 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; /** * `1`: nur pruefen (Verbindung, Eintrag vorhanden), keinen Datenstrom liefern (IN-06). Die * Weboberflaeche fragt so vor dem eigentlichen Herunterladen, weil sie Fehler eines * Downloads im Browser sonst nicht sieht. */ @IsOptional() @IsIn(['1']) check?: string; } /** Hoechstzahl gewaehlter Eintraege fuer ein Auswahl-ZIP (WR-08). */ export const ZIP_MAX_NAMES = 1000; /** * Auswahl-ZIP (WR-08): die Namen kommen im KOERPER eines POST, nicht in der Adresse — mehrere * hundert Namen sprengten sonst die Kopfzeilen-Grenze von Node (431) oder den Proxy (414). * Als JSON (`names` ist eine Liste) oder aus einem unsichtbaren Formular der Weboberflaeche * (`names` ist dieselbe Liste als JSON-Text; so laedt der Browser das ZIP als Strom direkt * auf die Platte, statt es im Arbeitsspeicher zu sammeln). */ export class ZipBodyDto { /** Ordner, aus dem die gewaehlten Eintraege kommen (`/` = Wurzel). */ @IsOptional() @IsString() @MaxLength(4096) dir?: string; @Transform(({ value }) => { if (typeof value !== 'string') return value; try { return JSON.parse(value); } catch { return value; } }) @IsArray() @ArrayMinSize(1) @ArrayMaxSize(ZIP_MAX_NAMES) @IsString({ each: true }) @MaxLength(255, { each: true }) names!: string[]; /** `true` (JSON) oder `1` (Formular): nur pruefen, kein ZIP (IN-06). */ @IsOptional() @IsIn([true, '1']) check?: boolean | string; }