mirror of
https://github.com/immich-app/immich.git
synced 2026-02-13 04:17:56 +03:00
feat: workflow foundation (#23621)
* feat: plugins * feat: table definition * feat: type and migration * feat: add repositories * feat: validate manifest with class-validator and load manifest info to database * feat: workflow/plugin controller/service layer * feat: implement workflow logic * feat: make trigger static * feat: dynamical instantiate plugin instances * fix: access control and helper script * feat: it works * chore: simplify * refactor: refactor and use queue for workflow execution * refactor: remove unsused property in plugin-schema * build wasm in prod * feat: plugin loader in transaction * fix: docker build arm64 * generated files * shell check * fix tests * fix: waiting for migration to finish before loading plugin * remove context reassignment * feat: use mise to manage extism tools (#23760) * pr feedback * refactor: create workflow now including create filters and actions * feat: workflow medium tests * fix: broken medium test * feat: medium tests * chore: unify workflow job * sign user id with jwt * chore: query plugin with filters and action * chore: read manifest in repository * chore: load manifest from server configs * merge main * feat: endpoint documentation * pr feedback * load plugin from absolute path * refactor:handle trigger * throw error and return early * pr feedback * unify plugin services * fix: plugins code * clean up * remove triggerConfig * clean up * displayName and methodName --------- Co-authored-by: Jason Rasmussen <jason@rasm.me> Co-authored-by: bo0tzz <git@bo0tzz.me>
This commit is contained in:
@@ -235,6 +235,7 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
[QueueName.VideoConversion]: { concurrency: 1 },
|
||||
[QueueName.Notification]: { concurrency: 5 },
|
||||
[QueueName.Ocr]: { concurrency: 1 },
|
||||
[QueueName.Workflow]: { concurrency: 5 },
|
||||
},
|
||||
logging: {
|
||||
enabled: true,
|
||||
|
||||
@@ -160,6 +160,8 @@ export const endpointTags: Record<ApiTag, string> = {
|
||||
[ApiTag.Partners]: 'A partner is a link with another user that allows sharing of assets between two users.',
|
||||
[ApiTag.People]:
|
||||
'A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job.',
|
||||
[ApiTag.Plugins]:
|
||||
'A plugin is an installed module that makes filters and actions available for the workflow feature.',
|
||||
[ApiTag.Search]:
|
||||
'Endpoints related to searching assets via text, smart search, optical character recognition (OCR), and other filters like person, album, and other metadata. Search endpoints usually support pagination and sorting.',
|
||||
[ApiTag.Server]:
|
||||
@@ -185,4 +187,6 @@ export const endpointTags: Record<ApiTag, string> = {
|
||||
[ApiTag.Users]:
|
||||
'Endpoints for viewing and updating the current users, including product key information, profile picture data, onboarding progress, and more.',
|
||||
[ApiTag.Views]: 'Endpoints for specialized views, such as the folder view.',
|
||||
[ApiTag.Workflows]:
|
||||
'A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution.',
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import { NotificationController } from 'src/controllers/notification.controller'
|
||||
import { OAuthController } from 'src/controllers/oauth.controller';
|
||||
import { PartnerController } from 'src/controllers/partner.controller';
|
||||
import { PersonController } from 'src/controllers/person.controller';
|
||||
import { PluginController } from 'src/controllers/plugin.controller';
|
||||
import { SearchController } from 'src/controllers/search.controller';
|
||||
import { ServerController } from 'src/controllers/server.controller';
|
||||
import { SessionController } from 'src/controllers/session.controller';
|
||||
@@ -32,6 +33,7 @@ import { TrashController } from 'src/controllers/trash.controller';
|
||||
import { UserAdminController } from 'src/controllers/user-admin.controller';
|
||||
import { UserController } from 'src/controllers/user.controller';
|
||||
import { ViewController } from 'src/controllers/view.controller';
|
||||
import { WorkflowController } from 'src/controllers/workflow.controller';
|
||||
|
||||
export const controllers = [
|
||||
ApiKeyController,
|
||||
@@ -54,6 +56,7 @@ export const controllers = [
|
||||
OAuthController,
|
||||
PartnerController,
|
||||
PersonController,
|
||||
PluginController,
|
||||
SearchController,
|
||||
ServerController,
|
||||
SessionController,
|
||||
@@ -68,4 +71,5 @@ export const controllers = [
|
||||
UserAdminController,
|
||||
UserController,
|
||||
ViewController,
|
||||
WorkflowController,
|
||||
];
|
||||
|
||||
36
server/src/controllers/plugin.controller.ts
Normal file
36
server/src/controllers/plugin.controller.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { PluginResponseDto } from 'src/dtos/plugin.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { Authenticated } from 'src/middleware/auth.guard';
|
||||
import { PluginService } from 'src/services/plugin.service';
|
||||
import { UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags('Plugins')
|
||||
@Controller('plugins')
|
||||
export class PluginController {
|
||||
constructor(private service: PluginService) {}
|
||||
|
||||
@Get()
|
||||
@Authenticated({ permission: Permission.PluginRead })
|
||||
@Endpoint({
|
||||
summary: 'List all plugins',
|
||||
description: 'Retrieve a list of plugins available to the authenticated user.',
|
||||
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
||||
})
|
||||
getPlugins(): Promise<PluginResponseDto[]> {
|
||||
return this.service.getAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Authenticated({ permission: Permission.PluginRead })
|
||||
@Endpoint({
|
||||
summary: 'Retrieve a plugin',
|
||||
description: 'Retrieve information about a specific plugin by its ID.',
|
||||
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
||||
})
|
||||
getPlugin(@Param() { id }: UUIDParamDto): Promise<PluginResponseDto> {
|
||||
return this.service.get(id);
|
||||
}
|
||||
}
|
||||
76
server/src/controllers/workflow.controller.ts
Normal file
76
server/src/controllers/workflow.controller.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { WorkflowCreateDto, WorkflowResponseDto, WorkflowUpdateDto } from 'src/dtos/workflow.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { WorkflowService } from 'src/services/workflow.service';
|
||||
import { UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags('Workflows')
|
||||
@Controller('workflows')
|
||||
export class WorkflowController {
|
||||
constructor(private service: WorkflowService) {}
|
||||
|
||||
@Post()
|
||||
@Authenticated({ permission: Permission.WorkflowCreate })
|
||||
@Endpoint({
|
||||
summary: 'Create a workflow',
|
||||
description: 'Create a new workflow, the workflow can also be created with empty filters and actions.',
|
||||
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
||||
})
|
||||
createWorkflow(@Auth() auth: AuthDto, @Body() dto: WorkflowCreateDto): Promise<WorkflowResponseDto> {
|
||||
return this.service.create(auth, dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Authenticated({ permission: Permission.WorkflowRead })
|
||||
@Endpoint({
|
||||
summary: 'List all workflows',
|
||||
description: 'Retrieve a list of workflows available to the authenticated user.',
|
||||
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
||||
})
|
||||
getWorkflows(@Auth() auth: AuthDto): Promise<WorkflowResponseDto[]> {
|
||||
return this.service.getAll(auth);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Authenticated({ permission: Permission.WorkflowRead })
|
||||
@Endpoint({
|
||||
summary: 'Retrieve a workflow',
|
||||
description: 'Retrieve information about a specific workflow by its ID.',
|
||||
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
||||
})
|
||||
getWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<WorkflowResponseDto> {
|
||||
return this.service.get(auth, id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@Authenticated({ permission: Permission.WorkflowUpdate })
|
||||
@Endpoint({
|
||||
summary: 'Update a workflow',
|
||||
description:
|
||||
'Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.',
|
||||
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
||||
})
|
||||
updateWorkflow(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: WorkflowUpdateDto,
|
||||
): Promise<WorkflowResponseDto> {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Authenticated({ permission: Permission.WorkflowDelete })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Endpoint({
|
||||
summary: 'Delete a workflow',
|
||||
description: 'Delete a workflow by its ID.',
|
||||
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
||||
})
|
||||
deleteWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.delete(auth, id);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
AssetVisibility,
|
||||
MemoryType,
|
||||
Permission,
|
||||
PluginContext,
|
||||
PluginTriggerType,
|
||||
SharedLinkType,
|
||||
SourceType,
|
||||
UserAvatarColor,
|
||||
@@ -14,7 +16,10 @@ import {
|
||||
} from 'src/enum';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table';
|
||||
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
|
||||
import { UserMetadataItem } from 'src/types';
|
||||
import type { ActionConfig, FilterConfig, JSONSchema } from 'src/types/plugin-schema.types';
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
@@ -277,6 +282,45 @@ export type AssetFace = {
|
||||
updateId: string;
|
||||
};
|
||||
|
||||
export type Plugin = Selectable<PluginTable>;
|
||||
|
||||
export type PluginFilter = Selectable<PluginFilterTable> & {
|
||||
methodName: string;
|
||||
title: string;
|
||||
description: string;
|
||||
supportedContexts: PluginContext[];
|
||||
schema: JSONSchema | null;
|
||||
};
|
||||
|
||||
export type PluginAction = Selectable<PluginActionTable> & {
|
||||
methodName: string;
|
||||
title: string;
|
||||
description: string;
|
||||
supportedContexts: PluginContext[];
|
||||
schema: JSONSchema | null;
|
||||
};
|
||||
|
||||
export type Workflow = Selectable<WorkflowTable> & {
|
||||
triggerType: PluginTriggerType;
|
||||
name: string | null;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowFilter = Selectable<WorkflowFilterTable> & {
|
||||
workflowId: string;
|
||||
filterId: string;
|
||||
filterConfig: FilterConfig | null;
|
||||
order: number;
|
||||
};
|
||||
|
||||
export type WorkflowAction = Selectable<WorkflowActionTable> & {
|
||||
workflowId: string;
|
||||
actionId: string;
|
||||
actionConfig: ActionConfig | null;
|
||||
order: number;
|
||||
};
|
||||
|
||||
const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const;
|
||||
const userWithPrefixColumns = [
|
||||
'user2.id',
|
||||
@@ -418,4 +462,15 @@ export const columns = {
|
||||
'asset_exif.state',
|
||||
'asset_exif.timeZone',
|
||||
],
|
||||
plugin: [
|
||||
'plugin.id as id',
|
||||
'plugin.name as name',
|
||||
'plugin.title as title',
|
||||
'plugin.description as description',
|
||||
'plugin.author as author',
|
||||
'plugin.version as version',
|
||||
'plugin.wasmPath as wasmPath',
|
||||
'plugin.createdAt as createdAt',
|
||||
'plugin.updatedAt as updatedAt',
|
||||
],
|
||||
} as const;
|
||||
|
||||
@@ -57,6 +57,13 @@ export class EnvDto {
|
||||
@Type(() => Number)
|
||||
IMMICH_MICROSERVICES_METRICS_PORT?: number;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
IMMICH_PLUGINS_ENABLED?: boolean;
|
||||
|
||||
@Optional()
|
||||
@Matches(/^\//, { message: 'IMMICH_PLUGINS_INSTALL_FOLDER must be an absolute path' })
|
||||
IMMICH_PLUGINS_INSTALL_FOLDER?: string;
|
||||
|
||||
@IsInt()
|
||||
@Optional()
|
||||
@Type(() => Number)
|
||||
|
||||
110
server/src/dtos/plugin-manifest.dto.ts
Normal file
110
server/src/dtos/plugin-manifest.dto.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsSemVer,
|
||||
IsString,
|
||||
Matches,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { PluginContext } from 'src/enum';
|
||||
import { JSONSchema } from 'src/types/plugin-schema.types';
|
||||
import { ValidateEnum } from 'src/validation';
|
||||
|
||||
class PluginManifestWasmDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
path!: string;
|
||||
}
|
||||
|
||||
class PluginManifestFilterDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
methodName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
description!: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsEnum(PluginContext, { each: true })
|
||||
supportedContexts!: PluginContext[];
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
schema?: JSONSchema;
|
||||
}
|
||||
|
||||
class PluginManifestActionDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
methodName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
description!: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateEnum({ enum: PluginContext, name: 'PluginContext', each: true })
|
||||
supportedContexts!: PluginContext[];
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
schema?: JSONSchema;
|
||||
}
|
||||
|
||||
export class PluginManifestDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/^[a-z0-9-]+[a-z0-9]$/, {
|
||||
message: 'Plugin name must contain only lowercase letters, numbers, and hyphens, and cannot end with a hyphen',
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsSemVer()
|
||||
version!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
description!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
author!: string;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => PluginManifestWasmDto)
|
||||
wasm!: PluginManifestWasmDto;
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PluginManifestFilterDto)
|
||||
@IsOptional()
|
||||
filters?: PluginManifestFilterDto[];
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PluginManifestActionDto)
|
||||
@IsOptional()
|
||||
actions?: PluginManifestActionDto[];
|
||||
}
|
||||
77
server/src/dtos/plugin.dto.ts
Normal file
77
server/src/dtos/plugin.dto.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
import { PluginAction, PluginFilter } from 'src/database';
|
||||
import { PluginContext } from 'src/enum';
|
||||
import type { JSONSchema } from 'src/types/plugin-schema.types';
|
||||
import { ValidateEnum } from 'src/validation';
|
||||
|
||||
export class PluginResponseDto {
|
||||
id!: string;
|
||||
name!: string;
|
||||
title!: string;
|
||||
description!: string;
|
||||
author!: string;
|
||||
version!: string;
|
||||
createdAt!: string;
|
||||
updatedAt!: string;
|
||||
filters!: PluginFilterResponseDto[];
|
||||
actions!: PluginActionResponseDto[];
|
||||
}
|
||||
|
||||
export class PluginFilterResponseDto {
|
||||
id!: string;
|
||||
pluginId!: string;
|
||||
methodName!: string;
|
||||
title!: string;
|
||||
description!: string;
|
||||
|
||||
@ValidateEnum({ enum: PluginContext, name: 'PluginContext' })
|
||||
supportedContexts!: PluginContext[];
|
||||
schema!: JSONSchema | null;
|
||||
}
|
||||
|
||||
export class PluginActionResponseDto {
|
||||
id!: string;
|
||||
pluginId!: string;
|
||||
methodName!: string;
|
||||
title!: string;
|
||||
description!: string;
|
||||
|
||||
@ValidateEnum({ enum: PluginContext, name: 'PluginContext' })
|
||||
supportedContexts!: PluginContext[];
|
||||
schema!: JSONSchema | null;
|
||||
}
|
||||
|
||||
export class PluginInstallDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
manifestPath!: string;
|
||||
}
|
||||
|
||||
export type MapPlugin = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
author: string;
|
||||
version: string;
|
||||
wasmPath: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
filters: PluginFilter[];
|
||||
actions: PluginAction[];
|
||||
};
|
||||
|
||||
export function mapPlugin(plugin: MapPlugin): PluginResponseDto {
|
||||
return {
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
title: plugin.title,
|
||||
description: plugin.description,
|
||||
author: plugin.author,
|
||||
version: plugin.version,
|
||||
createdAt: plugin.createdAt.toISOString(),
|
||||
updatedAt: plugin.updatedAt.toISOString(),
|
||||
filters: plugin.filters,
|
||||
actions: plugin.actions,
|
||||
};
|
||||
}
|
||||
@@ -91,4 +91,7 @@ export class QueuesResponseDto implements Record<QueueName, QueueResponseDto> {
|
||||
|
||||
@ApiProperty({ type: QueueResponseDto })
|
||||
[QueueName.Ocr]!: QueueResponseDto;
|
||||
|
||||
@ApiProperty({ type: QueueResponseDto })
|
||||
[QueueName.Workflow]!: QueueResponseDto;
|
||||
}
|
||||
|
||||
@@ -224,6 +224,12 @@ class SystemConfigJobDto implements Record<ConcurrentQueueName, JobSettingsDto>
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.Notification]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.Workflow]!: JobSettingsDto;
|
||||
}
|
||||
|
||||
class SystemConfigLibraryScanDto {
|
||||
|
||||
120
server/src/dtos/workflow.dto.ts
Normal file
120
server/src/dtos/workflow.dto.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNotEmpty, IsObject, IsString, IsUUID, ValidateNested } from 'class-validator';
|
||||
import { WorkflowAction, WorkflowFilter } from 'src/database';
|
||||
import { PluginTriggerType } from 'src/enum';
|
||||
import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types';
|
||||
import { Optional, ValidateBoolean, ValidateEnum } from 'src/validation';
|
||||
|
||||
export class WorkflowFilterItemDto {
|
||||
@IsUUID()
|
||||
filterId!: string;
|
||||
|
||||
@IsObject()
|
||||
@Optional()
|
||||
filterConfig?: FilterConfig;
|
||||
}
|
||||
|
||||
export class WorkflowActionItemDto {
|
||||
@IsUUID()
|
||||
actionId!: string;
|
||||
|
||||
@IsObject()
|
||||
@Optional()
|
||||
actionConfig?: ActionConfig;
|
||||
}
|
||||
|
||||
export class WorkflowCreateDto {
|
||||
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' })
|
||||
triggerType!: PluginTriggerType;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
description?: string;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
enabled?: boolean;
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WorkflowFilterItemDto)
|
||||
filters!: WorkflowFilterItemDto[];
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WorkflowActionItemDto)
|
||||
actions!: WorkflowActionItemDto[];
|
||||
}
|
||||
|
||||
export class WorkflowUpdateDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
description?: string;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
enabled?: boolean;
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WorkflowFilterItemDto)
|
||||
@Optional()
|
||||
filters?: WorkflowFilterItemDto[];
|
||||
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WorkflowActionItemDto)
|
||||
@Optional()
|
||||
actions?: WorkflowActionItemDto[];
|
||||
}
|
||||
|
||||
export class WorkflowResponseDto {
|
||||
id!: string;
|
||||
ownerId!: string;
|
||||
triggerType!: PluginTriggerType;
|
||||
name!: string | null;
|
||||
description!: string;
|
||||
createdAt!: string;
|
||||
enabled!: boolean;
|
||||
filters!: WorkflowFilterResponseDto[];
|
||||
actions!: WorkflowActionResponseDto[];
|
||||
}
|
||||
|
||||
export class WorkflowFilterResponseDto {
|
||||
id!: string;
|
||||
workflowId!: string;
|
||||
filterId!: string;
|
||||
filterConfig!: FilterConfig | null;
|
||||
order!: number;
|
||||
}
|
||||
|
||||
export class WorkflowActionResponseDto {
|
||||
id!: string;
|
||||
workflowId!: string;
|
||||
actionId!: string;
|
||||
actionConfig!: ActionConfig | null;
|
||||
order!: number;
|
||||
}
|
||||
|
||||
export function mapWorkflowFilter(filter: WorkflowFilter): WorkflowFilterResponseDto {
|
||||
return {
|
||||
id: filter.id,
|
||||
workflowId: filter.workflowId,
|
||||
filterId: filter.filterId,
|
||||
filterConfig: filter.filterConfig,
|
||||
order: filter.order,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapWorkflowAction(action: WorkflowAction): WorkflowActionResponseDto {
|
||||
return {
|
||||
id: action.id,
|
||||
workflowId: action.workflowId,
|
||||
actionId: action.actionId,
|
||||
actionConfig: action.actionConfig,
|
||||
order: action.order,
|
||||
};
|
||||
}
|
||||
@@ -177,6 +177,11 @@ export enum Permission {
|
||||
PinCodeUpdate = 'pinCode.update',
|
||||
PinCodeDelete = 'pinCode.delete',
|
||||
|
||||
PluginCreate = 'plugin.create',
|
||||
PluginRead = 'plugin.read',
|
||||
PluginUpdate = 'plugin.update',
|
||||
PluginDelete = 'plugin.delete',
|
||||
|
||||
ServerAbout = 'server.about',
|
||||
ServerApkLinks = 'server.apkLinks',
|
||||
ServerStorage = 'server.storage',
|
||||
@@ -240,6 +245,11 @@ export enum Permission {
|
||||
UserProfileImageUpdate = 'userProfileImage.update',
|
||||
UserProfileImageDelete = 'userProfileImage.delete',
|
||||
|
||||
WorkflowCreate = 'workflow.create',
|
||||
WorkflowRead = 'workflow.read',
|
||||
WorkflowUpdate = 'workflow.update',
|
||||
WorkflowDelete = 'workflow.delete',
|
||||
|
||||
AdminUserCreate = 'adminUser.create',
|
||||
AdminUserRead = 'adminUser.read',
|
||||
AdminUserUpdate = 'adminUser.update',
|
||||
@@ -525,6 +535,7 @@ export enum QueueName {
|
||||
Notification = 'notifications',
|
||||
BackupDatabase = 'backupDatabase',
|
||||
Ocr = 'ocr',
|
||||
Workflow = 'workflow',
|
||||
}
|
||||
|
||||
export enum JobName {
|
||||
@@ -601,6 +612,9 @@ export enum JobName {
|
||||
// OCR
|
||||
OcrQueueAll = 'OcrQueueAll',
|
||||
Ocr = 'Ocr',
|
||||
|
||||
// Workflow
|
||||
WorkflowRun = 'WorkflowRun',
|
||||
}
|
||||
|
||||
export enum QueueCommand {
|
||||
@@ -793,6 +807,7 @@ export enum ApiTag {
|
||||
NotificationsAdmin = 'Notifications (admin)',
|
||||
Partners = 'Partners',
|
||||
People = 'People',
|
||||
Plugins = 'Plugins',
|
||||
Search = 'Search',
|
||||
Server = 'Server',
|
||||
Sessions = 'Sessions',
|
||||
@@ -807,4 +822,16 @@ export enum ApiTag {
|
||||
UsersAdmin = 'Users (admin)',
|
||||
Users = 'Users',
|
||||
Views = 'Views',
|
||||
Workflows = 'Workflows',
|
||||
}
|
||||
|
||||
export enum PluginContext {
|
||||
Asset = 'asset',
|
||||
Album = 'album',
|
||||
Person = 'person',
|
||||
}
|
||||
|
||||
export enum PluginTriggerType {
|
||||
AssetCreate = 'AssetCreate',
|
||||
PersonRecognized = 'PersonRecognized',
|
||||
}
|
||||
|
||||
37
server/src/plugins.ts
Normal file
37
server/src/plugins.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { PluginContext, PluginTriggerType } from 'src/enum';
|
||||
import { JSONSchema } from 'src/types/plugin-schema.types';
|
||||
|
||||
export type PluginTrigger = {
|
||||
name: string;
|
||||
type: PluginTriggerType;
|
||||
description: string;
|
||||
context: PluginContext;
|
||||
schema: JSONSchema | null;
|
||||
};
|
||||
|
||||
export const pluginTriggers: PluginTrigger[] = [
|
||||
{
|
||||
name: 'Asset Uploaded',
|
||||
type: PluginTriggerType.AssetCreate,
|
||||
description: 'Triggered when a new asset is uploaded',
|
||||
context: PluginContext.Asset,
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
assetType: {
|
||||
type: 'string',
|
||||
description: 'Type of the asset',
|
||||
default: 'ALL',
|
||||
enum: ['Image', 'Video', 'All'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Person Recognized',
|
||||
type: PluginTriggerType.PersonRecognized,
|
||||
description: 'Triggered when a person is detected in an asset',
|
||||
context: PluginContext.Person,
|
||||
schema: null,
|
||||
},
|
||||
];
|
||||
@@ -243,3 +243,12 @@ from
|
||||
where
|
||||
"partner"."sharedById" in ($1)
|
||||
and "partner"."sharedWithId" = $2
|
||||
|
||||
-- AccessRepository.workflow.checkOwnerAccess
|
||||
select
|
||||
"workflow"."id"
|
||||
from
|
||||
"workflow"
|
||||
where
|
||||
"workflow"."id" in ($1)
|
||||
and "workflow"."ownerId" = $2
|
||||
|
||||
159
server/src/queries/plugin.repository.sql
Normal file
159
server/src/queries/plugin.repository.sql
Normal file
@@ -0,0 +1,159 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- PluginRepository.getPlugin
|
||||
select
|
||||
"plugin"."id" as "id",
|
||||
"plugin"."name" as "name",
|
||||
"plugin"."title" as "title",
|
||||
"plugin"."description" as "description",
|
||||
"plugin"."author" as "author",
|
||||
"plugin"."version" as "version",
|
||||
"plugin"."wasmPath" as "wasmPath",
|
||||
"plugin"."createdAt" as "createdAt",
|
||||
"plugin"."updatedAt" as "updatedAt",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
*
|
||||
from
|
||||
"plugin_filter"
|
||||
where
|
||||
"plugin_filter"."pluginId" = "plugin"."id"
|
||||
) as agg
|
||||
) as "filters",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
*
|
||||
from
|
||||
"plugin_action"
|
||||
where
|
||||
"plugin_action"."pluginId" = "plugin"."id"
|
||||
) as agg
|
||||
) as "actions"
|
||||
from
|
||||
"plugin"
|
||||
where
|
||||
"plugin"."id" = $1
|
||||
|
||||
-- PluginRepository.getPluginByName
|
||||
select
|
||||
"plugin"."id" as "id",
|
||||
"plugin"."name" as "name",
|
||||
"plugin"."title" as "title",
|
||||
"plugin"."description" as "description",
|
||||
"plugin"."author" as "author",
|
||||
"plugin"."version" as "version",
|
||||
"plugin"."wasmPath" as "wasmPath",
|
||||
"plugin"."createdAt" as "createdAt",
|
||||
"plugin"."updatedAt" as "updatedAt",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
*
|
||||
from
|
||||
"plugin_filter"
|
||||
where
|
||||
"plugin_filter"."pluginId" = "plugin"."id"
|
||||
) as agg
|
||||
) as "filters",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
*
|
||||
from
|
||||
"plugin_action"
|
||||
where
|
||||
"plugin_action"."pluginId" = "plugin"."id"
|
||||
) as agg
|
||||
) as "actions"
|
||||
from
|
||||
"plugin"
|
||||
where
|
||||
"plugin"."name" = $1
|
||||
|
||||
-- PluginRepository.getAllPlugins
|
||||
select
|
||||
"plugin"."id" as "id",
|
||||
"plugin"."name" as "name",
|
||||
"plugin"."title" as "title",
|
||||
"plugin"."description" as "description",
|
||||
"plugin"."author" as "author",
|
||||
"plugin"."version" as "version",
|
||||
"plugin"."wasmPath" as "wasmPath",
|
||||
"plugin"."createdAt" as "createdAt",
|
||||
"plugin"."updatedAt" as "updatedAt",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
*
|
||||
from
|
||||
"plugin_filter"
|
||||
where
|
||||
"plugin_filter"."pluginId" = "plugin"."id"
|
||||
) as agg
|
||||
) as "filters",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
*
|
||||
from
|
||||
"plugin_action"
|
||||
where
|
||||
"plugin_action"."pluginId" = "plugin"."id"
|
||||
) as agg
|
||||
) as "actions"
|
||||
from
|
||||
"plugin"
|
||||
order by
|
||||
"plugin"."name"
|
||||
|
||||
-- PluginRepository.getFilter
|
||||
select
|
||||
*
|
||||
from
|
||||
"plugin_filter"
|
||||
where
|
||||
"id" = $1
|
||||
|
||||
-- PluginRepository.getFiltersByPlugin
|
||||
select
|
||||
*
|
||||
from
|
||||
"plugin_filter"
|
||||
where
|
||||
"pluginId" = $1
|
||||
|
||||
-- PluginRepository.getAction
|
||||
select
|
||||
*
|
||||
from
|
||||
"plugin_action"
|
||||
where
|
||||
"id" = $1
|
||||
|
||||
-- PluginRepository.getActionsByPlugin
|
||||
select
|
||||
*
|
||||
from
|
||||
"plugin_action"
|
||||
where
|
||||
"pluginId" = $1
|
||||
68
server/src/queries/workflow.repository.sql
Normal file
68
server/src/queries/workflow.repository.sql
Normal file
@@ -0,0 +1,68 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- WorkflowRepository.getWorkflow
|
||||
select
|
||||
*
|
||||
from
|
||||
"workflow"
|
||||
where
|
||||
"id" = $1
|
||||
|
||||
-- WorkflowRepository.getWorkflowsByOwner
|
||||
select
|
||||
*
|
||||
from
|
||||
"workflow"
|
||||
where
|
||||
"ownerId" = $1
|
||||
order by
|
||||
"name"
|
||||
|
||||
-- WorkflowRepository.getWorkflowsByTrigger
|
||||
select
|
||||
*
|
||||
from
|
||||
"workflow"
|
||||
where
|
||||
"triggerType" = $1
|
||||
and "enabled" = $2
|
||||
|
||||
-- WorkflowRepository.getWorkflowByOwnerAndTrigger
|
||||
select
|
||||
*
|
||||
from
|
||||
"workflow"
|
||||
where
|
||||
"ownerId" = $1
|
||||
and "triggerType" = $2
|
||||
and "enabled" = $3
|
||||
|
||||
-- WorkflowRepository.deleteWorkflow
|
||||
delete from "workflow"
|
||||
where
|
||||
"id" = $1
|
||||
|
||||
-- WorkflowRepository.getFilters
|
||||
select
|
||||
*
|
||||
from
|
||||
"workflow_filter"
|
||||
where
|
||||
"workflowId" = $1
|
||||
order by
|
||||
"order" asc
|
||||
|
||||
-- WorkflowRepository.deleteFiltersByWorkflow
|
||||
delete from "workflow_filter"
|
||||
where
|
||||
"workflowId" = $1
|
||||
|
||||
-- WorkflowRepository.getActions
|
||||
select
|
||||
*
|
||||
from
|
||||
"workflow_action"
|
||||
where
|
||||
"workflowId" = $1
|
||||
order by
|
||||
"order" asc
|
||||
@@ -462,6 +462,26 @@ class TagAccess {
|
||||
}
|
||||
}
|
||||
|
||||
class WorkflowAccess {
|
||||
constructor(private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
|
||||
@ChunkedSet({ paramIndex: 1 })
|
||||
async checkOwnerAccess(userId: string, workflowIds: Set<string>) {
|
||||
if (workflowIds.size === 0) {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
return this.db
|
||||
.selectFrom('workflow')
|
||||
.select('workflow.id')
|
||||
.where('workflow.id', 'in', [...workflowIds])
|
||||
.where('workflow.ownerId', '=', userId)
|
||||
.execute()
|
||||
.then((workflows) => new Set(workflows.map((workflow) => workflow.id)));
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AccessRepository {
|
||||
activity: ActivityAccess;
|
||||
@@ -476,6 +496,7 @@ export class AccessRepository {
|
||||
stack: StackAccess;
|
||||
tag: TagAccess;
|
||||
timeline: TimelineAccess;
|
||||
workflow: WorkflowAccess;
|
||||
|
||||
constructor(@InjectKysely() db: Kysely<DB>) {
|
||||
this.activity = new ActivityAccess(db);
|
||||
@@ -490,5 +511,6 @@ export class AccessRepository {
|
||||
this.stack = new StackAccess(db);
|
||||
this.tag = new TagAccess(db);
|
||||
this.timeline = new TimelineAccess(db);
|
||||
this.workflow = new WorkflowAccess(db);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ export interface EnvData {
|
||||
root: string;
|
||||
indexHtml: string;
|
||||
};
|
||||
corePlugin: string;
|
||||
};
|
||||
|
||||
redis: RedisOptions;
|
||||
@@ -102,6 +103,11 @@ export interface EnvData {
|
||||
|
||||
workers: ImmichWorker[];
|
||||
|
||||
plugins: {
|
||||
enabled: boolean;
|
||||
installFolder?: string;
|
||||
};
|
||||
|
||||
noColor: boolean;
|
||||
nodeVersion?: string;
|
||||
}
|
||||
@@ -304,6 +310,7 @@ const getEnv = (): EnvData => {
|
||||
root: folders.web,
|
||||
indexHtml: join(folders.web, 'index.html'),
|
||||
},
|
||||
corePlugin: join(buildFolder, 'corePlugin'),
|
||||
},
|
||||
|
||||
storage: {
|
||||
@@ -319,6 +326,11 @@ const getEnv = (): EnvData => {
|
||||
|
||||
workers,
|
||||
|
||||
plugins: {
|
||||
enabled: !!dto.IMMICH_PLUGINS_ENABLED,
|
||||
installFolder: dto.IMMICH_PLUGINS_INSTALL_FOLDER,
|
||||
},
|
||||
|
||||
noColor: !!dto.NO_COLOR,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { compareSync, hash } from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { createHash, createPublicKey, createVerify, randomBytes, randomUUID } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
|
||||
@@ -57,4 +58,12 @@ export class CryptoRepository {
|
||||
randomBytesAsText(bytes: number) {
|
||||
return randomBytes(bytes).toString('base64').replaceAll(/\W/g, '');
|
||||
}
|
||||
|
||||
signJwt(payload: string | object | Buffer, secret: string, options?: jwt.SignOptions): string {
|
||||
return jwt.sign(payload, secret, { algorithm: 'HS256', ...options });
|
||||
}
|
||||
|
||||
verifyJwt<T = any>(token: string, secret: string): T {
|
||||
return jwt.verify(token, secret, { algorithms: ['HS256'] }) as T;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ClassConstructor } from 'class-transformer';
|
||||
import _ from 'lodash';
|
||||
import { Socket } from 'socket.io';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { Asset } from 'src/database';
|
||||
import { EventConfig } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ImmichWorker, JobStatus, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum';
|
||||
@@ -41,6 +42,7 @@ type EventMap = {
|
||||
AlbumInvite: [{ id: string; userId: string }];
|
||||
|
||||
// asset events
|
||||
AssetCreate: [{ asset: Asset }];
|
||||
AssetTag: [{ assetId: string }];
|
||||
AssetUntag: [{ assetId: string }];
|
||||
AssetHide: [{ assetId: string; userId: string }];
|
||||
|
||||
@@ -28,6 +28,7 @@ import { OAuthRepository } from 'src/repositories/oauth.repository';
|
||||
import { OcrRepository } from 'src/repositories/ocr.repository';
|
||||
import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||
import { PersonRepository } from 'src/repositories/person.repository';
|
||||
import { PluginRepository } from 'src/repositories/plugin.repository';
|
||||
import { ProcessRepository } from 'src/repositories/process.repository';
|
||||
import { SearchRepository } from 'src/repositories/search.repository';
|
||||
import { ServerInfoRepository } from 'src/repositories/server-info.repository';
|
||||
@@ -46,6 +47,7 @@ import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||
import { ViewRepository } from 'src/repositories/view-repository';
|
||||
import { WebsocketRepository } from 'src/repositories/websocket.repository';
|
||||
import { WorkflowRepository } from 'src/repositories/workflow.repository';
|
||||
|
||||
export const repositories = [
|
||||
AccessRepository,
|
||||
@@ -78,6 +80,7 @@ export const repositories = [
|
||||
OcrRepository,
|
||||
PartnerRepository,
|
||||
PersonRepository,
|
||||
PluginRepository,
|
||||
ProcessRepository,
|
||||
SearchRepository,
|
||||
SessionRepository,
|
||||
@@ -96,4 +99,5 @@ export const repositories = [
|
||||
ViewRepository,
|
||||
VersionHistoryRepository,
|
||||
WebsocketRepository,
|
||||
WorkflowRepository,
|
||||
];
|
||||
|
||||
176
server/src/repositories/plugin.repository.ts
Normal file
176
server/src/repositories/plugin.repository.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Kysely } from 'kysely';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { columns } from 'src/database';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
|
||||
import { DB } from 'src/schema';
|
||||
|
||||
@Injectable()
|
||||
export class PluginRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
/**
|
||||
* Loads a plugin from a validated manifest file in a transaction.
|
||||
* This ensures all plugin, filter, and action operations are atomic.
|
||||
* @param manifest The validated plugin manifest
|
||||
* @param basePath The base directory path where the plugin is located
|
||||
*/
|
||||
async loadPlugin(manifest: PluginManifestDto, basePath: string) {
|
||||
return this.db.transaction().execute(async (tx) => {
|
||||
// Upsert the plugin
|
||||
const plugin = await tx
|
||||
.insertInto('plugin')
|
||||
.values({
|
||||
name: manifest.name,
|
||||
title: manifest.title,
|
||||
description: manifest.description,
|
||||
author: manifest.author,
|
||||
version: manifest.version,
|
||||
wasmPath: `${basePath}/${manifest.wasm.path}`,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.column('name').doUpdateSet({
|
||||
title: manifest.title,
|
||||
description: manifest.description,
|
||||
author: manifest.author,
|
||||
version: manifest.version,
|
||||
wasmPath: `${basePath}/${manifest.wasm.path}`,
|
||||
}),
|
||||
)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const filters = manifest.filters
|
||||
? await tx
|
||||
.insertInto('plugin_filter')
|
||||
.values(
|
||||
manifest.filters.map((filter) => ({
|
||||
pluginId: plugin.id,
|
||||
methodName: filter.methodName,
|
||||
title: filter.title,
|
||||
description: filter.description,
|
||||
supportedContexts: filter.supportedContexts,
|
||||
schema: filter.schema,
|
||||
})),
|
||||
)
|
||||
.onConflict((oc) =>
|
||||
oc.column('methodName').doUpdateSet((eb) => ({
|
||||
pluginId: eb.ref('excluded.pluginId'),
|
||||
title: eb.ref('excluded.title'),
|
||||
description: eb.ref('excluded.description'),
|
||||
supportedContexts: eb.ref('excluded.supportedContexts'),
|
||||
schema: eb.ref('excluded.schema'),
|
||||
})),
|
||||
)
|
||||
.returningAll()
|
||||
.execute()
|
||||
: [];
|
||||
|
||||
const actions = manifest.actions
|
||||
? await tx
|
||||
.insertInto('plugin_action')
|
||||
.values(
|
||||
manifest.actions.map((action) => ({
|
||||
pluginId: plugin.id,
|
||||
methodName: action.methodName,
|
||||
title: action.title,
|
||||
description: action.description,
|
||||
supportedContexts: action.supportedContexts,
|
||||
schema: action.schema,
|
||||
})),
|
||||
)
|
||||
.onConflict((oc) =>
|
||||
oc.column('methodName').doUpdateSet((eb) => ({
|
||||
pluginId: eb.ref('excluded.pluginId'),
|
||||
title: eb.ref('excluded.title'),
|
||||
description: eb.ref('excluded.description'),
|
||||
supportedContexts: eb.ref('excluded.supportedContexts'),
|
||||
schema: eb.ref('excluded.schema'),
|
||||
})),
|
||||
)
|
||||
.returningAll()
|
||||
.execute()
|
||||
: [];
|
||||
|
||||
return { plugin, filters, actions };
|
||||
});
|
||||
}
|
||||
|
||||
async readDirectory(path: string) {
|
||||
return readdir(path, { withFileTypes: true });
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getPlugin(id: string) {
|
||||
return this.db
|
||||
.selectFrom('plugin')
|
||||
.select((eb) => [
|
||||
...columns.plugin,
|
||||
jsonArrayFrom(
|
||||
eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'),
|
||||
).as('filters'),
|
||||
jsonArrayFrom(
|
||||
eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'),
|
||||
).as('actions'),
|
||||
])
|
||||
.where('plugin.id', '=', id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
getPluginByName(name: string) {
|
||||
return this.db
|
||||
.selectFrom('plugin')
|
||||
.select((eb) => [
|
||||
...columns.plugin,
|
||||
jsonArrayFrom(
|
||||
eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'),
|
||||
).as('filters'),
|
||||
jsonArrayFrom(
|
||||
eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'),
|
||||
).as('actions'),
|
||||
])
|
||||
.where('plugin.name', '=', name)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
getAllPlugins() {
|
||||
return this.db
|
||||
.selectFrom('plugin')
|
||||
.select((eb) => [
|
||||
...columns.plugin,
|
||||
jsonArrayFrom(
|
||||
eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'),
|
||||
).as('filters'),
|
||||
jsonArrayFrom(
|
||||
eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'),
|
||||
).as('actions'),
|
||||
])
|
||||
.orderBy('plugin.name')
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getFilter(id: string) {
|
||||
return this.db.selectFrom('plugin_filter').selectAll().where('id', '=', id).executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getFiltersByPlugin(pluginId: string) {
|
||||
return this.db.selectFrom('plugin_filter').selectAll().where('pluginId', '=', pluginId).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getAction(id: string) {
|
||||
return this.db.selectFrom('plugin_action').selectAll().where('id', '=', id).executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getActionsByPlugin(pluginId: string) {
|
||||
return this.db.selectFrom('plugin_action').selectAll().where('pluginId', '=', pluginId).execute();
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,10 @@ export class StorageRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async readTextFile(filepath: string): Promise<string> {
|
||||
return fs.readFile(filepath, 'utf8');
|
||||
}
|
||||
|
||||
async checkFileExists(filepath: string, mode = constants.F_OK): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filepath, mode);
|
||||
|
||||
139
server/src/repositories/workflow.repository.ts
Normal file
139
server/src/repositories/workflow.repository.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Insertable, Kysely, Updateable } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { PluginTriggerType } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getWorkflow(id: string) {
|
||||
return this.db.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getWorkflowsByOwner(ownerId: string) {
|
||||
return this.db.selectFrom('workflow').selectAll().where('ownerId', '=', ownerId).orderBy('name').execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [PluginTriggerType.AssetCreate] })
|
||||
getWorkflowsByTrigger(type: PluginTriggerType) {
|
||||
return this.db
|
||||
.selectFrom('workflow')
|
||||
.selectAll()
|
||||
.where('triggerType', '=', type)
|
||||
.where('enabled', '=', true)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, PluginTriggerType.AssetCreate] })
|
||||
getWorkflowByOwnerAndTrigger(ownerId: string, type: PluginTriggerType) {
|
||||
return this.db
|
||||
.selectFrom('workflow')
|
||||
.selectAll()
|
||||
.where('ownerId', '=', ownerId)
|
||||
.where('triggerType', '=', type)
|
||||
.where('enabled', '=', true)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async createWorkflow(
|
||||
workflow: Insertable<WorkflowTable>,
|
||||
filters: Insertable<WorkflowFilterTable>[],
|
||||
actions: Insertable<WorkflowActionTable>[],
|
||||
) {
|
||||
return await this.db.transaction().execute(async (tx) => {
|
||||
const createdWorkflow = await tx.insertInto('workflow').values(workflow).returningAll().executeTakeFirstOrThrow();
|
||||
|
||||
if (filters.length > 0) {
|
||||
const newFilters = filters.map((filter) => ({
|
||||
...filter,
|
||||
workflowId: createdWorkflow.id,
|
||||
}));
|
||||
|
||||
await tx.insertInto('workflow_filter').values(newFilters).execute();
|
||||
}
|
||||
|
||||
if (actions.length > 0) {
|
||||
const newActions = actions.map((action) => ({
|
||||
...action,
|
||||
workflowId: createdWorkflow.id,
|
||||
}));
|
||||
await tx.insertInto('workflow_action').values(newActions).execute();
|
||||
}
|
||||
|
||||
return createdWorkflow;
|
||||
});
|
||||
}
|
||||
|
||||
async updateWorkflow(
|
||||
id: string,
|
||||
workflow: Updateable<WorkflowTable>,
|
||||
filters: Insertable<WorkflowFilterTable>[] | undefined,
|
||||
actions: Insertable<WorkflowActionTable>[] | undefined,
|
||||
) {
|
||||
return await this.db.transaction().execute(async (trx) => {
|
||||
if (Object.keys(workflow).length > 0) {
|
||||
await trx.updateTable('workflow').set(workflow).where('id', '=', id).execute();
|
||||
}
|
||||
|
||||
if (filters !== undefined) {
|
||||
await trx.deleteFrom('workflow_filter').where('workflowId', '=', id).execute();
|
||||
if (filters.length > 0) {
|
||||
const filtersWithWorkflowId = filters.map((filter) => ({
|
||||
...filter,
|
||||
workflowId: id,
|
||||
}));
|
||||
await trx.insertInto('workflow_filter').values(filtersWithWorkflowId).execute();
|
||||
}
|
||||
}
|
||||
|
||||
if (actions !== undefined) {
|
||||
await trx.deleteFrom('workflow_action').where('workflowId', '=', id).execute();
|
||||
if (actions.length > 0) {
|
||||
const actionsWithWorkflowId = actions.map((action) => ({
|
||||
...action,
|
||||
workflowId: id,
|
||||
}));
|
||||
await trx.insertInto('workflow_action').values(actionsWithWorkflowId).execute();
|
||||
}
|
||||
}
|
||||
|
||||
return await trx.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirstOrThrow();
|
||||
});
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async deleteWorkflow(id: string) {
|
||||
await this.db.deleteFrom('workflow').where('id', '=', id).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getFilters(workflowId: string) {
|
||||
return this.db
|
||||
.selectFrom('workflow_filter')
|
||||
.selectAll()
|
||||
.where('workflowId', '=', workflowId)
|
||||
.orderBy('order', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async deleteFiltersByWorkflow(workflowId: string) {
|
||||
await this.db.deleteFrom('workflow_filter').where('workflowId', '=', workflowId).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getActions(workflowId: string) {
|
||||
return this.db
|
||||
.selectFrom('workflow_action')
|
||||
.selectAll()
|
||||
.where('workflowId', '=', workflowId)
|
||||
.orderBy('order', 'asc')
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
|
||||
import { PartnerTable } from 'src/schema/tables/partner.table';
|
||||
import { PersonAuditTable } from 'src/schema/tables/person-audit.table';
|
||||
import { PersonTable } from 'src/schema/tables/person.table';
|
||||
import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table';
|
||||
import { SessionTable } from 'src/schema/tables/session.table';
|
||||
import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table';
|
||||
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
|
||||
@@ -69,6 +70,7 @@ import { UserMetadataAuditTable } from 'src/schema/tables/user-metadata-audit.ta
|
||||
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
|
||||
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
|
||||
import { Database, Extensions, Generated, Int8 } from 'src/sql-tools';
|
||||
|
||||
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
|
||||
@@ -125,6 +127,12 @@ export class ImmichDatabase {
|
||||
UserMetadataAuditTable,
|
||||
UserTable,
|
||||
VersionHistoryTable,
|
||||
PluginTable,
|
||||
PluginFilterTable,
|
||||
PluginActionTable,
|
||||
WorkflowTable,
|
||||
WorkflowFilterTable,
|
||||
WorkflowActionTable,
|
||||
];
|
||||
|
||||
functions = [
|
||||
@@ -231,4 +239,12 @@ export interface DB {
|
||||
user_metadata_audit: UserMetadataAuditTable;
|
||||
|
||||
version_history: VersionHistoryTable;
|
||||
|
||||
plugin: PluginTable;
|
||||
plugin_filter: PluginFilterTable;
|
||||
plugin_action: PluginActionTable;
|
||||
|
||||
workflow: WorkflowTable;
|
||||
workflow_filter: WorkflowFilterTable;
|
||||
workflow_action: WorkflowActionTable;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`CREATE TABLE "plugin" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"name" character varying NOT NULL,
|
||||
"title" character varying NOT NULL,
|
||||
"description" character varying NOT NULL,
|
||||
"author" character varying NOT NULL,
|
||||
"version" character varying NOT NULL,
|
||||
"wasmPath" character varying NOT NULL,
|
||||
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "plugin_name_uq" UNIQUE ("name"),
|
||||
CONSTRAINT "plugin_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "plugin_name_idx" ON "plugin" ("name");`.execute(db);
|
||||
await sql`CREATE TABLE "plugin_filter" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"pluginId" uuid NOT NULL,
|
||||
"methodName" character varying NOT NULL,
|
||||
"title" character varying NOT NULL,
|
||||
"description" character varying NOT NULL,
|
||||
"supportedContexts" character varying[] NOT NULL,
|
||||
"schema" jsonb,
|
||||
CONSTRAINT "plugin_filter_pluginId_fkey" FOREIGN KEY ("pluginId") REFERENCES "plugin" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "plugin_filter_methodName_uq" UNIQUE ("methodName"),
|
||||
CONSTRAINT "plugin_filter_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "plugin_filter_supportedContexts_idx" ON "plugin_filter" USING gin ("supportedContexts");`.execute(
|
||||
db,
|
||||
);
|
||||
await sql`CREATE INDEX "plugin_filter_pluginId_idx" ON "plugin_filter" ("pluginId");`.execute(db);
|
||||
await sql`CREATE INDEX "plugin_filter_methodName_idx" ON "plugin_filter" ("methodName");`.execute(db);
|
||||
await sql`CREATE TABLE "plugin_action" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"pluginId" uuid NOT NULL,
|
||||
"methodName" character varying NOT NULL,
|
||||
"title" character varying NOT NULL,
|
||||
"description" character varying NOT NULL,
|
||||
"supportedContexts" character varying[] NOT NULL,
|
||||
"schema" jsonb,
|
||||
CONSTRAINT "plugin_action_pluginId_fkey" FOREIGN KEY ("pluginId") REFERENCES "plugin" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "plugin_action_methodName_uq" UNIQUE ("methodName"),
|
||||
CONSTRAINT "plugin_action_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "plugin_action_supportedContexts_idx" ON "plugin_action" USING gin ("supportedContexts");`.execute(
|
||||
db,
|
||||
);
|
||||
await sql`CREATE INDEX "plugin_action_pluginId_idx" ON "plugin_action" ("pluginId");`.execute(db);
|
||||
await sql`CREATE INDEX "plugin_action_methodName_idx" ON "plugin_action" ("methodName");`.execute(db);
|
||||
await sql`CREATE TABLE "workflow" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"ownerId" uuid NOT NULL,
|
||||
"triggerType" character varying NOT NULL,
|
||||
"name" character varying,
|
||||
"description" character varying NOT NULL,
|
||||
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"enabled" boolean NOT NULL DEFAULT true,
|
||||
CONSTRAINT "workflow_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "workflow_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "workflow_ownerId_idx" ON "workflow" ("ownerId");`.execute(db);
|
||||
await sql`CREATE TABLE "workflow_filter" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"workflowId" uuid NOT NULL,
|
||||
"filterId" uuid NOT NULL,
|
||||
"filterConfig" jsonb,
|
||||
"order" integer NOT NULL,
|
||||
CONSTRAINT "workflow_filter_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "workflow_filter_filterId_fkey" FOREIGN KEY ("filterId") REFERENCES "plugin_filter" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "workflow_filter_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "workflow_filter_filterId_idx" ON "workflow_filter" ("filterId");`.execute(db);
|
||||
await sql`CREATE INDEX "workflow_filter_workflowId_order_idx" ON "workflow_filter" ("workflowId", "order");`.execute(
|
||||
db,
|
||||
);
|
||||
await sql`CREATE INDEX "workflow_filter_workflowId_idx" ON "workflow_filter" ("workflowId");`.execute(db);
|
||||
await sql`CREATE TABLE "workflow_action" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"workflowId" uuid NOT NULL,
|
||||
"actionId" uuid NOT NULL,
|
||||
"actionConfig" jsonb,
|
||||
"order" integer NOT NULL,
|
||||
CONSTRAINT "workflow_action_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "workflow_action_actionId_fkey" FOREIGN KEY ("actionId") REFERENCES "plugin_action" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "workflow_action_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "workflow_action_actionId_idx" ON "workflow_action" ("actionId");`.execute(db);
|
||||
await sql`CREATE INDEX "workflow_action_workflowId_order_idx" ON "workflow_action" ("workflowId", "order");`.execute(
|
||||
db,
|
||||
);
|
||||
await sql`CREATE INDEX "workflow_action_workflowId_idx" ON "workflow_action" ("workflowId");`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_plugin_filter_supportedContexts_idx', '{"type":"index","name":"plugin_filter_supportedContexts_idx","sql":"CREATE INDEX \\"plugin_filter_supportedContexts_idx\\" ON \\"plugin_filter\\" (\\"supportedContexts\\") USING gin;"}'::jsonb);`.execute(
|
||||
db,
|
||||
);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_plugin_action_supportedContexts_idx', '{"type":"index","name":"plugin_action_supportedContexts_idx","sql":"CREATE INDEX \\"plugin_action_supportedContexts_idx\\" ON \\"plugin_action\\" (\\"supportedContexts\\") USING gin;"}'::jsonb);`.execute(
|
||||
db,
|
||||
);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`DROP TABLE "workflow";`.execute(db);
|
||||
await sql`DROP TABLE "workflow_filter";`.execute(db);
|
||||
await sql`DROP TABLE "workflow_action";`.execute(db);
|
||||
|
||||
await sql`DROP TABLE "plugin";`.execute(db);
|
||||
await sql`DROP TABLE "plugin_filter";`.execute(db);
|
||||
await sql`DROP TABLE "plugin_action";`.execute(db);
|
||||
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_filter_supportedContexts_idx';`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_action_supportedContexts_idx';`.execute(db);
|
||||
}
|
||||
95
server/src/schema/tables/plugin.table.ts
Normal file
95
server/src/schema/tables/plugin.table.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { PluginContext } from 'src/enum';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ForeignKeyColumn,
|
||||
Generated,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
import type { JSONSchema } from 'src/types/plugin-schema.types';
|
||||
|
||||
@Table('plugin')
|
||||
export class PluginTable {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: Generated<string>;
|
||||
|
||||
@Column({ index: true, unique: true })
|
||||
name!: string;
|
||||
|
||||
@Column()
|
||||
title!: string;
|
||||
|
||||
@Column()
|
||||
description!: string;
|
||||
|
||||
@Column()
|
||||
author!: string;
|
||||
|
||||
@Column()
|
||||
version!: string;
|
||||
|
||||
@Column()
|
||||
wasmPath!: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
@Index({ columns: ['supportedContexts'], using: 'gin' })
|
||||
@Table('plugin_filter')
|
||||
export class PluginFilterTable {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: Generated<string>;
|
||||
|
||||
@ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
@Column({ index: true })
|
||||
pluginId!: string;
|
||||
|
||||
@Column({ index: true, unique: true })
|
||||
methodName!: string;
|
||||
|
||||
@Column()
|
||||
title!: string;
|
||||
|
||||
@Column()
|
||||
description!: string;
|
||||
|
||||
@Column({ type: 'character varying', array: true })
|
||||
supportedContexts!: Generated<PluginContext[]>;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
schema!: JSONSchema | null;
|
||||
}
|
||||
|
||||
@Index({ columns: ['supportedContexts'], using: 'gin' })
|
||||
@Table('plugin_action')
|
||||
export class PluginActionTable {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: Generated<string>;
|
||||
|
||||
@ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
@Column({ index: true })
|
||||
pluginId!: string;
|
||||
|
||||
@Column({ index: true, unique: true })
|
||||
methodName!: string;
|
||||
|
||||
@Column()
|
||||
title!: string;
|
||||
|
||||
@Column()
|
||||
description!: string;
|
||||
|
||||
@Column({ type: 'character varying', array: true })
|
||||
supportedContexts!: Generated<PluginContext[]>;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
schema!: JSONSchema | null;
|
||||
}
|
||||
78
server/src/schema/tables/workflow.table.ts
Normal file
78
server/src/schema/tables/workflow.table.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { PluginTriggerType } from 'src/enum';
|
||||
import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ForeignKeyColumn,
|
||||
Generated,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
Timestamp,
|
||||
} from 'src/sql-tools';
|
||||
import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types';
|
||||
|
||||
@Table('workflow')
|
||||
export class WorkflowTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: Generated<string>;
|
||||
|
||||
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
|
||||
ownerId!: string;
|
||||
|
||||
@Column()
|
||||
triggerType!: PluginTriggerType;
|
||||
|
||||
@Column({ nullable: true })
|
||||
name!: string | null;
|
||||
|
||||
@Column()
|
||||
description!: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
enabled!: boolean;
|
||||
}
|
||||
|
||||
@Index({ columns: ['workflowId', 'order'] })
|
||||
@Index({ columns: ['filterId'] })
|
||||
@Table('workflow_filter')
|
||||
export class WorkflowFilterTable {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: Generated<string>;
|
||||
|
||||
@ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
workflowId!: Generated<string>;
|
||||
|
||||
@ForeignKeyColumn(() => PluginFilterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
filterId!: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
filterConfig!: FilterConfig | null;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
order!: number;
|
||||
}
|
||||
|
||||
@Index({ columns: ['workflowId', 'order'] })
|
||||
@Index({ columns: ['actionId'] })
|
||||
@Table('workflow_action')
|
||||
export class WorkflowActionTable {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: Generated<string>;
|
||||
|
||||
@ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
workflowId!: Generated<string>;
|
||||
|
||||
@ForeignKeyColumn(() => PluginActionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
actionId!: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
actionConfig!: ActionConfig | null;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
order!: number;
|
||||
}
|
||||
@@ -426,6 +426,9 @@ export class AssetMediaService extends BaseService {
|
||||
}
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size });
|
||||
|
||||
await this.eventRepository.emit('AssetCreate', { asset });
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } });
|
||||
|
||||
return asset;
|
||||
|
||||
@@ -35,6 +35,7 @@ import { OAuthRepository } from 'src/repositories/oauth.repository';
|
||||
import { OcrRepository } from 'src/repositories/ocr.repository';
|
||||
import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||
import { PersonRepository } from 'src/repositories/person.repository';
|
||||
import { PluginRepository } from 'src/repositories/plugin.repository';
|
||||
import { ProcessRepository } from 'src/repositories/process.repository';
|
||||
import { SearchRepository } from 'src/repositories/search.repository';
|
||||
import { ServerInfoRepository } from 'src/repositories/server-info.repository';
|
||||
@@ -53,6 +54,7 @@ import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||
import { ViewRepository } from 'src/repositories/view-repository';
|
||||
import { WebsocketRepository } from 'src/repositories/websocket.repository';
|
||||
import { WorkflowRepository } from 'src/repositories/workflow.repository';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access';
|
||||
import { getConfig, updateConfig } from 'src/utils/config';
|
||||
@@ -88,6 +90,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
|
||||
OcrRepository,
|
||||
PartnerRepository,
|
||||
PersonRepository,
|
||||
PluginRepository,
|
||||
ProcessRepository,
|
||||
SearchRepository,
|
||||
ServerInfoRepository,
|
||||
@@ -105,6 +108,8 @@ export const BASE_SERVICE_DEPENDENCIES = [
|
||||
UserRepository,
|
||||
VersionHistoryRepository,
|
||||
ViewRepository,
|
||||
WebsocketRepository,
|
||||
WorkflowRepository,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
@@ -142,6 +147,7 @@ export class BaseService {
|
||||
protected ocrRepository: OcrRepository,
|
||||
protected partnerRepository: PartnerRepository,
|
||||
protected personRepository: PersonRepository,
|
||||
protected pluginRepository: PluginRepository,
|
||||
protected processRepository: ProcessRepository,
|
||||
protected searchRepository: SearchRepository,
|
||||
protected serverInfoRepository: ServerInfoRepository,
|
||||
@@ -160,6 +166,7 @@ export class BaseService {
|
||||
protected versionRepository: VersionHistoryRepository,
|
||||
protected viewRepository: ViewRepository,
|
||||
protected websocketRepository: WebsocketRepository,
|
||||
protected workflowRepository: WorkflowRepository,
|
||||
) {
|
||||
this.logger.setContext(this.constructor.name);
|
||||
this.storageCore = StorageCore.create(
|
||||
|
||||
@@ -23,6 +23,7 @@ import { NotificationService } from 'src/services/notification.service';
|
||||
import { OcrService } from 'src/services/ocr.service';
|
||||
import { PartnerService } from 'src/services/partner.service';
|
||||
import { PersonService } from 'src/services/person.service';
|
||||
import { PluginService } from 'src/services/plugin.service';
|
||||
import { QueueService } from 'src/services/queue.service';
|
||||
import { SearchService } from 'src/services/search.service';
|
||||
import { ServerService } from 'src/services/server.service';
|
||||
@@ -43,6 +44,7 @@ import { UserAdminService } from 'src/services/user-admin.service';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
import { ViewService } from 'src/services/view.service';
|
||||
import { WorkflowService } from 'src/services/workflow.service';
|
||||
|
||||
export const services = [
|
||||
ApiKeyService,
|
||||
@@ -70,6 +72,7 @@ export const services = [
|
||||
OcrService,
|
||||
PartnerService,
|
||||
PersonService,
|
||||
PluginService,
|
||||
QueueService,
|
||||
SearchService,
|
||||
ServerService,
|
||||
@@ -90,4 +93,5 @@ export const services = [
|
||||
UserService,
|
||||
VersionService,
|
||||
ViewService,
|
||||
WorkflowService,
|
||||
];
|
||||
|
||||
120
server/src/services/plugin-host.functions.ts
Normal file
120
server/src/services/plugin-host.functions.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { CurrentPlugin } from '@extism/extism';
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { Updateable } from 'kysely';
|
||||
import { Permission } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { requireAccess } from 'src/utils/access';
|
||||
|
||||
/**
|
||||
* Plugin host functions that are exposed to WASM plugins via Extism.
|
||||
* These functions allow plugins to interact with the Immich system.
|
||||
*/
|
||||
export class PluginHostFunctions {
|
||||
constructor(
|
||||
private assetRepository: AssetRepository,
|
||||
private albumRepository: AlbumRepository,
|
||||
private accessRepository: AccessRepository,
|
||||
private cryptoRepository: CryptoRepository,
|
||||
private logger: LoggingRepository,
|
||||
private pluginJwtSecret: string,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates Extism host function bindings for the plugin.
|
||||
* These are the functions that WASM plugins can call.
|
||||
*/
|
||||
getHostFunctions() {
|
||||
return {
|
||||
'extism:host/user': {
|
||||
updateAsset: (cp: CurrentPlugin, offs: bigint) => this.handleUpdateAsset(cp, offs),
|
||||
addAssetToAlbum: (cp: CurrentPlugin, offs: bigint) => this.handleAddAssetToAlbum(cp, offs),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Host function wrapper for updateAsset.
|
||||
* Reads the input from the plugin, parses it, and calls the actual update function.
|
||||
*/
|
||||
private async handleUpdateAsset(cp: CurrentPlugin, offs: bigint) {
|
||||
const input = JSON.parse(cp.read(offs)!.text());
|
||||
await this.updateAsset(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Host function wrapper for addAssetToAlbum.
|
||||
* Reads the input from the plugin, parses it, and calls the actual add function.
|
||||
*/
|
||||
private async handleAddAssetToAlbum(cp: CurrentPlugin, offs: bigint) {
|
||||
const input = JSON.parse(cp.read(offs)!.text());
|
||||
await this.addAssetToAlbum(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the JWT token and returns the auth context.
|
||||
*/
|
||||
private validateToken(authToken: string): { userId: string } {
|
||||
try {
|
||||
const auth = this.cryptoRepository.verifyJwt<{ userId: string }>(authToken, this.pluginJwtSecret);
|
||||
if (!auth.userId) {
|
||||
throw new UnauthorizedException('Invalid token: missing userId');
|
||||
}
|
||||
return auth;
|
||||
} catch (error) {
|
||||
this.logger.error('Token validation failed:', error);
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an asset with the given properties.
|
||||
*/
|
||||
async updateAsset(input: { authToken: string } & Updateable<AssetTable> & { id: string }) {
|
||||
const { authToken, id, ...assetData } = input;
|
||||
|
||||
// Validate token
|
||||
const auth = this.validateToken(authToken);
|
||||
|
||||
// Check access to the asset
|
||||
await requireAccess(this.accessRepository, {
|
||||
auth: { user: { id: auth.userId } } as any,
|
||||
permission: Permission.AssetUpdate,
|
||||
ids: [id],
|
||||
});
|
||||
|
||||
this.logger.log(`Updating asset ${id} -- ${JSON.stringify(assetData)}`);
|
||||
await this.assetRepository.update({ id, ...assetData });
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an asset to an album.
|
||||
*/
|
||||
async addAssetToAlbum(input: { authToken: string; assetId: string; albumId: string }) {
|
||||
const { authToken, assetId, albumId } = input;
|
||||
|
||||
// Validate token
|
||||
const auth = this.validateToken(authToken);
|
||||
|
||||
// Check access to both the asset and the album
|
||||
await requireAccess(this.accessRepository, {
|
||||
auth: { user: { id: auth.userId } } as any,
|
||||
permission: Permission.AssetRead,
|
||||
ids: [assetId],
|
||||
});
|
||||
|
||||
await requireAccess(this.accessRepository, {
|
||||
auth: { user: { id: auth.userId } } as any,
|
||||
permission: Permission.AlbumUpdate,
|
||||
ids: [albumId],
|
||||
});
|
||||
|
||||
this.logger.log(`Adding asset ${assetId} to album ${albumId}`);
|
||||
await this.albumRepository.addAssetIds(albumId, [assetId]);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
317
server/src/services/plugin.service.ts
Normal file
317
server/src/services/plugin.service.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { Plugin as ExtismPlugin, newPlugin } from '@extism/extism';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateOrReject } from 'class-validator';
|
||||
import { join } from 'node:path';
|
||||
import { Asset, WorkflowAction, WorkflowFilter } from 'src/database';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
|
||||
import { mapPlugin, PluginResponseDto } from 'src/dtos/plugin.dto';
|
||||
import { JobName, JobStatus, PluginTriggerType, QueueName } from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { PluginHostFunctions } from 'src/services/plugin-host.functions';
|
||||
import { IWorkflowJob, JobItem, JobOf, WorkflowData } from 'src/types';
|
||||
|
||||
interface WorkflowContext {
|
||||
authToken: string;
|
||||
asset: Asset;
|
||||
}
|
||||
|
||||
interface PluginInput<T = unknown> {
|
||||
authToken: string;
|
||||
config: T;
|
||||
data: {
|
||||
asset: Asset;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PluginService extends BaseService {
|
||||
private pluginJwtSecret!: string;
|
||||
private loadedPlugins: Map<string, ExtismPlugin> = new Map();
|
||||
private hostFunctions!: PluginHostFunctions;
|
||||
|
||||
@OnEvent({ name: 'AppBootstrap' })
|
||||
async onBootstrap() {
|
||||
this.pluginJwtSecret = this.cryptoRepository.randomBytesAsText(32);
|
||||
|
||||
await this.loadPluginsFromManifests();
|
||||
|
||||
this.hostFunctions = new PluginHostFunctions(
|
||||
this.assetRepository,
|
||||
this.albumRepository,
|
||||
this.accessRepository,
|
||||
this.cryptoRepository,
|
||||
this.logger,
|
||||
this.pluginJwtSecret,
|
||||
);
|
||||
|
||||
await this.loadPlugins();
|
||||
}
|
||||
|
||||
//
|
||||
// CRUD operations for plugins
|
||||
//
|
||||
async getAll(): Promise<PluginResponseDto[]> {
|
||||
const plugins = await this.pluginRepository.getAllPlugins();
|
||||
return plugins.map((plugin) => mapPlugin(plugin));
|
||||
}
|
||||
|
||||
async get(id: string): Promise<PluginResponseDto> {
|
||||
const plugin = await this.pluginRepository.getPlugin(id);
|
||||
if (!plugin) {
|
||||
throw new BadRequestException('Plugin not found');
|
||||
}
|
||||
return mapPlugin(plugin);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////
|
||||
// Plugin Loader
|
||||
//////////////////////////////////////////
|
||||
async loadPluginsFromManifests(): Promise<void> {
|
||||
// Load core plugin
|
||||
const { resourcePaths, plugins } = this.configRepository.getEnv();
|
||||
const coreManifestPath = `${resourcePaths.corePlugin}/manifest.json`;
|
||||
|
||||
const coreManifest = await this.readAndValidateManifest(coreManifestPath);
|
||||
await this.loadPluginToDatabase(coreManifest, resourcePaths.corePlugin);
|
||||
|
||||
this.logger.log(`Successfully processed core plugin: ${coreManifest.name} (version ${coreManifest.version})`);
|
||||
|
||||
// Load external plugins
|
||||
if (plugins.enabled && plugins.installFolder) {
|
||||
await this.loadExternalPlugins(plugins.installFolder);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadExternalPlugins(installFolder: string): Promise<void> {
|
||||
try {
|
||||
const entries = await this.pluginRepository.readDirectory(installFolder);
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pluginFolder = join(installFolder, entry.name);
|
||||
const manifestPath = join(pluginFolder, 'manifest.json');
|
||||
try {
|
||||
const manifest = await this.readAndValidateManifest(manifestPath);
|
||||
await this.loadPluginToDatabase(manifest, pluginFolder);
|
||||
|
||||
this.logger.log(`Successfully processed external plugin: ${manifest.name} (version ${manifest.version})`);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to load external plugin from ${manifestPath}:`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to scan external plugins folder ${installFolder}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPluginToDatabase(manifest: PluginManifestDto, basePath: string): Promise<void> {
|
||||
const currentPlugin = await this.pluginRepository.getPluginByName(manifest.name);
|
||||
if (currentPlugin != null && currentPlugin.version === manifest.version) {
|
||||
this.logger.log(`Plugin ${manifest.name} is up to date (version ${manifest.version}). Skipping`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { plugin, filters, actions } = await this.pluginRepository.loadPlugin(manifest, basePath);
|
||||
|
||||
this.logger.log(`Upserted plugin: ${plugin.name} (ID: ${plugin.id}, version: ${plugin.version})`);
|
||||
|
||||
for (const filter of filters) {
|
||||
this.logger.log(`Upserted plugin filter: ${filter.methodName} (ID: ${filter.id})`);
|
||||
}
|
||||
|
||||
for (const action of actions) {
|
||||
this.logger.log(`Upserted plugin action: ${action.methodName} (ID: ${action.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
private async readAndValidateManifest(manifestPath: string): Promise<PluginManifestDto> {
|
||||
const content = await this.storageRepository.readTextFile(manifestPath);
|
||||
const manifestData = JSON.parse(content);
|
||||
const manifest = plainToInstance(PluginManifestDto, manifestData);
|
||||
|
||||
await validateOrReject(manifest, {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
});
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
///////////////////////////////////////////
|
||||
// Plugin Execution
|
||||
///////////////////////////////////////////
|
||||
private async loadPlugins() {
|
||||
const plugins = await this.pluginRepository.getAllPlugins();
|
||||
for (const plugin of plugins) {
|
||||
try {
|
||||
this.logger.debug(`Loading plugin: ${plugin.name} from ${plugin.wasmPath}`);
|
||||
|
||||
const extismPlugin = await newPlugin(plugin.wasmPath, {
|
||||
useWasi: true,
|
||||
functions: this.hostFunctions.getHostFunctions(),
|
||||
});
|
||||
|
||||
this.loadedPlugins.set(plugin.id, extismPlugin);
|
||||
this.logger.log(`Successfully loaded plugin: ${plugin.name}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to load plugin ${plugin.name}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AssetCreate' })
|
||||
async handleAssetCreate({ asset }: ArgOf<'AssetCreate'>) {
|
||||
await this.handleTrigger(PluginTriggerType.AssetCreate, {
|
||||
ownerId: asset.ownerId,
|
||||
event: { userId: asset.ownerId, asset },
|
||||
});
|
||||
}
|
||||
|
||||
private async handleTrigger<T extends PluginTriggerType>(
|
||||
triggerType: T,
|
||||
params: { ownerId: string; event: WorkflowData[T] },
|
||||
): Promise<void> {
|
||||
const workflows = await this.workflowRepository.getWorkflowByOwnerAndTrigger(params.ownerId, triggerType);
|
||||
if (workflows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const jobs: JobItem[] = workflows.map((workflow) => ({
|
||||
name: JobName.WorkflowRun,
|
||||
data: {
|
||||
id: workflow.id,
|
||||
type: triggerType,
|
||||
event: params.event,
|
||||
} as IWorkflowJob<T>,
|
||||
}));
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
this.logger.debug(`Queued ${jobs.length} workflow execution jobs for trigger ${triggerType}`);
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.WorkflowRun, queue: QueueName.Workflow })
|
||||
async handleWorkflowRun({ id: workflowId, type, event }: JobOf<JobName.WorkflowRun>): Promise<JobStatus> {
|
||||
try {
|
||||
const workflow = await this.workflowRepository.getWorkflow(workflowId);
|
||||
if (!workflow) {
|
||||
this.logger.error(`Workflow ${workflowId} not found`);
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
const workflowFilters = await this.workflowRepository.getFilters(workflowId);
|
||||
const workflowActions = await this.workflowRepository.getActions(workflowId);
|
||||
|
||||
switch (type) {
|
||||
case PluginTriggerType.AssetCreate: {
|
||||
const data = event as WorkflowData[PluginTriggerType.AssetCreate];
|
||||
const asset = data.asset;
|
||||
|
||||
const authToken = this.cryptoRepository.signJwt({ userId: data.userId }, this.pluginJwtSecret);
|
||||
|
||||
const context = {
|
||||
authToken,
|
||||
asset,
|
||||
};
|
||||
|
||||
const filtersPassed = await this.executeFilters(workflowFilters, context);
|
||||
if (!filtersPassed) {
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
await this.executeActions(workflowActions, context);
|
||||
this.logger.debug(`Workflow ${workflowId} executed successfully`);
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
case PluginTriggerType.PersonRecognized: {
|
||||
this.logger.error('unimplemented');
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
default: {
|
||||
this.logger.error(`Unknown workflow trigger type: ${type}`);
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error executing workflow ${workflowId}:`, error);
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
}
|
||||
|
||||
private async executeFilters(workflowFilters: WorkflowFilter[], context: WorkflowContext): Promise<boolean> {
|
||||
for (const workflowFilter of workflowFilters) {
|
||||
const filter = await this.pluginRepository.getFilter(workflowFilter.filterId);
|
||||
if (!filter) {
|
||||
this.logger.error(`Filter ${workflowFilter.filterId} not found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const pluginInstance = this.loadedPlugins.get(filter.pluginId);
|
||||
if (!pluginInstance) {
|
||||
this.logger.error(`Plugin ${filter.pluginId} not loaded`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const filterInput: PluginInput = {
|
||||
authToken: context.authToken,
|
||||
config: workflowFilter.filterConfig,
|
||||
data: {
|
||||
asset: context.asset,
|
||||
},
|
||||
};
|
||||
|
||||
this.logger.debug(`Calling filter ${filter.methodName} with input: ${JSON.stringify(filterInput)}`);
|
||||
|
||||
const filterResult = await pluginInstance.call(
|
||||
filter.methodName,
|
||||
new TextEncoder().encode(JSON.stringify(filterInput)),
|
||||
);
|
||||
|
||||
if (!filterResult) {
|
||||
this.logger.error(`Filter ${filter.methodName} returned null`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = JSON.parse(filterResult.text());
|
||||
if (result.passed === false) {
|
||||
this.logger.debug(`Filter ${filter.methodName} returned false, stopping workflow execution`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async executeActions(workflowActions: WorkflowAction[], context: WorkflowContext): Promise<void> {
|
||||
for (const workflowAction of workflowActions) {
|
||||
const action = await this.pluginRepository.getAction(workflowAction.actionId);
|
||||
if (!action) {
|
||||
throw new Error(`Action ${workflowAction.actionId} not found`);
|
||||
}
|
||||
|
||||
const pluginInstance = this.loadedPlugins.get(action.pluginId);
|
||||
if (!pluginInstance) {
|
||||
throw new Error(`Plugin ${action.pluginId} not loaded`);
|
||||
}
|
||||
|
||||
const actionInput: PluginInput = {
|
||||
authToken: context.authToken,
|
||||
config: workflowAction.actionConfig,
|
||||
data: {
|
||||
asset: context.asset,
|
||||
},
|
||||
};
|
||||
|
||||
this.logger.debug(`Calling action ${action.methodName} with input: ${JSON.stringify(actionInput)}`);
|
||||
|
||||
await pluginInstance.call(action.methodName, JSON.stringify(actionInput));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ describe(QueueService.name, () => {
|
||||
it('should update concurrency', () => {
|
||||
sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig });
|
||||
|
||||
expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(16);
|
||||
expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(17);
|
||||
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FacialRecognition, 1);
|
||||
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DuplicateDetection, 1);
|
||||
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BackgroundTask, 5);
|
||||
@@ -97,6 +97,7 @@ describe(QueueService.name, () => {
|
||||
[QueueName.Notification]: expectedJobStatus,
|
||||
[QueueName.BackupDatabase]: expectedJobStatus,
|
||||
[QueueName.Ocr]: expectedJobStatus,
|
||||
[QueueName.Workflow]: expectedJobStatus,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
[QueueName.VideoConversion]: { concurrency: 1 },
|
||||
[QueueName.Notification]: { concurrency: 5 },
|
||||
[QueueName.Ocr]: { concurrency: 1 },
|
||||
[QueueName.Workflow]: { concurrency: 5 },
|
||||
},
|
||||
backup: {
|
||||
database: {
|
||||
|
||||
159
server/src/services/workflow.service.ts
Normal file
159
server/src/services/workflow.service.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Workflow } from 'src/database';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
mapWorkflowAction,
|
||||
mapWorkflowFilter,
|
||||
WorkflowCreateDto,
|
||||
WorkflowResponseDto,
|
||||
WorkflowUpdateDto,
|
||||
} from 'src/dtos/workflow.dto';
|
||||
import { Permission, PluginContext, PluginTriggerType } from 'src/enum';
|
||||
import { pluginTriggers } from 'src/plugins';
|
||||
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowService extends BaseService {
|
||||
async create(auth: AuthDto, dto: WorkflowCreateDto): Promise<WorkflowResponseDto> {
|
||||
const trigger = this.getTriggerOrFail(dto.triggerType);
|
||||
|
||||
const filterInserts = await this.validateAndMapFilters(dto.filters, trigger.context);
|
||||
const actionInserts = await this.validateAndMapActions(dto.actions, trigger.context);
|
||||
|
||||
const workflow = await this.workflowRepository.createWorkflow(
|
||||
{
|
||||
ownerId: auth.user.id,
|
||||
triggerType: dto.triggerType,
|
||||
name: dto.name,
|
||||
description: dto.description || '',
|
||||
enabled: dto.enabled ?? true,
|
||||
},
|
||||
filterInserts,
|
||||
actionInserts,
|
||||
);
|
||||
|
||||
return this.mapWorkflow(workflow);
|
||||
}
|
||||
|
||||
async getAll(auth: AuthDto): Promise<WorkflowResponseDto[]> {
|
||||
const workflows = await this.workflowRepository.getWorkflowsByOwner(auth.user.id);
|
||||
|
||||
return Promise.all(workflows.map((workflow) => this.mapWorkflow(workflow)));
|
||||
}
|
||||
|
||||
async get(auth: AuthDto, id: string): Promise<WorkflowResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.WorkflowRead, ids: [id] });
|
||||
const workflow = await this.findOrFail(id);
|
||||
return this.mapWorkflow(workflow);
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, id: string, dto: WorkflowUpdateDto): Promise<WorkflowResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.WorkflowUpdate, ids: [id] });
|
||||
|
||||
if (Object.values(dto).filter((prop) => prop !== undefined).length === 0) {
|
||||
throw new BadRequestException('No fields to update');
|
||||
}
|
||||
|
||||
const workflow = await this.findOrFail(id);
|
||||
const trigger = this.getTriggerOrFail(workflow.triggerType);
|
||||
|
||||
const { filters, actions, ...workflowUpdate } = dto;
|
||||
const filterInserts = filters && (await this.validateAndMapFilters(filters, trigger.context));
|
||||
const actionInserts = actions && (await this.validateAndMapActions(actions, trigger.context));
|
||||
|
||||
const updatedWorkflow = await this.workflowRepository.updateWorkflow(
|
||||
id,
|
||||
workflowUpdate,
|
||||
filterInserts,
|
||||
actionInserts,
|
||||
);
|
||||
|
||||
return this.mapWorkflow(updatedWorkflow);
|
||||
}
|
||||
|
||||
async delete(auth: AuthDto, id: string): Promise<void> {
|
||||
await this.requireAccess({ auth, permission: Permission.WorkflowDelete, ids: [id] });
|
||||
await this.workflowRepository.deleteWorkflow(id);
|
||||
}
|
||||
|
||||
private async validateAndMapFilters(
|
||||
filters: Array<{ filterId: string; filterConfig?: any }>,
|
||||
requiredContext: PluginContext,
|
||||
) {
|
||||
for (const dto of filters) {
|
||||
const filter = await this.pluginRepository.getFilter(dto.filterId);
|
||||
if (!filter) {
|
||||
throw new BadRequestException(`Invalid filter ID: ${dto.filterId}`);
|
||||
}
|
||||
|
||||
if (!filter.supportedContexts.includes(requiredContext)) {
|
||||
throw new BadRequestException(
|
||||
`Filter "${filter.title}" does not support ${requiredContext} context. Supported contexts: ${filter.supportedContexts.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return filters.map((dto, index) => ({
|
||||
filterId: dto.filterId,
|
||||
filterConfig: dto.filterConfig || null,
|
||||
order: index,
|
||||
}));
|
||||
}
|
||||
|
||||
private async validateAndMapActions(
|
||||
actions: Array<{ actionId: string; actionConfig?: any }>,
|
||||
requiredContext: PluginContext,
|
||||
) {
|
||||
for (const dto of actions) {
|
||||
const action = await this.pluginRepository.getAction(dto.actionId);
|
||||
if (!action) {
|
||||
throw new BadRequestException(`Invalid action ID: ${dto.actionId}`);
|
||||
}
|
||||
if (!action.supportedContexts.includes(requiredContext)) {
|
||||
throw new BadRequestException(
|
||||
`Action "${action.title}" does not support ${requiredContext} context. Supported contexts: ${action.supportedContexts.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return actions.map((dto, index) => ({
|
||||
actionId: dto.actionId,
|
||||
actionConfig: dto.actionConfig || null,
|
||||
order: index,
|
||||
}));
|
||||
}
|
||||
|
||||
private getTriggerOrFail(triggerType: PluginTriggerType) {
|
||||
const trigger = pluginTriggers.find((t) => t.type === triggerType);
|
||||
if (!trigger) {
|
||||
throw new BadRequestException(`Invalid trigger type: ${triggerType}`);
|
||||
}
|
||||
return trigger;
|
||||
}
|
||||
|
||||
private async findOrFail(id: string) {
|
||||
const workflow = await this.workflowRepository.getWorkflow(id);
|
||||
if (!workflow) {
|
||||
throw new BadRequestException('Workflow not found');
|
||||
}
|
||||
return workflow;
|
||||
}
|
||||
|
||||
private async mapWorkflow(workflow: Workflow): Promise<WorkflowResponseDto> {
|
||||
const filters = await this.workflowRepository.getFilters(workflow.id);
|
||||
const actions = await this.workflowRepository.getActions(workflow.id);
|
||||
|
||||
return {
|
||||
id: workflow.id,
|
||||
ownerId: workflow.ownerId,
|
||||
triggerType: workflow.triggerType,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
createdAt: workflow.createdAt.toISOString(),
|
||||
enabled: workflow.enabled,
|
||||
filters: filters.map((f) => mapWorkflowFilter(f)),
|
||||
actions: actions.map((a) => mapWorkflowAction(a)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { VECTOR_EXTENSIONS } from 'src/constants';
|
||||
import { Asset } from 'src/database';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
ImageFormat,
|
||||
JobName,
|
||||
MemoryType,
|
||||
PluginTriggerType,
|
||||
QueueName,
|
||||
StorageFolder,
|
||||
SyncEntityType,
|
||||
@@ -263,6 +265,23 @@ export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob {
|
||||
recipientId: string;
|
||||
}
|
||||
|
||||
export interface WorkflowData {
|
||||
[PluginTriggerType.AssetCreate]: {
|
||||
userId: string;
|
||||
asset: Asset;
|
||||
};
|
||||
[PluginTriggerType.PersonRecognized]: {
|
||||
personId: string;
|
||||
assetId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IWorkflowJob<T extends PluginTriggerType = PluginTriggerType> {
|
||||
id: string;
|
||||
type: T;
|
||||
event: WorkflowData[T];
|
||||
}
|
||||
|
||||
export interface JobCounts {
|
||||
active: number;
|
||||
completed: number;
|
||||
@@ -374,7 +393,10 @@ export type JobItem =
|
||||
|
||||
// OCR
|
||||
| { name: JobName.OcrQueueAll; data: IBaseJob }
|
||||
| { name: JobName.Ocr; data: IEntityJob };
|
||||
| { name: JobName.Ocr; data: IEntityJob }
|
||||
|
||||
// Workflow
|
||||
| { name: JobName.WorkflowRun; data: IWorkflowJob };
|
||||
|
||||
export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number];
|
||||
|
||||
|
||||
35
server/src/types/plugin-schema.types.ts
Normal file
35
server/src/types/plugin-schema.types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* JSON Schema types for plugin configuration schemas
|
||||
* Based on JSON Schema Draft 7
|
||||
*/
|
||||
|
||||
export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null';
|
||||
|
||||
export interface JSONSchemaProperty {
|
||||
type?: JSONSchemaType | JSONSchemaType[];
|
||||
description?: string;
|
||||
default?: any;
|
||||
enum?: any[];
|
||||
items?: JSONSchemaProperty;
|
||||
properties?: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
additionalProperties?: boolean | JSONSchemaProperty;
|
||||
}
|
||||
|
||||
export interface JSONSchema {
|
||||
type: 'object';
|
||||
properties?: Record<string, JSONSchemaProperty>;
|
||||
required?: string[];
|
||||
additionalProperties?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue };
|
||||
|
||||
export interface FilterConfig {
|
||||
[key: string]: ConfigValue;
|
||||
}
|
||||
|
||||
export interface ActionConfig {
|
||||
[key: string]: ConfigValue;
|
||||
}
|
||||
@@ -298,6 +298,12 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
|
||||
return access.stack.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
case Permission.WorkflowRead:
|
||||
case Permission.WorkflowUpdate:
|
||||
case Permission.WorkflowDelete: {
|
||||
return access.workflow.checkOwnerAccess(auth.user.id, ids);
|
||||
}
|
||||
|
||||
default: {
|
||||
return new Set<string>();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user