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:
parent
ffc31f034c
commit
d3e1572229
@ -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,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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']));
|
||||||
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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')
|
||||||
|
@ -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[]> {
|
||||||
|
@ -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,
|
||||||
|
@ -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 });
|
||||||
};
|
};
|
||||||
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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')
|
||||||
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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';
|
||||||
|
Loading…
x
Reference in New Issue
Block a user