From d2807b8d6ab1a72f37a662423ddda54f41c742ce Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Sat, 14 Oct 2023 13:12:59 -0400 Subject: [PATCH] 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 | Bin 21061 -> 21697 bytes mobile/openapi/doc/AuditApi.md | Bin 2481 -> 8494 bytes mobile/openapi/doc/FileChecksumDto.md | Bin 0 -> 441 bytes mobile/openapi/doc/FileChecksumResponseDto.md | Bin 0 -> 453 bytes mobile/openapi/doc/FileReportDto.md | Bin 0 -> 529 bytes mobile/openapi/doc/FileReportFixDto.md | Bin 0 -> 473 bytes mobile/openapi/doc/FileReportItemDto.md | Bin 0 -> 603 bytes mobile/openapi/doc/PathEntityType.md | Bin 0 -> 380 bytes mobile/openapi/doc/PathType.md | Bin 0 -> 374 bytes mobile/openapi/lib/api.dart | Bin 6811 -> 7073 bytes mobile/openapi/lib/api/audit_api.dart | Bin 2478 -> 6821 bytes mobile/openapi/lib/api_client.dart | Bin 20580 -> 21186 bytes mobile/openapi/lib/api_helper.dart | Bin 5128 -> 5328 bytes .../openapi/lib/model/file_checksum_dto.dart | Bin 0 -> 2880 bytes .../lib/model/file_checksum_response_dto.dart | Bin 0 -> 3188 bytes mobile/openapi/lib/model/file_report_dto.dart | Bin 0 -> 3067 bytes .../lib/model/file_report_fix_dto.dart | Bin 0 -> 2804 bytes .../lib/model/file_report_item_dto.dart | Bin 0 -> 4300 bytes .../openapi/lib/model/path_entity_type.dart | Bin 0 -> 2754 bytes mobile/openapi/lib/model/path_type.dart | Bin 0 -> 3157 bytes mobile/openapi/test/audit_api_test.dart | Bin 637 -> 1053 bytes .../openapi/test/file_checksum_dto_test.dart | Bin 0 -> 603 bytes .../test/file_checksum_response_dto_test.dart | Bin 0 -> 694 bytes mobile/openapi/test/file_report_dto_test.dart | Bin 0 -> 733 bytes .../test/file_report_fix_dto_test.dart | Bin 0 -> 609 bytes .../test/file_report_item_dto_test.dart | Bin 0 -> 995 bytes .../openapi/test/path_entity_type_test.dart | Bin 0 -> 425 bytes mobile/openapi/test/path_type_test.dart | Bin 0 -> 413 bytes 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, 1704 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 2865b1308a8732c6013d9d85e9e2f7031e613e70..8217b2f292f93380dc8b3aa0c855c32ffca8da4c 100644 GIT binary patch delta 582 zcmX@Qgz?}?#tmJPL1~#4j-@G?C2pBHsl{4au^K7)$@=1%0r%K1f2hD77HJs07Ham|P~AApukm)UE|o9j&F6o>~I4WAb!K z`FbRi++DF71k}i-1#<<$7?3NSGg6bYi%WB{yB25{$oOQKA}kITMR74q<>Z4>vYWq1 zGOC9mTjElZ4-FDDQDDeuaw+I4L?ep_r4|?D=M|@7(>1x!O{^ZII0zh4s7l~sD8@oW z-7+h%DTWARQSVujnu|>XP@FM$^Gk=9%ub0V8LoLHnI)AWl?AC#{{^53!p#Ka%`7ZP126xuY!R~py02Mk7r~m)} diff --git a/mobile/openapi/doc/AuditApi.md b/mobile/openapi/doc/AuditApi.md index 63a1c97a3bb5088631cca0c12aaa2a58e65b6260..8fbca70bc704951dc3d8499927ccfde01cbc7ddb 100644 GIT binary patch delta 1162 zcmah{&x_MQ6sEXjL-ZgBdeDW3*+V+g2KJz`s|)qVdJ!$%1qBg_$<&T+W`@jETI{kn z1z|xM@Ne)WXfOU99z-vC)PKR7Z_+JJQ>{5A?|u2+_rCYNKk@F$tIzE&8?^cv(_3tp zDnjl@VMN> zvr2p8`rlcRF__R5Nz!lnIc6)uAn>H?$s2E_4Pxty$vAv_BJpG2|;j4Pe+lr|Snu5Sc;kS03q zfqCn#9d*#=hxcMm6P{}XXkt~Ey>_*RSFewt)E0&zbg1qZ9Q07qAb2WR1`*Ar9zsEt z6kJhwD6(N49Yk=euY00k=A!@RTq5NzLyb)E_^}c^fChTpN>eH|A?t8UXvk=nahjQ5 z{>SlZ?Z98NXQ%`R&+tbgx(+k_2#>I`WY)ddvnUmrm)_kY_=-N9B|AN)fbvWV#&MdU z3OJ7ClxC3dA;_dia;j<3G~bs`o}QUA>&A8)c2tX?#iFfZ>T z^9mr3PJ3GpWUw!vY>$s8Y|n8VDkdR{0yd-#@Cjj}fYg2Q!=`payD0X^4_<*-}pRWpI8?0wbE=@b)#86i!fmf4Kf6YS5E^Xpc zNR%?MrQF!a_pcnRJ3dTMLTiJa9(x8RXvsx}x@m-{>s8t@gWFsR^hw$@H)XkaSud;Y ux*SYls2vA%tn;5$M?p>P#5sJcm;2{`YExGVo$+KH|FHN9{1rYILVN;vc!^B_ literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/FileChecksumResponseDto.md b/mobile/openapi/doc/FileChecksumResponseDto.md new file mode 100644 index 0000000000000000000000000000000000000000..9cdea7280acd1df09db76bcdd128b3c5c7299e19 GIT binary patch literal 453 zcma)2!D<3A5WVLs2KHbLWWC#y?OG_Pi0w^;4VzJen@mW?L!lqvWV=vX3T-ao&3p4^ z-c&#Vy$-f^WU#NFY>$s;>@Ml*c9;VBgbkArRRt^32KbCHNFnI%qE9H=wlzAkur5$! zn2h!LXgn+CMKHN9(!psPGa1=p5*zIh-r@Bvl&`_mMH|?X&uBxO73${Y;tf*jFD#_& z#xzo9v6L?ytv?MQLWmE?9*e91 literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/FileReportDto.md b/mobile/openapi/doc/FileReportDto.md new file mode 100644 index 0000000000000000000000000000000000000000..c6fd73e2d01be98df05d313fc71bc3f935d4addf GIT binary patch literal 529 zcma)(L2JT55QXpiD+Y4V1QPFUDOj{1sEF+?1hQmDU7GAHn-N;*kMC?0v@ON%CCtlv z-z;wvAdgOaTXbZwEuL+QTl5ro#-vn|z>>59J|GMR@Va@^dsKB@8y#5LE}CI<*6+@^ zNkm4&=ssEp(k95yz!rP8)*ZqpyngujnPJ}q>)Dc%X-zDS{OX8!HRbuOS!Ef%bI^U3 zi8afXw&#Zz4%Y1-2T9b@;7%V~1}138^$cZINsxj+Xm?7M=jf%B1!E`02&EeTQj5-{ zLjDu3%JNCtiyK$5Y-r_PRW&Y($#y=St>ywq3^m6N9qZUezN3&2z4s|x($mf2pILc@ T9G&rG9e+cd7XDbCDy6;vpg*TW literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/FileReportFixDto.md b/mobile/openapi/doc/FileReportFixDto.md new file mode 100644 index 0000000000000000000000000000000000000000..58135dcb03e1b2ecede590f1df5e3a9f2107599e GIT binary patch literal 473 zcma)2!D<3A5WVLs2KJyE$a-%}L0JVsMWnZ7VZ&x@LpPbQ8KH%Kd=o2Z+e4d6m^bge z;k^PVpx41xjtmaf$_{wIKqIg0Tg@ayQNV_@0e(R^b0FycRX?NXy3Xj4g>{(^lM8-# zahzq>Oqkqf>e*>Orsc>Mqu6PO@BvTnA%8W-ezbut`84f_6GPp+1l~+a{WS|IEu)K4 zCT1h$#zua)hFhmyO7|#bJN<=L&Xh@&g$9rMG%zqhPd+j7F&8*77UxI1~;3KojnNs@ts6z)fQ?9ByaLQ zl1v89W2d|+tkc*Q&!)unHG|o|VfxwcN`bJMEq6=*r?0coRMmLVN*T2)mmA literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/PathEntityType.md b/mobile/openapi/doc/PathEntityType.md new file mode 100644 index 0000000000000000000000000000000000000000..12783a48f0fd041ede4fd761b6d21a84c8af289f GIT binary patch literal 380 zcma)1!HNPg487+o0&`Fgt@quN;v58(VeuxylxcP?+NN~kLGa_Z9ffthXfEL;FYo15 z$dQ7HPJ6a=(v6Wr9mL6Hdx->4Rk&hfQ4WLy1EcAV=7*qdTWgYl^O2L_=sy3r>rIPh zA&fo?bx>+jUM6wW!^U{RD;AU77SwmiMR6Q5+OTAXdZ;{nKuW#B0K45h|B1r5F1hgJ zdfD&CT2H^~*PmT%tXyo!R2?1KE0xEVf~PXOQ- DG&OaR literal 0 HcmV?d00001 diff --git a/mobile/openapi/doc/PathType.md b/mobile/openapi/doc/PathType.md new file mode 100644 index 0000000000000000000000000000000000000000..48e944368845519625680d34e36c0dd55ef8e033 GIT binary patch literal 374 zcma)1!HNPg487+o0&{RZwBFs5q6a}`5N{$(nMS8*o6?B~!H?f|R#?}|ZZ6>^FYo15 z$dQ7HPJ6a=)wPjse~$!ERk&tjQ98n5htaebb0%n-#+oGHeB>lJdd{a$rAbjMgwbcA z4oY>(%Os9^*cwmxz+#fyg8DYOD2_u$Tb9gFFO`QcNU6Utz~L~@f1)sMN+P_vUXI5P zttY$XY_VBtZ!62~%~KFleAS7Q8WQ$n{JEahtAF0+yo!R2?1KD~_%?hq9|OP_kMnaB literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 2c6837cb7189d76a1af9c180f7a69837c2b5494f..48745a162c93ef63fe69f0e8bab9e05617840d3a 100644 GIT binary patch delta 131 zcmbPjy3l;X5gy*O%$(Ht@A?u}UfnCMSxEZobREj|%`?4KI=a delta 21 dcmZ2zKHGG|5uVMRyi&}Q4FuUXzY^Ha1pr(j2iE`q diff --git a/mobile/openapi/lib/api/audit_api.dart b/mobile/openapi/lib/api/audit_api.dart index 4eabd17c9c0fd681e82a5132a10f47fe027f60fc..24b93f178fe8594e2c2690e6cc3de92e3dd6f6e0 100644 GIT binary patch delta 1190 zcmb7CK~ED=5YDzjDVV4&CK3}eB#_;Otv#6-TG}Y4kpzMP5>FmB+gEs6_pQ5c4QL1+ zO!WS^dC`McW8~)50~dY(CoUZM6UeWPu3v_qIARmMSf*ttF)v-+nB`bI@J10#?Xg$2??hTfNWSt|RiHrqjsi z8JAm6qzsn)jw}8HcR?M(}n6rxG>)puh$<+VMSjheUvAUmXvR&t+IL zo@lYuO-DI}ePj3Fi*bEKeKn@Xh#Pp2S@j_|QPPsK)@tK2tA*S?X}O@(0obr(owMGf>mf0-;w^AB!`%=LGyMNZuCnUPkdNou)aBTTBa=VIqZW zmhQ~c{;Xj7f=o#|2vL%x@eWDSV-k?Q_5!sKHEPpf3gDjS4p428MkzGXvTLIWcsKb~pNr*lDTJs0 delta 15 WcmZ2#x=wh*F2>CfY@gVfYPkS2I|Z5m diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 1866c468834715b7082152e871bf1f93f95cf27f..9a98b4997ab8eaf952fccb0f7380a85315e66851 100644 GIT binary patch delta 229 zcmaE|fbq~$#tp}`dE7E{Qk^qWle3FUb0<5>h@%Q5N-3iXG)l{(3MlGnbAikVN-fAQ zDgl{*%mwP_Maa5kR)7?si2&8`A=G%5q=ManE(BDy`It5@J9A=5#$;J-IhFtr!&=*t Y9mEK!ESMaqAPNz5)HdFH$!L-+0L%MOD*ylh delta 19 bcmX@Kl<~;|#tp}`Hy7#hvTrstohSR9lX$O~$m1Uq*kv9MP>dm=Ta61B+VEtdXt+h13K=Lgt z2WBe}F1eIPXg_!uPz(TD2on)@RI`B?TtTp0h4^@XiWvZW62|`Z)oLNvGw5vHqc`e? zYmK#|*|kz7Wv`i48sa!uT>s!H)?^E=Pa&Rx$0%iGK--4xlV8956^Q$lo}9Qey+J?G z_<>A>w6NwD3TBFTD&x4fi_$5Egd%y$jbVC6L?d?vH^MlxpsO)zBX}VC)p|U@Dt{5U z0>=Z8mtCazd3U)QJLo>MssS~GY=`iRbcW$R7$q^7XD1humGA?XkePedxBD{>j6TD?IU0;fn@cQp z6@siw)CY-SD~yw5kB>_89DBm4PuD3b$Rwn#1iQz|aEXz~FrRiEP)0qBok^6kPVjf!1YQHwG!2RavI9)ce>xtf$C$wcsG~Y-@_7vb zu{_|AdCSv&LbKrXz;k-gu@npQq`$rUZBixdJHi_TU0V-om_doDT_BqWP*QFo+`0~5 zYqe$9B{mi0tTa5_w^({4H8*u>Nw34$%azZJxS)tc1tkF|Cl04bm`i2no67FmkVp^C z5^PA~2sg%w=1lLNw*-1}=EwIy!%^N80-VR?)R<9jqE}X{b4nlt1CN!WOthtwSgHfP)v_3Z0s9iBle}=}m@$ zVVfRN11acU1I@^uo*dQ1QyUax^c8b_r~jjyCraWe@@ht0ulXNU50-Vv@PLMh=;?s# zi74#)^R83FPFY?U2W^f!^#n12tAcmjx9^aEGBbDtGvv5NSzL3#BhKKq)By}Wd|h19 fTUkTBf9q8-W#9ixf4gD!Ds~UzRYK@{+!%QZr{x(S1|qXX%fQK3~pv~xSm~onErEy zW@Pzx#gvI(N58!u(5qONQt>2{D#=8_FQ6`K%~uI8_=cAz^zUL*No7uYuwu)0I;m=t zDE@CP6uOsegMTZg@W0_oV{oNUyQi|$hLt7}D@-Ud!IgE_CzBPTmmqAhDUZ;v$0(qf03Hi4xzJJCOcPA^~D z!kuAz8qCtx2*)PAOfvD$4@wrLfi*XfGo8PeD~?6lG_5d6k0npJ)=ce4PbiU`YoV=K z;PHukfOGi8?f8_3od?MqjE!^;frV%|JuQ@lPZhuLPhoqm_=Qim9NIM3ta{_3hOpWp zw;~;bksq}zF^$tlCQ4SoS6qS^9$4S+FCF04VxfjU>u`zYN0R!Vp)ciN=vSPfvP!pM zX<<)t`I>Ri`Lsosi5E91}*Q2~Lz;u1EoHp|21Bu%%ec8xCBFo&c;WumzItu`a(3 z$ZLlhYZ9eQn+GHXu325T)BNZwCBS0ckEihhHmv%a5SwN5UB#Q&KOfys=gavA2I45fBfp2pTj$2|VfabQg@Fz}+b_d<3hj`Q8qowUS$QS70kc z#tO~Dy~ov|s<^HTLz3^yu~@oFi64qg^iWYaV618*p>L#_Y$~(!p%EDzc|nswZgEUo z)5E?e)e#=|5fzbo?b+}c4Pu1aK`UpmRc|*k&3Zai#8xY(sE((@+v9$Oe&b~%oi6ae zU4VbGQEY!U)O5#6A9|##lLYyS9Y|=^9Xox|{21zTfmJ+8h2<=ERQ0tDPMqfIaRKcu zA;Awd-bd)(;pY1ht+=Xop4f=q1lRsh#F0DLvkA8GB5U5E>j|O}wuN*2#&1b`%rI%$#IEVjIJ>~oV;?@=-iB4{IH-GUF#_5VJu#EK0>6h z-i8~KA6cB#O$g6xY>d%W)Z-a{Mpw_2%yZ<|jN{JGcv4+>ykq8j=!uBF7RZjs!nUsu z9UivIbHX_q516qdm=Vl#-fE-0T%rs$UI!R*U!!d9IN;|~V_NDI1|L7qZs@Ao3=sQG iZEvQ3k#BqV7~fk|4i^pg*%@Zd?R03br$;(4OkL)XXkRxnc2`yJE_uuaaM04d_v9N~w6ZmMU9|f={3>Yt2^~FZhm^CJxVHS4m~go?ykE9duUJ zCR6;MS|~hTvK@X_OyRfTN~3e7&mYfasSPVlCRP|wtOZy0xIP=K5GB`HAEmL|hz;FiS8f3*wR*3}vUk?U(!L)`;EbfL=4Hrhj{QzK$)!%Sat5O4llJ8;B zGg^YMiRC;&>)uvG=K;)C=!E}k6w{D`tpgUzxZRVg!VFJ0k0Z335UrGX7Muw{DtyIgTh$_8lkN3tt<&b4Oh zK(aw4Y`GTNnnezsBpe^XPj20ZZZ{r)KLAWEakR`rLgZJdU;Wx&N^o zTDPBA<>`uHbwGiOe2R|WYgwXOr--#ESpi>g2?g=M`u_OZ;c6`wYUm;kmuTG4(utAJ zqP6l1&P-VaTZgo8BxOBa*}%#bxYpJnjE1kQsJVuCjmzzQzW%J)!Cxm8mn*Y=*6FM4sVP*vb{r>lXU`^~NS(k@ZNs!~)Ei zOJvg`>0&lv9iT^mHA!;T^jS+>}L8o03|>o*V)^<83d zGLYG*o5eI5^^Q$WMhCg_98Ws9_(6~q=LOHlx&g0lszVdORJz~sa0~< zA8CRLNZ4xSbk;fP5Dm!o@b#yRq?)1t(geaU>&14c2fsT~`bfc!RSn5q3PPR^5VYzJ zn!b-PZn|h=#2B#N+`+&3fhMgz?{r9!f50amZQ2EE#zz`|79#21v3p^*xI_&@ z7mptPFP#k@@naQV7HCV?FLH&Ac}4}KoRg-Rk~OWL)x%R;5`A=)axTvQ(aRI1@Dy43 znb>LfM^%GG^||d4(4UkY@x)C>w!IlQigJSJH8#fS2w@DjTi$RUuDpQq()b!@$Zm~t yxZ{8?L5*Qa_YB^Do?p{N(v$9Pi{bDGI@{bBruP<$a6ND%eHdmuJ>o?I=g5EX>e*WW literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8bf8062d305f10fcfab3fa41d47683eb65278644 GIT binary patch literal 2804 zcmbVOU2oeq6n*!vxCMq-0aSVI(~wkNi^d+(wK0%p4})O{j7(c>Wzi+6dPeI1zI!i4 zTmDFs&44Y7x}WEqORCrF^m_2_ue;I9KL$60+jn<^D;QpX9K>)nf}7DDd>UO{5C49F zW@PzxDvV2CC$C<2=vK`uZDcmnCYz~JUO-*dR!%cn%7v_49PZU(sf|17AzCzF+`k~S-KHAgd7h${Q- z*DNoDv7HX4bD(FSm$DK|mEiZX)5%L=Eex@_TS+|()jwBG!$Sw4hXCA4SDVU$fJ&}l zd}ucT#pUHPL5IVq5k&!Tg|dlqa$z9_?gz$`7$0rUu>gRN%DOi@-)pqQ68gX{Z1Glq zm&PChXnn7BNj(_hw1G5@M)zN2j-9zensZ3ekCDnn8ByjqJo)j%{{wZR-?K9=)mwyO z(??2Bm4mr-PzYPR*Hei}*?TJ((n%$AX{|78;u)zcq*a#9B5o`R-X4T@i=UlQ)f}Am zen=Xg%PSAG8qc<_E4Ys3&4RQIOGy|fVf*&1=P+Rye%34~O8NFy?jd=-WHba9h z>2QoaPGYF)5@kS~uasr=wa{N0Il}>9t(iJUq1cQhl;J2jm1j70g`N@{e&I;DzjF-? zTLYu7EntY{L|hq(2@4AA0DI`NNsq{PV? zB3N7~F}maHoCb_On1?PK{%ukbcw5RE2o9#uYpf4@DFPOhDuxFh#ktWdabMyPz)X}@ z#+v|!hr&o(myV=%*kFG=@wE|}6q3NCGT`itRh1}vqupS!bn6fk0miA1n$UWM`wyG$ zo(Bp%J!uOy-%>*AV!*KQ1{?xtN9l`{)zuD3BvBiQm`*+MEl&?^fOrq+#>;7HD+*9u zK={W|cyJmP+5>!q7c9hbOLv=#P^yOot=f})6ef+Oe5Q`DKB)U)!Ko^kk_1M>p5 zXIqBH3!b8MTKe5@k3n2D>%d_`cS_pO4YA@odYVfPl(1cknI||!pvK8^eqU&~mux-4hy@^rM{ybVKSKt!O51qP)EWGq)=+$g{xJ{!uy?#(MoYoVHAQc^t zQJ);>(MfGQcTO=zTQ!fQ`ajxvrYxQ#v1U(e?Ez0J2>aS6`H+eIU9=^ncsr)MfsME7 z^5WQ=c1+b0MGx)@x#d5+`vFSL;!!G)<{G7OF9A<0i)m>L1bp~Bx}kTehJF9K6rBGg V-|eB@e_%M86PjPBE<3oH{0nfNjC=q9 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..77b3f72505daeadfa7b11141ee9934105e9a964d GIT binary patch literal 4300 zcmbVPZExE+4E~;9!4Ace;@ot%PlxQ1ZdltxcUv>Gy#a@yFjQMG%v**e;2~wUnh-4=f-X~&va>>C|#;@+)z!GG;D6SZdRyL+O*K+4XU}B zDAV8Gq}fau+v(tT4)hfCLY88o`uP7{r;`=JS~$Vv&L!n=>g4>$X}IeE9AE{`rK?P7 zK|m>&Fli}Xf#Lu&*++}ka*zA~xS6v3Y$mfGwwfm_ipB72l$%?f=8vykt`>6J>O#2L zQ)}NuQL*9u9;VAl`Cs^2NMR+E+99x)7$GbS7RR_1*qN@19A@HH!nKqoDwX?XRTSd7 zkUMap%1mP566``|2udcL(j^c_g7aKUORljzSyAN@3z*BPs0s(S6cO|)tI8X8@+zzh zUeyuoB{0)Vc`HrADmSu}MmU)V_f}U1L&!9Go>$hvR1^hVi|hv=co;YNM$AIU;IMiG z5JCD8DTQn@AoAR+Z3zJ5ek4KO0PS%ZOGecTj?w*vGwAUUBGg=QFN6p&%f2FN$Ba2) z7uM*pUS)X%x9Nvu3@QuXYpjvMaB%l)tqY?5&eIO448CP%%kl$o?vm=*OV3~?r{vaHsNh|(^^Dma8UFsLiA z(~pk#;1_QZGkT-x*#Nu}!Q3Cl2yT8gtc#J+-U1Es7MNU;I3iys97(z?? z?*1KDRZW4ZyQble$0ND{b}=#Cm{|7;{cZGL@97?7f!)tC-oDm~C0cur z60iP1k*fAkm1GZ;V=>pNQL;sm3%sexBCl$AZgEm1xDT!WKg=@_We$F@TTssWzHMP@ z^c+1>C!O3U3QGhtneZ#_ROsqKYI zwhmD*Faqi(Fo3yOFb3l(p&Pu8dc^7k8l2qolbqZ~-aKm@6}x*o53Oml0eszM8lU}bV2Y2Zm-Zo*ss{}q2A18(*8=gRBnbMIz;qM)WTuk>!Dfb z`*iEYD?EKep}Tc&{?Zuj#H1~UiMc^*TltZ|%Z@AV+GNgSd;K5&ypS3%p;?#-jaKk1 zi!iZGoLgY@38uPu4KgM5X=Q zi4~Leo#Y{dvS1SRr}P+ z|7vBSuEh?Ym9Y5s(mEKoZdg4t+IgXUs&Y6~g_1U?yFq8A($b}+(f1JZTcOk6eo3>f zux>PhJ5Qz{Q%Nl zmEV%zN~)}RKwvqoA4FNpO#;0TanE3qj{R1-BuJgXZ8X1viCX8%W}Q{Ywqg6_K9i zFO#sR;p!AA5FM#hXrs}?I`M8m30ShjDtyBVO`$$27kVekk}Bhzx+`VO%#qTuUTbd6 zW_{er(2=7+Ly;Z4{(da^ehptlA-_pD#<1g=qvhVF*G+;vC?u;qv-kQDOBF(7~ zrIVA#vjFNAM^?JJc4qI{G2FvZTxaq`9o_FHjSlm1StG+ zM_BafzV#Xb{sLEB-Jl$h#R0?+qQ=RbbX}Iu4HCr^5|QU!*`glNV@RR>j!#zl+Ui!z z8%}E6mTA@%EckyMJu>}GscRC31GR`;0bcQx2~9v8cWpyBtH2bQ;24u>7xQ8e$svk+ zO}1g3SgP^y91tfRSc(sk9lM#o2qWRZyX&?_0S%iq3vakgqL(J~B-$1~=?a|yVVM0j zu(M$xds~CYQGMp_VXNq?HlFSz#Rlc%>MWDa6)EySOyT!^H@1HeLG;b&yVY2`aS_XWX|1s_$t}i6AD4m`O)ntCc_3`a{6Kro1{Tjh zUlEoimM5x?Dm~kHaSo9z#trKZ^KRu1t0!+O!tzX#kgyV~L4m1j$GZxKO$lRF^|Uwn z2Ob@c@)#0}rtagm=iKa;)w*tixVV=csWDrzAiZD;hh3d*& zSO$>~nOYj1W}UsJ6t7=sg^l)_4qrZLp3zTSuDFt0AKzh3W?zmEv=|5A3p!g$4^He+RgVUM$c7lX R)q{Jpr?GhArFTrj{{xFLlVAV< literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/path_type.dart b/mobile/openapi/lib/model/path_type.dart new file mode 100644 index 0000000000000000000000000000000000000000..9cb02e0c9e5eb9c4b067b3f04f486962765ab5aa GIT binary patch literal 3157 zcmai0ZEqqs5dO}un3JjtktnqHsmhh(B89Z6uF0uKDpd%r%&vjGTkNgv4ML~<_da8< z7uY3fqyV<(#WVBFGaik4qY+&{&K`dJZF)cb>v}f5q1(Gp(*fN)(EY=V{(QK(yZz@4 z#8~oUCX5@u8~*&R$IE=Jw2{e5n`D(2@`@@|S(zoWkXxzTAg=Rmsf|0UA&Q;Y+oY^q zV&uP7ZlJEj7N4as_;%7*7&rF3da9LmLb)W*;4ok1(s*@y)>)A&X_G>$HN@OTsN|QQ zl5``C?e*Z!kzSE5r4nU6#Q*zVua_3WTKX*9X0|USeeV&C5cyqjn+qpNkdix+s@l>n zcN=mWNu|+GxKc**Cs9;#F@&DKYh!Ad^xY=6Lw|k=SHbWM%9YBbDfYQqlRxEav25_M zmD{B>Y;ct;QP3A>8cA>FqkfXcz&k|hL#M-!{w0k+1fyQGIsU_H)jmXGU7pt0)+S$j zcjMy-%thS4KnO%9Dql;vo^7h_Qi;5vmL4CX1oi0|?VVhfFKYAQtJ+ef8tMNVS@}@M^O}1YHRZ)v^*Lt zlm)qLrRiS|Q52N4A4HGsa;gpiPN=~8 zw(a3706w7aORWo98@&^p&oQk;VddcY(l>VOC?joEIGi4=&JhVGE?-ffO%b}$m+0+V z;`K1M_u5UjrQ1hau%-?5LzBWImyJaa=h2@J=@=4CexT<2id_fiLH5m6lVskv&BSg(3iw zYqTKz&OJk-F+g4ih?3JaCk?zkp+J$LvI~l6$b=KL6zMmXKIjfxWZZ&?FCkFqwAsH5 zBgRsMkbc)YiQzKfK4`i2o%Fiz+V=n4Aoyl9Jt{mL!c#(KQ)9FVL2l3% z>NFKRHd`0MJ@8quv_JUGc1Jc2=&pEua?r@+N??3kq4M#MYxt(A@NunJ!ZDud+N>*n zG<%%)FCNu51(b&;UjQeVAhhfi{lJI_&x`2e6^iFk!S@4)hwlCY*cQ@(z#DdLF&Y}J T=5I!DYk;?8CkH}(&vW8GDi98W literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/audit_api_test.dart b/mobile/openapi/test/audit_api_test.dart index 68ffede19c056ff71bb12146dffb13c14309f26c..2ce8d3d8600947320c428974e63572bba8c6482f 100644 GIT binary patch delta 263 zcmey%GM8h+3C19Ww9E>}(v-{+x6GW>Vhs=(lvCAlW%ti%Fgtr~;%h z3Q3bsW^oBvr*lSXa&~cPZcu7*L4ICwD##45As}O6k`TAUcrd3U@qyMz@gVD%?9ZeJ E0Ka%$-v9sr delta 18 ZcmbQs@t0-83C793OzYWdHLba7xd22|1}Ojl 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 0000000000000000000000000000000000000000..6eb3a39023071ba9978bd2b0f50873e775b28306 GIT binary patch literal 603 zcmZvZ(NDrK5XRs4SDa5kf=qcbAV%WAkRVGGAB-`j*>0nuYiHYGh~aLrjrzUoe&kD0 zDs7Mz+OR@M96+NQi#bDy6)M*~)}pGlac3P|e({}Ub>o=9&qf%sD_+rAb3?a7V+n5T zd3&anbzC_na%w0FfhO4P*=ABGv`lJMlFY5SVmDVTTXSQh=rAL+YMyO)i344u;-p5J;XjE(>tcT#1-_}TEzn9}N-KF-W45tPl{g)3waB63LbW^a9RZNI5Q<9>Awz~R zI`y=Z+NZG&x+CFzC?zx!UI!eGjF$V(pl z{=Fn|9K|tAU$gZ3akiYTrr9ik`QmBTha`n%n!$6LEaq=l1oO!I4L5Ew9NZ2g--=Qy zgRD@76{5xww6e9hVOV2>()CZhXtXl!EQ8mdd}o<%9W(ge3PW_s8>*Tc+8r88;>Mn* z=So_~rDI}4hN2K?0^Oc9tA#|%Y9&j8xr$46cf<0E8yiK3jL^|M-|`YiN~7dj#Iy!} z>dyEV#G!SuFDHOE^}PkU5}4DKJXDx(ZQHESYKn@9Q_+VK8)&$Y-5z{J07NQ;ep*u0qg;rZ#Q|>l1K& n(BXC`^AOe(DR7o1X%N{T68$vsU%@}&pC%{Z|4c|OnC+u)zp~-l literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a843046683e41cb2902506e9f848d1a9c6036781 GIT binary patch literal 733 zcmbtRO-my|5WUZ@C{IZsI;*EeS6N{a3Ahfp*~22z7Be-I#+mN6yG9M-f491$h?s-m zp+Aa#@6~(Nd7kAtoc>#uyJxeD+2!eSR=|95KAS>O!bQ1+KV`9)|KAZ2AcFs`Y=p<}qmZjkRu(}Jt( ziReOdD#A8hfL8?0OKQ(+xY-EYYy`i!w<(Z#=#mc+b3G>LvFHSnp)g R-!b#5@HIE$E4g~kUI6Ns?gIb- literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..44e7344295fdb0182a237744340ce621fcf0dc6b GIT binary patch literal 609 zcmZuu-)q7!5Pt7paeHb7XE&dQ&M~3w5S#^Z4`YlN+N*}ONtdK9wd0iEDG2 zUMOiSmzIf=3`HeSd%C%3Rtt%S)k#aB sKRmf8Ux3{rw3-&7?H<;izdo9AvzLX>;8>Jnl_$AZIJkMK^6w$|1v?taB>(^b literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7e90322f70c6de4ddb347e70635b8dfa0c84e7f6 GIT binary patch literal 995 zcmb7>(M#ko5XRs8SBy`)!fHLA4nd=-}jrDq-m0-aPwFeC%5@set%Qu87x;n@&#lC+!ZDKEV9+|>4;@Pd0k6KSLdhS z&XZ88MjMA>ZJb!E4liKPgU4E6hdpXqOttE*akSGRyMJ=@!VV-H{uz{GyOuqlRyzI@ zI?v(G@3vP)dy<-jsu`%(3SG4Oov==6^rADmVHveji|=QmYNhi@vgr|KR#q>v!3$$i zORG};PQ%o*%Pk3^r!*XsAV5RyfmZ@cUdcU%$ybDZMke`dR1LjUdVUPA2>{F2O3MyZ zoT5Uw+IG#4FB8@_&J1=w3-Q~U*#bTyW9S1pr5mVPtX}-kvm0VxY&Y=&)FN2NGdddK zc@jQva-<)16&nmjZn7-0B8I$edifs`)=uN9{t_|?4}$!X)aH>;juv5)5w3gUg7jcu hL|G>91)78hVLo0ccz?@o_f literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7a9c9a714ecb1b3b89906823b3aeef9cc416ee34 GIT binary patch literal 425 zcmZvYK}*Ci5QXpg72~P9P&c|K*+p=nU0KkrNIiH8LpyDQZ4#4IDYF0FMA?h>kO>dI zH*aP*XPm?Cr7SM*^JD(JEAtHYhlf0ctbk)t!efyg_OBNKi{xF6*59sI*J~D~Y?Zbo z8f`@*d)mOLMn|=vo(57rJ!?4_ZT+kR`%j#lFryci-bZPJUEvU_K^uNg)`jNQ&D$%j zoJZw_tV5t|B-tOkn+@xwA}4yS+JN~EmAJVQRfpCw_B$ga2CFx0X`>A(G?It6id!e! ze~2gNc`RojhDPlmoCKcGcX=4fPvvBYeP~(3fKn}%@WmKFYxQUrNi_UnlN8R-X}M+W G#J&Ob>5yFj literal 0 HcmV?d00001 diff --git a/mobile/openapi/test/path_type_test.dart b/mobile/openapi/test/path_type_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..20862a0ef5b6489d28c5f6ed0ceb39030544d46a GIT binary patch literal 413 zcmZvYK}*Ci5QXpg72~P9P&c|K*+p=nU0KkrNIiH8LpyDQZ4#4IS!DmaiBd22kO`0Z z-n?WuXPm?Cr7SM*^JD(JEAtHYhlf0ctbk)t!efyg_OBNKi{xF6*59sI*J~D~Y?Zbo z8f`@*d)mOLMn|=vo(57rowOW`wtm)u{U@HBFryci-bZPJUEvU_K^tyQ)`j8L&D$%j zoJZw_tV5t|B-tOkn+@xwA}4yS+JN~EmAJVQRfpCw_WMRi3|4R0(ncFnXe1A36@Q&< z{~?~7=dnyc42{}BSP49#@5+-Q_Mv4B14^}6!WUxzt<|GhBoY0ik`$))dAVim%)S8y CCyQeM literal 0 HcmV?d00001 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} +
+
+