refactor: split integrity out of maintenance controller/service

pull/24205/head
izzy 2025-12-17 14:55:38 +00:00
parent 7d71f99783
commit 21c26dd65f
No known key found for this signature in database
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`;
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<IntegrityReportType, number>;
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();

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* | [**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

View File

@ -27,7 +27,7 @@ class MaintenanceAdminApi {
/// * [String] id (required):
Future<Response> 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<Response> 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<Response> 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<Response> 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<Response> 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;

View File

@ -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."

View File

@ -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",

View File

@ -146,6 +146,7 @@ export const endpointTags: Record<ApiTag, string> = {
[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]:

View File

@ -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,

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 { 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<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',
Duplicates = 'Duplicates',
Faces = 'Faces',
Integrity = 'Integrity (admin)',
Jobs = 'Jobs',
Libraries = 'Libraries',
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 { 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<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 })
async handleOrphanedFilesQueueAll({ refreshOnly }: IIntegrityJob = {}): Promise<JobStatus> {
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);
});
});
describe.skip('getIntegrityReportSummary'); // just calls repository
describe.skip('getIntegrityReport'); // just calls repository
});

View File

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