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:
Alex
2025-11-14 14:05:05 -06:00
committed by GitHub
parent d784d431d0
commit 4dcc049465
89 changed files with 7264 additions and 14 deletions

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View 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[];
}

View 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,
};
}

View File

@@ -91,4 +91,7 @@ export class QueuesResponseDto implements Record<QueueName, QueueResponseDto> {
@ApiProperty({ type: QueueResponseDto })
[QueueName.Ocr]!: QueueResponseDto;
@ApiProperty({ type: QueueResponseDto })
[QueueName.Workflow]!: QueueResponseDto;
}

View File

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

View 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,
};
}

View File

@@ -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
View 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,
},
];

View File

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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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();
}
}

View File

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

View 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();
}
}

View File

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

View File

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

View 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;
}

View 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;
}

View File

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

View File

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

View File

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

View 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;
}
}

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

View File

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

View File

@@ -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: {

View 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)),
};
}
}

View File

@@ -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];

View 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;
}

View File

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