mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-30 18:35:00 -04: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:
		
							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. |          * @param {*} [options] Override http request option. | ||||||
|          * @throws {RequiredError} |          * @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); |             const localVarAxiosArgs = await localVarAxiosParamCreator.getProfileImage(id, options); | ||||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); |             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||||
|         }, |         }, | ||||||
| @ -17945,7 +17945,7 @@ export const UserApiFactory = function (configuration?: Configuration, basePath? | |||||||
|          * @param {*} [options] Override http request option. |          * @param {*} [options] Override http request option. | ||||||
|          * @throws {RequiredError} |          * @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)); |             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 | ### HTTP request headers | ||||||
| 
 | 
 | ||||||
|  - **Content-Type**: Not defined |  - **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) | [[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 | ### HTTP request headers | ||||||
| 
 | 
 | ||||||
|  - **Content-Type**: Not defined |  - **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) | [[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) | [[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** | # **getProfileImage** | ||||||
| > Object getProfileImage(id) | > MultipartFile getProfileImage(id) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @ -384,7 +384,7 @@ Name | Type | Description  | Notes | |||||||
| 
 | 
 | ||||||
| ### Return type | ### Return type | ||||||
| 
 | 
 | ||||||
| [**Object**](Object.md) | [**MultipartFile**](MultipartFile.md) | ||||||
| 
 | 
 | ||||||
| ### Authorization | ### Authorization | ||||||
| 
 | 
 | ||||||
| @ -393,7 +393,7 @@ Name | Type | Description  | Notes | |||||||
| ### HTTP request headers | ### HTTP request headers | ||||||
| 
 | 
 | ||||||
|  - **Content-Type**: Not defined |  - **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) | [[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: |   /// Parameters: | ||||||
|   /// |   /// | ||||||
|   /// * [String] id (required): |   /// * [String] id (required): | ||||||
|   Future<Object?> getProfileImage(String id,) async { |   Future<MultipartFile?> getProfileImage(String id,) async { | ||||||
|     final response = await getProfileImageWithHttpInfo(id,); |     final response = await getProfileImageWithHttpInfo(id,); | ||||||
|     if (response.statusCode >= HttpStatus.badRequest) { |     if (response.statusCode >= HttpStatus.badRequest) { | ||||||
|       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); |       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" |     // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" | ||||||
|     // FormatException when trying to decode an empty string. |     // FormatException when trying to decode an empty string. | ||||||
|     if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { |     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; |     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 |       // TODO | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     //Future<Object> getProfileImage(String id) async |     //Future<MultipartFile> getProfileImage(String id) async | ||||||
|     test('test getProfileImage', () async { |     test('test getProfileImage', () async { | ||||||
|       // TODO |       // TODO | ||||||
|     }); |     }); | ||||||
|  | |||||||
| @ -1465,6 +1465,15 @@ | |||||||
|       "get": { |       "get": { | ||||||
|         "operationId": "serveFile", |         "operationId": "serveFile", | ||||||
|         "parameters": [ |         "parameters": [ | ||||||
|  |           { | ||||||
|  |             "name": "id", | ||||||
|  |             "required": true, | ||||||
|  |             "in": "path", | ||||||
|  |             "schema": { | ||||||
|  |               "format": "uuid", | ||||||
|  |               "type": "string" | ||||||
|  |             } | ||||||
|  |           }, | ||||||
|           { |           { | ||||||
|             "name": "isThumb", |             "name": "isThumb", | ||||||
|             "required": false, |             "required": false, | ||||||
| @ -1483,15 +1492,6 @@ | |||||||
|               "type": "boolean" |               "type": "boolean" | ||||||
|             } |             } | ||||||
|           }, |           }, | ||||||
|           { |  | ||||||
|             "name": "id", |  | ||||||
|             "required": true, |  | ||||||
|             "in": "path", |  | ||||||
|             "schema": { |  | ||||||
|               "format": "uuid", |  | ||||||
|               "type": "string" |  | ||||||
|             } |  | ||||||
|           }, |  | ||||||
|           { |           { | ||||||
|             "name": "key", |             "name": "key", | ||||||
|             "required": false, |             "required": false, | ||||||
| @ -1926,13 +1926,7 @@ | |||||||
|         "responses": { |         "responses": { | ||||||
|           "200": { |           "200": { | ||||||
|             "content": { |             "content": { | ||||||
|               "image/jpeg": { |               "application/octet-stream": { | ||||||
|                 "schema": { |  | ||||||
|                   "format": "binary", |  | ||||||
|                   "type": "string" |  | ||||||
|                 } |  | ||||||
|               }, |  | ||||||
|               "image/webp": { |  | ||||||
|                 "schema": { |                 "schema": { | ||||||
|                   "format": "binary", |                   "format": "binary", | ||||||
|                   "type": "string" |                   "type": "string" | ||||||
| @ -4499,7 +4493,7 @@ | |||||||
|         "responses": { |         "responses": { | ||||||
|           "200": { |           "200": { | ||||||
|             "content": { |             "content": { | ||||||
|               "image/jpeg": { |               "application/octet-stream": { | ||||||
|                 "schema": { |                 "schema": { | ||||||
|                   "format": "binary", |                   "format": "binary", | ||||||
|                   "type": "string" |                   "type": "string" | ||||||
| @ -6080,9 +6074,10 @@ | |||||||
|         "responses": { |         "responses": { | ||||||
|           "200": { |           "200": { | ||||||
|             "content": { |             "content": { | ||||||
|               "application/json": { |               "application/octet-stream": { | ||||||
|                 "schema": { |                 "schema": { | ||||||
|                   "type": "object" |                   "format": "binary", | ||||||
|  |                   "type": "string" | ||||||
|                 } |                 } | ||||||
|               } |               } | ||||||
|             }, |             }, | ||||||
|  | |||||||
| @ -16,6 +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 { JobName } from '../job'; | import { JobName } from '../job'; | ||||||
| import { | import { | ||||||
|   AssetStats, |   AssetStats, | ||||||
| @ -474,15 +475,16 @@ describe(AssetService.name, () => { | |||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should download a file', async () => { |     it('should download a file', async () => { | ||||||
|       const stream = new Readable(); |  | ||||||
| 
 |  | ||||||
|       accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); |       accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); | ||||||
|       assetMock.getByIds.mockResolvedValue([assetStub.image]); |       assetMock.getByIds.mockResolvedValue([assetStub.image]); | ||||||
|       storageMock.createReadStream.mockResolvedValue({ stream }); |  | ||||||
| 
 | 
 | ||||||
|       await expect(sut.downloadFile(authStub.admin, 'asset-1')).resolves.toEqual({ stream }); |       await expect(sut.downloadFile(authStub.admin, 'asset-1')).resolves.toEqual( | ||||||
| 
 |         new ImmichFileResponse({ | ||||||
|       expect(storageMock.createReadStream).toHaveBeenCalledWith(assetStub.image.originalPath, 'image/jpeg'); |           path: '/original/path.jpg', | ||||||
|  |           contentType: 'image/jpeg', | ||||||
|  |           cacheControl: false, | ||||||
|  |         }), | ||||||
|  |       ); | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should download an archive', async () => { |     it('should download an archive', async () => { | ||||||
|  | |||||||
| @ -7,7 +7,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, usePagination } from '../domain.util'; | import { 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 { | ||||||
|   CommunicationEvent, |   CommunicationEvent, | ||||||
| @ -274,7 +274,7 @@ export class AssetService { | |||||||
| 
 | 
 | ||||||
|     return { ...options, userIds }; |     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); |     await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, id); | ||||||
| 
 | 
 | ||||||
|     const [asset] = await this.assetRepository.getByIds([id]); |     const [asset] = await this.assetRepository.getByIds([id]); | ||||||
| @ -286,7 +286,11 @@ export class AssetService { | |||||||
|       throw new BadRequestException('Asset is offline'); |       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> { |   async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise<DownloadResponseDto> { | ||||||
|  | |||||||
| @ -16,6 +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 class ImmichFileResponse { | ||||||
|  |   public readonly path!: string; | ||||||
|  |   public readonly contentType!: string; | ||||||
|  |   public readonly cacheControl!: boolean; | ||||||
|  | 
 | ||||||
|  |   constructor(response: ImmichFileResponse) { | ||||||
|  |     Object.assign(this, response); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export interface OpenGraphTags { | export interface OpenGraphTags { | ||||||
|   title: string; |   title: string; | ||||||
|   description: string; |   description: string; | ||||||
|  | |||||||
| @ -18,6 +18,7 @@ import { | |||||||
|   personStub, |   personStub, | ||||||
| } from '@test'; | } from '@test'; | ||||||
| import { BulkIdErrorReason } from '../asset'; | import { BulkIdErrorReason } from '../asset'; | ||||||
|  | import { ImmichFileResponse } from '../domain.util'; | ||||||
| import { JobName } from '../job'; | import { JobName } from '../job'; | ||||||
| import { | import { | ||||||
|   IAssetRepository, |   IAssetRepository, | ||||||
| @ -203,8 +204,13 @@ describe(PersonService.name, () => { | |||||||
|     it('should serve the thumbnail', async () => { |     it('should serve the thumbnail', async () => { | ||||||
|       personMock.getById.mockResolvedValue(personStub.noName); |       personMock.getById.mockResolvedValue(personStub.noName); | ||||||
|       accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); |       accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1'])); | ||||||
|       await sut.getThumbnail(authStub.admin, 'person-1'); |       await expect(sut.getThumbnail(authStub.admin, 'person-1')).resolves.toEqual( | ||||||
|       expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/thumbnail.jpg', 'image/jpeg'); |         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'])); |       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 { 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 { usePagination } from '../domain.util'; | import { 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 { | ||||||
| @ -20,7 +20,6 @@ import { | |||||||
|   ISmartInfoRepository, |   ISmartInfoRepository, | ||||||
|   IStorageRepository, |   IStorageRepository, | ||||||
|   ISystemConfigRepository, |   ISystemConfigRepository, | ||||||
|   ImmichReadStream, |  | ||||||
|   UpdateFacesData, |   UpdateFacesData, | ||||||
|   WithoutProperty, |   WithoutProperty, | ||||||
| } from '../repositories'; | } from '../repositories'; | ||||||
| @ -173,14 +172,18 @@ export class PersonService { | |||||||
|     return this.repository.getStatistics(id); |     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); |     await this.access.requirePermission(auth, Permission.PERSON_READ, id); | ||||||
|     const person = await this.repository.getById(id); |     const person = await this.repository.getById(id); | ||||||
|     if (!person || !person.thumbnailPath) { |     if (!person || !person.thumbnailPath) { | ||||||
|       throw new NotFoundException(); |       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[]> { |   async getAssets(auth: AuthDto, id: string): Promise<AssetResponseDto[]> { | ||||||
|  | |||||||
| @ -17,7 +17,7 @@ import { | |||||||
|   userStub, |   userStub, | ||||||
| } from '@test'; | } from '@test'; | ||||||
| import { when } from 'jest-when'; | import { when } from 'jest-when'; | ||||||
| import { Readable } from 'stream'; | import { ImmichFileResponse } from '../domain.util'; | ||||||
| import { JobName } from '../job'; | import { JobName } from '../job'; | ||||||
| import { | import { | ||||||
|   IAlbumRepository, |   IAlbumRepository, | ||||||
| @ -390,15 +390,17 @@ describe(UserService.name, () => { | |||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     it('should return the profile picture', async () => { |     it('should return the profile picture', async () => { | ||||||
|       const stream = new Readable(); |  | ||||||
| 
 |  | ||||||
|       userMock.get.mockResolvedValue(userStub.profilePath); |       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(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 { BadRequestException, ForbiddenException, Inject, Injectable, Logger, 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 { IEntityJob, JobName } from '../job'; | import { IEntityJob, JobName } from '../job'; | ||||||
| import { | import { | ||||||
|   IAlbumRepository, |   IAlbumRepository, | ||||||
| @ -11,7 +12,6 @@ import { | |||||||
|   ILibraryRepository, |   ILibraryRepository, | ||||||
|   IStorageRepository, |   IStorageRepository, | ||||||
|   IUserRepository, |   IUserRepository, | ||||||
|   ImmichReadStream, |  | ||||||
|   UserFindOptions, |   UserFindOptions, | ||||||
| } from '../repositories'; | } from '../repositories'; | ||||||
| import { StorageCore, StorageFolder } from '../storage'; | import { StorageCore, StorageFolder } from '../storage'; | ||||||
| @ -99,12 +99,17 @@ export class UserService { | |||||||
|     await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [user.profileImagePath] } }); |     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, {}); |     const user = await this.findOrFail(id, {}); | ||||||
|     if (!user.profileImagePath) { |     if (!user.profileImagePath) { | ||||||
|       throw new NotFoundException('User does not have a profile image'); |       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>) { |   async resetAdminPassword(ask: (admin: UserResponseDto) => Promise<string | undefined>) { | ||||||
|  | |||||||
| @ -14,9 +14,9 @@ import { | |||||||
|   UseInterceptors, |   UseInterceptors, | ||||||
|   ValidationPipe, |   ValidationPipe, | ||||||
| } from '@nestjs/common'; | } 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 { 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 { 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'; | ||||||
| @ -83,35 +83,24 @@ export class AssetController { | |||||||
| 
 | 
 | ||||||
|   @SharedLinkRoute() |   @SharedLinkRoute() | ||||||
|   @Get('/file/:id') |   @Get('/file/:id') | ||||||
|   @ApiOkResponse({ |   @FileResponse() | ||||||
|     content: { |   serveFile( | ||||||
|       'application/octet-stream': { schema: { type: 'string', format: 'binary' } }, |  | ||||||
|     }, |  | ||||||
|   }) |  | ||||||
|   async serveFile( |  | ||||||
|     @Auth() auth: AuthDto, |     @Auth() auth: AuthDto, | ||||||
|     @Response() res: Res, |  | ||||||
|     @Query(new ValidationPipe({ transform: true })) query: ServeFileDto, |  | ||||||
|     @Param() { id }: UUIDParamDto, |     @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() |   @SharedLinkRoute() | ||||||
|   @Get('/thumbnail/:id') |   @Get('/thumbnail/:id') | ||||||
|   @ApiOkResponse({ |   @FileResponse() | ||||||
|     content: { |   getAssetThumbnail( | ||||||
|       'image/jpeg': { schema: { type: 'string', format: 'binary' } }, |  | ||||||
|       'image/webp': { schema: { type: 'string', format: 'binary' } }, |  | ||||||
|     }, |  | ||||||
|   }) |  | ||||||
|   async getAssetThumbnail( |  | ||||||
|     @Auth() auth: AuthDto, |     @Auth() auth: AuthDto, | ||||||
|     @Response() res: Res, |  | ||||||
|     @Param() { id }: UUIDParamDto, |     @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') |   @Get('/curated-objects') | ||||||
|  | |||||||
| @ -6,7 +6,7 @@ import { | |||||||
|   IAccessRepository, |   IAccessRepository, | ||||||
|   IJobRepository, |   IJobRepository, | ||||||
|   ILibraryRepository, |   ILibraryRepository, | ||||||
|   isConnectionAborted, |   ImmichFileResponse, | ||||||
|   JobName, |   JobName, | ||||||
|   mapAsset, |   mapAsset, | ||||||
|   mimeTypes, |   mimeTypes, | ||||||
| @ -16,12 +16,7 @@ import { | |||||||
| } from '@app/domain'; | } from '@app/domain'; | ||||||
| import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, LibraryType } from '@app/infra/entities'; | import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, LibraryType } from '@app/infra/entities'; | ||||||
| import { Inject, Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common'; | 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 { QueryFailedError } from 'typeorm'; | ||||||
| import { promisify } from 'util'; |  | ||||||
| import { IAssetRepository } from './asset-repository'; | import { IAssetRepository } from './asset-repository'; | ||||||
| import { AssetCore } from './asset.core'; | import { AssetCore } from './asset.core'; | ||||||
| import { AssetBulkUploadCheckDto } from './dto/asset-check.dto'; | 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 { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto'; | ||||||
| import { CuratedObjectsResponseDto } from './response-dto/curated-objects-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() | @Injectable() | ||||||
| export class AssetService { | export class AssetService { | ||||||
|   readonly logger = new Logger(AssetService.name); |   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); |     await this.access.requirePermission(auth, Permission.ASSET_VIEW, assetId); | ||||||
| 
 | 
 | ||||||
|     const asset = await this._assetRepository.get(assetId); |     const asset = await this._assetRepository.get(assetId); | ||||||
| @ -156,19 +144,12 @@ export class AssetService { | |||||||
|       throw new NotFoundException('Asset not found'); |       throw new NotFoundException('Asset not found'); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     try { |     const filepath = this.getThumbnailPath(asset, dto.format); | ||||||
|       await this.sendFile(res, this.getThumbnailPath(asset, query.format)); | 
 | ||||||
|     } catch (e) { |     return new ImmichFileResponse({ path: filepath, contentType: mimeTypes.lookup(filepath), cacheControl: true }); | ||||||
|       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 }, |  | ||||||
|       ); |  | ||||||
|     } |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   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
 |     // this is not quite right as sometimes this returns the original still
 | ||||||
|     await this.access.requirePermission(auth, Permission.ASSET_VIEW, assetId); |     await this.access.requirePermission(auth, Permission.ASSET_VIEW, assetId); | ||||||
| 
 | 
 | ||||||
| @ -181,10 +162,10 @@ export class AssetService { | |||||||
| 
 | 
 | ||||||
|     const filepath = |     const filepath = | ||||||
|       asset.type === AssetType.IMAGE |       asset.type === AssetType.IMAGE | ||||||
|         ? this.getServePath(asset, query, allowOriginalFile) |         ? this.getServePath(asset, dto, allowOriginalFile) | ||||||
|         : asset.encodedVideoPath || asset.originalPath; |         : 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[]> { |   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); |     const mimeType = mimeTypes.lookup(asset.originalPath); | ||||||
| 
 | 
 | ||||||
|     /** |     /** | ||||||
|      * Serve file viewer on the web |      * Serve file viewer on the web | ||||||
|      */ |      */ | ||||||
|     if (query.isWeb && mimeType != 'image/gif') { |     if (dto.isWeb && mimeType != 'image/gif') { | ||||||
|       if (!asset.resizePath) { |       if (!asset.resizePath) { | ||||||
|         this.logger.error('Error serving IMAGE asset for web'); |         this.logger.error('Error serving IMAGE asset for web'); | ||||||
|         throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile'); |         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 |      * 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; |       return asset.originalPath; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| @ -325,27 +306,6 @@ export class AssetService { | |||||||
|     return asset.resizePath; |     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) { |   private async getLibraryId(auth: AuthDto, libraryId?: string) { | ||||||
|     if (libraryId) { |     if (libraryId) { | ||||||
|       return libraryId; |       return libraryId; | ||||||
|  | |||||||
| @ -9,7 +9,7 @@ import { | |||||||
|   createParamDecorator, |   createParamDecorator, | ||||||
| } from '@nestjs/common'; | } from '@nestjs/common'; | ||||||
| import { Reflector } from '@nestjs/core'; | 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 { Request } from 'express'; | ||||||
| import { UAParser } from 'ua-parser-js'; | 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; |   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 => { | export const GetLoginDetails = createParamDecorator((data, ctx: ExecutionContext): LoginDetails => { | ||||||
|   const req = ctx.switchToHttp().getRequest<Request>(); |   const req = ctx.switchToHttp().getRequest<Request>(); | ||||||
|   const userAgent = UAParser(req.headers['user-agent']); |   const userAgent = UAParser(req.headers['user-agent']); | ||||||
|  | |||||||
| @ -32,7 +32,7 @@ import { | |||||||
|   TagController, |   TagController, | ||||||
|   UserController, |   UserController, | ||||||
| } from './controllers'; | } from './controllers'; | ||||||
| import { ErrorInterceptor, FileUploadInterceptor } from './interceptors'; | import { ErrorInterceptor, FileServeInterceptor, FileUploadInterceptor } from './interceptors'; | ||||||
| 
 | 
 | ||||||
| @Module({ | @Module({ | ||||||
|   imports: [ |   imports: [ | ||||||
| @ -66,6 +66,7 @@ import { ErrorInterceptor, FileUploadInterceptor } from './interceptors'; | |||||||
|   ], |   ], | ||||||
|   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, | ||||||
|  | |||||||
| @ -12,6 +12,7 @@ import { | |||||||
|   BulkIdsDto, |   BulkIdsDto, | ||||||
|   DownloadInfoDto, |   DownloadInfoDto, | ||||||
|   DownloadResponseDto, |   DownloadResponseDto, | ||||||
|  |   ImmichFileResponse, | ||||||
|   MapMarkerDto, |   MapMarkerDto, | ||||||
|   MapMarkerResponseDto, |   MapMarkerResponseDto, | ||||||
|   MemoryLaneDto, |   MemoryLaneDto, | ||||||
| @ -37,9 +38,9 @@ import { | |||||||
|   Query, |   Query, | ||||||
|   StreamableFile, |   StreamableFile, | ||||||
| } from '@nestjs/common'; | } 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 { 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 { UseValidation, asStreamableFile } 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'; | ||||||
| @ -88,7 +89,7 @@ export class AssetController { | |||||||
|   @SharedLinkRoute() |   @SharedLinkRoute() | ||||||
|   @Post('download/archive') |   @Post('download/archive') | ||||||
|   @HttpCode(HttpStatus.OK) |   @HttpCode(HttpStatus.OK) | ||||||
|   @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) |   @FileResponse() | ||||||
|   downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise<StreamableFile> { |   downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise<StreamableFile> { | ||||||
|     return this.service.downloadArchive(auth, dto).then(asStreamableFile); |     return this.service.downloadArchive(auth, dto).then(asStreamableFile); | ||||||
|   } |   } | ||||||
| @ -96,9 +97,9 @@ export class AssetController { | |||||||
|   @SharedLinkRoute() |   @SharedLinkRoute() | ||||||
|   @Post('download/:id') |   @Post('download/:id') | ||||||
|   @HttpCode(HttpStatus.OK) |   @HttpCode(HttpStatus.OK) | ||||||
|   @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) |   @FileResponse() | ||||||
|   downloadFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { |   downloadFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<ImmichFileResponse> { | ||||||
|     return this.service.downloadFile(auth, id).then(asStreamableFile); |     return this.service.downloadFile(auth, id); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   /** |   /** | ||||||
|  | |||||||
| @ -3,7 +3,6 @@ import { | |||||||
|   AssetResponseDto, |   AssetResponseDto, | ||||||
|   AuthDto, |   AuthDto, | ||||||
|   BulkIdResponseDto, |   BulkIdResponseDto, | ||||||
|   ImmichReadStream, |  | ||||||
|   MergePersonDto, |   MergePersonDto, | ||||||
|   PeopleResponseDto, |   PeopleResponseDto, | ||||||
|   PeopleUpdateDto, |   PeopleUpdateDto, | ||||||
| @ -13,16 +12,12 @@ import { | |||||||
|   PersonStatisticsResponseDto, |   PersonStatisticsResponseDto, | ||||||
|   PersonUpdateDto, |   PersonUpdateDto, | ||||||
| } from '@app/domain'; | } from '@app/domain'; | ||||||
| import { Body, Controller, Get, Param, Post, Put, Query, StreamableFile } from '@nestjs/common'; | import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common'; | ||||||
| import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; | import { ApiTags } from '@nestjs/swagger'; | ||||||
| import { Auth, Authenticated } from '../app.guard'; | import { Auth, Authenticated, FileResponse } from '../app.guard'; | ||||||
| import { UseValidation } from '../app.utils'; | import { UseValidation } from '../app.utils'; | ||||||
| import { UUIDParamDto } from './dto/uuid-param.dto'; | import { UUIDParamDto } from './dto/uuid-param.dto'; | ||||||
| 
 | 
 | ||||||
| function asStreamableFile({ stream, type, length }: ImmichReadStream) { |  | ||||||
|   return new StreamableFile(stream, { type, length }); |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| @ApiTags('Person') | @ApiTags('Person') | ||||||
| @Controller('person') | @Controller('person') | ||||||
| @Authenticated() | @Authenticated() | ||||||
| @ -74,13 +69,9 @@ export class PersonController { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @Get(':id/thumbnail') |   @Get(':id/thumbnail') | ||||||
|   @ApiOkResponse({ |   @FileResponse() | ||||||
|     content: { |  | ||||||
|       'image/jpeg': { schema: { type: 'string', format: 'binary' } }, |  | ||||||
|     }, |  | ||||||
|   }) |  | ||||||
|   getPersonThumbnail(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { |   getPersonThumbnail(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { | ||||||
|     return this.service.getThumbnail(auth, id).then(asStreamableFile); |     return this.service.getThumbnail(auth, id); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   @Get(':id/assets') |   @Get(':id/assets') | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ import { | |||||||
|   CreateUserDto as CreateDto, |   CreateUserDto as CreateDto, | ||||||
|   CreateProfileImageDto, |   CreateProfileImageDto, | ||||||
|   CreateProfileImageResponseDto, |   CreateProfileImageResponseDto, | ||||||
|  |   ImmichFileResponse, | ||||||
|   UpdateUserDto as UpdateDto, |   UpdateUserDto as UpdateDto, | ||||||
|   UserResponseDto, |   UserResponseDto, | ||||||
|   UserService, |   UserService, | ||||||
| @ -23,8 +24,8 @@ import { | |||||||
|   UseInterceptors, |   UseInterceptors, | ||||||
| } from '@nestjs/common'; | } from '@nestjs/common'; | ||||||
| import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; | import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; | ||||||
| import { AdminRoute, Auth, Authenticated } from '../app.guard'; | import { AdminRoute, Auth, Authenticated, FileResponse } from '../app.guard'; | ||||||
| import { UseValidation, asStreamableFile } from '../app.utils'; | import { UseValidation } 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,7 +94,8 @@ export class UserController { | |||||||
| 
 | 
 | ||||||
|   @Get('profile-image/:id') |   @Get('profile-image/:id') | ||||||
|   @Header('Cache-Control', 'private, no-cache, no-transform') |   @Header('Cache-Control', 'private, no-cache, no-transform') | ||||||
|   getProfileImage(@Param() { id }: UUIDParamDto): Promise<any> { |   @FileResponse() | ||||||
|     return this.service.getProfileImage(id).then(asStreamableFile); |   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 './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. |          * @param {*} [options] Override http request option. | ||||||
|          * @throws {RequiredError} |          * @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); |             const localVarAxiosArgs = await localVarAxiosParamCreator.getProfileImage(id, options); | ||||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); |             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||||
|         }, |         }, | ||||||
| @ -17945,7 +17945,7 @@ export const UserApiFactory = function (configuration?: Configuration, basePath? | |||||||
|          * @param {*} [options] Override http request option. |          * @param {*} [options] Override http request option. | ||||||
|          * @throws {RequiredError} |          * @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)); |             return localVarFp.getProfileImage(requestParameters.id, options).then((request) => request(axios, basePath)); | ||||||
|         }, |         }, | ||||||
|         /** |         /** | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user