diff --git a/e2e/src/api/specs/integrity.e2e-spec.ts b/e2e/src/api/specs/integrity.e2e-spec.ts index 36ad8addae..1cf7b7101d 100644 --- a/e2e/src/api/specs/integrity.e2e-spec.ts +++ b/e2e/src/api/specs/integrity.e2e-spec.ts @@ -7,7 +7,7 @@ import { afterEach, beforeAll, describe, expect, it } from 'vitest'; const assetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`; -describe('/admin/maintenance', () => { +describe('/admin/integrity', () => { let cookie: string | undefined; let admin: LoginResponseDto; let nonAdmin: LoginResponseDto; @@ -18,7 +18,7 @@ describe('/admin/maintenance', () => { nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); }); - describe('POST /integrity/summary (& jobs)', async () => { + describe('POST /summary (& jobs)', async () => { let baseline: Record; beforeAll(async () => { @@ -53,7 +53,7 @@ describe('/admin/maintenance', () => { await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, body } = await request(app) - .get('/admin/maintenance/integrity/summary') + .get('/admin/integrity/summary') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); @@ -77,7 +77,7 @@ describe('/admin/maintenance', () => { await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, body } = await request(app) - .get('/admin/maintenance/integrity/summary') + .get('/admin/integrity/summary') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); @@ -101,7 +101,7 @@ describe('/admin/maintenance', () => { await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, body } = await request(app) - .get('/admin/maintenance/integrity/summary') + .get('/admin/integrity/summary') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); @@ -123,7 +123,7 @@ describe('/admin/maintenance', () => { await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, body } = await request(app) - .get('/admin/maintenance/integrity/summary') + .get('/admin/integrity/summary') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); @@ -144,7 +144,7 @@ describe('/admin/maintenance', () => { await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, body } = await request(app) - .get('/admin/maintenance/integrity/summary') + .get('/admin/integrity/summary') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); @@ -167,7 +167,7 @@ describe('/admin/maintenance', () => { await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, body } = await request(app) - .get('/admin/maintenance/integrity/summary') + .get('/admin/integrity/summary') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); @@ -187,7 +187,7 @@ describe('/admin/maintenance', () => { await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); const { status, body } = await request(app) - .get('/admin/maintenance/integrity/summary') + .get('/admin/integrity/summary') .set('Authorization', `Bearer ${admin.accessToken}`) .send(); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 1c28bbdd43..f292ed494a 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -161,11 +161,11 @@ 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* | [**deleteIntegrityReport**](doc//MaintenanceAdminApi.md#deleteintegrityreport) | **DELETE** /admin/maintenance/integrity/report/{id} | Delete report entry and perform corresponding deletion action -*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* | [**deleteIntegrityReport**](doc//MaintenanceAdminApi.md#deleteintegrityreport) | **DELETE** /admin/integrity/report/{id} | Delete report entry and perform corresponding deletion action +*MaintenanceAdminApi* | [**getIntegrityReport**](doc//MaintenanceAdminApi.md#getintegrityreport) | **POST** /admin/integrity/report | Get integrity report by type +*MaintenanceAdminApi* | [**getIntegrityReportCsv**](doc//MaintenanceAdminApi.md#getintegrityreportcsv) | **GET** /admin/integrity/report/{type}/csv | Export integrity report by type as CSV +*MaintenanceAdminApi* | [**getIntegrityReportFile**](doc//MaintenanceAdminApi.md#getintegrityreportfile) | **GET** /admin/integrity/report/{id}/file | Download the orphan/broken file if one exists +*MaintenanceAdminApi* | [**getIntegrityReportSummary**](doc//MaintenanceAdminApi.md#getintegrityreportsummary) | **GET** /admin/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 *MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | Retrieve map markers diff --git a/mobile/openapi/lib/api/maintenance_admin_api.dart b/mobile/openapi/lib/api/maintenance_admin_api.dart index 3dcbea1426..c51923b334 100644 --- a/mobile/openapi/lib/api/maintenance_admin_api.dart +++ b/mobile/openapi/lib/api/maintenance_admin_api.dart @@ -27,7 +27,7 @@ class MaintenanceAdminApi { /// * [String] id (required): Future deleteIntegrityReportWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final apiPath = r'/admin/maintenance/integrity/report/{id}' + final apiPath = r'/admin/integrity/report/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -76,7 +76,7 @@ class MaintenanceAdminApi { /// * [MaintenanceGetIntegrityReportDto] maintenanceGetIntegrityReportDto (required): Future getIntegrityReportWithHttpInfo(MaintenanceGetIntegrityReportDto maintenanceGetIntegrityReportDto,) async { // ignore: prefer_const_declarations - final apiPath = r'/admin/maintenance/integrity/report'; + final apiPath = r'/admin/integrity/report'; // ignore: prefer_final_locals Object? postBody = maintenanceGetIntegrityReportDto; @@ -132,7 +132,7 @@ class MaintenanceAdminApi { /// * [IntegrityReportType] type (required): Future getIntegrityReportCsvWithHttpInfo(IntegrityReportType type,) async { // ignore: prefer_const_declarations - final apiPath = r'/admin/maintenance/integrity/report/{type}/csv' + final apiPath = r'/admin/integrity/report/{type}/csv' .replaceAll('{type}', type.toString()); // ignore: prefer_final_locals @@ -189,7 +189,7 @@ class MaintenanceAdminApi { /// * [String] id (required): Future getIntegrityReportFileWithHttpInfo(String id,) async { // ignore: prefer_const_declarations - final apiPath = r'/admin/maintenance/integrity/report/{id}/file' + final apiPath = r'/admin/integrity/report/{id}/file' .replaceAll('{id}', id); // ignore: prefer_final_locals @@ -242,7 +242,7 @@ class MaintenanceAdminApi { /// Note: This method returns the HTTP [Response]. Future getIntegrityReportSummaryWithHttpInfo() async { // ignore: prefer_const_declarations - final apiPath = r'/admin/maintenance/integrity/summary'; + final apiPath = r'/admin/integrity/summary'; // ignore: prefer_final_locals Object? postBody; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c608e59fc6..cf41d9571c 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -322,57 +322,7 @@ "x-immich-state": "Stable" } }, - "/admin/maintenance": { - "post": { - "description": "Put Immich into or take it out of maintenance mode", - "operationId": "setMaintenanceMode", - "parameters": [], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SetMaintenanceModeDto" - } - } - }, - "required": true - }, - "responses": { - "201": { - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "summary": "Set maintenance mode", - "tags": [ - "Maintenance (admin)" - ], - "x-immich-admin-only": true, - "x-immich-history": [ - { - "version": "v2.3.0", - "state": "Added" - }, - { - "version": "v2.3.0", - "state": "Alpha" - } - ], - "x-immich-permission": "maintenance", - "x-immich-state": "Alpha" - } - }, - "/admin/maintenance/integrity/report": { + "/admin/integrity/report": { "post": { "description": "...", "operationId": "getIntegrityReport", @@ -429,7 +379,7 @@ "x-immich-state": "Alpha" } }, - "/admin/maintenance/integrity/report/{id}": { + "/admin/integrity/report/{id}": { "delete": { "description": "...", "operationId": "deleteIntegrityReport", @@ -479,7 +429,7 @@ "x-immich-state": "Alpha" } }, - "/admin/maintenance/integrity/report/{id}/file": { + "/admin/integrity/report/{id}/file": { "get": { "description": "...", "operationId": "getIntegrityReportFile", @@ -537,7 +487,7 @@ "x-immich-state": "Alpha" } }, - "/admin/maintenance/integrity/report/{type}/csv": { + "/admin/integrity/report/{type}/csv": { "get": { "description": "...", "operationId": "getIntegrityReportCsv", @@ -594,7 +544,7 @@ "x-immich-state": "Alpha" } }, - "/admin/maintenance/integrity/summary": { + "/admin/integrity/summary": { "get": { "description": "...", "operationId": "getIntegrityReportSummary", @@ -641,6 +591,56 @@ "x-immich-state": "Alpha" } }, + "/admin/maintenance": { + "post": { + "description": "Put Immich into or take it out of maintenance mode", + "operationId": "setMaintenanceMode", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetMaintenanceModeDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Set maintenance mode", + "tags": [ + "Maintenance (admin)" + ], + "x-immich-admin-only": true, + "x-immich-history": [ + { + "version": "v2.3.0", + "state": "Added" + }, + { + "version": "v2.3.0", + "state": "Alpha" + } + ], + "x-immich-permission": "maintenance", + "x-immich-state": "Alpha" + } + }, "/admin/maintenance/login": { "post": { "description": "Login with maintenance token or cookie to receive current information and perform further actions.", @@ -14581,6 +14581,10 @@ "name": "Faces", "description": "A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created via manually." }, + { + "name": "Integrity (admin)", + "description": "Endpoints for viewing and managing integrity reports." + }, { "name": "Jobs", "description": "Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed." diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 9d7370ac21..d1836351f9 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -40,9 +40,6 @@ export type ActivityStatisticsResponseDto = { comments: number; likes: number; }; -export type SetMaintenanceModeDto = { - action: MaintenanceAction; -}; export type MaintenanceGetIntegrityReportDto = { "type": IntegrityReportType; }; @@ -59,6 +56,9 @@ export type MaintenanceIntegrityReportSummaryResponseDto = { missing_file: number; orphan_file: number; }; +export type SetMaintenanceModeDto = { + action: MaintenanceAction; +}; export type MaintenanceLoginDto = { token?: string; }; @@ -1884,18 +1884,6 @@ export function unlinkAllOAuthAccountsAdmin(opts?: Oazapfts.RequestOpts) { method: "POST" })); } -/** - * Set maintenance mode - */ -export function setMaintenanceMode({ setMaintenanceModeDto }: { - setMaintenanceModeDto: SetMaintenanceModeDto; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText("/admin/maintenance", oazapfts.json({ - ...opts, - method: "POST", - body: setMaintenanceModeDto - }))); -} /** * Get integrity report by type */ @@ -1905,7 +1893,7 @@ export function getIntegrityReport({ maintenanceGetIntegrityReportDto }: { return oazapfts.ok(oazapfts.fetchJson<{ status: 201; data: MaintenanceIntegrityReportResponseDto; - }>("/admin/maintenance/integrity/report", oazapfts.json({ + }>("/admin/integrity/report", oazapfts.json({ ...opts, method: "POST", body: maintenanceGetIntegrityReportDto @@ -1917,7 +1905,7 @@ export function getIntegrityReport({ maintenanceGetIntegrityReportDto }: { export function deleteIntegrityReport({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchText(`/admin/maintenance/integrity/report/${encodeURIComponent(id)}`, { + return oazapfts.ok(oazapfts.fetchText(`/admin/integrity/report/${encodeURIComponent(id)}`, { ...opts, method: "DELETE" })); @@ -1931,7 +1919,7 @@ export function getIntegrityReportFile({ id }: { return oazapfts.ok(oazapfts.fetchBlob<{ status: 200; data: Blob; - }>(`/admin/maintenance/integrity/report/${encodeURIComponent(id)}/file`, { + }>(`/admin/integrity/report/${encodeURIComponent(id)}/file`, { ...opts })); } @@ -1944,7 +1932,7 @@ export function getIntegrityReportCsv({ $type }: { return oazapfts.ok(oazapfts.fetchBlob<{ status: 200; data: Blob; - }>(`/admin/maintenance/integrity/report/${encodeURIComponent($type)}/csv`, { + }>(`/admin/integrity/report/${encodeURIComponent($type)}/csv`, { ...opts })); } @@ -1955,10 +1943,22 @@ export function getIntegrityReportSummary(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: MaintenanceIntegrityReportSummaryResponseDto; - }>("/admin/maintenance/integrity/summary", { + }>("/admin/integrity/summary", { ...opts })); } +/** + * Set maintenance mode + */ +export function setMaintenanceMode({ setMaintenanceModeDto }: { + setMaintenanceModeDto: SetMaintenanceModeDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/admin/maintenance", oazapfts.json({ + ...opts, + method: "POST", + body: setMaintenanceModeDto + }))); +} /** * Log into maintenance mode */ @@ -5235,15 +5235,15 @@ export enum UserAvatarColor { Gray = "gray", Amber = "amber" } -export enum MaintenanceAction { - Start = "start", - End = "end" -} export enum IntegrityReportType { OrphanFile = "orphan_file", MissingFile = "missing_file", ChecksumMismatch = "checksum_mismatch" } +export enum MaintenanceAction { + Start = "start", + End = "end" +} export enum NotificationLevel { Success = "success", Error = "error", diff --git a/server/src/constants.ts b/server/src/constants.ts index 33f8e3b4c5..f6c541317c 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -146,6 +146,7 @@ export const endpointTags: Record = { [ApiTag.Duplicates]: 'Endpoints for managing and identifying duplicate assets.', [ApiTag.Faces]: 'A face is a detected human face within an asset, which can be associated with a person. Faces are normally detected via machine learning, but can also be created via manually.', + [ApiTag.Integrity]: 'Endpoints for viewing and managing integrity reports.', [ApiTag.Jobs]: 'Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed.', [ApiTag.Libraries]: diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index 6ba3d38a73..5b407f0ac9 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -9,6 +9,7 @@ import { AuthController } from 'src/controllers/auth.controller'; import { DownloadController } from 'src/controllers/download.controller'; import { DuplicateController } from 'src/controllers/duplicate.controller'; import { FaceController } from 'src/controllers/face.controller'; +import { IntegrityController } from 'src/controllers/integrity.controller'; import { JobController } from 'src/controllers/job.controller'; import { LibraryController } from 'src/controllers/library.controller'; import { MaintenanceController } from 'src/controllers/maintenance.controller'; @@ -49,6 +50,7 @@ export const controllers = [ DownloadController, DuplicateController, FaceController, + IntegrityController, JobController, LibraryController, MaintenanceController, diff --git a/server/src/controllers/integrity.controller.ts b/server/src/controllers/integrity.controller.ts new file mode 100644 index 0000000000..1ca2a689bd --- /dev/null +++ b/server/src/controllers/integrity.controller.ts @@ -0,0 +1,90 @@ +import { Body, Controller, Delete, Get, Next, Param, Post, Res } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { NextFunction, Response } from 'express'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + MaintenanceGetIntegrityReportDto, + MaintenanceIntegrityReportResponseDto, + MaintenanceIntegrityReportSummaryResponseDto, +} from 'src/dtos/maintenance.dto'; +import { ApiTag, Permission } from 'src/enum'; +import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { IntegrityService } from 'src/services/integrity.service'; +import { sendFile } from 'src/utils/file'; +import { IntegrityReportTypeParamDto, UUIDParamDto } from 'src/validation'; + +@ApiTags(ApiTag.Maintenance) +@Controller('admin/integrity') +export class IntegrityController { + constructor( + private logger: LoggingRepository, + private service: IntegrityService, + ) {} + + @Get('summary') + @Endpoint({ + summary: 'Get integrity report summary', + description: '...', + history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'), + }) + @Authenticated({ permission: Permission.Maintenance, admin: true }) + getIntegrityReportSummary(): Promise { + return this.service.getIntegrityReportSummary(); + } + + @Post('report') + @Endpoint({ + summary: 'Get integrity report by type', + description: '...', + history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'), + }) + @Authenticated({ permission: Permission.Maintenance, admin: true }) + getIntegrityReport(@Body() dto: MaintenanceGetIntegrityReportDto): Promise { + return this.service.getIntegrityReport(dto); + } + + @Delete('report/:id') + @Endpoint({ + summary: 'Delete report entry and perform corresponding deletion action', + description: '...', + history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'), + }) + @Authenticated({ permission: Permission.Maintenance, admin: true }) + async deleteIntegrityReport(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + await this.service.deleteIntegrityReport(auth, id); + } + + @Get('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('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); + } +} diff --git a/server/src/controllers/maintenance.controller.ts b/server/src/controllers/maintenance.controller.ts index 553dfeea6b..7b2aa17582 100644 --- a/server/src/controllers/maintenance.controller.ts +++ b/server/src/controllers/maintenance.controller.ts @@ -1,32 +1,19 @@ -import { BadRequestException, Body, Controller, Delete, Get, Next, Param, Post, Res } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Post, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { NextFunction, Response } from 'express'; +import { Response } from 'express'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; -import { - MaintenanceAuthDto, - MaintenanceGetIntegrityReportDto, - MaintenanceIntegrityReportResponseDto, - MaintenanceIntegrityReportSummaryResponseDto, - MaintenanceLoginDto, - SetMaintenanceModeDto, -} from 'src/dtos/maintenance.dto'; +import { MaintenanceAuthDto, MaintenanceLoginDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto'; import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum'; -import { Auth, Authenticated, FileResponse, GetLoginDetails } from 'src/middleware/auth.guard'; -import { LoggingRepository } from 'src/repositories/logging.repository'; +import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; 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 logger: LoggingRepository, - private service: MaintenanceService, - ) {} + constructor(private service: MaintenanceService) {} @Post('login') @Endpoint({ @@ -59,69 +46,4 @@ export class MaintenanceController { }); } } - - @Get('integrity/summary') - @Endpoint({ - summary: 'Get integrity report summary', - description: '...', - history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'), - }) - @Authenticated({ permission: Permission.Maintenance, admin: true }) - getIntegrityReportSummary(): Promise { - return this.service.getIntegrityReportSummary(); - } - - @Post('integrity/report') - @Endpoint({ - summary: 'Get integrity report by type', - description: '...', - history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'), - }) - @Authenticated({ permission: Permission.Maintenance, admin: true }) - getIntegrityReport(@Body() dto: MaintenanceGetIntegrityReportDto): Promise { - return this.service.getIntegrityReport(dto); - } - - @Delete('integrity/report/:id') - @Endpoint({ - summary: 'Delete report entry and perform corresponding deletion action', - description: '...', - history: new HistoryBuilder().added('v9.9.9').alpha('v9.9.9'), - }) - @Authenticated({ permission: Permission.Maintenance, admin: true }) - async deleteIntegrityReport(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - await this.service.deleteIntegrityReport(auth, id); - } - - @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); - } } diff --git a/server/src/enum.ts b/server/src/enum.ts index 9c75f60ad2..8d2dcb37e4 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -863,6 +863,7 @@ export enum ApiTag { Download = 'Download', Duplicates = 'Duplicates', Faces = 'Faces', + Integrity = 'Integrity (admin)', Jobs = 'Jobs', Libraries = 'Libraries', Maintenance = 'Maintenance (admin)', diff --git a/server/src/services/integrity.service.spec.ts b/server/src/services/integrity.service.spec.ts new file mode 100644 index 0000000000..3c7505074b --- /dev/null +++ b/server/src/services/integrity.service.spec.ts @@ -0,0 +1,22 @@ +import { IntegrityService } from 'src/services/integrity.service'; +import { newTestService, ServiceMocks } from 'test/utils'; + +describe(IntegrityService.name, () => { + let sut: IntegrityService; + let mocks: ServiceMocks; + + beforeEach(() => { + ({ sut, mocks } = newTestService(IntegrityService)); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe.skip('getIntegrityReportSummary'); // just calls repository + describe.skip('getIntegrityReport'); // just calls repository + describe.skip('getIntegrityReportCsv'); // just calls repository + + describe.todo('getIntegrityReportFile'); + describe.todo('deleteIntegrityReport'); +}); diff --git a/server/src/services/integrity.service.ts b/server/src/services/integrity.service.ts index 725d608146..64832d0fcc 100644 --- a/server/src/services/integrity.service.ts +++ b/server/src/services/integrity.service.ts @@ -2,13 +2,21 @@ import { Injectable } from '@nestjs/common'; import { createHash } from 'node:crypto'; import { createReadStream } from 'node:fs'; import { stat } from 'node:fs/promises'; -import { Writable } from 'node:stream'; +import { basename } from 'node:path'; +import { Readable, Writable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent, OnJob } from 'src/decorators'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { + MaintenanceGetIntegrityReportDto, + MaintenanceIntegrityReportResponseDto, + MaintenanceIntegrityReportSummaryResponseDto, +} from 'src/dtos/maintenance.dto'; import { AssetStatus, + CacheControl, DatabaseLock, ImmichWorker, IntegrityReportType, @@ -28,6 +36,7 @@ import { IIntegrityPathWithChecksumJob, IIntegrityPathWithReportJob, } from 'src/types'; +import { ImmichFileResponse } from 'src/utils/file'; import { handlePromiseError } from 'src/utils/misc'; /** @@ -145,6 +154,54 @@ export class IntegrityService extends BaseService { }); } + getIntegrityReportSummary(): Promise { + return this.integrityRepository.getIntegrityReportSummary(); + } + + async getIntegrityReport(dto: MaintenanceGetIntegrityReportDto): Promise { + return { + items: await this.integrityRepository.getIntegrityReports(dto.type), + }; + } + + getIntegrityReportCsv(type: IntegrityReportType): Readable { + return this.integrityRepository.streamIntegrityReportsCSV(type); + } + + async getIntegrityReportFile(id: string): Promise { + const { path } = await this.integrityRepository.getById(id); + + return new ImmichFileResponse({ + path, + fileName: basename(path), + contentType: 'application/octet-stream', + cacheControl: CacheControl.PrivateWithoutCache, + }); + } + + async deleteIntegrityReport(auth: AuthDto, id: string): Promise { + const { path, assetId, fileAssetId } = await this.integrityRepository.getById(id); + + if (assetId) { + await this.assetRepository.updateAll([assetId], { + deletedAt: new Date(), + status: AssetStatus.Trashed, + }); + + await this.eventRepository.emit('AssetTrashAll', { + assetIds: [assetId], + userId: auth.user.id, + }); + + await this.integrityRepository.deleteById(id); + } else if (fileAssetId) { + await this.assetRepository.deleteFiles([{ id: fileAssetId }]); + } else { + await this.storageRepository.unlink(path); + await this.integrityRepository.deleteById(id); + } + } + @OnJob({ name: JobName.IntegrityOrphanedFilesQueueAll, queue: QueueName.IntegrityCheck }) async handleOrphanedFilesQueueAll({ refreshOnly }: IIntegrityJob = {}): Promise { this.logger.log(`Checking for out of date orphaned file reports...`); diff --git a/server/src/services/maintenance.service.spec.ts b/server/src/services/maintenance.service.spec.ts index 5996d6b3c1..cc497a6ea4 100644 --- a/server/src/services/maintenance.service.spec.ts +++ b/server/src/services/maintenance.service.spec.ts @@ -106,7 +106,4 @@ describe(MaintenanceService.name, () => { expect(mocks.systemMetadata.get).toHaveBeenCalledTimes(1); }); }); - - describe.skip('getIntegrityReportSummary'); // just calls repository - describe.skip('getIntegrityReport'); // just calls repository }); diff --git a/server/src/services/maintenance.service.ts b/server/src/services/maintenance.service.ts index 75ce1583ae..e6808300bc 100644 --- a/server/src/services/maintenance.service.ts +++ b/server/src/services/maintenance.service.ts @@ -1,18 +1,9 @@ import { Injectable } from '@nestjs/common'; -import { basename } from 'node:path'; -import { Readable } from 'node:stream'; import { OnEvent } from 'src/decorators'; -import { AuthDto } from 'src/dtos/auth.dto'; -import { - MaintenanceAuthDto, - MaintenanceGetIntegrityReportDto, - MaintenanceIntegrityReportResponseDto, - MaintenanceIntegrityReportSummaryResponseDto, -} from 'src/dtos/maintenance.dto'; -import { AssetStatus, CacheControl, IntegrityReportType, SystemMetadataKey } from 'src/enum'; +import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto'; +import { 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'; @@ -59,52 +50,4 @@ export class MaintenanceService extends BaseService { return await createMaintenanceLoginUrl(baseUrl, auth, secret); } - - getIntegrityReportSummary(): Promise { - return this.integrityRepository.getIntegrityReportSummary(); - } - - async getIntegrityReport(dto: MaintenanceGetIntegrityReportDto): Promise { - return { - items: await this.integrityRepository.getIntegrityReports(dto.type), - }; - } - - getIntegrityReportCsv(type: IntegrityReportType): Readable { - return this.integrityRepository.streamIntegrityReportsCSV(type); - } - - async getIntegrityReportFile(id: string): Promise { - const { path } = await this.integrityRepository.getById(id); - - return new ImmichFileResponse({ - path, - fileName: basename(path), - contentType: 'application/octet-stream', - cacheControl: CacheControl.PrivateWithoutCache, - }); - } - - async deleteIntegrityReport(auth: AuthDto, id: string): Promise { - const { path, assetId, fileAssetId } = await this.integrityRepository.getById(id); - - if (assetId) { - await this.assetRepository.updateAll([assetId], { - deletedAt: new Date(), - status: AssetStatus.Trashed, - }); - - await this.eventRepository.emit('AssetTrashAll', { - assetIds: [assetId], - userId: auth.user.id, - }); - - await this.integrityRepository.deleteById(id); - } else if (fileAssetId) { - await this.assetRepository.deleteFiles([{ id: fileAssetId }]); - } else { - await this.storageRepository.unlink(path); - await this.integrityRepository.deleteById(id); - } - } }