diff --git a/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart b/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart index 8f90f2d770..0d2e72d60b 100644 --- a/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart +++ b/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart @@ -26,14 +26,10 @@ class ImageViewerService { if (asset.type == AssetTypeEnum.IMAGE && asset.livePhotoVideoId != null) { var imageResponse = await _apiService.assetApi.downloadFileWithHttpInfo( asset.id, - isThumb: false, - isWeb: false, ); var motionReponse = await _apiService.assetApi.downloadFileWithHttpInfo( asset.livePhotoVideoId!, - isThumb: false, - isWeb: false, ); final AssetEntity? entity; @@ -54,8 +50,6 @@ class ImageViewerService { } else { var res = await _apiService.assetApi.downloadFileWithHttpInfo( asset.id, - isThumb: false, - isWeb: false, ); final AssetEntity? entity; diff --git a/mobile/lib/shared/services/share.service.dart b/mobile/lib/shared/services/share.service.dart index ebf1d9f0fd..84d9674ade 100644 --- a/mobile/lib/shared/services/share.service.dart +++ b/mobile/lib/shared/services/share.service.dart @@ -29,8 +29,6 @@ class ShareService { final tempFile = await File('${tempDir.path}/$fileName').create(); final res = await _apiService.assetApi.downloadFileWithHttpInfo( asset.remote!.id, - isThumb: false, - isWeb: false, ); tempFile.writeAsBytesSync(res.bodyBytes); return XFile(tempFile.path); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 53b96215f5..341b88421c 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.43.0 +- API version: 1.43.1 - Build package: org.openapitools.codegen.languages.DartClientCodegen ## Requirements diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 4f21fe41d6..4695c961a9 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -230,7 +230,7 @@ Name | Type | Description | Notes [[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) # **downloadFile** -> Object downloadFile(assetId, isThumb, isWeb) +> Object downloadFile(assetId) @@ -248,11 +248,9 @@ import 'package:openapi/api.dart'; final api_instance = AssetApi(); final assetId = assetId_example; // String | -final isThumb = true; // bool | -final isWeb = true; // bool | try { - final result = api_instance.downloadFile(assetId, isThumb, isWeb); + final result = api_instance.downloadFile(assetId); print(result); } catch (e) { print('Exception when calling AssetApi->downloadFile: $e\n'); @@ -264,8 +262,6 @@ try { Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- **assetId** | **String**| | - **isThumb** | **bool**| | [optional] - **isWeb** | **bool**| | [optional] ### Return type diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index e6fa7b0d5e..861c9dc155 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -234,11 +234,7 @@ class AssetApi { /// Parameters: /// /// * [String] assetId (required): - /// - /// * [bool] isThumb: - /// - /// * [bool] isWeb: - Future downloadFileWithHttpInfo(String assetId, { bool? isThumb, bool? isWeb, }) async { + Future downloadFileWithHttpInfo(String assetId,) async { // ignore: prefer_const_declarations final path = r'/asset/download/{assetId}' .replaceAll('{assetId}', assetId); @@ -250,13 +246,6 @@ class AssetApi { final headerParams = {}; final formParams = {}; - if (isThumb != null) { - queryParams.addAll(_queryParams('', 'isThumb', isThumb)); - } - if (isWeb != null) { - queryParams.addAll(_queryParams('', 'isWeb', isWeb)); - } - const contentTypes = []; @@ -276,12 +265,8 @@ class AssetApi { /// Parameters: /// /// * [String] assetId (required): - /// - /// * [bool] isThumb: - /// - /// * [bool] isWeb: - Future downloadFile(String assetId, { bool? isThumb, bool? isWeb, }) async { - final response = await downloadFileWithHttpInfo(assetId, isThumb: isThumb, isWeb: isWeb, ); + Future downloadFile(String assetId,) async { + final response = await downloadFileWithHttpInfo(assetId,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/model/album_response_dto.dart b/mobile/openapi/lib/model/album_response_dto.dart index 7858e857d6..282344a16f 100644 --- a/mobile/openapi/lib/model/album_response_dto.dart +++ b/mobile/openapi/lib/model/album_response_dto.dart @@ -43,51 +43,48 @@ class AlbumResponseDto { List assets; @override - bool operator ==(Object other) => - identical(this, other) || - other is AlbumResponseDto && - other.assetCount == assetCount && - other.id == id && - other.ownerId == ownerId && - other.albumName == albumName && - other.createdAt == createdAt && - other.albumThumbnailAssetId == albumThumbnailAssetId && - other.shared == shared && - other.sharedUsers == sharedUsers && - other.assets == assets; + bool operator ==(Object other) => identical(this, other) || other is AlbumResponseDto && + other.assetCount == assetCount && + other.id == id && + other.ownerId == ownerId && + other.albumName == albumName && + other.createdAt == createdAt && + other.albumThumbnailAssetId == albumThumbnailAssetId && + other.shared == shared && + other.sharedUsers == sharedUsers && + other.assets == assets; @override int get hashCode => - // ignore: unnecessary_parenthesis - (assetCount.hashCode) + - (id.hashCode) + - (ownerId.hashCode) + - (albumName.hashCode) + - (createdAt.hashCode) + - (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) + - (shared.hashCode) + - (sharedUsers.hashCode) + - (assets.hashCode); + // ignore: unnecessary_parenthesis + (assetCount.hashCode) + + (id.hashCode) + + (ownerId.hashCode) + + (albumName.hashCode) + + (createdAt.hashCode) + + (albumThumbnailAssetId == null ? 0 : albumThumbnailAssetId!.hashCode) + + (shared.hashCode) + + (sharedUsers.hashCode) + + (assets.hashCode); @override - String toString() => - 'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]'; + String toString() => 'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets]'; Map toJson() { final json = {}; - json[r'assetCount'] = this.assetCount; - json[r'id'] = this.id; - json[r'ownerId'] = this.ownerId; - json[r'albumName'] = this.albumName; - json[r'createdAt'] = this.createdAt; + json[r'assetCount'] = this.assetCount; + json[r'id'] = this.id; + json[r'ownerId'] = this.ownerId; + json[r'albumName'] = this.albumName; + json[r'createdAt'] = this.createdAt; if (this.albumThumbnailAssetId != null) { json[r'albumThumbnailAssetId'] = this.albumThumbnailAssetId; } else { // json[r'albumThumbnailAssetId'] = null; } - json[r'shared'] = this.shared; - json[r'sharedUsers'] = this.sharedUsers; - json[r'assets'] = this.assets; + json[r'shared'] = this.shared; + json[r'sharedUsers'] = this.sharedUsers; + json[r'assets'] = this.assets; return json; } @@ -101,13 +98,13 @@ class AlbumResponseDto { // Ensure that the map contains the required keys. // Note 1: the values aren't checked for validity beyond being non-null. // Note 2: this code is stripped in release mode! - // assert(() { - // requiredKeys.forEach((key) { - // assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.'); - // assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.'); - // }); - // return true; - // }()); + assert(() { + requiredKeys.forEach((key) { + assert(json.containsKey(key), 'Required key "AlbumResponseDto[$key]" is missing from JSON.'); + assert(json[key] != null, 'Required key "AlbumResponseDto[$key]" has a null value in JSON.'); + }); + return true; + }()); return AlbumResponseDto( assetCount: mapValueOfType(json, r'assetCount')!, @@ -115,8 +112,7 @@ class AlbumResponseDto { ownerId: mapValueOfType(json, r'ownerId')!, albumName: mapValueOfType(json, r'albumName')!, createdAt: mapValueOfType(json, r'createdAt')!, - albumThumbnailAssetId: - mapValueOfType(json, r'albumThumbnailAssetId'), + albumThumbnailAssetId: mapValueOfType(json, r'albumThumbnailAssetId'), shared: mapValueOfType(json, r'shared')!, sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers'])!, assets: AssetResponseDto.listFromJson(json[r'assets'])!, @@ -125,10 +121,7 @@ class AlbumResponseDto { return null; } - static List? listFromJson( - dynamic json, { - bool growable = false, - }) { + static List? listFromJson(dynamic json, {bool growable = false,}) { final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { @@ -156,18 +149,12 @@ class AlbumResponseDto { } // maps a json object with a list of AlbumResponseDto-objects as value to a dart map - static Map> mapListFromJson( - dynamic json, { - bool growable = false, - }) { + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { final map = >{}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = AlbumResponseDto.listFromJson( - entry.value, - growable: growable, - ); + final value = AlbumResponseDto.listFromJson(entry.value, growable: growable,); if (value != null) { map[entry.key] = value; } @@ -189,3 +176,4 @@ class AlbumResponseDto { 'assets', }; } + diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 08c7e6cc39..2f516e2bda 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -82,76 +82,73 @@ class AssetResponseDto { List tags; @override - bool operator ==(Object other) => - identical(this, other) || - other is AssetResponseDto && - other.type == type && - other.id == id && - other.deviceAssetId == deviceAssetId && - other.ownerId == ownerId && - other.deviceId == deviceId && - other.originalPath == originalPath && - other.resizePath == resizePath && - other.createdAt == createdAt && - other.modifiedAt == modifiedAt && - other.isFavorite == isFavorite && - other.mimeType == mimeType && - other.duration == duration && - other.webpPath == webpPath && - other.encodedVideoPath == encodedVideoPath && - other.exifInfo == exifInfo && - other.smartInfo == smartInfo && - other.livePhotoVideoId == livePhotoVideoId && - other.tags == tags; + bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto && + other.type == type && + other.id == id && + other.deviceAssetId == deviceAssetId && + other.ownerId == ownerId && + other.deviceId == deviceId && + other.originalPath == originalPath && + other.resizePath == resizePath && + other.createdAt == createdAt && + other.modifiedAt == modifiedAt && + other.isFavorite == isFavorite && + other.mimeType == mimeType && + other.duration == duration && + other.webpPath == webpPath && + other.encodedVideoPath == encodedVideoPath && + other.exifInfo == exifInfo && + other.smartInfo == smartInfo && + other.livePhotoVideoId == livePhotoVideoId && + other.tags == tags; @override int get hashCode => - // ignore: unnecessary_parenthesis - (type.hashCode) + - (id.hashCode) + - (deviceAssetId.hashCode) + - (ownerId.hashCode) + - (deviceId.hashCode) + - (originalPath.hashCode) + - (resizePath == null ? 0 : resizePath!.hashCode) + - (createdAt.hashCode) + - (modifiedAt.hashCode) + - (isFavorite.hashCode) + - (mimeType == null ? 0 : mimeType!.hashCode) + - (duration.hashCode) + - (webpPath == null ? 0 : webpPath!.hashCode) + - (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) + - (exifInfo == null ? 0 : exifInfo!.hashCode) + - (smartInfo == null ? 0 : smartInfo!.hashCode) + - (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) + - (tags.hashCode); + // ignore: unnecessary_parenthesis + (type.hashCode) + + (id.hashCode) + + (deviceAssetId.hashCode) + + (ownerId.hashCode) + + (deviceId.hashCode) + + (originalPath.hashCode) + + (resizePath == null ? 0 : resizePath!.hashCode) + + (createdAt.hashCode) + + (modifiedAt.hashCode) + + (isFavorite.hashCode) + + (mimeType == null ? 0 : mimeType!.hashCode) + + (duration.hashCode) + + (webpPath == null ? 0 : webpPath!.hashCode) + + (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) + + (exifInfo == null ? 0 : exifInfo!.hashCode) + + (smartInfo == null ? 0 : smartInfo!.hashCode) + + (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) + + (tags.hashCode); @override - String toString() => - 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId, tags=$tags]'; + String toString() => 'AssetResponseDto[type=$type, id=$id, deviceAssetId=$deviceAssetId, ownerId=$ownerId, deviceId=$deviceId, originalPath=$originalPath, resizePath=$resizePath, createdAt=$createdAt, modifiedAt=$modifiedAt, isFavorite=$isFavorite, mimeType=$mimeType, duration=$duration, webpPath=$webpPath, encodedVideoPath=$encodedVideoPath, exifInfo=$exifInfo, smartInfo=$smartInfo, livePhotoVideoId=$livePhotoVideoId, tags=$tags]'; Map toJson() { final json = {}; - json[r'type'] = this.type; - json[r'id'] = this.id; - json[r'deviceAssetId'] = this.deviceAssetId; - json[r'ownerId'] = this.ownerId; - json[r'deviceId'] = this.deviceId; - json[r'originalPath'] = this.originalPath; + json[r'type'] = this.type; + json[r'id'] = this.id; + json[r'deviceAssetId'] = this.deviceAssetId; + json[r'ownerId'] = this.ownerId; + json[r'deviceId'] = this.deviceId; + json[r'originalPath'] = this.originalPath; if (this.resizePath != null) { json[r'resizePath'] = this.resizePath; } else { // json[r'resizePath'] = null; } - json[r'createdAt'] = this.createdAt; - json[r'modifiedAt'] = this.modifiedAt; - json[r'isFavorite'] = this.isFavorite; + json[r'createdAt'] = this.createdAt; + json[r'modifiedAt'] = this.modifiedAt; + json[r'isFavorite'] = this.isFavorite; if (this.mimeType != null) { json[r'mimeType'] = this.mimeType; } else { // json[r'mimeType'] = null; } - json[r'duration'] = this.duration; + json[r'duration'] = this.duration; if (this.webpPath != null) { json[r'webpPath'] = this.webpPath; } else { @@ -177,7 +174,7 @@ class AssetResponseDto { } else { // json[r'livePhotoVideoId'] = null; } - json[r'tags'] = this.tags; + json[r'tags'] = this.tags; return json; } @@ -191,13 +188,13 @@ class AssetResponseDto { // Ensure that the map contains the required keys. // Note 1: the values aren't checked for validity beyond being non-null. // Note 2: this code is stripped in release mode! - // assert(() { - // requiredKeys.forEach((key) { - // assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.'); - // assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.'); - // }); - // return true; - // }()); + assert(() { + requiredKeys.forEach((key) { + assert(json.containsKey(key), 'Required key "AssetResponseDto[$key]" is missing from JSON.'); + assert(json[key] != null, 'Required key "AssetResponseDto[$key]" has a null value in JSON.'); + }); + return true; + }()); return AssetResponseDto( type: AssetTypeEnum.fromJson(json[r'type'])!, @@ -223,10 +220,7 @@ class AssetResponseDto { return null; } - static List? listFromJson( - dynamic json, { - bool growable = false, - }) { + static List? listFromJson(dynamic json, {bool growable = false,}) { final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { @@ -254,18 +248,12 @@ class AssetResponseDto { } // maps a json object with a list of AssetResponseDto-objects as value to a dart map - static Map> mapListFromJson( - dynamic json, { - bool growable = false, - }) { + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { final map = >{}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = AssetResponseDto.listFromJson( - entry.value, - growable: growable, - ); + final value = AssetResponseDto.listFromJson(entry.value, growable: growable,); if (value != null) { map[entry.key] = value; } @@ -292,3 +280,4 @@ class AssetResponseDto { 'tags', }; } + diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 086bbca010..3bf405b8fa 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -47,7 +47,7 @@ void main() { // // - //Future downloadFile(String assetId, { bool isThumb, bool isWeb }) async + //Future downloadFile(String assetId) async test('test downloadFile', () async { // TODO }); diff --git a/server/apps/immich/src/api-v1/asset/asset.controller.ts b/server/apps/immich/src/api-v1/asset/asset.controller.ts index ae924dfa3b..d0cf3af6a5 100644 --- a/server/apps/immich/src/api-v1/asset/asset.controller.ts +++ b/server/apps/immich/src/api-v1/asset/asset.controller.ts @@ -15,6 +15,7 @@ import { Put, UploadedFiles, Patch, + StreamableFile, } from '@nestjs/common'; import { Authenticated } from '../../decorators/authenticated.decorator'; import { AssetService } from './asset.service'; @@ -28,7 +29,7 @@ import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger'; import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto'; -import { AssetResponseDto } from '@app/domain'; +import { AssetResponseDto, ImmichReadStream } from '@app/domain'; import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto'; import { AssetFileUploadDto } from './dto/asset-file-upload.dto'; import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto'; @@ -55,6 +56,10 @@ import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto import { AssetSearchDto } from './dto/asset-search.dto'; import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config'; +function asStreamableFile({ stream, type, length }: ImmichReadStream) { + return new StreamableFile(stream, { type, length }); +} + @ApiBearerAuth() @ApiTags('Asset') @Controller('asset') @@ -103,12 +108,9 @@ export class AssetController { async downloadFile( @GetAuthUser() authUser: AuthUserDto, @Response({ passthrough: true }) res: Res, - @Query(new ValidationPipe({ transform: true })) query: ServeFileDto, @Param('assetId') assetId: string, ): Promise { - this.assetService.checkDownloadAccess(authUser); - await this.assetService.checkAssetsAccess(authUser, [assetId]); - return this.assetService.downloadFile(query, assetId, res); + return this.assetService.downloadFile(authUser, assetId).then(asStreamableFile); } @Authenticated({ isShared: true }) diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index c215e22fe5..08c4ed1aca 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts @@ -9,12 +9,13 @@ import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-use import { DownloadService } from '../../modules/download/download.service'; import { AlbumRepository, IAlbumRepository } from '../album/album-repository'; import { StorageService } from '@app/storage'; -import { ICryptoRepository, IJobRepository, ISharedLinkRepository, JobName } from '@app/domain'; +import { ICryptoRepository, IJobRepository, ISharedLinkRepository, IStorageRepository, JobName } from '@app/domain'; import { authStub, newCryptoRepositoryMock, newJobRepositoryMock, newSharedLinkRepositoryMock, + newStorageRepositoryMock, sharedLinkResponseStub, sharedLinkStub, } from '@app/domain/../test'; @@ -110,6 +111,7 @@ describe('AssetService', () => { let sharedLinkRepositoryMock: jest.Mocked; let cryptoMock: jest.Mocked; let jobMock: jest.Mocked; + let storageMock: jest.Mocked; beforeEach(() => { assetRepositoryMock = { @@ -154,6 +156,7 @@ describe('AssetService', () => { sharedLinkRepositoryMock = newSharedLinkRepositoryMock(); jobMock = newJobRepositoryMock(); cryptoMock = newCryptoRepositoryMock(); + storageMock = newStorageRepositoryMock(); sut = new AssetService( assetRepositoryMock, @@ -164,6 +167,7 @@ describe('AssetService', () => { sharedLinkRepositoryMock, jobMock, cryptoMock, + storageMock, ); }); @@ -413,4 +417,15 @@ describe('AssetService', () => { expect(() => sut.checkDownloadAccess(authStub.readonlySharedLink)).toThrow(ForbiddenException); }); }); + + describe('downloadFile', () => { + it('should download a single file', async () => { + assetRepositoryMock.countByIdAndUser.mockResolvedValue(1); + assetRepositoryMock.get.mockResolvedValue(_getAsset_1()); + + await sut.downloadFile(authStub.admin, 'id_1'); + + expect(storageMock.createReadStream).toHaveBeenCalledWith('fake_path/asset_1.jpeg', 'image/jpeg'); + }); + }); }); diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index e9ee8abbb6..35c14e7036 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -10,7 +10,6 @@ import { StreamableFile, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { createHash } from 'node:crypto'; import { QueryFailedError, Repository } from 'typeorm'; import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { AssetEntity, AssetType, SharedLinkType } from '@app/infra'; @@ -23,7 +22,14 @@ import { SearchAssetDto } from './dto/search-asset.dto'; import fs from 'fs/promises'; import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; -import { AssetResponseDto, JobName, mapAsset, mapAssetWithoutExif } from '@app/domain'; +import { + AssetResponseDto, + ImmichReadStream, + IStorageRepository, + JobName, + mapAsset, + mapAssetWithoutExif, +} from '@app/domain'; import { CreateAssetDto, UploadFile } from './dto/create-asset.dto'; import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto'; import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto'; @@ -73,6 +79,7 @@ export class AssetService { @Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, + @Inject(IStorageRepository) private storage: IStorageRepository, ) { this.assetCore = new AssetCore(_assetRepository, jobRepository, storageService); this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository); @@ -189,62 +196,21 @@ export class AssetService { return this.downloadService.downloadArchive(`immich-${now}`, assetToDownload); } - public async downloadFile(query: ServeFileDto, assetId: string, res: Res) { + public async downloadFile(authUser: AuthUserDto, assetId: string): Promise { + this.checkDownloadAccess(authUser); + await this.checkAssetsAccess(authUser, [assetId]); + try { - let fileReadStream = null; - const asset = await this._assetRepository.getById(assetId); - - // Download Video - if (asset.type === AssetType.VIDEO) { - const { size } = await fileInfo(asset.originalPath); - - res.set({ - 'Content-Type': asset.mimeType, - 'Content-Length': size, - }); - - await fs.access(asset.originalPath, constants.R_OK | constants.W_OK); - fileReadStream = createReadStream(asset.originalPath); - } else { - // Download Image - if (!query.isThumb) { - /** - * Download Image Original File - */ - const { size } = await fileInfo(asset.originalPath); - - res.set({ - 'Content-Type': asset.mimeType, - 'Content-Length': size, - }); - - await fs.access(asset.originalPath, constants.R_OK | constants.W_OK); - fileReadStream = createReadStream(asset.originalPath); - } else { - /** - * Download Image Resize File - */ - if (!asset.resizePath) { - throw new NotFoundException('resizePath not set'); - } - - const { size } = await fileInfo(asset.resizePath); - - res.set({ - 'Content-Type': 'image/jpeg', - 'Content-Length': size, - }); - - await fs.access(asset.resizePath, constants.R_OK | constants.W_OK); - fileReadStream = createReadStream(asset.resizePath); - } + const asset = await this._assetRepository.get(assetId); + if (asset && asset.originalPath && asset.mimeType) { + return this.storage.createReadStream(asset.originalPath, asset.mimeType); } - - return new StreamableFile(fileReadStream); } catch (e) { Logger.error(`Error download asset ${e}`, 'downloadFile'); throw new InternalServerErrorException(`Failed to download asset ${e}`, 'DownloadFile'); } + + throw new NotFoundException(); } public async getAssetThumbnail( @@ -255,8 +221,7 @@ export class AssetService { ) { let fileReadStream: ReadStream; - const asset = await this.assetRepository.findOne({ where: { id: assetId } }); - + const asset = await this._assetRepository.get(assetId); if (!asset) { throw new NotFoundException('Asset not found'); } @@ -584,18 +549,6 @@ export class AssetService { return this._assetRepository.getAssetByChecksum(userId, checksum); } - calculateChecksum(filePath: string): Promise { - const fileReadStream = createReadStream(filePath); - const sha1Hash = createHash('sha1'); - const deferred = new Promise((resolve, reject) => { - sha1Hash.once('error', (err) => reject(err)); - sha1Hash.once('finish', () => resolve(sha1Hash.read())); - }); - - fileReadStream.pipe(sha1Hash); - return deferred; - } - getAssetCountByUserId(authUser: AuthUserDto): Promise { return this._assetRepository.getAssetCountByUserId(authUser.id); } diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 31b73bfebe..c31e7ee287 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -1109,24 +1109,6 @@ "operationId": "downloadFile", "description": "", "parameters": [ - { - "name": "isThumb", - "required": false, - "in": "query", - "schema": { - "title": "Is serve thumbnail (resize) file", - "type": "boolean" - } - }, - { - "name": "isWeb", - "required": false, - "in": "query", - "schema": { - "title": "Is request made from web", - "type": "boolean" - } - }, { "name": "assetId", "required": true, diff --git a/server/libs/domain/src/auth/auth.core.ts b/server/libs/domain/src/auth/auth.core.ts index 9b4d54ac53..d330baad74 100644 --- a/server/libs/domain/src/auth/auth.core.ts +++ b/server/libs/domain/src/auth/auth.core.ts @@ -6,11 +6,6 @@ import { ICryptoRepository } from '../crypto/crypto.repository'; import { LoginResponseDto, mapLoginResponse } from './response-dto'; import { IUserTokenRepository, UserTokenCore } from '../user-token'; -export type JwtValidationResult = { - status: boolean; - userId: string | null; -}; - export class AuthCore { private userTokenCore: UserTokenCore; constructor( diff --git a/server/libs/domain/src/auth/dto/index.ts b/server/libs/domain/src/auth/dto/index.ts index 87c5cc3707..323d12f8fc 100644 --- a/server/libs/domain/src/auth/dto/index.ts +++ b/server/libs/domain/src/auth/dto/index.ts @@ -1,5 +1,4 @@ export * from './auth-user.dto'; export * from './change-password.dto'; -export * from './jwt-payload.dto'; export * from './login-credential.dto'; export * from './sign-up.dto'; diff --git a/server/libs/domain/src/auth/dto/jwt-payload.dto.ts b/server/libs/domain/src/auth/dto/jwt-payload.dto.ts deleted file mode 100644 index 4f3b7993fc..0000000000 --- a/server/libs/domain/src/auth/dto/jwt-payload.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -export class JwtPayloadDto { - userId!: string; - email!: string; -} diff --git a/server/libs/domain/src/index.ts b/server/libs/domain/src/index.ts index 3113aa1dd0..99456733ed 100644 --- a/server/libs/domain/src/index.ts +++ b/server/libs/domain/src/index.ts @@ -8,6 +8,7 @@ export * from './domain.module'; export * from './job'; export * from './oauth'; export * from './share'; +export * from './storage'; export * from './system-config'; export * from './tag'; export * from './user'; diff --git a/server/libs/domain/src/storage/index.ts b/server/libs/domain/src/storage/index.ts new file mode 100644 index 0000000000..b6109ead5e --- /dev/null +++ b/server/libs/domain/src/storage/index.ts @@ -0,0 +1 @@ +export * from './storage.repository'; diff --git a/server/libs/domain/src/storage/storage.repository.ts b/server/libs/domain/src/storage/storage.repository.ts new file mode 100644 index 0000000000..53d452f318 --- /dev/null +++ b/server/libs/domain/src/storage/storage.repository.ts @@ -0,0 +1,13 @@ +import { ReadStream } from 'fs'; + +export interface ImmichReadStream { + stream: ReadStream; + type: string; + length: number; +} + +export const IStorageRepository = 'IStorageRepository'; + +export interface IStorageRepository { + createReadStream(filepath: string, mimeType: string): Promise; +} diff --git a/server/libs/domain/test/index.ts b/server/libs/domain/test/index.ts index dfec3ffa49..94c4f27503 100644 --- a/server/libs/domain/test/index.ts +++ b/server/libs/domain/test/index.ts @@ -4,6 +4,7 @@ export * from './device-info.repository.mock'; export * from './fixtures'; export * from './job.repository.mock'; export * from './shared-link.repository.mock'; +export * from './storage.repository.mock'; export * from './system-config.repository.mock'; export * from './user-token.repository.mock'; export * from './user.repository.mock'; diff --git a/server/libs/domain/test/storage.repository.mock.ts b/server/libs/domain/test/storage.repository.mock.ts new file mode 100644 index 0000000000..6cad338699 --- /dev/null +++ b/server/libs/domain/test/storage.repository.mock.ts @@ -0,0 +1,7 @@ +import { IStorageRepository } from '../src'; + +export const newStorageRepositoryMock = (): jest.Mocked => { + return { + createReadStream: jest.fn(), + }; +}; diff --git a/server/libs/infra/src/infra.module.ts b/server/libs/infra/src/infra.module.ts index df16c1dfd8..b71373b1b0 100644 --- a/server/libs/infra/src/infra.module.ts +++ b/server/libs/infra/src/infra.module.ts @@ -4,6 +4,7 @@ import { IJobRepository, IKeyRepository, ISharedLinkRepository, + IStorageRepository, ISystemConfigRepository, IUserRepository, QueueName, @@ -29,6 +30,7 @@ import { UserTokenEntity, } from './db'; import { JobRepository } from './job'; +import { FilesystemProvider } from './storage'; const providers: Provider[] = [ { provide: ICryptoRepository, useClass: CryptoRepository }, @@ -36,6 +38,7 @@ const providers: Provider[] = [ { provide: IKeyRepository, useClass: APIKeyRepository }, { provide: IJobRepository, useClass: JobRepository }, { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, + { provide: IStorageRepository, useClass: FilesystemProvider }, { provide: ISystemConfigRepository, useClass: SystemConfigRepository }, { provide: IUserRepository, useClass: UserRepository }, { provide: IUserTokenRepository, useClass: UserTokenRepository }, diff --git a/server/libs/infra/src/storage/filesystem.provider.ts b/server/libs/infra/src/storage/filesystem.provider.ts new file mode 100644 index 0000000000..ca2d847f1b --- /dev/null +++ b/server/libs/infra/src/storage/filesystem.provider.ts @@ -0,0 +1,18 @@ +import { ImmichReadStream, IStorageRepository } from '@app/domain'; +import { constants, createReadStream, stat } from 'fs'; +import fs from 'fs/promises'; +import { promisify } from 'util'; + +const fileInfo = promisify(stat); + +export class FilesystemProvider implements IStorageRepository { + async createReadStream(filepath: string, mimeType: string): Promise { + const { size } = await fileInfo(filepath); + await fs.access(filepath, constants.R_OK | constants.W_OK); + return { + stream: createReadStream(filepath), + length: size, + type: mimeType, + }; + } +} diff --git a/server/libs/infra/src/storage/index.ts b/server/libs/infra/src/storage/index.ts new file mode 100644 index 0000000000..ae8187f1fe --- /dev/null +++ b/server/libs/infra/src/storage/index.ts @@ -0,0 +1 @@ +export * from './filesystem.provider'; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 8eb39ea7c9..6d0ac70362 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.43.0 + * The version of the OpenAPI document: 1.43.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). @@ -3729,12 +3729,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration /** * * @param {string} assetId - * @param {boolean} [isThumb] - * @param {boolean} [isWeb] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - downloadFile: async (assetId: string, isThumb?: boolean, isWeb?: boolean, options: AxiosRequestConfig = {}): Promise => { + downloadFile: async (assetId: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'assetId' is not null or undefined assertParamExists('downloadFile', 'assetId', assetId) const localVarPath = `/asset/download/{assetId}` @@ -3754,14 +3752,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration // http bearer authentication required await setBearerAuthToObject(localVarHeaderParameter, configuration) - if (isThumb !== undefined) { - localVarQueryParameter['isThumb'] = isThumb; - } - - if (isWeb !== undefined) { - localVarQueryParameter['isWeb'] = isWeb; - } - setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -4489,13 +4479,11 @@ export const AssetApiFp = function(configuration?: Configuration) { /** * * @param {string} assetId - * @param {boolean} [isThumb] - * @param {boolean} [isWeb] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async downloadFile(assetId: string, isThumb?: boolean, isWeb?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(assetId, isThumb, isWeb, options); + async downloadFile(assetId: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(assetId, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -4719,13 +4707,11 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath /** * * @param {string} assetId - * @param {boolean} [isThumb] - * @param {boolean} [isWeb] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - downloadFile(assetId: string, isThumb?: boolean, isWeb?: boolean, options?: any): AxiosPromise { - return localVarFp.downloadFile(assetId, isThumb, isWeb, options).then((request) => request(axios, basePath)); + downloadFile(assetId: string, options?: any): AxiosPromise { + return localVarFp.downloadFile(assetId, options).then((request) => request(axios, basePath)); }, /** * @@ -4939,14 +4925,12 @@ export class AssetApi extends BaseAPI { /** * * @param {string} assetId - * @param {boolean} [isThumb] - * @param {boolean} [isWeb] * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof AssetApi */ - public downloadFile(assetId: string, isThumb?: boolean, isWeb?: boolean, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).downloadFile(assetId, isThumb, isWeb, options).then((request) => request(this.axios, this.basePath)); + public downloadFile(assetId: string, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).downloadFile(assetId, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/web/src/api/open-api/base.ts b/web/src/api/open-api/base.ts index 58f76152d5..f022bd6e33 100644 --- a/web/src/api/open-api/base.ts +++ b/web/src/api/open-api/base.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.43.0 + * The version of the OpenAPI document: 1.43.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/common.ts b/web/src/api/open-api/common.ts index 2df54adf62..d41e01f325 100644 --- a/web/src/api/open-api/common.ts +++ b/web/src/api/open-api/common.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.43.0 + * The version of the OpenAPI document: 1.43.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/configuration.ts b/web/src/api/open-api/configuration.ts index bb49e3c84c..b12628461b 100644 --- a/web/src/api/open-api/configuration.ts +++ b/web/src/api/open-api/configuration.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.43.0 + * The version of the OpenAPI document: 1.43.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/api/open-api/index.ts b/web/src/api/open-api/index.ts index 9b6a043311..052d483f15 100644 --- a/web/src/api/open-api/index.ts +++ b/web/src/api/open-api/index.ts @@ -4,7 +4,7 @@ * Immich * Immich API * - * The version of the OpenAPI document: 1.43.0 + * The version of the OpenAPI document: 1.43.1 * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 36819c747f..2199359432 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -136,10 +136,8 @@ $downloadAssets[imageFileName] = 0; - const { data, status } = await api.assetApi.downloadFile(assetId, false, false, { - params: { - key - }, + const { data, status } = await api.assetApi.downloadFile(assetId, { + params: { key }, responseType: 'blob', onDownloadProgress: (progressEvent) => { if (progressEvent.lengthComputable) {