diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index d9eb463717..93cd0c714a 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -161,7 +161,10 @@ Class | Method | HTTP request | Description *LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | Scan a library *LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | Update a library *LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | Validate library settings +*MaintenanceAdminApi* | [**deleteIntegrityReportFile**](doc//MaintenanceAdminApi.md#deleteintegrityreportfile) | **DELETE** /admin/maintenance/integrity/report/{id}/file | Delete associated file if it exists *MaintenanceAdminApi* | [**getIntegrityReport**](doc//MaintenanceAdminApi.md#getintegrityreport) | **POST** /admin/maintenance/integrity/report | Get integrity report by type +*MaintenanceAdminApi* | [**getIntegrityReportCsv**](doc//MaintenanceAdminApi.md#getintegrityreportcsv) | **GET** /admin/maintenance/integrity/report/{type}/csv | Export integrity report by type as CSV +*MaintenanceAdminApi* | [**getIntegrityReportFile**](doc//MaintenanceAdminApi.md#getintegrityreportfile) | **GET** /admin/maintenance/integrity/report/{id}/file | Download the orphan/broken file if one exists *MaintenanceAdminApi* | [**getIntegrityReportSummary**](doc//MaintenanceAdminApi.md#getintegrityreportsummary) | **GET** /admin/maintenance/integrity/summary | Get integrity report summary *MaintenanceAdminApi* | [**maintenanceLogin**](doc//MaintenanceAdminApi.md#maintenancelogin) | **POST** /admin/maintenance/login | Log into maintenance mode *MaintenanceAdminApi* | [**setMaintenanceMode**](doc//MaintenanceAdminApi.md#setmaintenancemode) | **POST** /admin/maintenance | Set maintenance mode diff --git a/mobile/openapi/lib/api/maintenance_admin_api.dart b/mobile/openapi/lib/api/maintenance_admin_api.dart index 6023b36fc6..96fb5920ff 100644 --- a/mobile/openapi/lib/api/maintenance_admin_api.dart +++ b/mobile/openapi/lib/api/maintenance_admin_api.dart @@ -16,6 +16,55 @@ class MaintenanceAdminApi { final ApiClient apiClient; + /// Delete associated file if it exists + /// + /// ... + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future deleteIntegrityReportFileWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/admin/maintenance/integrity/report/{id}/file' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Delete associated file if it exists + /// + /// ... + /// + /// Parameters: + /// + /// * [String] id (required): + Future deleteIntegrityReportFile(String id,) async { + final response = await deleteIntegrityReportFileWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Get integrity report by type /// /// ... @@ -72,6 +121,120 @@ class MaintenanceAdminApi { return null; } + /// Export integrity report by type as CSV + /// + /// ... + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [IntegrityReportType] type (required): + Future getIntegrityReportCsvWithHttpInfo(IntegrityReportType type,) async { + // ignore: prefer_const_declarations + final apiPath = r'/admin/maintenance/integrity/report/{type}/csv' + .replaceAll('{type}', type.toString()); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Export integrity report by type as CSV + /// + /// ... + /// + /// Parameters: + /// + /// * [IntegrityReportType] type (required): + Future getIntegrityReportCsv(IntegrityReportType type,) async { + final response = await getIntegrityReportCsvWithHttpInfo(type,); + 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), 'MultipartFile',) as MultipartFile; + + } + return null; + } + + /// Download the orphan/broken file if one exists + /// + /// ... + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getIntegrityReportFileWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/admin/maintenance/integrity/report/{id}/file' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Download the orphan/broken file if one exists + /// + /// ... + /// + /// Parameters: + /// + /// * [String] id (required): + Future getIntegrityReportFile(String id,) async { + final response = await getIntegrityReportFileWithHttpInfo(id,); + 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), 'MultipartFile',) as MultipartFile; + + } + return null; + } + /// Get integrity report summary /// /// ... diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 9406a56f0a..928294b757 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -429,6 +429,169 @@ "x-immich-state": "Alpha" } }, + "/admin/maintenance/integrity/report/{id}/file": { + "delete": { + "description": "...", + "operationId": "deleteIntegrityReportFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Delete associated file if it exists", + "tags": [ + "Maintenance (admin)" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v9.9.9", + "state": "Added" + }, + { + "version": "v9.9.9", + "state": "Alpha" + } + ], + "x-immich-permission": "maintenance", + "x-immich-state": "Alpha" + }, + "get": { + "description": "...", + "operationId": "getIntegrityReportFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Download the orphan/broken file if one exists", + "tags": [ + "Maintenance (admin)" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v9.9.9", + "state": "Added" + }, + { + "version": "v9.9.9", + "state": "Alpha" + } + ], + "x-immich-permission": "maintenance", + "x-immich-state": "Alpha" + } + }, + "/admin/maintenance/integrity/report/{type}/csv": { + "get": { + "description": "...", + "operationId": "getIntegrityReportCsv", + "parameters": [ + { + "name": "type", + "required": true, + "in": "path", + "schema": { + "$ref": "#/components/schemas/IntegrityReportType" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Export integrity report by type as CSV", + "tags": [ + "Maintenance (admin)" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v9.9.9", + "state": "Added" + }, + { + "version": "v9.9.9", + "state": "Alpha" + } + ], + "x-immich-permission": "maintenance", + "x-immich-state": "Alpha" + } + }, "/admin/maintenance/integrity/summary": { "get": { "description": "...", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c4cc056344..9d1b69146f 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1909,6 +1909,43 @@ export function getIntegrityReport({ maintenanceGetIntegrityReportDto }: { body: maintenanceGetIntegrityReportDto }))); } +/** + * Delete associated file if it exists + */ +export function deleteIntegrityReportFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/admin/maintenance/integrity/report/${encodeURIComponent(id)}/file`, { + ...opts, + method: "DELETE" + })); +} +/** + * Download the orphan/broken file if one exists + */ +export function getIntegrityReportFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchBlob<{ + status: 200; + data: Blob; + }>(`/admin/maintenance/integrity/report/${encodeURIComponent(id)}/file`, { + ...opts + })); +} +/** + * Export integrity report by type as CSV + */ +export function getIntegrityReportCsv({ $type }: { + $type: IntegrityReportType; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchBlob<{ + status: 200; + data: Blob; + }>(`/admin/maintenance/integrity/report/${encodeURIComponent($type)}/csv`, { + ...opts + })); +} /** * Get integrity report summary */ diff --git a/server/src/controllers/maintenance.controller.ts b/server/src/controllers/maintenance.controller.ts index b41e892c61..a930d1c6e1 100644 --- a/server/src/controllers/maintenance.controller.ts +++ b/server/src/controllers/maintenance.controller.ts @@ -1,6 +1,6 @@ -import { BadRequestException, Body, Controller, Get, Post, Res } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Delete, Get, Next, Param, Post, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { Response } from 'express'; +import { NextFunction, Response } from 'express'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { @@ -12,15 +12,21 @@ import { SetMaintenanceModeDto, } from 'src/dtos/maintenance.dto'; import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum'; -import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; +import { Auth, Authenticated, FileResponse, GetLoginDetails } from 'src/middleware/auth.guard'; +import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoginDetails } from 'src/services/auth.service'; import { MaintenanceService } from 'src/services/maintenance.service'; +import { sendFile } from 'src/utils/file'; import { respondWithCookie } from 'src/utils/response'; +import { IntegrityReportTypeParamDto, UUIDParamDto } from 'src/validation'; @ApiTags(ApiTag.Maintenance) @Controller('admin/maintenance') export class MaintenanceController { - constructor(private service: MaintenanceService) {} + constructor( + private logger: LoggingRepository, + private service: MaintenanceService, + ) {} @Post('login') @Endpoint({ @@ -75,4 +81,47 @@ export class MaintenanceController { getIntegrityReport(@Body() dto: MaintenanceGetIntegrityReportDto): Promise { return this.service.getIntegrityReport(dto); } + + @Get('integrity/report/:type/csv') + @Endpoint({ + summary: 'Export integrity report by type as CSV', + description: '...', + history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'), + }) + @FileResponse() + @Authenticated({ permission: Permission.Maintenance, admin: true }) + getIntegrityReportCsv(@Param() { type }: IntegrityReportTypeParamDto, @Res() res: Response): void { + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Cache-Control', 'private, no-cache, no-transform'); + res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(`${Date.now()}-${type}.csv`)}"`); + + this.service.getIntegrityReportCsv(type).pipe(res); + } + + @Get('integrity/report/:id/file') + @Endpoint({ + summary: 'Download the orphan/broken file if one exists', + description: '...', + history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'), + }) + @FileResponse() + @Authenticated({ permission: Permission.Maintenance, admin: true }) + async getIntegrityReportFile( + @Param() { id }: UUIDParamDto, + @Res() res: Response, + @Next() next: NextFunction, + ): Promise { + await sendFile(res, next, () => this.service.getIntegrityReportFile(id), this.logger); + } + + @Delete('integrity/report/:id/file') + @Endpoint({ + summary: 'Delete associated file if it exists', + description: '...', + history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'), + }) + @Authenticated({ permission: Permission.Maintenance, admin: true }) + async deleteIntegrityReportFile(@Param() { id }: UUIDParamDto): Promise { + await this.service.deleteIntegrityReportFile(id); + } } diff --git a/server/src/repositories/integrity-report.repository.ts b/server/src/repositories/integrity-report.repository.ts index 9dcf8ca8e6..5e6cc0666c 100644 --- a/server/src/repositories/integrity-report.repository.ts +++ b/server/src/repositories/integrity-report.repository.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; +import { Readable } from 'node:stream'; import { MaintenanceGetIntegrityReportDto, MaintenanceIntegrityReportResponseDto, @@ -23,8 +24,16 @@ export class IntegrityReportRepository { .executeTakeFirst(); } - async getIntegrityReportSummary(): Promise { - return await this.db + getById(id: string) { + return this.db + .selectFrom('integrity_report') + .selectAll('integrity_report') + .where('id', '=', id) + .executeTakeFirstOrThrow(); + } + + getIntegrityReportSummary(): Promise { + return this.db .selectFrom('integrity_report') .select((eb) => eb.fn @@ -58,6 +67,28 @@ export class IntegrityReportRepository { }; } + getIntegrityReportCsv(type: IntegrityReportType): Readable { + const items = this.db + .selectFrom('integrity_report') + .select(['id', 'type', 'path']) + .where('type', '=', type) + .orderBy('createdAt', 'desc') + .stream(); + + // very rudimentary csv serialiser + async function* generator() { + yield 'id,type,path\n'; + + for await (const item of items) { + // no expectation of particularly bad filenames + // but they could potentially have a newline or quote character + yield `${item.id},${item.type},"${item.path.replace(/"/g, '\\"')}"\n`; + } + } + + return Readable.from(generator()); + } + deleteById(id: string) { return this.db.deleteFrom('integrity_report').where('id', '=', id).execute(); } diff --git a/server/src/services/maintenance.service.ts b/server/src/services/maintenance.service.ts index af41eeed8f..0d13702363 100644 --- a/server/src/services/maintenance.service.ts +++ b/server/src/services/maintenance.service.ts @@ -1,4 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { basename } from 'node:path'; +import { Readable } from 'node:stream'; import { OnEvent } from 'src/decorators'; import { MaintenanceAuthDto, @@ -6,9 +8,10 @@ import { MaintenanceIntegrityReportResponseDto, MaintenanceIntegrityReportSummaryResponseDto, } from 'src/dtos/maintenance.dto'; -import { SystemMetadataKey } from 'src/enum'; +import { CacheControl, IntegrityReportType, SystemMetadataKey } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { MaintenanceModeState } from 'src/types'; +import { ImmichFileResponse } from 'src/utils/file'; import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance'; import { getExternalDomain } from 'src/utils/misc'; @@ -63,4 +66,25 @@ export class MaintenanceService extends BaseService { getIntegrityReport(dto: MaintenanceGetIntegrityReportDto): Promise { return this.integrityReportRepository.getIntegrityReport(dto); } + + getIntegrityReportCsv(type: IntegrityReportType): Readable { + return this.integrityReportRepository.getIntegrityReportCsv(type); + } + + async getIntegrityReportFile(id: string): Promise { + const { path } = await this.integrityReportRepository.getById(id); + + return new ImmichFileResponse({ + path, + fileName: basename(path), + contentType: 'application/octet-stream', + cacheControl: CacheControl.PrivateWithoutCache, + }); + } + + async deleteIntegrityReportFile(id: string): Promise { + const { path } = await this.integrityReportRepository.getById(id); + await this.storageRepository.unlink(path); + await this.integrityReportRepository.deleteById(id); + } } diff --git a/server/src/validation.ts b/server/src/validation.ts index 6d4bbfbe36..da5033dc32 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -33,6 +33,7 @@ import { import { CronJob } from 'cron'; import { DateTime } from 'luxon'; import sanitize from 'sanitize-filename'; +import { IntegrityReportType } from 'src/enum'; import { isIP, isIPRange } from 'validator'; @Injectable() @@ -96,6 +97,12 @@ export class UUIDAssetIDParamDto { assetId!: string; } +export class IntegrityReportTypeParamDto { + @IsNotEmpty() + @ApiProperty({ enum: IntegrityReportType, enumName: 'IntegrityReportType' }) + type!: IntegrityReportType; +} + type PinCodeOptions = { optional?: boolean } & OptionalOptions; export const PinCode = (options?: PinCodeOptions & ApiPropertyOptions) => { const { optional, nullable, emptyToNull, ...apiPropertyOptions } = { diff --git a/web/src/routes/admin/maintenance/integrity-report/[type]/+page.svelte b/web/src/routes/admin/maintenance/integrity-report/[type]/+page.svelte index 2b2fb42fbc..da0ea3d509 100644 --- a/web/src/routes/admin/maintenance/integrity-report/[type]/+page.svelte +++ b/web/src/routes/admin/maintenance/integrity-report/[type]/+page.svelte @@ -1,9 +1,21 @@ - + {/snippet}
@@ -60,11 +117,20 @@ - {#each data.integrityReport.items as { id, path } (id)} - + {#each integrityReport as { id, path } (id)} + {path} - handleOpen(event, { position: 'top-right' }, id)} + aria-label={$t('open')} + disabled={deleting.has(id)} + /> {/each} diff --git a/web/src/routes/admin/maintenance/integrity-report/[type]/+page.ts b/web/src/routes/admin/maintenance/integrity-report/[type]/+page.ts index 9c6b73ec0e..2d3bcedced 100644 --- a/web/src/routes/admin/maintenance/integrity-report/[type]/+page.ts +++ b/web/src/routes/admin/maintenance/integrity-report/[type]/+page.ts @@ -15,6 +15,7 @@ export const load = (async ({ params, url }) => { const $t = await getFormatter(); return { + type, integrityReport, meta: { title: $t(`admin.maintenance_integrity_${type}`),