1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 15:11:58 +00:00

refactor(server): immich file responses (#5641)

* refactor(server): immich file response

* chore: open api

* chore: tests

* chore: fix logger import

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen 2023-12-12 09:58:25 -05:00 committed by GitHub
parent af7c4ae090
commit cbca69841a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 178 additions and 146 deletions

View file

@ -17843,7 +17843,7 @@ export const UserApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getProfileImage(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> {
async getProfileImage(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<File>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getProfileImage(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
@ -17945,7 +17945,7 @@ export const UserApiFactory = function (configuration?: Configuration, basePath?
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getProfileImage(requestParameters: UserApiGetProfileImageRequest, options?: AxiosRequestConfig): AxiosPromise<object> {
getProfileImage(requestParameters: UserApiGetProfileImageRequest, options?: AxiosRequestConfig): AxiosPromise<File> {
return localVarFp.getProfileImage(requestParameters.id, options).then((request) => request(axios, basePath));
},
/**

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1465,6 +1465,15 @@
"get": {
"operationId": "serveFile",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "isThumb",
"required": false,
@ -1483,15 +1492,6 @@
"type": "boolean"
}
},
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "key",
"required": false,
@ -1926,13 +1926,7 @@
"responses": {
"200": {
"content": {
"image/jpeg": {
"schema": {
"format": "binary",
"type": "string"
}
},
"image/webp": {
"application/octet-stream": {
"schema": {
"format": "binary",
"type": "string"
@ -4499,7 +4493,7 @@
"responses": {
"200": {
"content": {
"image/jpeg": {
"application/octet-stream": {
"schema": {
"format": "binary",
"type": "string"
@ -6080,9 +6074,10 @@
"responses": {
"200": {
"content": {
"application/json": {
"application/octet-stream": {
"schema": {
"type": "object"
"format": "binary",
"type": "string"
}
}
},

View file

@ -16,6 +16,7 @@ import {
} from '@test';
import { when } from 'jest-when';
import { Readable } from 'stream';
import { ImmichFileResponse } from '../domain.util';
import { JobName } from '../job';
import {
AssetStats,
@ -474,15 +475,16 @@ describe(AssetService.name, () => {
});
it('should download a file', async () => {
const stream = new Readable();
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
assetMock.getByIds.mockResolvedValue([assetStub.image]);
storageMock.createReadStream.mockResolvedValue({ stream });
await expect(sut.downloadFile(authStub.admin, 'asset-1')).resolves.toEqual({ stream });
expect(storageMock.createReadStream).toHaveBeenCalledWith(assetStub.image.originalPath, 'image/jpeg');
await expect(sut.downloadFile(authStub.admin, 'asset-1')).resolves.toEqual(
new ImmichFileResponse({
path: '/original/path.jpg',
contentType: 'image/jpeg',
cacheControl: false,
}),
);
});
it('should download an archive', async () => {

View file

@ -7,7 +7,7 @@ import sanitize from 'sanitize-filename';
import { AccessCore, Permission } from '../access';
import { AuthDto } from '../auth';
import { mimeTypes } from '../domain.constant';
import { HumanReadableSize, usePagination } from '../domain.util';
import { HumanReadableSize, ImmichFileResponse, usePagination } from '../domain.util';
import { IAssetDeletionJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
import {
CommunicationEvent,
@ -274,7 +274,7 @@ export class AssetService {
return { ...options, userIds };
}
async downloadFile(auth: AuthDto, id: string): Promise<ImmichReadStream> {
async downloadFile(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, id);
const [asset] = await this.assetRepository.getByIds([id]);
@ -286,7 +286,11 @@ export class AssetService {
throw new BadRequestException('Asset is offline');
}
return this.storageRepository.createReadStream(asset.originalPath, mimeTypes.lookup(asset.originalPath));
return new ImmichFileResponse({
path: asset.originalPath,
contentType: mimeTypes.lookup(asset.originalPath),
cacheControl: false,
});
}
async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise<DownloadResponseDto> {

View file

@ -16,6 +16,16 @@ import { CronJob } from 'cron';
import { basename, extname } from 'node:path';
import sanitize from 'sanitize-filename';
export class ImmichFileResponse {
public readonly path!: string;
public readonly contentType!: string;
public readonly cacheControl!: boolean;
constructor(response: ImmichFileResponse) {
Object.assign(this, response);
}
}
export interface OpenGraphTags {
title: string;
description: string;

View file

@ -18,6 +18,7 @@ import {
personStub,
} from '@test';
import { BulkIdErrorReason } from '../asset';
import { ImmichFileResponse } from '../domain.util';
import { JobName } from '../job';
import {
IAssetRepository,
@ -203,8 +204,13 @@ describe(PersonService.name, () => {
it('should serve the thumbnail', async () => {
personMock.getById.mockResolvedValue(personStub.noName);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await sut.getThumbnail(authStub.admin, 'person-1');
expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/thumbnail.jpg', 'image/jpeg');
await expect(sut.getThumbnail(authStub.admin, 'person-1')).resolves.toEqual(
new ImmichFileResponse({
path: '/path/to/thumbnail.jpg',
contentType: 'image/jpeg',
cacheControl: true,
}),
);
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
});
});

View file

@ -5,7 +5,7 @@ import { AccessCore, Permission } from '../access';
import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset';
import { AuthDto } from '../auth';
import { mimeTypes } from '../domain.constant';
import { usePagination } from '../domain.util';
import { ImmichFileResponse, usePagination } from '../domain.util';
import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
import { FACE_THUMBNAIL_SIZE } from '../media';
import {
@ -20,7 +20,6 @@ import {
ISmartInfoRepository,
IStorageRepository,
ISystemConfigRepository,
ImmichReadStream,
UpdateFacesData,
WithoutProperty,
} from '../repositories';
@ -173,14 +172,18 @@ export class PersonService {
return this.repository.getStatistics(id);
}
async getThumbnail(auth: AuthDto, id: string): Promise<ImmichReadStream> {
async getThumbnail(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
await this.access.requirePermission(auth, Permission.PERSON_READ, id);
const person = await this.repository.getById(id);
if (!person || !person.thumbnailPath) {
throw new NotFoundException();
}
return this.storageRepository.createReadStream(person.thumbnailPath, mimeTypes.lookup(person.thumbnailPath));
return new ImmichFileResponse({
path: person.thumbnailPath,
contentType: mimeTypes.lookup(person.thumbnailPath),
cacheControl: true,
});
}
async getAssets(auth: AuthDto, id: string): Promise<AssetResponseDto[]> {

View file

@ -17,7 +17,7 @@ import {
userStub,
} from '@test';
import { when } from 'jest-when';
import { Readable } from 'stream';
import { ImmichFileResponse } from '../domain.util';
import { JobName } from '../job';
import {
IAlbumRepository,
@ -390,15 +390,17 @@ describe(UserService.name, () => {
});
it('should return the profile picture', async () => {
const stream = new Readable();
userMock.get.mockResolvedValue(userStub.profilePath);
storageMock.createReadStream.mockResolvedValue({ stream });
await expect(sut.getProfileImage(userStub.profilePath.id)).resolves.toEqual({ stream });
await expect(sut.getProfileImage(userStub.profilePath.id)).resolves.toEqual(
new ImmichFileResponse({
path: '/path/to/profile.jpg',
contentType: 'image/jpeg',
cacheControl: false,
}),
);
expect(userMock.get).toHaveBeenCalledWith(userStub.profilePath.id, {});
expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/profile.jpg', 'image/jpeg');
});
});

View file

@ -2,6 +2,7 @@ import { UserEntity } from '@app/infra/entities';
import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { randomBytes } from 'crypto';
import { AuthDto } from '../auth';
import { ImmichFileResponse } from '../domain.util';
import { IEntityJob, JobName } from '../job';
import {
IAlbumRepository,
@ -11,7 +12,6 @@ import {
ILibraryRepository,
IStorageRepository,
IUserRepository,
ImmichReadStream,
UserFindOptions,
} from '../repositories';
import { StorageCore, StorageFolder } from '../storage';
@ -99,12 +99,17 @@ export class UserService {
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [user.profileImagePath] } });
}
async getProfileImage(id: string): Promise<ImmichReadStream> {
async getProfileImage(id: string): Promise<ImmichFileResponse> {
const user = await this.findOrFail(id, {});
if (!user.profileImagePath) {
throw new NotFoundException('User does not have a profile image');
}
return this.storageRepository.createReadStream(user.profileImagePath, 'image/jpeg');
return new ImmichFileResponse({
path: user.profileImagePath,
contentType: 'image/jpeg',
cacheControl: false,
});
}
async resetAdminPassword(ask: (admin: UserResponseDto) => Promise<string | undefined>) {

View file

@ -14,9 +14,9 @@ import {
UseInterceptors,
ValidationPipe,
} from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiBody, ApiConsumes, ApiHeader, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Response as Res } from 'express';
import { Auth, Authenticated, SharedLinkRoute } from '../../app.guard';
import { Auth, Authenticated, FileResponse, SharedLinkRoute } from '../../app.guard';
import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
import { FileUploadInterceptor, ImmichFile, Route, mapToUploadFile } from '../../interceptors';
import FileNotEmptyValidator from '../validation/file-not-empty-validator';
@ -83,35 +83,24 @@ export class AssetController {
@SharedLinkRoute()
@Get('/file/:id')
@ApiOkResponse({
content: {
'application/octet-stream': { schema: { type: 'string', format: 'binary' } },
},
})
async serveFile(
@FileResponse()
serveFile(
@Auth() auth: AuthDto,
@Response() res: Res,
@Query(new ValidationPipe({ transform: true })) query: ServeFileDto,
@Param() { id }: UUIDParamDto,
@Query(new ValidationPipe({ transform: true })) dto: ServeFileDto,
) {
await this.assetService.serveFile(auth, id, query, res);
return this.assetService.serveFile(auth, id, dto);
}
@SharedLinkRoute()
@Get('/thumbnail/:id')
@ApiOkResponse({
content: {
'image/jpeg': { schema: { type: 'string', format: 'binary' } },
'image/webp': { schema: { type: 'string', format: 'binary' } },
},
})
async getAssetThumbnail(
@FileResponse()
getAssetThumbnail(
@Auth() auth: AuthDto,
@Response() res: Res,
@Param() { id }: UUIDParamDto,
@Query(new ValidationPipe({ transform: true })) query: GetAssetThumbnailDto,
@Query(new ValidationPipe({ transform: true })) dto: GetAssetThumbnailDto,
) {
await this.assetService.serveThumbnail(auth, id, query, res);
return this.assetService.serveThumbnail(auth, id, dto);
}
@Get('/curated-objects')

View file

@ -6,7 +6,7 @@ import {
IAccessRepository,
IJobRepository,
ILibraryRepository,
isConnectionAborted,
ImmichFileResponse,
JobName,
mapAsset,
mimeTypes,
@ -16,12 +16,7 @@ import {
} from '@app/domain';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, LibraryType } from '@app/infra/entities';
import { Inject, Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common';
import { Response as Res, Response } from 'express';
import { constants } from 'fs';
import fs from 'fs/promises';
import path from 'path';
import { QueryFailedError } from 'typeorm';
import { promisify } from 'util';
import { IAssetRepository } from './asset-repository';
import { AssetCore } from './asset.core';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
@ -41,13 +36,6 @@ import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-as
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
type SendFile = Parameters<Response['sendFile']>;
type SendFileOptions = SendFile[1];
// TODO: move file sending logic to an interceptor
const sendFile = (res: Response, path: string, options: SendFileOptions) =>
promisify<string, SendFileOptions>(res.sendFile).bind(res)(path, options);
@Injectable()
export class AssetService {
readonly logger = new Logger(AssetService.name);
@ -148,7 +136,7 @@ export class AssetService {
}
}
async serveThumbnail(auth: AuthDto, assetId: string, query: GetAssetThumbnailDto, res: Res) {
async serveThumbnail(auth: AuthDto, assetId: string, dto: GetAssetThumbnailDto): Promise<ImmichFileResponse> {
await this.access.requirePermission(auth, Permission.ASSET_VIEW, assetId);
const asset = await this._assetRepository.get(assetId);
@ -156,19 +144,12 @@ export class AssetService {
throw new NotFoundException('Asset not found');
}
try {
await this.sendFile(res, this.getThumbnailPath(asset, query.format));
} catch (e) {
res.header('Cache-Control', 'none');
this.logger.error(`Cannot create read stream for asset ${asset.id}`, 'getAssetThumbnail');
throw new InternalServerErrorException(
`Cannot read thumbnail file for asset ${asset.id} - contact your administrator`,
{ cause: e as Error },
);
}
const filepath = this.getThumbnailPath(asset, dto.format);
return new ImmichFileResponse({ path: filepath, contentType: mimeTypes.lookup(filepath), cacheControl: true });
}
public async serveFile(auth: AuthDto, assetId: string, query: ServeFileDto, res: Res) {
public async serveFile(auth: AuthDto, assetId: string, dto: ServeFileDto): Promise<ImmichFileResponse> {
// this is not quite right as sometimes this returns the original still
await this.access.requirePermission(auth, Permission.ASSET_VIEW, assetId);
@ -181,10 +162,10 @@ export class AssetService {
const filepath =
asset.type === AssetType.IMAGE
? this.getServePath(asset, query, allowOriginalFile)
? this.getServePath(asset, dto, allowOriginalFile)
: asset.encodedVideoPath || asset.originalPath;
await this.sendFile(res, filepath);
return new ImmichFileResponse({ path: filepath, contentType: mimeTypes.lookup(filepath), cacheControl: true });
}
async getAssetSearchTerm(auth: AuthDto): Promise<string[]> {
@ -292,13 +273,13 @@ export class AssetService {
}
}
private getServePath(asset: AssetEntity, query: ServeFileDto, allowOriginalFile: boolean): string {
private getServePath(asset: AssetEntity, dto: ServeFileDto, allowOriginalFile: boolean): string {
const mimeType = mimeTypes.lookup(asset.originalPath);
/**
* Serve file viewer on the web
*/
if (query.isWeb && mimeType != 'image/gif') {
if (dto.isWeb && mimeType != 'image/gif') {
if (!asset.resizePath) {
this.logger.error('Error serving IMAGE asset for web');
throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile');
@ -310,7 +291,7 @@ export class AssetService {
/**
* Serve thumbnail image for both web and mobile app
*/
if ((!query.isThumb && allowOriginalFile) || (query.isWeb && mimeType === 'image/gif')) {
if ((!dto.isThumb && allowOriginalFile) || (dto.isWeb && mimeType === 'image/gif')) {
return asset.originalPath;
}
@ -325,27 +306,6 @@ export class AssetService {
return asset.resizePath;
}
private async sendFile(res: Res, filepath: string): Promise<void> {
await fs.access(filepath, constants.R_OK);
const options: SendFileOptions = { dotfiles: 'allow' };
if (!path.isAbsolute(filepath)) {
options.root = process.cwd();
}
res.set('Cache-Control', 'private, max-age=86400, no-transform');
res.header('Content-Type', mimeTypes.lookup(filepath));
try {
await sendFile(res, filepath, options);
} catch (error: Error | any) {
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;
}
}
private async getLibraryId(auth: AuthDto, libraryId?: string) {
if (libraryId) {
return libraryId;

View file

@ -9,7 +9,7 @@ import {
createParamDecorator,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ApiBearerAuth, ApiCookieAuth, ApiQuery, ApiSecurity } from '@nestjs/swagger';
import { ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger';
import { Request } from 'express';
import { UAParser } from 'ua-parser-js';
@ -54,6 +54,11 @@ export const Auth = createParamDecorator((data, ctx: ExecutionContext): AuthDto
return ctx.switchToHttp().getRequest<{ user: AuthDto }>().user;
});
export const FileResponse = () =>
ApiOkResponse({
content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } },
});
export const GetLoginDetails = createParamDecorator((data, ctx: ExecutionContext): LoginDetails => {
const req = ctx.switchToHttp().getRequest<Request>();
const userAgent = UAParser(req.headers['user-agent']);

View file

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

View file

@ -12,6 +12,7 @@ import {
BulkIdsDto,
DownloadInfoDto,
DownloadResponseDto,
ImmichFileResponse,
MapMarkerDto,
MapMarkerResponseDto,
MemoryLaneDto,
@ -37,9 +38,9 @@ import {
Query,
StreamableFile,
} from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiTags } from '@nestjs/swagger';
import { DeviceIdDto } from '../api-v1/asset/dto/device-id.dto';
import { Auth, Authenticated, SharedLinkRoute } from '../app.guard';
import { Auth, Authenticated, FileResponse, SharedLinkRoute } from '../app.guard';
import { UseValidation, asStreamableFile } from '../app.utils';
import { Route } from '../interceptors';
import { UUIDParamDto } from './dto/uuid-param.dto';
@ -88,7 +89,7 @@ export class AssetController {
@SharedLinkRoute()
@Post('download/archive')
@HttpCode(HttpStatus.OK)
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
@FileResponse()
downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise<StreamableFile> {
return this.service.downloadArchive(auth, dto).then(asStreamableFile);
}
@ -96,9 +97,9 @@ export class AssetController {
@SharedLinkRoute()
@Post('download/:id')
@HttpCode(HttpStatus.OK)
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
downloadFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
return this.service.downloadFile(auth, id).then(asStreamableFile);
@FileResponse()
downloadFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<ImmichFileResponse> {
return this.service.downloadFile(auth, id);
}
/**

View file

@ -3,7 +3,6 @@ import {
AssetResponseDto,
AuthDto,
BulkIdResponseDto,
ImmichReadStream,
MergePersonDto,
PeopleResponseDto,
PeopleUpdateDto,
@ -13,16 +12,12 @@ import {
PersonStatisticsResponseDto,
PersonUpdateDto,
} from '@app/domain';
import { Body, Controller, Get, Param, Post, Put, Query, StreamableFile } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { Auth, Authenticated } from '../app.guard';
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Auth, Authenticated, FileResponse } from '../app.guard';
import { UseValidation } from '../app.utils';
import { UUIDParamDto } from './dto/uuid-param.dto';
function asStreamableFile({ stream, type, length }: ImmichReadStream) {
return new StreamableFile(stream, { type, length });
}
@ApiTags('Person')
@Controller('person')
@Authenticated()
@ -74,13 +69,9 @@ export class PersonController {
}
@Get(':id/thumbnail')
@ApiOkResponse({
content: {
'image/jpeg': { schema: { type: 'string', format: 'binary' } },
},
})
@FileResponse()
getPersonThumbnail(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
return this.service.getThumbnail(auth, id).then(asStreamableFile);
return this.service.getThumbnail(auth, id);
}
@Get(':id/assets')

View file

@ -3,6 +3,7 @@ import {
CreateUserDto as CreateDto,
CreateProfileImageDto,
CreateProfileImageResponseDto,
ImmichFileResponse,
UpdateUserDto as UpdateDto,
UserResponseDto,
UserService,
@ -23,8 +24,8 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { AdminRoute, Auth, Authenticated } from '../app.guard';
import { UseValidation, asStreamableFile } from '../app.utils';
import { AdminRoute, Auth, Authenticated, FileResponse } from '../app.guard';
import { UseValidation } from '../app.utils';
import { FileUploadInterceptor, Route } from '../interceptors';
import { UUIDParamDto } from './dto/uuid-param.dto';
@ -93,7 +94,8 @@ export class UserController {
@Get('profile-image/:id')
@Header('Cache-Control', 'private, no-cache, no-transform')
getProfileImage(@Param() { id }: UUIDParamDto): Promise<any> {
return this.service.getProfileImage(id).then(asStreamableFile);
@FileResponse()
getProfileImage(@Param() { id }: UUIDParamDto): Promise<ImmichFileResponse> {
return this.service.getProfileImage(id);
}
}

View file

@ -0,0 +1,55 @@
import { ImmichFileResponse, isConnectionAborted } from '@app/domain';
import { CallHandler, ExecutionContext, Logger, 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 Logger(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,2 +1,3 @@
export * from './error.interceptor';
export * from './file.interceptor';
export * from './file-serve.interceptor';
export * from './file-upload.interceptor';

View file

@ -17843,7 +17843,7 @@ export const UserApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getProfileImage(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> {
async getProfileImage(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<File>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getProfileImage(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
@ -17945,7 +17945,7 @@ export const UserApiFactory = function (configuration?: Configuration, basePath?
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getProfileImage(requestParameters: UserApiGetProfileImageRequest, options?: AxiosRequestConfig): AxiosPromise<object> {
getProfileImage(requestParameters: UserApiGetProfileImageRequest, options?: AxiosRequestConfig): AxiosPromise<File> {
return localVarFp.getProfileImage(requestParameters.id, options).then((request) => request(axios, basePath));
},
/**