refactor: split integrity out of maintenance controller/service

This commit is contained in:
izzy
2025-12-17 14:55:38 +00:00
parent 7d71f99783
commit 21c26dd65f
14 changed files with 283 additions and 244 deletions

View File

@@ -7,7 +7,7 @@ import { afterEach, beforeAll, describe, expect, it } from 'vitest';
const assetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`; const assetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
describe('/admin/maintenance', () => { describe('/admin/integrity', () => {
let cookie: string | undefined; let cookie: string | undefined;
let admin: LoginResponseDto; let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto; let nonAdmin: LoginResponseDto;
@@ -18,7 +18,7 @@ describe('/admin/maintenance', () => {
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
}); });
describe('POST /integrity/summary (& jobs)', async () => { describe('POST /summary (& jobs)', async () => {
let baseline: Record<IntegrityReportType, number>; let baseline: Record<IntegrityReportType, number>;
beforeAll(async () => { beforeAll(async () => {
@@ -53,7 +53,7 @@ describe('/admin/maintenance', () => {
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/admin/maintenance/integrity/summary') .get('/admin/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send(); .send();
@@ -77,7 +77,7 @@ describe('/admin/maintenance', () => {
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/admin/maintenance/integrity/summary') .get('/admin/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send(); .send();
@@ -101,7 +101,7 @@ describe('/admin/maintenance', () => {
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/admin/maintenance/integrity/summary') .get('/admin/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send(); .send();
@@ -123,7 +123,7 @@ describe('/admin/maintenance', () => {
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/admin/maintenance/integrity/summary') .get('/admin/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send(); .send();
@@ -144,7 +144,7 @@ describe('/admin/maintenance', () => {
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/admin/maintenance/integrity/summary') .get('/admin/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send(); .send();
@@ -167,7 +167,7 @@ describe('/admin/maintenance', () => {
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/admin/maintenance/integrity/summary') .get('/admin/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send(); .send();
@@ -187,7 +187,7 @@ describe('/admin/maintenance', () => {
await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck); await utils.waitForQueueFinish(admin.accessToken, QueueName.IntegrityCheck);
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/admin/maintenance/integrity/summary') .get('/admin/integrity/summary')
.set('Authorization', `Bearer ${admin.accessToken}`) .set('Authorization', `Bearer ${admin.accessToken}`)
.send(); .send();

View File

@@ -161,11 +161,11 @@ Class | Method | HTTP request | Description
*LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | Scan a library *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* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | Update a library
*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | Validate library settings *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* | [**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/maintenance/integrity/report | Get integrity report by type *MaintenanceAdminApi* | [**getIntegrityReport**](doc//MaintenanceAdminApi.md#getintegrityreport) | **POST** /admin/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* | [**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/maintenance/integrity/report/{id}/file | Download the orphan/broken file if one exists *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/maintenance/integrity/summary | Get integrity report summary *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* | [**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 *MaintenanceAdminApi* | [**setMaintenanceMode**](doc//MaintenanceAdminApi.md#setmaintenancemode) | **POST** /admin/maintenance | Set maintenance mode
*MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | Retrieve map markers *MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | Retrieve map markers

View File

@@ -27,7 +27,7 @@ class MaintenanceAdminApi {
/// * [String] id (required): /// * [String] id (required):
Future<Response> deleteIntegrityReportWithHttpInfo(String id,) async { Future<Response> deleteIntegrityReportWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance/integrity/report/{id}' final apiPath = r'/admin/integrity/report/{id}'
.replaceAll('{id}', id); .replaceAll('{id}', id);
// ignore: prefer_final_locals // ignore: prefer_final_locals
@@ -76,7 +76,7 @@ class MaintenanceAdminApi {
/// * [MaintenanceGetIntegrityReportDto] maintenanceGetIntegrityReportDto (required): /// * [MaintenanceGetIntegrityReportDto] maintenanceGetIntegrityReportDto (required):
Future<Response> getIntegrityReportWithHttpInfo(MaintenanceGetIntegrityReportDto maintenanceGetIntegrityReportDto,) async { Future<Response> getIntegrityReportWithHttpInfo(MaintenanceGetIntegrityReportDto maintenanceGetIntegrityReportDto,) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance/integrity/report'; final apiPath = r'/admin/integrity/report';
// ignore: prefer_final_locals // ignore: prefer_final_locals
Object? postBody = maintenanceGetIntegrityReportDto; Object? postBody = maintenanceGetIntegrityReportDto;
@@ -132,7 +132,7 @@ class MaintenanceAdminApi {
/// * [IntegrityReportType] type (required): /// * [IntegrityReportType] type (required):
Future<Response> getIntegrityReportCsvWithHttpInfo(IntegrityReportType type,) async { Future<Response> getIntegrityReportCsvWithHttpInfo(IntegrityReportType type,) async {
// ignore: prefer_const_declarations // 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()); .replaceAll('{type}', type.toString());
// ignore: prefer_final_locals // ignore: prefer_final_locals
@@ -189,7 +189,7 @@ class MaintenanceAdminApi {
/// * [String] id (required): /// * [String] id (required):
Future<Response> getIntegrityReportFileWithHttpInfo(String id,) async { Future<Response> getIntegrityReportFileWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance/integrity/report/{id}/file' final apiPath = r'/admin/integrity/report/{id}/file'
.replaceAll('{id}', id); .replaceAll('{id}', id);
// ignore: prefer_final_locals // ignore: prefer_final_locals
@@ -242,7 +242,7 @@ class MaintenanceAdminApi {
/// Note: This method returns the HTTP [Response]. /// Note: This method returns the HTTP [Response].
Future<Response> getIntegrityReportSummaryWithHttpInfo() async { Future<Response> getIntegrityReportSummaryWithHttpInfo() async {
// ignore: prefer_const_declarations // ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance/integrity/summary'; final apiPath = r'/admin/integrity/summary';
// ignore: prefer_final_locals // ignore: prefer_final_locals
Object? postBody; Object? postBody;

View File

@@ -322,57 +322,7 @@
"x-immich-state": "Stable" "x-immich-state": "Stable"
} }
}, },
"/admin/maintenance": { "/admin/integrity/report": {
"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": {
"post": { "post": {
"description": "...", "description": "...",
"operationId": "getIntegrityReport", "operationId": "getIntegrityReport",
@@ -429,7 +379,7 @@
"x-immich-state": "Alpha" "x-immich-state": "Alpha"
} }
}, },
"/admin/maintenance/integrity/report/{id}": { "/admin/integrity/report/{id}": {
"delete": { "delete": {
"description": "...", "description": "...",
"operationId": "deleteIntegrityReport", "operationId": "deleteIntegrityReport",
@@ -479,7 +429,7 @@
"x-immich-state": "Alpha" "x-immich-state": "Alpha"
} }
}, },
"/admin/maintenance/integrity/report/{id}/file": { "/admin/integrity/report/{id}/file": {
"get": { "get": {
"description": "...", "description": "...",
"operationId": "getIntegrityReportFile", "operationId": "getIntegrityReportFile",
@@ -537,7 +487,7 @@
"x-immich-state": "Alpha" "x-immich-state": "Alpha"
} }
}, },
"/admin/maintenance/integrity/report/{type}/csv": { "/admin/integrity/report/{type}/csv": {
"get": { "get": {
"description": "...", "description": "...",
"operationId": "getIntegrityReportCsv", "operationId": "getIntegrityReportCsv",
@@ -594,7 +544,7 @@
"x-immich-state": "Alpha" "x-immich-state": "Alpha"
} }
}, },
"/admin/maintenance/integrity/summary": { "/admin/integrity/summary": {
"get": { "get": {
"description": "...", "description": "...",
"operationId": "getIntegrityReportSummary", "operationId": "getIntegrityReportSummary",
@@ -641,6 +591,56 @@
"x-immich-state": "Alpha" "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": { "/admin/maintenance/login": {
"post": { "post": {
"description": "Login with maintenance token or cookie to receive current information and perform further actions.", "description": "Login with maintenance token or cookie to receive current information and perform further actions.",
@@ -14581,6 +14581,10 @@
"name": "Faces", "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." "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", "name": "Jobs",
"description": "Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed." "description": "Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed."

View File

@@ -40,9 +40,6 @@ export type ActivityStatisticsResponseDto = {
comments: number; comments: number;
likes: number; likes: number;
}; };
export type SetMaintenanceModeDto = {
action: MaintenanceAction;
};
export type MaintenanceGetIntegrityReportDto = { export type MaintenanceGetIntegrityReportDto = {
"type": IntegrityReportType; "type": IntegrityReportType;
}; };
@@ -59,6 +56,9 @@ export type MaintenanceIntegrityReportSummaryResponseDto = {
missing_file: number; missing_file: number;
orphan_file: number; orphan_file: number;
}; };
export type SetMaintenanceModeDto = {
action: MaintenanceAction;
};
export type MaintenanceLoginDto = { export type MaintenanceLoginDto = {
token?: string; token?: string;
}; };
@@ -1884,18 +1884,6 @@ export function unlinkAllOAuthAccountsAdmin(opts?: Oazapfts.RequestOpts) {
method: "POST" 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 * Get integrity report by type
*/ */
@@ -1905,7 +1893,7 @@ export function getIntegrityReport({ maintenanceGetIntegrityReportDto }: {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 201; status: 201;
data: MaintenanceIntegrityReportResponseDto; data: MaintenanceIntegrityReportResponseDto;
}>("/admin/maintenance/integrity/report", oazapfts.json({ }>("/admin/integrity/report", oazapfts.json({
...opts, ...opts,
method: "POST", method: "POST",
body: maintenanceGetIntegrityReportDto body: maintenanceGetIntegrityReportDto
@@ -1917,7 +1905,7 @@ export function getIntegrityReport({ maintenanceGetIntegrityReportDto }: {
export function deleteIntegrityReport({ id }: { export function deleteIntegrityReport({ id }: {
id: string; id: string;
}, opts?: Oazapfts.RequestOpts) { }, 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, ...opts,
method: "DELETE" method: "DELETE"
})); }));
@@ -1931,7 +1919,7 @@ export function getIntegrityReportFile({ id }: {
return oazapfts.ok(oazapfts.fetchBlob<{ return oazapfts.ok(oazapfts.fetchBlob<{
status: 200; status: 200;
data: Blob; data: Blob;
}>(`/admin/maintenance/integrity/report/${encodeURIComponent(id)}/file`, { }>(`/admin/integrity/report/${encodeURIComponent(id)}/file`, {
...opts ...opts
})); }));
} }
@@ -1944,7 +1932,7 @@ export function getIntegrityReportCsv({ $type }: {
return oazapfts.ok(oazapfts.fetchBlob<{ return oazapfts.ok(oazapfts.fetchBlob<{
status: 200; status: 200;
data: Blob; data: Blob;
}>(`/admin/maintenance/integrity/report/${encodeURIComponent($type)}/csv`, { }>(`/admin/integrity/report/${encodeURIComponent($type)}/csv`, {
...opts ...opts
})); }));
} }
@@ -1955,10 +1943,22 @@ export function getIntegrityReportSummary(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: MaintenanceIntegrityReportSummaryResponseDto; data: MaintenanceIntegrityReportSummaryResponseDto;
}>("/admin/maintenance/integrity/summary", { }>("/admin/integrity/summary", {
...opts ...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 * Log into maintenance mode
*/ */
@@ -5235,15 +5235,15 @@ export enum UserAvatarColor {
Gray = "gray", Gray = "gray",
Amber = "amber" Amber = "amber"
} }
export enum MaintenanceAction {
Start = "start",
End = "end"
}
export enum IntegrityReportType { export enum IntegrityReportType {
OrphanFile = "orphan_file", OrphanFile = "orphan_file",
MissingFile = "missing_file", MissingFile = "missing_file",
ChecksumMismatch = "checksum_mismatch" ChecksumMismatch = "checksum_mismatch"
} }
export enum MaintenanceAction {
Start = "start",
End = "end"
}
export enum NotificationLevel { export enum NotificationLevel {
Success = "success", Success = "success",
Error = "error", Error = "error",

View File

@@ -146,6 +146,7 @@ export const endpointTags: Record<ApiTag, string> = {
[ApiTag.Duplicates]: 'Endpoints for managing and identifying duplicate assets.', [ApiTag.Duplicates]: 'Endpoints for managing and identifying duplicate assets.',
[ApiTag.Faces]: [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.', '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]: [ApiTag.Jobs]:
'Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed.', 'Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed.',
[ApiTag.Libraries]: [ApiTag.Libraries]:

View File

@@ -9,6 +9,7 @@ import { AuthController } from 'src/controllers/auth.controller';
import { DownloadController } from 'src/controllers/download.controller'; import { DownloadController } from 'src/controllers/download.controller';
import { DuplicateController } from 'src/controllers/duplicate.controller'; import { DuplicateController } from 'src/controllers/duplicate.controller';
import { FaceController } from 'src/controllers/face.controller'; import { FaceController } from 'src/controllers/face.controller';
import { IntegrityController } from 'src/controllers/integrity.controller';
import { JobController } from 'src/controllers/job.controller'; import { JobController } from 'src/controllers/job.controller';
import { LibraryController } from 'src/controllers/library.controller'; import { LibraryController } from 'src/controllers/library.controller';
import { MaintenanceController } from 'src/controllers/maintenance.controller'; import { MaintenanceController } from 'src/controllers/maintenance.controller';
@@ -49,6 +50,7 @@ export const controllers = [
DownloadController, DownloadController,
DuplicateController, DuplicateController,
FaceController, FaceController,
IntegrityController,
JobController, JobController,
LibraryController, LibraryController,
MaintenanceController, MaintenanceController,

View File

@@ -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<MaintenanceIntegrityReportSummaryResponseDto> {
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<MaintenanceIntegrityReportResponseDto> {
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<void> {
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<void> {
await sendFile(res, next, () => this.service.getIntegrityReportFile(id), this.logger);
}
}

View File

@@ -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 { ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express'; import { Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators'; import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { import { MaintenanceAuthDto, MaintenanceLoginDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
MaintenanceAuthDto,
MaintenanceGetIntegrityReportDto,
MaintenanceIntegrityReportResponseDto,
MaintenanceIntegrityReportSummaryResponseDto,
MaintenanceLoginDto,
SetMaintenanceModeDto,
} from 'src/dtos/maintenance.dto';
import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum'; import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum';
import { Auth, Authenticated, FileResponse, GetLoginDetails } from 'src/middleware/auth.guard'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { LoginDetails } from 'src/services/auth.service'; import { LoginDetails } from 'src/services/auth.service';
import { MaintenanceService } from 'src/services/maintenance.service'; import { MaintenanceService } from 'src/services/maintenance.service';
import { sendFile } from 'src/utils/file';
import { respondWithCookie } from 'src/utils/response'; import { respondWithCookie } from 'src/utils/response';
import { IntegrityReportTypeParamDto, UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Maintenance) @ApiTags(ApiTag.Maintenance)
@Controller('admin/maintenance') @Controller('admin/maintenance')
export class MaintenanceController { export class MaintenanceController {
constructor( constructor(private service: MaintenanceService) {}
private logger: LoggingRepository,
private service: MaintenanceService,
) {}
@Post('login') @Post('login')
@Endpoint({ @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<MaintenanceIntegrityReportSummaryResponseDto> {
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<MaintenanceIntegrityReportResponseDto> {
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<void> {
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<void> {
await sendFile(res, next, () => this.service.getIntegrityReportFile(id), this.logger);
}
} }

View File

@@ -863,6 +863,7 @@ export enum ApiTag {
Download = 'Download', Download = 'Download',
Duplicates = 'Duplicates', Duplicates = 'Duplicates',
Faces = 'Faces', Faces = 'Faces',
Integrity = 'Integrity (admin)',
Jobs = 'Jobs', Jobs = 'Jobs',
Libraries = 'Libraries', Libraries = 'Libraries',
Maintenance = 'Maintenance (admin)', Maintenance = 'Maintenance (admin)',

View File

@@ -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');
});

View File

@@ -2,13 +2,21 @@ import { Injectable } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs'; import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises'; 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 { pipeline } from 'node:stream/promises';
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants'; import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import {
MaintenanceGetIntegrityReportDto,
MaintenanceIntegrityReportResponseDto,
MaintenanceIntegrityReportSummaryResponseDto,
} from 'src/dtos/maintenance.dto';
import { import {
AssetStatus, AssetStatus,
CacheControl,
DatabaseLock, DatabaseLock,
ImmichWorker, ImmichWorker,
IntegrityReportType, IntegrityReportType,
@@ -28,6 +36,7 @@ import {
IIntegrityPathWithChecksumJob, IIntegrityPathWithChecksumJob,
IIntegrityPathWithReportJob, IIntegrityPathWithReportJob,
} from 'src/types'; } from 'src/types';
import { ImmichFileResponse } from 'src/utils/file';
import { handlePromiseError } from 'src/utils/misc'; import { handlePromiseError } from 'src/utils/misc';
/** /**
@@ -145,6 +154,54 @@ export class IntegrityService extends BaseService {
}); });
} }
getIntegrityReportSummary(): Promise<MaintenanceIntegrityReportSummaryResponseDto> {
return this.integrityRepository.getIntegrityReportSummary();
}
async getIntegrityReport(dto: MaintenanceGetIntegrityReportDto): Promise<MaintenanceIntegrityReportResponseDto> {
return {
items: await this.integrityRepository.getIntegrityReports(dto.type),
};
}
getIntegrityReportCsv(type: IntegrityReportType): Readable {
return this.integrityRepository.streamIntegrityReportsCSV(type);
}
async getIntegrityReportFile(id: string): Promise<ImmichFileResponse> {
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<void> {
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 }) @OnJob({ name: JobName.IntegrityOrphanedFilesQueueAll, queue: QueueName.IntegrityCheck })
async handleOrphanedFilesQueueAll({ refreshOnly }: IIntegrityJob = {}): Promise<JobStatus> { async handleOrphanedFilesQueueAll({ refreshOnly }: IIntegrityJob = {}): Promise<JobStatus> {
this.logger.log(`Checking for out of date orphaned file reports...`); this.logger.log(`Checking for out of date orphaned file reports...`);

View File

@@ -106,7 +106,4 @@ describe(MaintenanceService.name, () => {
expect(mocks.systemMetadata.get).toHaveBeenCalledTimes(1); expect(mocks.systemMetadata.get).toHaveBeenCalledTimes(1);
}); });
}); });
describe.skip('getIntegrityReportSummary'); // just calls repository
describe.skip('getIntegrityReport'); // just calls repository
}); });

View File

@@ -1,18 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { basename } from 'node:path';
import { Readable } from 'node:stream';
import { OnEvent } from 'src/decorators'; import { OnEvent } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { import { SystemMetadataKey } from 'src/enum';
MaintenanceAuthDto,
MaintenanceGetIntegrityReportDto,
MaintenanceIntegrityReportResponseDto,
MaintenanceIntegrityReportSummaryResponseDto,
} from 'src/dtos/maintenance.dto';
import { AssetStatus, CacheControl, IntegrityReportType, SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { MaintenanceModeState } from 'src/types'; import { MaintenanceModeState } from 'src/types';
import { ImmichFileResponse } from 'src/utils/file';
import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance'; import { createMaintenanceLoginUrl, generateMaintenanceSecret, signMaintenanceJwt } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc'; import { getExternalDomain } from 'src/utils/misc';
@@ -59,52 +50,4 @@ export class MaintenanceService extends BaseService {
return await createMaintenanceLoginUrl(baseUrl, auth, secret); return await createMaintenanceLoginUrl(baseUrl, auth, secret);
} }
getIntegrityReportSummary(): Promise<MaintenanceIntegrityReportSummaryResponseDto> {
return this.integrityRepository.getIntegrityReportSummary();
}
async getIntegrityReport(dto: MaintenanceGetIntegrityReportDto): Promise<MaintenanceIntegrityReportResponseDto> {
return {
items: await this.integrityRepository.getIntegrityReports(dto.type),
};
}
getIntegrityReportCsv(type: IntegrityReportType): Readable {
return this.integrityRepository.streamIntegrityReportsCSV(type);
}
async getIntegrityReportFile(id: string): Promise<ImmichFileResponse> {
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<void> {
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);
}
}
} }