From dadcf49ecaf601677789ee67c9d0047115b95d32 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Sat, 14 Oct 2023 03:46:30 +0200 Subject: [PATCH 1/3] fix(server,web): correctly remove metadata from shared links (#4464) * wip: strip metadata * fix: authenticate time buckets * hide detail panel * fix tests * fix lint * add e2e tests * chore: open api * fix web compilation error * feat: test with asset with gps position * fix: only import fs.promises.cp * fix: cleanup mapasset * fix: format --------- Co-authored-by: Alex Tran --- cli/src/api/open-api/api.ts | 14 ++- mobile/openapi/doc/AssetResponseDto.md | 3 +- mobile/openapi/doc/SharedLinkCreateDto.md | 2 +- mobile/openapi/doc/SharedLinkEditDto.md | 2 +- mobile/openapi/doc/SharedLinkResponseDto.md | 2 +- .../openapi/lib/model/asset_response_dto.dart | 11 +- .../lib/model/shared_link_create_dto.dart | 14 +-- .../lib/model/shared_link_edit_dto.dart | 18 +-- .../lib/model/shared_link_response_dto.dart | 16 +-- .../openapi/test/asset_response_dto_test.dart | 6 +- .../test/shared_link_create_dto_test.dart | 4 +- .../test/shared_link_edit_dto_test.dart | 4 +- .../test/shared_link_response_dto_test.dart | 4 +- server/immich-openapi-specs.json | 21 ++-- server/src/domain/asset/asset.service.ts | 12 +- .../asset/response-dto/asset-response.dto.ts | 50 +++++--- .../asset/response-dto/exif-response.dto.ts | 12 ++ server/src/domain/auth/auth.service.ts | 4 +- server/src/domain/auth/dto/auth-user.dto.ts | 2 +- server/src/domain/person/person.service.ts | 2 +- server/src/domain/search/search.service.ts | 2 +- .../shared-link/shared-link-response.dto.ts | 15 +-- .../src/domain/shared-link/shared-link.dto.ts | 4 +- .../shared-link/shared-link.service.spec.ts | 6 +- .../domain/shared-link/shared-link.service.ts | 10 +- server/src/domain/tag/tag.service.ts | 2 +- .../immich/api-v1/asset/asset.controller.ts | 2 +- .../src/immich/api-v1/asset/asset.service.ts | 33 +++--- .../immich/controllers/asset.controller.ts | 2 +- server/test/api/shared-link-api.ts | 7 ++ server/test/assets | 2 +- server/test/e2e/shared-link.e2e-spec.ts | 110 ++++++++++++++++-- server/test/fixtures/auth.stub.ts | 12 +- server/test/fixtures/shared-link.stub.ts | 24 +++- web/src/api/open-api/api.ts | 14 ++- .../asset-viewer/asset-viewer-nav-bar.svelte | 10 +- .../asset-viewer/asset-viewer.svelte | 8 +- .../create-shared-link-modal.svelte | 14 +-- .../sharedlinks-page/shared-link-card.svelte | 2 +- 39 files changed, 332 insertions(+), 150 deletions(-) diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 2be639a0f..0c8d2673c 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -640,6 +640,12 @@ export interface AssetResponseDto { * @memberof AssetResponseDto */ 'fileModifiedAt': string; + /** + * + * @type {boolean} + * @memberof AssetResponseDto + */ + 'hasMetadata': boolean; /** * * @type {string} @@ -749,7 +755,7 @@ export interface AssetResponseDto { */ 'tags'?: Array; /** - * base64 encoded thumbhash + * * @type {string} * @memberof AssetResponseDto */ @@ -2882,7 +2888,7 @@ export interface SharedLinkCreateDto { * @type {boolean} * @memberof SharedLinkCreateDto */ - 'showExif'?: boolean; + 'showMetadata'?: boolean; /** * * @type {SharedLinkType} @@ -2927,7 +2933,7 @@ export interface SharedLinkEditDto { * @type {boolean} * @memberof SharedLinkEditDto */ - 'showExif'?: boolean; + 'showMetadata'?: boolean; } /** * @@ -2994,7 +3000,7 @@ export interface SharedLinkResponseDto { * @type {boolean} * @memberof SharedLinkResponseDto */ - 'showExif': boolean; + 'showMetadata': boolean; /** * * @type {SharedLinkType} diff --git a/mobile/openapi/doc/AssetResponseDto.md b/mobile/openapi/doc/AssetResponseDto.md index 2fd91061f..a08be71ac 100644 --- a/mobile/openapi/doc/AssetResponseDto.md +++ b/mobile/openapi/doc/AssetResponseDto.md @@ -15,6 +15,7 @@ Name | Type | Description | Notes **exifInfo** | [**ExifResponseDto**](ExifResponseDto.md) | | [optional] **fileCreatedAt** | [**DateTime**](DateTime.md) | | **fileModifiedAt** | [**DateTime**](DateTime.md) | | +**hasMetadata** | **bool** | | **id** | **String** | | **isArchived** | **bool** | | **isExternal** | **bool** | | @@ -33,7 +34,7 @@ Name | Type | Description | Notes **resized** | **bool** | | **smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional] **tags** | [**List**](TagResponseDto.md) | | [optional] [default to const []] -**thumbhash** | **String** | base64 encoded thumbhash | +**thumbhash** | **String** | | **type** | [**AssetTypeEnum**](AssetTypeEnum.md) | | **updatedAt** | [**DateTime**](DateTime.md) | | diff --git a/mobile/openapi/doc/SharedLinkCreateDto.md b/mobile/openapi/doc/SharedLinkCreateDto.md index 807a8d18e..852610ae1 100644 --- a/mobile/openapi/doc/SharedLinkCreateDto.md +++ b/mobile/openapi/doc/SharedLinkCreateDto.md @@ -14,7 +14,7 @@ Name | Type | Description | Notes **assetIds** | **List** | | [optional] [default to const []] **description** | **String** | | [optional] **expiresAt** | [**DateTime**](DateTime.md) | | [optional] -**showExif** | **bool** | | [optional] [default to true] +**showMetadata** | **bool** | | [optional] [default to true] **type** | [**SharedLinkType**](SharedLinkType.md) | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/SharedLinkEditDto.md b/mobile/openapi/doc/SharedLinkEditDto.md index 387ef4c50..f035e23c6 100644 --- a/mobile/openapi/doc/SharedLinkEditDto.md +++ b/mobile/openapi/doc/SharedLinkEditDto.md @@ -12,7 +12,7 @@ Name | Type | Description | Notes **allowUpload** | **bool** | | [optional] **description** | **String** | | [optional] **expiresAt** | [**DateTime**](DateTime.md) | | [optional] -**showExif** | **bool** | | [optional] +**showMetadata** | **bool** | | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/SharedLinkResponseDto.md b/mobile/openapi/doc/SharedLinkResponseDto.md index 3e7c8256b..24b76c86c 100644 --- a/mobile/openapi/doc/SharedLinkResponseDto.md +++ b/mobile/openapi/doc/SharedLinkResponseDto.md @@ -17,7 +17,7 @@ Name | Type | Description | Notes **expiresAt** | [**DateTime**](DateTime.md) | | **id** | **String** | | **key** | **String** | | -**showExif** | **bool** | | +**showMetadata** | **bool** | | **type** | [**SharedLinkType**](SharedLinkType.md) | | **userId** | **String** | | diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index f127b5d2c..b2feb0ee8 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -20,6 +20,7 @@ class AssetResponseDto { this.exifInfo, required this.fileCreatedAt, required this.fileModifiedAt, + required this.hasMetadata, required this.id, required this.isArchived, required this.isExternal, @@ -64,6 +65,8 @@ class AssetResponseDto { DateTime fileModifiedAt; + bool hasMetadata; + String id; bool isArchived; @@ -112,7 +115,6 @@ class AssetResponseDto { List tags; - /// base64 encoded thumbhash String? thumbhash; AssetTypeEnum type; @@ -128,6 +130,7 @@ class AssetResponseDto { other.exifInfo == exifInfo && other.fileCreatedAt == fileCreatedAt && other.fileModifiedAt == fileModifiedAt && + other.hasMetadata == hasMetadata && other.id == id && other.isArchived == isArchived && other.isExternal == isExternal && @@ -160,6 +163,7 @@ class AssetResponseDto { (exifInfo == null ? 0 : exifInfo!.hashCode) + (fileCreatedAt.hashCode) + (fileModifiedAt.hashCode) + + (hasMetadata.hashCode) + (id.hashCode) + (isArchived.hashCode) + (isExternal.hashCode) + @@ -183,7 +187,7 @@ class AssetResponseDto { (updatedAt.hashCode); @override - String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]'; + String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -198,6 +202,7 @@ class AssetResponseDto { } json[r'fileCreatedAt'] = this.fileCreatedAt.toUtc().toIso8601String(); json[r'fileModifiedAt'] = this.fileModifiedAt.toUtc().toIso8601String(); + json[r'hasMetadata'] = this.hasMetadata; json[r'id'] = this.id; json[r'isArchived'] = this.isArchived; json[r'isExternal'] = this.isExternal; @@ -253,6 +258,7 @@ class AssetResponseDto { exifInfo: ExifResponseDto.fromJson(json[r'exifInfo']), fileCreatedAt: mapDateTime(json, r'fileCreatedAt', '')!, fileModifiedAt: mapDateTime(json, r'fileModifiedAt', '')!, + hasMetadata: mapValueOfType(json, r'hasMetadata')!, id: mapValueOfType(json, r'id')!, isArchived: mapValueOfType(json, r'isArchived')!, isExternal: mapValueOfType(json, r'isExternal')!, @@ -327,6 +333,7 @@ class AssetResponseDto { 'duration', 'fileCreatedAt', 'fileModifiedAt', + 'hasMetadata', 'id', 'isArchived', 'isExternal', diff --git a/mobile/openapi/lib/model/shared_link_create_dto.dart b/mobile/openapi/lib/model/shared_link_create_dto.dart index a940808c1..8ce045ca1 100644 --- a/mobile/openapi/lib/model/shared_link_create_dto.dart +++ b/mobile/openapi/lib/model/shared_link_create_dto.dart @@ -19,7 +19,7 @@ class SharedLinkCreateDto { this.assetIds = const [], this.description, this.expiresAt, - this.showExif = true, + this.showMetadata = true, required this.type, }); @@ -47,7 +47,7 @@ class SharedLinkCreateDto { DateTime? expiresAt; - bool showExif; + bool showMetadata; SharedLinkType type; @@ -59,7 +59,7 @@ class SharedLinkCreateDto { other.assetIds == assetIds && other.description == description && other.expiresAt == expiresAt && - other.showExif == showExif && + other.showMetadata == showMetadata && other.type == type; @override @@ -71,11 +71,11 @@ class SharedLinkCreateDto { (assetIds.hashCode) + (description == null ? 0 : description!.hashCode) + (expiresAt == null ? 0 : expiresAt!.hashCode) + - (showExif.hashCode) + + (showMetadata.hashCode) + (type.hashCode); @override - String toString() => 'SharedLinkCreateDto[albumId=$albumId, allowDownload=$allowDownload, allowUpload=$allowUpload, assetIds=$assetIds, description=$description, expiresAt=$expiresAt, showExif=$showExif, type=$type]'; + String toString() => 'SharedLinkCreateDto[albumId=$albumId, allowDownload=$allowDownload, allowUpload=$allowUpload, assetIds=$assetIds, description=$description, expiresAt=$expiresAt, showMetadata=$showMetadata, type=$type]'; Map toJson() { final json = {}; @@ -97,7 +97,7 @@ class SharedLinkCreateDto { } else { // json[r'expiresAt'] = null; } - json[r'showExif'] = this.showExif; + json[r'showMetadata'] = this.showMetadata; json[r'type'] = this.type; return json; } @@ -118,7 +118,7 @@ class SharedLinkCreateDto { : const [], description: mapValueOfType(json, r'description'), expiresAt: mapDateTime(json, r'expiresAt', ''), - showExif: mapValueOfType(json, r'showExif') ?? true, + showMetadata: mapValueOfType(json, r'showMetadata') ?? true, type: SharedLinkType.fromJson(json[r'type'])!, ); } diff --git a/mobile/openapi/lib/model/shared_link_edit_dto.dart b/mobile/openapi/lib/model/shared_link_edit_dto.dart index fb7936155..6b72e025d 100644 --- a/mobile/openapi/lib/model/shared_link_edit_dto.dart +++ b/mobile/openapi/lib/model/shared_link_edit_dto.dart @@ -17,7 +17,7 @@ class SharedLinkEditDto { this.allowUpload, this.description, this.expiresAt, - this.showExif, + this.showMetadata, }); /// @@ -52,7 +52,7 @@ class SharedLinkEditDto { /// source code must fall back to having a nullable type. /// Consider adding a "default:" property in the specification file to hide this note. /// - bool? showExif; + bool? showMetadata; @override bool operator ==(Object other) => identical(this, other) || other is SharedLinkEditDto && @@ -60,7 +60,7 @@ class SharedLinkEditDto { other.allowUpload == allowUpload && other.description == description && other.expiresAt == expiresAt && - other.showExif == showExif; + other.showMetadata == showMetadata; @override int get hashCode => @@ -69,10 +69,10 @@ class SharedLinkEditDto { (allowUpload == null ? 0 : allowUpload!.hashCode) + (description == null ? 0 : description!.hashCode) + (expiresAt == null ? 0 : expiresAt!.hashCode) + - (showExif == null ? 0 : showExif!.hashCode); + (showMetadata == null ? 0 : showMetadata!.hashCode); @override - String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, expiresAt=$expiresAt, showExif=$showExif]'; + String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, expiresAt=$expiresAt, showMetadata=$showMetadata]'; Map toJson() { final json = {}; @@ -96,10 +96,10 @@ class SharedLinkEditDto { } else { // json[r'expiresAt'] = null; } - if (this.showExif != null) { - json[r'showExif'] = this.showExif; + if (this.showMetadata != null) { + json[r'showMetadata'] = this.showMetadata; } else { - // json[r'showExif'] = null; + // json[r'showMetadata'] = null; } return json; } @@ -116,7 +116,7 @@ class SharedLinkEditDto { allowUpload: mapValueOfType(json, r'allowUpload'), description: mapValueOfType(json, r'description'), expiresAt: mapDateTime(json, r'expiresAt', ''), - showExif: mapValueOfType(json, r'showExif'), + showMetadata: mapValueOfType(json, r'showMetadata'), ); } return null; diff --git a/mobile/openapi/lib/model/shared_link_response_dto.dart b/mobile/openapi/lib/model/shared_link_response_dto.dart index 72a7299a4..33aa0577d 100644 --- a/mobile/openapi/lib/model/shared_link_response_dto.dart +++ b/mobile/openapi/lib/model/shared_link_response_dto.dart @@ -22,7 +22,7 @@ class SharedLinkResponseDto { required this.expiresAt, required this.id, required this.key, - required this.showExif, + required this.showMetadata, required this.type, required this.userId, }); @@ -51,7 +51,7 @@ class SharedLinkResponseDto { String key; - bool showExif; + bool showMetadata; SharedLinkType type; @@ -68,7 +68,7 @@ class SharedLinkResponseDto { other.expiresAt == expiresAt && other.id == id && other.key == key && - other.showExif == showExif && + other.showMetadata == showMetadata && other.type == type && other.userId == userId; @@ -84,12 +84,12 @@ class SharedLinkResponseDto { (expiresAt == null ? 0 : expiresAt!.hashCode) + (id.hashCode) + (key.hashCode) + - (showExif.hashCode) + + (showMetadata.hashCode) + (type.hashCode) + (userId.hashCode); @override - String toString() => 'SharedLinkResponseDto[album=$album, allowDownload=$allowDownload, allowUpload=$allowUpload, assets=$assets, createdAt=$createdAt, description=$description, expiresAt=$expiresAt, id=$id, key=$key, showExif=$showExif, type=$type, userId=$userId]'; + String toString() => 'SharedLinkResponseDto[album=$album, allowDownload=$allowDownload, allowUpload=$allowUpload, assets=$assets, createdAt=$createdAt, description=$description, expiresAt=$expiresAt, id=$id, key=$key, showMetadata=$showMetadata, type=$type, userId=$userId]'; Map toJson() { final json = {}; @@ -114,7 +114,7 @@ class SharedLinkResponseDto { } json[r'id'] = this.id; json[r'key'] = this.key; - json[r'showExif'] = this.showExif; + json[r'showMetadata'] = this.showMetadata; json[r'type'] = this.type; json[r'userId'] = this.userId; return json; @@ -137,7 +137,7 @@ class SharedLinkResponseDto { expiresAt: mapDateTime(json, r'expiresAt', ''), id: mapValueOfType(json, r'id')!, key: mapValueOfType(json, r'key')!, - showExif: mapValueOfType(json, r'showExif')!, + showMetadata: mapValueOfType(json, r'showMetadata')!, type: SharedLinkType.fromJson(json[r'type'])!, userId: mapValueOfType(json, r'userId')!, ); @@ -195,7 +195,7 @@ class SharedLinkResponseDto { 'expiresAt', 'id', 'key', - 'showExif', + 'showMetadata', 'type', 'userId', }; diff --git a/mobile/openapi/test/asset_response_dto_test.dart b/mobile/openapi/test/asset_response_dto_test.dart index a5f972201..f450aae27 100644 --- a/mobile/openapi/test/asset_response_dto_test.dart +++ b/mobile/openapi/test/asset_response_dto_test.dart @@ -52,6 +52,11 @@ void main() { // TODO }); + // bool hasMetadata + test('to test the property `hasMetadata`', () async { + // TODO + }); + // String id test('to test the property `id`', () async { // TODO @@ -142,7 +147,6 @@ void main() { // TODO }); - // base64 encoded thumbhash // String thumbhash test('to test the property `thumbhash`', () async { // TODO diff --git a/mobile/openapi/test/shared_link_create_dto_test.dart b/mobile/openapi/test/shared_link_create_dto_test.dart index 397e6e902..e02cbe481 100644 --- a/mobile/openapi/test/shared_link_create_dto_test.dart +++ b/mobile/openapi/test/shared_link_create_dto_test.dart @@ -46,8 +46,8 @@ void main() { // TODO }); - // bool showExif (default value: true) - test('to test the property `showExif`', () async { + // bool showMetadata (default value: true) + test('to test the property `showMetadata`', () async { // TODO }); diff --git a/mobile/openapi/test/shared_link_edit_dto_test.dart b/mobile/openapi/test/shared_link_edit_dto_test.dart index 7df8a5e48..26fbb92fd 100644 --- a/mobile/openapi/test/shared_link_edit_dto_test.dart +++ b/mobile/openapi/test/shared_link_edit_dto_test.dart @@ -36,8 +36,8 @@ void main() { // TODO }); - // bool showExif - test('to test the property `showExif`', () async { + // bool showMetadata + test('to test the property `showMetadata`', () async { // TODO }); diff --git a/mobile/openapi/test/shared_link_response_dto_test.dart b/mobile/openapi/test/shared_link_response_dto_test.dart index 867fb0b13..fbe26b9ae 100644 --- a/mobile/openapi/test/shared_link_response_dto_test.dart +++ b/mobile/openapi/test/shared_link_response_dto_test.dart @@ -61,8 +61,8 @@ void main() { // TODO }); - // bool showExif - test('to test the property `showExif`', () async { + // bool showMetadata + test('to test the property `showMetadata`', () async { // TODO }); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 19b553b9e..6392669b1 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -5770,6 +5770,9 @@ "format": "date-time", "type": "string" }, + "hasMetadata": { + "type": "boolean" + }, "id": { "type": "string" }, @@ -5833,7 +5836,6 @@ "type": "array" }, "thumbhash": { - "description": "base64 encoded thumbhash", "nullable": true, "type": "string" }, @@ -5847,7 +5849,6 @@ }, "required": [ "type", - "id", "deviceAssetId", "deviceId", "ownerId", @@ -5855,19 +5856,21 @@ "originalPath", "originalFileName", "resized", - "thumbhash", "fileCreatedAt", "fileModifiedAt", "updatedAt", "isFavorite", "isArchived", "isTrashed", - "localDateTime", "isOffline", "isExternal", "isReadOnly", + "checksum", + "id", + "thumbhash", + "localDateTime", "duration", - "checksum" + "hasMetadata" ], "type": "object" }, @@ -7599,7 +7602,7 @@ "nullable": true, "type": "string" }, - "showExif": { + "showMetadata": { "default": true, "type": "boolean" }, @@ -7628,7 +7631,7 @@ "nullable": true, "type": "string" }, - "showExif": { + "showMetadata": { "type": "boolean" } }, @@ -7670,7 +7673,7 @@ "key": { "type": "string" }, - "showExif": { + "showMetadata": { "type": "boolean" }, "type": { @@ -7691,7 +7694,7 @@ "assets", "allowUpload", "allowDownload", - "showExif" + "showMetadata" ], "type": "object" }, diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 27abedd1e..3f7a9e33a 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -47,6 +47,7 @@ import { BulkIdsDto, MapMarkerResponseDto, MemoryLaneResponseDto, + SanitizedAssetResponseDto, TimeBucketResponseDto, mapAsset, } from './response-dto'; @@ -198,10 +199,17 @@ export class AssetService { return this.assetRepository.getTimeBuckets(dto); } - async getByTimeBucket(authUser: AuthUserDto, dto: TimeBucketAssetDto): Promise { + async getByTimeBucket( + authUser: AuthUserDto, + dto: TimeBucketAssetDto, + ): Promise { await this.timeBucketChecks(authUser, dto); const assets = await this.assetRepository.getByTimeBucket(dto.timeBucket, dto); - return assets.map(mapAsset); + if (authUser.isShowMetadata) { + return assets.map((asset) => mapAsset(asset)); + } else { + return assets.map((asset) => mapAsset(asset, true)); + } } async downloadFile(authUser: AuthUserDto, id: string): Promise { diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts index 53454056a..e7d5061be 100644 --- a/server/src/domain/asset/response-dto/asset-response.dto.ts +++ b/server/src/domain/asset/response-dto/asset-response.dto.ts @@ -6,43 +6,62 @@ import { UserResponseDto, mapUser } from '../../user/response-dto/user-response. import { ExifResponseDto, mapExif } from './exif-response.dto'; import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto'; -export class AssetResponseDto { +export class SanitizedAssetResponseDto { id!: string; + @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType }) + type!: AssetType; + thumbhash!: string | null; + resized!: boolean; + localDateTime!: Date; + duration!: string; + livePhotoVideoId?: string | null; + hasMetadata!: boolean; +} + +export class AssetResponseDto extends SanitizedAssetResponseDto { deviceAssetId!: string; deviceId!: string; ownerId!: string; owner?: UserResponseDto; libraryId!: string; - - @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType }) - type!: AssetType; originalPath!: string; originalFileName!: string; resized!: boolean; - /**base64 encoded thumbhash */ - thumbhash!: string | null; fileCreatedAt!: Date; fileModifiedAt!: Date; updatedAt!: Date; isFavorite!: boolean; isArchived!: boolean; isTrashed!: boolean; - localDateTime!: Date; isOffline!: boolean; isExternal!: boolean; isReadOnly!: boolean; - duration!: string; exifInfo?: ExifResponseDto; smartInfo?: SmartInfoResponseDto; - livePhotoVideoId?: string | null; tags?: TagResponseDto[]; people?: PersonResponseDto[]; /**base64 encoded sha1 hash */ checksum!: string; } -function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto { +export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetResponseDto { + const sanitizedAssetResponse: SanitizedAssetResponseDto = { + id: entity.id, + type: entity.type, + thumbhash: entity.thumbhash?.toString('base64') ?? null, + localDateTime: entity.localDateTime, + resized: !!entity.resizePath, + duration: entity.duration ?? '0:00:00.00000', + livePhotoVideoId: entity.livePhotoVideoId, + hasMetadata: false, + }; + + if (stripMetadata) { + return sanitizedAssetResponse as AssetResponseDto; + } + return { + ...sanitizedAssetResponse, id: entity.id, deviceAssetId: entity.deviceAssetId, ownerId: entity.ownerId, @@ -62,7 +81,7 @@ function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto { isArchived: entity.isArchived, isTrashed: !!entity.deletedAt, duration: entity.duration ?? '0:00:00.00000', - exifInfo: withExif ? (entity.exifInfo ? mapExif(entity.exifInfo) : undefined) : undefined, + exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined, smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined, livePhotoVideoId: entity.livePhotoVideoId, tags: entity.tags?.map(mapTag), @@ -71,17 +90,10 @@ function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto { isExternal: entity.isExternal, isOffline: entity.isOffline, isReadOnly: entity.isReadOnly, + hasMetadata: true, }; } -export function mapAsset(entity: AssetEntity): AssetResponseDto { - return _map(entity, true); -} - -export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto { - return _map(entity, false); -} - export class MemoryLaneResponseDto { title!: string; assets!: AssetResponseDto[]; diff --git a/server/src/domain/asset/response-dto/exif-response.dto.ts b/server/src/domain/asset/response-dto/exif-response.dto.ts index 8c9e5843c..cb0f8399a 100644 --- a/server/src/domain/asset/response-dto/exif-response.dto.ts +++ b/server/src/domain/asset/response-dto/exif-response.dto.ts @@ -52,3 +52,15 @@ export function mapExif(entity: ExifEntity): ExifResponseDto { projectionType: entity.projectionType, }; } + +export function mapSanitizedExif(entity: ExifEntity): ExifResponseDto { + return { + fileSizeInByte: entity.fileSizeInByte ? parseInt(entity.fileSizeInByte.toString()) : null, + orientation: entity.orientation, + dateTimeOriginal: entity.dateTimeOriginal, + timeZone: entity.timeZone, + projectionType: entity.projectionType, + exifImageWidth: entity.exifImageWidth, + exifImageHeight: entity.exifImageHeight, + }; +} diff --git a/server/src/domain/auth/auth.service.ts b/server/src/domain/auth/auth.service.ts index 141abb0b7..01cb5adac 100644 --- a/server/src/domain/auth/auth.service.ts +++ b/server/src/domain/auth/auth.service.ts @@ -380,7 +380,7 @@ export class AuthService { sharedLinkId: link.id, isAllowUpload: link.allowUpload, isAllowDownload: link.allowDownload, - isShowExif: link.showExif, + isShowMetadata: link.showExif, }; } } @@ -431,7 +431,7 @@ export class AuthService { isPublicUser: false, isAllowUpload: true, isAllowDownload: true, - isShowExif: true, + isShowMetadata: true, accessTokenId: token.id, }; } diff --git a/server/src/domain/auth/dto/auth-user.dto.ts b/server/src/domain/auth/dto/auth-user.dto.ts index 0f2c9e41d..a689096d8 100644 --- a/server/src/domain/auth/dto/auth-user.dto.ts +++ b/server/src/domain/auth/dto/auth-user.dto.ts @@ -6,7 +6,7 @@ export class AuthUserDto { sharedLinkId?: string; isAllowUpload?: boolean; isAllowDownload?: boolean; - isShowExif?: boolean; + isShowMetadata?: boolean; accessTokenId?: string; externalPath?: string | null; } diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index b806862df..162ab8fdb 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -97,7 +97,7 @@ export class PersonService { async getAssets(authUser: AuthUserDto, id: string): Promise { await this.access.requirePermission(authUser, Permission.PERSON_READ, id); const assets = await this.repository.getAssets(id); - return assets.map(mapAsset); + return assets.map((asset) => mapAsset(asset)); } async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise { diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index 5100e1b4a..ba637bb3b 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -154,7 +154,7 @@ export class SearchService { items: assets.items .map((item) => lookup[item.id]) .filter((item) => !!item) - .map(mapAsset), + .map((asset) => mapAsset(asset)), }, }; } diff --git a/server/src/domain/shared-link/shared-link-response.dto.ts b/server/src/domain/shared-link/shared-link-response.dto.ts index f3b0d1678..52592d36f 100644 --- a/server/src/domain/shared-link/shared-link-response.dto.ts +++ b/server/src/domain/shared-link/shared-link-response.dto.ts @@ -2,7 +2,7 @@ import { SharedLinkEntity, SharedLinkType } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import _ from 'lodash'; import { AlbumResponseDto, mapAlbumWithoutAssets } from '../album'; -import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '../asset'; +import { AssetResponseDto, mapAsset } from '../asset'; export class SharedLinkResponseDto { id!: string; @@ -17,8 +17,9 @@ export class SharedLinkResponseDto { assets!: AssetResponseDto[]; album?: AlbumResponseDto; allowUpload!: boolean; + allowDownload!: boolean; - showExif!: boolean; + showMetadata!: boolean; } export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseDto { @@ -35,15 +36,15 @@ export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseD type: sharedLink.type, createdAt: sharedLink.createdAt, expiresAt: sharedLink.expiresAt, - assets: assets.map(mapAsset), + assets: assets.map((asset) => mapAsset(asset)), album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, allowUpload: sharedLink.allowUpload, allowDownload: sharedLink.allowDownload, - showExif: sharedLink.showExif, + showMetadata: sharedLink.showExif, }; } -export function mapSharedLinkWithNoExif(sharedLink: SharedLinkEntity): SharedLinkResponseDto { +export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): SharedLinkResponseDto { const linkAssets = sharedLink.assets || []; const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset); @@ -57,10 +58,10 @@ export function mapSharedLinkWithNoExif(sharedLink: SharedLinkEntity): SharedLin type: sharedLink.type, createdAt: sharedLink.createdAt, expiresAt: sharedLink.expiresAt, - assets: assets.map(mapAssetWithoutExif), + assets: assets.map((asset) => mapAsset(asset, true)) as AssetResponseDto[], album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined, allowUpload: sharedLink.allowUpload, allowDownload: sharedLink.allowDownload, - showExif: sharedLink.showExif, + showMetadata: sharedLink.showExif, }; } diff --git a/server/src/domain/shared-link/shared-link.dto.ts b/server/src/domain/shared-link/shared-link.dto.ts index 0ea5b70c6..4c86afb62 100644 --- a/server/src/domain/shared-link/shared-link.dto.ts +++ b/server/src/domain/shared-link/shared-link.dto.ts @@ -34,7 +34,7 @@ export class SharedLinkCreateDto { @Optional() @IsBoolean() - showExif?: boolean = true; + showMetadata?: boolean = true; } export class SharedLinkEditDto { @@ -51,5 +51,5 @@ export class SharedLinkEditDto { allowDownload?: boolean; @Optional() - showExif?: boolean; + showMetadata?: boolean; } diff --git a/server/src/domain/shared-link/shared-link.service.spec.ts b/server/src/domain/shared-link/shared-link.service.spec.ts index ae3a5a374..f902d7a68 100644 --- a/server/src/domain/shared-link/shared-link.service.spec.ts +++ b/server/src/domain/shared-link/shared-link.service.spec.ts @@ -59,10 +59,10 @@ describe(SharedLinkService.name, () => { expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId); }); - it('should return not return exif', async () => { + it('should not return metadata', async () => { const authDto = authStub.adminSharedLinkNoExif; shareMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); - await expect(sut.getMine(authDto)).resolves.toEqual(sharedLinkResponseStub.readonlyNoExif); + await expect(sut.getMine(authDto)).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata); expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId); }); }); @@ -137,7 +137,7 @@ describe(SharedLinkService.name, () => { await sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, assetIds: [assetStub.image.id], - showExif: true, + showMetadata: true, allowDownload: true, allowUpload: true, }); diff --git a/server/src/domain/shared-link/shared-link.service.ts b/server/src/domain/shared-link/shared-link.service.ts index a3c19b8c4..06b5b7897 100644 --- a/server/src/domain/shared-link/shared-link.service.ts +++ b/server/src/domain/shared-link/shared-link.service.ts @@ -4,7 +4,7 @@ import { AccessCore, Permission } from '../access'; import { AssetIdErrorReason, AssetIdsDto, AssetIdsResponseDto } from '../asset'; import { AuthUserDto } from '../auth'; import { IAccessRepository, ICryptoRepository, ISharedLinkRepository } from '../repositories'; -import { SharedLinkResponseDto, mapSharedLink, mapSharedLinkWithNoExif } from './shared-link-response.dto'; +import { SharedLinkResponseDto, mapSharedLink, mapSharedLinkWithoutMetadata } from './shared-link-response.dto'; import { SharedLinkCreateDto, SharedLinkEditDto } from './shared-link.dto'; @Injectable() @@ -24,7 +24,7 @@ export class SharedLinkService { } async getMine(authUser: AuthUserDto): Promise { - const { sharedLinkId: id, isPublicUser, isShowExif } = authUser; + const { sharedLinkId: id, isPublicUser, isShowMetadata: isShowExif } = authUser; if (!isPublicUser || !id) { throw new ForbiddenException(); @@ -69,7 +69,7 @@ export class SharedLinkService { expiresAt: dto.expiresAt || null, allowUpload: dto.allowUpload ?? true, allowDownload: dto.allowDownload ?? true, - showExif: dto.showExif ?? true, + showExif: dto.showMetadata ?? true, }); return this.map(sharedLink, { withExif: true }); @@ -84,7 +84,7 @@ export class SharedLinkService { expiresAt: dto.expiresAt, allowUpload: dto.allowUpload, allowDownload: dto.allowDownload, - showExif: dto.showExif, + showExif: dto.showMetadata, }); return this.map(sharedLink, { withExif: true }); } @@ -157,6 +157,6 @@ export class SharedLinkService { } private map(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) { - return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithNoExif(sharedLink); + return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink); } } diff --git a/server/src/domain/tag/tag.service.ts b/server/src/domain/tag/tag.service.ts index cf4bffdaa..ea6dae9a0 100644 --- a/server/src/domain/tag/tag.service.ts +++ b/server/src/domain/tag/tag.service.ts @@ -47,7 +47,7 @@ export class TagService { async getAssets(authUser: AuthUserDto, id: string): Promise { await this.findOrFail(authUser, id); const assets = await this.repository.getAssets(authUser.id, id); - return assets.map(mapAsset); + return assets.map((asset) => mapAsset(asset)); } async addAssets(authUser: AuthUserDto, id: string, dto: AssetIdsDto): Promise { diff --git a/server/src/immich/api-v1/asset/asset.controller.ts b/server/src/immich/api-v1/asset/asset.controller.ts index ad6e160b3..9df52f710 100644 --- a/server/src/immich/api-v1/asset/asset.controller.ts +++ b/server/src/immich/api-v1/asset/asset.controller.ts @@ -186,7 +186,7 @@ export class AssetController { @SharedLinkRoute() @Get('/assetById/:id') getAssetById(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise { - return this.assetService.getAssetById(authUser, id); + return this.assetService.getAssetById(authUser, id) as Promise; } /** diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 9f2c25196..d3c1fe876 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -10,9 +10,9 @@ import { IStorageRepository, JobName, mapAsset, - mapAssetWithoutExif, mimeTypes, Permission, + SanitizedAssetResponseDto, UploadFile, } from '@app/domain'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, LibraryType } from '@app/infra/entities'; @@ -187,22 +187,29 @@ export class AssetService { return assets.map((asset) => mapAsset(asset)); } - public async getAssetById(authUser: AuthUserDto, assetId: string): Promise { + public async getAssetById( + authUser: AuthUserDto, + assetId: string, + ): Promise { await this.access.requirePermission(authUser, Permission.ASSET_READ, assetId); - const allowExif = this.getExifPermission(authUser); + const includeMetadata = this.getExifPermission(authUser); const asset = await this._assetRepository.getById(assetId); - const data = allowExif ? mapAsset(asset) : mapAssetWithoutExif(asset); + if (includeMetadata) { + const data = mapAsset(asset); - if (data.ownerId !== authUser.id) { - data.people = []; + if (data.ownerId !== authUser.id) { + data.people = []; + } + + if (authUser.isPublicUser) { + delete data.owner; + } + + return data; + } else { + return mapAsset(asset, true); } - - if (authUser.isPublicUser) { - delete data.owner; - } - - return data; } async serveThumbnail(authUser: AuthUserDto, assetId: string, query: GetAssetThumbnailDto, res: Res) { @@ -374,7 +381,7 @@ export class AssetService { } getExifPermission(authUser: AuthUserDto) { - return !authUser.isPublicUser || authUser.isShowExif; + return !authUser.isPublicUser || authUser.isShowMetadata; } private getThumbnailPath(asset: AssetEntity, format: GetAssetThumbnailFormatEnum) { diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index 4780906ad..f4f376e98 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -98,7 +98,7 @@ export class AssetController { @Authenticated({ isShared: true }) @Get('time-bucket') getByTimeBucket(@AuthUser() authUser: AuthUserDto, @Query() dto: TimeBucketAssetDto): Promise { - return this.service.getByTimeBucket(authUser, dto); + return this.service.getByTimeBucket(authUser, dto) as Promise; } @Post('jobs') diff --git a/server/test/api/shared-link-api.ts b/server/test/api/shared-link-api.ts index c34093b0a..d6179f6b6 100644 --- a/server/test/api/shared-link-api.ts +++ b/server/test/api/shared-link-api.ts @@ -10,4 +10,11 @@ export const sharedLinkApi = { expect(status).toBe(201); return body as SharedLinkResponseDto; }, + + getMySharedLink: async (server: any, key: string) => { + const { status, body } = await request(server).get('/shared-link/me').query({ key }); + + expect(status).toBe(200); + return body as SharedLinkResponseDto; + }, }; diff --git a/server/test/assets b/server/test/assets index 9e6e1bcc2..948f353e3 160000 --- a/server/test/assets +++ b/server/test/assets @@ -1 +1 @@ -Subproject commit 9e6e1bcc245e0ae0285bb596faf310ead851fac6 +Subproject commit 948f353e3c9b66156c86c86cf078e0746ec1598e diff --git a/server/test/e2e/shared-link.e2e-spec.ts b/server/test/e2e/shared-link.e2e-spec.ts index 2f88f7cef..3a52c15a0 100644 --- a/server/test/e2e/shared-link.e2e-spec.ts +++ b/server/test/e2e/shared-link.e2e-spec.ts @@ -1,11 +1,17 @@ import { AlbumResponseDto, LoginResponseDto, SharedLinkResponseDto } from '@app/domain'; import { PartnerController } from '@app/immich'; -import { SharedLinkType } from '@app/infra/entities'; +import { LibraryType, SharedLinkType } from '@app/infra/entities'; import { INestApplication } from '@nestjs/common'; import { api } from '@test/api'; import { db } from '@test/db'; import { errorStub, uuidStub } from '@test/fixtures'; -import { createTestApp } from '@test/test-utils'; +import { + IMMICH_TEST_ASSET_PATH, + IMMICH_TEST_ASSET_TEMP_PATH, + createTestApp, + restoreTempFolder, +} from '@test/test-utils'; +import { cp } from 'fs/promises'; import request from 'supertest'; const user1Dto = { @@ -18,24 +24,22 @@ const user1Dto = { describe(`${PartnerController.name} (e2e)`, () => { let app: INestApplication; let server: any; - let loginResponse: LoginResponseDto; - let accessToken: string; + let admin: LoginResponseDto; let user1: LoginResponseDto; let album: AlbumResponseDto; let sharedLink: SharedLinkResponseDto; beforeAll(async () => { - app = await createTestApp(); + app = await createTestApp(true); server = app.getHttpServer(); }); beforeEach(async () => { await db.reset(); await api.authApi.adminSignUp(server); - loginResponse = await api.authApi.adminLogin(server); - accessToken = loginResponse.accessToken; + admin = await api.authApi.adminLogin(server); - await api.userApi.create(server, accessToken, user1Dto); + await api.userApi.create(server, admin.accessToken, user1Dto); user1 = await api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password }); album = await api.albumApi.create(server, user1.accessToken, { albumName: 'shared with link' }); @@ -48,6 +52,7 @@ describe(`${PartnerController.name} (e2e)`, () => { afterAll(async () => { await db.disconnect(); await app.close(); + await restoreTempFolder(); }); describe('GET /shared-link', () => { @@ -68,7 +73,9 @@ describe(`${PartnerController.name} (e2e)`, () => { }); it('should not get shared links created by other users', async () => { - const { status, body } = await request(server).get('/shared-link').set('Authorization', `Bearer ${accessToken}`); + const { status, body } = await request(server) + .get('/shared-link') + .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toEqual([]); @@ -77,7 +84,9 @@ describe(`${PartnerController.name} (e2e)`, () => { describe('GET /shared-link/me', () => { it('should not require admin authentication', async () => { - const { status } = await request(server).get('/shared-link/me').set('Authorization', `Bearer ${accessToken}`); + const { status } = await request(server) + .get('/shared-link/me') + .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(403); }); @@ -104,7 +113,7 @@ describe(`${PartnerController.name} (e2e)`, () => { type: SharedLinkType.ALBUM, albumId: softDeletedAlbum.id, }); - await api.userApi.delete(server, accessToken, user1.userId); + await api.userApi.delete(server, admin.accessToken, user1.userId); const { status, body } = await request(server).get('/shared-link/me').query({ key: softDeletedAlbumLink.key }); @@ -133,7 +142,7 @@ describe(`${PartnerController.name} (e2e)`, () => { it('should not get shared link by id if user has not created the link or it does not exist', async () => { const { status, body } = await request(server) .get(`/shared-link/${sharedLink.id}`) - .set('Authorization', `Bearer ${accessToken}`); + .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(400); expect(body).toEqual(expect.objectContaining({ message: 'Shared link not found' })); @@ -248,4 +257,81 @@ describe(`${PartnerController.name} (e2e)`, () => { expect(status).toBe(200); }); }); + + describe('Shared link metadata', () => { + beforeEach(async () => { + await restoreTempFolder(); + + await cp( + `${IMMICH_TEST_ASSET_PATH}/metadata/gps-position/thompson-springs.jpg`, + `${IMMICH_TEST_ASSET_TEMP_PATH}/thompson-springs.jpg`, + ); + + await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/'); + + const library = await api.libraryApi.create(server, admin.accessToken, { + type: LibraryType.EXTERNAL, + importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], + }); + + await api.libraryApi.scanLibrary(server, admin.accessToken, library.id); + + const assets = await api.assetApi.getAllAssets(server, admin.accessToken); + + expect(assets).toHaveLength(1); + + album = await api.albumApi.create(server, admin.accessToken, { albumName: 'New album' }); + await api.albumApi.addAssets(server, admin.accessToken, album.id, { ids: [assets[0].id] }); + }); + + it('should return metadata for album shared link', async () => { + const sharedLink = await api.sharedLinkApi.create(server, admin.accessToken, { + type: SharedLinkType.ALBUM, + albumId: album.id, + }); + + const returnedLink = await api.sharedLinkApi.getMySharedLink(server, sharedLink.key); + + expect(returnedLink.assets).toHaveLength(1); + expect(returnedLink.album).toBeDefined(); + + const returnedAsset = returnedLink.assets[0]; + expect(returnedAsset).toEqual( + expect.objectContaining({ + originalFileName: 'thompson-springs', + resized: true, + localDateTime: '2022-01-10T15:15:44.310Z', + fileCreatedAt: '2022-01-10T19:15:44.310Z', + exifInfo: expect.objectContaining({ + longitude: -108.400968333333, + latitude: 39.115, + orientation: '1', + dateTimeOriginal: '2022-01-10T19:15:44.310Z', + timeZone: 'UTC-4', + state: 'Mesa County, Colorado', + country: 'United States of America', + }), + }), + ); + }); + + it('should not return metadata for album shared link without metadata', async () => { + const sharedLink = await api.sharedLinkApi.create(server, admin.accessToken, { + type: SharedLinkType.ALBUM, + albumId: album.id, + showMetadata: false, + }); + + const returnedLink = await api.sharedLinkApi.getMySharedLink(server, sharedLink.key); + + expect(returnedLink.assets).toHaveLength(1); + expect(returnedLink.album).toBeDefined(); + + const returnedAsset = returnedLink.assets[0]; + expect(returnedAsset).not.toHaveProperty('exifInfo'); + expect(returnedAsset).not.toHaveProperty('fileCreatedAt'); + expect(returnedAsset).not.toHaveProperty('originalFilename'); + expect(returnedAsset).not.toHaveProperty('originalPath'); + }); + }); }); diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index 154ba0f67..6a45c16af 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -48,7 +48,7 @@ export const authStub = { isPublicUser: false, isAllowUpload: true, isAllowDownload: true, - isShowExif: true, + isShowMetadata: true, accessTokenId: 'token-id', externalPath: null, }), @@ -59,7 +59,7 @@ export const authStub = { isPublicUser: false, isAllowUpload: true, isAllowDownload: true, - isShowExif: true, + isShowMetadata: true, accessTokenId: 'token-id', externalPath: null, }), @@ -70,7 +70,7 @@ export const authStub = { isPublicUser: false, isAllowUpload: true, isAllowDownload: true, - isShowExif: true, + isShowMetadata: true, accessTokenId: 'token-id', externalPath: '/data/user1', }), @@ -81,7 +81,7 @@ export const authStub = { isAllowUpload: true, isAllowDownload: true, isPublicUser: true, - isShowExif: true, + isShowMetadata: true, sharedLinkId: '123', }), adminSharedLinkNoExif: Object.freeze({ @@ -91,7 +91,7 @@ export const authStub = { isAllowUpload: true, isAllowDownload: true, isPublicUser: true, - isShowExif: false, + isShowMetadata: false, sharedLinkId: '123', }), readonlySharedLink: Object.freeze({ @@ -101,7 +101,7 @@ export const authStub = { isAllowUpload: false, isAllowDownload: false, isPublicUser: true, - isShowExif: true, + isShowMetadata: true, sharedLinkId: '123', accessTokenId: 'token-id', }), diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index a5d180abe..acb14c6b2 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -71,8 +71,20 @@ const assetResponse: AssetResponseDto = { checksum: 'ZmlsZSBoYXNo', isTrashed: false, libraryId: 'library-id', + hasMetadata: true, }; +const assetResponseWithoutMetadata = { + id: 'id_1', + type: AssetType.VIDEO, + resized: false, + thumbhash: null, + localDateTime: today, + duration: '0:00:00.00000', + livePhotoVideoId: null, + hasMetadata: false, +} as AssetResponseDto; + const albumResponse: AlbumResponseDto = { albumName: 'Test Album', description: '', @@ -253,7 +265,7 @@ export const sharedLinkResponseStub = { expiresAt: tomorrow, id: '123', key: sharedLinkBytes.toString('base64url'), - showExif: true, + showMetadata: true, type: SharedLinkType.ALBUM, userId: 'admin_id', }), @@ -267,7 +279,7 @@ export const sharedLinkResponseStub = { expiresAt: yesterday, id: '123', key: sharedLinkBytes.toString('base64url'), - showExif: true, + showMetadata: true, type: SharedLinkType.ALBUM, userId: 'admin_id', }), @@ -281,11 +293,11 @@ export const sharedLinkResponseStub = { description: null, allowUpload: false, allowDownload: false, - showExif: true, + showMetadata: true, album: albumResponse, assets: [assetResponse], }), - readonlyNoExif: Object.freeze({ + readonlyNoMetadata: Object.freeze({ id: '123', userId: 'admin_id', key: sharedLinkBytes.toString('base64url'), @@ -295,8 +307,8 @@ export const sharedLinkResponseStub = { description: null, allowUpload: false, allowDownload: false, - showExif: false, + showMetadata: false, album: { ...albumResponse, startDate: assetResponse.fileCreatedAt, endDate: assetResponse.fileCreatedAt }, - assets: [{ ...assetResponse, exifInfo: undefined }], + assets: [{ ...assetResponseWithoutMetadata, exifInfo: undefined }], }), }; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 2be639a0f..0c8d2673c 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -640,6 +640,12 @@ export interface AssetResponseDto { * @memberof AssetResponseDto */ 'fileModifiedAt': string; + /** + * + * @type {boolean} + * @memberof AssetResponseDto + */ + 'hasMetadata': boolean; /** * * @type {string} @@ -749,7 +755,7 @@ export interface AssetResponseDto { */ 'tags'?: Array; /** - * base64 encoded thumbhash + * * @type {string} * @memberof AssetResponseDto */ @@ -2882,7 +2888,7 @@ export interface SharedLinkCreateDto { * @type {boolean} * @memberof SharedLinkCreateDto */ - 'showExif'?: boolean; + 'showMetadata'?: boolean; /** * * @type {SharedLinkType} @@ -2927,7 +2933,7 @@ export interface SharedLinkEditDto { * @type {boolean} * @memberof SharedLinkEditDto */ - 'showExif'?: boolean; + 'showMetadata'?: boolean; } /** * @@ -2994,7 +3000,7 @@ export interface SharedLinkResponseDto { * @type {boolean} * @memberof SharedLinkResponseDto */ - 'showExif': boolean; + 'showMetadata': boolean; /** * * @type {SharedLinkType} diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 2394964d9..fe83d087b 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -28,6 +28,7 @@ export let showMotionPlayButton: boolean; export let isMotionPhotoPlaying = false; export let showDownloadButton: boolean; + export let showDetailButton: boolean; export let showSlideshow = false; const isOwner = asset.ownerId === $page.data.user?.id; @@ -133,7 +134,14 @@ title="Download" /> {/if} - dispatch('showDetail')} title="Info" /> + {#if showDetailButton} + dispatch('showDetail')} + title="Info" + /> + {/if} {#if isOwner} handleKeyboardPress(keyInfo); @@ -392,6 +393,7 @@ showZoomButton={asset.type === AssetTypeEnum.Image} showMotionPlayButton={!!asset.livePhotoVideoId} showDownloadButton={shouldShowDownloadButton} + showDetailButton={shouldShowDetailButton} showSlideshow={!!assetStore} on:goBack={closeViewer} on:showDetail={showDetailInfoHandler} @@ -433,9 +435,9 @@ on:close={closeViewer} on:onVideoEnded={() => (shouldPlayMotionPhoto = false)} /> - {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || asset.originalPath - .toLowerCase() - .endsWith('.insp')} + {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath + .toLowerCase() + .endsWith('.insp'))} {:else} diff --git a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte index 5ebc4d3e6..1c8506bdf 100644 --- a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte +++ b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte @@ -21,7 +21,7 @@ let description = ''; let allowDownload = true; let allowUpload = false; - let showExif = true; + let showMetadata = true; let expirationTime = ''; let shouldChangeExpirationTime = false; let canCopyImagesToClipboard = true; @@ -41,7 +41,7 @@ } allowUpload = editingLink.allowUpload; allowDownload = editingLink.allowDownload; - showExif = editingLink.showExif; + showMetadata = editingLink.showMetadata; albumId = editingLink.album?.id; assetIds = editingLink.assets.map(({ id }) => id); @@ -66,7 +66,7 @@ allowUpload, description, allowDownload, - showExif, + showMetadata, }, }); sharedLink = `${window.location.origin}/share/${data.key}`; @@ -119,9 +119,9 @@ sharedLinkEditDto: { description, expiresAt: shouldChangeExpirationTime ? expirationDate : undefined, - allowUpload: allowUpload, - allowDownload: allowDownload, - showExif: showExif, + allowUpload, + allowDownload, + showMetadata, }, }); @@ -184,7 +184,7 @@
- +
diff --git a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte index 664e6f315..b8e24ddcd 100644 --- a/web/src/lib/components/sharedlinks-page/shared-link-card.svelte +++ b/web/src/lib/components/sharedlinks-page/shared-link-card.svelte @@ -136,7 +136,7 @@
{/if} - {#if link.showExif} + {#if link.showMetadata}
From ed386dd12ac5302268adb01a1ad1d4916c2ce274 Mon Sep 17 00:00:00 2001 From: GenericGuy Date: Sat, 14 Oct 2023 03:50:18 +0200 Subject: [PATCH 2/3] fix(server): always show people with name, ignore count (#4414) --- server/src/infra/repositories/person.repository.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/infra/repositories/person.repository.ts b/server/src/infra/repositories/person.repository.ts index b5d73d197..d651b3380 100644 --- a/server/src/infra/repositories/person.repository.ts +++ b/server/src/infra/repositories/person.repository.ts @@ -72,7 +72,7 @@ export class PersonRepository implements IPersonRepository { .addOrderBy("NULLIF(person.name, '') IS NULL", 'ASC') .addOrderBy('COUNT(face.assetId)', 'DESC') .addOrderBy("NULLIF(person.name, '')", 'ASC', 'NULLS LAST') - .having('COUNT(face.assetId) >= :faces', { faces: options?.minimumFaceCount || 1 }) + .having("person.name != '' OR COUNT(face.assetId) >= :faces", { faces: options?.minimumFaceCount || 1 }) .groupBy('person.id') .limit(500); if (!options?.withHidden) { From d2807b8d6ab1a72f37a662423ddda54f41c742ce Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Sat, 14 Oct 2023 13:12:59 -0400 Subject: [PATCH 3/3] feat(web,server): offline/untracked files admin tool (#4447) * feat: admin repair orphans tool * chore: open api * fix: include upload folder * fix: bugs * feat: empty placeholder * fix: checks * feat: move buttons to top of page * feat: styling and clipboard * styling * better clicking hitbox * fix: show title on hover * feat: download report * restrict file access to immich related files * Add description --------- Co-authored-by: Alex Tran Co-authored-by: Daniel Dietzler --- cli/src/api/open-api/api.ts | 378 ++++++++++++++++++ mobile/openapi/.openapi-generator/FILES | 21 + mobile/openapi/README.md | 10 + mobile/openapi/doc/AuditApi.md | 163 ++++++++ mobile/openapi/doc/FileChecksumDto.md | 15 + mobile/openapi/doc/FileChecksumResponseDto.md | 16 + mobile/openapi/doc/FileReportDto.md | 16 + mobile/openapi/doc/FileReportFixDto.md | 15 + mobile/openapi/doc/FileReportItemDto.md | 19 + mobile/openapi/doc/PathEntityType.md | 14 + mobile/openapi/doc/PathType.md | 14 + mobile/openapi/lib/api.dart | 7 + mobile/openapi/lib/api/audit_api.dart | 130 ++++++ mobile/openapi/lib/api_client.dart | 14 + mobile/openapi/lib/api_helper.dart | 6 + .../openapi/lib/model/file_checksum_dto.dart | 100 +++++ .../lib/model/file_checksum_response_dto.dart | 106 +++++ mobile/openapi/lib/model/file_report_dto.dart | 108 +++++ .../lib/model/file_report_fix_dto.dart | 98 +++++ .../lib/model/file_report_item_dto.dart | 139 +++++++ .../openapi/lib/model/path_entity_type.dart | 88 ++++ mobile/openapi/lib/model/path_type.dart | 100 +++++ mobile/openapi/test/audit_api_test.dart | 15 + .../openapi/test/file_checksum_dto_test.dart | 27 ++ .../test/file_checksum_response_dto_test.dart | 32 ++ mobile/openapi/test/file_report_dto_test.dart | 32 ++ .../test/file_report_fix_dto_test.dart | 27 ++ .../test/file_report_item_dto_test.dart | 47 +++ .../openapi/test/path_entity_type_test.dart | 21 + mobile/openapi/test/path_type_test.dart | 21 + server/immich-openapi-specs.json | 223 +++++++++++ server/src/domain/audit/audit.dto.ts | 55 ++- ....service.spec.ts => audit.service.spec.ts} | 34 +- server/src/domain/audit/audit.service.ts | 189 ++++++++- .../src/domain/metadata/metadata.service.ts | 5 +- .../domain/repositories/asset.repository.ts | 1 + .../server-info/server-info.service.spec.ts | 26 +- .../domain/server-info/server-info.service.ts | 17 +- .../storage-template.service.ts | 2 +- server/src/domain/storage/storage.core.ts | 16 +- .../domain/storage/storage.service.spec.ts | 17 +- server/src/domain/storage/storage.service.ts | 14 +- .../immich/controllers/audit.controller.ts | 33 +- server/src/infra/entities/move.entity.ts | 6 +- .../infra/repositories/asset.repository.ts | 2 +- web/src/api/api.ts | 3 + web/src/api/open-api/api.ts | 378 ++++++++++++++++++ web/src/lib/assets/empty-4.svg | 1 + .../elements/buttons/link-button.svelte | 3 +- .../side-bar/admin-side-bar.svelte | 4 + web/src/lib/constants.ts | 1 + web/src/routes/admin/repair/+page.server.ts | 26 ++ web/src/routes/admin/repair/+page.svelte | 336 ++++++++++++++++ 53 files changed, 3104 insertions(+), 87 deletions(-) create mode 100644 mobile/openapi/doc/FileChecksumDto.md create mode 100644 mobile/openapi/doc/FileChecksumResponseDto.md create mode 100644 mobile/openapi/doc/FileReportDto.md create mode 100644 mobile/openapi/doc/FileReportFixDto.md create mode 100644 mobile/openapi/doc/FileReportItemDto.md create mode 100644 mobile/openapi/doc/PathEntityType.md create mode 100644 mobile/openapi/doc/PathType.md create mode 100644 mobile/openapi/lib/model/file_checksum_dto.dart create mode 100644 mobile/openapi/lib/model/file_checksum_response_dto.dart create mode 100644 mobile/openapi/lib/model/file_report_dto.dart create mode 100644 mobile/openapi/lib/model/file_report_fix_dto.dart create mode 100644 mobile/openapi/lib/model/file_report_item_dto.dart create mode 100644 mobile/openapi/lib/model/path_entity_type.dart create mode 100644 mobile/openapi/lib/model/path_type.dart create mode 100644 mobile/openapi/test/file_checksum_dto_test.dart create mode 100644 mobile/openapi/test/file_checksum_response_dto_test.dart create mode 100644 mobile/openapi/test/file_report_dto_test.dart create mode 100644 mobile/openapi/test/file_report_fix_dto_test.dart create mode 100644 mobile/openapi/test/file_report_item_dto_test.dart create mode 100644 mobile/openapi/test/path_entity_type_test.dart create mode 100644 mobile/openapi/test/path_type_test.dart rename server/src/domain/audit/{audi.service.spec.ts => audit.service.spec.ts} (64%) create mode 100644 web/src/lib/assets/empty-4.svg create mode 100644 web/src/routes/admin/repair/+page.server.ts create mode 100644 web/src/routes/admin/repair/+page.svelte diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 0c8d2673c..549cc59d0 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -1604,6 +1604,109 @@ export interface ExifResponseDto { */ 'timeZone'?: string | null; } +/** + * + * @export + * @interface FileChecksumDto + */ +export interface FileChecksumDto { + /** + * + * @type {Array} + * @memberof FileChecksumDto + */ + 'filenames': Array; +} +/** + * + * @export + * @interface FileChecksumResponseDto + */ +export interface FileChecksumResponseDto { + /** + * + * @type {string} + * @memberof FileChecksumResponseDto + */ + 'checksum': string; + /** + * + * @type {string} + * @memberof FileChecksumResponseDto + */ + 'filename': string; +} +/** + * + * @export + * @interface FileReportDto + */ +export interface FileReportDto { + /** + * + * @type {Array} + * @memberof FileReportDto + */ + 'extras': Array; + /** + * + * @type {Array} + * @memberof FileReportDto + */ + 'orphans': Array; +} +/** + * + * @export + * @interface FileReportFixDto + */ +export interface FileReportFixDto { + /** + * + * @type {Array} + * @memberof FileReportFixDto + */ + 'items': Array; +} +/** + * + * @export + * @interface FileReportItemDto + */ +export interface FileReportItemDto { + /** + * + * @type {string} + * @memberof FileReportItemDto + */ + 'checksum'?: string; + /** + * + * @type {string} + * @memberof FileReportItemDto + */ + 'entityId': string; + /** + * + * @type {PathEntityType} + * @memberof FileReportItemDto + */ + 'entityType': PathEntityType; + /** + * + * @type {PathType} + * @memberof FileReportItemDto + */ + 'pathType': PathType; + /** + * + * @type {string} + * @memberof FileReportItemDto + */ + 'pathValue': string; +} + + /** * * @export @@ -2186,6 +2289,40 @@ export interface OAuthConfigResponseDto { */ 'url'?: string; } +/** + * + * @export + * @enum {string} + */ + +export const PathEntityType = { + Asset: 'asset', + Person: 'person', + User: 'user' +} as const; + +export type PathEntityType = typeof PathEntityType[keyof typeof PathEntityType]; + + +/** + * + * @export + * @enum {string} + */ + +export const PathType = { + Original: 'original', + JpegThumbnail: 'jpeg_thumbnail', + WebpThumbnail: 'webp_thumbnail', + EncodedVideo: 'encoded_video', + Sidecar: 'sidecar', + Face: 'face', + Profile: 'profile' +} as const; + +export type PathType = typeof PathType[keyof typeof PathType]; + + /** * * @export @@ -8821,6 +8958,50 @@ export class AssetApi extends BaseAPI { */ export const AuditApiAxiosParamCreator = function (configuration?: Configuration) { return { + /** + * + * @param {FileReportFixDto} fileReportFixDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + fixAuditFiles: async (fileReportFixDto: FileReportFixDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'fileReportFixDto' is not null or undefined + assertParamExists('fixAuditFiles', 'fileReportFixDto', fileReportFixDto) + const localVarPath = `/audit/file-report/fix`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(fileReportFixDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {EntityType} entityType @@ -8875,6 +9056,88 @@ export const AuditApiAxiosParamCreator = function (configuration?: Configuration let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAuditFiles: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/audit/file-report`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {FileChecksumDto} fileChecksumDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getFileChecksums: async (fileChecksumDto: FileChecksumDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'fileChecksumDto' is not null or undefined + assertParamExists('getFileChecksums', 'fileChecksumDto', fileChecksumDto) + const localVarPath = `/audit/file-report/checksum`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(fileChecksumDto, localVarRequestOptions, configuration) + return { url: toPathString(localVarUrlObj), options: localVarRequestOptions, @@ -8890,6 +9153,16 @@ export const AuditApiAxiosParamCreator = function (configuration?: Configuration export const AuditApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = AuditApiAxiosParamCreator(configuration) return { + /** + * + * @param {FileReportFixDto} fileReportFixDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async fixAuditFiles(fileReportFixDto: FileReportFixDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.fixAuditFiles(fileReportFixDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {EntityType} entityType @@ -8902,6 +9175,25 @@ export const AuditApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getAuditDeletes(entityType, after, userId, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getAuditFiles(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAuditFiles(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {FileChecksumDto} fileChecksumDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getFileChecksums(fileChecksumDto: FileChecksumDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getFileChecksums(fileChecksumDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, } }; @@ -8912,6 +9204,15 @@ export const AuditApiFp = function(configuration?: Configuration) { export const AuditApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = AuditApiFp(configuration) return { + /** + * + * @param {AuditApiFixAuditFilesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + fixAuditFiles(requestParameters: AuditApiFixAuditFilesRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.fixAuditFiles(requestParameters.fileReportFixDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {AuditApiGetAuditDeletesRequest} requestParameters Request parameters. @@ -8921,9 +9222,40 @@ export const AuditApiFactory = function (configuration?: Configuration, basePath getAuditDeletes(requestParameters: AuditApiGetAuditDeletesRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.getAuditDeletes(requestParameters.entityType, requestParameters.after, requestParameters.userId, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAuditFiles(options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.getAuditFiles(options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {AuditApiGetFileChecksumsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getFileChecksums(requestParameters: AuditApiGetFileChecksumsRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.getFileChecksums(requestParameters.fileChecksumDto, options).then((request) => request(axios, basePath)); + }, }; }; +/** + * Request parameters for fixAuditFiles operation in AuditApi. + * @export + * @interface AuditApiFixAuditFilesRequest + */ +export interface AuditApiFixAuditFilesRequest { + /** + * + * @type {FileReportFixDto} + * @memberof AuditApiFixAuditFiles + */ + readonly fileReportFixDto: FileReportFixDto +} + /** * Request parameters for getAuditDeletes operation in AuditApi. * @export @@ -8952,6 +9284,20 @@ export interface AuditApiGetAuditDeletesRequest { readonly userId?: string } +/** + * Request parameters for getFileChecksums operation in AuditApi. + * @export + * @interface AuditApiGetFileChecksumsRequest + */ +export interface AuditApiGetFileChecksumsRequest { + /** + * + * @type {FileChecksumDto} + * @memberof AuditApiGetFileChecksums + */ + readonly fileChecksumDto: FileChecksumDto +} + /** * AuditApi - object-oriented interface * @export @@ -8959,6 +9305,17 @@ export interface AuditApiGetAuditDeletesRequest { * @extends {BaseAPI} */ export class AuditApi extends BaseAPI { + /** + * + * @param {AuditApiFixAuditFilesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuditApi + */ + public fixAuditFiles(requestParameters: AuditApiFixAuditFilesRequest, options?: AxiosRequestConfig) { + return AuditApiFp(this.configuration).fixAuditFiles(requestParameters.fileReportFixDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AuditApiGetAuditDeletesRequest} requestParameters Request parameters. @@ -8969,6 +9326,27 @@ export class AuditApi extends BaseAPI { public getAuditDeletes(requestParameters: AuditApiGetAuditDeletesRequest, options?: AxiosRequestConfig) { return AuditApiFp(this.configuration).getAuditDeletes(requestParameters.entityType, requestParameters.after, requestParameters.userId, options).then((request) => request(this.axios, this.basePath)); } + + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuditApi + */ + public getAuditFiles(options?: AxiosRequestConfig) { + return AuditApiFp(this.configuration).getAuditFiles(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {AuditApiGetFileChecksumsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuditApi + */ + public getFileChecksums(requestParameters: AuditApiGetFileChecksumsRequest, options?: AxiosRequestConfig) { + return AuditApiFp(this.configuration).getFileChecksums(requestParameters.fileChecksumDto, options).then((request) => request(this.axios, this.basePath)); + } } diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 494835c0a..bf699a313 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -59,6 +59,11 @@ doc/DownloadInfoDto.md doc/DownloadResponseDto.md doc/EntityType.md doc/ExifResponseDto.md +doc/FileChecksumDto.md +doc/FileChecksumResponseDto.md +doc/FileReportDto.md +doc/FileReportFixDto.md +doc/FileReportItemDto.md doc/ImportAssetDto.md doc/JobApi.md doc/JobCommand.md @@ -84,6 +89,8 @@ doc/OAuthCallbackDto.md doc/OAuthConfigDto.md doc/OAuthConfigResponseDto.md doc/PartnerApi.md +doc/PathEntityType.md +doc/PathType.md doc/PeopleResponseDto.md doc/PeopleUpdateDto.md doc/PeopleUpdateItem.md @@ -227,6 +234,11 @@ lib/model/download_info_dto.dart lib/model/download_response_dto.dart lib/model/entity_type.dart lib/model/exif_response_dto.dart +lib/model/file_checksum_dto.dart +lib/model/file_checksum_response_dto.dart +lib/model/file_report_dto.dart +lib/model/file_report_fix_dto.dart +lib/model/file_report_item_dto.dart lib/model/import_asset_dto.dart lib/model/job_command.dart lib/model/job_command_dto.dart @@ -248,6 +260,8 @@ lib/model/o_auth_authorize_response_dto.dart lib/model/o_auth_callback_dto.dart lib/model/o_auth_config_dto.dart lib/model/o_auth_config_response_dto.dart +lib/model/path_entity_type.dart +lib/model/path_type.dart lib/model/people_response_dto.dart lib/model/people_update_dto.dart lib/model/people_update_item.dart @@ -364,6 +378,11 @@ test/download_info_dto_test.dart test/download_response_dto_test.dart test/entity_type_test.dart test/exif_response_dto_test.dart +test/file_checksum_dto_test.dart +test/file_checksum_response_dto_test.dart +test/file_report_dto_test.dart +test/file_report_fix_dto_test.dart +test/file_report_item_dto_test.dart test/import_asset_dto_test.dart test/job_api_test.dart test/job_command_dto_test.dart @@ -389,6 +408,8 @@ test/o_auth_callback_dto_test.dart test/o_auth_config_dto_test.dart test/o_auth_config_response_dto_test.dart test/partner_api_test.dart +test/path_entity_type_test.dart +test/path_type_test.dart test/people_response_dto_test.dart test/people_update_dto_test.dart test/people_update_item_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 2865b1308..8217b2f29 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -117,7 +117,10 @@ Class | Method | HTTP request | Description *AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} | *AssetApi* | [**updateAssets**](doc//AssetApi.md#updateassets) | **PUT** /asset | *AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload | +*AuditApi* | [**fixAuditFiles**](doc//AuditApi.md#fixauditfiles) | **POST** /audit/file-report/fix | *AuditApi* | [**getAuditDeletes**](doc//AuditApi.md#getauditdeletes) | **GET** /audit/deletes | +*AuditApi* | [**getAuditFiles**](doc//AuditApi.md#getauditfiles) | **GET** /audit/file-report | +*AuditApi* | [**getFileChecksums**](doc//AuditApi.md#getfilechecksums) | **POST** /audit/file-report/checksum | *AuthenticationApi* | [**adminSignUp**](doc//AuthenticationApi.md#adminsignup) | **POST** /auth/admin-sign-up | *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | *AuthenticationApi* | [**getAuthDevices**](doc//AuthenticationApi.md#getauthdevices) | **GET** /auth/devices | @@ -247,6 +250,11 @@ Class | Method | HTTP request | Description - [DownloadResponseDto](doc//DownloadResponseDto.md) - [EntityType](doc//EntityType.md) - [ExifResponseDto](doc//ExifResponseDto.md) + - [FileChecksumDto](doc//FileChecksumDto.md) + - [FileChecksumResponseDto](doc//FileChecksumResponseDto.md) + - [FileReportDto](doc//FileReportDto.md) + - [FileReportFixDto](doc//FileReportFixDto.md) + - [FileReportItemDto](doc//FileReportItemDto.md) - [ImportAssetDto](doc//ImportAssetDto.md) - [JobCommand](doc//JobCommand.md) - [JobCommandDto](doc//JobCommandDto.md) @@ -268,6 +276,8 @@ Class | Method | HTTP request | Description - [OAuthCallbackDto](doc//OAuthCallbackDto.md) - [OAuthConfigDto](doc//OAuthConfigDto.md) - [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md) + - [PathEntityType](doc//PathEntityType.md) + - [PathType](doc//PathType.md) - [PeopleResponseDto](doc//PeopleResponseDto.md) - [PeopleUpdateDto](doc//PeopleUpdateDto.md) - [PeopleUpdateItem](doc//PeopleUpdateItem.md) diff --git a/mobile/openapi/doc/AuditApi.md b/mobile/openapi/doc/AuditApi.md index 63a1c97a3..8fbca70bc 100644 --- a/mobile/openapi/doc/AuditApi.md +++ b/mobile/openapi/doc/AuditApi.md @@ -9,9 +9,66 @@ All URIs are relative to */api* Method | HTTP request | Description ------------- | ------------- | ------------- +[**fixAuditFiles**](AuditApi.md#fixauditfiles) | **POST** /audit/file-report/fix | [**getAuditDeletes**](AuditApi.md#getauditdeletes) | **GET** /audit/deletes | +[**getAuditFiles**](AuditApi.md#getauditfiles) | **GET** /audit/file-report | +[**getFileChecksums**](AuditApi.md#getfilechecksums) | **POST** /audit/file-report/checksum | +# **fixAuditFiles** +> fixAuditFiles(fileReportFixDto) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = AuditApi(); +final fileReportFixDto = FileReportFixDto(); // FileReportFixDto | + +try { + api_instance.fixAuditFiles(fileReportFixDto); +} catch (e) { + print('Exception when calling AuditApi->fixAuditFiles: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **fileReportFixDto** | [**FileReportFixDto**](FileReportFixDto.md)| | + +### Return type + +void (empty response body) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: Not defined + +[[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) + # **getAuditDeletes** > AuditDeletesResponseDto getAuditDeletes(entityType, after, userId) @@ -71,3 +128,109 @@ 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) +# **getAuditFiles** +> FileReportDto getAuditFiles() + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = AuditApi(); + +try { + final result = api_instance.getAuditFiles(); + print(result); +} catch (e) { + print('Exception when calling AuditApi->getAuditFiles: $e\n'); +} +``` + +### Parameters +This endpoint does not need any parameter. + +### Return type + +[**FileReportDto**](FileReportDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[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) + +# **getFileChecksums** +> List getFileChecksums(fileChecksumDto) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = AuditApi(); +final fileChecksumDto = FileChecksumDto(); // FileChecksumDto | + +try { + final result = api_instance.getFileChecksums(fileChecksumDto); + print(result); +} catch (e) { + print('Exception when calling AuditApi->getFileChecksums: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **fileChecksumDto** | [**FileChecksumDto**](FileChecksumDto.md)| | + +### Return type + +[**List**](FileChecksumResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + +[[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) + diff --git a/mobile/openapi/doc/FileChecksumDto.md b/mobile/openapi/doc/FileChecksumDto.md new file mode 100644 index 000000000..b7070431f --- /dev/null +++ b/mobile/openapi/doc/FileChecksumDto.md @@ -0,0 +1,15 @@ +# openapi.model.FileChecksumDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**filenames** | **List** | | [default to const []] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/FileChecksumResponseDto.md b/mobile/openapi/doc/FileChecksumResponseDto.md new file mode 100644 index 000000000..9cdea7280 --- /dev/null +++ b/mobile/openapi/doc/FileChecksumResponseDto.md @@ -0,0 +1,16 @@ +# openapi.model.FileChecksumResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**checksum** | **String** | | +**filename** | **String** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/FileReportDto.md b/mobile/openapi/doc/FileReportDto.md new file mode 100644 index 000000000..c6fd73e2d --- /dev/null +++ b/mobile/openapi/doc/FileReportDto.md @@ -0,0 +1,16 @@ +# openapi.model.FileReportDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**extras** | **List** | | [default to const []] +**orphans** | [**List**](FileReportItemDto.md) | | [default to const []] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/FileReportFixDto.md b/mobile/openapi/doc/FileReportFixDto.md new file mode 100644 index 000000000..58135dcb0 --- /dev/null +++ b/mobile/openapi/doc/FileReportFixDto.md @@ -0,0 +1,15 @@ +# openapi.model.FileReportFixDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**items** | [**List**](FileReportItemDto.md) | | [default to const []] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/FileReportItemDto.md b/mobile/openapi/doc/FileReportItemDto.md new file mode 100644 index 000000000..8ba2b0a34 --- /dev/null +++ b/mobile/openapi/doc/FileReportItemDto.md @@ -0,0 +1,19 @@ +# openapi.model.FileReportItemDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**checksum** | **String** | | [optional] +**entityId** | **String** | | +**entityType** | [**PathEntityType**](PathEntityType.md) | | +**pathType** | [**PathType**](PathType.md) | | +**pathValue** | **String** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/PathEntityType.md b/mobile/openapi/doc/PathEntityType.md new file mode 100644 index 000000000..12783a48f --- /dev/null +++ b/mobile/openapi/doc/PathEntityType.md @@ -0,0 +1,14 @@ +# openapi.model.PathEntityType + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/PathType.md b/mobile/openapi/doc/PathType.md new file mode 100644 index 000000000..48e944368 --- /dev/null +++ b/mobile/openapi/doc/PathType.md @@ -0,0 +1,14 @@ +# openapi.model.PathType + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 2c6837cb7..48745a162 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -96,6 +96,11 @@ part 'model/download_info_dto.dart'; part 'model/download_response_dto.dart'; part 'model/entity_type.dart'; part 'model/exif_response_dto.dart'; +part 'model/file_checksum_dto.dart'; +part 'model/file_checksum_response_dto.dart'; +part 'model/file_report_dto.dart'; +part 'model/file_report_fix_dto.dart'; +part 'model/file_report_item_dto.dart'; part 'model/import_asset_dto.dart'; part 'model/job_command.dart'; part 'model/job_command_dto.dart'; @@ -117,6 +122,8 @@ part 'model/o_auth_authorize_response_dto.dart'; part 'model/o_auth_callback_dto.dart'; part 'model/o_auth_config_dto.dart'; part 'model/o_auth_config_response_dto.dart'; +part 'model/path_entity_type.dart'; +part 'model/path_type.dart'; part 'model/people_response_dto.dart'; part 'model/people_update_dto.dart'; part 'model/people_update_item.dart'; diff --git a/mobile/openapi/lib/api/audit_api.dart b/mobile/openapi/lib/api/audit_api.dart index 4eabd17c9..24b93f178 100644 --- a/mobile/openapi/lib/api/audit_api.dart +++ b/mobile/openapi/lib/api/audit_api.dart @@ -16,6 +16,45 @@ class AuditApi { final ApiClient apiClient; + /// Performs an HTTP 'POST /audit/file-report/fix' operation and returns the [Response]. + /// Parameters: + /// + /// * [FileReportFixDto] fileReportFixDto (required): + Future fixAuditFilesWithHttpInfo(FileReportFixDto fileReportFixDto,) async { + // ignore: prefer_const_declarations + final path = r'/audit/file-report/fix'; + + // ignore: prefer_final_locals + Object? postBody = fileReportFixDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [FileReportFixDto] fileReportFixDto (required): + Future fixAuditFiles(FileReportFixDto fileReportFixDto,) async { + final response = await fixAuditFilesWithHttpInfo(fileReportFixDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'GET /audit/deletes' operation and returns the [Response]. /// Parameters: /// @@ -76,4 +115,95 @@ class AuditApi { } return null; } + + /// Performs an HTTP 'GET /audit/file-report' operation and returns the [Response]. + Future getAuditFilesWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/audit/file-report'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getAuditFiles() async { + final response = await getAuditFilesWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'FileReportDto',) as FileReportDto; + + } + return null; + } + + /// Performs an HTTP 'POST /audit/file-report/checksum' operation and returns the [Response]. + /// Parameters: + /// + /// * [FileChecksumDto] fileChecksumDto (required): + Future getFileChecksumsWithHttpInfo(FileChecksumDto fileChecksumDto,) async { + // ignore: prefer_const_declarations + final path = r'/audit/file-report/checksum'; + + // ignore: prefer_final_locals + Object? postBody = fileChecksumDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [FileChecksumDto] fileChecksumDto (required): + Future?> getFileChecksums(FileChecksumDto fileChecksumDto,) async { + final response = await getFileChecksumsWithHttpInfo(fileChecksumDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(); + + } + return null; + } } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 1866c4688..9a98b4997 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -283,6 +283,16 @@ class ApiClient { return EntityTypeTypeTransformer().decode(value); case 'ExifResponseDto': return ExifResponseDto.fromJson(value); + case 'FileChecksumDto': + return FileChecksumDto.fromJson(value); + case 'FileChecksumResponseDto': + return FileChecksumResponseDto.fromJson(value); + case 'FileReportDto': + return FileReportDto.fromJson(value); + case 'FileReportFixDto': + return FileReportFixDto.fromJson(value); + case 'FileReportItemDto': + return FileReportItemDto.fromJson(value); case 'ImportAssetDto': return ImportAssetDto.fromJson(value); case 'JobCommand': @@ -325,6 +335,10 @@ class ApiClient { return OAuthConfigDto.fromJson(value); case 'OAuthConfigResponseDto': return OAuthConfigResponseDto.fromJson(value); + case 'PathEntityType': + return PathEntityTypeTypeTransformer().decode(value); + case 'PathType': + return PathTypeTypeTransformer().decode(value); case 'PeopleResponseDto': return PeopleResponseDto.fromJson(value); case 'PeopleUpdateDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index ee254516d..417a282e0 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -91,6 +91,12 @@ String parameterToString(dynamic value) { if (value is ModelType) { return ModelTypeTypeTransformer().encode(value).toString(); } + if (value is PathEntityType) { + return PathEntityTypeTypeTransformer().encode(value).toString(); + } + if (value is PathType) { + return PathTypeTypeTransformer().encode(value).toString(); + } if (value is SharedLinkType) { return SharedLinkTypeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/file_checksum_dto.dart b/mobile/openapi/lib/model/file_checksum_dto.dart new file mode 100644 index 000000000..e587586cc --- /dev/null +++ b/mobile/openapi/lib/model/file_checksum_dto.dart @@ -0,0 +1,100 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class FileChecksumDto { + /// Returns a new [FileChecksumDto] instance. + FileChecksumDto({ + this.filenames = const [], + }); + + List filenames; + + @override + bool operator ==(Object other) => identical(this, other) || other is FileChecksumDto && + other.filenames == filenames; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (filenames.hashCode); + + @override + String toString() => 'FileChecksumDto[filenames=$filenames]'; + + Map toJson() { + final json = {}; + json[r'filenames'] = this.filenames; + return json; + } + + /// Returns a new [FileChecksumDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static FileChecksumDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return FileChecksumDto( + filenames: json[r'filenames'] is List + ? (json[r'filenames'] as List).cast() + : const [], + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = FileChecksumDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = FileChecksumDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of FileChecksumDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = FileChecksumDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'filenames', + }; +} + diff --git a/mobile/openapi/lib/model/file_checksum_response_dto.dart b/mobile/openapi/lib/model/file_checksum_response_dto.dart new file mode 100644 index 000000000..2cfd87b4b --- /dev/null +++ b/mobile/openapi/lib/model/file_checksum_response_dto.dart @@ -0,0 +1,106 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class FileChecksumResponseDto { + /// Returns a new [FileChecksumResponseDto] instance. + FileChecksumResponseDto({ + required this.checksum, + required this.filename, + }); + + String checksum; + + String filename; + + @override + bool operator ==(Object other) => identical(this, other) || other is FileChecksumResponseDto && + other.checksum == checksum && + other.filename == filename; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (checksum.hashCode) + + (filename.hashCode); + + @override + String toString() => 'FileChecksumResponseDto[checksum=$checksum, filename=$filename]'; + + Map toJson() { + final json = {}; + json[r'checksum'] = this.checksum; + json[r'filename'] = this.filename; + return json; + } + + /// Returns a new [FileChecksumResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static FileChecksumResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return FileChecksumResponseDto( + checksum: mapValueOfType(json, r'checksum')!, + filename: mapValueOfType(json, r'filename')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = FileChecksumResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = FileChecksumResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of FileChecksumResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = FileChecksumResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'checksum', + 'filename', + }; +} + diff --git a/mobile/openapi/lib/model/file_report_dto.dart b/mobile/openapi/lib/model/file_report_dto.dart new file mode 100644 index 000000000..0d00938b8 --- /dev/null +++ b/mobile/openapi/lib/model/file_report_dto.dart @@ -0,0 +1,108 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class FileReportDto { + /// Returns a new [FileReportDto] instance. + FileReportDto({ + this.extras = const [], + this.orphans = const [], + }); + + List extras; + + List orphans; + + @override + bool operator ==(Object other) => identical(this, other) || other is FileReportDto && + other.extras == extras && + other.orphans == orphans; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (extras.hashCode) + + (orphans.hashCode); + + @override + String toString() => 'FileReportDto[extras=$extras, orphans=$orphans]'; + + Map toJson() { + final json = {}; + json[r'extras'] = this.extras; + json[r'orphans'] = this.orphans; + return json; + } + + /// Returns a new [FileReportDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static FileReportDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return FileReportDto( + extras: json[r'extras'] is List + ? (json[r'extras'] as List).cast() + : const [], + orphans: FileReportItemDto.listFromJson(json[r'orphans']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = FileReportDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = FileReportDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of FileReportDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = FileReportDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'extras', + 'orphans', + }; +} + diff --git a/mobile/openapi/lib/model/file_report_fix_dto.dart b/mobile/openapi/lib/model/file_report_fix_dto.dart new file mode 100644 index 000000000..8bf8062d3 --- /dev/null +++ b/mobile/openapi/lib/model/file_report_fix_dto.dart @@ -0,0 +1,98 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class FileReportFixDto { + /// Returns a new [FileReportFixDto] instance. + FileReportFixDto({ + this.items = const [], + }); + + List items; + + @override + bool operator ==(Object other) => identical(this, other) || other is FileReportFixDto && + other.items == items; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (items.hashCode); + + @override + String toString() => 'FileReportFixDto[items=$items]'; + + Map toJson() { + final json = {}; + json[r'items'] = this.items; + return json; + } + + /// Returns a new [FileReportFixDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static FileReportFixDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return FileReportFixDto( + items: FileReportItemDto.listFromJson(json[r'items']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = FileReportFixDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = FileReportFixDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of FileReportFixDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = FileReportFixDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'items', + }; +} + diff --git a/mobile/openapi/lib/model/file_report_item_dto.dart b/mobile/openapi/lib/model/file_report_item_dto.dart new file mode 100644 index 000000000..77b3f7250 --- /dev/null +++ b/mobile/openapi/lib/model/file_report_item_dto.dart @@ -0,0 +1,139 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class FileReportItemDto { + /// Returns a new [FileReportItemDto] instance. + FileReportItemDto({ + this.checksum, + required this.entityId, + required this.entityType, + required this.pathType, + required this.pathValue, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? checksum; + + String entityId; + + PathEntityType entityType; + + PathType pathType; + + String pathValue; + + @override + bool operator ==(Object other) => identical(this, other) || other is FileReportItemDto && + other.checksum == checksum && + other.entityId == entityId && + other.entityType == entityType && + other.pathType == pathType && + other.pathValue == pathValue; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (checksum == null ? 0 : checksum!.hashCode) + + (entityId.hashCode) + + (entityType.hashCode) + + (pathType.hashCode) + + (pathValue.hashCode); + + @override + String toString() => 'FileReportItemDto[checksum=$checksum, entityId=$entityId, entityType=$entityType, pathType=$pathType, pathValue=$pathValue]'; + + Map toJson() { + final json = {}; + if (this.checksum != null) { + json[r'checksum'] = this.checksum; + } else { + // json[r'checksum'] = null; + } + json[r'entityId'] = this.entityId; + json[r'entityType'] = this.entityType; + json[r'pathType'] = this.pathType; + json[r'pathValue'] = this.pathValue; + return json; + } + + /// Returns a new [FileReportItemDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static FileReportItemDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return FileReportItemDto( + checksum: mapValueOfType(json, r'checksum'), + entityId: mapValueOfType(json, r'entityId')!, + entityType: PathEntityType.fromJson(json[r'entityType'])!, + pathType: PathType.fromJson(json[r'pathType'])!, + pathValue: mapValueOfType(json, r'pathValue')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = FileReportItemDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = FileReportItemDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of FileReportItemDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = FileReportItemDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'entityId', + 'entityType', + 'pathType', + 'pathValue', + }; +} + diff --git a/mobile/openapi/lib/model/path_entity_type.dart b/mobile/openapi/lib/model/path_entity_type.dart new file mode 100644 index 000000000..bbc8d5158 --- /dev/null +++ b/mobile/openapi/lib/model/path_entity_type.dart @@ -0,0 +1,88 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class PathEntityType { + /// Instantiate a new enum with the provided [value]. + const PathEntityType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const asset = PathEntityType._(r'asset'); + static const person = PathEntityType._(r'person'); + static const user = PathEntityType._(r'user'); + + /// List of all possible values in this [enum][PathEntityType]. + static const values = [ + asset, + person, + user, + ]; + + static PathEntityType? fromJson(dynamic value) => PathEntityTypeTypeTransformer().decode(value); + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PathEntityType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [PathEntityType] to String, +/// and [decode] dynamic data back to [PathEntityType]. +class PathEntityTypeTypeTransformer { + factory PathEntityTypeTypeTransformer() => _instance ??= const PathEntityTypeTypeTransformer._(); + + const PathEntityTypeTypeTransformer._(); + + String encode(PathEntityType data) => data.value; + + /// Decodes a [dynamic value][data] to a PathEntityType. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + PathEntityType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'asset': return PathEntityType.asset; + case r'person': return PathEntityType.person; + case r'user': return PathEntityType.user; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [PathEntityTypeTypeTransformer] instance. + static PathEntityTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/path_type.dart b/mobile/openapi/lib/model/path_type.dart new file mode 100644 index 000000000..9cb02e0c9 --- /dev/null +++ b/mobile/openapi/lib/model/path_type.dart @@ -0,0 +1,100 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class PathType { + /// Instantiate a new enum with the provided [value]. + const PathType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const original = PathType._(r'original'); + static const jpegThumbnail = PathType._(r'jpeg_thumbnail'); + static const webpThumbnail = PathType._(r'webp_thumbnail'); + static const encodedVideo = PathType._(r'encoded_video'); + static const sidecar = PathType._(r'sidecar'); + static const face = PathType._(r'face'); + static const profile = PathType._(r'profile'); + + /// List of all possible values in this [enum][PathType]. + static const values = [ + original, + jpegThumbnail, + webpThumbnail, + encodedVideo, + sidecar, + face, + profile, + ]; + + static PathType? fromJson(dynamic value) => PathTypeTypeTransformer().decode(value); + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PathType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [PathType] to String, +/// and [decode] dynamic data back to [PathType]. +class PathTypeTypeTransformer { + factory PathTypeTypeTransformer() => _instance ??= const PathTypeTypeTransformer._(); + + const PathTypeTypeTransformer._(); + + String encode(PathType data) => data.value; + + /// Decodes a [dynamic value][data] to a PathType. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + PathType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'original': return PathType.original; + case r'jpeg_thumbnail': return PathType.jpegThumbnail; + case r'webp_thumbnail': return PathType.webpThumbnail; + case r'encoded_video': return PathType.encodedVideo; + case r'sidecar': return PathType.sidecar; + case r'face': return PathType.face; + case r'profile': return PathType.profile; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [PathTypeTypeTransformer] instance. + static PathTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/test/audit_api_test.dart b/mobile/openapi/test/audit_api_test.dart index 68ffede19..2ce8d3d86 100644 --- a/mobile/openapi/test/audit_api_test.dart +++ b/mobile/openapi/test/audit_api_test.dart @@ -17,10 +17,25 @@ void main() { // final instance = AuditApi(); group('tests for AuditApi', () { + //Future fixAuditFiles(FileReportFixDto fileReportFixDto) async + test('test fixAuditFiles', () async { + // TODO + }); + //Future getAuditDeletes(EntityType entityType, DateTime after, { String userId }) async test('test getAuditDeletes', () async { // TODO }); + //Future getAuditFiles() async + test('test getAuditFiles', () async { + // TODO + }); + + //Future> getFileChecksums(FileChecksumDto fileChecksumDto) async + test('test getFileChecksums', () async { + // TODO + }); + }); } diff --git a/mobile/openapi/test/file_checksum_dto_test.dart b/mobile/openapi/test/file_checksum_dto_test.dart new file mode 100644 index 000000000..6eb3a3902 --- /dev/null +++ b/mobile/openapi/test/file_checksum_dto_test.dart @@ -0,0 +1,27 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for FileChecksumDto +void main() { + // final instance = FileChecksumDto(); + + group('test FileChecksumDto', () { + // List filenames (default value: const []) + test('to test the property `filenames`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/file_checksum_response_dto_test.dart b/mobile/openapi/test/file_checksum_response_dto_test.dart new file mode 100644 index 000000000..a90fc6164 --- /dev/null +++ b/mobile/openapi/test/file_checksum_response_dto_test.dart @@ -0,0 +1,32 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for FileChecksumResponseDto +void main() { + // final instance = FileChecksumResponseDto(); + + group('test FileChecksumResponseDto', () { + // String checksum + test('to test the property `checksum`', () async { + // TODO + }); + + // String filename + test('to test the property `filename`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/file_report_dto_test.dart b/mobile/openapi/test/file_report_dto_test.dart new file mode 100644 index 000000000..a84304668 --- /dev/null +++ b/mobile/openapi/test/file_report_dto_test.dart @@ -0,0 +1,32 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for FileReportDto +void main() { + // final instance = FileReportDto(); + + group('test FileReportDto', () { + // List extras (default value: const []) + test('to test the property `extras`', () async { + // TODO + }); + + // List orphans (default value: const []) + test('to test the property `orphans`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/file_report_fix_dto_test.dart b/mobile/openapi/test/file_report_fix_dto_test.dart new file mode 100644 index 000000000..44e734429 --- /dev/null +++ b/mobile/openapi/test/file_report_fix_dto_test.dart @@ -0,0 +1,27 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for FileReportFixDto +void main() { + // final instance = FileReportFixDto(); + + group('test FileReportFixDto', () { + // List items (default value: const []) + test('to test the property `items`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/file_report_item_dto_test.dart b/mobile/openapi/test/file_report_item_dto_test.dart new file mode 100644 index 000000000..7e90322f7 --- /dev/null +++ b/mobile/openapi/test/file_report_item_dto_test.dart @@ -0,0 +1,47 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for FileReportItemDto +void main() { + // final instance = FileReportItemDto(); + + group('test FileReportItemDto', () { + // String checksum + test('to test the property `checksum`', () async { + // TODO + }); + + // String entityId + test('to test the property `entityId`', () async { + // TODO + }); + + // PathEntityType entityType + test('to test the property `entityType`', () async { + // TODO + }); + + // PathType pathType + test('to test the property `pathType`', () async { + // TODO + }); + + // String pathValue + test('to test the property `pathValue`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/path_entity_type_test.dart b/mobile/openapi/test/path_entity_type_test.dart new file mode 100644 index 000000000..7a9c9a714 --- /dev/null +++ b/mobile/openapi/test/path_entity_type_test.dart @@ -0,0 +1,21 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for PathEntityType +void main() { + + group('test PathEntityType', () { + + }); + +} diff --git a/mobile/openapi/test/path_type_test.dart b/mobile/openapi/test/path_type_test.dart new file mode 100644 index 000000000..20862a0ef --- /dev/null +++ b/mobile/openapi/test/path_type_test.dart @@ -0,0 +1,21 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for PathType +void main() { + + group('test PathType', () { + + }); + +} diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 6392669b1..cc649d784 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -2286,6 +2286,118 @@ ] } }, + "/audit/file-report": { + "get": { + "operationId": "getAuditFiles", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileReportDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Audit" + ] + } + }, + "/audit/file-report/checksum": { + "post": { + "operationId": "getFileChecksums", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileChecksumDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/FileChecksumResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Audit" + ] + } + }, + "/audit/file-report/fix": { + "post": { + "operationId": "fixAuditFiles", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileReportFixDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Audit" + ] + } + }, "/auth/admin-sign-up": { "post": { "operationId": "adminSignUp", @@ -6580,6 +6692,97 @@ }, "type": "object" }, + "FileChecksumDto": { + "properties": { + "filenames": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "filenames" + ], + "type": "object" + }, + "FileChecksumResponseDto": { + "properties": { + "checksum": { + "type": "string" + }, + "filename": { + "type": "string" + } + }, + "required": [ + "filename", + "checksum" + ], + "type": "object" + }, + "FileReportDto": { + "properties": { + "extras": { + "items": { + "type": "string" + }, + "type": "array" + }, + "orphans": { + "items": { + "$ref": "#/components/schemas/FileReportItemDto" + }, + "type": "array" + } + }, + "required": [ + "orphans", + "extras" + ], + "type": "object" + }, + "FileReportFixDto": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/FileReportItemDto" + }, + "type": "array" + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "FileReportItemDto": { + "properties": { + "checksum": { + "type": "string" + }, + "entityId": { + "format": "uuid", + "type": "string" + }, + "entityType": { + "$ref": "#/components/schemas/PathEntityType" + }, + "pathType": { + "$ref": "#/components/schemas/PathType" + }, + "pathValue": { + "type": "string" + } + }, + "required": [ + "entityId", + "entityType", + "pathType", + "pathValue" + ], + "type": "object" + }, "ImportAssetDto": { "properties": { "assetPath": { @@ -7027,6 +7230,26 @@ ], "type": "object" }, + "PathEntityType": { + "enum": [ + "asset", + "person", + "user" + ], + "type": "string" + }, + "PathType": { + "enum": [ + "original", + "jpeg_thumbnail", + "webp_thumbnail", + "encoded_video", + "sidecar", + "face", + "profile" + ], + "type": "string" + }, "PeopleResponseDto": { "properties": { "people": { diff --git a/server/src/domain/audit/audit.dto.ts b/server/src/domain/audit/audit.dto.ts index b437ed5b7..d941f9a1d 100644 --- a/server/src/domain/audit/audit.dto.ts +++ b/server/src/domain/audit/audit.dto.ts @@ -1,8 +1,10 @@ -import { EntityType } from '@app/infra/entities'; +import { AssetPathType, EntityType, PathType, PersonPathType, UserPathType } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsDate, IsEnum, IsUUID } from 'class-validator'; -import { Optional } from '../domain.util'; +import { IsArray, IsDate, IsEnum, IsString, IsUUID, ValidateNested } from 'class-validator'; +import { Optional, ValidateUUID } from '../domain.util'; + +const PathEnum = Object.values({ ...AssetPathType, ...PersonPathType, ...UserPathType }); export class AuditDeletesDto { @IsDate() @@ -19,7 +21,54 @@ export class AuditDeletesDto { userId?: string; } +export enum PathEntityType { + ASSET = 'asset', + PERSON = 'person', + USER = 'user', +} + export class AuditDeletesResponseDto { needsFullSync!: boolean; ids!: string[]; } + +export class FileReportDto { + orphans!: FileReportItemDto[]; + extras!: string[]; +} + +export class FileChecksumDto { + @IsString({ each: true }) + filenames!: string[]; +} + +export class FileChecksumResponseDto { + filename!: string; + checksum!: string; +} + +export class FileReportFixDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => FileReportItemDto) + items!: FileReportItemDto[]; +} + +// used both as request and response dto +export class FileReportItemDto { + @ValidateUUID() + entityId!: string; + + @ApiProperty({ enumName: 'PathEntityType', enum: PathEntityType }) + @IsEnum(PathEntityType) + entityType!: PathEntityType; + + @ApiProperty({ enumName: 'PathType', enum: PathEnum }) + @IsEnum(PathEnum) + pathType!: PathType; + + @IsString() + pathValue!: string; + + checksum?: string; +} diff --git a/server/src/domain/audit/audi.service.spec.ts b/server/src/domain/audit/audit.service.spec.ts similarity index 64% rename from server/src/domain/audit/audi.service.spec.ts rename to server/src/domain/audit/audit.service.spec.ts index 39b447330..5e68250fa 100644 --- a/server/src/domain/audit/audi.service.spec.ts +++ b/server/src/domain/audit/audit.service.spec.ts @@ -1,17 +1,45 @@ import { DatabaseAction, EntityType } from '@app/infra/entities'; -import { IAccessRepositoryMock, auditStub, authStub, newAccessRepositoryMock, newAuditRepositoryMock } from '@test'; -import { IAuditRepository } from '../repositories'; +import { + IAccessRepositoryMock, + auditStub, + authStub, + newAccessRepositoryMock, + newAssetRepositoryMock, + newAuditRepositoryMock, + newCryptoRepositoryMock, + newPersonRepositoryMock, + newStorageRepositoryMock, + newUserRepositoryMock, +} from '@test'; +import { + IAssetRepository, + IAuditRepository, + ICryptoRepository, + IPersonRepository, + IStorageRepository, + IUserRepository, +} from '../repositories'; import { AuditService } from './audit.service'; describe(AuditService.name, () => { let sut: AuditService; let accessMock: IAccessRepositoryMock; + let assetMock: jest.Mocked; let auditMock: jest.Mocked; + let cryptoMock: jest.Mocked; + let personMock: jest.Mocked; + let storageMock: jest.Mocked; + let userMock: jest.Mocked; beforeEach(async () => { accessMock = newAccessRepositoryMock(); + assetMock = newAssetRepositoryMock(); + cryptoMock = newCryptoRepositoryMock(); auditMock = newAuditRepositoryMock(); - sut = new AuditService(accessMock, auditMock); + personMock = newPersonRepositoryMock(); + storageMock = newStorageRepositoryMock(); + userMock = newUserRepositoryMock(); + sut = new AuditService(accessMock, assetMock, cryptoMock, personMock, auditMock, storageMock, userMock); }); it('should work', () => { diff --git a/server/src/domain/audit/audit.service.ts b/server/src/domain/audit/audit.service.ts index 7e1574d48..dc3d65b65 100644 --- a/server/src/domain/audit/audit.service.ts +++ b/server/src/domain/audit/audit.service.ts @@ -1,19 +1,44 @@ -import { DatabaseAction } from '@app/infra/entities'; -import { Inject, Injectable } from '@nestjs/common'; +import { AssetPathType, DatabaseAction, PersonPathType, UserPathType } from '@app/infra/entities'; +import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; import { DateTime } from 'luxon'; +import { resolve } from 'node:path'; import { AccessCore, Permission } from '../access'; import { AuthUserDto } from '../auth'; import { AUDIT_LOG_MAX_DURATION } from '../domain.constant'; -import { IAccessRepository, IAuditRepository } from '../repositories'; -import { AuditDeletesDto, AuditDeletesResponseDto } from './audit.dto'; +import { usePagination } from '../domain.util'; +import { JOBS_ASSET_PAGINATION_SIZE } from '../job'; +import { + IAccessRepository, + IAssetRepository, + IAuditRepository, + ICryptoRepository, + IPersonRepository, + IStorageRepository, + IUserRepository, +} from '../repositories'; +import { StorageCore, StorageFolder } from '../storage'; +import { + AuditDeletesDto, + AuditDeletesResponseDto, + FileChecksumDto, + FileChecksumResponseDto, + FileReportItemDto, + PathEntityType, +} from './audit.dto'; @Injectable() export class AuditService { private access: AccessCore; + private logger = new Logger(AuditService.name); constructor( @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, + @Inject(IPersonRepository) private personRepository: IPersonRepository, @Inject(IAuditRepository) private repository: IAuditRepository, + @Inject(IStorageRepository) private storageRepository: IStorageRepository, + @Inject(IUserRepository) private userRepository: IUserRepository, ) { this.access = new AccessCore(accessRepository); } @@ -40,4 +65,160 @@ export class AuditService { ids: audits.map(({ entityId }) => entityId), }; } + + async getChecksums(dto: FileChecksumDto) { + const results: FileChecksumResponseDto[] = []; + for (const filename of dto.filenames) { + if (!StorageCore.isImmichPath(filename)) { + throw new BadRequestException( + `Could not get the checksum of ${filename} because the file isn't accessible by Immich`, + ); + } + + const checksum = await this.cryptoRepository.hashFile(filename); + results.push({ filename, checksum: checksum.toString('base64') }); + } + return results; + } + + async fixItems(items: FileReportItemDto[]) { + for (const { entityId: id, pathType, pathValue } of items) { + if (!StorageCore.isImmichPath(pathValue)) { + throw new BadRequestException( + `Could not fix item ${id} with path ${pathValue} because the file isn't accessible by Immich`, + ); + } + + switch (pathType) { + case AssetPathType.ENCODED_VIDEO: + await this.assetRepository.save({ id, encodedVideoPath: pathValue }); + break; + + case AssetPathType.JPEG_THUMBNAIL: + await this.assetRepository.save({ id, resizePath: pathValue }); + break; + + case AssetPathType.WEBP_THUMBNAIL: + await this.assetRepository.save({ id, webpPath: pathValue }); + break; + + case AssetPathType.ORIGINAL: + await this.assetRepository.save({ id, originalPath: pathValue }); + break; + + case AssetPathType.SIDECAR: + await this.assetRepository.save({ id, sidecarPath: pathValue }); + break; + + case PersonPathType.FACE: + await this.personRepository.update({ id, thumbnailPath: pathValue }); + break; + + case UserPathType.PROFILE: + await this.userRepository.update(id, { profileImagePath: pathValue }); + break; + } + } + } + + async getFileReport() { + const fullPath = (filename: string) => resolve(filename); + const hasFile = (items: Set, filename: string) => items.has(filename) || items.has(fullPath(filename)); + const crawl = async (folder: StorageFolder) => + new Set(await this.storageRepository.crawl({ pathsToCrawl: [StorageCore.getBaseFolder(folder)] })); + + const uploadFiles = await crawl(StorageFolder.UPLOAD); + const libraryFiles = await crawl(StorageFolder.LIBRARY); + const thumbFiles = await crawl(StorageFolder.THUMBNAILS); + const videoFiles = await crawl(StorageFolder.ENCODED_VIDEO); + const profileFiles = await crawl(StorageFolder.PROFILE); + const allFiles = new Set(); + for (const list of [libraryFiles, thumbFiles, videoFiles, profileFiles, uploadFiles]) { + for (const item of list) { + allFiles.add(item); + } + } + + const track = (filename: string | null) => { + if (!filename) { + return; + } + allFiles.delete(filename); + allFiles.delete(fullPath(filename)); + }; + + this.logger.log( + `Found ${libraryFiles.size} original files, ${thumbFiles.size} thumbnails, ${videoFiles.size} encoded videos, ${profileFiles.size} profile files`, + ); + const pagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (options) => + this.assetRepository.getAll(options, { withDeleted: true }), + ); + + let assetCount = 0; + + const orphans: FileReportItemDto[] = []; + for await (const assets of pagination) { + assetCount += assets.length; + for (const { id, originalPath, resizePath, encodedVideoPath, webpPath, isExternal, checksum } of assets) { + for (const file of [originalPath, resizePath, encodedVideoPath, webpPath]) { + track(file); + } + + const entity = { entityId: id, entityType: PathEntityType.ASSET, checksum: checksum.toString('base64') }; + if ( + originalPath && + !hasFile(libraryFiles, originalPath) && + !hasFile(uploadFiles, originalPath) && + // Android motion assets + !hasFile(videoFiles, originalPath) && + // ignore external library assets + !isExternal + ) { + orphans.push({ ...entity, pathType: AssetPathType.ORIGINAL, pathValue: originalPath }); + } + if (resizePath && !hasFile(thumbFiles, resizePath)) { + orphans.push({ ...entity, pathType: AssetPathType.JPEG_THUMBNAIL, pathValue: resizePath }); + } + if (webpPath && !hasFile(thumbFiles, webpPath)) { + orphans.push({ ...entity, pathType: AssetPathType.WEBP_THUMBNAIL, pathValue: webpPath }); + } + if (encodedVideoPath && !hasFile(videoFiles, encodedVideoPath)) { + orphans.push({ ...entity, pathType: AssetPathType.WEBP_THUMBNAIL, pathValue: encodedVideoPath }); + } + } + } + + const users = await this.userRepository.getList(); + for (const { id, profileImagePath } of users) { + track(profileImagePath); + + const entity = { entityId: id, entityType: PathEntityType.USER }; + if (profileImagePath && !hasFile(profileFiles, profileImagePath)) { + orphans.push({ ...entity, pathType: UserPathType.PROFILE, pathValue: profileImagePath }); + } + } + + const people = await this.personRepository.getAll(); + for (const { id, thumbnailPath } of people) { + track(thumbnailPath); + const entity = { entityId: id, entityType: PathEntityType.PERSON }; + if (thumbnailPath && !hasFile(thumbFiles, thumbnailPath)) { + orphans.push({ ...entity, pathType: PersonPathType.FACE, pathValue: thumbnailPath }); + } + } + + this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${people.length} people`); + + const extras: string[] = []; + for (const file of allFiles) { + extras.push(file); + } + + // send as absolute paths + for (const orphan of orphans) { + orphan.pathValue = fullPath(orphan.pathValue); + } + + return { orphans, extras }; + } } diff --git a/server/src/domain/metadata/metadata.service.ts b/server/src/domain/metadata/metadata.service.ts index cc03537f5..2779df54c 100644 --- a/server/src/domain/metadata/metadata.service.ts +++ b/server/src/domain/metadata/metadata.service.ts @@ -289,6 +289,9 @@ export class MetadataService { }); const checksum = this.cryptoRepository.hashSha1(video); + const motionPath = this.storageCore.getAndroidMotionPath(asset); + this.storageCore.ensureFolders(motionPath); + let motionAsset = await this.assetRepository.getByChecksum(asset.ownerId, checksum); if (!motionAsset) { const createdAt = asset.fileCreatedAt ?? asset.createdAt; @@ -300,7 +303,7 @@ export class MetadataService { localDateTime: createdAt, checksum, ownerId: asset.ownerId, - originalPath: this.storageCore.getAndroidMotionPath(asset), + originalPath: motionPath, originalFileName: asset.originalFileName, isVisible: false, isReadOnly: false, diff --git a/server/src/domain/repositories/asset.repository.ts b/server/src/domain/repositories/asset.repository.ts index 89a4afbf1..5266c98ae 100644 --- a/server/src/domain/repositories/asset.repository.ts +++ b/server/src/domain/repositories/asset.repository.ts @@ -14,6 +14,7 @@ export interface AssetSearchOptions { trashedBefore?: Date; type?: AssetType; order?: 'ASC' | 'DESC'; + withDeleted?: boolean; } export interface LivePhotoSearchOptions { diff --git a/server/src/domain/server-info/server-info.service.spec.ts b/server/src/domain/server-info/server-info.service.spec.ts index 8655f7cc5..53115594c 100644 --- a/server/src/domain/server-info/server-info.service.spec.ts +++ b/server/src/domain/server-info/server-info.service.spec.ts @@ -1,40 +1,20 @@ -import { - newAssetRepositoryMock, - newMoveRepositoryMock, - newPersonRepositoryMock, - newStorageRepositoryMock, - newSystemConfigRepositoryMock, - newUserRepositoryMock, -} from '@test'; +import { newStorageRepositoryMock, newSystemConfigRepositoryMock, newUserRepositoryMock } from '@test'; import { serverVersion } from '../domain.constant'; -import { - IAssetRepository, - IMoveRepository, - IPersonRepository, - IStorageRepository, - ISystemConfigRepository, - IUserRepository, -} from '../repositories'; +import { IStorageRepository, ISystemConfigRepository, IUserRepository } from '../repositories'; import { ServerInfoService } from './server-info.service'; describe(ServerInfoService.name, () => { let sut: ServerInfoService; - let assetMock: jest.Mocked; let configMock: jest.Mocked; - let moveMock: jest.Mocked; - let personMock: jest.Mocked; let storageMock: jest.Mocked; let userMock: jest.Mocked; beforeEach(() => { - assetMock = newAssetRepositoryMock(); configMock = newSystemConfigRepositoryMock(); - moveMock = newMoveRepositoryMock(); - personMock = newPersonRepositoryMock(); storageMock = newStorageRepositoryMock(); userMock = newUserRepositoryMock(); - sut = new ServerInfoService(assetMock, configMock, moveMock, personMock, userMock, storageMock); + sut = new ServerInfoService(configMock, userMock, storageMock); }); it('should work', () => { diff --git a/server/src/domain/server-info/server-info.service.ts b/server/src/domain/server-info/server-info.service.ts index d68b48473..1406423ab 100644 --- a/server/src/domain/server-info/server-info.service.ts +++ b/server/src/domain/server-info/server-info.service.ts @@ -1,15 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { mimeTypes, serverVersion } from '../domain.constant'; import { asHumanReadable } from '../domain.util'; -import { - IAssetRepository, - IMoveRepository, - IPersonRepository, - IStorageRepository, - ISystemConfigRepository, - IUserRepository, - UserStatsQueryResponse, -} from '../repositories'; +import { IStorageRepository, ISystemConfigRepository, IUserRepository, UserStatsQueryResponse } from '../repositories'; import { StorageCore, StorageFolder } from '../storage'; import { SystemConfigCore } from '../system-config'; import { @@ -25,22 +17,17 @@ import { @Injectable() export class ServerInfoService { private configCore: SystemConfigCore; - private storageCore: StorageCore; constructor( - @Inject(IAssetRepository) assetRepository: IAssetRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, - @Inject(IMoveRepository) moveRepository: IMoveRepository, - @Inject(IPersonRepository) personRepository: IPersonRepository, @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, ) { this.configCore = SystemConfigCore.create(configRepository); - this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository); } async getInfo(): Promise { - const libraryBase = this.storageCore.getBaseFolder(StorageFolder.LIBRARY); + const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY); const diskInfo = await this.storageRepository.checkDiskUsage(libraryBase); const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2); diff --git a/server/src/domain/storage-template/storage-template.service.ts b/server/src/domain/storage-template/storage-template.service.ts index 6681e6062..b04ffc89a 100644 --- a/server/src/domain/storage-template/storage-template.service.ts +++ b/server/src/domain/storage-template/storage-template.service.ts @@ -90,7 +90,7 @@ export class StorageTemplateService { } this.logger.debug('Cleaning up empty directories...'); - const libraryFolder = this.storageCore.getBaseFolder(StorageFolder.LIBRARY); + const libraryFolder = StorageCore.getBaseFolder(StorageFolder.LIBRARY); await this.storageRepository.removeEmptyDirs(libraryFolder); this.logger.log('Finished storage template migration'); diff --git a/server/src/domain/storage/storage.core.ts b/server/src/domain/storage/storage.core.ts index 249b2857f..69e2bd799 100644 --- a/server/src/domain/storage/storage.core.ts +++ b/server/src/domain/storage/storage.core.ts @@ -1,6 +1,6 @@ import { AssetEntity, AssetPathType, PathType, PersonEntity, PersonPathType } from '@app/infra/entities'; import { Logger } from '@nestjs/common'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { APP_MEDIA_LOCATION } from '../domain.constant'; import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories'; @@ -32,14 +32,14 @@ export class StorageCore { ) {} getFolderLocation(folder: StorageFolder, userId: string) { - return join(this.getBaseFolder(folder), userId); + return join(StorageCore.getBaseFolder(folder), userId); } getLibraryFolder(user: { storageLabel: string | null; id: string }) { - return join(this.getBaseFolder(StorageFolder.LIBRARY), user.storageLabel || user.id); + return join(StorageCore.getBaseFolder(StorageFolder.LIBRARY), user.storageLabel || user.id); } - getBaseFolder(folder: StorageFolder) { + static getBaseFolder(folder: StorageFolder) { return join(APP_MEDIA_LOCATION, folder); } @@ -64,7 +64,11 @@ export class StorageCore { } isAndroidMotionPath(originalPath: string) { - return originalPath.startsWith(this.getBaseFolder(StorageFolder.ENCODED_VIDEO)); + return originalPath.startsWith(StorageCore.getBaseFolder(StorageFolder.ENCODED_VIDEO)); + } + + static isImmichPath(path: string) { + return resolve(path).startsWith(resolve(APP_MEDIA_LOCATION)); } async moveAssetFile(asset: AssetEntity, pathType: GeneratedAssetPath) { @@ -135,7 +139,7 @@ export class StorageCore { } removeEmptyDirs(folder: StorageFolder) { - return this.repository.removeEmptyDirs(this.getBaseFolder(folder)); + return this.repository.removeEmptyDirs(StorageCore.getBaseFolder(folder)); } private savePath(pathType: PathType, id: string, newPath: string) { diff --git a/server/src/domain/storage/storage.service.spec.ts b/server/src/domain/storage/storage.service.spec.ts index e197dee4a..0c5531e5f 100644 --- a/server/src/domain/storage/storage.service.spec.ts +++ b/server/src/domain/storage/storage.service.spec.ts @@ -1,25 +1,14 @@ -import { - newAssetRepositoryMock, - newMoveRepositoryMock, - newPersonRepositoryMock, - newStorageRepositoryMock, -} from '@test'; -import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories'; +import { newStorageRepositoryMock } from '@test'; +import { IStorageRepository } from '../repositories'; import { StorageService } from './storage.service'; describe(StorageService.name, () => { let sut: StorageService; - let assetMock: jest.Mocked; - let moveMock: jest.Mocked; - let personMock: jest.Mocked; let storageMock: jest.Mocked; beforeEach(async () => { - assetMock = newAssetRepositoryMock(); - moveMock = newMoveRepositoryMock(); - personMock = newPersonRepositoryMock(); storageMock = newStorageRepositoryMock(); - sut = new StorageService(assetMock, moveMock, personMock, storageMock); + sut = new StorageService(storageMock); }); it('should work', () => { diff --git a/server/src/domain/storage/storage.service.ts b/server/src/domain/storage/storage.service.ts index 629811313..0d7c9432e 100644 --- a/server/src/domain/storage/storage.service.ts +++ b/server/src/domain/storage/storage.service.ts @@ -1,24 +1,16 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { IDeleteFilesJob } from '../job'; -import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories'; +import { IStorageRepository } from '../repositories'; import { StorageCore, StorageFolder } from './storage.core'; @Injectable() export class StorageService { private logger = new Logger(StorageService.name); - private storageCore: StorageCore; - constructor( - @Inject(IAssetRepository) assetRepository: IAssetRepository, - @Inject(IMoveRepository) private moveRepository: IMoveRepository, - @Inject(IPersonRepository) personRepository: IPersonRepository, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, - ) { - this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository); - } + constructor(@Inject(IStorageRepository) private storageRepository: IStorageRepository) {} init() { - const libraryBase = this.storageCore.getBaseFolder(StorageFolder.LIBRARY); + const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY); this.storageRepository.mkdirSync(libraryBase); } diff --git a/server/src/immich/controllers/audit.controller.ts b/server/src/immich/controllers/audit.controller.ts index bb720323f..a50d33741 100644 --- a/server/src/immich/controllers/audit.controller.ts +++ b/server/src/immich/controllers/audit.controller.ts @@ -1,7 +1,16 @@ -import { AuditDeletesDto, AuditDeletesResponseDto, AuditService, AuthUserDto } from '@app/domain'; -import { Controller, Get, Query } from '@nestjs/common'; +import { + AuditDeletesDto, + AuditDeletesResponseDto, + AuditService, + AuthUserDto, + FileChecksumDto, + FileChecksumResponseDto, + FileReportDto, + FileReportFixDto, +} from '@app/domain'; +import { Body, Controller, Get, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { AuthUser, Authenticated } from '../app.guard'; +import { AdminRoute, AuthUser, Authenticated } from '../app.guard'; import { UseValidation } from '../app.utils'; @ApiTags('Audit') @@ -15,4 +24,22 @@ export class AuditController { getAuditDeletes(@AuthUser() authUser: AuthUserDto, @Query() dto: AuditDeletesDto): Promise { return this.service.getDeletes(authUser, dto); } + + @AdminRoute() + @Get('file-report') + getAuditFiles(): Promise { + return this.service.getFileReport(); + } + + @AdminRoute() + @Post('file-report/checksum') + getFileChecksums(@Body() dto: FileChecksumDto): Promise { + return this.service.getChecksums(dto); + } + + @AdminRoute() + @Post('file-report/fix') + fixAuditFiles(@Body() dto: FileReportFixDto): Promise { + return this.service.fixItems(dto.items); + } } diff --git a/server/src/infra/entities/move.entity.ts b/server/src/infra/entities/move.entity.ts index daeb7f4b4..de20cb973 100644 --- a/server/src/infra/entities/move.entity.ts +++ b/server/src/infra/entities/move.entity.ts @@ -34,4 +34,8 @@ export enum PersonPathType { FACE = 'face', } -export type PathType = AssetPathType | PersonPathType; +export enum UserPathType { + PROFILE = 'profile', +} + +export type PathType = AssetPathType | PersonPathType | UserPathType; diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 37153d86f..362b00d52 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -174,7 +174,7 @@ export class AssetRepository implements IAssetRepository { person: true, }, }, - withDeleted: !!options.trashedBefore, + withDeleted: options.withDeleted ?? !!options.trashedBefore, order: { // Ensures correct order when paginating createdAt: options.order ?? 'ASC', diff --git a/web/src/api/api.ts b/web/src/api/api.ts index 00b60dfca..9beb370d3 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -19,6 +19,7 @@ import { SystemConfigApi, UserApi, UserApiFp, + AuditApi, } from './open-api'; import { BASE_PATH } from './open-api/base'; import { DUMMY_BASE_URL, toPathString } from './open-api/common'; @@ -28,6 +29,7 @@ export class ImmichApi { public albumApi: AlbumApi; public libraryApi: LibraryApi; public assetApi: AssetApi; + public auditApi: AuditApi; public authenticationApi: AuthenticationApi; public jobApi: JobApi; public keyApi: APIKeyApi; @@ -51,6 +53,7 @@ export class ImmichApi { this.config = new Configuration(params); this.albumApi = new AlbumApi(this.config); + this.auditApi = new AuditApi(this.config); this.libraryApi = new LibraryApi(this.config); this.assetApi = new AssetApi(this.config); this.authenticationApi = new AuthenticationApi(this.config); diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 0c8d2673c..549cc59d0 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1604,6 +1604,109 @@ export interface ExifResponseDto { */ 'timeZone'?: string | null; } +/** + * + * @export + * @interface FileChecksumDto + */ +export interface FileChecksumDto { + /** + * + * @type {Array} + * @memberof FileChecksumDto + */ + 'filenames': Array; +} +/** + * + * @export + * @interface FileChecksumResponseDto + */ +export interface FileChecksumResponseDto { + /** + * + * @type {string} + * @memberof FileChecksumResponseDto + */ + 'checksum': string; + /** + * + * @type {string} + * @memberof FileChecksumResponseDto + */ + 'filename': string; +} +/** + * + * @export + * @interface FileReportDto + */ +export interface FileReportDto { + /** + * + * @type {Array} + * @memberof FileReportDto + */ + 'extras': Array; + /** + * + * @type {Array} + * @memberof FileReportDto + */ + 'orphans': Array; +} +/** + * + * @export + * @interface FileReportFixDto + */ +export interface FileReportFixDto { + /** + * + * @type {Array} + * @memberof FileReportFixDto + */ + 'items': Array; +} +/** + * + * @export + * @interface FileReportItemDto + */ +export interface FileReportItemDto { + /** + * + * @type {string} + * @memberof FileReportItemDto + */ + 'checksum'?: string; + /** + * + * @type {string} + * @memberof FileReportItemDto + */ + 'entityId': string; + /** + * + * @type {PathEntityType} + * @memberof FileReportItemDto + */ + 'entityType': PathEntityType; + /** + * + * @type {PathType} + * @memberof FileReportItemDto + */ + 'pathType': PathType; + /** + * + * @type {string} + * @memberof FileReportItemDto + */ + 'pathValue': string; +} + + /** * * @export @@ -2186,6 +2289,40 @@ export interface OAuthConfigResponseDto { */ 'url'?: string; } +/** + * + * @export + * @enum {string} + */ + +export const PathEntityType = { + Asset: 'asset', + Person: 'person', + User: 'user' +} as const; + +export type PathEntityType = typeof PathEntityType[keyof typeof PathEntityType]; + + +/** + * + * @export + * @enum {string} + */ + +export const PathType = { + Original: 'original', + JpegThumbnail: 'jpeg_thumbnail', + WebpThumbnail: 'webp_thumbnail', + EncodedVideo: 'encoded_video', + Sidecar: 'sidecar', + Face: 'face', + Profile: 'profile' +} as const; + +export type PathType = typeof PathType[keyof typeof PathType]; + + /** * * @export @@ -8821,6 +8958,50 @@ export class AssetApi extends BaseAPI { */ export const AuditApiAxiosParamCreator = function (configuration?: Configuration) { return { + /** + * + * @param {FileReportFixDto} fileReportFixDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + fixAuditFiles: async (fileReportFixDto: FileReportFixDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'fileReportFixDto' is not null or undefined + assertParamExists('fixAuditFiles', 'fileReportFixDto', fileReportFixDto) + const localVarPath = `/audit/file-report/fix`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(fileReportFixDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {EntityType} entityType @@ -8875,6 +9056,88 @@ export const AuditApiAxiosParamCreator = function (configuration?: Configuration let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAuditFiles: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/audit/file-report`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {FileChecksumDto} fileChecksumDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getFileChecksums: async (fileChecksumDto: FileChecksumDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'fileChecksumDto' is not null or undefined + assertParamExists('getFileChecksums', 'fileChecksumDto', fileChecksumDto) + const localVarPath = `/audit/file-report/checksum`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(fileChecksumDto, localVarRequestOptions, configuration) + return { url: toPathString(localVarUrlObj), options: localVarRequestOptions, @@ -8890,6 +9153,16 @@ export const AuditApiAxiosParamCreator = function (configuration?: Configuration export const AuditApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = AuditApiAxiosParamCreator(configuration) return { + /** + * + * @param {FileReportFixDto} fileReportFixDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async fixAuditFiles(fileReportFixDto: FileReportFixDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.fixAuditFiles(fileReportFixDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {EntityType} entityType @@ -8902,6 +9175,25 @@ export const AuditApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getAuditDeletes(entityType, after, userId, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getAuditFiles(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAuditFiles(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {FileChecksumDto} fileChecksumDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getFileChecksums(fileChecksumDto: FileChecksumDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getFileChecksums(fileChecksumDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, } }; @@ -8912,6 +9204,15 @@ export const AuditApiFp = function(configuration?: Configuration) { export const AuditApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = AuditApiFp(configuration) return { + /** + * + * @param {AuditApiFixAuditFilesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + fixAuditFiles(requestParameters: AuditApiFixAuditFilesRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.fixAuditFiles(requestParameters.fileReportFixDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {AuditApiGetAuditDeletesRequest} requestParameters Request parameters. @@ -8921,9 +9222,40 @@ export const AuditApiFactory = function (configuration?: Configuration, basePath getAuditDeletes(requestParameters: AuditApiGetAuditDeletesRequest, options?: AxiosRequestConfig): AxiosPromise { return localVarFp.getAuditDeletes(requestParameters.entityType, requestParameters.after, requestParameters.userId, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAuditFiles(options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.getAuditFiles(options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {AuditApiGetFileChecksumsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getFileChecksums(requestParameters: AuditApiGetFileChecksumsRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.getFileChecksums(requestParameters.fileChecksumDto, options).then((request) => request(axios, basePath)); + }, }; }; +/** + * Request parameters for fixAuditFiles operation in AuditApi. + * @export + * @interface AuditApiFixAuditFilesRequest + */ +export interface AuditApiFixAuditFilesRequest { + /** + * + * @type {FileReportFixDto} + * @memberof AuditApiFixAuditFiles + */ + readonly fileReportFixDto: FileReportFixDto +} + /** * Request parameters for getAuditDeletes operation in AuditApi. * @export @@ -8952,6 +9284,20 @@ export interface AuditApiGetAuditDeletesRequest { readonly userId?: string } +/** + * Request parameters for getFileChecksums operation in AuditApi. + * @export + * @interface AuditApiGetFileChecksumsRequest + */ +export interface AuditApiGetFileChecksumsRequest { + /** + * + * @type {FileChecksumDto} + * @memberof AuditApiGetFileChecksums + */ + readonly fileChecksumDto: FileChecksumDto +} + /** * AuditApi - object-oriented interface * @export @@ -8959,6 +9305,17 @@ export interface AuditApiGetAuditDeletesRequest { * @extends {BaseAPI} */ export class AuditApi extends BaseAPI { + /** + * + * @param {AuditApiFixAuditFilesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuditApi + */ + public fixAuditFiles(requestParameters: AuditApiFixAuditFilesRequest, options?: AxiosRequestConfig) { + return AuditApiFp(this.configuration).fixAuditFiles(requestParameters.fileReportFixDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AuditApiGetAuditDeletesRequest} requestParameters Request parameters. @@ -8969,6 +9326,27 @@ export class AuditApi extends BaseAPI { public getAuditDeletes(requestParameters: AuditApiGetAuditDeletesRequest, options?: AxiosRequestConfig) { return AuditApiFp(this.configuration).getAuditDeletes(requestParameters.entityType, requestParameters.after, requestParameters.userId, options).then((request) => request(this.axios, this.basePath)); } + + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuditApi + */ + public getAuditFiles(options?: AxiosRequestConfig) { + return AuditApiFp(this.configuration).getAuditFiles(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {AuditApiGetFileChecksumsRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuditApi + */ + public getFileChecksums(requestParameters: AuditApiGetFileChecksumsRequest, options?: AxiosRequestConfig) { + return AuditApiFp(this.configuration).getFileChecksums(requestParameters.fileChecksumDto, options).then((request) => request(this.axios, this.basePath)); + } } diff --git a/web/src/lib/assets/empty-4.svg b/web/src/lib/assets/empty-4.svg new file mode 100644 index 000000000..05aeb2b2a --- /dev/null +++ b/web/src/lib/assets/empty-4.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/lib/components/elements/buttons/link-button.svelte b/web/src/lib/components/elements/buttons/link-button.svelte index 0d0d36907..d5fe8b29b 100644 --- a/web/src/lib/components/elements/buttons/link-button.svelte +++ b/web/src/lib/components/elements/buttons/link-button.svelte @@ -6,8 +6,9 @@ import Button from './button.svelte'; export let color: Color = 'transparent-gray'; + export let disabled = false; - diff --git a/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte b/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte index 6a88602d6..76f87a3d9 100644 --- a/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/admin-side-bar.svelte @@ -7,6 +7,7 @@ import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte'; import Cog from 'svelte-material-icons/Cog.svelte'; import Server from 'svelte-material-icons/Server.svelte'; + import Tools from 'svelte-material-icons/Tools.svelte'; import Sync from 'svelte-material-icons/Sync.svelte'; @@ -27,6 +28,9 @@ + + +
diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 33d311ed0..71572ef7c 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -12,6 +12,7 @@ export enum AppRoute { ADMIN_SETTINGS = '/admin/system-settings', ADMIN_STATS = '/admin/server-status', ADMIN_JOBS = '/admin/jobs-status', + ADMIN_REPAIR = '/admin/repair', ALBUMS = '/albums', LIBRARIES = '/libraries', diff --git a/web/src/routes/admin/repair/+page.server.ts b/web/src/routes/admin/repair/+page.server.ts new file mode 100644 index 000000000..9f04e013c --- /dev/null +++ b/web/src/routes/admin/repair/+page.server.ts @@ -0,0 +1,26 @@ +import { AppRoute } from '$lib/constants'; +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load = (async ({ parent, locals: { api } }) => { + const { user } = await parent(); + + if (!user) { + throw redirect(302, AppRoute.AUTH_LOGIN); + } else if (!user.isAdmin) { + throw redirect(302, AppRoute.PHOTOS); + } + + const { + data: { orphans, extras }, + } = await api.auditApi.getAuditFiles(); + + return { + user, + orphans, + extras, + meta: { + title: 'Repair', + }, + }; +}) satisfies PageServerLoad; diff --git a/web/src/routes/admin/repair/+page.svelte b/web/src/routes/admin/repair/+page.svelte new file mode 100644 index 000000000..57b203df8 --- /dev/null +++ b/web/src/routes/admin/repair/+page.svelte @@ -0,0 +1,336 @@ + + + + +
+ handleRepair()} disabled={matches.length === 0 || repairing}> +
+ + Repair All +
+
+ handleCheckAll()} disabled={extras.length === 0 || checking}> +
+ + Check All +
+
+ handleDownload()} disabled={extras.length + orphans.length === 0}> +
+ + Export +
+
+ handleRefresh()}> +
+ + Refresh +
+
+
+
+
+ {#if matches.length + extras.length + orphans.length === 0} +
+ +
+ {:else} +
+ + + + + + + + {#each matches as match (match.extra.filename)} + handleSplit(match)} + > + + + + {/each} + +
+
+

MATCHES {matches.length ? `(${matches.length})` : ''}

+

These files are matched by their checksums

+
+
+ {match.orphan.pathValue} => + {match.extra.filename} + + ({match.orphan.entityType}/{match.orphan.pathType}) +
+ + + + + + + + + {#each orphans as orphan, index (index)} + + + + + + {/each} + +
+
+

OFFLINE PATHS {orphans.length ? `(${orphans.length})` : ''}

+

+ These files are the results of manually deletion of the default upload library +

+
+
copyToClipboard(orphan.pathValue)}> + + + {orphan.pathValue} + + ({orphan.entityType}) +
+ + + + + + + + + {#each extras as extra (extra.filename)} + handleCheckOne(extra.filename)} + title={extra.filename} + > + + + + {/each} + +
+
+

UNTRACKS FILES {extras.length ? `(${extras.length})` : ''}

+

+ These files are not tracked by the application. They can be the results of failed moves, + interrupted uploads, or left behind due to a bug +

+
+
copyToClipboard(extra.filename)}> + + + {extra.filename} + + {#if extra.checksum} + [sha1:{extra.checksum}] + {/if} + +
+
+ {/if} +
+
+