feat(web,server): offline/untracked files admin tool (#4447)

* feat: admin repair orphans tool

* chore: open api

* fix: include upload folder

* fix: bugs

* feat: empty placeholder

* fix: checks

* feat: move buttons to top of page

* feat: styling and clipboard

* styling

* better clicking hitbox

* fix: show title on hover

* feat: download report

* restrict file access to immich related files

* Add description

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
Jason Rasmussen
2023-10-14 13:12:59 -04:00
committed by GitHub
parent ed386dd12a
commit d2807b8d6a
53 changed files with 3104 additions and 87 deletions

View File

@@ -2286,6 +2286,118 @@
]
}
},
"/audit/file-report": {
"get": {
"operationId": "getAuditFiles",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FileReportDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Audit"
]
}
},
"/audit/file-report/checksum": {
"post": {
"operationId": "getFileChecksums",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FileChecksumDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/FileChecksumResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Audit"
]
}
},
"/audit/file-report/fix": {
"post": {
"operationId": "fixAuditFiles",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FileReportFixDto"
}
}
},
"required": true
},
"responses": {
"201": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Audit"
]
}
},
"/auth/admin-sign-up": {
"post": {
"operationId": "adminSignUp",
@@ -6580,6 +6692,97 @@
},
"type": "object"
},
"FileChecksumDto": {
"properties": {
"filenames": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"filenames"
],
"type": "object"
},
"FileChecksumResponseDto": {
"properties": {
"checksum": {
"type": "string"
},
"filename": {
"type": "string"
}
},
"required": [
"filename",
"checksum"
],
"type": "object"
},
"FileReportDto": {
"properties": {
"extras": {
"items": {
"type": "string"
},
"type": "array"
},
"orphans": {
"items": {
"$ref": "#/components/schemas/FileReportItemDto"
},
"type": "array"
}
},
"required": [
"orphans",
"extras"
],
"type": "object"
},
"FileReportFixDto": {
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/FileReportItemDto"
},
"type": "array"
}
},
"required": [
"items"
],
"type": "object"
},
"FileReportItemDto": {
"properties": {
"checksum": {
"type": "string"
},
"entityId": {
"format": "uuid",
"type": "string"
},
"entityType": {
"$ref": "#/components/schemas/PathEntityType"
},
"pathType": {
"$ref": "#/components/schemas/PathType"
},
"pathValue": {
"type": "string"
}
},
"required": [
"entityId",
"entityType",
"pathType",
"pathValue"
],
"type": "object"
},
"ImportAssetDto": {
"properties": {
"assetPath": {
@@ -7027,6 +7230,26 @@
],
"type": "object"
},
"PathEntityType": {
"enum": [
"asset",
"person",
"user"
],
"type": "string"
},
"PathType": {
"enum": [
"original",
"jpeg_thumbnail",
"webp_thumbnail",
"encoded_video",
"sidecar",
"face",
"profile"
],
"type": "string"
},
"PeopleResponseDto": {
"properties": {
"people": {

View File

@@ -1,8 +1,10 @@
import { EntityType } from '@app/infra/entities';
import { AssetPathType, EntityType, PathType, PersonPathType, UserPathType } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsDate, IsEnum, IsUUID } from 'class-validator';
import { Optional } from '../domain.util';
import { IsArray, IsDate, IsEnum, IsString, IsUUID, ValidateNested } from 'class-validator';
import { Optional, ValidateUUID } from '../domain.util';
const PathEnum = Object.values({ ...AssetPathType, ...PersonPathType, ...UserPathType });
export class AuditDeletesDto {
@IsDate()
@@ -19,7 +21,54 @@ export class AuditDeletesDto {
userId?: string;
}
export enum PathEntityType {
ASSET = 'asset',
PERSON = 'person',
USER = 'user',
}
export class AuditDeletesResponseDto {
needsFullSync!: boolean;
ids!: string[];
}
export class FileReportDto {
orphans!: FileReportItemDto[];
extras!: string[];
}
export class FileChecksumDto {
@IsString({ each: true })
filenames!: string[];
}
export class FileChecksumResponseDto {
filename!: string;
checksum!: string;
}
export class FileReportFixDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => FileReportItemDto)
items!: FileReportItemDto[];
}
// used both as request and response dto
export class FileReportItemDto {
@ValidateUUID()
entityId!: string;
@ApiProperty({ enumName: 'PathEntityType', enum: PathEntityType })
@IsEnum(PathEntityType)
entityType!: PathEntityType;
@ApiProperty({ enumName: 'PathType', enum: PathEnum })
@IsEnum(PathEnum)
pathType!: PathType;
@IsString()
pathValue!: string;
checksum?: string;
}

View File

@@ -1,17 +1,45 @@
import { DatabaseAction, EntityType } from '@app/infra/entities';
import { IAccessRepositoryMock, auditStub, authStub, newAccessRepositoryMock, newAuditRepositoryMock } from '@test';
import { IAuditRepository } from '../repositories';
import {
IAccessRepositoryMock,
auditStub,
authStub,
newAccessRepositoryMock,
newAssetRepositoryMock,
newAuditRepositoryMock,
newCryptoRepositoryMock,
newPersonRepositoryMock,
newStorageRepositoryMock,
newUserRepositoryMock,
} from '@test';
import {
IAssetRepository,
IAuditRepository,
ICryptoRepository,
IPersonRepository,
IStorageRepository,
IUserRepository,
} from '../repositories';
import { AuditService } from './audit.service';
describe(AuditService.name, () => {
let sut: AuditService;
let accessMock: IAccessRepositoryMock;
let assetMock: jest.Mocked<IAssetRepository>;
let auditMock: jest.Mocked<IAuditRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let personMock: jest.Mocked<IPersonRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
let userMock: jest.Mocked<IUserRepository>;
beforeEach(async () => {
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
auditMock = newAuditRepositoryMock();
sut = new AuditService(accessMock, auditMock);
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
sut = new AuditService(accessMock, assetMock, cryptoMock, personMock, auditMock, storageMock, userMock);
});
it('should work', () => {

View File

@@ -1,19 +1,44 @@
import { DatabaseAction } from '@app/infra/entities';
import { Inject, Injectable } from '@nestjs/common';
import { AssetPathType, DatabaseAction, PersonPathType, UserPathType } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { DateTime } from 'luxon';
import { resolve } from 'node:path';
import { AccessCore, Permission } from '../access';
import { AuthUserDto } from '../auth';
import { AUDIT_LOG_MAX_DURATION } from '../domain.constant';
import { IAccessRepository, IAuditRepository } from '../repositories';
import { AuditDeletesDto, AuditDeletesResponseDto } from './audit.dto';
import { usePagination } from '../domain.util';
import { JOBS_ASSET_PAGINATION_SIZE } from '../job';
import {
IAccessRepository,
IAssetRepository,
IAuditRepository,
ICryptoRepository,
IPersonRepository,
IStorageRepository,
IUserRepository,
} from '../repositories';
import { StorageCore, StorageFolder } from '../storage';
import {
AuditDeletesDto,
AuditDeletesResponseDto,
FileChecksumDto,
FileChecksumResponseDto,
FileReportItemDto,
PathEntityType,
} from './audit.dto';
@Injectable()
export class AuditService {
private access: AccessCore;
private logger = new Logger(AuditService.name);
constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IPersonRepository) private personRepository: IPersonRepository,
@Inject(IAuditRepository) private repository: IAuditRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
) {
this.access = new AccessCore(accessRepository);
}
@@ -40,4 +65,160 @@ export class AuditService {
ids: audits.map(({ entityId }) => entityId),
};
}
async getChecksums(dto: FileChecksumDto) {
const results: FileChecksumResponseDto[] = [];
for (const filename of dto.filenames) {
if (!StorageCore.isImmichPath(filename)) {
throw new BadRequestException(
`Could not get the checksum of ${filename} because the file isn't accessible by Immich`,
);
}
const checksum = await this.cryptoRepository.hashFile(filename);
results.push({ filename, checksum: checksum.toString('base64') });
}
return results;
}
async fixItems(items: FileReportItemDto[]) {
for (const { entityId: id, pathType, pathValue } of items) {
if (!StorageCore.isImmichPath(pathValue)) {
throw new BadRequestException(
`Could not fix item ${id} with path ${pathValue} because the file isn't accessible by Immich`,
);
}
switch (pathType) {
case AssetPathType.ENCODED_VIDEO:
await this.assetRepository.save({ id, encodedVideoPath: pathValue });
break;
case AssetPathType.JPEG_THUMBNAIL:
await this.assetRepository.save({ id, resizePath: pathValue });
break;
case AssetPathType.WEBP_THUMBNAIL:
await this.assetRepository.save({ id, webpPath: pathValue });
break;
case AssetPathType.ORIGINAL:
await this.assetRepository.save({ id, originalPath: pathValue });
break;
case AssetPathType.SIDECAR:
await this.assetRepository.save({ id, sidecarPath: pathValue });
break;
case PersonPathType.FACE:
await this.personRepository.update({ id, thumbnailPath: pathValue });
break;
case UserPathType.PROFILE:
await this.userRepository.update(id, { profileImagePath: pathValue });
break;
}
}
}
async getFileReport() {
const fullPath = (filename: string) => resolve(filename);
const hasFile = (items: Set<string>, filename: string) => items.has(filename) || items.has(fullPath(filename));
const crawl = async (folder: StorageFolder) =>
new Set(await this.storageRepository.crawl({ pathsToCrawl: [StorageCore.getBaseFolder(folder)] }));
const uploadFiles = await crawl(StorageFolder.UPLOAD);
const libraryFiles = await crawl(StorageFolder.LIBRARY);
const thumbFiles = await crawl(StorageFolder.THUMBNAILS);
const videoFiles = await crawl(StorageFolder.ENCODED_VIDEO);
const profileFiles = await crawl(StorageFolder.PROFILE);
const allFiles = new Set<string>();
for (const list of [libraryFiles, thumbFiles, videoFiles, profileFiles, uploadFiles]) {
for (const item of list) {
allFiles.add(item);
}
}
const track = (filename: string | null) => {
if (!filename) {
return;
}
allFiles.delete(filename);
allFiles.delete(fullPath(filename));
};
this.logger.log(
`Found ${libraryFiles.size} original files, ${thumbFiles.size} thumbnails, ${videoFiles.size} encoded videos, ${profileFiles.size} profile files`,
);
const pagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (options) =>
this.assetRepository.getAll(options, { withDeleted: true }),
);
let assetCount = 0;
const orphans: FileReportItemDto[] = [];
for await (const assets of pagination) {
assetCount += assets.length;
for (const { id, originalPath, resizePath, encodedVideoPath, webpPath, isExternal, checksum } of assets) {
for (const file of [originalPath, resizePath, encodedVideoPath, webpPath]) {
track(file);
}
const entity = { entityId: id, entityType: PathEntityType.ASSET, checksum: checksum.toString('base64') };
if (
originalPath &&
!hasFile(libraryFiles, originalPath) &&
!hasFile(uploadFiles, originalPath) &&
// Android motion assets
!hasFile(videoFiles, originalPath) &&
// ignore external library assets
!isExternal
) {
orphans.push({ ...entity, pathType: AssetPathType.ORIGINAL, pathValue: originalPath });
}
if (resizePath && !hasFile(thumbFiles, resizePath)) {
orphans.push({ ...entity, pathType: AssetPathType.JPEG_THUMBNAIL, pathValue: resizePath });
}
if (webpPath && !hasFile(thumbFiles, webpPath)) {
orphans.push({ ...entity, pathType: AssetPathType.WEBP_THUMBNAIL, pathValue: webpPath });
}
if (encodedVideoPath && !hasFile(videoFiles, encodedVideoPath)) {
orphans.push({ ...entity, pathType: AssetPathType.WEBP_THUMBNAIL, pathValue: encodedVideoPath });
}
}
}
const users = await this.userRepository.getList();
for (const { id, profileImagePath } of users) {
track(profileImagePath);
const entity = { entityId: id, entityType: PathEntityType.USER };
if (profileImagePath && !hasFile(profileFiles, profileImagePath)) {
orphans.push({ ...entity, pathType: UserPathType.PROFILE, pathValue: profileImagePath });
}
}
const people = await this.personRepository.getAll();
for (const { id, thumbnailPath } of people) {
track(thumbnailPath);
const entity = { entityId: id, entityType: PathEntityType.PERSON };
if (thumbnailPath && !hasFile(thumbFiles, thumbnailPath)) {
orphans.push({ ...entity, pathType: PersonPathType.FACE, pathValue: thumbnailPath });
}
}
this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${people.length} people`);
const extras: string[] = [];
for (const file of allFiles) {
extras.push(file);
}
// send as absolute paths
for (const orphan of orphans) {
orphan.pathValue = fullPath(orphan.pathValue);
}
return { orphans, extras };
}
}

View File

@@ -289,6 +289,9 @@ export class MetadataService {
});
const checksum = this.cryptoRepository.hashSha1(video);
const motionPath = this.storageCore.getAndroidMotionPath(asset);
this.storageCore.ensureFolders(motionPath);
let motionAsset = await this.assetRepository.getByChecksum(asset.ownerId, checksum);
if (!motionAsset) {
const createdAt = asset.fileCreatedAt ?? asset.createdAt;
@@ -300,7 +303,7 @@ export class MetadataService {
localDateTime: createdAt,
checksum,
ownerId: asset.ownerId,
originalPath: this.storageCore.getAndroidMotionPath(asset),
originalPath: motionPath,
originalFileName: asset.originalFileName,
isVisible: false,
isReadOnly: false,

View File

@@ -14,6 +14,7 @@ export interface AssetSearchOptions {
trashedBefore?: Date;
type?: AssetType;
order?: 'ASC' | 'DESC';
withDeleted?: boolean;
}
export interface LivePhotoSearchOptions {

View File

@@ -1,40 +1,20 @@
import {
newAssetRepositoryMock,
newMoveRepositoryMock,
newPersonRepositoryMock,
newStorageRepositoryMock,
newSystemConfigRepositoryMock,
newUserRepositoryMock,
} from '@test';
import { newStorageRepositoryMock, newSystemConfigRepositoryMock, newUserRepositoryMock } from '@test';
import { serverVersion } from '../domain.constant';
import {
IAssetRepository,
IMoveRepository,
IPersonRepository,
IStorageRepository,
ISystemConfigRepository,
IUserRepository,
} from '../repositories';
import { IStorageRepository, ISystemConfigRepository, IUserRepository } from '../repositories';
import { ServerInfoService } from './server-info.service';
describe(ServerInfoService.name, () => {
let sut: ServerInfoService;
let assetMock: jest.Mocked<IAssetRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>;
let moveMock: jest.Mocked<IMoveRepository>;
let personMock: jest.Mocked<IPersonRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
let userMock: jest.Mocked<IUserRepository>;
beforeEach(() => {
assetMock = newAssetRepositoryMock();
configMock = newSystemConfigRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
sut = new ServerInfoService(assetMock, configMock, moveMock, personMock, userMock, storageMock);
sut = new ServerInfoService(configMock, userMock, storageMock);
});
it('should work', () => {

View File

@@ -1,15 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { mimeTypes, serverVersion } from '../domain.constant';
import { asHumanReadable } from '../domain.util';
import {
IAssetRepository,
IMoveRepository,
IPersonRepository,
IStorageRepository,
ISystemConfigRepository,
IUserRepository,
UserStatsQueryResponse,
} from '../repositories';
import { IStorageRepository, ISystemConfigRepository, IUserRepository, UserStatsQueryResponse } from '../repositories';
import { StorageCore, StorageFolder } from '../storage';
import { SystemConfigCore } from '../system-config';
import {
@@ -25,22 +17,17 @@ import {
@Injectable()
export class ServerInfoService {
private configCore: SystemConfigCore;
private storageCore: StorageCore;
constructor(
@Inject(IAssetRepository) assetRepository: IAssetRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {
this.configCore = SystemConfigCore.create(configRepository);
this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
}
async getInfo(): Promise<ServerInfoResponseDto> {
const libraryBase = this.storageCore.getBaseFolder(StorageFolder.LIBRARY);
const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY);
const diskInfo = await this.storageRepository.checkDiskUsage(libraryBase);
const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2);

View File

@@ -90,7 +90,7 @@ export class StorageTemplateService {
}
this.logger.debug('Cleaning up empty directories...');
const libraryFolder = this.storageCore.getBaseFolder(StorageFolder.LIBRARY);
const libraryFolder = StorageCore.getBaseFolder(StorageFolder.LIBRARY);
await this.storageRepository.removeEmptyDirs(libraryFolder);
this.logger.log('Finished storage template migration');

View File

@@ -1,6 +1,6 @@
import { AssetEntity, AssetPathType, PathType, PersonEntity, PersonPathType } from '@app/infra/entities';
import { Logger } from '@nestjs/common';
import { dirname, join } from 'node:path';
import { dirname, join, resolve } from 'node:path';
import { APP_MEDIA_LOCATION } from '../domain.constant';
import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
@@ -32,14 +32,14 @@ export class StorageCore {
) {}
getFolderLocation(folder: StorageFolder, userId: string) {
return join(this.getBaseFolder(folder), userId);
return join(StorageCore.getBaseFolder(folder), userId);
}
getLibraryFolder(user: { storageLabel: string | null; id: string }) {
return join(this.getBaseFolder(StorageFolder.LIBRARY), user.storageLabel || user.id);
return join(StorageCore.getBaseFolder(StorageFolder.LIBRARY), user.storageLabel || user.id);
}
getBaseFolder(folder: StorageFolder) {
static getBaseFolder(folder: StorageFolder) {
return join(APP_MEDIA_LOCATION, folder);
}
@@ -64,7 +64,11 @@ export class StorageCore {
}
isAndroidMotionPath(originalPath: string) {
return originalPath.startsWith(this.getBaseFolder(StorageFolder.ENCODED_VIDEO));
return originalPath.startsWith(StorageCore.getBaseFolder(StorageFolder.ENCODED_VIDEO));
}
static isImmichPath(path: string) {
return resolve(path).startsWith(resolve(APP_MEDIA_LOCATION));
}
async moveAssetFile(asset: AssetEntity, pathType: GeneratedAssetPath) {
@@ -135,7 +139,7 @@ export class StorageCore {
}
removeEmptyDirs(folder: StorageFolder) {
return this.repository.removeEmptyDirs(this.getBaseFolder(folder));
return this.repository.removeEmptyDirs(StorageCore.getBaseFolder(folder));
}
private savePath(pathType: PathType, id: string, newPath: string) {

View File

@@ -1,25 +1,14 @@
import {
newAssetRepositoryMock,
newMoveRepositoryMock,
newPersonRepositoryMock,
newStorageRepositoryMock,
} from '@test';
import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
import { newStorageRepositoryMock } from '@test';
import { IStorageRepository } from '../repositories';
import { StorageService } from './storage.service';
describe(StorageService.name, () => {
let sut: StorageService;
let assetMock: jest.Mocked<IAssetRepository>;
let moveMock: jest.Mocked<IMoveRepository>;
let personMock: jest.Mocked<IPersonRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
beforeEach(async () => {
assetMock = newAssetRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
sut = new StorageService(assetMock, moveMock, personMock, storageMock);
sut = new StorageService(storageMock);
});
it('should work', () => {

View File

@@ -1,24 +1,16 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { IDeleteFilesJob } from '../job';
import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
import { IStorageRepository } from '../repositories';
import { StorageCore, StorageFolder } from './storage.core';
@Injectable()
export class StorageService {
private logger = new Logger(StorageService.name);
private storageCore: StorageCore;
constructor(
@Inject(IAssetRepository) assetRepository: IAssetRepository,
@Inject(IMoveRepository) private moveRepository: IMoveRepository,
@Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {
this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
}
constructor(@Inject(IStorageRepository) private storageRepository: IStorageRepository) {}
init() {
const libraryBase = this.storageCore.getBaseFolder(StorageFolder.LIBRARY);
const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY);
this.storageRepository.mkdirSync(libraryBase);
}

View File

@@ -1,7 +1,16 @@
import { AuditDeletesDto, AuditDeletesResponseDto, AuditService, AuthUserDto } from '@app/domain';
import { Controller, Get, Query } from '@nestjs/common';
import {
AuditDeletesDto,
AuditDeletesResponseDto,
AuditService,
AuthUserDto,
FileChecksumDto,
FileChecksumResponseDto,
FileReportDto,
FileReportFixDto,
} from '@app/domain';
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthUser, Authenticated } from '../app.guard';
import { AdminRoute, AuthUser, Authenticated } from '../app.guard';
import { UseValidation } from '../app.utils';
@ApiTags('Audit')
@@ -15,4 +24,22 @@ export class AuditController {
getAuditDeletes(@AuthUser() authUser: AuthUserDto, @Query() dto: AuditDeletesDto): Promise<AuditDeletesResponseDto> {
return this.service.getDeletes(authUser, dto);
}
@AdminRoute()
@Get('file-report')
getAuditFiles(): Promise<FileReportDto> {
return this.service.getFileReport();
}
@AdminRoute()
@Post('file-report/checksum')
getFileChecksums(@Body() dto: FileChecksumDto): Promise<FileChecksumResponseDto[]> {
return this.service.getChecksums(dto);
}
@AdminRoute()
@Post('file-report/fix')
fixAuditFiles(@Body() dto: FileReportFixDto): Promise<void> {
return this.service.fixItems(dto.items);
}
}

View File

@@ -34,4 +34,8 @@ export enum PersonPathType {
FACE = 'face',
}
export type PathType = AssetPathType | PersonPathType;
export enum UserPathType {
PROFILE = 'profile',
}
export type PathType = AssetPathType | PersonPathType | UserPathType;

View File

@@ -174,7 +174,7 @@ export class AssetRepository implements IAssetRepository {
person: true,
},
},
withDeleted: !!options.trashedBefore,
withDeleted: options.withDeleted ?? !!options.trashedBefore,
order: {
// Ensures correct order when paginating
createdAt: options.order ?? 'ASC',