feat: workflows & plugins (#26727)

feat: plugins

chore: better types

feat: plugins
This commit is contained in:
Jason Rasmussen
2026-05-18 11:09:33 -04:00
committed by GitHub
parent 7384799f19
commit 3d075f2bf8
144 changed files with 6099 additions and 7419 deletions
@@ -0,0 +1,56 @@
import { PluginController } from 'src/controllers/plugin.controller';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PluginService } from 'src/services/plugin.service';
import request from 'supertest';
import { errorDto } from 'test/medium/responses';
import { factory } from 'test/small.factory';
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(PluginController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(PluginService);
beforeAll(async () => {
ctx = await controllerSetup(PluginController, [
{ provide: PluginService, useValue: service },
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('GET /plugins', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/plugins');
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require id to be a uuid`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.get(`/plugins`)
.query({ id: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
describe('GET /plugins/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/plugins/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require id to be a uuid`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.get(`/plugins/invalid`)
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
});
+22 -17
View File
@@ -1,7 +1,12 @@
import { Controller, Get, Param } from '@nestjs/common';
import { Controller, Get, Param, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto';
import {
PluginMethodResponseDto,
PluginMethodSearchDto,
PluginResponseDto,
PluginSearchDto,
} from 'src/dtos/plugin.dto';
import { Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { PluginService } from 'src/services/plugin.service';
@@ -12,26 +17,26 @@ import { UUIDParamDto } from 'src/validation';
export class PluginController {
constructor(private service: PluginService) {}
@Get('triggers')
@Authenticated({ permission: Permission.PluginRead })
@Endpoint({
summary: 'List all plugin triggers',
description: 'Retrieve a list of all available plugin triggers.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
getPluginTriggers(): PluginTriggerResponseDto[] {
return this.service.getTriggers();
}
@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'),
history: HistoryBuilder.v3(),
})
getPlugins(): Promise<PluginResponseDto[]> {
return this.service.getAll();
searchPlugins(@Query() dto: PluginSearchDto): Promise<PluginResponseDto[]> {
return this.service.search(dto);
}
@Get('methods')
@Authenticated({ permission: Permission.PluginRead })
@Endpoint({
summary: 'Retrieve plugin methods',
description: 'Retrieve a list of plugin methods',
history: HistoryBuilder.v3(),
})
searchPluginMethods(@Query() dto: PluginMethodSearchDto): Promise<PluginMethodResponseDto[]> {
return this.service.searchMethods(dto);
}
@Get(':id')
@@ -39,7 +44,7 @@ export class PluginController {
@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'),
history: HistoryBuilder.v3(),
})
getPlugin(@Param() { id }: UUIDParamDto): Promise<PluginResponseDto> {
return this.service.get(id);
@@ -0,0 +1,113 @@
import { WorkflowController } from 'src/controllers/workflow.controller';
import { WorkflowTrigger } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { WorkflowService } from 'src/services/workflow.service';
import request from 'supertest';
import { errorDto } from 'test/medium/responses';
import { factory } from 'test/small.factory';
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
describe(WorkflowController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(WorkflowService);
beforeAll(async () => {
ctx = await controllerSetup(WorkflowController, [
{ provide: WorkflowService, useValue: service },
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
describe('POST /workflows', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/workflows').send({});
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require a valid trigger`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.post(`/workflows`)
.send({ trigger: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([
{ path: ['trigger'], message: expect.stringContaining('Invalid option: expected one of') },
]),
);
});
it(`should require a valid enabled value`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.post(`/workflows`)
.send({ enabled: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.validationError([{ path: ['enabled'], message: 'Invalid input: expected boolean, received string' }]),
);
});
it(`should not require a name`, async () => {
const { status } = await request(ctx.getHttpServer())
.post(`/workflows`)
.send({ trigger: WorkflowTrigger.AssetCreate })
.set('Authorization', `Bearer token`);
expect(status).toBe(201);
expect(service.create).toHaveBeenCalled();
});
});
describe('GET /workflows', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/workflows');
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require id to be a uuid`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.get(`/workflows`)
.query({ id: 'invalid' })
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
describe('GET /workflows/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/workflows/${factory.uuid()}`);
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require id to be a uuid`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.get(`/workflows/invalid`)
.set('Authorization', `Bearer token`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
describe('PUT /workflows/:id', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put(`/workflows/${factory.uuid()}`).send({});
expect(ctx.authenticate).toHaveBeenCalled();
});
it(`should require id to be a uuid`, async () => {
const { status, body } = await request(ctx.getHttpServer())
.put(`/workflows/invalid`)
.set('Authorization', `Bearer token`)
.send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.validationError([{ path: ['id'], message: 'Invalid UUID' }]));
});
});
});
+38 -9
View File
@@ -1,8 +1,15 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } 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 {
WorkflowCreateDto,
WorkflowResponseDto,
WorkflowSearchDto,
WorkflowShareResponseDto,
WorkflowTriggerResponseDto,
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';
@@ -18,7 +25,7 @@ export class WorkflowController {
@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'),
history: HistoryBuilder.v3(),
})
createWorkflow(@Auth() auth: AuthDto, @Body() dto: WorkflowCreateDto): Promise<WorkflowResponseDto> {
return this.service.create(auth, dto);
@@ -29,10 +36,21 @@ export class WorkflowController {
@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'),
history: HistoryBuilder.v3(),
})
getWorkflows(@Auth() auth: AuthDto): Promise<WorkflowResponseDto[]> {
return this.service.getAll(auth);
searchWorkflows(@Auth() auth: AuthDto, @Query() dto: WorkflowSearchDto): Promise<WorkflowResponseDto[]> {
return this.service.search(auth, dto);
}
@Get('triggers')
@Authenticated({ permission: false })
@Endpoint({
summary: 'List all workflow triggers',
description: 'Retrieve a list of all available workflow triggers.',
history: HistoryBuilder.v3(),
})
getWorkflowTriggers(): WorkflowTriggerResponseDto[] {
return this.service.getTriggers();
}
@Get(':id')
@@ -40,19 +58,30 @@ export class WorkflowController {
@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'),
history: HistoryBuilder.v3(),
})
getWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<WorkflowResponseDto> {
return this.service.get(auth, id);
}
@Get(':id/share')
@Authenticated({ permission: Permission.WorkflowRead })
@Endpoint({
summary: 'Retrieve a workflow',
description: 'Retrieve a workflow details without ids, default values, etc.',
history: HistoryBuilder.v3(),
})
getWorkflowForShare(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<WorkflowShareResponseDto> {
return this.service.share(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'),
history: HistoryBuilder.v3(),
})
updateWorkflow(
@Auth() auth: AuthDto,
@@ -68,7 +97,7 @@ export class WorkflowController {
@Endpoint({
summary: 'Delete a workflow',
description: 'Delete a workflow by its ID.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
history: HistoryBuilder.v3(),
})
deleteWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
+37 -52
View File
@@ -8,8 +8,6 @@ import {
ChecksumAlgorithm,
MemoryType,
Permission,
PluginContext,
PluginTriggerType,
SharedLinkType,
SourceType,
UserAvatarColor,
@@ -18,10 +16,8 @@ import {
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
import { PluginTable } from 'src/schema/tables/plugin.table';
import { UserMetadataItem } from 'src/types';
import type { ActionConfig, FilterConfig, JSONSchema } from 'src/types/plugin-schema.types';
export type AuthUser = {
id: string;
@@ -276,42 +272,7 @@ export type AssetFace = {
isVisible: boolean;
};
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;
pluginFilterId: string;
filterConfig: FilterConfig | null;
order: number;
};
export type WorkflowAction = Selectable<WorkflowActionTable> & {
workflowId: string;
pluginActionId: string;
actionConfig: ActionConfig | null;
order: number;
};
export type Plugin = Selectable<PluginTable>;
const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const;
const userWithPrefixColumns = [
@@ -343,6 +304,32 @@ export const columns = {
'asset.height',
'asset.isEdited',
],
workflowAssetV1: [
'asset.id',
'asset.ownerId',
'asset.stackId',
'asset.livePhotoVideoId',
'asset.libraryId',
'asset.duplicateId',
'asset.createdAt',
'asset.updatedAt',
'asset.deletedAt',
'asset.fileCreatedAt',
'asset.fileModifiedAt',
'asset.localDateTime',
'asset.type',
'asset.status',
'asset.visibility',
'asset.duration',
'asset.checksum',
'asset.originalPath',
'asset.originalFileName',
'asset.isOffline',
'asset.isFavorite',
'asset.isExternal',
'asset.isEdited',
'asset.isFavorite',
],
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
assetFilesForThumbnail: [
'asset_file.id',
@@ -374,6 +361,15 @@ export const columns = {
tag: ['tag.id', 'tag.value', 'tag.createdAt', 'tag.updatedAt', 'tag.color', 'tag.parentId'],
apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'],
notification: ['id', 'createdAt', 'level', 'type', 'title', 'description', 'data', 'readAt'],
pluginMethod: [
'plugin_method.name',
'plugin_method.title',
'plugin_method.description',
'plugin_method.types',
'plugin_method.schema',
'plugin_method.hostFunctions',
'plugin_method.uiHints',
],
syncAsset: [
'asset.id',
'asset.ownerId',
@@ -487,17 +483,6 @@ export const columns = {
'asset_exif.tags',
'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;
export type LockableProperty = (typeof lockableProperties)[number];
+4
View File
@@ -200,6 +200,10 @@ export class HistoryBuilder {
private hasDeprecated = false;
private items: HistoryEntry[] = [];
static v3() {
return new HistoryBuilder().added('v3.0.0');
}
added(version: string, description?: string) {
return this.push({ version, state: 'Added', description });
}
+32
View File
@@ -0,0 +1,32 @@
import { createZodDto } from 'nestjs-zod';
import z from 'zod';
export const JsonSchemaTypeSchema = z
.enum(['string', 'number', 'integer', 'boolean', 'object'])
.meta({ id: 'JsonSchemaType' });
const JsonSchemaPropertySchema = z
.object({
type: JsonSchemaTypeSchema.optional().default('object').describe('Type'),
title: z.string().describe('Title'),
description: z.string().describe('Description'),
default: z.any().optional().describe('Default value'),
enum: z.array(z.string()).optional().describe('Valid choices for enum types'),
array: z.boolean().optional().describe('Type is an array type'),
required: z.array(z.string()).optional().describe('A list of required properties'),
uiHint: z.string().optional(),
get properties() {
return z.record(z.string(), JsonSchemaPropertySchema).optional();
},
})
.meta({ id: 'JsonSchemaPropertyDto' });
export const JsonSchemaSchema = z
.object({
...JsonSchemaPropertySchema.shape,
title: z.string().optional().describe('Title'),
description: z.string().optional().describe('Description'),
})
.meta({ id: 'JsonSchemaDto' });
export class JsonSchemaDto extends createZodDto(JsonSchemaSchema) {}
+20 -31
View File
@@ -1,39 +1,29 @@
import { createZodDto } from 'nestjs-zod';
import { PluginContextSchema } from 'src/enum';
import { JSONSchemaSchema } from 'src/types/plugin-schema.types';
import { JsonSchemaSchema } from 'src/dtos/json-schema.dto';
import { WorkflowTypeSchema } from 'src/enum';
import z from 'zod';
const pluginNameRegex = /^[a-z0-9-]+[a-z0-9]$/;
const semverRegex =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
const PluginManifestWasmSchema = z
.object({
path: z.string().describe('WASM file path'),
})
.meta({ id: 'PluginManifestWasmDto' });
export const PluginManifestMethodSchemaSchema = JsonSchemaSchema.nullable()
.optional()
.transform((value) => (value && Object.keys(value).length === 0 ? null : value));
const PluginManifestFilterSchema = z
const PluginManifestMethodSchema = z
.object({
methodName: z.string().describe('Filter method name'),
title: z.string().describe('Filter title'),
description: z.string().describe('Filter description'),
supportedContexts: z.array(PluginContextSchema).min(1).describe('Supported contexts'),
schema: JSONSchemaSchema.optional(),
name: z.string().min(1).describe('Method name'),
title: z.string().min(1).describe('Method title'),
description: z.string().min(1).describe('Method description'),
types: z.array(WorkflowTypeSchema).min(1).describe('Workflow type'),
hostFunctions: z.boolean().optional().default(false).describe('Method uses host functions'),
schema: PluginManifestMethodSchemaSchema.describe('Schema'),
uiHints: z.array(z.string()).optional().describe('Ui hints, for example "filter"'),
})
.meta({ id: 'PluginManifestFilterDto' });
.meta({ id: 'PluginManifestMethodDto' });
const PluginManifestActionSchema = z
.object({
methodName: z.string().describe('Action method name'),
title: z.string().describe('Action title'),
description: z.string().describe('Action description'),
supportedContexts: z.array(PluginContextSchema).min(1).describe('Supported contexts'),
schema: JSONSchemaSchema.optional(),
})
.meta({ id: 'PluginManifestActionDto' });
export const PluginManifestSchema = z
const PluginManifestSchema = z
.object({
name: z
.string()
@@ -44,12 +34,11 @@ export const PluginManifestSchema = z
)
.describe('Plugin name (lowercase, numbers, hyphens only)'),
version: z.string().regex(semverRegex).describe('Plugin version (semver)'),
title: z.string().describe('Plugin title'),
description: z.string().describe('Plugin description'),
author: z.string().describe('Plugin author'),
wasm: PluginManifestWasmSchema,
filters: z.array(PluginManifestFilterSchema).optional().describe('Plugin filters'),
actions: z.array(PluginManifestActionSchema).optional().describe('Plugin actions'),
title: z.string().min(1).describe('Plugin title'),
description: z.string().min(1).describe('Plugin description'),
wasmPath: z.string().min(1).describe('WASM file path'),
author: z.string().min(1).describe('Plugin author'),
methods: z.array(PluginManifestMethodSchema).optional().default([]).describe('Plugin methods'),
})
.meta({ id: 'PluginManifestDto' });
+69 -39
View File
@@ -1,39 +1,33 @@
import { createZodDto } from 'nestjs-zod';
import { PluginAction, PluginFilter } from 'src/database';
import { PluginContextSchema, PluginTriggerTypeSchema } from 'src/enum';
import { JSONSchemaSchema } from 'src/types/plugin-schema.types';
import { JsonSchemaDto } from 'src/dtos/json-schema.dto';
import { WorkflowTriggerSchema, WorkflowType, WorkflowTypeSchema } from 'src/enum';
import { asMethodString } from 'src/utils/workflow';
import z from 'zod';
const PluginTriggerResponseSchema = z
const PluginSearchSchema = z
.object({
type: PluginTriggerTypeSchema,
contextType: PluginContextSchema,
id: z.uuidv4().optional().describe('Plugin ID'),
enabled: z.boolean().optional().describe('Whether the plugin is enabled'),
name: z.string().optional(),
version: z.string().optional(),
title: z.string().optional(),
description: z.string().optional(),
})
.meta({ id: 'PluginTriggerResponseDto' });
.meta({ id: 'PluginSearchDto' });
const PluginFilterResponseSchema = z
const PluginMethodResponseSchema = z
.object({
id: z.string().describe('Filter ID'),
pluginId: z.string().describe('Plugin ID'),
methodName: z.string().describe('Method name'),
title: z.string().describe('Filter title'),
description: z.string().describe('Filter description'),
supportedContexts: z.array(PluginContextSchema).describe('Supported contexts'),
schema: JSONSchemaSchema.nullable().describe('Filter schema'),
key: z.string().describe('Key'),
name: z.string().describe('Name'),
title: z.string().describe('Title'),
description: z.string().describe('Description'),
types: z.array(WorkflowTypeSchema).describe('Workflow types'),
uiHints: z.array(z.string()).describe('Ui hints'),
// TODO fix this
schema: z.object().optional(),
hostFunctions: z.boolean(),
})
.meta({ id: 'PluginFilterResponseDto' });
const PluginActionResponseSchema = z
.object({
id: z.string().describe('Action ID'),
pluginId: z.string().describe('Plugin ID'),
methodName: z.string().describe('Method name'),
title: z.string().describe('Action title'),
description: z.string().describe('Action description'),
supportedContexts: z.array(PluginContextSchema).describe('Supported contexts'),
schema: JSONSchemaSchema.nullable().describe('Action schema'),
})
.meta({ id: 'PluginActionResponseDto' });
.meta({ id: 'PluginMethodResponseDto' });
const PluginResponseSchema = z
.object({
@@ -45,29 +39,53 @@ const PluginResponseSchema = z
version: z.string().describe('Plugin version'),
createdAt: z.string().describe('Creation date'),
updatedAt: z.string().describe('Last update date'),
filters: z.array(PluginFilterResponseSchema).describe('Plugin filters'),
actions: z.array(PluginActionResponseSchema).describe('Plugin actions'),
methods: z.array(PluginMethodResponseSchema).describe('Plugin methods'),
})
.meta({ id: 'PluginResponseDto' });
export class PluginTriggerResponseDto extends createZodDto(PluginTriggerResponseSchema) {}
export class PluginResponseDto extends createZodDto(PluginResponseSchema) {}
const PluginMethodSearchSchema = z
.object({
id: z.uuidv4().optional().describe('Plugin method ID'),
enabled: z.boolean().optional().describe('Whether the plugin method is enabled'),
name: z.string().optional(),
title: z.string().optional(),
description: z.string().optional(),
type: WorkflowTypeSchema.optional().describe('Workflow types'),
trigger: WorkflowTriggerSchema.optional().describe('Workflow trigger'),
pluginName: z.string().optional().describe('Plugin name'),
pluginVersion: z.string().optional().describe('Plugin version'),
})
.meta({ id: 'PluginMethodSearchDto' });
type MapPlugin = {
export class PluginSearchDto extends createZodDto(PluginSearchSchema) {}
export class PluginResponseDto extends createZodDto(PluginResponseSchema) {}
export class PluginMethodSearchDto extends createZodDto(PluginMethodSearchSchema) {}
export class PluginMethodResponseDto extends createZodDto(PluginMethodResponseSchema) {}
type Plugin = {
id: string;
name: string;
title: string;
description: string;
author: string;
version: string;
wasmPath: string;
createdAt: Date;
updatedAt: Date;
filters: PluginFilter[];
actions: PluginAction[];
methods: PluginMethod[];
};
export function mapPlugin(plugin: MapPlugin): PluginResponseDto {
type PluginMethod = {
pluginName: string;
name: string;
title: string;
description: string;
types: WorkflowType[];
schema: JsonSchemaDto | null;
hostFunctions: boolean;
uiHints: string[];
};
export function mapPlugin(plugin: Plugin): PluginResponseDto {
return {
id: plugin.id,
name: plugin.name,
@@ -77,7 +95,19 @@ export function mapPlugin(plugin: MapPlugin): PluginResponseDto {
version: plugin.version,
createdAt: plugin.createdAt.toISOString(),
updatedAt: plugin.updatedAt.toISOString(),
filters: plugin.filters,
actions: plugin.actions,
methods: plugin.methods.map((method) => mapMethod(method)),
};
}
export const mapMethod = (method: PluginMethod): PluginMethodResponseDto => {
return {
key: asMethodString({ pluginName: method.pluginName, methodName: method.name }),
name: method.name,
title: method.title,
hostFunctions: method.hostFunctions,
uiHints: method.uiHints,
description: method.description,
types: method.types,
schema: method.schema as any,
};
};
+98 -64
View File
@@ -1,101 +1,135 @@
import type { WorkflowStepConfig } from '@immich/plugin-sdk';
import { createZodDto } from 'nestjs-zod';
import type { WorkflowAction, WorkflowFilter } from 'src/database';
import { PluginTriggerTypeSchema } from 'src/enum';
import { ActionConfigSchema, FilterConfigSchema } from 'src/types/plugin-schema.types';
import { WorkflowTrigger, WorkflowTriggerSchema, WorkflowTypeSchema } from 'src/enum';
import z from 'zod';
const WorkflowFilterItemSchema = z
const WorkflowTriggerResponseSchema = z
.object({
pluginFilterId: z.uuidv4().describe('Plugin filter ID'),
filterConfig: FilterConfigSchema.optional(),
trigger: WorkflowTriggerSchema.describe('Trigger type'),
types: z.array(WorkflowTypeSchema).describe('Workflow types'),
})
.meta({ id: 'WorkflowFilterItemDto' });
.meta({ id: 'WorkflowTriggerResponseDto' });
const WorkflowActionItemSchema = z
const WorkflowSearchSchema = z
.object({
pluginActionId: z.uuidv4().describe('Plugin action ID'),
actionConfig: ActionConfigSchema.optional(),
id: z.uuidv4().optional().describe('Workflow ID'),
trigger: WorkflowTriggerSchema.optional().describe('Workflow trigger type'),
name: z.string().optional().describe('Workflow name'),
description: z.string().optional().describe('Workflow description'),
enabled: z.boolean().optional().describe('Workflow enabled'),
})
.meta({ id: 'WorkflowActionItemDto' });
.meta({ id: 'WorkflowSearchDto' });
const WorkflowStepSchema = z
.object({
method: z.string().describe('Step plugin method'),
config: z.record(z.string(), z.unknown()).nullable().describe('Step configuration'),
enabled: z.boolean().optional().describe('Step is enabled'),
})
.meta({ id: 'WorkflowStepDto' });
const WorkflowShareStepSchema = z
.object({
method: z.string().describe('Step plugin method'),
config: z.record(z.string(), z.unknown()).nullable().describe('Step configuration'),
enabled: z.boolean().optional().describe('Step is enabled'),
})
.meta({ id: 'WorkflowShareStepDto' });
const WorkflowCreateSchema = z
.object({
triggerType: PluginTriggerTypeSchema,
name: z.string().describe('Workflow name'),
description: z.string().optional().describe('Workflow description'),
trigger: WorkflowTriggerSchema.describe('Workflow trigger type'),
name: z.string().nullable().optional().describe('Workflow name'),
description: z.string().nullable().optional().describe('Workflow description'),
enabled: z.boolean().optional().describe('Workflow enabled'),
filters: z.array(WorkflowFilterItemSchema).describe('Workflow filters'),
actions: z.array(WorkflowActionItemSchema).describe('Workflow actions'),
steps: z.array(WorkflowStepSchema).optional(),
})
.meta({ id: 'WorkflowCreateDto' });
const WorkflowUpdateSchema = z
.object({
triggerType: PluginTriggerTypeSchema.optional(),
name: z.string().optional().describe('Workflow name'),
description: z.string().optional().describe('Workflow description'),
trigger: WorkflowTriggerSchema.optional().describe('Workflow trigger type'),
name: z.string().nullable().optional().describe('Workflow name'),
description: z.string().nullable().optional().describe('Workflow description'),
enabled: z.boolean().optional().describe('Workflow enabled'),
filters: z.array(WorkflowFilterItemSchema).optional().describe('Workflow filters'),
actions: z.array(WorkflowActionItemSchema).optional().describe('Workflow actions'),
steps: z.array(WorkflowStepSchema).optional(),
})
.meta({ id: 'WorkflowUpdateDto' });
const WorkflowFilterResponseSchema = z
.object({
id: z.string().describe('Filter ID'),
workflowId: z.string().describe('Workflow ID'),
pluginFilterId: z.string().describe('Plugin filter ID'),
filterConfig: FilterConfigSchema.nullable(),
order: z.int().describe('Filter order'),
})
.meta({ id: 'WorkflowFilterResponseDto' });
const WorkflowActionResponseSchema = z
.object({
id: z.string().describe('Action ID'),
workflowId: z.string().describe('Workflow ID'),
pluginActionId: z.string().describe('Plugin action ID'),
actionConfig: ActionConfigSchema.nullable(),
order: z.int().describe('Action order'),
})
.meta({ id: 'WorkflowActionResponseDto' });
const WorkflowResponseSchema = z
.object({
id: z.string().describe('Workflow ID'),
ownerId: z.string().describe('Owner user ID'),
triggerType: PluginTriggerTypeSchema,
trigger: WorkflowTriggerSchema.describe('Workflow trigger type'),
name: z.string().nullable().describe('Workflow name'),
description: z.string().describe('Workflow description'),
description: z.string().nullable().describe('Workflow description'),
createdAt: z.string().describe('Creation date'),
updatedAt: z.string().describe('Update date'),
enabled: z.boolean().describe('Workflow enabled'),
filters: z.array(WorkflowFilterResponseSchema).describe('Workflow filters'),
actions: z.array(WorkflowActionResponseSchema).describe('Workflow actions'),
steps: z.array(WorkflowStepSchema).describe('Workflow steps'),
})
.meta({ id: 'WorkflowResponseDto' });
const WorkflowShareResponseSchema = z
.object({
trigger: WorkflowTriggerSchema.describe('Workflow trigger type'),
name: z.string().nullable().describe('Workflow name'),
description: z.string().nullable().describe('Workflow description'),
steps: z.array(WorkflowShareStepSchema).describe('Workflow steps'),
})
.meta({ id: 'WorkflowShareResponseDto' });
export class WorkflowTriggerResponseDto extends createZodDto(WorkflowTriggerResponseSchema) {}
export class WorkflowSearchDto extends createZodDto(WorkflowSearchSchema) {}
export class WorkflowCreateDto extends createZodDto(WorkflowCreateSchema) {}
export class WorkflowUpdateDto extends createZodDto(WorkflowUpdateSchema) {}
export class WorkflowResponseDto extends createZodDto(WorkflowResponseSchema) {}
class WorkflowFilterResponseDto extends createZodDto(WorkflowFilterResponseSchema) {}
class WorkflowActionResponseDto extends createZodDto(WorkflowActionResponseSchema) {}
export class WorkflowShareResponseDto extends createZodDto(WorkflowShareResponseSchema) {}
export function mapWorkflowFilter(filter: WorkflowFilter): WorkflowFilterResponseDto {
return {
id: filter.id,
workflowId: filter.workflowId,
pluginFilterId: filter.pluginFilterId,
filterConfig: filter.filterConfig,
order: filter.order,
};
}
type Workflow = {
id: string;
createdAt: Date;
updatedAt: Date;
trigger: WorkflowTrigger;
name: string | null;
description: string | null;
enabled: boolean;
};
export function mapWorkflowAction(action: WorkflowAction): WorkflowActionResponseDto {
type WorkflowStep = {
enabled: boolean;
methodName: string;
config: WorkflowStepConfig | null;
pluginName: string;
};
export const mapWorkflow = (workflow: Workflow & { steps: WorkflowStep[] }): WorkflowResponseDto => {
return {
id: action.id,
workflowId: action.workflowId,
pluginActionId: action.pluginActionId,
actionConfig: action.actionConfig,
order: action.order,
id: workflow.id,
enabled: workflow.enabled,
trigger: workflow.trigger,
name: workflow.name,
description: workflow.description,
createdAt: workflow.createdAt.toISOString(),
updatedAt: workflow.updatedAt.toISOString(),
steps: workflow.steps.map((step) => ({
method: `${step.pluginName}#${step.methodName}`,
// TODO fix this
config: step.config as any,
enabled: step.enabled,
})),
};
}
};
export const mapWorkflowShare = (workflow: Workflow & { steps: WorkflowStep[] }): WorkflowShareResponseDto => {
return {
trigger: workflow.trigger,
name: workflow.name,
description: workflow.description,
steps: workflow.steps.map((step) => ({
method: `${step.pluginName}#${step.methodName}`,
// TODO fix this
config: step.config as any,
enabled: step.enabled ? undefined : false,
})),
};
};
+17 -6
View File
@@ -749,8 +749,11 @@ export enum BootstrapEventPriority {
StorageService = -195,
// Other services may need to queue jobs on bootstrap.
JobService = -190,
// Initialise config after other bootstrap services, stop other services from using config on bootstrap
// Initialize config after other bootstrap services, stop other services from using config on bootstrap
SystemConfig = 100,
PluginSync = 190,
// Load plugins into memory after sync
PluginLoad = 200,
}
export enum QueueName {
@@ -863,7 +866,7 @@ export enum JobName {
Ocr = 'Ocr',
// Workflow
WorkflowRun = 'WorkflowRun',
WorkflowAssetCreate = 'WorkflowAssetCreate',
}
export const JobNameSchema = z.enum(JobName).describe('Job name').meta({ id: 'JobName' });
@@ -909,6 +912,7 @@ export enum DatabaseLock {
CLIPDimSize = 512,
Library = 1337,
NightlyJobs = 600,
PluginImport = 666,
MediaLocation = 700,
GetSystemConfig = 69,
BackupDatabase = 42,
@@ -1160,12 +1164,19 @@ export enum PluginContext {
export const PluginContextSchema = z.enum(PluginContext).describe('Plugin context').meta({ id: 'PluginContextType' });
export enum PluginTriggerType {
export enum WorkflowTrigger {
AssetCreate = 'AssetCreate',
PersonRecognized = 'PersonRecognized',
}
export const PluginTriggerTypeSchema = z
.enum(PluginTriggerType)
export const WorkflowTriggerSchema = z
.enum(WorkflowTrigger)
.describe('Plugin trigger type')
.meta({ id: 'PluginTriggerType' });
.meta({ id: 'WorkflowTrigger' });
export enum WorkflowType {
AssetV1 = 'AssetV1',
AssetPersonV1 = 'AssetPersonV1',
}
export const WorkflowTypeSchema = z.enum(WorkflowType).describe('Workflow type').meta({ id: 'WorkflowType' });
-17
View File
@@ -1,17 +0,0 @@
import { PluginContext, PluginTriggerType } from 'src/enum';
export type PluginTrigger = {
type: PluginTriggerType;
contextType: PluginContext;
};
export const pluginTriggers: PluginTrigger[] = [
{
type: PluginTriggerType.AssetCreate,
contextType: PluginContext.Asset,
},
{
type: PluginTriggerType.PersonRecognized,
contextType: PluginContext.Person,
},
];
+115 -115
View File
@@ -1,159 +1,159 @@
-- NOTE: This file is auto generated by ./sql-generator
-- PluginRepository.getPlugin
-- PluginRepository.getForLoad
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",
"plugin"."id",
"plugin"."name",
"plugin"."version",
"plugin"."wasmBytes",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
"plugin_method"."name",
"plugin_method"."hostFunctions"
from
"plugin_filter"
"plugin_method"
where
"plugin_filter"."pluginId" = "plugin"."id"
"plugin_method"."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"
) as "methods"
from
"plugin"
where
"plugin"."id" = $1
"enabled" = $1
-- PluginRepository.getPluginByName
-- PluginRepository.search
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",
"plugin"."id",
"plugin"."name",
"plugin"."title",
"plugin"."description",
"plugin"."author",
"plugin"."version",
"plugin"."createdAt",
"plugin"."updatedAt",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
"plugin_method"."name",
"plugin_method"."title",
"plugin_method"."description",
"plugin_method"."types",
"plugin_method"."schema",
"plugin_method"."hostFunctions",
"plugin_method"."uiHints",
"plugin"."name" as "pluginName"
from
"plugin_filter"
"plugin_method"
where
"plugin_filter"."pluginId" = "plugin"."id"
"plugin_method"."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"
) as "methods"
from
"plugin"
order by
"plugin"."name"
-- PluginRepository.getFilter
-- PluginRepository.getByName
select
*
"plugin"."id",
"plugin"."name",
"plugin"."title",
"plugin"."description",
"plugin"."author",
"plugin"."version",
"plugin"."createdAt",
"plugin"."updatedAt",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"plugin_method"."name",
"plugin_method"."title",
"plugin_method"."description",
"plugin_method"."types",
"plugin_method"."schema",
"plugin_method"."hostFunctions",
"plugin_method"."uiHints",
"plugin"."name" as "pluginName"
from
"plugin_method"
where
"plugin_method"."pluginId" = "plugin"."id"
) as agg
) as "methods"
from
"plugin_filter"
"plugin"
where
"id" = $1
"plugin"."name" = $1
-- PluginRepository.getFiltersByPlugin
-- PluginRepository.get
select
*
"plugin"."id",
"plugin"."name",
"plugin"."title",
"plugin"."description",
"plugin"."author",
"plugin"."version",
"plugin"."createdAt",
"plugin"."updatedAt",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"plugin_method"."name",
"plugin_method"."title",
"plugin_method"."description",
"plugin_method"."types",
"plugin_method"."schema",
"plugin_method"."hostFunctions",
"plugin_method"."uiHints",
"plugin"."name" as "pluginName"
from
"plugin_method"
where
"plugin_method"."pluginId" = "plugin"."id"
) as agg
) as "methods"
from
"plugin_filter"
"plugin"
where
"pluginId" = $1
"plugin"."id" = $1
-- PluginRepository.getAction
-- PluginRepository.getForValidation
select
*
"plugin_method"."id",
"plugin_method"."name",
"plugin"."name" as "pluginName",
"plugin_method"."types"
from
"plugin_action"
where
"id" = $1
"plugin_method"
inner join "plugin" on "plugin_method"."pluginId" = "plugin"."id"
-- PluginRepository.getActionsByPlugin
-- PluginRepository.searchMethods
select
*
"plugin"."name" as "pluginName",
"plugin_method"."pluginId",
"plugin_method"."id",
"plugin_method"."name",
"plugin_method"."title",
"plugin_method"."description",
"plugin_method"."types",
"plugin_method"."schema",
"plugin_method"."hostFunctions",
"plugin_method"."uiHints"
from
"plugin_action"
where
"pluginId" = $1
"plugin_method"
inner join "plugin" on "plugin"."id" = "plugin_method"."pluginId"
order by
"plugin_method"."name"
+84 -53
View File
@@ -1,70 +1,101 @@
-- NOTE: This file is auto generated by ./sql-generator
-- WorkflowRepository.getWorkflow
-- WorkflowRepository.search
select
*
"workflow"."id",
"workflow"."name",
"workflow"."description",
"workflow"."trigger",
"workflow"."enabled",
"workflow"."createdAt",
"workflow"."updatedAt",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"plugin"."name" as "pluginName",
"plugin_method"."name" as "methodName",
"workflow_step"."config",
"workflow_step"."enabled"
from
"workflow_step"
inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId"
inner join "plugin" on "plugin"."id" = "plugin_method"."pluginId"
where
"workflow"."id" = "workflow_step"."workflowId"
) as agg
) as "steps"
from
"workflow"
order by
"createdAt" desc
-- WorkflowRepository.get
select
"workflow"."id",
"workflow"."name",
"workflow"."description",
"workflow"."trigger",
"workflow"."enabled",
"workflow"."createdAt",
"workflow"."updatedAt",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"plugin"."name" as "pluginName",
"plugin_method"."name" as "methodName",
"workflow_step"."config",
"workflow_step"."enabled"
from
"workflow_step"
inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId"
inner join "plugin" on "plugin"."id" = "plugin_method"."pluginId"
where
"workflow"."id" = "workflow_step"."workflowId"
) as agg
) as "steps"
from
"workflow"
where
"id" = $1
order by
"createdAt" desc
-- WorkflowRepository.getWorkflowsByOwner
-- WorkflowRepository.getForWorkflowRun
select
*
"workflow"."id",
"workflow"."name",
"workflow"."trigger",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"workflow_step"."id",
"workflow_step"."config",
"plugin_method"."pluginId" as "pluginId",
"plugin_method"."name" as "methodName",
"plugin_method"."types" as "types",
"plugin_method"."hostFunctions"
from
"workflow_step"
inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId"
where
"workflow_step"."workflowId" = "workflow"."id"
and "workflow_step"."enabled" = $1
) as agg
) as "steps"
from
"workflow"
where
"ownerId" = $1
order by
"createdAt" desc
-- WorkflowRepository.getWorkflowsByTrigger
select
*
from
"workflow"
where
"triggerType" = $1
and "enabled" = $2
-- WorkflowRepository.getWorkflowByOwnerAndTrigger
select
*
from
"workflow"
where
"ownerId" = $1
and "triggerType" = $2
"id" = $2
and "enabled" = $3
-- WorkflowRepository.deleteWorkflow
-- WorkflowRepository.delete
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
+1 -1
View File
@@ -346,7 +346,7 @@ const getEnv = (): EnvData => {
root: folders.web,
indexHtml: join(folders.web, 'index.html'),
},
corePlugin: join(buildFolder, 'corePlugin'),
corePlugin: join(buildFolder, 'plugins', 'immich-plugin-core'),
},
setup: {
@@ -119,6 +119,10 @@ export class LoggingRepository {
logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : [];
}
getLogLevel(): LogLevel {
return logLevels[0] || LogLevel.Fatal;
}
verbose(message: string, ...details: LogDetails) {
this.handleMessage(LogLevel.Verbose, message, details);
}
+223 -145
View File
@@ -1,176 +1,254 @@
import { CallContext, Plugin as ExtismPlugin, newPlugin } from '@extism/extism';
import { Injectable } from '@nestjs/common';
import { Kysely } from 'kysely';
import { createPool, Pool } from 'generic-pool';
import { Insertable, 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 { PluginMethodSearchDto, PluginSearchDto } from 'src/dtos/plugin.dto';
import { LogLevel, WorkflowType } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { DB } from 'src/schema';
import { PluginMethodTable } from 'src/schema/tables/plugin-method.table';
import { PluginTable } from 'src/schema/tables/plugin.table';
type PluginMethod = { pluginKey: string; methodName: string };
type PluginLoad = { key: string; label: string; wasmBytes: Buffer };
export type PluginHostFunction = (callContext: CallContext, input: bigint) => Promise<bigint> | bigint;
export type PluginLoadOptions = {
runInWorker?: boolean;
functions?: Record<string, PluginHostFunction>;
};
export type PluginMethodSearchResponse = {
id: string;
name: string;
pluginName: string;
types: WorkflowType[];
};
const levels = {
[LogLevel.Verbose]: 'trace',
[LogLevel.Debug]: 'debug',
[LogLevel.Log]: 'info',
[LogLevel.Warn]: 'warn',
[LogLevel.Error]: 'error',
[LogLevel.Fatal]: 'error',
} as const;
const asExtismLogLevel = (logLevel: LogLevel) => levels[logLevel] || 'info';
@Injectable()
export class PluginRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
private pluginMap: Map<string, { label: string; pool: Pool<ExtismPlugin> }> = new Map();
/**
* 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();
constructor(
@InjectKysely() private db: Kysely<DB>,
private logger: LoggingRepository,
) {
this.logger.setContext(PluginRepository.name);
}
@GenerateSql()
getAllPlugins() {
getForLoad() {
return this.db
.selectFrom('plugin')
.select((eb) => [
...columns.plugin,
'plugin.id',
'plugin.name',
'plugin.version',
'plugin.wasmBytes',
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'),
eb
.selectFrom('plugin_method')
.whereRef('plugin_method.pluginId', '=', 'plugin.id')
.select(['plugin_method.name', 'plugin_method.hostFunctions']),
).as('methods'),
])
.where('enabled', '=', true)
.execute();
}
private queryBuilder() {
return this.db.selectFrom('plugin').select((eb) => [
'plugin.id',
'plugin.name',
'plugin.title',
'plugin.description',
'plugin.author',
'plugin.version',
'plugin.createdAt',
'plugin.updatedAt',
jsonArrayFrom(
eb
.selectFrom('plugin_method')
.select([...columns.pluginMethod, 'plugin.name as pluginName'])
.whereRef('plugin_method.pluginId', '=', 'plugin.id'),
).as('methods'),
]);
}
@GenerateSql()
search(dto: PluginSearchDto = {}) {
return this.queryBuilder()
.$if(!!dto.id, (qb) => qb.where('plugin.id', '=', dto.id!))
.$if(!!dto.name, (qb) => qb.where('plugin.name', '=', dto.name!))
.$if(!!dto.title, (qb) => qb.where('plugin.title', '=', dto.title!))
.$if(!!dto.description, (qb) => qb.where('plugin.description', '=', dto.description!))
.$if(!!dto.version, (qb) => qb.where('plugin.version', '=', dto.version!))
.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.STRING] })
getByName(name: string) {
return this.queryBuilder().where('plugin.name', '=', name).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getFiltersByPlugin(pluginId: string) {
return this.db.selectFrom('plugin_filter').selectAll().where('pluginId', '=', pluginId).execute();
get(id: string) {
return this.queryBuilder().where('plugin.id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getAction(id: string) {
return this.db.selectFrom('plugin_action').selectAll().where('id', '=', id).executeTakeFirst();
@GenerateSql()
getForValidation(): Promise<PluginMethodSearchResponse[]> {
return this.db
.selectFrom('plugin_method')
.innerJoin('plugin', 'plugin_method.pluginId', 'plugin.id')
.select(['plugin_method.id', 'plugin_method.name', 'plugin.name as pluginName', 'plugin_method.types'])
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getActionsByPlugin(pluginId: string) {
return this.db.selectFrom('plugin_action').selectAll().where('pluginId', '=', pluginId).execute();
@GenerateSql()
searchMethods(dto: PluginMethodSearchDto = {}) {
return this.db
.selectFrom('plugin_method')
.innerJoin('plugin', 'plugin.id', 'plugin_method.pluginId')
.select(['plugin.name as pluginName', 'plugin_method.pluginId', 'plugin_method.id', ...columns.pluginMethod])
.$if(!!dto.id, (qb) => qb.where('plugin_method.id', '=', dto.id!))
.$if(!!dto.name, (qb) => qb.where('plugin_method.name', '=', dto.name!))
.$if(!!dto.title, (qb) => qb.where('plugin_method.title', '=', dto.title!))
.$if(!!dto.type, (qb) => qb.where('plugin_method.types', '@>', [dto.type!]))
.$if(!!dto.description, (qb) => qb.where('plugin_method.description', '=', dto.description!))
.$if(!!dto.pluginVersion, (qb) => qb.where('plugin.version', '=', dto.pluginVersion!))
.$if(!!dto.pluginName, (qb) => qb.where('plugin.name', '=', dto.pluginName!))
.orderBy('plugin_method.name')
.execute();
}
async upsert(dto: Insertable<PluginTable>, initialMethods: Omit<Insertable<PluginMethodTable>, 'pluginId'>[]) {
return this.db.transaction().execute(async (tx) => {
// Upsert the plugin
const plugin = await tx
.insertInto('plugin')
.values(dto)
.onConflict((oc) =>
oc.columns(['name', 'version']).doUpdateSet((eb) => ({
title: eb.ref('excluded.title'),
description: eb.ref('excluded.description'),
author: eb.ref('excluded.author'),
version: eb.ref('excluded.version'),
wasmBytes: eb.ref('excluded.wasmBytes'),
})),
)
.returning(['id', 'name'])
.executeTakeFirstOrThrow();
// prune methods that no longer exist
if (initialMethods.length > 0) {
await tx
.deleteFrom('plugin_method')
.where('plugin_method.pluginId', '=', plugin.id)
.where(
'name',
'not in',
initialMethods.map((method) => method.name),
)
.execute();
}
const methods =
initialMethods.length > 0
? await tx
.insertInto('plugin_method')
.values(initialMethods.map((method) => ({ ...method, pluginId: plugin.id })))
.onConflict((oc) =>
oc.columns(['pluginId', 'name']).doUpdateSet(({ ref }) => ({
pluginId: ref('excluded.pluginId'),
name: ref('excluded.name'),
title: ref('excluded.title'),
description: ref('excluded.description'),
types: ref('excluded.types'),
hostFunctions: ref('excluded.hostFunctions'),
uiHints: ref('excluded.uiHints'),
schema: ref('excluded.schema'),
})),
)
.returningAll()
.execute()
: [];
return { ...plugin, methods };
});
}
async load({ key, label, wasmBytes }: PluginLoad, { runInWorker, functions }: PluginLoadOptions) {
const data = new Uint8Array(wasmBytes.buffer, wasmBytes.byteOffset, wasmBytes.byteLength);
const logger = LoggingRepository.create(`Plugin:${label}`);
const pool = createPool<ExtismPlugin>(
{
create: () =>
newPlugin(
{ wasm: [{ data }] },
{
useWasi: true,
runInWorker,
functions: {
'extism:host/user': functions ?? {},
},
logger: {
trace: (message) => logger.verbose(message),
info: (message) => logger.log(message),
debug: (message) => logger.debug(message),
warn: (message) => logger.warn(message),
error: (message) => logger.error(message),
} as Console,
logLevel: asExtismLogLevel(logger.getLogLevel()),
},
),
destroy: (plugin) => plugin.close(),
},
{ min: 1, max: 5 },
);
try {
await pool.ready();
this.pluginMap.set(key, { pool, label });
} catch (error: Error | any) {
throw new Error(`Unable to instantiate plugin: ${key}`, { cause: error });
}
}
async callMethod<T>({ pluginKey, methodName }: PluginMethod, input: unknown) {
const item = this.pluginMap.get(pluginKey);
if (!item) {
throw new Error(`No loaded plugin found for ${pluginKey}`);
}
const { pool, label } = item;
try {
const plugin = await pool.acquire();
try {
const result = await plugin.call(methodName, JSON.stringify(input));
return (result ? result.json() : result) as T;
} finally {
await pool.release(plugin);
}
} catch (error: Error | any) {
throw new Error(`Plugin method call failed: ${label}#${methodName}`, { cause: error });
}
}
}
+28 -9
View File
@@ -2,7 +2,15 @@ import { Injectable } from '@nestjs/common';
import archiver from 'archiver';
import chokidar, { ChokidarOptions } from 'chokidar';
import { escapePath, glob, globStream } from 'fast-glob';
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, ReadOptionsWithBuffer } from 'node:fs';
import {
constants,
createReadStream,
createWriteStream,
Dirent,
existsSync,
mkdirSync,
ReadOptionsWithBuffer,
} from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { PassThrough, Readable, Writable } from 'node:stream';
@@ -50,6 +58,10 @@ export class StorageRepository {
return fs.readdir(folder);
}
readdirWithTypes(folder: string): Promise<Dirent[]> {
return fs.readdir(folder, { withFileTypes: true });
}
copyFile(source: string, target: string) {
return fs.copyFile(source, target);
}
@@ -117,17 +129,24 @@ export class StorageRepository {
}
async readFile(filepath: string, options?: ReadOptionsWithBuffer<Buffer>): Promise<Buffer> {
const file = await fs.open(filepath);
try {
const { buffer } = await file.read(options);
return buffer as Buffer;
} finally {
await file.close();
// read a slice
if (options) {
const file = await fs.open(filepath);
try {
const { buffer } = await file.read(options);
return buffer as Buffer;
} finally {
await file.close();
}
}
// read everything
return fs.readFile(filepath);
}
async readTextFile(filepath: string): Promise<string> {
return fs.readFile(filepath, 'utf8');
async readJsonFile<T>(filepath: string): Promise<T> {
const file = await fs.readFile(filepath, 'utf8');
return JSON.parse(file) as T;
}
async checkFileExists(filepath: string, mode = constants.F_OK): Promise<boolean> {
+149 -118
View File
@@ -1,149 +1,180 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Updateable } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { PluginTriggerType } from 'src/enum';
import { WorkflowSearchDto } from 'src/dtos/workflow.dto';
import { DB } from 'src/schema';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table';
import { WorkflowTable } from 'src/schema/tables/workflow.table';
export type WorkflowStepUpsert = Omit<Insertable<WorkflowStepTable>, 'workflowId' | 'order'>;
@Injectable()
export class WorkflowRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
private queryBuilder(db?: Kysely<DB>) {
return (db ?? this.db)
.selectFrom('workflow')
.select([
'workflow.id',
'workflow.name',
'workflow.description',
'workflow.trigger',
'workflow.enabled',
'workflow.createdAt',
'workflow.updatedAt',
])
.select((eb) => [
jsonArrayFrom(
eb
.selectFrom('workflow_step')
.innerJoin('plugin_method', 'plugin_method.id', 'workflow_step.pluginMethodId')
.innerJoin('plugin', 'plugin.id', 'plugin_method.pluginId')
.whereRef('workflow.id', '=', 'workflow_step.workflowId')
.select([
'plugin.name as pluginName',
'plugin_method.name as methodName',
'workflow_step.config',
'workflow_step.enabled',
]),
).as('steps'),
]);
}
@GenerateSql({ params: [DummyValue.UUID] })
getWorkflow(id: string) {
search(dto: WorkflowSearchDto & { ownerId?: string }) {
return this.queryBuilder()
.$if(!!dto.ownerId, (qb) => qb.where('ownerId', '=', dto.ownerId!))
.$if(!!dto.trigger, (qb) => qb.where('trigger', '=', dto.trigger!))
.$if(dto.enabled !== undefined, (qb) => qb.where('enabled', '=', dto.enabled!))
.orderBy('createdAt', 'desc')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
get(id: string) {
return this.queryBuilder().where('id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getForWorkflowRun(id: string) {
return this.db
.selectFrom('workflow')
.selectAll()
.select(['workflow.id', 'workflow.name', 'workflow.trigger'])
.select((eb) => [
jsonArrayFrom(
eb
.selectFrom('workflow_step')
.innerJoin('plugin_method', 'plugin_method.id', 'workflow_step.pluginMethodId')
.whereRef('workflow_step.workflowId', '=', 'workflow.id')
.where('workflow_step.enabled', '=', true)
.select([
'workflow_step.id',
'workflow_step.config',
'plugin_method.pluginId as pluginId',
'plugin_method.name as methodName',
'plugin_method.types as types',
'plugin_method.hostFunctions',
]),
).as('steps'),
])
.where('id', '=', id)
.orderBy('createdAt', 'desc')
.where('enabled', '=', true)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getWorkflowsByOwner(ownerId: string) {
return this.db
.selectFrom('workflow')
.selectAll()
.where('ownerId', '=', ownerId)
.orderBy('createdAt', 'desc')
.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;
create(dto: Insertable<WorkflowTable>, steps?: WorkflowStepUpsert[]) {
return this.db.transaction().execute(async (tx) => {
const { id } = await tx.insertInto('workflow').values(dto).returning(['id']).executeTakeFirstOrThrow();
return this.replaceAndReturn(tx, id, steps);
});
}
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();
update(id: string, dto: Updateable<WorkflowTable>, steps?: WorkflowStepUpsert[]) {
return this.db.transaction().execute(async (tx) => {
if (Object.values(dto).some((prop) => prop !== undefined)) {
await tx.updateTable('workflow').set(dto).where('id', '=', id).executeTakeFirstOrThrow();
}
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();
return this.replaceAndReturn(tx, id, steps);
});
}
private async replaceAndReturn(tx: Kysely<DB>, workflowId: string, steps?: WorkflowStepUpsert[]) {
if (steps) {
await tx.deleteFrom('workflow_step').where('workflowId', '=', workflowId).execute();
if (steps.length > 0) {
await tx
.insertInto('workflow_step')
.values(
steps.map((step, i) => ({
workflowId,
enabled: step.enabled ?? true,
pluginMethodId: step.pluginMethodId,
config: step.config,
order: i,
})),
)
.returningAll()
.execute();
}
}
return this.queryBuilder(tx).where('id', '=', workflowId).executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.UUID] })
async deleteWorkflow(id: string) {
async delete(id: string) {
await this.db.deleteFrom('workflow').where('id', '=', id).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getFilters(workflowId: string) {
getForAssetV1(assetId: 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();
.selectFrom('asset')
.leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id')
.select((eb) => [
...columns.workflowAssetV1,
jsonObjectFrom(
eb
.selectFrom('asset_exif')
.select([
'asset_exif.make',
'asset_exif.model',
'asset_exif.orientation',
'asset_exif.dateTimeOriginal',
'asset_exif.modifyDate',
'asset_exif.exifImageWidth',
'asset_exif.exifImageHeight',
'asset_exif.fileSizeInByte',
'asset_exif.lensModel',
'asset_exif.fNumber',
'asset_exif.focalLength',
'asset_exif.iso',
'asset_exif.latitude',
'asset_exif.longitude',
'asset_exif.city',
'asset_exif.state',
'asset_exif.country',
'asset_exif.description',
'asset_exif.fps',
'asset_exif.exposureTime',
'asset_exif.livePhotoCID',
'asset_exif.timeZone',
'asset_exif.projectionType',
'asset_exif.profileDescription',
'asset_exif.colorspace',
'asset_exif.bitsPerSample',
'asset_exif.autoStackId',
'asset_exif.rating',
'asset_exif.tags',
'asset_exif.updatedAt',
])
.whereRef('asset_exif.assetId', '=', 'asset.id'),
).as('exifInfo'),
])
.where('id', '=', assetId)
.executeTakeFirstOrThrow();
}
}
+8 -10
View File
@@ -60,7 +60,8 @@ 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 { PluginMethodTable } from 'src/schema/tables/plugin-method.table';
import { 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';
@@ -82,7 +83,8 @@ import {
VideoStreamSessionTable,
VideoStreamVariantTable,
} from 'src/schema/tables/video-stream.table';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
import { WorkflowStepTable } from 'src/schema/tables/workflow-step.table';
import { WorkflowTable } from 'src/schema/tables/workflow.table';
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
@Database({ name: 'immich' })
@@ -143,11 +145,9 @@ export class ImmichDatabase {
VideoStreamVariantTable,
VideoStreamSegmentTable,
PluginTable,
PluginFilterTable,
PluginActionTable,
PluginMethodTable,
WorkflowTable,
WorkflowFilterTable,
WorkflowActionTable,
WorkflowStepTable,
];
functions = [
@@ -264,10 +264,8 @@ export interface DB {
video_stream_segment: VideoStreamSegmentTable;
plugin: PluginTable;
plugin_filter: PluginFilterTable;
plugin_action: PluginActionTable;
plugin_method: PluginMethodTable;
workflow: WorkflowTable;
workflow_filter: WorkflowFilterTable;
workflow_action: WorkflowActionTable;
workflow_step: WorkflowStepTable;
}
@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "workflow" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "workflow" DROP COLUMN "updateId";`.execute(db);
}
@@ -0,0 +1,83 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// take #2...
await sql`DROP TABLE "workflow_action";`.execute(db);
await sql`DROP TABLE "workflow_filter";`.execute(db);
await sql`DROP TABLE "workflow";`.execute(db);
await sql`DROP TABLE "plugin_action";`.execute(db);
await sql`DROP TABLE "plugin_filter";`.execute(db);
await sql`DROP TABLE "plugin";`.execute(db);
await sql`CREATE TABLE "plugin" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"enabled" boolean NOT NULL DEFAULT true,
"name" character varying NOT NULL,
"version" character varying NOT NULL,
"title" character varying NOT NULL,
"description" character varying NOT NULL,
"author" character varying NOT NULL,
"wasmBytes" bytea NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT "plugin_name_version_uq" UNIQUE ("name", "version"),
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_method" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"pluginId" uuid NOT NULL,
"name" character varying NOT NULL,
"title" character varying NOT NULL,
"description" character varying NOT NULL,
"types" character varying[] NOT NULL,
"hostFunctions" boolean NOT NULL DEFAULT false,
"uiHints" character varying[] NOT NULL DEFAULT '{}',
"schema" jsonb,
CONSTRAINT "plugin_method_pluginId_fkey" FOREIGN KEY ("pluginId") REFERENCES "plugin" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "plugin_method_pluginId_name_uq" UNIQUE ("pluginId", "name"),
CONSTRAINT "plugin_method_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "plugin_method_pluginId_idx" ON "plugin_method" ("pluginId");`.execute(db);
await sql`CREATE TABLE "workflow" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"ownerId" uuid NOT NULL,
"trigger" character varying NOT NULL,
"name" character varying,
"description" character varying,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
"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 OR REPLACE TRIGGER "workflow_updatedAt"
BEFORE UPDATE ON "workflow"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
await sql`CREATE TABLE "workflow_step" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"enabled" boolean NOT NULL DEFAULT true,
"workflowId" uuid NOT NULL,
"pluginMethodId" uuid NOT NULL,
"config" jsonb,
"order" integer NOT NULL,
CONSTRAINT "workflow_step_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "workflow_step_pluginMethodId_fkey" FOREIGN KEY ("pluginMethodId") REFERENCES "plugin_method" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "workflow_step_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "workflow_step_workflowId_idx" ON "workflow_step" ("workflowId");`.execute(db);
await sql`CREATE INDEX "workflow_step_pluginMethodId_idx" ON "workflow_step" ("pluginMethodId");`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_workflow_updatedAt', '{"type":"trigger","name":"workflow_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"workflow_updatedAt\\"\\n BEFORE UPDATE ON \\"workflow\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.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);
}
export async function down(): Promise<void> {
// not supported
}
+1 -1
View File
@@ -111,7 +111,7 @@ export class AssetExifTable {
tags!: string[] | null;
@UpdateDateColumn({ default: () => 'clock_timestamp()' })
updatedAt!: Generated<Date>;
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@@ -0,0 +1,35 @@
import { Column, ForeignKeyColumn, Generated, PrimaryGeneratedColumn, Table, Unique } from '@immich/sql-tools';
import { JsonSchemaDto } from 'src/dtos/json-schema.dto';
import { WorkflowType } from 'src/enum';
import { PluginTable } from 'src/schema/tables/plugin.table';
@Unique({ columns: ['pluginId', 'name'] })
@Table('plugin_method')
export class PluginMethodTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
pluginId!: string;
@Column()
name!: string;
@Column()
title!: string;
@Column()
description!: string;
@Column({ type: 'character varying', array: true })
types!: Generated<WorkflowType[]>;
@Column({ type: 'boolean', default: false })
hostFunctions!: Generated<boolean>;
@Column({ type: 'jsonb', nullable: true })
schema!: JsonSchemaDto | null;
@Column({ type: 'character varying', default: [], array: true })
uiHints!: Generated<string[]>;
}
+10 -61
View File
@@ -1,25 +1,29 @@
import {
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
Index,
PrimaryGeneratedColumn,
Table,
Timestamp,
Unique,
UpdateDateColumn,
} from '@immich/sql-tools';
import { PluginContext } from 'src/enum';
import type { JSONSchema } from 'src/types/plugin-schema.types';
@Unique({ columns: ['name', 'version'] })
@Table('plugin')
export class PluginTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@Column({ type: 'boolean', default: true })
enabled!: Generated<boolean>;
@Column({ index: true, unique: true })
name!: string;
@Column()
version!: string;
@Column()
title!: string;
@@ -29,11 +33,8 @@ export class PluginTable {
@Column()
author!: string;
@Column()
version!: string;
@Column()
wasmPath!: string;
@Column({ type: 'bytea' })
wasmBytes!: Buffer;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@@ -41,55 +42,3 @@ export class PluginTable {
@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;
}
@@ -0,0 +1,26 @@
import { WorkflowStepConfig } from '@immich/plugin-sdk';
import { Column, ForeignKeyColumn, PrimaryGeneratedColumn, Table } from '@immich/sql-tools';
import { Generated } from 'kysely';
import { PluginMethodTable } from 'src/schema/tables/plugin-method.table';
import { WorkflowTable } from 'src/schema/tables/workflow.table';
@Table('workflow_step')
export class WorkflowStepTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@Column({ type: 'boolean', default: true })
enabled!: boolean;
@ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
workflowId!: string;
@ForeignKeyColumn(() => PluginMethodTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
pluginMethodId!: string;
@Column({ type: 'jsonb', nullable: true })
config!: WorkflowStepConfig | null;
@Column({ type: 'integer' })
order!: number;
}
+14 -48
View File
@@ -3,17 +3,17 @@ import {
CreateDateColumn,
ForeignKeyColumn,
Generated,
Index,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { PluginTriggerType } from 'src/enum';
import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { WorkflowTrigger } from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table';
import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types';
@Table('workflow')
@UpdatedAtTrigger('workflow_updatedAt')
export class WorkflowTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@@ -22,57 +22,23 @@ export class WorkflowTable {
ownerId!: string;
@Column()
triggerType!: PluginTriggerType;
trigger!: WorkflowTrigger;
@Column({ nullable: true })
name!: string | null;
@Column()
description!: string;
@Column({ nullable: true })
description!: string | null;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn()
updateId!: Generated<string>;
@Column({ type: 'boolean', default: true })
enabled!: boolean;
}
@Index({ columns: ['workflowId', 'order'] })
@Index({ columns: ['pluginFilterId'] })
@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' })
pluginFilterId!: string;
@Column({ type: 'jsonb', nullable: true })
filterConfig!: FilterConfig | null;
@Column({ type: 'integer' })
order!: number;
}
@Index({ columns: ['workflowId', 'order'] })
@Index({ columns: ['pluginActionId'] })
@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' })
pluginActionId!: string;
@Column({ type: 'jsonb', nullable: true })
actionConfig!: ActionConfig | null;
@Column({ type: 'integer' })
order!: number;
enabled!: Generated<boolean>;
}
+61
View File
@@ -58,6 +58,7 @@ 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 { ClassConstructor } from 'src/types';
import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access';
import { getConfig, updateConfig } from 'src/utils/config';
@@ -187,6 +188,66 @@ export class BaseService {
);
}
static create<T extends BaseService>(Service: ClassConstructor<T>, ctx: BaseService) {
const service = new Service(
LoggingRepository.create(),
ctx.accessRepository,
ctx.activityRepository,
ctx.albumRepository,
ctx.albumUserRepository,
ctx.apiKeyRepository,
ctx.appRepository,
ctx.assetRepository,
ctx.assetEditRepository,
ctx.assetJobRepository,
ctx.configRepository,
ctx.cronRepository,
ctx.cryptoRepository,
ctx.databaseRepository,
ctx.downloadRepository,
ctx.duplicateRepository,
ctx.emailRepository,
ctx.eventRepository,
ctx.jobRepository,
ctx.libraryRepository,
ctx.machineLearningRepository,
ctx.mapRepository,
ctx.mediaRepository,
ctx.memoryRepository,
ctx.metadataRepository,
ctx.moveRepository,
ctx.notificationRepository,
ctx.oauthRepository,
ctx.ocrRepository,
ctx.partnerRepository,
ctx.personRepository,
ctx.pluginRepository,
ctx.processRepository,
ctx.searchRepository,
ctx.serverInfoRepository,
ctx.sessionRepository,
ctx.sharedLinkRepository,
ctx.sharedLinkAssetRepository,
ctx.stackRepository,
ctx.storageRepository,
ctx.syncRepository,
ctx.syncCheckpointRepository,
ctx.systemMetadataRepository,
ctx.tagRepository,
ctx.telemetryRepository,
ctx.trashRepository,
ctx.userRepository,
ctx.versionRepository,
ctx.viewRepository,
ctx.websocketRepository,
ctx.workflowRepository,
);
service.logger.setContext(this.name);
return service as T;
}
get worker() {
return this.configRepository.getWorker();
}
+2
View File
@@ -44,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 { WorkflowExecutionService } from 'src/services/workflow-execution.service';
import { WorkflowService } from 'src/services/workflow.service';
export const services = [
@@ -93,5 +94,6 @@ export const services = [
UserService,
VersionService,
ViewService,
WorkflowExecutionService,
WorkflowService,
];
@@ -1,120 +0,0 @@
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;
}
}
+17 -296
View File
@@ -1,313 +1,34 @@
import { Plugin as ExtismPlugin, newPlugin } from '@extism/extism';
import { BadRequestException, Injectable } from '@nestjs/common';
import { join } from 'node:path';
import { Asset, WorkflowAction, WorkflowFilter } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators';
import { PluginManifestDto, PluginManifestSchema } from 'src/dtos/plugin-manifest.dto';
import { mapPlugin, PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto';
import { JobName, JobStatus, PluginTriggerType, QueueName } from 'src/enum';
import { pluginTriggers } from 'src/plugins';
import { ArgOf } from 'src/repositories/event.repository';
import {
mapMethod,
mapPlugin,
PluginMethodResponseDto,
PluginMethodSearchDto,
PluginResponseDto,
PluginSearchDto,
} from 'src/dtos/plugin.dto';
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;
};
}
import { isMethodCompatible } from 'src/utils/workflow';
@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();
}
getTriggers(): PluginTriggerResponseDto[] {
return pluginTriggers;
}
//
// CRUD operations for plugins
//
async getAll(): Promise<PluginResponseDto[]> {
const plugins = await this.pluginRepository.getAllPlugins();
async search(dto: PluginSearchDto): Promise<PluginResponseDto[]> {
const plugins = await this.pluginRepository.search(dto);
return plugins.map((plugin) => mapPlugin(plugin));
}
async get(id: string): Promise<PluginResponseDto> {
const plugin = await this.pluginRepository.getPlugin(id);
const plugin = await this.pluginRepository.get(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.external.allow && plugins.external.installFolder) {
await this.loadExternalPlugins(plugins.external.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);
return PluginManifestSchema.parse(manifestData);
}
///////////////////////////////////////////
// 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.pluginFilterId);
if (!filter) {
this.logger.error(`Filter ${workflowFilter.pluginFilterId} 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.pluginActionId);
if (!action) {
throw new Error(`Action ${workflowAction.pluginActionId} 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));
}
async searchMethods(dto: PluginMethodSearchDto): Promise<PluginMethodResponseDto[]> {
const methods = await this.pluginRepository.searchMethods(dto);
return methods
.filter((method) => !dto.trigger || isMethodCompatible(method, dto.trigger))
.map((method) => mapMethod(method));
}
}
@@ -0,0 +1,344 @@
import { CurrentPlugin } from '@extism/extism';
import { WorkflowChanges, WorkflowEventData, WorkflowEventPayload, WorkflowResponse } from '@immich/plugin-sdk';
import { HttpException, UnauthorizedException } from '@nestjs/common';
import _ from 'lodash';
import { join } from 'node:path';
import { OnEvent, OnJob } from 'src/decorators';
import { AlbumsAddAssetsDto } from 'src/dtos/album.dto';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
import {
BootstrapEventPriority,
DatabaseLock,
ImmichWorker,
JobName,
JobStatus,
QueueName,
WorkflowTrigger,
WorkflowType,
} from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { AlbumService } from 'src/services/album.service';
import { BaseService } from 'src/services/base.service';
import { JobOf } from 'src/types';
const dummy = () => {
throw new Error(
`Calling host functions is not allowed without setting methods[].hostFunctions=true in the plugin manifest`,
);
};
type ExecuteOptions<T extends WorkflowType> = {
read: (type: T) => Promise<{ authUserId: string; data: WorkflowEventData<T> }>;
write: (changes: WorkflowChanges<T>) => Promise<void>;
};
export class WorkflowExecutionService extends BaseService {
private jwtSecret!: string;
@OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.PluginSync, workers: [ImmichWorker.Microservices] })
async onPluginSync() {
await this.databaseRepository.withLock(DatabaseLock.PluginImport, async () => {
// TODO avoid importing plugins in each worker
// Can this use system metadata similar to geocoding?
const { resourcePaths, plugins } = this.configRepository.getEnv();
await this.importFolder(resourcePaths.corePlugin, { force: true });
if (plugins.external.allow && plugins.external.installFolder) {
await this.importFolders(plugins.external.installFolder);
}
});
}
@OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.PluginLoad, workers: [ImmichWorker.Microservices] })
async onPluginLoad() {
this.jwtSecret = this.cryptoRepository.randomBytesAsText(32);
const albumService = BaseService.create(AlbumService, this);
const albumAddAssets = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
albumService.addAssets(authDto, ...args),
);
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
albumService.addAssetsToAlbums(authDto, ...args),
);
const functions = {
albumAddAssets,
addAssetsToAlbums,
};
const stubs = {
albumAddAssets: dummy,
addAssetsToAlbums: dummy,
};
const plugins = await this.pluginRepository.getForLoad();
for (const { id, name, version, wasmBytes, methods } of plugins) {
const method = methods.some(({ hostFunctions }) => !hostFunctions);
if (method) {
const label = `${name}@${version}`;
const key = this.getPluginKey({ id, hostFunctions: false });
try {
await this.pluginRepository.load({ key, label, wasmBytes }, { runInWorker: false, functions: stubs });
this.logger.log(`Loaded plugin: ${label}`);
} catch (error) {
this.logger.error(`Unable to load plugin ${label} (${id})`, error);
}
}
const methodWithFunction = methods.some(({ hostFunctions }) => hostFunctions);
if (methodWithFunction) {
const label = `${name}@${version}/worker`;
const key = this.getPluginKey({ id, hostFunctions: true });
try {
await this.pluginRepository.load({ key, label, wasmBytes }, { runInWorker: true, functions });
this.logger.log(`Loaded plugin with host functions: ${label}`);
} catch (error) {
this.logger.error(`Unable to load plugin with host functions ${label} (${id})`, error);
}
}
}
}
private getPluginKey({ id, hostFunctions }: { id: string; hostFunctions: boolean }) {
return id + (hostFunctions ? '/worker' : '');
}
private wrap<T>(fn: (authDto: AuthDto, args: T) => Promise<unknown>) {
return async (plugin: CurrentPlugin, offset: bigint) => {
try {
const handle = plugin.read(offset);
if (!handle) {
return plugin.store(
JSON.stringify({ success: false, status: 400, message: 'Called host function without input' }),
);
}
const { authToken, args } = handle.json() as { authToken: string; args: T };
if (!authToken) {
throw new Error('authToken is required');
}
const authDto = this.validate(authToken);
const response = await fn(authDto, args);
return plugin.store(JSON.stringify({ success: true, response }));
} catch (error: Error | any) {
if (error instanceof HttpException) {
this.logger.error(`Plugin host exception: ${error}`);
return plugin.store(
JSON.stringify({ success: false, status: error.getStatus(), message: error.getResponse() }),
);
}
this.logger.error(`Plugin host exception: ${error}`, error?.stack);
return plugin.store(
JSON.stringify({
success: false,
status: 500,
message: `Internal server error: ${error}`,
}),
);
}
};
}
private async importFolders(installFolder: string): Promise<void> {
try {
const entries = await this.storageRepository.readdirWithTypes(installFolder);
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
await this.importFolder(join(installFolder, entry.name));
}
} catch (error) {
this.logger.error(`Failed to import plugins folder ${installFolder}:`, error);
}
}
private async importFolder(folder: string, options?: { force?: boolean }) {
try {
const manifestPath = join(folder, 'manifest.json');
const dto = await this.storageRepository.readJsonFile(manifestPath);
const result = PluginManifestDto.schema.safeParse(dto);
if (!result.success) {
const issues = result.error.issues.map((issue) => ` - [${issue.path.join('.')}] ${issue.message}`).join('\n');
this.logger.warn(`Invalid plugin manifest at ${manifestPath}:\n${issues}`);
return;
}
const manifest = result.data;
const existing = await this.pluginRepository.getByName(manifest.name);
if (existing && existing.version === manifest.version && options?.force !== true) {
return;
}
const wasmPath = `${folder}/${manifest.wasmPath}`;
const wasmBytes = await this.storageRepository.readFile(wasmPath);
const plugin = await this.pluginRepository.upsert(
{
enabled: true,
name: manifest.name,
title: manifest.title,
description: manifest.description,
author: manifest.author,
version: manifest.version,
wasmBytes,
},
manifest.methods,
);
if (existing) {
this.logger.log(
`Upgraded plugin ${manifest.name} (${plugin.methods.length} methods) from ${existing.version} to ${manifest.version} `,
);
} else {
this.logger.log(
`Imported plugin ${manifest.name}@${manifest.version} (${plugin.methods.length} methods) from ${folder}`,
);
}
return manifest;
} catch {
this.logger.warn(`Failed to import plugin from ${folder}:`);
}
}
private validate(authToken: string): AuthDto {
try {
const jwt = this.cryptoRepository.verifyJwt<{ userId: string }>(authToken, this.jwtSecret);
if (!jwt.userId) {
throw new UnauthorizedException('Invalid token: missing userId');
}
return {
user: {
id: jwt.userId,
},
} as AuthDto;
} catch (error) {
this.logger.error('Token validation failed:', error);
throw new UnauthorizedException('Invalid token');
}
}
private sign(userId: string) {
return this.cryptoRepository.signJwt({ userId }, this.jwtSecret);
}
@OnEvent({ name: 'AssetCreate' })
async onAssetCreate({ asset }: ArgOf<'AssetCreate'>) {
const dto = { ownerId: asset.ownerId, trigger: WorkflowTrigger.AssetCreate };
const items = await this.workflowRepository.search(dto);
await this.jobRepository.queueAll(
items.map((workflow) => ({
name: JobName.WorkflowAssetCreate,
data: { workflowId: workflow.id, assetId: asset.id },
})),
);
}
@OnJob({ name: JobName.WorkflowAssetCreate, queue: QueueName.Workflow })
handleAssetCreate({ workflowId, assetId }: JobOf<JobName.WorkflowAssetCreate>) {
return this.execute(workflowId, (type) => {
switch (type) {
case WorkflowType.AssetV1: {
return {
read: async () => {
const asset = await this.workflowRepository.getForAssetV1(assetId);
return {
data: { asset } as any,
authUserId: asset.ownerId,
};
},
write: async (changes) => {
if (changes.asset) {
await this.assetRepository.update({
id: assetId,
..._.omitBy(
{
isFavorite: changes.asset?.isFavorite,
visibility: changes.asset?.visibility,
},
_.isUndefined,
),
});
}
},
} satisfies ExecuteOptions<typeof type>;
}
}
});
}
private async execute<T extends WorkflowType>(
workflowId: string,
getHandler: (type: T) => ExecuteOptions<T> | undefined,
) {
const workflow = await this.workflowRepository.getForWorkflowRun(workflowId);
if (!workflow) {
return;
}
// TODO infer from steps
const type = 'AssetV1' as T;
const handler = getHandler(type);
if (!handler) {
this.logger.error(`Misconfigured workflow ${workflowId}: no handler for type ${type}`);
return;
}
try {
const { read, write } = handler;
const readResult = await read(type);
let data = readResult.data;
for (const step of workflow.steps) {
const payload: WorkflowEventPayload = {
trigger: workflow.trigger,
type,
config: step.config ?? {},
workflow: {
id: workflowId,
authToken: this.sign(readResult.authUserId),
stepId: step.id,
},
data,
};
if (step.methodName.startsWith('noop')) {
continue;
}
const result = await this.pluginRepository.callMethod<WorkflowResponse<T>>(
{
pluginKey: this.getPluginKey({ id: step.pluginId, hostFunctions: step.hostFunctions }),
methodName: step.methodName,
},
payload,
);
if (result?.changes) {
await write(result.changes);
({ data } = await read(type));
}
const shouldContinue = result?.workflow?.continue ?? true;
if (!shouldContinue) {
break;
}
}
this.logger.debug(`Workflow ${workflowId} executed successfully`);
} catch (error) {
this.logger.error(`Error executing workflow ${workflowId}:`, error);
return JobStatus.Failed;
}
}
}
+67 -112
View File
@@ -1,159 +1,114 @@
import { WorkflowStepConfig } from '@immich/plugin-sdk';
import { BadRequestException, Injectable } from '@nestjs/common';
import { Workflow } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto';
import {
mapWorkflowAction,
mapWorkflowFilter,
mapWorkflow,
mapWorkflowShare,
WorkflowCreateDto,
WorkflowResponseDto,
WorkflowSearchDto,
WorkflowShareResponseDto,
WorkflowTriggerResponseDto,
WorkflowUpdateDto,
} from 'src/dtos/workflow.dto';
import { Permission, PluginContext, PluginTriggerType } from 'src/enum';
import { pluginTriggers } from 'src/plugins';
import { Permission, WorkflowTrigger } from 'src/enum';
import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository';
import { BaseService } from 'src/services/base.service';
import { getWorkflowTriggers, isMethodCompatible, resolveMethod } from 'src/utils/workflow';
@Injectable()
export class WorkflowService extends BaseService {
async create(auth: AuthDto, dto: WorkflowCreateDto): Promise<WorkflowResponseDto> {
const context = this.getContextForTrigger(dto.triggerType);
const filterInserts = await this.validateAndMapFilters(dto.filters, context);
const actionInserts = await this.validateAndMapActions(dto.actions, 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);
getTriggers(): WorkflowTriggerResponseDto[] {
return getWorkflowTriggers();
}
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 search(auth: AuthDto, dto: WorkflowSearchDto): Promise<WorkflowResponseDto[]> {
const workflows = await this.workflowRepository.search({ ...dto, ownerId: auth.user.id });
return workflows.map((workflow) => 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);
return mapWorkflow(workflow);
}
async share(auth: AuthDto, id: string): Promise<WorkflowShareResponseDto> {
await this.requireAccess({ auth, permission: Permission.WorkflowRead, ids: [id] });
const workflow = await this.findOrFail(id);
return mapWorkflowShare(workflow);
}
async create(auth: AuthDto, dto: WorkflowCreateDto): Promise<WorkflowResponseDto> {
const { steps: stepsDto, ...workflowDto } = dto;
const steps = await this.resolveAndValidateSteps(stepsDto ?? [], workflowDto.trigger);
const workflow = await this.workflowRepository.create(
{
...workflowDto,
ownerId: auth.user.id,
},
steps.map((step) => ({
enabled: step.enabled ?? true,
config: step.config as WorkflowStepConfig,
pluginMethodId: step.pluginMethod.id,
})),
);
return mapWorkflow({ ...workflow, steps: [] });
}
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 context = this.getContextForTrigger(dto.triggerType ?? workflow.triggerType);
const { filters, actions, ...workflowUpdate } = dto;
const filterInserts = filters && (await this.validateAndMapFilters(filters, context));
const actionInserts = actions && (await this.validateAndMapActions(actions, context));
const updatedWorkflow = await this.workflowRepository.updateWorkflow(
const { steps: stepsDto, ...workflowDto } = dto;
const current = await this.findOrFail(id);
const steps = stepsDto ? await this.resolveAndValidateSteps(stepsDto, dto.trigger ?? current.trigger) : undefined;
const workflow = await this.workflowRepository.update(
id,
workflowUpdate,
filterInserts,
actionInserts,
workflowDto,
steps?.map((step) => ({
enabled: step.enabled ?? true,
config: step.config as WorkflowStepConfig,
pluginMethodId: step.pluginMethod.id,
})),
);
return this.mapWorkflow(updatedWorkflow);
return mapWorkflow(workflow);
}
async delete(auth: AuthDto, id: string): Promise<void> {
await this.requireAccess({ auth, permission: Permission.WorkflowDelete, ids: [id] });
await this.workflowRepository.deleteWorkflow(id);
await this.workflowRepository.delete(id);
}
private async validateAndMapFilters(
filters: Array<{ pluginFilterId: string; filterConfig?: any }>,
requiredContext: PluginContext,
) {
for (const dto of filters) {
const filter = await this.pluginRepository.getFilter(dto.pluginFilterId);
if (!filter) {
throw new BadRequestException(`Invalid filter ID: ${dto.pluginFilterId}`);
private async resolveAndValidateSteps<T extends { method: string }>(steps: T[], trigger: WorkflowTrigger) {
const methods = await this.pluginRepository.getForValidation();
const results: Array<T & { pluginMethod: PluginMethodSearchResponse }> = [];
for (const step of steps) {
const pluginMethod = resolveMethod(methods, step.method);
if (!pluginMethod) {
throw new BadRequestException(`Unknown method ${step.method}`);
}
if (!filter.supportedContexts.includes(requiredContext)) {
throw new BadRequestException(
`Filter "${filter.title}" does not support ${requiredContext} context. Supported contexts: ${filter.supportedContexts.join(', ')}`,
);
if (!isMethodCompatible(pluginMethod, trigger)) {
throw new BadRequestException(`Method "${step.method}" is incompatible with workflow trigger: "${trigger}"`);
}
results.push({ ...step, pluginMethod });
}
return filters.map((dto, index) => ({
pluginFilterId: dto.pluginFilterId,
filterConfig: dto.filterConfig || null,
order: index,
}));
}
// TODO make sure all steps can use a common WorkflowType
private async validateAndMapActions(
actions: Array<{ pluginActionId: string; actionConfig?: any }>,
requiredContext: PluginContext,
) {
for (const dto of actions) {
const action = await this.pluginRepository.getAction(dto.pluginActionId);
if (!action) {
throw new BadRequestException(`Invalid action ID: ${dto.pluginActionId}`);
}
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) => ({
pluginActionId: dto.pluginActionId,
actionConfig: dto.actionConfig || null,
order: index,
}));
}
private getContextForTrigger(type: PluginTriggerType) {
const trigger = pluginTriggers.find((t) => t.type === type);
if (!trigger) {
throw new BadRequestException(`Invalid trigger type: ${type}`);
}
return trigger.contextType;
return results;
}
private async findOrFail(id: string) {
const workflow = await this.workflowRepository.getWorkflow(id);
const workflow = await this.workflowRepository.get(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)),
};
}
}
+27 -19
View File
@@ -1,7 +1,7 @@
import { ShallowDehydrateObject } from 'kysely';
import { SystemConfig } from 'src/config';
import { VECTOR_EXTENSIONS } from 'src/constants';
import { Asset, AssetFile } from 'src/database';
import { AssetFile } from 'src/database';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
@@ -22,7 +22,6 @@ import {
ImageFormat,
JobName,
MemoryType,
PluginTriggerType,
QueueName,
StorageFolder,
SyncEntityType,
@@ -30,10 +29,13 @@ import {
TranscodeTarget,
UserMetadataKey,
VideoCodec,
WorkflowTrigger,
WorkflowType,
} from 'src/enum';
export type DeepPartial<T> =
T extends Record<string, unknown>
export type DeepPartial<T> = T extends Date
? T
: T extends Record<string, unknown>
? { [K in keyof T]?: DeepPartial<T[K]> }
: T extends Array<infer R>
? DeepPartial<R>[]
@@ -288,22 +290,11 @@ 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> {
export type IWorkflowJob<T extends WorkflowType = WorkflowType> = {
id: string;
trigger: WorkflowTrigger;
type: T;
event: WorkflowData[T];
}
};
export interface JobCounts {
active: number;
@@ -413,7 +404,7 @@ export type JobItem =
| { name: JobName.Ocr; data: IEntityJob }
// Workflow
| { name: JobName.WorkflowRun; data: IWorkflowJob }
| { name: JobName.WorkflowAssetCreate; data: { workflowId: string; assetId: string } }
// Editor
| { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob };
@@ -574,3 +565,20 @@ export interface UserMetadata extends Record<UserMetadataKey, Record<string, any
}
export type MaybeDehydrated<T> = T | ShallowDehydrateObject<T>;
export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object';
export type JSONSchemaProperty = {
type: JSONSchemaType;
description?: string;
default?: any;
enum?: string[];
array?: boolean;
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
export interface ClassConstructor<T = any> extends Function {
new (...args: any[]): T;
}
-56
View File
@@ -1,56 +0,0 @@
/**
* JSON Schema types for plugin configuration schemas
* Based on JSON Schema Draft 7
*/
import z from 'zod';
const JSONSchemaTypeSchema = z
.enum(['string', 'number', 'integer', 'boolean', 'object', 'array', 'null'])
.meta({ id: 'PluginJsonSchemaType' });
const JSONSchemaPropertySchema = z
.object({
type: JSONSchemaTypeSchema.optional(),
description: z.string().optional(),
default: z.any().optional(),
enum: z.array(z.string()).optional(),
get items() {
return JSONSchemaPropertySchema.optional();
},
get properties() {
return z.record(z.string(), JSONSchemaPropertySchema).optional();
},
required: z.array(z.string()).optional(),
get additionalProperties() {
return z.union([z.boolean(), JSONSchemaPropertySchema]).optional();
},
})
.meta({ id: 'PluginJsonSchemaProperty' });
export type JSONSchemaProperty = z.infer<typeof JSONSchemaPropertySchema>;
export const JSONSchemaSchema = z
.object({
type: JSONSchemaTypeSchema.optional(),
properties: z.record(z.string(), JSONSchemaPropertySchema).optional(),
required: z.array(z.string()).optional(),
additionalProperties: z.boolean().optional(),
description: z.string().optional(),
})
.meta({ id: 'PluginJsonSchema' });
export type JSONSchema = z.infer<typeof JSONSchemaSchema>;
type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue };
const ConfigValueSchema: z.ZodType<ConfigValue> = z.any().meta({ id: 'PluginConfigValue' });
export const FilterConfigSchema = z.record(z.string(), ConfigValueSchema).meta({ id: 'WorkflowFilterConfig' });
export type FilterConfig = z.infer<typeof FilterConfigSchema>;
export const ActionConfigSchema = z.record(z.string(), ConfigValueSchema).meta({ id: 'WorkflowActionConfig' });
export type ActionConfig = z.infer<typeof ActionConfigSchema>;
+36
View File
@@ -0,0 +1,36 @@
import { WorkflowTrigger, WorkflowType } from 'src/enum';
import { isMethodCompatible } from 'src/utils/workflow';
const tests: Array<{ trigger: WorkflowTrigger; types: WorkflowType[]; expected: boolean }> = [
{
trigger: WorkflowTrigger.AssetCreate,
types: [WorkflowType.AssetV1],
expected: true,
},
{
trigger: WorkflowTrigger.AssetCreate,
types: [WorkflowType.AssetPersonV1],
expected: true,
},
{
trigger: WorkflowTrigger.PersonRecognized,
types: [WorkflowType.AssetPersonV1],
expected: true,
},
{
trigger: WorkflowTrigger.PersonRecognized,
types: [WorkflowType.AssetV1],
expected: false,
},
{
trigger: WorkflowTrigger.PersonRecognized,
types: [WorkflowType.AssetV1, WorkflowType.AssetPersonV1],
expected: true,
},
];
describe(isMethodCompatible.name, () => {
it.each(tests)('should return $expected for trigger $trigger with types $types', ({ trigger, types, expected }) => {
expect(isMethodCompatible({ types }, trigger)).toBe(expected);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { WorkflowTrigger, WorkflowType } from 'src/enum';
import { PluginMethodSearchResponse } from 'src/repositories/plugin.repository';
export const triggerMap: Record<WorkflowTrigger, WorkflowType[]> = {
[WorkflowTrigger.AssetCreate]: [WorkflowType.AssetV1],
[WorkflowTrigger.PersonRecognized]: [WorkflowType.AssetPersonV1],
};
export const getWorkflowTriggers = () =>
Object.entries(triggerMap).map(([trigger, types]) => ({ trigger: trigger as WorkflowTrigger, types }));
/** some types extend other types and have implied compatibility */
const inferredMap: Record<WorkflowType, WorkflowType[]> = {
[WorkflowType.AssetV1]: [],
[WorkflowType.AssetPersonV1]: [WorkflowType.AssetV1],
};
const withImpliedItems = (type: WorkflowType): WorkflowType[] => {
const childTypes = inferredMap[type];
const results = [type];
for (const child of childTypes) {
results.push(...withImpliedItems(child));
}
return results;
};
export const isMethodCompatible = (pluginMethod: { types: WorkflowType[] }, trigger: WorkflowTrigger) => {
const validTypes = triggerMap[trigger];
const pluginCompatibility = pluginMethod.types.map((type) => withImpliedItems(type));
for (const requested of validTypes) {
for (const pluginCompatibilityGroup of pluginCompatibility) {
if (pluginCompatibilityGroup.includes(requested)) {
return true;
}
}
}
return false;
};
export const resolveMethod = (methods: PluginMethodSearchResponse[], method: string) => {
const result = parseMethodString(method);
if (!result) {
return;
}
const { pluginName, methodName } = result;
return methods.find((method) => method.pluginName === pluginName && method.name === methodName);
};
export const asMethodString = (method: { pluginName: string; methodName: string }) => {
return `${method.pluginName}#${method.methodName}`;
};
const METHOD_REGEX = /^(?<name>[^@#\s]+)(?:@(?<version>[^#\s]*))?#(?<method>[^@#\s]+)$/;
export const parseMethodString = (method: string) => {
const matches = METHOD_REGEX.exec(method);
if (!matches) {
return;
}
const pluginName = matches.groups?.name;
const version = matches.groups?.version;
const methodName = matches.groups?.method;
return { pluginName, version, methodName };
};