Skip to content

Commit

Permalink
File Uploads: Add download endpoint (#2409)
Browse files Browse the repository at this point in the history
Adds an optional download endpoint to download file uploads. The
endpoint is only enabled if a download secret is provided in the module
config:

```ts
FileUploadsModule.register({
  /* ... */,
  download: {
    apiUrl: config.apiUrl,
    secret: "your secret",
  },
})
```

We also add a timeout to the URL (1h).

Example URL:
`http://localhost:4000/file-uploads/6a61a9439a7d9073b3415f08c317366f89901e64/d4821356-809a-4585-bb16-c4ade214e13b/1723450853782`

---------

Co-authored-by: Thomas Dax <thomas.dax@vivid-planet.com>
  • Loading branch information
johnnyomair and thomasdax98 authored Sep 12, 2024
1 parent ad151b0 commit a970190
Show file tree
Hide file tree
Showing 11 changed files with 224 additions and 17 deletions.
16 changes: 16 additions & 0 deletions .changeset/polite-kids-drum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@comet/cms-api": minor
---

File Uploads: Add download endpoint

The endpoint can be enabled by providing the `download` option in the module config:

```ts
FileUploadsModule.register({
/* ... */,
download: {
secret: "your secret",
},
})
```
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,5 @@ JAEGER_UI_PORT=16686
JAEGER_OLTP_PORT=4318
TRACING_ENABLED=1

# file uploads
FILE_UPLOADS_DOWNLOAD_SECRET=gPM8DTrrAdCMPYaNM99sH6hgtJfPWuEV
1 change: 1 addition & 0 deletions demo/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export class AppModule {
upload: {
public: true,
},
download: { public: true, ...config.fileUploads.download },
}),
...(config.contentGeneration
? [
Expand Down
6 changes: 6 additions & 0 deletions demo/api/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ export function createConfig(processEnv: NodeJS.ProcessEnv) {
environment: envVars.SENTRY_ENVIRONMENT,
}
: undefined,
fileUploads: {
...cometConfig.fileUploads,
download: {
secret: envVars.FILE_UPLOADS_DOWNLOAD_SECRET,
},
},
};
}

Expand Down
4 changes: 4 additions & 0 deletions demo/api/src/config/environment-variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,8 @@ export class EnvironmentVariables {
@ValidateIf((v) => v.SENTRY_DSN)
@IsString()
SENTRY_ENVIRONMENT?: string;

@IsString()
@MinLength(16)
FILE_UPLOADS_DOWNLOAD_SECRET: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Type } from "class-transformer";
import { IsHash, IsNumber, IsUUID } from "class-validator";

export class DownloadParams {
@IsUUID()
id: string;

@Type(() => Number)
@IsNumber()
timeout: number;
}

export class HashDownloadParams extends DownloadParams {
@IsHash("sha1")
hash: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { InjectRepository } from "@mikro-orm/nestjs";
import { EntityRepository } from "@mikro-orm/postgresql";
import { Controller, Get, GoneException, Headers, Inject, NotFoundException, Param, Res, Type } from "@nestjs/common";
import { Response } from "express";

import { DisableCometGuards } from "../auth/decorators/disable-comet-guards.decorator";
import { BlobStorageBackendService } from "../blob-storage/backends/blob-storage-backend.service";
import { calculatePartialRanges, createHashedPath } from "../dam/files/files.utils";
import { RequiredPermission } from "../user-permissions/decorators/required-permission.decorator";
import { DownloadParams, HashDownloadParams } from "./dto/file-uploads-download.params";
import { FileUpload } from "./entities/file-upload.entity";
import { FileUploadsConfig } from "./file-uploads.config";
import { FILE_UPLOADS_CONFIG } from "./file-uploads.constants";
import { FileUploadsService } from "./file-uploads.service";

export function createFileUploadsDownloadController(options: { public: boolean }): Type<unknown> {
@Controller("file-uploads")
class BaseFileUploadsDownloadController {
constructor(
@InjectRepository(FileUpload) private readonly fileUploadsRepository: EntityRepository<FileUpload>,
@Inject(BlobStorageBackendService) private readonly blobStorageBackendService: BlobStorageBackendService,
@Inject(FILE_UPLOADS_CONFIG) private readonly config: FileUploadsConfig,
private readonly fileUploadsService: FileUploadsService,
) {}

@Get(":hash/:id/:timeout")
async download(@Param() { hash, ...params }: HashDownloadParams, @Res() res: Response, @Headers("range") range?: string): Promise<void> {
if (!this.isValidHash(hash, params)) {
throw new NotFoundException();
}

if (Date.now() > params.timeout) {
throw new GoneException();
}

const file = await this.fileUploadsRepository.findOne(params.id);

if (!file) {
throw new NotFoundException();
}

const filePath = createHashedPath(file.contentHash);
const fileExists = await this.blobStorageBackendService.fileExists(this.config.directory, filePath);

if (!fileExists) {
throw new NotFoundException();
}

const headers = {
"content-disposition": `attachment; filename="${file.name}"`,
"content-type": file.mimetype,
"last-modified": file.updatedAt?.toUTCString(),
"content-length": file.size,
};

// https://medium.com/@vishal1909/how-to-handle-partial-content-in-node-js-8b0a5aea216
let stream: NodeJS.ReadableStream;

if (range) {
const { start, end, contentLength } = calculatePartialRanges(file.size, range);

if (start >= file.size || end >= file.size) {
res.writeHead(416, {
"content-range": `bytes */${file.size}`,
});
res.end();
return;
}

stream = await this.blobStorageBackendService.getPartialFile(
this.config.directory,
createHashedPath(file.contentHash),
start,
contentLength,
);

res.writeHead(206, {
...headers,
"accept-ranges": "bytes",
"content-range": `bytes ${start}-${end}/${file.size}`,
"content-length": contentLength,
});
} else {
stream = await this.blobStorageBackendService.getFile(this.config.directory, createHashedPath(file.contentHash));

res.writeHead(200, headers);
}

stream.pipe(res);
}

private isValidHash(hash: string, params: DownloadParams): boolean {
return hash === this.fileUploadsService.createHash(params);
}
}

if (options.public) {
@DisableCometGuards()
class PublicFileUploadsDownloadController extends BaseFileUploadsDownloadController {}

return PublicFileUploadsDownloadController;
}

@RequiredPermission("fileUploads", { skipScopeCheck: true })
class PrivateFileUploadsDownloadController extends BaseFileUploadsDownloadController {}

return PrivateFileUploadsDownloadController;
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { InjectRepository } from "@mikro-orm/nestjs";
import { EntityManager, EntityRepository } from "@mikro-orm/postgresql";
import { Controller, forwardRef, Inject, Post, Type, UploadedFile, UseInterceptors } from "@nestjs/common";
import { EntityManager } from "@mikro-orm/postgresql";
import { Controller, Inject, Post, Type, UploadedFile, UseInterceptors } from "@nestjs/common";
import rimraf from "rimraf";

import { DisableCometGuards } from "../auth/decorators/disable-comet-guards.decorator";
import { BlobStorageBackendService } from "../blob-storage/backends/blob-storage-backend.service";
import { FileUploadInput } from "../dam/files/dto/file-upload.input";
import { RequiredPermission } from "../user-permissions/decorators/required-permission.decorator";
import { FileUpload } from "./entities/file-upload.entity";
Expand All @@ -13,20 +11,22 @@ import { FILE_UPLOADS_CONFIG } from "./file-uploads.constants";
import { FileUploadsService } from "./file-uploads.service";
import { FileUploadsFileInterceptor } from "./file-uploads-file.interceptor";

export function createFileUploadsController(options: { public: boolean }): Type<unknown> {
type FileUploadsUploadResponse = Pick<FileUpload, "id" | "name" | "size" | "mimetype" | "contentHash" | "createdAt" | "updatedAt"> & {
downloadUrl?: string;
};

export function createFileUploadsUploadController(options: { public: boolean }): Type<unknown> {
@Controller("file-uploads")
class BaseFileUploadsController {
class BaseFileUploadsUploadController {
constructor(
@InjectRepository(FileUpload) private readonly fileUploadsRepository: EntityRepository<FileUpload>,
@Inject(forwardRef(() => BlobStorageBackendService)) private readonly blobStorageBackendService: BlobStorageBackendService,
@Inject(FILE_UPLOADS_CONFIG) private readonly config: FileUploadsConfig,
private readonly fileUploadsService: FileUploadsService,
private readonly entityManager: EntityManager,
@Inject(FILE_UPLOADS_CONFIG) private readonly config: FileUploadsConfig,
) {}

@Post("upload")
@UseInterceptors(FileUploadsFileInterceptor("file"))
async upload(@UploadedFile() file: FileUploadInput): Promise<FileUpload> {
async upload(@UploadedFile() file: FileUploadInput): Promise<FileUploadsUploadResponse> {
const fileUpload = await this.fileUploadsService.upload(file);

await this.entityManager.flush();
Expand All @@ -37,19 +37,28 @@ export function createFileUploadsController(options: { public: boolean }): Type<
}
});

return fileUpload;
let downloadUrl: string | undefined;

if (this.config.download) {
downloadUrl = this.fileUploadsService.createDownloadUrl(fileUpload);
}

return {
...fileUpload,
downloadUrl,
};
}
}

if (options.public) {
@DisableCometGuards()
class PublicFileUploadsController extends BaseFileUploadsController {}
class PublicFileUploadsUploadController extends BaseFileUploadsUploadController {}

return PublicFileUploadsController;
return PublicFileUploadsUploadController;
}

@RequiredPermission("fileUploads", { skipScopeCheck: true })
class PrivateFileUploadsController extends BaseFileUploadsController {}
class PrivateFileUploadsUploadController extends BaseFileUploadsUploadController {}

return PrivateFileUploadsController;
return PrivateFileUploadsUploadController;
}
4 changes: 4 additions & 0 deletions packages/api/cms-api/src/file-uploads/file-uploads.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@ export interface FileUploadsConfig {
upload?: {
public: boolean;
};
download?: {
public?: boolean;
secret: string;
};
}
17 changes: 15 additions & 2 deletions packages/api/cms-api/src/file-uploads/file-uploads.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { FileValidationService } from "../dam/files/file-validation.service";
import { FileUpload } from "./entities/file-upload.entity";
import { FileUploadsConfig } from "./file-uploads.config";
import { FILE_UPLOADS_CONFIG, FILE_UPLOADS_FILE_VALIDATION_SERVICE } from "./file-uploads.constants";
import { createFileUploadsController } from "./file-uploads.controller";
import { FileUploadsService } from "./file-uploads.service";
import { createFileUploadsDownloadController } from "./file-uploads-download.controller";
import { createFileUploadsUploadController } from "./file-uploads-upload.controller";

@Global()
@Module({})
Expand All @@ -25,11 +26,23 @@ export class FileUploadsModule {
acceptedMimeTypes: options.acceptedMimeTypes,
}),
};

const controllers = [createFileUploadsUploadController(options.upload ?? { public: false })];

if (options.download) {
if (options.download.secret.length < 16) {
throw new Error("The download secret must be at least 16 characters long.");
}

const FileUploadsDownloadController = createFileUploadsDownloadController({ public: options.download.public ?? false });
controllers.push(FileUploadsDownloadController);
}

return {
module: FileUploadsModule,
imports: [MikroOrmModule.forFeature([FileUpload]), BlobStorageModule],
providers: [fileUploadsConfigProvider, FileUploadsService, fileUploadsFileValidatorProvider],
controllers: [createFileUploadsController(options.upload ?? { public: false })],
controllers,
exports: [FileUploadsService],
};
}
Expand Down
28 changes: 28 additions & 0 deletions packages/api/cms-api/src/file-uploads/file-uploads.service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { InjectRepository } from "@mikro-orm/nestjs";
import { EntityRepository } from "@mikro-orm/postgresql";
import { forwardRef, Inject, Injectable } from "@nestjs/common";
import { createHmac } from "crypto";
import { addHours } from "date-fns";
import hasha from "hasha";
import { basename, extname } from "path";

import { BlobStorageBackendService } from "../blob-storage/backends/blob-storage-backend.service";
import { FileUploadInput } from "../dam/files/dto/file-upload.input";
import { slugifyFilename } from "../dam/files/files.utils";
import { DownloadParams } from "./dto/file-uploads-download.params";
import { FileUpload } from "./entities/file-upload.entity";
import { FileUploadsConfig } from "./file-uploads.config";
import { FILE_UPLOADS_CONFIG } from "./file-uploads.constants";
Expand Down Expand Up @@ -38,4 +41,29 @@ export class FileUploadsService {

return fileUpload;
}

createHash(params: DownloadParams): string {
if (!this.config.download) {
throw new Error("File Uploads: Missing download configuration");
}

const hash = `file-upload:${params.id}:${params.timeout}`;

return createHmac("sha1", this.config.download.secret).update(hash).digest("hex");
}

createDownloadUrl(file: FileUpload): string {
if (!this.config.download) {
throw new Error("File Uploads: Missing download configuration");
}

const timeout = addHours(new Date(), 1).getTime();

const hash = this.createHash({
id: file.id,
timeout,
});

return ["/file-uploads", hash, file.id, timeout].join("/");
}
}

0 comments on commit a970190

Please sign in to comment.