Compare commits

..

1 Commits

Author SHA1 Message Date
bo0tzz
c24830acbf fix: do not cancel running docs builds 2024-10-02 10:24:20 +02:00
150 changed files with 2221 additions and 1872 deletions

View File

@@ -9,7 +9,7 @@ on:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
cancel-in-progress: false
jobs:
pre-job:

6
cli/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@immich/cli",
"version": "2.2.23",
"version": "2.2.22",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.2.23",
"version": "2.2.22",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"fast-glob": "^3.3.2",
@@ -52,7 +52,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.117.0",
"version": "1.116.2",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.23",
"version": "2.2.22",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",

View File

@@ -30,8 +30,6 @@ You can organize these as one parent with seven child datasets, for example `mnt
:::info Permissions
The **pgData** dataset must be owned by the user `netdata` (UID 999) for postgres to start. The other datasets must be owned by the user `root` (UID 0) or a group that includes the user `root` (UID 0) for immich to have the necessary permissions.
The **library** dataset must have [ACL mode](https://www.truenas.com/docs/core/coretutorials/storage/pools/permissions/#access-control-lists) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **uploads** to **library**, immich performs `chmod` internally and needs to be allowed to execute the command.
:::
## Installing the Immich Application

View File

@@ -201,7 +201,7 @@ const config = {
darkTheme: prism.themes.dracula,
additionalLanguages: ['sql', 'diff', 'bash', 'powershell', 'nginx'],
},
image: 'img/feature-panel.png',
image: 'overview/img/feature-panel.png',
}),
};

View File

@@ -1,8 +1,4 @@
[
{
"label": "v1.117.0",
"url": "https://v1.117.0.archive.immich.app"
},
{
"label": "v1.116.2",
"url": "https://v1.116.2.archive.immich.app"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 758 KiB

8
e2e/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.117.0",
"version": "1.116.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.117.0",
"version": "1.116.2",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@@ -45,7 +45,7 @@
},
"../cli": {
"name": "@immich/cli",
"version": "2.2.23",
"version": "2.2.22",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@@ -92,7 +92,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.117.0",
"version": "1.116.2",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.117.0",
"version": "1.116.2",
"description": "",
"main": "index.js",
"type": "module",

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.117.0"
version = "1.116.2"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 162,
"android.injected.version.name" => "1.117.0",
"android.injected.version.code" => 161,
"android.injected.version.name" => "1.116.2",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release"
lane :release do
increment_version_number(
version_number: "1.117.0"
version_number: "1.116.2"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -507,7 +507,7 @@ class SyncService {
List<Album> onDevice, [
Set<String>? excludedAssets,
]) async {
onDevice.sort((a, b) => a.localId!.compareTo(b.localId!));
onDevice.sort((a, b) => a.id.compareTo(b.id));
final inDb =
await _albumRepository.getAll(remote: false, sortBy: AlbumSort.localId);
final List<Asset> deleteCandidates = [];

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.117.0
- API version: 1.116.2
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.117.0+162
version: 1.116.2+161
environment:
sdk: '>=3.3.0 <4.0.0'

View File

@@ -7436,7 +7436,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.117.0",
"version": "1.116.2",
"contact": {}
},
"tags": [],

View File

@@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
"version": "1.117.0",
"version": "1.116.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.117.0",
"version": "1.116.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.117.0",
"version": "1.116.2",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 1.117.0
* 1.116.2
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/

View File

@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.117.0",
"version": "1.116.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.117.0",
"version": "1.116.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@nestjs/bullmq": "^10.0.1",

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.117.0",
"version": "1.116.2",
"description": "",
"author": "",
"private": true,

View File

@@ -7,7 +7,7 @@ import { useSwagger } from 'src/utils/misc';
const sync = async () => {
const app = await NestFactory.create<NestExpressApplication>(ApiModule, { preview: true });
useSwagger(app, { write: true });
useSwagger(app, true);
await app.close();
};

View File

@@ -12,7 +12,6 @@ import {
Colorspace,
CQMode,
ImageFormat,
ImmichEnvironment,
LogLevel,
ToneMapping,
TranscodeHWAccel,
@@ -323,10 +322,7 @@ export const immichAppConfig: ConfigModuleOptions = {
envFilePath: '.env',
isGlobal: true,
validationSchema: Joi.object({
IMMICH_ENV: Joi.string()
.optional()
.valid(...Object.values(ImmichEnvironment))
.default(ImmichEnvironment.PRODUCTION),
IMMICH_ENV: Joi.string().optional().valid('development', 'testing', 'production').default('production'),
IMMICH_LOG_LEVEL: Joi.string()
.optional()
.valid(...Object.values(LogLevel)),

View File

@@ -21,6 +21,7 @@ export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
export const ONE_HOUR = Duration.fromObject({ hours: 1 });
export const envName = (process.env.IMMICH_ENV || 'production').toUpperCase();
export const isDev = () => process.env.IMMICH_ENV === 'development';
export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload';
const HOST_SERVER_PORT = process.env.IMMICH_PORT || '2283';
export const DEFAULT_EXTERNAL_DOMAIN = 'http://localhost:' + HOST_SERVER_PORT;

View File

@@ -328,9 +328,3 @@ export enum PaginationMode {
LIMIT_OFFSET = 'limit-offset',
SKIP_TAKE = 'skip-take',
}
export enum ImmichEnvironment {
DEVELOPMENT = 'development',
TESTING = 'testing',
PRODUCTION = 'production',
}

View File

@@ -1,23 +1,16 @@
import { ImmichEnvironment, LogLevel } from 'src/enum';
import { VectorExtension } from 'src/interfaces/database.interface';
export const IConfigRepository = 'IConfigRepository';
export interface EnvData {
environment: ImmichEnvironment;
configFile?: string;
logLevel?: LogLevel;
database: {
skipMigrations: boolean;
vectorExtension: VectorExtension;
};
storage: {
ignoreMountCheckErrors: boolean;
};
nodeVersion?: string;
}
export interface IConfigRepository {

View File

@@ -5,7 +5,7 @@ export const ILoggerRepository = 'ILoggerRepository';
export interface ILoggerRepository {
setAppName(name: string): void;
setContext(message: string): void;
setLogLevel(level: LogLevel | false): void;
setLogLevel(level: LogLevel): void;
isLevelEnabled(level: LogLevel): boolean;
verbose(message: any, ...args: any): void;

View File

@@ -1,17 +1,12 @@
import { Injectable } from '@nestjs/common';
import { getVectorExtension } from 'src/database.config';
import { ImmichEnvironment, LogLevel } from 'src/enum';
import { EnvData, IConfigRepository } from 'src/interfaces/config.interface';
// TODO replace src/config validation with class-validator, here
@Injectable()
export class ConfigRepository implements IConfigRepository {
getEnv(): EnvData {
return {
environment: process.env.IMMICH_ENV as ImmichEnvironment,
configFile: process.env.IMMICH_CONFIG_FILE,
logLevel: process.env.IMMICH_LOG_LEVEL as LogLevel,
database: {
skipMigrations: process.env.DB_SKIP_MIGRATIONS === 'true',
vectorExtension: getVectorExtension(),

View File

@@ -25,8 +25,8 @@ export class LoggerRepository extends ConsoleLogger implements ILoggerRepository
return isLogLevelEnabled(level, LoggerRepository.logLevels);
}
setLogLevel(level: LogLevel | false): void {
LoggerRepository.logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : [];
setLogLevel(level: LogLevel): void {
LoggerRepository.logLevels = LOG_LEVELS.slice(LOG_LEVELS.indexOf(level));
}
protected formatContext(context: string): string {

View File

@@ -79,12 +79,13 @@ export class MediaRepository implements IMediaRepository {
failOn: options.processInvalidImages ? 'none' : 'error',
limitInputPixels: false,
raw: options.raw,
})
.pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16')
.withIccProfile(options.colorspace);
});
if (!options.raw) {
pipeline = pipeline.rotate();
pipeline = pipeline
.pipelineColorspace(options.colorspace === Colorspace.SRGB ? 'srgb' : 'rgb16')
.withIccProfile(options.colorspace)
.rotate();
}
if (options.crop) {

View File

@@ -5,7 +5,6 @@ import { readFile } from 'node:fs/promises';
import { promisify } from 'node:util';
import sharp from 'sharp';
import { resourcePaths } from 'src/constants';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { GitHubRelease, IServerInfoRepository, ServerBuildVersions } from 'src/interfaces/server-info.interface';
import { Instrumentation } from 'src/utils/instrumentation';
@@ -38,10 +37,7 @@ const getLockfileVersion = (name: string, lockfile?: BuildLockfile) => {
@Instrumentation()
@Injectable()
export class ServerInfoRepository implements IServerInfoRepository {
constructor(
@Inject(IConfigRepository) private configRepository: IConfigRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) {
this.logger.setContext(ServerInfoRepository.name);
}
@@ -60,8 +56,6 @@ export class ServerInfoRepository implements IServerInfoRepository {
}
async getBuildVersions(): Promise<ServerBuildVersions> {
const { nodeVersion } = this.configRepository.getEnv();
const [nodejsOutput, ffmpegOutput, magickOutput] = await Promise.all([
maybeFirstLine('node --version'),
maybeFirstLine('ffmpeg -version'),
@@ -73,7 +67,7 @@ export class ServerInfoRepository implements IServerInfoRepository {
.catch(() => this.logger.warn(`Failed to read ${resourcePaths.lockFile}`));
return {
nodejs: nodejsOutput || nodeVersion || '',
nodejs: nodejsOutput || process.env.NODE_VERSION || '',
exiftool: await exiftool.version(),
ffmpeg: getLockfileVersion('ffmpeg', lockfile) || ffmpegOutput.replaceAll('ffmpeg version', '') || '',
libvips: getLockfileVersion('libvips', lockfile) || sharp.versions.vips,

View File

@@ -4,18 +4,20 @@ import { IActivityRepository } from 'src/interfaces/activity.interface';
import { ActivityService } from 'src/services/activity.service';
import { activityStub } from 'test/fixtures/activity.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newActivityRepositoryMock } from 'test/repositories/activity.repository.mock';
import { Mocked } from 'vitest';
describe(ActivityService.name, () => {
let sut: ActivityService;
let accessMock: IAccessRepositoryMock;
let activityMock: Mocked<IActivityRepository>;
beforeEach(() => {
({ sut, accessMock, activityMock } = newTestService(ActivityService));
accessMock = newAccessRepositoryMock();
activityMock = newActivityRepositoryMock();
sut = new ActivityService(accessMock, activityMock);
});
it('should work', () => {

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import {
ActivityCreateDto,
ActivityDto,
@@ -13,14 +13,20 @@ import {
import { AuthDto } from 'src/dtos/auth.dto';
import { ActivityEntity } from 'src/entities/activity.entity';
import { Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IActivityRepository } from 'src/interfaces/activity.interface';
import { requireAccess } from 'src/utils/access';
@Injectable()
export class ActivityService extends BaseService {
export class ActivityService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IActivityRepository) private repository: IActivityRepository,
) {}
async getAll(auth: AuthDto, dto: ActivitySearchDto): Promise<ActivityResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] });
const activities = await this.activityRepository.search({
await requireAccess(this.access, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] });
const activities = await this.repository.search({
userId: dto.userId,
albumId: dto.albumId,
assetId: dto.level === ReactionLevel.ALBUM ? null : dto.assetId,
@@ -31,12 +37,12 @@ export class ActivityService extends BaseService {
}
async getStatistics(auth: AuthDto, dto: ActivityDto): Promise<ActivityStatisticsResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] });
return { comments: await this.activityRepository.getStatistics(dto.assetId, dto.albumId) };
await requireAccess(this.access, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] });
return { comments: await this.repository.getStatistics(dto.assetId, dto.albumId) };
}
async create(auth: AuthDto, dto: ActivityCreateDto): Promise<MaybeDuplicate<ActivityResponseDto>> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ACTIVITY_CREATE, ids: [dto.albumId] });
await requireAccess(this.access, { auth, permission: Permission.ACTIVITY_CREATE, ids: [dto.albumId] });
const common = {
userId: auth.user.id,
@@ -49,7 +55,7 @@ export class ActivityService extends BaseService {
if (dto.type === ReactionType.LIKE) {
delete dto.comment;
[activity] = await this.activityRepository.search({
[activity] = await this.repository.search({
...common,
// `null` will search for an album like
assetId: dto.assetId ?? null,
@@ -59,7 +65,7 @@ export class ActivityService extends BaseService {
}
if (!activity) {
activity = await this.activityRepository.create({
activity = await this.repository.create({
...common,
isLiked: dto.type === ReactionType.LIKE,
comment: dto.comment,
@@ -70,7 +76,7 @@ export class ActivityService extends BaseService {
}
async delete(auth: AuthDto, id: string): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ACTIVITY_DELETE, ids: [id] });
await this.activityRepository.delete(id);
await requireAccess(this.access, { auth, permission: Permission.ACTIVITY_DELETE, ids: [id] });
await this.repository.delete(id);
}
}

View File

@@ -4,27 +4,39 @@ import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { AlbumUserRole } from 'src/enum';
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { AlbumService } from 'src/services/album.service';
import { albumStub } from 'test/fixtures/album.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAlbumUserRepositoryMock } from 'test/repositories/album-user.repository.mock';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest';
describe(AlbumService.name, () => {
let sut: AlbumService;
let accessMock: IAccessRepositoryMock;
let albumMock: Mocked<IAlbumRepository>;
let albumUserMock: Mocked<IAlbumUserRepository>;
let assetMock: Mocked<IAssetRepository>;
let eventMock: Mocked<IEventRepository>;
let userMock: Mocked<IUserRepository>;
let albumUserMock: Mocked<IAlbumUserRepository>;
beforeEach(() => {
({ sut, accessMock, albumMock, albumUserMock, eventMock, userMock } = newTestService(AlbumService));
accessMock = newAccessRepositoryMock();
albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock();
eventMock = newEventRepositoryMock();
userMock = newUserRepositoryMock();
albumUserMock = newAlbumUserRepositoryMock();
sut = new AlbumService(accessMock, albumMock, assetMock, eventMock, userMock, albumUserMock);
});
it('should work', () => {

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import {
AddUsersDto,
AlbumInfoDto,
@@ -17,13 +17,26 @@ import { AlbumUserEntity } from 'src/entities/album-user.entity';
import { AlbumEntity } from 'src/entities/album.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/interfaces/album.interface';
import { BaseService } from 'src/services/base.service';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
import { AlbumAssetCount, AlbumInfoOptions, IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { checkAccess, requireAccess } from 'src/utils/access';
import { addAssets, removeAssets } from 'src/utils/asset.util';
@Injectable()
export class AlbumService extends BaseService {
export class AlbumService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IAlbumUserRepository) private albumUserRepository: IAlbumUserRepository,
) {}
async getStatistics(auth: AuthDto): Promise<AlbumStatisticsResponseDto> {
const [owned, shared, notShared] = await Promise.all([
this.albumRepository.getOwned(auth.user.id),
@@ -82,7 +95,7 @@ export class AlbumService extends BaseService {
}
async get(auth: AuthDto, id: string, dto: AlbumInfoDto): Promise<AlbumResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.ALBUM_READ, ids: [id] });
await this.albumRepository.updateThumbnails();
const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets;
const album = await this.findOrFail(id, { withAssets });
@@ -106,7 +119,7 @@ export class AlbumService extends BaseService {
}
}
const allowedAssetIdsSet = await checkAccess(this.accessRepository, {
const allowedAssetIdsSet = await checkAccess(this.access, {
auth,
permission: Permission.ASSET_SHARE,
ids: dto.assetIds || [],
@@ -130,7 +143,7 @@ export class AlbumService extends BaseService {
}
async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_UPDATE, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.ALBUM_UPDATE, ids: [id] });
const album = await this.findOrFail(id, { withAssets: true });
@@ -153,17 +166,17 @@ export class AlbumService extends BaseService {
}
async delete(auth: AuthDto, id: string): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_DELETE, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.ALBUM_DELETE, ids: [id] });
await this.albumRepository.delete(id);
}
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
const album = await this.findOrFail(id, { withAssets: false });
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_ADD_ASSET, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.ALBUM_ADD_ASSET, ids: [id] });
const results = await addAssets(
auth,
{ access: this.accessRepository, bulk: this.albumRepository },
{ access: this.access, bulk: this.albumRepository },
{ parentId: id, assetIds: dto.ids },
);
@@ -182,12 +195,12 @@ export class AlbumService extends BaseService {
}
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_REMOVE_ASSET, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.ALBUM_REMOVE_ASSET, ids: [id] });
const album = await this.findOrFail(id, { withAssets: false });
const results = await removeAssets(
auth,
{ access: this.accessRepository, bulk: this.albumRepository },
{ access: this.access, bulk: this.albumRepository },
{ parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.ALBUM_DELETE },
);
@@ -203,7 +216,7 @@ export class AlbumService extends BaseService {
}
async addUsers(auth: AuthDto, id: string, { albumUsers }: AddUsersDto): Promise<AlbumResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_SHARE, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [id] });
const album = await this.findOrFail(id, { withAssets: false });
@@ -247,14 +260,14 @@ export class AlbumService extends BaseService {
// non-admin can remove themselves
if (auth.user.id !== userId) {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_SHARE, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [id] });
}
await this.albumUserRepository.delete({ albumId: id, userId });
}
async updateUser(auth: AuthDto, id: string, userId: string, dto: Partial<AlbumUserEntity>): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_SHARE, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [id] });
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
}

View File

@@ -5,17 +5,19 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { APIKeyService } from 'src/services/api-key.service';
import { keyStub } from 'test/fixtures/api-key.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { newTestService } from 'test/utils';
import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { Mocked } from 'vitest';
describe(APIKeyService.name, () => {
let sut: APIKeyService;
let cryptoMock: Mocked<ICryptoRepository>;
let keyMock: Mocked<IKeyRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
beforeEach(() => {
({ sut, cryptoMock, keyMock } = newTestService(APIKeyService));
cryptoMock = newCryptoRepositoryMock();
keyMock = newKeyRepositoryMock();
sut = new APIKeyService(cryptoMock, keyMock);
});
describe('create', () => {

View File

@@ -1,21 +1,27 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { APIKeyEntity } from 'src/entities/api-key.entity';
import { BaseService } from 'src/services/base.service';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { isGranted } from 'src/utils/access';
@Injectable()
export class APIKeyService extends BaseService {
export class APIKeyService {
constructor(
@Inject(ICryptoRepository) private crypto: ICryptoRepository,
@Inject(IKeyRepository) private repository: IKeyRepository,
) {}
async create(auth: AuthDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
const secret = this.cryptoRepository.newPassword(32);
const secret = this.crypto.newPassword(32);
if (auth.apiKey && !isGranted({ requested: dto.permissions, current: auth.apiKey.permissions })) {
throw new BadRequestException('Cannot grant permissions you do not have');
}
const entity = await this.keyRepository.create({
key: this.cryptoRepository.hashSha256(secret),
const entity = await this.repository.create({
key: this.crypto.hashSha256(secret),
name: dto.name || 'API Key',
userId: auth.user.id,
permissions: dto.permissions,
@@ -25,27 +31,27 @@ export class APIKeyService extends BaseService {
}
async update(auth: AuthDto, id: string, dto: APIKeyUpdateDto): Promise<APIKeyResponseDto> {
const exists = await this.keyRepository.getById(auth.user.id, id);
const exists = await this.repository.getById(auth.user.id, id);
if (!exists) {
throw new BadRequestException('API Key not found');
}
const key = await this.keyRepository.update(auth.user.id, id, { name: dto.name });
const key = await this.repository.update(auth.user.id, id, { name: dto.name });
return this.map(key);
}
async delete(auth: AuthDto, id: string): Promise<void> {
const exists = await this.keyRepository.getById(auth.user.id, id);
const exists = await this.repository.getById(auth.user.id, id);
if (!exists) {
throw new BadRequestException('API Key not found');
}
await this.keyRepository.delete(auth.user.id, id);
await this.repository.delete(auth.user.id, id);
}
async getById(auth: AuthDto, id: string): Promise<APIKeyResponseDto> {
const key = await this.keyRepository.getById(auth.user.id, id);
const key = await this.repository.getById(auth.user.id, id);
if (!key) {
throw new BadRequestException('API Key not found');
}
@@ -53,7 +59,7 @@ export class APIKeyService extends BaseService {
}
async getAll(auth: AuthDto): Promise<APIKeyResponseDto[]> {
const keys = await this.keyRepository.getByUserId(auth.user.id);
const keys = await this.repository.getByUserId(auth.user.id);
return keys.map((key) => this.map(key));
}

View File

@@ -6,7 +6,9 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, AssetType, CacheControl } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { AssetMediaService } from 'src/services/asset-media.service';
@@ -14,8 +16,13 @@ import { ImmichFileResponse } from 'src/utils/file';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { QueryFailedError } from 'typeorm';
import { Mocked } from 'vitest';
@@ -182,15 +189,24 @@ const copiedAsset = Object.freeze({
describe(AssetMediaService.name, () => {
let sut: AssetMediaService;
let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>;
let jobMock: Mocked<IJobRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let storageMock: Mocked<IStorageRepository>;
let userMock: Mocked<IUserRepository>;
let eventMock: Mocked<IEventRepository>;
beforeEach(() => {
({ sut, accessMock, assetMock, jobMock, storageMock, userMock } = newTestService(AssetMediaService));
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
jobMock = newJobRepositoryMock();
loggerMock = newLoggerRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
eventMock = newEventRepositoryMock();
sut = new AssetMediaService(accessMock, assetMock, jobMock, storageMock, userMock, eventMock, loggerMock);
});
describe('getUploadAssetIdByChecksum', () => {

View File

@@ -1,4 +1,10 @@
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
Inject,
Injectable,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { extname } from 'node:path';
import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core';
@@ -22,8 +28,13 @@ import {
import { AuthDto } from 'src/dtos/auth.dto';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, AssetType, CacheControl, Permission, StorageFolder } from 'src/enum';
import { JobName } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { requireAccess, requireUploadAccess } from 'src/utils/access';
import { getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
import { ImmichFileResponse } from 'src/utils/file';
@@ -45,7 +56,19 @@ export interface UploadFile {
}
@Injectable()
export class AssetMediaService extends BaseService {
export class AssetMediaService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(AssetMediaService.name);
}
async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise<AssetMediaResponseDto | undefined> {
if (!checksum) {
return;
@@ -125,7 +148,7 @@ export class AssetMediaService extends BaseService {
sidecarFile?: UploadFile,
): Promise<AssetMediaResponseDto> {
try {
await requireAccess(this.accessRepository, {
await requireAccess(this.access, {
auth,
permission: Permission.ASSET_UPLOAD,
// do not need an id here, but the interface requires it
@@ -159,7 +182,7 @@ export class AssetMediaService extends BaseService {
sidecarFile?: UploadFile,
): Promise<AssetMediaResponseDto> {
try {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] });
const asset = (await this.assetRepository.getById(id)) as AssetEntity;
this.requireQuota(auth, file.size);
@@ -182,7 +205,7 @@ export class AssetMediaService extends BaseService {
}
async downloadOriginal(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DOWNLOAD, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.ASSET_DOWNLOAD, ids: [id] });
const asset = await this.findOrFail(id);
if (!asset) {
@@ -197,7 +220,7 @@ export class AssetMediaService extends BaseService {
}
async viewThumbnail(auth: AuthDto, id: string, dto: AssetMediaOptionsDto): Promise<ImmichFileResponse> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_VIEW, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.ASSET_VIEW, ids: [id] });
const asset = await this.findOrFail(id);
const size = dto.size ?? AssetMediaSize.THUMBNAIL;
@@ -220,7 +243,7 @@ export class AssetMediaService extends BaseService {
}
async playbackVideo(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_VIEW, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.ASSET_VIEW, ids: [id] });
const asset = await this.findOrFail(id);
if (!asset) {

View File

@@ -4,10 +4,13 @@ import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, AssetType } from 'src/enum';
import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IStackRepository } from 'src/interfaces/stack.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { AssetService } from 'src/services/asset.service';
import { assetStub } from 'test/fixtures/asset.stub';
@@ -15,8 +18,16 @@ import { authStub } from 'test/fixtures/auth.stub';
import { faceStub } from 'test/fixtures/face.stub';
import { partnerStub } from 'test/fixtures/partner.stub';
import { userStub } from 'test/fixtures/user.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
import { newStackRepositoryMock } from 'test/repositories/stack.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked, vitest } from 'vitest';
const stats: AssetStats = {
@@ -34,14 +45,16 @@ const statResponse: AssetStatsResponseDto = {
describe(AssetService.name, () => {
let sut: AssetService;
let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>;
let eventMock: Mocked<IEventRepository>;
let configMock: Mocked<IConfigRepository>;
let jobMock: Mocked<IJobRepository>;
let partnerMock: Mocked<IPartnerRepository>;
let stackMock: Mocked<IStackRepository>;
let userMock: Mocked<IUserRepository>;
let eventMock: Mocked<IEventRepository>;
let stackMock: Mocked<IStackRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let partnerMock: Mocked<IPartnerRepository>;
let loggerMock: Mocked<ILoggerRepository>;
it('should work', () => {
expect(sut).toBeDefined();
@@ -54,8 +67,29 @@ describe(AssetService.name, () => {
};
beforeEach(() => {
({ sut, accessMock, assetMock, eventMock, jobMock, userMock, partnerMock, stackMock } =
newTestService(AssetService));
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
userMock = newUserRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
partnerMock = newPartnerRepositoryMock();
stackMock = newStackRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new AssetService(
accessMock,
assetMock,
configMock,
jobMock,
systemMock,
userMock,
eventMock,
partnerMock,
stackMock,
loggerMock,
);
mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]);
});

View File

@@ -1,4 +1,4 @@
import { BadRequestException } from '@nestjs/common';
import { BadRequestException, Inject } from '@nestjs/common';
import _ from 'lodash';
import { DateTime, Duration } from 'luxon';
import {
@@ -20,20 +20,46 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { MemoryLaneDto } from 'src/dtos/search.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import {
IAssetDeleteJob,
IJobRepository,
ISidecarWriteJob,
JOBS_ASSET_PAGINATION_SIZE,
JobItem,
JobName,
JobStatus,
} from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IStackRepository } from 'src/interfaces/stack.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { BaseService } from 'src/services/base.service';
import { requireAccess } from 'src/utils/access';
import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util';
import { usePagination } from 'src/utils/pagination';
export class AssetService extends BaseService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
@Inject(IStackRepository) private stackRepository: IStackRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(AssetService.name);
}
async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
const partnerIds = await getMyPartnerIds({
userId: auth.user.id,
@@ -86,7 +112,7 @@ export class AssetService extends BaseService {
}
async get(auth: AuthDto, id: string): Promise<AssetResponseDto | SanitizedAssetResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_READ, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.ASSET_READ, ids: [id] });
const asset = await this.assetRepository.getById(
id,
@@ -135,7 +161,7 @@ export class AssetService extends BaseService {
}
async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: [id] });
const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto;
const repos = { asset: this.assetRepository, event: this.eventRepository };
@@ -178,7 +204,7 @@ export class AssetService extends BaseService {
async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> {
const { ids, dateTimeOriginal, latitude, longitude, ...options } = dto;
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids });
await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids });
for (const id of ids) {
await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude });
@@ -275,7 +301,7 @@ export class AssetService extends BaseService {
async deleteAll(auth: AuthDto, dto: AssetBulkDeleteDto): Promise<void> {
const { ids, force } = dto;
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DELETE, ids });
await requireAccess(this.access, { auth, permission: Permission.ASSET_DELETE, ids });
await this.assetRepository.updateAll(ids, {
deletedAt: new Date(),
status: force ? AssetStatus.DELETED : AssetStatus.TRASHED,
@@ -284,7 +310,7 @@ export class AssetService extends BaseService {
}
async run(auth: AuthDto, dto: AssetJobsDto) {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds });
await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds });
const jobs: JobItem[] = [];

View File

@@ -1,18 +1,46 @@
import { DatabaseAction, EntityType } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { AuditService } from 'src/services/audit.service';
import { auditStub } from 'test/fixtures/audit.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { newTestService } from 'test/utils';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newAuditRepositoryMock } from 'test/repositories/audit.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest';
describe(AuditService.name, () => {
let sut: AuditService;
let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>;
let auditMock: Mocked<IAuditRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let personMock: Mocked<IPersonRepository>;
let storageMock: Mocked<IStorageRepository>;
let userMock: Mocked<IUserRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
({ sut, auditMock } = newTestService(AuditService));
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
auditMock = newAuditRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new AuditService(accessMock, assetMock, cryptoMock, personMock, auditMock, storageMock, userMock, loggerMock);
});
it('should work', () => {

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { resolve } from 'node:path';
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
@@ -21,24 +21,44 @@ import {
StorageFolder,
UserPathType,
} from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { requireAccess } from 'src/utils/access';
import { getAssetFiles } from 'src/utils/asset.util';
import { usePagination } from 'src/utils/pagination';
@Injectable()
export class AuditService extends BaseService {
export class AuditService {
constructor(
@Inject(IAccessRepository) private access: 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,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(AuditService.name);
}
async handleCleanup(): Promise<JobStatus> {
await this.auditRepository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate());
await this.repository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate());
return JobStatus.SUCCESS;
}
async getDeletes(auth: AuthDto, dto: AuditDeletesDto): Promise<AuditDeletesResponseDto> {
const userId = dto.userId || auth.user.id;
await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_READ, ids: [userId] });
await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: [userId] });
const audits = await this.auditRepository.getAfter(dto.after, {
const audits = await this.repository.getAfter(dto.after, {
userIds: [userId],
entityType: dto.entityType,
action: DatabaseAction.DELETE,

View File

@@ -5,8 +5,10 @@ import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import { AuthType } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@@ -18,7 +20,15 @@ import { sessionStub } from 'test/fixtures/session.stub';
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newTestService } from 'test/utils';
import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock';
import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mock, Mocked, vitest } from 'vitest';
// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
@@ -49,14 +59,15 @@ const oauthUserWithDefaultQuota = {
describe('AuthService', () => {
let sut: AuthService;
let configMock: Mocked<IConfigRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let eventMock: Mocked<IEventRepository>;
let keyMock: Mocked<IKeyRepository>;
let sessionMock: Mocked<ISessionRepository>;
let sharedLinkMock: Mocked<ISharedLinkRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let userMock: Mocked<IUserRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let sessionMock: Mocked<ISessionRepository>;
let shareMock: Mocked<ISharedLinkRepository>;
let keyMock: Mocked<IKeyRepository>;
let callbackMock: Mock;
let userinfoMock: Mock;
@@ -81,8 +92,27 @@ describe('AuthService', () => {
}),
} as any);
({ sut, cryptoMock, eventMock, keyMock, sessionMock, sharedLinkMock, systemMock, userMock } =
newTestService(AuthService));
configMock = newConfigRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
eventMock = newEventRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
sessionMock = newSessionRepositoryMock();
shareMock = newSharedLinkRepositoryMock();
keyMock = newKeyRepositoryMock();
sut = new AuthService(
configMock,
cryptoMock,
eventMock,
systemMock,
loggerMock,
userMock,
sessionMock,
shareMock,
keyMock,
);
});
it('should be defined', () => {
@@ -267,7 +297,7 @@ describe('AuthService', () => {
describe('validate - shared key', () => {
it('should not accept a non-existent key', async () => {
sharedLinkMock.getByKey.mockResolvedValue(null);
shareMock.getByKey.mockResolvedValue(null);
await expect(
sut.authenticate({
headers: { 'x-immich-share-key': 'key' },
@@ -278,7 +308,7 @@ describe('AuthService', () => {
});
it('should not accept an expired key', async () => {
sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
await expect(
sut.authenticate({
headers: { 'x-immich-share-key': 'key' },
@@ -289,7 +319,7 @@ describe('AuthService', () => {
});
it('should not accept a key without a user', async () => {
sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
userMock.get.mockResolvedValue(null);
await expect(
sut.authenticate({
@@ -301,7 +331,7 @@ describe('AuthService', () => {
});
it('should accept a base64url key', async () => {
sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
userMock.get.mockResolvedValue(userStub.admin);
await expect(
sut.authenticate({
@@ -313,11 +343,11 @@ describe('AuthService', () => {
user: userStub.admin,
sharedLink: sharedLinkStub.valid,
});
expect(sharedLinkMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
expect(shareMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
});
it('should accept a hex key', async () => {
sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
userMock.get.mockResolvedValue(userStub.admin);
await expect(
sut.authenticate({
@@ -329,7 +359,7 @@ describe('AuthService', () => {
user: userStub.admin,
sharedLink: sharedLinkStub.valid,
});
expect(sharedLinkMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
expect(shareMock.getByKey).toHaveBeenCalledWith(sharedLinkStub.valid.key);
});
});

View File

@@ -1,6 +1,7 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
InternalServerErrorException,
UnauthorizedException,
@@ -12,7 +13,6 @@ import { IncomingHttpHeaders } from 'node:http';
import { Issuer, UserinfoResponse, custom, generators } from 'openid-client';
import { SystemConfig } from 'src/config';
import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
import { OnEvent } from 'src/decorators';
import {
AuthDto,
ChangePasswordDto,
@@ -30,6 +30,15 @@ import {
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { UserEntity } from 'src/entities/user.entity';
import { AuthType, Permission } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { BaseService } from 'src/services/base.service';
import { isGranted } from 'src/utils/access';
import { HumanReadableSize } from 'src/utils/bytes';
@@ -63,8 +72,20 @@ export type ValidateRequest = {
@Injectable()
export class AuthService extends BaseService {
@OnEvent({ name: 'app.bootstrap' })
onBootstrap() {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ISessionRepository) private sessionRepository: ISessionRepository,
@Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
@Inject(IKeyRepository) private keyRepository: IKeyRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(AuthService.name);
custom.setHttpOptionsDefaults({ timeout: 30_000 });
}

View File

@@ -1,97 +1,16 @@
import { Inject } from '@nestjs/common';
import { SystemConfig } from 'src/config';
import { StorageCore } from 'src/cores/storage.core';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IActivityRepository } from 'src/interfaces/activity.interface';
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository } from 'src/interfaces/job.interface';
import { ILibraryRepository } from 'src/interfaces/library.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IMapRepository } from 'src/interfaces/map.interface';
import { IMediaRepository } from 'src/interfaces/media.interface';
import { IMemoryRepository } from 'src/interfaces/memory.interface';
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
import { IMetricRepository } from 'src/interfaces/metric.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { INotificationRepository } from 'src/interfaces/notification.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { IStackRepository } from 'src/interfaces/stack.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { ITrashRepository } from 'src/interfaces/trash.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
import { IViewRepository } from 'src/interfaces/view.interface';
import { getConfig, updateConfig } from 'src/utils/config';
export class BaseService {
protected storageCore: StorageCore;
constructor(
@Inject(ILoggerRepository) protected logger: ILoggerRepository,
@Inject(IAccessRepository) protected accessRepository: IAccessRepository,
@Inject(IActivityRepository) protected activityRepository: IActivityRepository,
@Inject(IAuditRepository) protected auditRepository: IAuditRepository,
@Inject(IAlbumRepository) protected albumRepository: IAlbumRepository,
@Inject(IAlbumUserRepository) protected albumUserRepository: IAlbumUserRepository,
@Inject(IAssetRepository) protected assetRepository: IAssetRepository,
@Inject(IConfigRepository) protected configRepository: IConfigRepository,
@Inject(ICryptoRepository) protected cryptoRepository: ICryptoRepository,
@Inject(IDatabaseRepository) protected databaseRepository: IDatabaseRepository,
@Inject(IEventRepository) protected eventRepository: IEventRepository,
@Inject(IJobRepository) protected jobRepository: IJobRepository,
@Inject(IKeyRepository) protected keyRepository: IKeyRepository,
@Inject(ILibraryRepository) protected libraryRepository: ILibraryRepository,
@Inject(IMachineLearningRepository) protected machineLearningRepository: IMachineLearningRepository,
@Inject(IMapRepository) protected mapRepository: IMapRepository,
@Inject(IMediaRepository) protected mediaRepository: IMediaRepository,
@Inject(IMemoryRepository) protected memoryRepository: IMemoryRepository,
@Inject(IMetadataRepository) protected metadataRepository: IMetadataRepository,
@Inject(IMetricRepository) protected metricRepository: IMetricRepository,
@Inject(IMoveRepository) protected moveRepository: IMoveRepository,
@Inject(INotificationRepository) protected notificationRepository: INotificationRepository,
@Inject(IPartnerRepository) protected partnerRepository: IPartnerRepository,
@Inject(IPersonRepository) protected personRepository: IPersonRepository,
@Inject(ISearchRepository) protected searchRepository: ISearchRepository,
@Inject(IServerInfoRepository) protected serverInfoRepository: IServerInfoRepository,
@Inject(ISessionRepository) protected sessionRepository: ISessionRepository,
@Inject(ISharedLinkRepository) protected sharedLinkRepository: ISharedLinkRepository,
@Inject(IStackRepository) protected stackRepository: IStackRepository,
@Inject(IStorageRepository) protected storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) protected systemMetadataRepository: ISystemMetadataRepository,
@Inject(ITagRepository) protected tagRepository: ITagRepository,
@Inject(ITrashRepository) protected trashRepository: ITrashRepository,
@Inject(IUserRepository) protected userRepository: IUserRepository,
@Inject(IVersionHistoryRepository) protected versionRepository: IVersionHistoryRepository,
@Inject(IViewRepository) protected viewRepository: IViewRepository,
) {
this.logger.setContext(this.constructor.name);
this.storageCore = StorageCore.create(
assetRepository,
configRepository,
cryptoRepository,
moveRepository,
personRepository,
storageRepository,
systemMetadataRepository,
this.logger,
);
}
@Inject(ILoggerRepository) protected logger: ILoggerRepository,
) {}
private get repos() {
return {

View File

@@ -1,16 +1,34 @@
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { CliService } from 'src/services/cli.service';
import { userStub } from 'test/fixtures/user.stub';
import { newTestService } from 'test/utils';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked, describe, it } from 'vitest';
describe(CliService.name, () => {
let sut: CliService;
let configMock: Mocked<IConfigRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let userMock: Mocked<IUserRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
({ sut, userMock } = newTestService(CliService));
configMock = newConfigRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new CliService(configMock, cryptoMock, systemMock, userMock, loggerMock);
});
describe('resetAdminPassword', () => {

View File

@@ -1,10 +1,26 @@
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { SALT_ROUNDS } from 'src/constants';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { BaseService } from 'src/services/base.service';
@Injectable()
export class CliService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(CliService.name);
}
async listUsers(): Promise<UserAdminResponseDto[]> {
const users = await this.userRepository.getList({ withDeleted: true });
return users.map((user) => mapUserAdmin(user));

View File

@@ -7,13 +7,13 @@ import {
} from 'src/interfaces/database.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { DatabaseService } from 'src/services/database.service';
import { mockEnvData } from 'test/repositories/config.repository.mock';
import { newTestService } from 'test/utils';
import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { Mocked } from 'vitest';
describe(DatabaseService.name, () => {
let sut: DatabaseService;
let configMock: Mocked<IConfigRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let loggerMock: Mocked<ILoggerRepository>;
@@ -24,7 +24,11 @@ describe(DatabaseService.name, () => {
let versionAboveRange: string;
beforeEach(() => {
({ sut, configMock, databaseMock, loggerMock } = newTestService(DatabaseService));
configMock = newConfigRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new DatabaseService(configMock, databaseMock, loggerMock);
extensionRange = '0.2.x';
databaseMock.getExtensionVersionRange.mockReturnValue(extensionRange);

View File

@@ -1,15 +1,17 @@
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { Duration } from 'luxon';
import semver from 'semver';
import { OnEvent } from 'src/decorators';
import { IConfigRepository } from 'src/interfaces/config.interface';
import {
DatabaseExtension,
DatabaseLock,
EXTENSION_NAMES,
IDatabaseRepository,
VectorExtension,
VectorIndex,
} from 'src/interfaces/database.interface';
import { BaseService } from 'src/services/base.service';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
type CreateFailedArgs = { name: string; extension: string; otherName: string };
type UpdateFailedArgs = { name: string; extension: string; availableVersion: string };
@@ -61,9 +63,17 @@ const messages = {
const RETRY_DURATION = Duration.fromObject({ seconds: 5 });
@Injectable()
export class DatabaseService extends BaseService {
export class DatabaseService {
private reconnection?: NodeJS.Timeout;
constructor(
@Inject(IConfigRepository) private configRepository: IConfigRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(DatabaseService.name);
}
@OnEvent({ name: 'app.bootstrap', priority: -200 })
async onBootstrap() {
const version = await this.databaseRepository.getPostgresVersion();

View File

@@ -2,12 +2,15 @@ import { BadRequestException } from '@nestjs/common';
import { DownloadResponseDto } from 'src/dtos/download.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { DownloadService } from 'src/services/download.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { Readable } from 'typeorm/platform/PlatformTools.js';
import { Mocked, vitest } from 'vitest';
@@ -25,6 +28,7 @@ describe(DownloadService.name, () => {
let sut: DownloadService;
let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let storageMock: Mocked<IStorageRepository>;
it('should work', () => {
@@ -32,7 +36,12 @@ describe(DownloadService.name, () => {
});
beforeEach(() => {
({ sut, accessMock, assetMock, storageMock } = newTestService(DownloadService));
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
loggerMock = newLoggerRepositoryMock();
storageMock = newStorageRepositoryMock();
sut = new DownloadService(accessMock, assetMock, loggerMock, storageMock);
});
describe('downloadArchive', () => {

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { parse } from 'node:path';
import { StorageCore } from 'src/cores/storage.core';
import { AssetIdsDto } from 'src/dtos/asset.dto';
@@ -6,15 +6,26 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { Permission } from 'src/enum';
import { ImmichReadStream } from 'src/interfaces/storage.interface';
import { BaseService } from 'src/services/base.service';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ImmichReadStream, IStorageRepository } from 'src/interfaces/storage.interface';
import { requireAccess } from 'src/utils/access';
import { HumanReadableSize } from 'src/utils/bytes';
import { usePagination } from 'src/utils/pagination';
import { getPreferences } from 'src/utils/preferences';
@Injectable()
export class DownloadService extends BaseService {
export class DownloadService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {
this.logger.setContext(DownloadService.name);
}
async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise<DownloadResponseDto> {
const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4;
const archives: DownloadArchiveInfo[] = [];
@@ -62,7 +73,7 @@ export class DownloadService extends BaseService {
}
async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise<ImmichReadStream> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DOWNLOAD, ids: dto.assetIds });
await requireAccess(this.access, { auth, permission: Permission.ASSET_DOWNLOAD, ids: dto.assetIds });
const zip = this.storageRepository.createZipStream();
const assets = await this.assetRepository.getByIds(dto.assetIds);
@@ -105,20 +116,20 @@ export class DownloadService extends BaseService {
if (dto.assetIds) {
const assetIds = dto.assetIds;
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DOWNLOAD, ids: assetIds });
await requireAccess(this.access, { auth, permission: Permission.ASSET_DOWNLOAD, ids: assetIds });
const assets = await this.assetRepository.getByIds(assetIds, { exifInfo: true });
return usePagination(PAGINATION_SIZE, () => ({ hasNextPage: false, items: assets }));
}
if (dto.albumId) {
const albumId = dto.albumId;
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_DOWNLOAD, ids: [albumId] });
await requireAccess(this.access, { auth, permission: Permission.ALBUM_DOWNLOAD, ids: [albumId] });
return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByAlbumId(pagination, albumId));
}
if (dto.userId) {
const userId = dto.userId;
await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_DOWNLOAD, ids: [userId] });
await requireAccess(this.access, { auth, permission: Permission.TIMELINE_DOWNLOAD, ids: [userId] });
return usePagination(PAGINATION_SIZE, (pagination) =>
this.assetRepository.getByUserId(pagination, userId, { isVisible: true }),
);

View File

@@ -1,4 +1,6 @@
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
@@ -6,22 +8,37 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf
import { DuplicateService } from 'src/services/duplicate.service';
import { SearchService } from 'src/services/search.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { newTestService } from 'test/utils';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked, beforeEach, vitest } from 'vitest';
vitest.useFakeTimers();
describe(SearchService.name, () => {
let sut: DuplicateService;
let assetMock: Mocked<IAssetRepository>;
let jobMock: Mocked<IJobRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let searchMock: Mocked<ISearchRepository>;
let configMock: Mocked<IConfigRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let searchMock: Mocked<ISearchRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let jobMock: Mocked<IJobRepository>;
beforeEach(() => {
({ sut, assetMock, jobMock, loggerMock, searchMock, systemMock } = newTestService(DuplicateService));
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
searchMock = newSearchRepositoryMock();
loggerMock = newLoggerRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
sut = new DuplicateService(configMock, systemMock, searchMock, assetMock, loggerMock, cryptoMock, jobMock);
});
it('should work', () => {

View File

@@ -1,11 +1,22 @@
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { DuplicateResponseDto, mapDuplicateResponse } from 'src/dtos/duplicate.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { WithoutProperty } from 'src/interfaces/asset.interface';
import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface';
import { AssetDuplicateResult } from 'src/interfaces/search.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import {
IBaseJob,
IEntityJob,
IJobRepository,
JOBS_ASSET_PAGINATION_SIZE,
JobName,
JobStatus,
} from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AssetDuplicateResult, ISearchRepository } from 'src/interfaces/search.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BaseService } from 'src/services/base.service';
import { getAssetFiles } from 'src/utils/asset.util';
import { isDuplicateDetectionEnabled } from 'src/utils/misc';
@@ -13,6 +24,19 @@ import { usePagination } from 'src/utils/pagination';
@Injectable()
export class DuplicateService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(DuplicateService.name);
}
async getDuplicates(auth: AuthDto): Promise<DuplicateResponseDto[]> {
const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] });

View File

@@ -1,6 +1,8 @@
import { BadRequestException } from '@nestjs/common';
import { defaults } from 'src/config';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import {
IJobRepository,
JobCommand,
@@ -10,10 +12,20 @@ import {
JobStatus,
QueueName,
} from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMetricRepository } from 'src/interfaces/metric.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { JobService } from 'src/services/job.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { newTestService } from 'test/utils';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newMetricRepositoryMock } from 'test/repositories/metric.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked, vitest } from 'vitest';
const makeMockHandlers = (status: JobStatus) => {
@@ -27,11 +39,24 @@ const makeMockHandlers = (status: JobStatus) => {
describe(JobService.name, () => {
let sut: JobService;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>;
let personMock: Mocked<IPersonRepository>;
let metricMock: Mocked<IMetricRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
({ sut, assetMock, jobMock, systemMock } = newTestService(JobService));
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
personMock = newPersonRepositoryMock();
metricMock = newMetricRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new JobService(assetMock, configMock, eventMock, jobMock, systemMock, personMock, metricMock, loggerMock);
});
it('should work', () => {

View File

@@ -1,12 +1,15 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { snakeCase } from 'lodash';
import { OnEvent } from 'src/decorators';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto';
import { AssetType, ManualJobName } from 'src/enum';
import { ArgOf } from 'src/interfaces/event.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
import {
ConcurrentQueueName,
IJobRepository,
JobCommand,
JobHandler,
JobItem,
@@ -15,6 +18,10 @@ import {
QueueCleanType,
QueueName,
} from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMetricRepository } from 'src/interfaces/metric.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BaseService } from 'src/services/base.service';
const asJobItem = (dto: JobCreateDto): JobItem => {
@@ -41,6 +48,20 @@ const asJobItem = (dto: JobCreateDto): JobItem => {
export class JobService extends BaseService {
private isMicroservices = false;
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IPersonRepository) private personRepository: IPersonRepository,
@Inject(IMetricRepository) private metricRepository: IMetricRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(JobService.name);
}
@OnEvent({ name: 'app.bootstrap' })
onBootstrap(app: ArgOf<'app.bootstrap'>) {
this.isMicroservices = app === 'microservices';

View File

@@ -5,6 +5,8 @@ import { mapLibrary } from 'src/dtos/library.dto';
import { UserEntity } from 'src/entities/user.entity';
import { AssetType } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import {
IJobRepository,
@@ -15,6 +17,7 @@ import {
JobStatus,
} from 'src/interfaces/job.interface';
import { ILibraryRepository } from 'src/interfaces/library.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { LibraryService } from 'src/services/library.service';
@@ -23,8 +26,15 @@ import { authStub } from 'test/fixtures/auth.stub';
import { libraryStub } from 'test/fixtures/library.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub';
import { makeMockWatcher } from 'test/repositories/storage.repository.mock';
import { newTestService } from 'test/utils';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { makeMockWatcher, newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked, vitest } from 'vitest';
async function* mockWalk() {
@@ -35,14 +45,37 @@ describe(LibraryService.name, () => {
let sut: LibraryService;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let jobMock: Mocked<IJobRepository>;
let libraryMock: Mocked<ILibraryRepository>;
let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
({ sut, assetMock, databaseMock, jobMock, libraryMock, storageMock, systemMock } = newTestService(LibraryService));
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
libraryMock = newLibraryRepositoryMock();
assetMock = newAssetRepositoryMock();
jobMock = newJobRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
storageMock = newStorageRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new LibraryService(
assetMock,
configMock,
cryptoMock,
databaseMock,
jobMock,
libraryMock,
storageMock,
systemMock,
loggerMock,
);
databaseMock.tryLock.mockResolvedValue(true);
});

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { R_OK } from 'node:constants';
import path, { basename, parse } from 'node:path';
import picomatch from 'picomatch';
@@ -17,16 +17,24 @@ import {
import { AssetEntity } from 'src/entities/asset.entity';
import { LibraryEntity } from 'src/entities/library.entity';
import { AssetType } from 'src/enum';
import { DatabaseLock } from 'src/interfaces/database.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface';
import {
IEntityJob,
IJobRepository,
ILibraryAssetJob,
ILibraryFileJob,
JobName,
JOBS_LIBRARY_PAGINATION_SIZE,
JobStatus,
} from 'src/interfaces/job.interface';
import { ILibraryRepository } from 'src/interfaces/library.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BaseService } from 'src/services/base.service';
import { mimeTypes } from 'src/utils/mime-types';
import { handlePromiseError } from 'src/utils/misc';
@@ -39,6 +47,21 @@ export class LibraryService extends BaseService {
private watchLock = false;
private watchers: Record<string, () => Promise<void>> = {};
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ILibraryRepository) private repository: ILibraryRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(LibraryService.name);
}
@OnEvent({ name: 'app.bootstrap' })
async onBootstrap() {
const config = await this.getConfig({ withCache: false });
@@ -194,14 +217,14 @@ export class LibraryService extends BaseService {
return false;
}
const libraries = await this.libraryRepository.getAll(false);
const libraries = await this.repository.getAll(false);
for (const library of libraries) {
await this.watch(library.id);
}
}
async getStatistics(id: string): Promise<LibraryStatsResponseDto> {
const statistics = await this.libraryRepository.getStatistics(id);
const statistics = await this.repository.getStatistics(id);
if (!statistics) {
throw new BadRequestException(`Library ${id} not found`);
}
@@ -214,13 +237,13 @@ export class LibraryService extends BaseService {
}
async getAll(): Promise<LibraryResponseDto[]> {
const libraries = await this.libraryRepository.getAll(false);
const libraries = await this.repository.getAll(false);
return libraries.map((library) => mapLibrary(library));
}
async handleQueueCleanup(): Promise<JobStatus> {
this.logger.debug('Cleaning up any pending library deletions');
const pendingDeletion = await this.libraryRepository.getAllDeleted();
const pendingDeletion = await this.repository.getAllDeleted();
await this.jobRepository.queueAll(
pendingDeletion.map((libraryToDelete) => ({ name: JobName.LIBRARY_DELETE, data: { id: libraryToDelete.id } })),
);
@@ -228,7 +251,7 @@ export class LibraryService extends BaseService {
}
async create(dto: CreateLibraryDto): Promise<LibraryResponseDto> {
const library = await this.libraryRepository.create({
const library = await this.repository.create({
ownerId: dto.ownerId,
name: dto.name ?? 'New External Library',
importPaths: dto.importPaths ?? [],
@@ -303,7 +326,7 @@ export class LibraryService extends BaseService {
async update(id: string, dto: UpdateLibraryDto): Promise<LibraryResponseDto> {
await this.findOrFail(id);
const library = await this.libraryRepository.update({ id, ...dto });
const library = await this.repository.update({ id, ...dto });
if (dto.importPaths) {
const validation = await this.validate(id, { importPaths: dto.importPaths });
@@ -326,7 +349,7 @@ export class LibraryService extends BaseService {
await this.unwatch(id);
}
await this.libraryRepository.softDelete(id);
await this.repository.softDelete(id);
await this.jobRepository.queue({ name: JobName.LIBRARY_DELETE, data: { id } });
}
@@ -356,7 +379,7 @@ export class LibraryService extends BaseService {
if (!assetsFound) {
this.logger.log(`Deleting library ${libraryId}`);
await this.libraryRepository.delete(libraryId);
await this.repository.delete(libraryId);
}
return JobStatus.SUCCESS;
}
@@ -384,7 +407,7 @@ export class LibraryService extends BaseService {
this.logger.log(`Importing new library asset: ${assetPath}`);
const library = await this.libraryRepository.get(job.id, true);
const library = await this.repository.get(job.id, true);
if (!library || library.deletedAt) {
this.logger.error('Cannot import asset into deleted library');
return JobStatus.FAILED;
@@ -454,7 +477,7 @@ export class LibraryService extends BaseService {
await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_CLEANUP, data: {} });
const libraries = await this.libraryRepository.getAll(true);
const libraries = await this.repository.getAll(true);
await this.jobRepository.queueAll(
libraries.map((library) => ({
name: JobName.LIBRARY_QUEUE_SYNC_FILES,
@@ -530,7 +553,7 @@ export class LibraryService extends BaseService {
}
async handleQueueSyncFiles(job: IEntityJob): Promise<JobStatus> {
const library = await this.libraryRepository.get(job.id);
const library = await this.repository.get(job.id);
if (!library) {
this.logger.debug(`Library ${job.id} not found, skipping refresh`);
return JobStatus.SKIPPED;
@@ -575,13 +598,13 @@ export class LibraryService extends BaseService {
this.logger.warn(`No valid import paths found for library ${library.id}`);
}
await this.libraryRepository.update({ id: job.id, refreshedAt: new Date() });
await this.repository.update({ id: job.id, refreshedAt: new Date() });
return JobStatus.SUCCESS;
}
async handleQueueSyncAssets(job: IEntityJob): Promise<JobStatus> {
const library = await this.libraryRepository.get(job.id);
const library = await this.repository.get(job.id);
if (!library) {
return JobStatus.SKIPPED;
}
@@ -613,7 +636,7 @@ export class LibraryService extends BaseService {
}
private async findOrFail(id: string) {
const library = await this.libraryRepository.get(id);
const library = await this.repository.get(id);
if (!library) {
throw new BadRequestException('Library not found');
}

View File

@@ -1,19 +1,26 @@
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IMapRepository } from 'src/interfaces/map.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { MapService } from 'src/services/map.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { newTestService } from 'test/utils';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newMapRepositoryMock } from 'test/repositories/map.repository.mock';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
import { Mocked } from 'vitest';
describe(MapService.name, () => {
let sut: MapService;
let mapMock: Mocked<IMapRepository>;
let albumMock: Mocked<IAlbumRepository>;
let partnerMock: Mocked<IPartnerRepository>;
let mapMock: Mocked<IMapRepository>;
beforeEach(() => {
({ sut, mapMock, partnerMock } = newTestService(MapService));
albumMock = newAlbumRepositoryMock();
partnerMock = newPartnerRepositoryMock();
mapMock = newMapRepositoryMock();
sut = new MapService(albumMock, partnerMock, mapMock);
});
describe('getMapMarkers', () => {

View File

@@ -1,9 +1,18 @@
import { Inject } from '@nestjs/common';
import { AuthDto } from 'src/dtos/auth.dto';
import { MapMarkerDto, MapMarkerResponseDto, MapReverseGeocodeDto } from 'src/dtos/map.dto';
import { BaseService } from 'src/services/base.service';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IMapRepository } from 'src/interfaces/map.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { getMyPartnerIds } from 'src/utils/asset.util';
export class MapService extends BaseService {
export class MapService {
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
@Inject(IMapRepository) private mapRepository: IMapRepository,
) {}
async getMapMarkers(auth: AuthDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
const userIds = [auth.user.id];
if (options.withPartners) {

View File

@@ -12,9 +12,12 @@ import {
VideoCodec,
} from 'src/enum';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMediaRepository, RawImageInfo } from 'src/interfaces/media.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@@ -23,23 +26,55 @@ import { assetStub } from 'test/fixtures/asset.stub';
import { faceStub } from 'test/fixtures/face.stub';
import { probeStub } from 'test/fixtures/media.stub';
import { personStub } from 'test/fixtures/person.stub';
import { newTestService } from 'test/utils';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock';
import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked } from 'vitest';
describe(MediaService.name, () => {
let sut: MediaService;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let jobMock: Mocked<IJobRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let mediaMock: Mocked<IMediaRepository>;
let moveMock: Mocked<IMoveRepository>;
let personMock: Mocked<IPersonRepository>;
let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
({ sut, assetMock, jobMock, loggerMock, mediaMock, personMock, storageMock, systemMock } =
newTestService(MediaService));
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
jobMock = newJobRepositoryMock();
mediaMock = newMediaRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new MediaService(
assetMock,
configMock,
personMock,
jobMock,
mediaMock,
storageMock,
systemMock,
moveMock,
cryptoMock,
loggerMock,
);
});
it('should be defined', () => {

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { dirname } from 'node:path';
import { StorageCore } from 'src/cores/storage.core';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
@@ -17,17 +17,31 @@ import {
VideoCodec,
VideoContainer,
} from 'src/enum';
import { UpsertFileOptions, WithoutProperty } from 'src/interfaces/asset.interface';
import { IAssetRepository, UpsertFileOptions, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import {
IBaseJob,
IEntityJob,
IJobRepository,
JOBS_ASSET_PAGINATION_SIZE,
JobItem,
JobName,
JobStatus,
QueueName,
} from 'src/interfaces/job.interface';
import { AudioStreamInfo, TranscodeCommand, VideoFormat, VideoStreamInfo } from 'src/interfaces/media.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import {
AudioStreamInfo,
IMediaRepository,
TranscodeCommand,
VideoFormat,
VideoStreamInfo,
} from 'src/interfaces/media.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BaseService } from 'src/services/base.service';
import { getAssetFiles } from 'src/utils/asset.util';
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
@@ -36,9 +50,36 @@ import { usePagination } from 'src/utils/pagination';
@Injectable()
export class MediaService extends BaseService {
private storageCore: StorageCore;
private maliOpenCL?: boolean;
private devices?: string[];
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IPersonRepository) private personRepository: IPersonRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(MediaService.name);
this.storageCore = StorageCore.create(
assetRepository,
configRepository,
cryptoRepository,
moveRepository,
personRepository,
storageRepository,
systemMetadataRepository,
this.logger,
);
}
async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise<JobStatus> {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force

View File

@@ -5,18 +5,20 @@ import { MemoryService } from 'src/services/memory.service';
import { authStub } from 'test/fixtures/auth.stub';
import { memoryStub } from 'test/fixtures/memory.stub';
import { userStub } from 'test/fixtures/user.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newMemoryRepositoryMock } from 'test/repositories/memory.repository.mock';
import { Mocked } from 'vitest';
describe(MemoryService.name, () => {
let sut: MemoryService;
let accessMock: IAccessRepositoryMock;
let memoryMock: Mocked<IMemoryRepository>;
let sut: MemoryService;
beforeEach(() => {
({ sut, accessMock, memoryMock } = newTestService(MemoryService));
accessMock = newAccessRepositoryMock();
memoryMock = newMemoryRepositoryMock();
sut = new MemoryService(accessMock, memoryMock);
});
it('should be defined', () => {

View File

@@ -1,22 +1,28 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IMemoryRepository } from 'src/interfaces/memory.interface';
import { checkAccess, requireAccess } from 'src/utils/access';
import { addAssets, removeAssets } from 'src/utils/asset.util';
@Injectable()
export class MemoryService extends BaseService {
export class MemoryService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IMemoryRepository) private repository: IMemoryRepository,
) {}
async search(auth: AuthDto) {
const memories = await this.memoryRepository.search(auth.user.id);
const memories = await this.repository.search(auth.user.id);
return memories.map((memory) => mapMemory(memory));
}
async get(auth: AuthDto, id: string): Promise<MemoryResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_READ, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.MEMORY_READ, ids: [id] });
const memory = await this.findOrFail(id);
return mapMemory(memory);
}
@@ -25,12 +31,12 @@ export class MemoryService extends BaseService {
// TODO validate type/data combination
const assetIds = dto.assetIds || [];
const allowedAssetIds = await checkAccess(this.accessRepository, {
const allowedAssetIds = await checkAccess(this.access, {
auth,
permission: Permission.ASSET_SHARE,
ids: assetIds,
});
const memory = await this.memoryRepository.create({
const memory = await this.repository.create({
ownerId: auth.user.id,
type: dto.type,
data: dto.data,
@@ -44,9 +50,9 @@ export class MemoryService extends BaseService {
}
async update(auth: AuthDto, id: string, dto: MemoryUpdateDto): Promise<MemoryResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_UPDATE, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.MEMORY_UPDATE, ids: [id] });
const memory = await this.memoryRepository.update({
const memory = await this.repository.update({
id,
isSaved: dto.isSaved,
memoryAt: dto.memoryAt,
@@ -57,28 +63,28 @@ export class MemoryService extends BaseService {
}
async remove(auth: AuthDto, id: string): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_DELETE, ids: [id] });
await this.memoryRepository.delete(id);
await requireAccess(this.access, { auth, permission: Permission.MEMORY_DELETE, ids: [id] });
await this.repository.delete(id);
}
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_READ, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.MEMORY_READ, ids: [id] });
const repos = { access: this.accessRepository, bulk: this.memoryRepository };
const repos = { access: this.access, bulk: this.repository };
const results = await addAssets(auth, repos, { parentId: id, assetIds: dto.ids });
const hasSuccess = results.find(({ success }) => success);
if (hasSuccess) {
await this.memoryRepository.update({ id, updatedAt: new Date() });
await this.repository.update({ id, updatedAt: new Date() });
}
return results;
}
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.MEMORY_UPDATE, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.MEMORY_UPDATE, ids: [id] });
const repos = { access: this.accessRepository, bulk: this.memoryRepository };
const repos = { access: this.access, bulk: this.repository };
const results = await removeAssets(auth, repos, {
parentId: id,
assetIds: dto.ids,
@@ -87,14 +93,14 @@ export class MemoryService extends BaseService {
const hasSuccess = results.find(({ success }) => success);
if (hasSuccess) {
await this.memoryRepository.update({ id, updatedAt: new Date() });
await this.repository.update({ id, updatedAt: new Date() });
}
return results;
}
private async findOrFail(id: string) {
const memory = await this.memoryRepository.get(id);
const memory = await this.repository.get(id);
if (!memory) {
throw new BadRequestException('Memory not found');
}

View File

@@ -6,12 +6,16 @@ import { ExifEntity } from 'src/entities/exif.entity';
import { AssetType, SourceType } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMapRepository } from 'src/interfaces/map.interface';
import { IMediaRepository } from 'src/interfaces/media.interface';
import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@@ -24,7 +28,23 @@ import { probeStub } from 'test/fixtures/media.stub';
import { metadataStub } from 'test/fixtures/metadata.stub';
import { personStub } from 'test/fixtures/person.stub';
import { tagStub } from 'test/fixtures/tag.stub';
import { newTestService } from 'test/utils';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newMapRepositoryMock } from 'test/repositories/map.repository.mock';
import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock';
import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock';
import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest';
describe(MetadataService.name, () => {
@@ -32,35 +52,60 @@ describe(MetadataService.name, () => {
let albumMock: Mocked<IAlbumRepository>;
let assetMock: Mocked<IAssetRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let configMock: Mocked<IConfigRepository>;
let cryptoRepository: Mocked<ICryptoRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>;
let mapMock: Mocked<IMapRepository>;
let mediaMock: Mocked<IMediaRepository>;
let metadataMock: Mocked<IMetadataRepository>;
let moveMock: Mocked<IMoveRepository>;
let mediaMock: Mocked<IMediaRepository>;
let personMock: Mocked<IPersonRepository>;
let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let tagMock: Mocked<ITagRepository>;
let userMock: Mocked<IUserRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
({
sut,
albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
cryptoRepository = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
mapMock = newMapRepositoryMock();
metadataMock = newMetadataRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock();
eventMock = newEventRepositoryMock();
storageMock = newStorageRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
mediaMock = newMediaRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
tagMock = newTagRepositoryMock();
sut = new MetadataService(
albumMock,
assetMock,
cryptoMock,
configMock,
cryptoRepository,
databaseMock,
eventMock,
jobMock,
mapMock,
mediaMock,
metadataMock,
moveMock,
personMock,
storageMock,
systemMock,
tagMock,
userMock,
} = newTestService(MetadataService));
loggerMock,
);
});
afterEach(async () => {
@@ -524,10 +569,10 @@ describe(MetadataService.name, () => {
EmbeddedVideoFile: new BinaryField(0, ''),
EmbeddedVideoType: 'MotionPhoto_Data',
});
cryptoMock.hashSha1.mockReturnValue(randomBytes(512));
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid);
cryptoRepository.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid);
const video = randomBytes(512);
metadataMock.extractBinaryTag.mockResolvedValue(video);
@@ -567,10 +612,10 @@ describe(MetadataService.name, () => {
EmbeddedVideoFile: new BinaryField(0, ''),
EmbeddedVideoType: 'MotionPhoto_Data',
});
cryptoMock.hashSha1.mockReturnValue(randomBytes(512));
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid);
cryptoRepository.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid);
const video = randomBytes(512);
metadataMock.extractBinaryTag.mockResolvedValue(video);
@@ -611,10 +656,10 @@ describe(MetadataService.name, () => {
MicroVideo: 1,
MicroVideoOffset: 1,
});
cryptoMock.hashSha1.mockReturnValue(randomBytes(512));
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
cryptoMock.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid);
cryptoRepository.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid);
const video = randomBytes(512);
storageMock.readFile.mockResolvedValue(video);
@@ -655,7 +700,7 @@ describe(MetadataService.name, () => {
MicroVideo: 1,
MicroVideoOffset: 1,
});
cryptoMock.hashSha1.mockReturnValue(randomBytes(512));
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue(null);
assetMock.create.mockImplementation((asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset }));
const video = randomBytes(512);
@@ -680,7 +725,7 @@ describe(MetadataService.name, () => {
MicroVideo: 1,
MicroVideoOffset: 1,
});
cryptoMock.hashSha1.mockReturnValue(randomBytes(512));
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset);
const video = randomBytes(512);
storageMock.readFile.mockResolvedValue(video);
@@ -702,7 +747,7 @@ describe(MetadataService.name, () => {
MicroVideo: 1,
MicroVideoOffset: 1,
});
cryptoMock.hashSha1.mockReturnValue(randomBytes(512));
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true });
const video = randomBytes(512);
storageMock.readFile.mockResolvedValue(video);
@@ -728,7 +773,7 @@ describe(MetadataService.name, () => {
MicroVideo: 1,
MicroVideoOffset: 1,
});
cryptoMock.hashSha1.mockReturnValue(randomBytes(512));
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.getByChecksum.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
const video = randomBytes(512);

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { ContainerDirectoryItem, ExifDateTime, Maybe, Tags } from 'exiftool-vendored';
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
import _ from 'lodash';
@@ -13,20 +13,32 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { AssetType, SourceType } from 'src/enum';
import { WithoutProperty } from 'src/interfaces/asset.interface';
import { DatabaseLock } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
import {
IBaseJob,
IEntityJob,
IJobRepository,
ISidecarWriteJob,
JobName,
JOBS_ASSET_PAGINATION_SIZE,
JobStatus,
QueueName,
} from 'src/interfaces/job.interface';
import { ReverseGeocodeResult } from 'src/interfaces/map.interface';
import { ImmichTags } from 'src/interfaces/metadata.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMapRepository, ReverseGeocodeResult } from 'src/interfaces/map.interface';
import { IMediaRepository } from 'src/interfaces/media.interface';
import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { BaseService } from 'src/services/base.service';
import { isFaceImportEnabled } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination';
@@ -87,6 +99,41 @@ const validateRange = (value: number | undefined, min: number, max: number): Non
@Injectable()
export class MetadataService extends BaseService {
private storageCore: StorageCore;
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IMapRepository) private mapRepository: IMapRepository,
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
@Inject(IMetadataRepository) private repository: IMetadataRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IPersonRepository) private personRepository: IPersonRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ITagRepository) private tagRepository: ITagRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(MetadataService.name);
this.storageCore = StorageCore.create(
assetRepository,
configRepository,
cryptoRepository,
moveRepository,
personRepository,
storageRepository,
systemMetadataRepository,
this.logger,
);
}
@OnEvent({ name: 'app.bootstrap' })
async onBootstrap(app: ArgOf<'app.bootstrap'>) {
if (app !== 'microservices') {
@@ -98,7 +145,7 @@ export class MetadataService extends BaseService {
@OnEvent({ name: 'app.shutdown' })
async onShutdown() {
await this.metadataRepository.teardown();
await this.repository.teardown();
}
@OnEvent({ name: 'config.update' })
@@ -325,7 +372,7 @@ export class MetadataService extends BaseService {
return JobStatus.SKIPPED;
}
await this.metadataRepository.writeTags(sidecarPath, exif);
await this.repository.writeTags(sidecarPath, exif);
if (!asset.sidecarPath) {
await this.assetRepository.update({ id, sidecarPath });
@@ -335,8 +382,8 @@ export class MetadataService extends BaseService {
}
private async getExifTags(asset: AssetEntity): Promise<ImmichTags> {
const mediaTags = await this.metadataRepository.readTags(asset.originalPath);
const sidecarTags = asset.sidecarPath ? await this.metadataRepository.readTags(asset.sidecarPath) : {};
const mediaTags = await this.repository.readTags(asset.originalPath);
const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : {};
const videoTags = asset.type === AssetType.VIDEO ? await this.getVideoTags(asset.originalPath) : {};
// make sure dates comes from sidecar
@@ -420,11 +467,11 @@ export class MetadataService extends BaseService {
// Samsung MotionPhoto video extraction
// HEIC-encoded
if (hasMotionPhotoVideo) {
video = await this.metadataRepository.extractBinaryTag(asset.originalPath, 'MotionPhotoVideo');
video = await this.repository.extractBinaryTag(asset.originalPath, 'MotionPhotoVideo');
}
// JPEG-encoded; HEIC also contains these tags, so this conditional must come second
else if (hasEmbeddedVideoFile) {
video = await this.metadataRepository.extractBinaryTag(asset.originalPath, 'EmbeddedVideoFile');
video = await this.repository.extractBinaryTag(asset.originalPath, 'EmbeddedVideoFile');
}
// Default video extraction
else {

View File

@@ -6,8 +6,10 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetFileType, UserMetadataKey } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
@@ -15,7 +17,15 @@ import { NotificationService } from 'src/services/notification.service';
import { albumStub } from 'test/fixtures/album.stub';
import { assetStub } from 'test/fixtures/asset.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newTestService } from 'test/utils';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newNotificationRepositoryMock } from 'test/repositories/notification.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest';
const configs = {
@@ -56,19 +66,39 @@ const configs = {
};
describe(NotificationService.name, () => {
let sut: NotificationService;
let albumMock: Mocked<IAlbumRepository>;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let notificationMock: Mocked<INotificationRepository>;
let sut: NotificationService;
let systemMock: Mocked<ISystemMetadataRepository>;
let userMock: Mocked<IUserRepository>;
beforeEach(() => {
({ sut, albumMock, assetMock, eventMock, jobMock, notificationMock, systemMock, userMock } =
newTestService(NotificationService));
albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
loggerMock = newLoggerRepositoryMock();
notificationMock = newNotificationRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
userMock = newUserRepositoryMock();
sut = new NotificationService(
configMock,
eventMock,
systemMock,
notificationMock,
userMock,
jobMock,
loggerMock,
assetMock,
albumMock,
);
});
it('should work', () => {

View File

@@ -1,18 +1,25 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants';
import { OnEvent } from 'src/decorators';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { AlbumEntity } from 'src/entities/album.entity';
import { ArgOf } from 'src/interfaces/event.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
import {
IEmailJob,
IJobRepository,
INotifyAlbumInviteJob,
INotifyAlbumUpdateJob,
INotifySignupJob,
JobName,
JobStatus,
} from 'src/interfaces/job.interface';
import { EmailImageAttachment, EmailTemplate } from 'src/interfaces/notification.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { EmailImageAttachment, EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { BaseService } from 'src/services/base.service';
import { getAssetFiles } from 'src/utils/asset.util';
import { getFilenameExtension } from 'src/utils/file';
@@ -21,6 +28,21 @@ import { getPreferences } from 'src/utils/preferences';
@Injectable()
export class NotificationService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(INotificationRepository) private notificationRepository: INotificationRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(NotificationService.name);
}
@OnEvent({ name: 'config.update' })
onConfigUpdate({ oldConfig, newConfig }: ArgOf<'config.update'>) {
this.eventRepository.clientBroadcast('on_config_update');

View File

@@ -1,17 +1,20 @@
import { BadRequestException } from '@nestjs/common';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IPartnerRepository, PartnerDirection } from 'src/interfaces/partner.interface';
import { PartnerService } from 'src/services/partner.service';
import { authStub } from 'test/fixtures/auth.stub';
import { partnerStub } from 'test/fixtures/partner.stub';
import { newTestService } from 'test/utils';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
import { Mocked } from 'vitest';
describe(PartnerService.name, () => {
let sut: PartnerService;
let partnerMock: Mocked<IPartnerRepository>;
let accessMock: Mocked<IAccessRepository>;
beforeEach(() => {
({ sut, partnerMock } = newTestService(PartnerService));
partnerMock = newPartnerRepositoryMock();
sut = new PartnerService(partnerMock, accessMock);
});
it('should work', () => {

View File

@@ -1,38 +1,43 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AuthDto } from 'src/dtos/auth.dto';
import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto';
import { mapUser } from 'src/dtos/user.dto';
import { PartnerEntity } from 'src/entities/partner.entity';
import { Permission } from 'src/enum';
import { PartnerDirection, PartnerIds } from 'src/interfaces/partner.interface';
import { BaseService } from 'src/services/base.service';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IPartnerRepository, PartnerDirection, PartnerIds } from 'src/interfaces/partner.interface';
import { requireAccess } from 'src/utils/access';
@Injectable()
export class PartnerService extends BaseService {
export class PartnerService {
constructor(
@Inject(IPartnerRepository) private repository: IPartnerRepository,
@Inject(IAccessRepository) private access: IAccessRepository,
) {}
async create(auth: AuthDto, sharedWithId: string): Promise<PartnerResponseDto> {
const partnerId: PartnerIds = { sharedById: auth.user.id, sharedWithId };
const exists = await this.partnerRepository.get(partnerId);
const exists = await this.repository.get(partnerId);
if (exists) {
throw new BadRequestException(`Partner already exists`);
}
const partner = await this.partnerRepository.create(partnerId);
const partner = await this.repository.create(partnerId);
return this.mapPartner(partner, PartnerDirection.SharedBy);
}
async remove(auth: AuthDto, sharedWithId: string): Promise<void> {
const partnerId: PartnerIds = { sharedById: auth.user.id, sharedWithId };
const partner = await this.partnerRepository.get(partnerId);
const partner = await this.repository.get(partnerId);
if (!partner) {
throw new BadRequestException('Partner not found');
}
await this.partnerRepository.remove(partner);
await this.repository.remove(partner);
}
async search(auth: AuthDto, { direction }: PartnerSearchDto): Promise<PartnerResponseDto[]> {
const partners = await this.partnerRepository.getAll(auth.user.id);
const partners = await this.repository.getAll(auth.user.id);
const key = direction === PartnerDirection.SharedBy ? 'sharedById' : 'sharedWithId';
return partners
.filter((partner) => partner.sharedBy && partner.sharedWith) // Filter out soft deleted users
@@ -41,10 +46,10 @@ export class PartnerService extends BaseService {
}
async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise<PartnerResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PARTNER_UPDATE, ids: [sharedById] });
await requireAccess(this.access, { auth, permission: Permission.PARTNER_UPDATE, ids: [sharedById] });
const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id };
const entity = await this.partnerRepository.update({ ...partnerId, inTimeline: dto.inTimeline });
const entity = await this.repository.update({ ...partnerId, inTimeline: dto.inTimeline });
return this.mapPartner(entity, PartnerDirection.SharedWith);
}

View File

@@ -4,10 +4,13 @@ import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { CacheControl, Colorspace, ImageFormat, SourceType, SystemMetadataKey } from 'src/enum';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { DetectedFaces, IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IMediaRepository } from 'src/interfaces/media.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { FaceSearchResult, ISearchRepository } from 'src/interfaces/search.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
@@ -19,8 +22,19 @@ import { authStub } from 'test/fixtures/auth.stub';
import { faceStub } from 'test/fixtures/face.stub';
import { personStub } from 'test/fixtures/person.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock';
import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock';
import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { IsNull } from 'typeorm';
import { Mocked } from 'vitest';
@@ -53,33 +67,51 @@ const detectFaceMock: DetectedFaces = {
};
describe(PersonService.name, () => {
let sut: PersonService;
let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let configMock: Mocked<IConfigRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let jobMock: Mocked<IJobRepository>;
let machineLearningMock: Mocked<IMachineLearningRepository>;
let mediaMock: Mocked<IMediaRepository>;
let moveMock: Mocked<IMoveRepository>;
let personMock: Mocked<IPersonRepository>;
let searchMock: Mocked<ISearchRepository>;
let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let searchMock: Mocked<ISearchRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let sut: PersonService;
beforeEach(() => {
({
sut,
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
jobMock = newJobRepositoryMock();
machineLearningMock = newMachineLearningRepositoryMock();
moveMock = newMoveRepositoryMock();
mediaMock = newMediaRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
searchMock = newSearchRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new PersonService(
accessMock,
assetMock,
cryptoMock,
jobMock,
configMock,
machineLearningMock,
moveMock,
mediaMock,
personMock,
searchMock,
storageMock,
systemMock,
} = newTestService(PersonService));
storageMock,
jobMock,
searchMock,
cryptoMock,
loggerMock,
);
});
it('should be defined', () => {

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { FACE_THUMBNAIL_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
@@ -31,11 +31,15 @@ import {
SourceType,
SystemMetadataKey,
} from 'src/enum';
import { WithoutProperty } from 'src/interfaces/asset.interface';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import {
IBaseJob,
IDeferrableJob,
IEntityJob,
IJobRepository,
INightlyJob,
JOBS_ASSET_PAGINATION_SIZE,
JobItem,
@@ -43,9 +47,14 @@ import {
JobStatus,
QueueName,
} from 'src/interfaces/job.interface';
import { BoundingBox } from 'src/interfaces/machine-learning.interface';
import { CropOptions, ImageDimensions, InputDimensions } from 'src/interfaces/media.interface';
import { UpdateFacesData } from 'src/interfaces/person.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { BoundingBox, IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { CropOptions, IMediaRepository, ImageDimensions, InputDimensions } from 'src/interfaces/media.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BaseService } from 'src/services/base.service';
import { checkAccess, requireAccess } from 'src/utils/access';
import { getAssetFiles } from 'src/utils/asset.util';
@@ -57,6 +66,37 @@ import { IsNull } from 'typeorm';
@Injectable()
export class PersonService extends BaseService {
private storageCore: StorageCore;
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IMachineLearningRepository) private machineLearningRepository: IMachineLearningRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
@Inject(IPersonRepository) private repository: IPersonRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISearchRepository) private smartInfoRepository: ISearchRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(PersonService.name);
this.storageCore = StorageCore.create(
assetRepository,
configRepository,
cryptoRepository,
moveRepository,
repository,
storageRepository,
systemMetadataRepository,
this.logger,
);
}
async getAll(auth: AuthDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
const { withHidden = false, page, size } = dto;
const pagination = {
@@ -65,11 +105,11 @@ export class PersonService extends BaseService {
};
const { machineLearning } = await this.getConfig({ withCache: false });
const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, {
const { items, hasNextPage } = await this.repository.getAllForUser(pagination, auth.user.id, {
minimumFaceCount: machineLearning.facialRecognition.minFaces,
withHidden,
});
const { total, hidden } = await this.personRepository.getNumberOfPeople(auth.user.id);
const { total, hidden } = await this.repository.getNumberOfPeople(auth.user.id);
return {
people: items.map((person) => mapPerson(person)),
@@ -80,15 +120,15 @@ export class PersonService extends BaseService {
}
async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise<PersonResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] });
await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] });
const person = await this.findOrFail(personId);
const result: PersonResponseDto[] = [];
const changeFeaturePhoto: string[] = [];
for (const data of dto.data) {
const faces = await this.personRepository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]);
const faces = await this.repository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]);
for (const face of faces) {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_CREATE, ids: [face.id] });
await requireAccess(this.access, { auth, permission: Permission.PERSON_CREATE, ids: [face.id] });
if (person.faceAssetId === null) {
changeFeaturePhoto.push(person.id);
}
@@ -96,7 +136,7 @@ export class PersonService extends BaseService {
changeFeaturePhoto.push(face.person.id);
}
await this.personRepository.reassignFace(face.id, personId);
await this.repository.reassignFace(face.id, personId);
}
result.push(person);
@@ -109,12 +149,12 @@ export class PersonService extends BaseService {
}
async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise<PersonResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] });
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_CREATE, ids: [dto.id] });
const face = await this.personRepository.getFaceById(dto.id);
await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [personId] });
await requireAccess(this.access, { auth, permission: Permission.PERSON_CREATE, ids: [dto.id] });
const face = await this.repository.getFaceById(dto.id);
const person = await this.findOrFail(personId);
await this.personRepository.reassignFace(face.id, personId);
await this.repository.reassignFace(face.id, personId);
if (person.faceAssetId === null) {
await this.createNewFeaturePhoto([person.id]);
}
@@ -126,8 +166,8 @@ export class PersonService extends BaseService {
}
async getFacesById(auth: AuthDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_READ, ids: [dto.id] });
const faces = await this.personRepository.getFaces(dto.id);
await requireAccess(this.access, { auth, permission: Permission.ASSET_READ, ids: [dto.id] });
const faces = await this.repository.getFaces(dto.id);
return faces.map((asset) => mapFaces(asset, auth));
}
@@ -138,10 +178,10 @@ export class PersonService extends BaseService {
const jobs: JobItem[] = [];
for (const personId of changeFeaturePhoto) {
const assetFace = await this.personRepository.getRandomFace(personId);
const assetFace = await this.repository.getRandomFace(personId);
if (assetFace !== null) {
await this.personRepository.update({ id: personId, faceAssetId: assetFace.id });
await this.repository.update({ id: personId, faceAssetId: assetFace.id });
jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: personId } });
}
}
@@ -150,18 +190,18 @@ export class PersonService extends BaseService {
}
async getById(auth: AuthDto, id: string): Promise<PersonResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_READ, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] });
return this.findOrFail(id).then(mapPerson);
}
async getStatistics(auth: AuthDto, id: string): Promise<PersonStatisticsResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_READ, ids: [id] });
return this.personRepository.getStatistics(id);
await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] });
return this.repository.getStatistics(id);
}
async getThumbnail(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_READ, ids: [id] });
const person = await this.personRepository.getById(id);
await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] });
const person = await this.repository.getById(id);
if (!person || !person.thumbnailPath) {
throw new NotFoundException();
}
@@ -174,13 +214,13 @@ export class PersonService extends BaseService {
}
async getAssets(auth: AuthDto, id: string): Promise<AssetResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_READ, ids: [id] });
const assets = await this.personRepository.getAssets(id);
await requireAccess(this.access, { auth, permission: Permission.PERSON_READ, ids: [id] });
const assets = await this.repository.getAssets(id);
return assets.map((asset) => mapAsset(asset));
}
create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
return this.personRepository.create({
return this.repository.create({
ownerId: auth.user.id,
name: dto.name,
birthDate: dto.birthDate,
@@ -189,14 +229,14 @@ export class PersonService extends BaseService {
}
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_UPDATE, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [id] });
const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto;
// TODO: set by faceId directly
let faceId: string | undefined = undefined;
if (assetId) {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_READ, ids: [assetId] });
const [face] = await this.personRepository.getFacesByIds([{ personId: id, assetId }]);
await requireAccess(this.access, { auth, permission: Permission.ASSET_READ, ids: [assetId] });
const [face] = await this.repository.getFacesByIds([{ personId: id, assetId }]);
if (!face) {
throw new BadRequestException('Invalid assetId for feature face');
}
@@ -204,7 +244,7 @@ export class PersonService extends BaseService {
faceId = face.id;
}
const person = await this.personRepository.update({ id, faceAssetId: faceId, name, birthDate, isHidden });
const person = await this.repository.update({ id, faceAssetId: faceId, name, birthDate, isHidden });
if (assetId) {
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } });
@@ -234,12 +274,12 @@ export class PersonService extends BaseService {
private async delete(people: PersonEntity[]) {
await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath)));
await this.personRepository.delete(people);
await this.repository.delete(people);
this.logger.debug(`Deleted ${people.length} people`);
}
async handlePersonCleanup(): Promise<JobStatus> {
const people = await this.personRepository.getAllWithoutFaces();
const people = await this.repository.getAllWithoutFaces();
await this.delete(people);
return JobStatus.SUCCESS;
}
@@ -251,7 +291,7 @@ export class PersonService extends BaseService {
}
if (force) {
await this.personRepository.deleteFaces({ sourceType: SourceType.MACHINE_LEARNING });
await this.repository.deleteFaces({ sourceType: SourceType.MACHINE_LEARNING });
await this.handlePersonCleanup();
}
@@ -324,7 +364,7 @@ export class PersonService extends BaseService {
});
}
const faceIds = await this.personRepository.createFaces(mappedFaces);
const faceIds = await this.repository.createFaces(mappedFaces);
await this.jobRepository.queueAll(faceIds.map((id) => ({ name: JobName.FACIAL_RECOGNITION, data: { id } })));
}
@@ -347,7 +387,7 @@ export class PersonService extends BaseService {
if (nightly) {
const [state, latestFaceDate] = await Promise.all([
this.systemMetadataRepository.get(SystemMetadataKey.FACIAL_RECOGNITION_STATE),
this.personRepository.getLatestFaceDate(),
this.repository.getLatestFaceDate(),
]);
if (state?.lastRun && latestFaceDate && state.lastRun > latestFaceDate) {
@@ -359,7 +399,7 @@ export class PersonService extends BaseService {
const { waiting } = await this.jobRepository.getJobCounts(QueueName.FACIAL_RECOGNITION);
if (force) {
await this.personRepository.unassignFaces({ sourceType: SourceType.MACHINE_LEARNING });
await this.repository.unassignFaces({ sourceType: SourceType.MACHINE_LEARNING });
await this.handlePersonCleanup();
} else if (waiting) {
this.logger.debug(
@@ -370,7 +410,7 @@ export class PersonService extends BaseService {
const lastRun = new Date().toISOString();
const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.personRepository.getAllFaces(pagination, {
this.repository.getAllFaces(pagination, {
where: force ? undefined : { personId: IsNull(), sourceType: IsNull() },
}),
);
@@ -392,7 +432,7 @@ export class PersonService extends BaseService {
return JobStatus.SKIPPED;
}
const face = await this.personRepository.getFaceByIdWithAssets(
const face = await this.repository.getFaceByIdWithAssets(
id,
{ person: true, asset: true, faceSearch: true },
{ id: true, personId: true, sourceType: true, faceSearch: { embedding: true } },
@@ -417,7 +457,7 @@ export class PersonService extends BaseService {
return JobStatus.SKIPPED;
}
const matches = await this.searchRepository.searchFaces({
const matches = await this.smartInfoRepository.searchFaces({
userIds: [face.asset.ownerId],
embedding: face.faceSearch.embedding,
maxDistance: machineLearning.facialRecognition.maxDistance,
@@ -441,7 +481,7 @@ export class PersonService extends BaseService {
let personId = matches.find((match) => match.face.personId)?.face.personId;
if (!personId) {
const matchWithPerson = await this.searchRepository.searchFaces({
const matchWithPerson = await this.smartInfoRepository.searchFaces({
userIds: [face.asset.ownerId],
embedding: face.faceSearch.embedding,
maxDistance: machineLearning.facialRecognition.maxDistance,
@@ -456,21 +496,21 @@ export class PersonService extends BaseService {
if (isCore && !personId) {
this.logger.log(`Creating new person for face ${id}`);
const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id });
const newPerson = await this.repository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id });
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: newPerson.id } });
personId = newPerson.id;
}
if (personId) {
this.logger.debug(`Assigning face ${id} to person ${personId}`);
await this.personRepository.reassignFaces({ faceIds: [id], newPersonId: personId });
await this.repository.reassignFaces({ faceIds: [id], newPersonId: personId });
}
return JobStatus.SUCCESS;
}
async handlePersonMigration({ id }: IEntityJob): Promise<JobStatus> {
const person = await this.personRepository.getById(id);
const person = await this.repository.getById(id);
if (!person) {
return JobStatus.FAILED;
}
@@ -486,13 +526,13 @@ export class PersonService extends BaseService {
return JobStatus.SKIPPED;
}
const person = await this.personRepository.getById(data.id);
const person = await this.repository.getById(data.id);
if (!person?.faceAssetId) {
this.logger.error(`Could not generate person thumbnail: person ${person?.id} has no face asset`);
return JobStatus.FAILED;
}
const face = await this.personRepository.getFaceByIdWithAssets(person.faceAssetId);
const face = await this.repository.getFaceByIdWithAssets(person.faceAssetId);
if (face === null) {
this.logger.error(`Could not generate person thumbnail: face ${person.faceAssetId} not found`);
return JobStatus.FAILED;
@@ -532,7 +572,7 @@ export class PersonService extends BaseService {
};
await this.mediaRepository.generateThumbnail(inputPath, thumbnailOptions, thumbnailPath);
await this.personRepository.update({ id: person.id, thumbnailPath });
await this.repository.update({ id: person.id, thumbnailPath });
return JobStatus.SUCCESS;
}
@@ -543,13 +583,13 @@ export class PersonService extends BaseService {
throw new BadRequestException('Cannot merge a person into themselves');
}
await requireAccess(this.accessRepository, { auth, permission: Permission.PERSON_UPDATE, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.PERSON_UPDATE, ids: [id] });
let primaryPerson = await this.findOrFail(id);
const primaryName = primaryPerson.name || primaryPerson.id;
const results: BulkIdResponseDto[] = [];
const allowedIds = await checkAccess(this.accessRepository, {
const allowedIds = await checkAccess(this.access, {
auth,
permission: Permission.PERSON_MERGE,
ids: mergeIds,
@@ -563,7 +603,7 @@ export class PersonService extends BaseService {
}
try {
const mergePerson = await this.personRepository.getById(mergeId);
const mergePerson = await this.repository.getById(mergeId);
if (!mergePerson) {
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NOT_FOUND });
continue;
@@ -579,14 +619,14 @@ export class PersonService extends BaseService {
}
if (Object.keys(update).length > 0) {
primaryPerson = await this.personRepository.update({ id: primaryPerson.id, ...update });
primaryPerson = await this.repository.update({ id: primaryPerson.id, ...update });
}
const mergeName = mergePerson.name || mergePerson.id;
const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id };
this.logger.log(`Merging ${mergeName} into ${primaryName}`);
await this.personRepository.reassignFaces(mergeData);
await this.repository.reassignFaces(mergeData);
await this.delete([mergePerson]);
this.logger.log(`Merged ${mergeName} into ${primaryName}`);
@@ -600,7 +640,7 @@ export class PersonService extends BaseService {
}
private async findOrFail(id: string) {
const person = await this.personRepository.getById(id);
const person = await this.repository.getById(id);
if (!person) {
throw new BadRequestException('Person not found');
}

View File

@@ -1,26 +1,60 @@
import { mapAsset } from 'src/dtos/asset-response.dto';
import { SearchSuggestionType } from 'src/dtos/search.dto';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { SearchService } from 'src/services/search.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { personStub } from 'test/fixtures/person.stub';
import { newTestService } from 'test/utils';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked, beforeEach, vitest } from 'vitest';
vitest.useFakeTimers();
describe(SearchService.name, () => {
let sut: SearchService;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let machineMock: Mocked<IMachineLearningRepository>;
let personMock: Mocked<IPersonRepository>;
let searchMock: Mocked<ISearchRepository>;
let partnerMock: Mocked<IPartnerRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
({ sut, assetMock, personMock, searchMock } = newTestService(SearchService));
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
machineMock = newMachineLearningRepositoryMock();
personMock = newPersonRepositoryMock();
searchMock = newSearchRepositoryMock();
partnerMock = newPartnerRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new SearchService(
configMock,
systemMock,
machineMock,
personMock,
searchMock,
assetMock,
partnerMock,
loggerMock,
);
});
it('should work', () => {

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { AssetMapOptions, AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { PersonResponseDto } from 'src/dtos/person.dto';
@@ -16,13 +16,34 @@ import {
} from 'src/dtos/search.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetOrder } from 'src/enum';
import { SearchExploreItem } from 'src/interfaces/search.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISearchRepository, SearchExploreItem } from 'src/interfaces/search.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BaseService } from 'src/services/base.service';
import { getMyPartnerIds } from 'src/utils/asset.util';
import { isSmartSearchEnabled } from 'src/utils/misc';
@Injectable()
export class SearchService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
@Inject(IPersonRepository) private personRepository: IPersonRepository,
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(SearchService.name);
}
async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden });
}
@@ -87,11 +108,7 @@ export class SearchService extends BaseService {
const userIds = await this.getUserIdsToSearch(auth);
const embedding = await this.machineLearningRepository.encodeText(
machineLearning.url,
dto.query,
machineLearning.clip,
);
const embedding = await this.machineLearning.encodeText(machineLearning.url, dto.query, machineLearning.clip);
const page = dto.page ?? 1;
const size = dto.size || 100;
const { hasNextPage, items } = await this.searchRepository.searchSmart(

View File

@@ -1,20 +1,41 @@
import { SystemMetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { ServerService } from 'src/services/server.service';
import { newTestService } from 'test/utils';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest';
describe(ServerService.name, () => {
let sut: ServerService;
let configMock: Mocked<IConfigRepository>;
let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let userMock: Mocked<IUserRepository>;
let serverInfoMock: Mocked<IServerInfoRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
beforeEach(() => {
({ sut, storageMock, systemMock, userMock } = newTestService(ServerService));
configMock = newConfigRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
serverInfoMock = newServerInfoRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
loggerMock = newLoggerRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
sut = new ServerService(configMock, userMock, storageMock, systemMock, serverInfoMock, loggerMock, cryptoMock);
});
it('should work', () => {

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { getBuildMetadata, getServerLicensePublicKey } from 'src/config';
import { serverVersion } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
@@ -15,7 +15,13 @@ import {
UsageByUserDto,
} from 'src/dtos/server.dto';
import { StorageFolder, SystemMetadataKey } from 'src/enum';
import { UserStatsQueryResponse } from 'src/interfaces/user.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface';
import { BaseService } from 'src/services/base.service';
import { asHumanReadable } from 'src/utils/bytes';
import { mimeTypes } from 'src/utils/mime-types';
@@ -23,6 +29,19 @@ import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchE
@Injectable()
export class ServerService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IServerInfoRepository) private serverInfoRepository: IServerInfoRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(ServerService.name);
}
@OnEvent({ name: 'app.bootstrap' })
async onBootstrap(): Promise<void> {
const featureFlags = await this.getFeatures();

View File

@@ -1,21 +1,27 @@
import { UserEntity } from 'src/entities/user.entity';
import { JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { SessionService } from 'src/services/session.service';
import { authStub } from 'test/fixtures/auth.stub';
import { sessionStub } from 'test/fixtures/session.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock';
import { Mocked } from 'vitest';
describe('SessionService', () => {
let sut: SessionService;
let accessMock: Mocked<IAccessRepositoryMock>;
let loggerMock: Mocked<ILoggerRepository>;
let sessionMock: Mocked<ISessionRepository>;
beforeEach(() => {
({ sut, accessMock, sessionMock } = newTestService(SessionService));
accessMock = newAccessRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sessionMock = newSessionRepositoryMock();
sut = new SessionService(accessMock, loggerMock, sessionMock);
});
it('should be defined', () => {

View File

@@ -1,14 +1,24 @@
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto, mapSession } from 'src/dtos/session.dto';
import { Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { JobStatus } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { requireAccess } from 'src/utils/access';
@Injectable()
export class SessionService extends BaseService {
export class SessionService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(ISessionRepository) private sessionRepository: ISessionRepository,
) {
this.logger.setContext(SessionService.name);
}
async handleCleanup() {
const sessions = await this.sessionRepository.search({
updatedBefore: DateTime.now().minus({ days: 90 }).toJSDate(),
@@ -34,7 +44,7 @@ export class SessionService extends BaseService {
}
async delete(auth: AuthDto, id: string): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.AUTH_DEVICE_DELETE, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.AUTH_DEVICE_DELETE, ids: [id] });
await this.sessionRepository.delete(id);
}

View File

@@ -3,24 +3,42 @@ import _ from 'lodash';
import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants';
import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { SharedLinkType } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { SharedLinkService } from 'src/services/shared-link.service';
import { albumStub } from 'test/fixtures/album.stub';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked } from 'vitest';
describe(SharedLinkService.name, () => {
let sut: SharedLinkService;
let accessMock: IAccessRepositoryMock;
let sharedLinkMock: Mocked<ISharedLinkRepository>;
let configMock: Mocked<IConfigRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let shareMock: Mocked<ISharedLinkRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let logMock: Mocked<ILoggerRepository>;
beforeEach(() => {
({ sut, accessMock, sharedLinkMock } = newTestService(SharedLinkService));
accessMock = newAccessRepositoryMock();
configMock = newConfigRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
shareMock = newSharedLinkRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
logMock = newLoggerRepositoryMock();
sut = new SharedLinkService(accessMock, configMock, cryptoMock, logMock, shareMock, systemMock);
});
it('should work', () => {
@@ -29,55 +47,55 @@ describe(SharedLinkService.name, () => {
describe('getAll', () => {
it('should return all shared links for a user', async () => {
sharedLinkMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]);
shareMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]);
await expect(sut.getAll(authStub.user1)).resolves.toEqual([
sharedLinkResponseStub.expired,
sharedLinkResponseStub.valid,
]);
expect(sharedLinkMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
expect(shareMock.getAll).toHaveBeenCalledWith(authStub.user1.user.id);
});
});
describe('getMine', () => {
it('should only work for a public user', async () => {
await expect(sut.getMine(authStub.admin, {})).rejects.toBeInstanceOf(ForbiddenException);
expect(sharedLinkMock.get).not.toHaveBeenCalled();
expect(shareMock.get).not.toHaveBeenCalled();
});
it('should return the shared link for the public user', async () => {
const authDto = authStub.adminSharedLink;
sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid);
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.valid);
expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
expect(shareMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
});
it('should not return metadata', async () => {
const authDto = authStub.adminSharedLinkNoExif;
sharedLinkMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
shareMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata);
expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
expect(shareMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
});
it('should throw an error for an password protected shared link', async () => {
const authDto = authStub.adminSharedLink;
sharedLinkMock.get.mockResolvedValue(sharedLinkStub.passwordRequired);
shareMock.get.mockResolvedValue(sharedLinkStub.passwordRequired);
await expect(sut.getMine(authDto, {})).rejects.toBeInstanceOf(UnauthorizedException);
expect(sharedLinkMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
expect(shareMock.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
});
});
describe('get', () => {
it('should throw an error for an invalid shared link', async () => {
sharedLinkMock.get.mockResolvedValue(null);
shareMock.get.mockResolvedValue(null);
await expect(sut.get(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException);
expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id');
expect(sharedLinkMock.update).not.toHaveBeenCalled();
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id');
expect(shareMock.update).not.toHaveBeenCalled();
});
it('should get a shared link by id', async () => {
sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid);
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.get(authStub.user1, sharedLinkStub.valid.id)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
});
});
@@ -108,7 +126,7 @@ describe(SharedLinkService.name, () => {
it('should create an album shared link', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id]));
sharedLinkMock.create.mockResolvedValue(sharedLinkStub.valid);
shareMock.create.mockResolvedValue(sharedLinkStub.valid);
await sut.create(authStub.admin, { type: SharedLinkType.ALBUM, albumId: albumStub.oneAsset.id });
@@ -116,7 +134,7 @@ describe(SharedLinkService.name, () => {
authStub.admin.user.id,
new Set([albumStub.oneAsset.id]),
);
expect(sharedLinkMock.create).toHaveBeenCalledWith({
expect(shareMock.create).toHaveBeenCalledWith({
type: SharedLinkType.ALBUM,
userId: authStub.admin.user.id,
albumId: albumStub.oneAsset.id,
@@ -132,7 +150,7 @@ describe(SharedLinkService.name, () => {
it('should create an individual shared link', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual);
shareMock.create.mockResolvedValue(sharedLinkStub.individual);
await sut.create(authStub.admin, {
type: SharedLinkType.INDIVIDUAL,
@@ -146,7 +164,7 @@ describe(SharedLinkService.name, () => {
authStub.admin.user.id,
new Set([assetStub.image.id]),
);
expect(sharedLinkMock.create).toHaveBeenCalledWith({
expect(shareMock.create).toHaveBeenCalledWith({
type: SharedLinkType.INDIVIDUAL,
userId: authStub.admin.user.id,
albumId: null,
@@ -162,7 +180,7 @@ describe(SharedLinkService.name, () => {
it('should create a shared link with allowDownload set to false when showMetadata is false', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual);
shareMock.create.mockResolvedValue(sharedLinkStub.individual);
await sut.create(authStub.admin, {
type: SharedLinkType.INDIVIDUAL,
@@ -176,7 +194,7 @@ describe(SharedLinkService.name, () => {
authStub.admin.user.id,
new Set([assetStub.image.id]),
);
expect(sharedLinkMock.create).toHaveBeenCalledWith({
expect(shareMock.create).toHaveBeenCalledWith({
type: SharedLinkType.INDIVIDUAL,
userId: authStub.admin.user.id,
albumId: null,
@@ -193,18 +211,18 @@ describe(SharedLinkService.name, () => {
describe('update', () => {
it('should throw an error for an invalid shared link', async () => {
sharedLinkMock.get.mockResolvedValue(null);
shareMock.get.mockResolvedValue(null);
await expect(sut.update(authStub.user1, 'missing-id', {})).rejects.toBeInstanceOf(BadRequestException);
expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id');
expect(sharedLinkMock.update).not.toHaveBeenCalled();
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id');
expect(shareMock.update).not.toHaveBeenCalled();
});
it('should update a shared link', async () => {
sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid);
sharedLinkMock.update.mockResolvedValue(sharedLinkStub.valid);
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
shareMock.update.mockResolvedValue(sharedLinkStub.valid);
await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false });
expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
expect(sharedLinkMock.update).toHaveBeenCalledWith({
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
expect(shareMock.update).toHaveBeenCalledWith({
id: sharedLinkStub.valid.id,
userId: authStub.user1.user.id,
allowDownload: false,
@@ -214,31 +232,31 @@ describe(SharedLinkService.name, () => {
describe('remove', () => {
it('should throw an error for an invalid shared link', async () => {
sharedLinkMock.get.mockResolvedValue(null);
shareMock.get.mockResolvedValue(null);
await expect(sut.remove(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException);
expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id');
expect(sharedLinkMock.update).not.toHaveBeenCalled();
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id');
expect(shareMock.update).not.toHaveBeenCalled();
});
it('should remove a key', async () => {
sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid);
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await sut.remove(authStub.user1, sharedLinkStub.valid.id);
expect(sharedLinkMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
expect(sharedLinkMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
expect(shareMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid);
});
});
describe('addAssets', () => {
it('should not work on album shared links', async () => {
sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid);
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('should add assets to a shared link', async () => {
sharedLinkMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual));
sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual);
shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual));
shareMock.create.mockResolvedValue(sharedLinkStub.individual);
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-3']));
await expect(
@@ -250,7 +268,7 @@ describe(SharedLinkService.name, () => {
]);
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledTimes(1);
expect(sharedLinkMock.update).toHaveBeenCalledWith({
expect(shareMock.update).toHaveBeenCalledWith({
...sharedLinkStub.individual,
assets: [assetStub.image, { id: 'asset-3' }],
});
@@ -259,15 +277,15 @@ describe(SharedLinkService.name, () => {
describe('removeAssets', () => {
it('should not work on album shared links', async () => {
sharedLinkMock.get.mockResolvedValue(sharedLinkStub.valid);
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('should remove assets from a shared link', async () => {
sharedLinkMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual));
sharedLinkMock.create.mockResolvedValue(sharedLinkStub.individual);
shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual));
shareMock.create.mockResolvedValue(sharedLinkStub.individual);
await expect(
sut.removeAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2'] }),
@@ -276,29 +294,29 @@ describe(SharedLinkService.name, () => {
{ assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND },
]);
expect(sharedLinkMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] });
expect(shareMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] });
});
});
describe('getMetadataTags', () => {
it('should return null when auth is not a shared link', async () => {
await expect(sut.getMetadataTags(authStub.admin)).resolves.toBe(null);
expect(sharedLinkMock.get).not.toHaveBeenCalled();
expect(shareMock.get).not.toHaveBeenCalled();
});
it('should return null when shared link has a password', async () => {
await expect(sut.getMetadataTags(authStub.passwordSharedLink)).resolves.toBe(null);
expect(sharedLinkMock.get).not.toHaveBeenCalled();
expect(shareMock.get).not.toHaveBeenCalled();
});
it('should return metadata tags', async () => {
sharedLinkMock.get.mockResolvedValue(sharedLinkStub.individual);
shareMock.get.mockResolvedValue(sharedLinkStub.individual);
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
description: '1 shared photos & videos',
imageUrl: `${DEFAULT_EXTERNAL_DOMAIN}/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`,
title: 'Public Share',
});
expect(sharedLinkMock.get).toHaveBeenCalled();
expect(shareMock.get).toHaveBeenCalled();
});
});
});

View File

@@ -1,4 +1,4 @@
import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants';
import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AssetIdsDto } from 'src/dtos/asset.dto';
@@ -14,14 +14,32 @@ import {
import { AssetEntity } from 'src/entities/asset.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { Permission, SharedLinkType } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BaseService } from 'src/services/base.service';
import { checkAccess, requireAccess } from 'src/utils/access';
import { OpenGraphTags } from 'src/utils/misc';
@Injectable()
export class SharedLinkService extends BaseService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
@Inject(ISharedLinkRepository) private repository: ISharedLinkRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(SharedLinkService.name);
}
getAll(auth: AuthDto): Promise<SharedLinkResponseDto[]> {
return this.sharedLinkRepository.getAll(auth.user.id).then((links) => links.map((link) => mapSharedLink(link)));
return this.repository.getAll(auth.user.id).then((links) => links.map((link) => mapSharedLink(link)));
}
async getMine(auth: AuthDto, dto: SharedLinkPasswordDto): Promise<SharedLinkResponseDto> {
@@ -49,7 +67,7 @@ export class SharedLinkService extends BaseService {
if (!dto.albumId) {
throw new BadRequestException('Invalid albumId');
}
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_SHARE, ids: [dto.albumId] });
await requireAccess(this.access, { auth, permission: Permission.ALBUM_SHARE, ids: [dto.albumId] });
break;
}
@@ -58,13 +76,13 @@ export class SharedLinkService extends BaseService {
throw new BadRequestException('Invalid assetIds');
}
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_SHARE, ids: dto.assetIds });
await requireAccess(this.access, { auth, permission: Permission.ASSET_SHARE, ids: dto.assetIds });
break;
}
}
const sharedLink = await this.sharedLinkRepository.create({
const sharedLink = await this.repository.create({
key: this.cryptoRepository.randomBytes(50),
userId: auth.user.id,
type: dto.type,
@@ -83,7 +101,7 @@ export class SharedLinkService extends BaseService {
async update(auth: AuthDto, id: string, dto: SharedLinkEditDto) {
await this.findOrFail(auth.user.id, id);
const sharedLink = await this.sharedLinkRepository.update({
const sharedLink = await this.repository.update({
id,
userId: auth.user.id,
description: dto.description,
@@ -98,12 +116,12 @@ export class SharedLinkService extends BaseService {
async remove(auth: AuthDto, id: string): Promise<void> {
const sharedLink = await this.findOrFail(auth.user.id, id);
await this.sharedLinkRepository.remove(sharedLink);
await this.repository.remove(sharedLink);
}
// TODO: replace `userId` with permissions and access control checks
private async findOrFail(userId: string, id: string) {
const sharedLink = await this.sharedLinkRepository.get(userId, id);
const sharedLink = await this.repository.get(userId, id);
if (!sharedLink) {
throw new BadRequestException('Shared link not found');
}
@@ -119,7 +137,7 @@ export class SharedLinkService extends BaseService {
const existingAssetIds = new Set(sharedLink.assets.map((asset) => asset.id));
const notPresentAssetIds = dto.assetIds.filter((assetId) => !existingAssetIds.has(assetId));
const allowedAssetIds = await checkAccess(this.accessRepository, {
const allowedAssetIds = await checkAccess(this.access, {
auth,
permission: Permission.ASSET_SHARE,
ids: notPresentAssetIds,
@@ -143,7 +161,7 @@ export class SharedLinkService extends BaseService {
sharedLink.assets.push({ id: assetId } as AssetEntity);
}
await this.sharedLinkRepository.update(sharedLink);
await this.repository.update(sharedLink);
return results;
}
@@ -167,7 +185,7 @@ export class SharedLinkService extends BaseService {
sharedLink.assets = sharedLink.assets.filter((asset) => asset.id !== assetId);
}
await this.sharedLinkRepository.update(sharedLink);
await this.repository.update(sharedLink);
return results;
}

View File

@@ -1,6 +1,9 @@
import { SystemConfig } from 'src/config';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@@ -8,20 +11,47 @@ import { SmartInfoService } from 'src/services/smart-info.service';
import { getCLIPModelInfo } from 'src/utils/misc';
import { assetStub } from 'test/fixtures/asset.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { newTestService } from 'test/utils';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock';
import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked } from 'vitest';
describe(SmartInfoService.name, () => {
let sut: SmartInfoService;
let assetMock: Mocked<IAssetRepository>;
let jobMock: Mocked<IJobRepository>;
let machineLearningMock: Mocked<IMachineLearningRepository>;
let searchMock: Mocked<ISearchRepository>;
let configMock: Mocked<IConfigRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let jobMock: Mocked<IJobRepository>;
let searchMock: Mocked<ISearchRepository>;
let machineMock: Mocked<IMachineLearningRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
({ sut, assetMock, jobMock, machineLearningMock, searchMock, systemMock } = newTestService(SmartInfoService));
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
searchMock = newSearchRepositoryMock();
jobMock = newJobRepositoryMock();
machineMock = newMachineLearningRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new SmartInfoService(
assetMock,
configMock,
databaseMock,
jobMock,
machineMock,
searchMock,
systemMock,
loggerMock,
);
assetMock.getByIds.mockResolvedValue([assetStub.image]);
});
@@ -283,7 +313,7 @@ describe(SmartInfoService.name, () => {
expect(await sut.handleEncodeClip({ id: '123' })).toEqual(JobStatus.SKIPPED);
expect(assetMock.getByIds).not.toHaveBeenCalled();
expect(machineLearningMock.encodeImage).not.toHaveBeenCalled();
expect(machineMock.encodeImage).not.toHaveBeenCalled();
});
it('should skip assets without a resize path', async () => {
@@ -292,15 +322,15 @@ describe(SmartInfoService.name, () => {
expect(await sut.handleEncodeClip({ id: assetStub.noResizePath.id })).toEqual(JobStatus.FAILED);
expect(searchMock.upsert).not.toHaveBeenCalled();
expect(machineLearningMock.encodeImage).not.toHaveBeenCalled();
expect(machineMock.encodeImage).not.toHaveBeenCalled();
});
it('should save the returned objects', async () => {
machineLearningMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]);
machineMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]);
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.SUCCESS);
expect(machineLearningMock.encodeImage).toHaveBeenCalledWith(
expect(machineMock.encodeImage).toHaveBeenCalledWith(
'http://immich-machine-learning:3003',
'/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
@@ -313,7 +343,7 @@ describe(SmartInfoService.name, () => {
expect(await sut.handleEncodeClip({ id: assetStub.livePhotoMotionAsset.id })).toEqual(JobStatus.SKIPPED);
expect(machineLearningMock.encodeImage).not.toHaveBeenCalled();
expect(machineMock.encodeImage).not.toHaveBeenCalled();
expect(searchMock.upsert).not.toHaveBeenCalled();
});
});

View File

@@ -1,17 +1,23 @@
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { SystemConfig } from 'src/config';
import { OnEvent } from 'src/decorators';
import { WithoutProperty } from 'src/interfaces/asset.interface';
import { DatabaseLock } from 'src/interfaces/database.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface';
import {
IBaseJob,
IEntityJob,
IJobRepository,
JOBS_ASSET_PAGINATION_SIZE,
JobName,
JobStatus,
QueueName,
} from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BaseService } from 'src/services/base.service';
import { getAssetFiles } from 'src/utils/asset.util';
import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc';
@@ -19,6 +25,20 @@ import { usePagination } from 'src/utils/pagination';
@Injectable()
export class SmartInfoService extends BaseService {
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
@Inject(ISearchRepository) private repository: ISearchRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(SmartInfoService.name);
}
@OnEvent({ name: 'app.bootstrap' })
async onBootstrap(app: ArgOf<'app.bootstrap'>) {
if (app !== 'microservices') {
@@ -52,7 +72,7 @@ export class SmartInfoService extends BaseService {
await this.databaseRepository.withLock(DatabaseLock.CLIPDimSize, async () => {
const { dimSize } = getCLIPModelInfo(newConfig.machineLearning.clip.modelName);
const dbDimSize = await this.searchRepository.getDimensionSize();
const dbDimSize = await this.repository.getDimensionSize();
this.logger.verbose(`Current database CLIP dimension size is ${dbDimSize}`);
const modelChange =
@@ -73,10 +93,10 @@ export class SmartInfoService extends BaseService {
`Dimension size of model ${newConfig.machineLearning.clip.modelName} is ${dimSize}, but database expects ${dbDimSize}.`,
);
this.logger.log(`Updating database CLIP dimension size to ${dimSize}.`);
await this.searchRepository.setDimensionSize(dimSize);
await this.repository.setDimensionSize(dimSize);
this.logger.log(`Successfully updated database CLIP dimension size from ${dbDimSize} to ${dimSize}.`);
} else {
await this.searchRepository.deleteAllSearchEmbeddings();
await this.repository.deleteAllSearchEmbeddings();
}
if (!isPaused) {
@@ -92,7 +112,7 @@ export class SmartInfoService extends BaseService {
}
if (force) {
await this.searchRepository.deleteAllSearchEmbeddings();
await this.repository.deleteAllSearchEmbeddings();
}
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
@@ -130,7 +150,7 @@ export class SmartInfoService extends BaseService {
return JobStatus.FAILED;
}
const embedding = await this.machineLearningRepository.encodeImage(
const embedding = await this.machineLearning.encodeImage(
machineLearning.url,
previewFile.path,
machineLearning.clip,
@@ -141,7 +161,7 @@ export class SmartInfoService extends BaseService {
await this.databaseRepository.wait(DatabaseLock.CLIPDimSize);
}
await this.searchRepository.upsert(asset.id, embedding);
await this.repository.upsert(asset.id, embedding);
return JobStatus.SUCCESS;
}

View File

@@ -1,13 +1,21 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { StackCreateDto, StackResponseDto, StackSearchDto, StackUpdateDto, mapStack } from 'src/dtos/stack.dto';
import { Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IStackRepository } from 'src/interfaces/stack.interface';
import { requireAccess } from 'src/utils/access';
@Injectable()
export class StackService extends BaseService {
export class StackService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IStackRepository) private stackRepository: IStackRepository,
) {}
async search(auth: AuthDto, dto: StackSearchDto): Promise<StackResponseDto[]> {
const stacks = await this.stackRepository.search({
ownerId: auth.user.id,
@@ -18,7 +26,7 @@ export class StackService extends BaseService {
}
async create(auth: AuthDto, dto: StackCreateDto): Promise<StackResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds });
await requireAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds });
const stack = await this.stackRepository.create({ ownerId: auth.user.id, assetIds: dto.assetIds });
@@ -28,13 +36,13 @@ export class StackService extends BaseService {
}
async get(auth: AuthDto, id: string): Promise<StackResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.STACK_READ, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.STACK_READ, ids: [id] });
const stack = await this.findOrFail(id);
return mapStack(stack, { auth });
}
async update(auth: AuthDto, id: string, dto: StackUpdateDto): Promise<StackResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.STACK_UPDATE, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.STACK_UPDATE, ids: [id] });
const stack = await this.findOrFail(id);
if (dto.primaryAssetId && !stack.assets.some(({ id }) => id === dto.primaryAssetId)) {
throw new BadRequestException('Primary asset must be in the stack');
@@ -48,13 +56,13 @@ export class StackService extends BaseService {
}
async delete(auth: AuthDto, id: string): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.STACK_DELETE, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.STACK_DELETE, ids: [id] });
await this.stackRepository.delete(id);
await this.eventRepository.emit('stack.delete', { stackId: id, userId: auth.user.id });
}
async deleteAll(auth: AuthDto, dto: BulkIdsDto): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.STACK_DELETE, ids: dto.ids });
await requireAccess(this.access, { auth, permission: Permission.STACK_DELETE, ids: dto.ids });
await this.stackRepository.deleteAll(dto.ids);
await this.eventRepository.emit('stacks.delete', { stackIds: dto.ids, userId: auth.user.id });
}

View File

@@ -4,9 +4,13 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { AssetPathType } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
@@ -14,30 +18,66 @@ import { StorageTemplateService } from 'src/services/storage-template.service';
import { albumStub } from 'test/fixtures/album.stub';
import { assetStub } from 'test/fixtures/asset.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newTestService } from 'test/utils';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest';
describe(StorageTemplateService.name, () => {
let sut: StorageTemplateService;
let albumMock: Mocked<IAlbumRepository>;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let moveMock: Mocked<IMoveRepository>;
let personMock: Mocked<IPersonRepository>;
let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let userMock: Mocked<IUserRepository>;
let loggerMock: Mocked<ILoggerRepository>;
it('should work', () => {
expect(sut).toBeDefined();
});
beforeEach(() => {
({ sut, albumMock, assetMock, cryptoMock, moveMock, storageMock, systemMock, userMock } =
newTestService(StorageTemplateService));
assetMock = newAssetRepositoryMock();
albumMock = newAlbumRepositoryMock();
configMock = newConfigRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
moveMock = newMoveRepositoryMock();
personMock = newPersonRepositoryMock();
storageMock = newStorageRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
systemMock.get.mockResolvedValue({ storageTemplate: { enabled: true } });
sut = new StorageTemplateService(
albumMock,
assetMock,
configMock,
systemMock,
moveMock,
personMock,
storageMock,
userMock,
cryptoMock,
databaseMock,
loggerMock,
);
sut.onConfigUpdate({ newConfig: defaults });
});

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import handlebar from 'handlebars';
import { DateTime } from 'luxon';
import path from 'node:path';
@@ -16,9 +16,19 @@ import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetPathType, AssetType, StorageFolder } from 'src/enum';
import { DatabaseLock } from 'src/interfaces/database.interface';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface';
import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { BaseService } from 'src/services/base.service';
import { getLivePhotoMotionFilename } from 'src/utils/file';
import { usePagination } from 'src/utils/pagination';
@@ -37,6 +47,7 @@ interface RenderMetadata {
@Injectable()
export class StorageTemplateService extends BaseService {
private storageCore: StorageCore;
private _template: {
compiled: HandlebarsTemplateDelegate<any>;
raw: string;
@@ -50,6 +61,33 @@ export class StorageTemplateService extends BaseService {
return this._template;
}
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IPersonRepository) personRepository: IPersonRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(StorageTemplateService.name);
this.storageCore = StorageCore.create(
assetRepository,
configRepository,
cryptoRepository,
moveRepository,
personRepository,
storageRepository,
systemMetadataRepository,
this.logger,
);
}
@OnEvent({ name: 'config.update', server: true })
onConfigUpdate({ newConfig }: ArgOf<'config.update'>) {
const template = newConfig.storageTemplate.template;

View File

@@ -1,21 +1,33 @@
import { SystemMetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { StorageService } from 'src/services/storage.service';
import { mockEnvData } from 'test/repositories/config.repository.mock';
import { newTestService } from 'test/utils';
import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked } from 'vitest';
describe(StorageService.name, () => {
let sut: StorageService;
let configMock: Mocked<IConfigRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let storageMock: Mocked<IStorageRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
beforeEach(() => {
({ sut, configMock, storageMock, systemMock } = newTestService(StorageService));
configMock = newConfigRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
storageMock = newStorageRepositoryMock();
loggerMock = newLoggerRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
sut = new StorageService(configMock, databaseMock, storageMock, loggerMock, systemMock);
});
it('should work', () => {

View File

@@ -1,23 +1,36 @@
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { join } from 'node:path';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators';
import { StorageFolder, SystemMetadataKey } from 'src/enum';
import { DatabaseLock } from 'src/interfaces/database.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ImmichStartupError } from 'src/utils/events';
const docsMessage = `Please see https://immich.app/docs/administration/system-integrity#folder-checks for more information.`;
@Injectable()
export class StorageService extends BaseService {
export class StorageService {
constructor(
@Inject(IConfigRepository) private configRepository: IConfigRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(ISystemMetadataRepository) private systemMetadata: ISystemMetadataRepository,
) {
this.logger.setContext(StorageService.name);
}
@OnEvent({ name: 'app.bootstrap' })
async onBootstrap() {
const envData = this.configRepository.getEnv();
await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
const flags = (await this.systemMetadataRepository.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false };
const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false };
const enabled = flags.mountFiles ?? false;
this.logger.log(`Verifying system mount folder checks (enabled=${enabled})`);
@@ -36,7 +49,7 @@ export class StorageService extends BaseService {
if (!flags.mountFiles) {
flags.mountFiles = true;
await this.systemMetadataRepository.set(SystemMetadataKey.SYSTEM_FLAGS, flags);
await this.systemMetadata.set(SystemMetadataKey.SYSTEM_FLAGS, flags);
this.logger.log('Successfully enabled system mount folders checks');
}

View File

@@ -1,5 +1,6 @@
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
@@ -7,7 +8,10 @@ import { SyncService } from 'src/services/sync.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { partnerStub } from 'test/fixtures/partner.stub';
import { newTestService } from 'test/utils';
import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newAuditRepositoryMock } from 'test/repositories/audit.repository.mock';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
import { Mocked } from 'vitest';
const untilDate = new Date(2024);
@@ -15,13 +19,17 @@ const mapAssetOpts = { auth: authStub.user1, stripMetadata: false, withStack: tr
describe(SyncService.name, () => {
let sut: SyncService;
let accessMock: Mocked<IAccessRepository>;
let assetMock: Mocked<IAssetRepository>;
let auditMock: Mocked<IAuditRepository>;
let partnerMock: Mocked<IPartnerRepository>;
let auditMock: Mocked<IAuditRepository>;
beforeEach(() => {
({ sut, assetMock, auditMock, partnerMock } = newTestService(SyncService));
partnerMock = newPartnerRepositoryMock();
assetMock = newAssetRepositoryMock();
accessMock = newAccessRepositoryMock();
auditMock = newAuditRepositoryMock();
sut = new SyncService(accessMock, assetMock, partnerMock, auditMock);
});
it('should exist', () => {

View File

@@ -1,21 +1,32 @@
import { Inject } from '@nestjs/common';
import { DateTime } from 'luxon';
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetDeltaSyncDto, AssetDeltaSyncResponseDto, AssetFullSyncDto } from 'src/dtos/sync.dto';
import { DatabaseAction, EntityType, Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { requireAccess } from 'src/utils/access';
import { getMyPartnerIds } from 'src/utils/asset.util';
import { setIsEqual } from 'src/utils/set';
const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] };
export class SyncService extends BaseService {
export class SyncService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
@Inject(IAuditRepository) private auditRepository: IAuditRepository,
) {}
async getFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise<AssetResponseDto[]> {
// mobile implementation is faster if this is a single id
const userId = dto.userId || auth.user.id;
await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_READ, ids: [userId] });
await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: [userId] });
const assets = await this.assetRepository.getAllForUserFullSync({
ownerId: userId,
updatedUntil: dto.updatedUntil,
@@ -39,7 +50,7 @@ export class SyncService extends BaseService {
return FULL_SYNC;
}
await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_READ, ids: dto.userIds });
await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: dto.userIds });
const limit = 10_000;
const upserted = await this.assetRepository.getChangedDeltaSync({ limit, updatedAfter: dto.updatedAfter, userIds });

View File

@@ -18,8 +18,10 @@ import { QueueName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { SystemConfigService } from 'src/services/system-config.service';
import { mockEnvData } from 'test/repositories/config.repository.mock';
import { newTestService } from 'test/utils';
import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { DeepPartial } from 'typeorm';
import { Mocked } from 'vitest';
@@ -187,14 +189,18 @@ const updatedConfig = Object.freeze<SystemConfig>({
describe(SystemConfigService.name, () => {
let sut: SystemConfigService;
let configMock: Mocked<IConfigRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let eventMock: Mocked<IEventRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
beforeEach(() => {
({ sut, configMock, eventMock, loggerMock, systemMock } = newTestService(SystemConfigService));
configMock = newConfigRepositoryMock();
eventMock = newEventRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new SystemConfigService(configMock, eventMock, systemMock, loggerMock);
});
it('should work', () => {

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { instanceToPlain } from 'class-transformer';
import _ from 'lodash';
import { defaults } from 'src/config';
@@ -14,13 +14,27 @@ import {
} from 'src/constants';
import { OnEvent } from 'src/decorators';
import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto';
import { ArgOf } from 'src/interfaces/event.interface';
import { LogLevel } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BaseService } from 'src/services/base.service';
import { clearConfigCache } from 'src/utils/config';
import { toPlainObject } from 'src/utils/object';
@Injectable()
export class SystemConfigService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(SystemConfigService.name);
}
@OnEvent({ name: 'app.bootstrap', priority: -100 })
async onBootstrap() {
const config = await this.getConfig({ withCache: false });
@@ -38,7 +52,7 @@ export class SystemConfigService extends BaseService {
@OnEvent({ name: 'config.update', server: true })
onConfigUpdate({ newConfig: { logging } }: ArgOf<'config.update'>) {
const { logLevel: envLevel } = this.configRepository.getEnv();
const envLevel = this.getEnvLogLevel();
const configLevel = logging.enabled ? logging.level : false;
const level = envLevel ?? configLevel;
this.logger.setLogLevel(level);
@@ -49,8 +63,7 @@ export class SystemConfigService extends BaseService {
@OnEvent({ name: 'config.validate' })
onConfigValidate({ newConfig, oldConfig }: ArgOf<'config.validate'>) {
const { logLevel } = this.configRepository.getEnv();
if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && logLevel) {
if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) {
throw new Error('Logging cannot be changed while the environment variable IMMICH_LOG_LEVEL is set.');
}
}
@@ -96,4 +109,8 @@ export class SystemConfigService extends BaseService {
const { theme } = await this.getConfig({ withCache: false });
return theme.customCss;
}
private getEnvLogLevel() {
return process.env.IMMICH_LOG_LEVEL as LogLevel;
}
}

View File

@@ -1,15 +1,16 @@
import { SystemMetadataKey } from 'src/enum';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { SystemMetadataService } from 'src/services/system-metadata.service';
import { newTestService } from 'test/utils';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked } from 'vitest';
describe(SystemMetadataService.name, () => {
let sut: SystemMetadataService;
let systemMock: Mocked<ISystemMetadataRepository>;
let metadataMock: Mocked<ISystemMetadataRepository>;
beforeEach(() => {
({ sut, systemMock } = newTestService(SystemMetadataService));
metadataMock = newSystemMetadataRepositoryMock();
sut = new SystemMetadataService(metadataMock);
});
it('should work', () => {
@@ -19,12 +20,12 @@ describe(SystemMetadataService.name, () => {
describe('updateAdminOnboarding', () => {
it('should update isOnboarded to true', async () => {
await expect(sut.updateAdminOnboarding({ isOnboarded: true })).resolves.toBeUndefined();
expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true });
expect(metadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true });
});
it('should update isOnboarded to false', async () => {
await expect(sut.updateAdminOnboarding({ isOnboarded: false })).resolves.toBeUndefined();
expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false });
expect(metadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false });
});
});
});

View File

@@ -1,27 +1,29 @@
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import {
AdminOnboardingResponseDto,
AdminOnboardingUpdateDto,
ReverseGeocodingStateResponseDto,
} from 'src/dtos/system-metadata.dto';
import { SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@Injectable()
export class SystemMetadataService extends BaseService {
export class SystemMetadataService {
constructor(@Inject(ISystemMetadataRepository) private repository: ISystemMetadataRepository) {}
async getAdminOnboarding(): Promise<AdminOnboardingResponseDto> {
const value = await this.systemMetadataRepository.get(SystemMetadataKey.ADMIN_ONBOARDING);
const value = await this.repository.get(SystemMetadataKey.ADMIN_ONBOARDING);
return { isOnboarded: false, ...value };
}
async updateAdminOnboarding(dto: AdminOnboardingUpdateDto): Promise<void> {
await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, {
await this.repository.set(SystemMetadataKey.ADMIN_ONBOARDING, {
isOnboarded: dto.isOnboarded,
});
}
async getReverseGeocodingState(): Promise<ReverseGeocodingStateResponseDto> {
const value = await this.systemMetadataRepository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE);
const value = await this.repository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE);
return { lastUpdate: null, lastImportFileName: null, ...value };
}
}

View File

@@ -1,21 +1,26 @@
import { BadRequestException } from '@nestjs/common';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { TagService } from 'src/services/tag.service';
import { authStub } from 'test/fixtures/auth.stub';
import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newTagRepositoryMock } from 'test/repositories/tag.repository.mock';
import { Mocked } from 'vitest';
describe(TagService.name, () => {
let sut: TagService;
let accessMock: IAccessRepositoryMock;
let eventMock: Mocked<IEventRepository>;
let tagMock: Mocked<ITagRepository>;
beforeEach(() => {
({ sut, accessMock, tagMock } = newTestService(TagService));
accessMock = newAccessRepositoryMock();
eventMock = newEventRepositoryMock();
tagMock = newTagRepositoryMock();
sut = new TagService(accessMock, eventMock, tagMock);
accessMock.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1']));
});

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@@ -12,22 +12,29 @@ import {
} from 'src/dtos/tag.dto';
import { TagEntity } from 'src/entities/tag.entity';
import { Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { JobStatus } from 'src/interfaces/job.interface';
import { AssetTagItem } from 'src/interfaces/tag.interface';
import { BaseService } from 'src/services/base.service';
import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface';
import { checkAccess, requireAccess } from 'src/utils/access';
import { addAssets, removeAssets } from 'src/utils/asset.util';
import { upsertTags } from 'src/utils/tag';
@Injectable()
export class TagService extends BaseService {
export class TagService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ITagRepository) private repository: ITagRepository,
) {}
async getAll(auth: AuthDto) {
const tags = await this.tagRepository.getAll(auth.user.id);
const tags = await this.repository.getAll(auth.user.id);
return tags.map((tag) => mapTag(tag));
}
async get(auth: AuthDto, id: string): Promise<TagResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_READ, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [id] });
const tag = await this.findOrFail(id);
return mapTag(tag);
}
@@ -35,8 +42,8 @@ export class TagService extends BaseService {
async create(auth: AuthDto, dto: TagCreateDto) {
let parent: TagEntity | undefined;
if (dto.parentId) {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_READ, ids: [dto.parentId] });
parent = (await this.tagRepository.get(dto.parentId)) || undefined;
await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [dto.parentId] });
parent = (await this.repository.get(dto.parentId)) || undefined;
if (!parent) {
throw new BadRequestException('Tag not found');
}
@@ -44,41 +51,41 @@ export class TagService extends BaseService {
const userId = auth.user.id;
const value = parent ? `${parent.value}/${dto.name}` : dto.name;
const duplicate = await this.tagRepository.getByValue(userId, value);
const duplicate = await this.repository.getByValue(userId, value);
if (duplicate) {
throw new BadRequestException(`A tag with that name already exists`);
}
const tag = await this.tagRepository.create({ userId, value, parent });
const tag = await this.repository.create({ userId, value, parent });
return mapTag(tag);
}
async update(auth: AuthDto, id: string, dto: TagUpdateDto): Promise<TagResponseDto> {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_UPDATE, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.TAG_UPDATE, ids: [id] });
const { color } = dto;
const tag = await this.tagRepository.update({ id, color });
const tag = await this.repository.update({ id, color });
return mapTag(tag);
}
async upsert(auth: AuthDto, dto: TagUpsertDto) {
const tags = await upsertTags(this.tagRepository, { userId: auth.user.id, tags: dto.tags });
const tags = await upsertTags(this.repository, { userId: auth.user.id, tags: dto.tags });
return tags.map((tag) => mapTag(tag));
}
async remove(auth: AuthDto, id: string): Promise<void> {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_DELETE, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.TAG_DELETE, ids: [id] });
// TODO sync tag changes for affected assets
await this.tagRepository.delete(id);
await this.repository.delete(id);
}
async bulkTagAssets(auth: AuthDto, dto: TagBulkAssetsDto): Promise<TagBulkAssetsResponseDto> {
const [tagIds, assetIds] = await Promise.all([
checkAccess(this.accessRepository, { auth, permission: Permission.TAG_ASSET, ids: dto.tagIds }),
checkAccess(this.accessRepository, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }),
checkAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: dto.tagIds }),
checkAccess(this.access, { auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds }),
]);
const items: AssetTagItem[] = [];
@@ -88,7 +95,7 @@ export class TagService extends BaseService {
}
}
const results = await this.tagRepository.upsertAssetIds(items);
const results = await this.repository.upsertAssetIds(items);
for (const assetId of new Set(results.map((item) => item.assetId))) {
await this.eventRepository.emit('asset.tag', { assetId });
}
@@ -97,11 +104,11 @@ export class TagService extends BaseService {
}
async addAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_ASSET, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: [id] });
const results = await addAssets(
auth,
{ access: this.accessRepository, bulk: this.tagRepository },
{ access: this.access, bulk: this.repository },
{ parentId: id, assetIds: dto.ids },
);
@@ -115,11 +122,11 @@ export class TagService extends BaseService {
}
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_ASSET, ids: [id] });
await requireAccess(this.access, { auth, permission: Permission.TAG_ASSET, ids: [id] });
const results = await removeAssets(
auth,
{ access: this.accessRepository, bulk: this.tagRepository },
{ access: this.access, bulk: this.repository },
{ parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.TAG_DELETE },
);
@@ -133,12 +140,12 @@ export class TagService extends BaseService {
}
async handleTagCleanup() {
await this.tagRepository.deleteEmptyTags();
await this.repository.deleteEmptyTags();
return JobStatus.SUCCESS;
}
private async findOrFail(id: string) {
const tag = await this.tagRepository.get(id);
const tag = await this.repository.get(id);
if (!tag) {
throw new BadRequestException('Tag not found');
}

View File

@@ -1,20 +1,25 @@
import { BadRequestException } from '@nestjs/common';
import { IAssetRepository, TimeBucketSize } from 'src/interfaces/asset.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { TimelineService } from 'src/services/timeline.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
import { Mocked } from 'vitest';
describe(TimelineService.name, () => {
let sut: TimelineService;
let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>;
let partnerMock: Mocked<IPartnerRepository>;
beforeEach(() => {
({ sut, accessMock, assetMock } = newTestService(TimelineService));
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
partnerMock = newPartnerRepositoryMock();
sut = new TimelineService(accessMock, assetMock, partnerMock);
});
describe('getTimeBuckets', () => {

View File

@@ -1,18 +1,26 @@
import { BadRequestException } from '@nestjs/common';
import { BadRequestException, Inject } from '@nestjs/common';
import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto';
import { Permission } from 'src/enum';
import { TimeBucketOptions } from 'src/interfaces/asset.interface';
import { BaseService } from 'src/services/base.service';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository, TimeBucketOptions } from 'src/interfaces/asset.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { requireAccess } from 'src/utils/access';
import { getMyPartnerIds } from 'src/utils/asset.util';
export class TimelineService extends BaseService {
export class TimelineService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAssetRepository) private repository: IAssetRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
) {}
async getTimeBuckets(auth: AuthDto, dto: TimeBucketDto): Promise<TimeBucketResponseDto[]> {
await this.timeBucketChecks(auth, dto);
const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto);
return this.assetRepository.getTimeBuckets(timeBucketOptions);
return this.repository.getTimeBuckets(timeBucketOptions);
}
async getTimeBucket(
@@ -21,7 +29,7 @@ export class TimelineService extends BaseService {
): Promise<AssetResponseDto[] | SanitizedAssetResponseDto[]> {
await this.timeBucketChecks(auth, dto);
const timeBucketOptions = await this.buildTimeBucketOptions(auth, dto);
const assets = await this.assetRepository.getTimeBucket(dto.timeBucket, timeBucketOptions);
const assets = await this.repository.getTimeBucket(dto.timeBucket, timeBucketOptions);
return !auth.sharedLink || auth.sharedLink?.showExif
? assets.map((asset) => mapAsset(asset, { withStack: true, auth }))
: assets.map((asset) => mapAsset(asset, { stripMetadata: true, auth }));
@@ -48,20 +56,20 @@ export class TimelineService extends BaseService {
private async timeBucketChecks(auth: AuthDto, dto: TimeBucketDto) {
if (dto.albumId) {
await requireAccess(this.accessRepository, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] });
await requireAccess(this.access, { auth, permission: Permission.ALBUM_READ, ids: [dto.albumId] });
} else {
dto.userId = dto.userId || auth.user.id;
}
if (dto.userId) {
await requireAccess(this.accessRepository, { auth, permission: Permission.TIMELINE_READ, ids: [dto.userId] });
await requireAccess(this.access, { auth, permission: Permission.TIMELINE_READ, ids: [dto.userId] });
if (dto.isArchived !== false) {
await requireAccess(this.accessRepository, { auth, permission: Permission.ARCHIVE_READ, ids: [dto.userId] });
await requireAccess(this.access, { auth, permission: Permission.ARCHIVE_READ, ids: [dto.userId] });
}
}
if (dto.tagId) {
await requireAccess(this.accessRepository, { auth, permission: Permission.TAG_READ, ids: [dto.tagId] });
await requireAccess(this.access, { auth, permission: Permission.TAG_READ, ids: [dto.tagId] });
}
if (dto.withPartners) {

View File

@@ -1,25 +1,37 @@
import { BadRequestException } from '@nestjs/common';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ITrashRepository } from 'src/interfaces/trash.interface';
import { TrashService } from 'src/services/trash.service';
import { authStub } from 'test/fixtures/auth.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newTrashRepositoryMock } from 'test/repositories/trash.repository.mock';
import { Mocked } from 'vitest';
describe(TrashService.name, () => {
let sut: TrashService;
let accessMock: IAccessRepositoryMock;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>;
let trashMock: Mocked<ITrashRepository>;
let loggerMock: Mocked<ILoggerRepository>;
it('should work', () => {
expect(sut).toBeDefined();
});
beforeEach(() => {
({ sut, accessMock, jobMock, trashMock } = newTestService(TrashService));
accessMock = newAccessRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
trashMock = newTrashRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new TrashService(accessMock, eventMock, jobMock, trashMock, loggerMock);
});
describe('restoreAssets', () => {

View File

@@ -1,21 +1,35 @@
import { Inject } from '@nestjs/common';
import { OnEvent } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { TrashResponseDto } from 'src/dtos/trash.dto';
import { Permission } from 'src/enum';
import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ITrashRepository } from 'src/interfaces/trash.interface';
import { requireAccess } from 'src/utils/access';
import { usePagination } from 'src/utils/pagination';
export class TrashService extends BaseService {
export class TrashService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ITrashRepository) private trashRepository: ITrashRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(TrashService.name);
}
async restoreAssets(auth: AuthDto, dto: BulkIdsDto): Promise<TrashResponseDto> {
const { ids } = dto;
if (ids.length === 0) {
return { count: 0 };
}
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DELETE, ids });
await requireAccess(this.access, { auth, permission: Permission.ASSET_DELETE, ids });
await this.trashRepository.restoreAll(ids);
await this.eventRepository.emit('assets.restore', { assetIds: ids, userId: auth.user.id });

View File

@@ -1,22 +1,41 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { mapUserAdmin } from 'src/dtos/user.dto';
import { UserStatus } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { UserAdminService } from 'src/services/user-admin.service';
import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newTestService } from 'test/utils';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked, describe } from 'vitest';
describe(UserAdminService.name, () => {
let sut: UserAdminService;
let albumMock: Mocked<IAlbumRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let userMock: Mocked<IUserRepository>;
beforeEach(() => {
({ sut, jobMock, userMock } = newTestService(UserAdminService));
albumMock = newAlbumRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new UserAdminService(albumMock, cryptoMock, eventMock, jobMock, userMock, loggerMock);
userMock.get.mockImplementation((userId) =>
Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? null),

Some files were not shown because too many files have changed in this diff Show More