forked from Cutlery/immich
		
	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:
		
							parent
							
								
									af7c4ae090
								
							
						
					
					
						commit
						cbca69841a
					
				
							
								
								
									
										4
									
								
								cli/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								cli/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @ -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)); | ||||
|         }, | ||||
|         /** | ||||
|  | ||||
							
								
								
									
										2
									
								
								mobile/openapi/doc/AssetApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/doc/AssetApi.md
									
									
									
										generated
									
									
									
								
							| @ -725,7 +725,7 @@ Name | Type | Description  | Notes | ||||
| ### HTTP request headers | ||||
| 
 | ||||
|  - **Content-Type**: Not defined | ||||
|  - **Accept**: image/jpeg, image/webp | ||||
|  - **Accept**: application/octet-stream | ||||
| 
 | ||||
| [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										2
									
								
								mobile/openapi/doc/PersonApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/doc/PersonApi.md
									
									
									
										generated
									
									
									
								
							| @ -343,7 +343,7 @@ Name | Type | Description  | Notes | ||||
| ### HTTP request headers | ||||
| 
 | ||||
|  - **Content-Type**: Not defined | ||||
|  - **Accept**: image/jpeg | ||||
|  - **Accept**: application/octet-stream | ||||
| 
 | ||||
| [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										6
									
								
								mobile/openapi/doc/UserApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								mobile/openapi/doc/UserApi.md
									
									
									
										generated
									
									
									
								
							| @ -343,7 +343,7 @@ This endpoint does not need any parameter. | ||||
| [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) | ||||
| 
 | ||||
| # **getProfileImage** | ||||
| > Object getProfileImage(id) | ||||
| > MultipartFile getProfileImage(id) | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| @ -384,7 +384,7 @@ Name | Type | Description  | Notes | ||||
| 
 | ||||
| ### Return type | ||||
| 
 | ||||
| [**Object**](Object.md) | ||||
| [**MultipartFile**](MultipartFile.md) | ||||
| 
 | ||||
| ### Authorization | ||||
| 
 | ||||
| @ -393,7 +393,7 @@ Name | Type | Description  | Notes | ||||
| ### HTTP request headers | ||||
| 
 | ||||
|  - **Content-Type**: Not defined | ||||
|  - **Accept**: application/json | ||||
|  - **Accept**: application/octet-stream | ||||
| 
 | ||||
| [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										4
									
								
								mobile/openapi/lib/api/user_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								mobile/openapi/lib/api/user_api.dart
									
									
									
										generated
									
									
									
								
							| @ -327,7 +327,7 @@ class UserApi { | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [String] id (required): | ||||
|   Future<Object?> getProfileImage(String id,) async { | ||||
|   Future<MultipartFile?> getProfileImage(String id,) async { | ||||
|     final response = await getProfileImageWithHttpInfo(id,); | ||||
|     if (response.statusCode >= HttpStatus.badRequest) { | ||||
|       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||
| @ -336,7 +336,7 @@ class UserApi { | ||||
|     // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" | ||||
|     // FormatException when trying to decode an empty string. | ||||
|     if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { | ||||
|       return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'Object',) as Object; | ||||
|       return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile; | ||||
|      | ||||
|     } | ||||
|     return null; | ||||
|  | ||||
							
								
								
									
										2
									
								
								mobile/openapi/test/user_api_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/test/user_api_test.dart
									
									
									
										generated
									
									
									
								
							| @ -47,7 +47,7 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     //Future<Object> getProfileImage(String id) async | ||||
|     //Future<MultipartFile> getProfileImage(String id) async | ||||
|     test('test getProfileImage', () async { | ||||
|       // TODO | ||||
|     }); | ||||
|  | ||||
| @ -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" | ||||
|                 } | ||||
|               } | ||||
|             }, | ||||
|  | ||||
| @ -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 () => { | ||||
|  | ||||
| @ -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> { | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
| @ -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'])); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
| @ -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[]> { | ||||
|  | ||||
| @ -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'); | ||||
|     }); | ||||
|   }); | ||||
| 
 | ||||
|  | ||||
| @ -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>) { | ||||
|  | ||||
| @ -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') | ||||
|  | ||||
| @ -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; | ||||
|  | ||||
| @ -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']); | ||||
|  | ||||
| @ -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, | ||||
|  | ||||
| @ -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); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|  | ||||
| @ -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') | ||||
|  | ||||
| @ -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); | ||||
|   } | ||||
| } | ||||
|  | ||||
							
								
								
									
										55
									
								
								server/src/immich/interceptors/file-serve.interceptor.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								server/src/immich/interceptors/file-serve.interceptor.ts
									
									
									
									
									
										Normal 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; | ||||
|         } | ||||
|       }), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @ -1,2 +1,3 @@ | ||||
| export * from './error.interceptor'; | ||||
| export * from './file.interceptor'; | ||||
| export * from './file-serve.interceptor'; | ||||
| export * from './file-upload.interceptor'; | ||||
|  | ||||
							
								
								
									
										4
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										4
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @ -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)); | ||||
|         }, | ||||
|         /** | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user