Skip to content

Commit 5c0e782

Browse files
committed
feat: add temporary file upload and download endpoints with root directory support
1 parent 5f17543 commit 5c0e782

File tree

10 files changed

+108
-11
lines changed

10 files changed

+108
-11
lines changed

src/modules/files-storage/api/controller/files-storage.controller.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,49 @@ export class FilesStorageController {
126126
return streamableFile;
127127
}
128128

129+
@ApiOperation({ summary: 'Streamable download of a temporary file.' })
130+
@ApiProduces('application/octet-stream')
131+
@ApiResponse({ status: 200, schema: { type: 'string', format: 'binary' } })
132+
@ApiResponse({ status: 206, schema: { type: 'string', format: 'binary' } })
133+
@ApiResponse({ status: 400, type: ApiValidationError })
134+
@ApiResponse({ status: 403, type: ForbiddenException })
135+
@ApiResponse({ status: 404, type: NotFoundException })
136+
@ApiResponse({ status: 406, type: NotAcceptableException })
137+
@ApiResponse({ status: 500, type: InternalServerErrorException })
138+
@ApiHeader({ name: 'Range', required: false })
139+
@UseInterceptors(CurrentDownloadMetricsInterceptor)
140+
@Get('/temp/download/:fileRecordId/:fileName')
141+
public async tempDownload(
142+
@Param() params: DownloadFileParams,
143+
@Req() req: Request,
144+
@Res({ passthrough: true }) response: Response,
145+
@Headers('Range') bytesRange?: string
146+
): Promise<StreamableFile> {
147+
const fileResponse = await this.filesStorageUC.tempDownload(params, bytesRange);
148+
const streamableFile = this.streamFileToClient(req, fileResponse, response, bytesRange);
149+
150+
return streamableFile;
151+
}
152+
153+
@ApiOperation({ summary: 'Temporäres Hochladen einer Datei.' })
154+
@ApiResponse({ status: 201, type: FileRecordResponse })
155+
@ApiResponse({ status: 400, type: ApiValidationError })
156+
@ApiResponse({ status: 400, type: BadRequestException })
157+
@ApiResponse({ status: 403, type: ForbiddenException })
158+
@ApiResponse({ status: 500, type: InternalServerErrorException })
159+
@ApiConsumes('multipart/form-data')
160+
@Post('/temp/upload/:storageLocation/:storageLocationId/:parentType/:parentId')
161+
public async tempUpload(
162+
@Body() _: FileParams,
163+
@Param() params: FileRecordParams,
164+
@CurrentUser() currentUser: ICurrentUser,
165+
@Req() req: Request
166+
): Promise<FileRecordResponse> {
167+
const response = await this.filesStorageUC.tempUpload(currentUser.userId, params, req);
168+
169+
return response;
170+
}
171+
129172
@ApiOperation({ summary: 'Streamable download of a preview file.' })
130173
@ApiResponse({ status: 200, type: StreamableFile })
131174
@ApiResponse({ status: 206, type: StreamableFile })

src/modules/files-storage/api/mapper/file-dto.mapper.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@ export class FileDtoMapper {
1111
return file;
1212
}
1313

14-
public static mapFromBusboyFileInfo(fileInfo: BusboyFileInfo, stream: Readable, abortSignal?: AbortSignal): FileDto {
15-
const file = FileDtoFactory.create(fileInfo.filename, stream, fileInfo.mimeType, abortSignal);
14+
public static mapFromBusboyFileInfo(
15+
fileInfo: BusboyFileInfo,
16+
stream: Readable,
17+
abortSignal?: AbortSignal,
18+
rootDirectory?: string
19+
): FileDto {
20+
const file = FileDtoFactory.create(fileInfo.filename, stream, fileInfo.mimeType, abortSignal, rootDirectory);
1621

1722
return file;
1823
}

src/modules/files-storage/api/uc/files-storage.uc.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ export const FileStorageAuthorizationContext = {
6161

6262
@Injectable()
6363
export class FilesStorageUC {
64+
private readonly tempFolderName = 'temp';
65+
6466
constructor(
6567
private readonly logger: Logger,
6668
private readonly authorizationClientAdapter: AuthorizationClientAdapter,
@@ -104,6 +106,28 @@ export class FilesStorageUC {
104106
return fileRecordResponse;
105107
}
106108

109+
public async tempDownload(params: DownloadFileParams, bytesRange: string | undefined): Promise<GetFileResponse> {
110+
const fileRecord = await this.filesStorageService.getFileRecord(params.fileRecordId);
111+
const parentInfo = fileRecord.getParentInfo();
112+
113+
await this.checkPermission(parentInfo, FileStorageAuthorizationContext.read);
114+
115+
return this.filesStorageService.downloadFile(fileRecord, bytesRange, this.tempFolderName);
116+
}
117+
118+
public async tempUpload(userId: string, params: FileRecordParams, req: Request): Promise<FileRecordResponse> {
119+
await Promise.all([
120+
this.checkPermission(params, FileStorageAuthorizationContext.create),
121+
this.checkStorageLocationCanRead(params.storageLocation, params.storageLocationId),
122+
]);
123+
124+
const fileRecord = await this.uploadFileWithBusboy(userId, params, req, this.tempFolderName);
125+
const status = this.filesStorageService.getFileRecordStatus(fileRecord);
126+
const fileRecordResponse = FileRecordMapper.mapToFileRecordResponse(fileRecord, status);
127+
128+
return fileRecordResponse;
129+
}
130+
107131
// download
108132
public async download(params: DownloadFileParams, bytesRange?: string): Promise<GetFileResponse> {
109133
const fileRecord = await this.filesStorageService.getFileRecord(params.fileRecordId);
@@ -341,7 +365,12 @@ export class FilesStorageUC {
341365
}
342366

343367
// private: stream helper
344-
private uploadFileWithBusboy(userId: EntityId, params: FileRecordParams, req: AbortableRequest): Promise<FileRecord> {
368+
private uploadFileWithBusboy(
369+
userId: EntityId,
370+
params: FileRecordParams,
371+
req: AbortableRequest,
372+
rootDirectory?: string
373+
): Promise<FileRecord> {
345374
return new Promise<FileRecord>((resolve, reject) => {
346375
const bb = busboy({ headers: req.headers, defParamCharset: 'utf8' });
347376
const abortController = new AbortController();
@@ -397,7 +426,7 @@ export class FilesStorageUC {
397426
bb.on('file', (_name, file, info) => {
398427
if (isResolved) return; // Already resolved/rejected
399428

400-
const fileDto = FileDtoMapper.mapFromBusboyFileInfo(info, file, abortController.signal);
429+
const fileDto = FileDtoMapper.mapFromBusboyFileInfo(info, file, abortController.signal, rootDirectory);
401430

402431
fileRecordPromise = RequestContext.create(this.em, () => {
403432
return this.filesStorageService.uploadFile(userId, params, fileDto);

src/modules/files-storage/domain/dto/file.dto.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export class FileDto implements File {
77
this.data = file.data;
88
this.mimeType = file.mimeType;
99
this.abortSignal = file.abortSignal;
10+
this.rootDirectory = file.rootDirectory;
1011
}
1112

1213
name: string;
@@ -16,4 +17,6 @@ export class FileDto implements File {
1617
mimeType: string;
1718

1819
abortSignal?: AbortSignal;
20+
21+
rootDirectory: string | undefined = undefined;
1922
}

src/modules/files-storage/domain/dto/pass-through-file.dto.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export class PassThroughFileDto implements FileDto {
99
this.abortSignal = file.abortSignal;
1010
this.streamCompletion = file.streamCompletion;
1111
this.fileSize = file.fileSize;
12+
this.rootDirectory = file.rootDirectory;
1213
}
1314

1415
name: string;
@@ -22,4 +23,6 @@ export class PassThroughFileDto implements FileDto {
2223
streamCompletion?: Promise<void>;
2324

2425
fileSize: number;
26+
27+
rootDirectory: string | undefined;
2528
}

src/modules/files-storage/domain/factory/file-dto.factory.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@ import { Readable } from 'node:stream';
22
import { FileDto } from '../dto';
33

44
export class FileDtoFactory {
5-
public static create(name: string, stream: Readable, mimeType: string, abortSignal?: AbortSignal): FileDto {
5+
public static create(
6+
name: string,
7+
stream: Readable,
8+
mimeType: string,
9+
abortSignal?: AbortSignal,
10+
rootDirectory?: string
11+
): FileDto {
612
const file = new FileDto({
713
name,
814
data: stream,
915
mimeType,
1016
abortSignal,
17+
rootDirectory,
1118
});
1219

1320
return file;

src/modules/files-storage/domain/factory/pass-through-file-dto.factory.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ export class PassThroughFileDtoFactory {
77
sourceFile: FileDto,
88
passThrough: PassThrough,
99
mimeType: string,
10-
newFileName?: string
10+
newFileName?: string,
11+
rootDirectory?: string
1112
): PassThroughFileDto {
1213
const streamCompletion = awaitStreamCompletion(passThrough, sourceFile.abortSignal);
1314
const file = new PassThroughFileDto({
@@ -17,6 +18,7 @@ export class PassThroughFileDtoFactory {
1718
abortSignal: sourceFile.abortSignal,
1819
streamCompletion,
1920
fileSize: 0,
21+
rootDirectory: rootDirectory ?? sourceFile.rootDirectory,
2022
});
2123

2224
return file;

src/modules/files-storage/domain/file-record.do.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,8 +338,8 @@ export class FileRecord extends DomainObject<FileRecordProps> {
338338
}
339339
}
340340

341-
public createPath(): string {
342-
const path = [this.props.storageLocationId, this.id].join('/');
341+
public createPath(rootDirectory?: string): string {
342+
const path = [rootDirectory, this.props.storageLocationId, this.id].filter(Boolean).join('/');
343343

344344
return path;
345345
}

src/modules/files-storage/domain/service/files-storage.service.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ export class FilesStorageService {
207207
}
208208

209209
private async uploadAndScan(fileRecord: FileRecord, file: PassThroughFileDto): Promise<void> {
210-
const filePath = fileRecord.createPath();
210+
const filePath = fileRecord.createPath(file.rootDirectory);
211211

212212
if (this.shouldStreamToAntivirus(fileRecord)) {
213213
const pipedStream = file.data.pipe(new PassThrough());
@@ -299,8 +299,12 @@ export class FilesStorageService {
299299
}
300300
}
301301

302-
public async downloadFile(fileRecord: FileRecord, bytesRange?: string): Promise<GetFileResponse> {
303-
const pathToFile = fileRecord.createPath();
302+
public async downloadFile(
303+
fileRecord: FileRecord,
304+
bytesRange?: string,
305+
rootDirectory?: string
306+
): Promise<GetFileResponse> {
307+
const pathToFile = fileRecord.createPath(rootDirectory);
304308
const file = await this.storageClient.get(pathToFile, bytesRange);
305309
const fileResponse = FileResponseFactory.create(file, fileRecord.getName());
306310

src/modules/files-storage/testing/file-dto.test.factory.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ class FileDtoTestFactory {
1616
data: Readable.from('abc'),
1717
mimeType: 'application/octet-stream',
1818
abortSignal: new AbortController().signal,
19+
rootDirectory: undefined,
1920
};
2021

2122
private static readonly mimeTypeMap = new Map<string, () => Readable>([

0 commit comments

Comments
 (0)