1
0
mirror of https://github.com/immich-app/immich.git synced 2025-02-19 20:10:14 +02:00

fix(server): file sending and cache control (#5829)

* fix: file sending

* fix: tests
This commit is contained in:
Jason Rasmussen 2023-12-18 11:33:46 -05:00 committed by GitHub
parent ffc31f034c
commit d3e1572229
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 132 additions and 98 deletions

View File

@ -16,7 +16,7 @@ import {
} from '@test'; } from '@test';
import { when } from 'jest-when'; import { when } from 'jest-when';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { ImmichFileResponse } from '../domain.util'; import { CacheControl, ImmichFileResponse } from '../domain.util';
import { JobName } from '../job'; import { JobName } from '../job';
import { import {
AssetStats, AssetStats,
@ -482,7 +482,7 @@ describe(AssetService.name, () => {
new ImmichFileResponse({ new ImmichFileResponse({
path: '/original/path.jpg', path: '/original/path.jpg',
contentType: 'image/jpeg', contentType: 'image/jpeg',
cacheControl: false, cacheControl: CacheControl.NONE,
}), }),
); );
}); });

View File

@ -8,7 +8,7 @@ import sanitize from 'sanitize-filename';
import { AccessCore, Permission } from '../access'; import { AccessCore, Permission } from '../access';
import { AuthDto } from '../auth'; import { AuthDto } from '../auth';
import { mimeTypes } from '../domain.constant'; import { mimeTypes } from '../domain.constant';
import { HumanReadableSize, ImmichFileResponse, usePagination } from '../domain.util'; import { CacheControl, HumanReadableSize, ImmichFileResponse, usePagination } from '../domain.util';
import { IAssetDeletionJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; import { IAssetDeletionJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
import { import {
ClientEvent, ClientEvent,
@ -290,7 +290,7 @@ export class AssetService {
return new ImmichFileResponse({ return new ImmichFileResponse({
path: asset.originalPath, path: asset.originalPath,
contentType: mimeTypes.lookup(asset.originalPath), contentType: mimeTypes.lookup(asset.originalPath),
cacheControl: false, cacheControl: CacheControl.NONE,
}); });
} }

View File

@ -16,10 +16,16 @@ import { CronJob } from 'cron';
import { basename, extname } from 'node:path'; import { basename, extname } from 'node:path';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
export enum CacheControl {
PRIVATE_WITH_CACHE = 'private_with_cache',
PRIVATE_WITHOUT_CACHE = 'private_without_cache',
NONE = 'none',
}
export class ImmichFileResponse { export class ImmichFileResponse {
public readonly path!: string; public readonly path!: string;
public readonly contentType!: string; public readonly contentType!: string;
public readonly cacheControl!: boolean; public readonly cacheControl!: CacheControl;
constructor(response: ImmichFileResponse) { constructor(response: ImmichFileResponse) {
Object.assign(this, response); Object.assign(this, response);

View File

@ -18,7 +18,7 @@ import {
personStub, personStub,
} from '@test'; } from '@test';
import { BulkIdErrorReason } from '../asset'; import { BulkIdErrorReason } from '../asset';
import { ImmichFileResponse } from '../domain.util'; import { CacheControl, ImmichFileResponse } from '../domain.util';
import { JobName } from '../job'; import { JobName } from '../job';
import { import {
IAssetRepository, IAssetRepository,
@ -208,7 +208,7 @@ describe(PersonService.name, () => {
new ImmichFileResponse({ new ImmichFileResponse({
path: '/path/to/thumbnail.jpg', path: '/path/to/thumbnail.jpg',
contentType: 'image/jpeg', contentType: 'image/jpeg',
cacheControl: true, cacheControl: CacheControl.PRIVATE_WITHOUT_CACHE,
}), }),
); );
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1'])); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));

View File

@ -6,7 +6,7 @@ import { AccessCore, Permission } from '../access';
import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset'; import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset';
import { AuthDto } from '../auth'; import { AuthDto } from '../auth';
import { mimeTypes } from '../domain.constant'; import { mimeTypes } from '../domain.constant';
import { ImmichFileResponse, usePagination } from '../domain.util'; import { CacheControl, ImmichFileResponse, usePagination } from '../domain.util';
import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
import { FACE_THUMBNAIL_SIZE } from '../media'; import { FACE_THUMBNAIL_SIZE } from '../media';
import { import {
@ -183,7 +183,7 @@ export class PersonService {
return new ImmichFileResponse({ return new ImmichFileResponse({
path: person.thumbnailPath, path: person.thumbnailPath,
contentType: mimeTypes.lookup(person.thumbnailPath), contentType: mimeTypes.lookup(person.thumbnailPath),
cacheControl: true, cacheControl: CacheControl.PRIVATE_WITHOUT_CACHE,
}); });
} }

View File

@ -17,7 +17,7 @@ import {
userStub, userStub,
} from '@test'; } from '@test';
import { when } from 'jest-when'; import { when } from 'jest-when';
import { ImmichFileResponse } from '../domain.util'; import { CacheControl, ImmichFileResponse } from '../domain.util';
import { JobName } from '../job'; import { JobName } from '../job';
import { import {
IAlbumRepository, IAlbumRepository,
@ -396,7 +396,7 @@ describe(UserService.name, () => {
new ImmichFileResponse({ new ImmichFileResponse({
path: '/path/to/profile.jpg', path: '/path/to/profile.jpg',
contentType: 'image/jpeg', contentType: 'image/jpeg',
cacheControl: false, cacheControl: CacheControl.NONE,
}), }),
); );

View File

@ -3,7 +3,7 @@ import { ImmichLogger } from '@app/infra/logger';
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { randomBytes } from 'crypto'; import { randomBytes } from 'crypto';
import { AuthDto } from '../auth'; import { AuthDto } from '../auth';
import { ImmichFileResponse } from '../domain.util'; import { CacheControl, ImmichFileResponse } from '../domain.util';
import { IEntityJob, JobName } from '../job'; import { IEntityJob, JobName } from '../job';
import { import {
IAlbumRepository, IAlbumRepository,
@ -109,7 +109,7 @@ export class UserService {
return new ImmichFileResponse({ return new ImmichFileResponse({
path: user.profileImagePath, path: user.profileImagePath,
contentType: 'image/jpeg', contentType: 'image/jpeg',
cacheControl: false, cacheControl: CacheControl.NONE,
}); });
} }

View File

@ -5,18 +5,20 @@ import {
Get, Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Next,
Param, Param,
ParseFilePipe, ParseFilePipe,
Post, Post,
Query, Query,
Response, Res,
UploadedFiles, UploadedFiles,
UseInterceptors, UseInterceptors,
ValidationPipe, ValidationPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBody, ApiConsumes, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Response as Res } from 'express'; import { NextFunction, Response } from 'express';
import { Auth, Authenticated, FileResponse, SharedLinkRoute } from '../../app.guard'; import { Auth, Authenticated, FileResponse, SharedLinkRoute } from '../../app.guard';
import { sendFile } from '../../app.utils';
import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto'; import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
import { FileUploadInterceptor, ImmichFile, Route, mapToUploadFile } from '../../interceptors'; import { FileUploadInterceptor, ImmichFile, Route, mapToUploadFile } from '../../interceptors';
import FileNotEmptyValidator from '../validation/file-not-empty-validator'; import FileNotEmptyValidator from '../validation/file-not-empty-validator';
@ -58,7 +60,7 @@ export class AssetController {
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles, @UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles,
@Body(new ValidationPipe({ transform: true })) dto: CreateAssetDto, @Body(new ValidationPipe({ transform: true })) dto: CreateAssetDto,
@Response({ passthrough: true }) res: Res, @Res({ passthrough: true }) res: Response,
): Promise<AssetFileUploadResponseDto> { ): Promise<AssetFileUploadResponseDto> {
const file = mapToUploadFile(files.assetData[0]); const file = mapToUploadFile(files.assetData[0]);
const _livePhotoFile = files.livePhotoData?.[0]; const _livePhotoFile = files.livePhotoData?.[0];
@ -84,23 +86,27 @@ export class AssetController {
@SharedLinkRoute() @SharedLinkRoute()
@Get('/file/:id') @Get('/file/:id')
@FileResponse() @FileResponse()
serveFile( async serveFile(
@Res() res: Response,
@Next() next: NextFunction,
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto, @Param() { id }: UUIDParamDto,
@Query(new ValidationPipe({ transform: true })) dto: ServeFileDto, @Query(new ValidationPipe({ transform: true })) dto: ServeFileDto,
) { ) {
return this.assetService.serveFile(auth, id, dto); await sendFile(res, next, () => this.assetService.serveFile(auth, id, dto));
} }
@SharedLinkRoute() @SharedLinkRoute()
@Get('/thumbnail/:id') @Get('/thumbnail/:id')
@FileResponse() @FileResponse()
getAssetThumbnail( async getAssetThumbnail(
@Res() res: Response,
@Next() next: NextFunction,
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto, @Param() { id }: UUIDParamDto,
@Query(new ValidationPipe({ transform: true })) dto: GetAssetThumbnailDto, @Query(new ValidationPipe({ transform: true })) dto: GetAssetThumbnailDto,
) { ) {
return this.assetService.serveThumbnail(auth, id, dto); await sendFile(res, next, () => this.assetService.serveThumbnail(auth, id, dto));
} }
@Get('/curated-objects') @Get('/curated-objects')

View File

@ -2,6 +2,7 @@ import {
AccessCore, AccessCore,
AssetResponseDto, AssetResponseDto,
AuthDto, AuthDto,
CacheControl,
getLivePhotoMotionFilename, getLivePhotoMotionFilename,
IAccessRepository, IAccessRepository,
IJobRepository, IJobRepository,
@ -147,7 +148,11 @@ export class AssetService {
const filepath = this.getThumbnailPath(asset, dto.format); const filepath = this.getThumbnailPath(asset, dto.format);
return new ImmichFileResponse({ path: filepath, contentType: mimeTypes.lookup(filepath), cacheControl: true }); return new ImmichFileResponse({
path: filepath,
contentType: mimeTypes.lookup(filepath),
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
});
} }
public async serveFile(auth: AuthDto, assetId: string, dto: ServeFileDto): Promise<ImmichFileResponse> { public async serveFile(auth: AuthDto, assetId: string, dto: ServeFileDto): Promise<ImmichFileResponse> {
@ -166,7 +171,11 @@ export class AssetService {
? this.getServePath(asset, dto, allowOriginalFile) ? this.getServePath(asset, dto, allowOriginalFile)
: asset.encodedVideoPath || asset.originalPath; : asset.encodedVideoPath || asset.originalPath;
return new ImmichFileResponse({ path: filepath, contentType: mimeTypes.lookup(filepath), cacheControl: true }); return new ImmichFileResponse({
path: filepath,
contentType: mimeTypes.lookup(filepath),
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
});
} }
async getAssetSearchTerm(auth: AuthDto): Promise<string[]> { async getAssetSearchTerm(auth: AuthDto): Promise<string[]> {

View File

@ -32,7 +32,7 @@ import {
TagController, TagController,
UserController, UserController,
} from './controllers'; } from './controllers';
import { ErrorInterceptor, FileServeInterceptor, FileUploadInterceptor } from './interceptors'; import { ErrorInterceptor, FileUploadInterceptor } from './interceptors';
@Module({ @Module({
imports: [ imports: [
@ -66,7 +66,6 @@ import { ErrorInterceptor, FileServeInterceptor, FileUploadInterceptor } from '.
], ],
providers: [ providers: [
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor }, { provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
{ provide: APP_INTERCEPTOR, useClass: FileServeInterceptor },
{ provide: APP_GUARD, useClass: AppGuard }, { provide: APP_GUARD, useClass: AppGuard },
{ provide: IAssetRepository, useClass: AssetRepository }, { provide: IAssetRepository, useClass: AssetRepository },
AppService, AppService,

View File

@ -1,11 +1,15 @@
import { import {
CacheControl,
IMMICH_ACCESS_COOKIE, IMMICH_ACCESS_COOKIE,
IMMICH_API_KEY_HEADER, IMMICH_API_KEY_HEADER,
IMMICH_API_KEY_NAME, IMMICH_API_KEY_NAME,
ImmichFileResponse,
ImmichReadStream, ImmichReadStream,
isConnectionAborted,
serverVersion, serverVersion,
} from '@app/domain'; } from '@app/domain';
import { INestApplication, StreamableFile } from '@nestjs/common'; import { ImmichLogger } from '@app/infra/logger';
import { HttpException, INestApplication, StreamableFile } from '@nestjs/common';
import { import {
DocumentBuilder, DocumentBuilder,
OpenAPIObject, OpenAPIObject,
@ -13,8 +17,11 @@ import {
SwaggerDocumentOptions, SwaggerDocumentOptions,
SwaggerModule, SwaggerModule,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { writeFileSync } from 'fs'; import { writeFileSync } from 'fs';
import path from 'path'; import { access, constants } from 'fs/promises';
import path, { isAbsolute } from 'path';
import { promisify } from 'util';
import { applyDecorators, UsePipes, ValidationPipe } from '@nestjs/common'; import { applyDecorators, UsePipes, ValidationPipe } from '@nestjs/common';
import { Metadata } from './app.guard'; import { Metadata } from './app.guard';
@ -30,6 +37,57 @@ export function UseValidation() {
); );
} }
type SendFile = Parameters<Response['sendFile']>;
type SendFileOptions = SendFile[1];
const logger = new ImmichLogger('SendFile');
export const sendFile = async (
res: Response,
next: NextFunction,
handler: () => Promise<ImmichFileResponse>,
): Promise<void> => {
const _sendFile = (path: string, options: SendFileOptions) =>
promisify<string, SendFileOptions>(res.sendFile).bind(res)(path, options);
try {
const file = await handler();
switch (file.cacheControl) {
case CacheControl.PRIVATE_WITH_CACHE:
res.set('Cache-Control', 'private, max-age=86400, no-transform');
break;
case CacheControl.PRIVATE_WITHOUT_CACHE:
res.set('Cache-Control', 'private, no-cache, no-transform');
break;
}
res.header('Content-Type', file.contentType);
const options: SendFileOptions = { dotfiles: 'allow' };
if (!isAbsolute(file.path)) {
options.root = process.cwd();
}
await access(file.path, constants.R_OK);
return _sendFile(file.path, options);
} catch (error: Error | any) {
// ignore client-closed connection
if (isConnectionAborted(error)) {
return;
}
// log non-http errors
if (error instanceof HttpException === false) {
logger.error(`Unable to send file: ${error.name}`, error.stack);
}
res.header('Cache-Control', 'none');
next(error);
}
};
export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => { export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => {
return new StreamableFile(stream, { type, length }); return new StreamableFile(stream, { type, length });
}; };

View File

@ -12,7 +12,6 @@ import {
BulkIdsDto, BulkIdsDto,
DownloadInfoDto, DownloadInfoDto,
DownloadResponseDto, DownloadResponseDto,
ImmichFileResponse,
MapMarkerDto, MapMarkerDto,
MapMarkerResponseDto, MapMarkerResponseDto,
MemoryLaneDto, MemoryLaneDto,
@ -32,16 +31,19 @@ import {
Get, Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Next,
Param, Param,
Post, Post,
Put, Put,
Query, Query,
Res,
StreamableFile, StreamableFile,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { DeviceIdDto } from '../api-v1/asset/dto/device-id.dto'; import { DeviceIdDto } from '../api-v1/asset/dto/device-id.dto';
import { Auth, Authenticated, FileResponse, SharedLinkRoute } from '../app.guard'; import { Auth, Authenticated, FileResponse, SharedLinkRoute } from '../app.guard';
import { UseValidation, asStreamableFile } from '../app.utils'; import { UseValidation, asStreamableFile, sendFile } from '../app.utils';
import { Route } from '../interceptors'; import { Route } from '../interceptors';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ -98,8 +100,13 @@ export class AssetController {
@Post('download/:id') @Post('download/:id')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@FileResponse() @FileResponse()
downloadFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<ImmichFileResponse> { async downloadFile(
return this.service.downloadFile(auth, id); @Res() res: Response,
@Next() next: NextFunction,
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
) {
await sendFile(res, next, () => this.service.downloadFile(auth, id));
} }
/** /**

View File

@ -12,10 +12,11 @@ import {
PersonStatisticsResponseDto, PersonStatisticsResponseDto,
PersonUpdateDto, PersonUpdateDto,
} from '@app/domain'; } from '@app/domain';
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common'; import { Body, Controller, Get, Next, Param, Post, Put, Query, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { Auth, Authenticated, FileResponse } from '../app.guard'; import { Auth, Authenticated, FileResponse } from '../app.guard';
import { UseValidation } from '../app.utils'; import { UseValidation, sendFile } from '../app.utils';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Person') @ApiTags('Person')
@ -70,8 +71,13 @@ export class PersonController {
@Get(':id/thumbnail') @Get(':id/thumbnail')
@FileResponse() @FileResponse()
getPersonThumbnail(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { async getPersonThumbnail(
return this.service.getThumbnail(auth, id); @Res() res: Response,
@Next() next: NextFunction,
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
) {
await sendFile(res, next, () => this.service.getThumbnail(auth, id));
} }
@Get(':id/assets') @Get(':id/assets')

View File

@ -3,7 +3,6 @@ import {
CreateUserDto as CreateDto, CreateUserDto as CreateDto,
CreateProfileImageDto, CreateProfileImageDto,
CreateProfileImageResponseDto, CreateProfileImageResponseDto,
ImmichFileResponse,
UpdateUserDto as UpdateDto, UpdateUserDto as UpdateDto,
UserResponseDto, UserResponseDto,
UserService, UserService,
@ -13,19 +12,21 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
Header,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
Next,
Param, Param,
Post, Post,
Put, Put,
Query, Query,
Res,
UploadedFile, UploadedFile,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { AdminRoute, Auth, Authenticated, FileResponse } from '../app.guard'; import { AdminRoute, Auth, Authenticated, FileResponse } from '../app.guard';
import { UseValidation } from '../app.utils'; import { UseValidation, sendFile } from '../app.utils';
import { FileUploadInterceptor, Route } from '../interceptors'; import { FileUploadInterceptor, Route } from '../interceptors';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ -93,9 +94,8 @@ export class UserController {
} }
@Get('profile-image/:id') @Get('profile-image/:id')
@Header('Cache-Control', 'private, no-cache, no-transform')
@FileResponse() @FileResponse()
getProfileImage(@Param() { id }: UUIDParamDto): Promise<ImmichFileResponse> { async getProfileImage(@Res() res: Response, @Next() next: NextFunction, @Param() { id }: UUIDParamDto) {
return this.service.getProfileImage(id); await sendFile(res, next, () => this.service.getProfileImage(id));
} }
} }

View File

@ -22,7 +22,7 @@ export class ErrorInterceptor implements NestInterceptor {
if (error instanceof HttpException === false) { if (error instanceof HttpException === false) {
const errorMessage = routeToErrorMessage(context.getHandler().name); const errorMessage = routeToErrorMessage(context.getHandler().name);
if (!isConnectionAborted(error)) { if (!isConnectionAborted(error)) {
this.logger.error(errorMessage, error, error?.errors); this.logger.error(errorMessage, error, error?.errors, error?.stack);
} }
return new InternalServerErrorException(errorMessage); return new InternalServerErrorException(errorMessage);
} else { } else {

View File

@ -1,56 +0,0 @@
import { ImmichFileResponse, isConnectionAborted } from '@app/domain';
import { ImmichLogger } from '@app/infra/logger';
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
import { Response } from 'express';
import { access, constants } from 'fs/promises';
import { isAbsolute } from 'path';
import { Observable, mergeMap } from 'rxjs';
import { promisify } from 'util';
type SendFile = Parameters<Response['sendFile']>;
type SendFileOptions = SendFile[1];
export class FileServeInterceptor implements NestInterceptor {
private logger = new ImmichLogger(FileServeInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
const http = context.switchToHttp();
const res = http.getResponse<Response>();
const sendFile = (path: string, options: SendFileOptions) =>
promisify<string, SendFileOptions>(res.sendFile).bind(res)(path, options);
return next.handle().pipe(
mergeMap(async (file) => {
if (file instanceof ImmichFileResponse === false) {
return file;
}
try {
if (file.cacheControl) {
res.set('Cache-Control', 'private, max-age=86400, no-transform');
}
res.header('Content-Type', file.contentType);
const options: SendFileOptions = { dotfiles: 'allow' };
if (!isAbsolute(file.path)) {
options.root = process.cwd();
}
await access(file.path, constants.R_OK);
return sendFile(file.path, options);
} catch (error: Error | any) {
res.header('Cache-Control', 'none');
if (!isConnectionAborted(error)) {
this.logger.error(`Unable to send file: ${error.name}`, error.stack);
}
// throwing closes the connection and prevents `Error: write EPIPE`
throw error;
}
}),
);
}
}

View File

@ -1,3 +1,2 @@
export * from './error.interceptor'; export * from './error.interceptor';
export * from './file-serve.interceptor';
export * from './file-upload.interceptor'; export * from './file-upload.interceptor';