From 6f92ecedf62d4b5451ab157ea7ea837a1b5193ba Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:29:38 -0800 Subject: [PATCH] feat: add HTTP range request support for file serving --- .../core/attachment/attachment.controller.ts | 105 ++++++++++++------ .../storage/drivers/local.driver.ts | 14 +++ .../integrations/storage/drivers/s3.driver.ts | 19 ++++ .../interfaces/storage-driver.interface.ts | 5 + .../integrations/storage/storage.service.ts | 7 ++ 5 files changed, 116 insertions(+), 34 deletions(-) diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts index cc058ac6..3215a74d 100644 --- a/apps/server/src/core/attachment/attachment.controller.ts +++ b/apps/server/src/core/attachment/attachment.controller.ts @@ -17,13 +17,13 @@ import { UseInterceptors, } from '@nestjs/common'; import { AttachmentService } from './services/attachment.service'; -import { FastifyReply } from 'fastify'; +import { FastifyReply, FastifyRequest } from 'fastify'; import { FileInterceptor } from '../../common/interceptors/file.interceptor'; import * as bytes from 'bytes'; import { AuthUser } from '../../common/decorators/auth-user.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; -import { User, Workspace } from '@docmost/db/types/entity.types'; +import { Attachment, User, Workspace } from '@docmost/db/types/entity.types'; import { StorageService } from '../../integrations/storage/storage.service'; import { getAttachmentFolderPath, @@ -151,6 +151,7 @@ export class AttachmentController { @UseGuards(JwtAuthGuard) @Get('/files/:fileId/:fileName') async getFile( + @Req() req: FastifyRequest, @Res() res: FastifyReply, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace, @@ -181,22 +182,7 @@ export class AttachmentController { } try { - const fileStream = await this.storageService.readStream( - attachment.filePath, - ); - res.headers({ - 'Content-Type': attachment.mimeType, - 'Cache-Control': 'private, max-age=3600', - }); - - if (!inlineFileExtensions.includes(attachment.fileExt)) { - res.header( - 'Content-Disposition', - `attachment; filename="${encodeURIComponent(attachment.fileName)}"`, - ); - } - - return res.send(fileStream); + return await this.sendFileResponse(req, res, attachment, 'private'); } catch (err) { this.logger.error(err); throw new NotFoundException('File not found'); @@ -205,6 +191,7 @@ export class AttachmentController { @Get('/files/public/:fileId/:fileName') async getPublicFile( + @Req() req: FastifyRequest, @Res() res: FastifyReply, @AuthWorkspace() workspace: Workspace, @Param('fileId') fileId: string, @@ -243,22 +230,7 @@ export class AttachmentController { } try { - const fileStream = await this.storageService.readStream( - attachment.filePath, - ); - res.headers({ - 'Content-Type': attachment.mimeType, - 'Cache-Control': 'public, max-age=3600', - }); - - if (!inlineFileExtensions.includes(attachment.fileExt)) { - res.header( - 'Content-Disposition', - `attachment; filename="${encodeURIComponent(attachment.fileName)}"`, - ); - } - - return res.send(fileStream); + return await this.sendFileResponse(req, res, attachment, 'public'); } catch (err) { this.logger.error(err); throw new NotFoundException('File not found'); @@ -433,4 +405,69 @@ export class AttachmentController { return; } } + + private async sendFileResponse( + req: FastifyRequest, + res: FastifyReply, + attachment: Attachment, + cacheScope: 'private' | 'public', + ) { + const fileSize = Number(attachment.fileSize); + const rangeHeader = req.headers.range; + + res.header('Accept-Ranges', 'bytes'); + + if (!inlineFileExtensions.includes(attachment.fileExt)) { + res.header( + 'Content-Disposition', + `attachment; filename="${encodeURIComponent(attachment.fileName)}"`, + ); + } + + if (rangeHeader && fileSize) { + const match = rangeHeader.match(/bytes=(\d+)-(\d*)/); + if (match) { + const start = parseInt(match[1], 10); + const end = match[2] + ? Math.min(parseInt(match[2], 10), fileSize - 1) + : fileSize - 1; + + if (start >= fileSize || start > end) { + res.status(416); + res.header('Content-Range', `bytes */${fileSize}`); + return res.send(); + } + + const fileStream = await this.storageService.readRangeStream( + attachment.filePath, + { start, end }, + ); + + res.status(206); + res.headers({ + 'Content-Type': attachment.mimeType, + 'Content-Range': `bytes ${start}-${end}/${fileSize}`, + 'Content-Length': end - start + 1, + 'Cache-Control': `${cacheScope}, max-age=3600`, + }); + + return res.send(fileStream); + } + } + + const fileStream = await this.storageService.readStream( + attachment.filePath, + ); + + res.headers({ + 'Content-Type': attachment.mimeType, + 'Cache-Control': `${cacheScope}, max-age=3600`, + }); + + if (fileSize) { + res.header('Content-Length', fileSize); + } + + return res.send(fileStream); + } } diff --git a/apps/server/src/integrations/storage/drivers/local.driver.ts b/apps/server/src/integrations/storage/drivers/local.driver.ts index aada2c05..90f7b7dd 100644 --- a/apps/server/src/integrations/storage/drivers/local.driver.ts +++ b/apps/server/src/integrations/storage/drivers/local.driver.ts @@ -73,6 +73,20 @@ export class LocalDriver implements StorageDriver { } } + async readRangeStream( + filePath: string, + range: { start: number; end: number }, + ): Promise { + try { + return createReadStream(this._fullPath(filePath), { + start: range.start, + end: range.end, + }); + } catch (err) { + throw new Error(`Failed to read file: ${(err as Error).message}`); + } + } + async exists(filePath: string): Promise { try { return await fs.pathExists(this._fullPath(filePath)); diff --git a/apps/server/src/integrations/storage/drivers/s3.driver.ts b/apps/server/src/integrations/storage/drivers/s3.driver.ts index 8221e466..02087176 100644 --- a/apps/server/src/integrations/storage/drivers/s3.driver.ts +++ b/apps/server/src/integrations/storage/drivers/s3.driver.ts @@ -130,6 +130,25 @@ export class S3Driver implements StorageDriver { } } + async readRangeStream( + filePath: string, + range: { start: number; end: number }, + ): Promise { + try { + const command = new GetObjectCommand({ + Bucket: this.config.bucket, + Key: filePath, + Range: `bytes=${range.start}-${range.end}`, + }); + + const response = await this.s3Client.send(command); + + return response.Body as Readable; + } catch (err) { + throw new Error(`Failed to read file from S3: ${(err as Error).message}`); + } + } + async exists(filePath: string): Promise { try { const command = new HeadObjectCommand({ diff --git a/apps/server/src/integrations/storage/interfaces/storage-driver.interface.ts b/apps/server/src/integrations/storage/interfaces/storage-driver.interface.ts index f376c56f..c5764e0e 100644 --- a/apps/server/src/integrations/storage/interfaces/storage-driver.interface.ts +++ b/apps/server/src/integrations/storage/interfaces/storage-driver.interface.ts @@ -11,6 +11,11 @@ export interface StorageDriver { readStream(filePath: string): Promise; + readRangeStream( + filePath: string, + range: { start: number; end: number }, + ): Promise; + exists(filePath: string): Promise; getUrl(filePath: string): string; diff --git a/apps/server/src/integrations/storage/storage.service.ts b/apps/server/src/integrations/storage/storage.service.ts index 3ed887af..cb643cc8 100644 --- a/apps/server/src/integrations/storage/storage.service.ts +++ b/apps/server/src/integrations/storage/storage.service.ts @@ -33,6 +33,13 @@ export class StorageService { return this.storageDriver.readStream(filePath); } + async readRangeStream( + filePath: string, + range: { start: number; end: number }, + ): Promise { + return this.storageDriver.readRangeStream(filePath, range); + } + async exists(filePath: string): Promise { return this.storageDriver.exists(filePath); }