feat: workflow foundation (#23621)

* feat: plugins

* feat: table definition

* feat: type and migration

* feat: add repositories

* feat: validate manifest with class-validator and load manifest info to database

* feat: workflow/plugin controller/service layer

* feat: implement workflow logic

* feat: make trigger static

* feat: dynamical instantiate plugin instances

* fix: access control and helper script

* feat: it works

* chore: simplify

* refactor: refactor and use queue for workflow execution

* refactor: remove unsused property in plugin-schema

* build wasm in prod

* feat: plugin loader in transaction

* fix: docker build arm64

* generated files

* shell check

* fix tests

* fix: waiting for migration to finish before loading plugin

* remove context reassignment

* feat: use mise to manage extism tools (#23760)

* pr feedback

* refactor: create workflow now including create filters and actions

* feat: workflow medium tests

* fix: broken medium test

* feat: medium tests

* chore: unify workflow job

* sign user id with jwt

* chore: query plugin with filters and action

* chore: read manifest in repository

* chore: load manifest from server configs

* merge main

* feat: endpoint documentation

* pr feedback

* load plugin from absolute path

* refactor:handle trigger

* throw error and return early

* pr feedback

* unify plugin services

* fix: plugins code

* clean up

* remove triggerConfig

* clean up

* displayName and methodName

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
Co-authored-by: bo0tzz <git@bo0tzz.me>
This commit is contained in:
Alex 2025-11-14 14:05:05 -06:00 committed by GitHub
parent d784d431d0
commit 4dcc049465
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
89 changed files with 5032 additions and 8 deletions

View file

@ -41,6 +41,7 @@ services:
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
- ../plugins:/build/corePlugin
env_file:
- .env
environment:

View file

@ -2181,6 +2181,7 @@
"welcome": "Welcome",
"welcome_to_immich": "Welcome to Immich",
"wifi_name": "Wi-Fi Name",
"workflow": "Workflow",
"wrong_pin_code": "Wrong PIN code",
"year": "Year",
"years_ago": "{years, plural, one {# year} other {# years}} ago",

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/lib/api/plugins_api.dart generated Normal file

Binary file not shown.

BIN
mobile/openapi/lib/api/workflows_api.dart generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -7865,6 +7865,111 @@
"x-immich-state": "Stable"
}
},
"/plugins": {
"get": {
"description": "Retrieve a list of plugins available to the authenticated user.",
"operationId": "getPlugins",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/PluginResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "List all plugins",
"tags": [
"Plugins"
],
"x-immich-history": [
{
"version": "v2.3.0",
"state": "Added"
},
{
"version": "v2.3.0",
"state": "Alpha"
}
],
"x-immich-permission": "plugin.read",
"x-immich-state": "Alpha"
}
},
"/plugins/{id}": {
"get": {
"description": "Retrieve information about a specific plugin by its ID.",
"operationId": "getPlugin",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PluginResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Retrieve a plugin",
"tags": [
"Plugins"
],
"x-immich-history": [
{
"version": "v2.3.0",
"state": "Added"
},
{
"version": "v2.3.0",
"state": "Alpha"
}
],
"x-immich-permission": "plugin.read",
"x-immich-state": "Alpha"
}
},
"/search/cities": {
"get": {
"description": "Retrieve a list of assets with each asset belonging to a different city. This endpoint is used on the places pages to show a single thumbnail for each city the user has assets in.",
@ -13485,6 +13590,276 @@
],
"x-immich-state": "Stable"
}
},
"/workflows": {
"get": {
"description": "Retrieve a list of workflows available to the authenticated user.",
"operationId": "getWorkflows",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/WorkflowResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "List all workflows",
"tags": [
"Workflows"
],
"x-immich-history": [
{
"version": "v2.3.0",
"state": "Added"
},
{
"version": "v2.3.0",
"state": "Alpha"
}
],
"x-immich-permission": "workflow.read",
"x-immich-state": "Alpha"
},
"post": {
"description": "Create a new workflow, the workflow can also be created with empty filters and actions.",
"operationId": "createWorkflow",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WorkflowCreateDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WorkflowResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Create a workflow",
"tags": [
"Workflows"
],
"x-immich-history": [
{
"version": "v2.3.0",
"state": "Added"
},
{
"version": "v2.3.0",
"state": "Alpha"
}
],
"x-immich-permission": "workflow.create",
"x-immich-state": "Alpha"
}
},
"/workflows/{id}": {
"delete": {
"description": "Delete a workflow by its ID.",
"operationId": "deleteWorkflow",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Delete a workflow",
"tags": [
"Workflows"
],
"x-immich-history": [
{
"version": "v2.3.0",
"state": "Added"
},
{
"version": "v2.3.0",
"state": "Alpha"
}
],
"x-immich-permission": "workflow.delete",
"x-immich-state": "Alpha"
},
"get": {
"description": "Retrieve information about a specific workflow by its ID.",
"operationId": "getWorkflow",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WorkflowResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Retrieve a workflow",
"tags": [
"Workflows"
],
"x-immich-history": [
{
"version": "v2.3.0",
"state": "Added"
},
{
"version": "v2.3.0",
"state": "Alpha"
}
],
"x-immich-permission": "workflow.read",
"x-immich-state": "Alpha"
},
"put": {
"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.",
"operationId": "updateWorkflow",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WorkflowUpdateDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/WorkflowResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Update a workflow",
"tags": [
"Workflows"
],
"x-immich-history": [
{
"version": "v2.3.0",
"state": "Added"
},
{
"version": "v2.3.0",
"state": "Alpha"
}
],
"x-immich-permission": "workflow.update",
"x-immich-state": "Alpha"
}
}
},
"info": {
@ -13566,6 +13941,10 @@
"name": "People",
"description": "A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job."
},
{
"name": "Plugins",
"description": "A plugin is an installed module that makes filters and actions available for the workflow feature."
},
{
"name": "Search",
"description": "Endpoints related to searching assets via text, smart search, optical character recognition (OCR), and other filters like person, album, and other metadata. Search endpoints usually support pagination and sorting."
@ -13621,6 +14000,10 @@
{
"name": "Views",
"description": "Endpoints for specialized views, such as the folder view."
},
{
"name": "Workflows",
"description": "A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution."
}
],
"servers": [
@ -17022,6 +17405,10 @@
"pinCode.create",
"pinCode.update",
"pinCode.delete",
"plugin.create",
"plugin.read",
"plugin.update",
"plugin.delete",
"server.about",
"server.apkLinks",
"server.storage",
@ -17071,6 +17458,10 @@
"userProfileImage.read",
"userProfileImage.update",
"userProfileImage.delete",
"workflow.create",
"workflow.read",
"workflow.update",
"workflow.delete",
"adminUser.create",
"adminUser.read",
"adminUser.update",
@ -17367,6 +17758,152 @@
],
"type": "object"
},
"PluginActionResponseDto": {
"properties": {
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"methodName": {
"type": "string"
},
"pluginId": {
"type": "string"
},
"schema": {
"nullable": true,
"type": "object"
},
"supportedContexts": {
"items": {
"$ref": "#/components/schemas/PluginContext"
},
"type": "array"
},
"title": {
"type": "string"
}
},
"required": [
"description",
"id",
"methodName",
"pluginId",
"schema",
"supportedContexts",
"title"
],
"type": "object"
},
"PluginContext": {
"enum": [
"asset",
"album",
"person"
],
"type": "string"
},
"PluginFilterResponseDto": {
"properties": {
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"methodName": {
"type": "string"
},
"pluginId": {
"type": "string"
},
"schema": {
"nullable": true,
"type": "object"
},
"supportedContexts": {
"items": {
"$ref": "#/components/schemas/PluginContext"
},
"type": "array"
},
"title": {
"type": "string"
}
},
"required": [
"description",
"id",
"methodName",
"pluginId",
"schema",
"supportedContexts",
"title"
],
"type": "object"
},
"PluginResponseDto": {
"properties": {
"actions": {
"items": {
"$ref": "#/components/schemas/PluginActionResponseDto"
},
"type": "array"
},
"author": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"description": {
"type": "string"
},
"filters": {
"items": {
"$ref": "#/components/schemas/PluginFilterResponseDto"
},
"type": "array"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"title": {
"type": "string"
},
"updatedAt": {
"type": "string"
},
"version": {
"type": "string"
}
},
"required": [
"actions",
"author",
"createdAt",
"description",
"filters",
"id",
"name",
"title",
"updatedAt",
"version"
],
"type": "object"
},
"PluginTriggerType": {
"enum": [
"AssetCreate",
"PersonRecognized"
],
"type": "string"
},
"PurchaseResponse": {
"properties": {
"hideBuyButtonUntil": {
@ -17438,7 +17975,8 @@
"library",
"notifications",
"backupDatabase",
"ocr"
"ocr",
"workflow"
],
"type": "string"
},
@ -17552,6 +18090,9 @@
},
"videoConversion": {
"$ref": "#/components/schemas/QueueResponseDto"
},
"workflow": {
"$ref": "#/components/schemas/QueueResponseDto"
}
},
"required": [
@ -17570,7 +18111,8 @@
"smartSearch",
"storageTemplateMigration",
"thumbnailGeneration",
"videoConversion"
"videoConversion",
"workflow"
],
"type": "object"
},
@ -20420,6 +20962,9 @@
},
"videoConversion": {
"$ref": "#/components/schemas/JobSettingsDto"
},
"workflow": {
"$ref": "#/components/schemas/JobSettingsDto"
}
},
"required": [
@ -20434,7 +20979,8 @@
"sidecar",
"smartSearch",
"thumbnailGeneration",
"videoConversion"
"videoConversion",
"workflow"
],
"type": "object"
},
@ -21999,6 +22545,211 @@
"webm"
],
"type": "string"
},
"WorkflowActionItemDto": {
"properties": {
"actionConfig": {
"type": "object"
},
"actionId": {
"format": "uuid",
"type": "string"
}
},
"required": [
"actionId"
],
"type": "object"
},
"WorkflowActionResponseDto": {
"properties": {
"actionConfig": {
"nullable": true,
"type": "object"
},
"actionId": {
"type": "string"
},
"id": {
"type": "string"
},
"order": {
"type": "number"
},
"workflowId": {
"type": "string"
}
},
"required": [
"actionConfig",
"actionId",
"id",
"order",
"workflowId"
],
"type": "object"
},
"WorkflowCreateDto": {
"properties": {
"actions": {
"items": {
"$ref": "#/components/schemas/WorkflowActionItemDto"
},
"type": "array"
},
"description": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"filters": {
"items": {
"$ref": "#/components/schemas/WorkflowFilterItemDto"
},
"type": "array"
},
"name": {
"type": "string"
},
"triggerType": {
"allOf": [
{
"$ref": "#/components/schemas/PluginTriggerType"
}
]
}
},
"required": [
"actions",
"filters",
"name",
"triggerType"
],
"type": "object"
},
"WorkflowFilterItemDto": {
"properties": {
"filterConfig": {
"type": "object"
},
"filterId": {
"format": "uuid",
"type": "string"
}
},
"required": [
"filterId"
],
"type": "object"
},
"WorkflowFilterResponseDto": {
"properties": {
"filterConfig": {
"nullable": true,
"type": "object"
},
"filterId": {
"type": "string"
},
"id": {
"type": "string"
},
"order": {
"type": "number"
},
"workflowId": {
"type": "string"
}
},
"required": [
"filterConfig",
"filterId",
"id",
"order",
"workflowId"
],
"type": "object"
},
"WorkflowResponseDto": {
"properties": {
"actions": {
"items": {
"$ref": "#/components/schemas/WorkflowActionResponseDto"
},
"type": "array"
},
"createdAt": {
"type": "string"
},
"description": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"filters": {
"items": {
"$ref": "#/components/schemas/WorkflowFilterResponseDto"
},
"type": "array"
},
"id": {
"type": "string"
},
"name": {
"nullable": true,
"type": "string"
},
"ownerId": {
"type": "string"
},
"triggerType": {
"enum": [
"AssetCreate",
"PersonRecognized"
],
"type": "string"
}
},
"required": [
"actions",
"createdAt",
"description",
"enabled",
"filters",
"id",
"name",
"ownerId",
"triggerType"
],
"type": "object"
},
"WorkflowUpdateDto": {
"properties": {
"actions": {
"items": {
"$ref": "#/components/schemas/WorkflowActionItemDto"
},
"type": "array"
},
"description": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"filters": {
"items": {
"$ref": "#/components/schemas/WorkflowFilterItemDto"
},
"type": "array"
},
"name": {
"type": "string"
}
},
"type": "object"
}
}
}

View file

@ -732,6 +732,7 @@ export type QueuesResponseDto = {
storageTemplateMigration: QueueResponseDto;
thumbnailGeneration: QueueResponseDto;
videoConversion: QueueResponseDto;
workflow: QueueResponseDto;
};
export type JobCreateDto = {
name: ManualJobName;
@ -926,6 +927,36 @@ export type AssetFaceUpdateDto = {
export type PersonStatisticsResponseDto = {
assets: number;
};
export type PluginActionResponseDto = {
description: string;
id: string;
methodName: string;
pluginId: string;
schema: object | null;
supportedContexts: PluginContext[];
title: string;
};
export type PluginFilterResponseDto = {
description: string;
id: string;
methodName: string;
pluginId: string;
schema: object | null;
supportedContexts: PluginContext[];
title: string;
};
export type PluginResponseDto = {
actions: PluginActionResponseDto[];
author: string;
createdAt: string;
description: string;
filters: PluginFilterResponseDto[];
id: string;
name: string;
title: string;
updatedAt: string;
version: string;
};
export type SearchExploreItem = {
data: AssetResponseDto;
value: string;
@ -1411,6 +1442,7 @@ export type SystemConfigJobDto = {
smartSearch: JobSettingsDto;
thumbnailGeneration: JobSettingsDto;
videoConversion: JobSettingsDto;
workflow: JobSettingsDto;
};
export type SystemConfigLibraryScanDto = {
cronExpression: string;
@ -1667,6 +1699,54 @@ export type CreateProfileImageResponseDto = {
profileImagePath: string;
userId: string;
};
export type WorkflowActionResponseDto = {
actionConfig: object | null;
actionId: string;
id: string;
order: number;
workflowId: string;
};
export type WorkflowFilterResponseDto = {
filterConfig: object | null;
filterId: string;
id: string;
order: number;
workflowId: string;
};
export type WorkflowResponseDto = {
actions: WorkflowActionResponseDto[];
createdAt: string;
description: string;
enabled: boolean;
filters: WorkflowFilterResponseDto[];
id: string;
name: string | null;
ownerId: string;
triggerType: TriggerType;
};
export type WorkflowActionItemDto = {
actionConfig?: object;
actionId: string;
};
export type WorkflowFilterItemDto = {
filterConfig?: object;
filterId: string;
};
export type WorkflowCreateDto = {
actions: WorkflowActionItemDto[];
description?: string;
enabled?: boolean;
filters: WorkflowFilterItemDto[];
name: string;
triggerType: PluginTriggerType;
};
export type WorkflowUpdateDto = {
actions?: WorkflowActionItemDto[];
description?: string;
enabled?: boolean;
filters?: WorkflowFilterItemDto[];
name?: string;
};
/**
* List all activities
*/
@ -3510,6 +3590,30 @@ export function getPersonThumbnail({ id }: {
...opts
}));
}
/**
* List all plugins
*/
export function getPlugins(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PluginResponseDto[];
}>("/plugins", {
...opts
}));
}
/**
* Retrieve a plugin
*/
export function getPlugin({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: PluginResponseDto;
}>(`/plugins/${encodeURIComponent(id)}`, {
...opts
}));
}
/**
* Retrieve assets by city
*/
@ -4824,6 +4928,72 @@ export function getUniqueOriginalPaths(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
/**
* List all workflows
*/
export function getWorkflows(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: WorkflowResponseDto[];
}>("/workflows", {
...opts
}));
}
/**
* Create a workflow
*/
export function createWorkflow({ workflowCreateDto }: {
workflowCreateDto: WorkflowCreateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: WorkflowResponseDto;
}>("/workflows", oazapfts.json({
...opts,
method: "POST",
body: workflowCreateDto
})));
}
/**
* Delete a workflow
*/
export function deleteWorkflow({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/workflows/${encodeURIComponent(id)}`, {
...opts,
method: "DELETE"
}));
}
/**
* Retrieve a workflow
*/
export function getWorkflow({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: WorkflowResponseDto;
}>(`/workflows/${encodeURIComponent(id)}`, {
...opts
}));
}
/**
* Update a workflow
*/
export function updateWorkflow({ id, workflowUpdateDto }: {
id: string;
workflowUpdateDto: WorkflowUpdateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: WorkflowResponseDto;
}>(`/workflows/${encodeURIComponent(id)}`, oazapfts.json({
...opts,
method: "PUT",
body: workflowUpdateDto
})));
}
export enum ReactionLevel {
Album = "album",
Asset = "asset"
@ -4976,6 +5146,10 @@ export enum Permission {
PinCodeCreate = "pinCode.create",
PinCodeUpdate = "pinCode.update",
PinCodeDelete = "pinCode.delete",
PluginCreate = "plugin.create",
PluginRead = "plugin.read",
PluginUpdate = "plugin.update",
PluginDelete = "plugin.delete",
ServerAbout = "server.about",
ServerApkLinks = "server.apkLinks",
ServerStorage = "server.storage",
@ -5025,6 +5199,10 @@ export enum Permission {
UserProfileImageRead = "userProfileImage.read",
UserProfileImageUpdate = "userProfileImage.update",
UserProfileImageDelete = "userProfileImage.delete",
WorkflowCreate = "workflow.create",
WorkflowRead = "workflow.read",
WorkflowUpdate = "workflow.update",
WorkflowDelete = "workflow.delete",
AdminUserCreate = "adminUser.create",
AdminUserRead = "adminUser.read",
AdminUserUpdate = "adminUser.update",
@ -5083,7 +5261,8 @@ export enum QueueName {
Library = "library",
Notifications = "notifications",
BackupDatabase = "backupDatabase",
Ocr = "ocr"
Ocr = "ocr",
Workflow = "workflow"
}
export enum QueueCommand {
Start = "start",
@ -5104,6 +5283,11 @@ export enum PartnerDirection {
SharedBy = "shared-by",
SharedWith = "shared-with"
}
export enum PluginContext {
Asset = "asset",
Album = "album",
Person = "person"
}
export enum SearchSuggestionType {
Country = "country",
State = "state",
@ -5255,3 +5439,11 @@ export enum OAuthTokenEndpointAuthMethod {
ClientSecretPost = "client_secret_post",
ClientSecretBasic = "client_secret_basic"
}
export enum TriggerType {
AssetCreate = "AssetCreate",
PersonRecognized = "PersonRecognized"
}
export enum PluginTriggerType {
AssetCreate = "AssetCreate",
PersonRecognized = "PersonRecognized"
}

2
plugins/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules
dist

26
plugins/LICENSE Normal file
View file

@ -0,0 +1,26 @@
Copyright 2024, The Extism Authors.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors
may be used to endorse or promote products derived from this software without
specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

12
plugins/esbuild.js Normal file
View file

@ -0,0 +1,12 @@
const esbuild = require('esbuild');
esbuild
.build({
entryPoints: ['src/index.ts'],
outdir: 'dist',
bundle: true,
sourcemap: true,
minify: false, // might want to use true for production build
format: 'cjs', // needs to be CJS for now
target: ['es2020'] // don't go over es2020 because quickjs doesn't support it
})

127
plugins/manifest.json Normal file
View file

@ -0,0 +1,127 @@
{
"name": "immich-core",
"version": "2.0.0",
"title": "Immich Core",
"description": "Core workflow capabilities for Immich",
"author": "Immich Team",
"wasm": {
"path": "dist/plugin.wasm"
},
"filters": [
{
"methodName": "filterFileName",
"title": "Filter by filename",
"description": "Filter assets by filename pattern using text matching or regular expressions",
"supportedContexts": ["asset"],
"schema": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Text or regex pattern to match against filename"
},
"matchType": {
"type": "string",
"enum": ["contains", "regex", "exact"],
"default": "contains",
"description": "Type of pattern matching to perform"
},
"caseSensitive": {
"type": "boolean",
"default": false,
"description": "Whether matching should be case-sensitive"
}
},
"required": ["pattern"]
}
},
{
"methodName": "filterFileType",
"title": "Filter by file type",
"description": "Filter assets by file type",
"supportedContexts": ["asset"],
"schema": {
"type": "object",
"properties": {
"fileTypes": {
"type": "array",
"items": {
"type": "string",
"enum": ["IMAGE", "VIDEO"]
},
"description": "Allowed file types"
}
},
"required": ["fileTypes"]
}
},
{
"methodName": "filterPerson",
"title": "Filter by person",
"description": "Filter by detected person",
"supportedContexts": ["person"],
"schema": {
"type": "object",
"properties": {
"personIds": {
"type": "array",
"items": {
"type": "string"
},
"description": "List of person to match"
},
"matchAny": {
"type": "boolean",
"default": true,
"description": "Match any name (true) or require all names (false)"
}
},
"required": ["personIds"]
}
}
],
"actions": [
{
"methodName": "actionArchive",
"title": "Archive",
"description": "Move the asset to archive",
"supportedContexts": ["asset"],
"schema": {}
},
{
"methodName": "actionFavorite",
"title": "Favorite",
"description": "Mark the asset as favorite or unfavorite",
"supportedContexts": ["asset"],
"schema": {
"type": "object",
"properties": {
"favorite": {
"type": "boolean",
"default": true,
"description": "Set favorite (true) or unfavorite (false)"
}
}
}
},
{
"methodName": "actionAddToAlbum",
"title": "Add to Album",
"description": "Add the item to a specified album",
"supportedContexts": ["asset", "person"],
"schema": {
"type": "object",
"properties": {
"albumId": {
"type": "string",
"description": "Target album ID"
}
},
"required": ["albumId"]
}
}
]
}

11
plugins/mise.toml Normal file
View file

@ -0,0 +1,11 @@
[tools]
"github:extism/cli" = "1.6.3"
"github:webassembly/binaryen" = "version_124"
"github:extism/js-pdk" = "1.5.1"
[tasks.install]
run = "pnpm install --frozen-lockfile"
[tasks.build]
depends = ["install"]
run = "pnpm run build"

443
plugins/package-lock.json generated Normal file
View file

@ -0,0 +1,443 @@
{
"name": "js-pdk-template",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "js-pdk-template",
"version": "1.0.0",
"license": "BSD-3-Clause",
"devDependencies": {
"@extism/js-pdk": "^1.0.1",
"esbuild": "^0.19.6",
"typescript": "^5.3.2"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz",
"integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz",
"integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz",
"integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz",
"integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz",
"integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz",
"integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz",
"integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz",
"integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz",
"integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==",
"cpu": [
"arm"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz",
"integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz",
"integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz",
"integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==",
"cpu": [
"loong64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz",
"integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==",
"cpu": [
"mips64el"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz",
"integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==",
"cpu": [
"ppc64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz",
"integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==",
"cpu": [
"riscv64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz",
"integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==",
"cpu": [
"s390x"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz",
"integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz",
"integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz",
"integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz",
"integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz",
"integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz",
"integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==",
"cpu": [
"ia32"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz",
"integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/@extism/js-pdk": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@extism/js-pdk/-/js-pdk-1.0.1.tgz",
"integrity": "sha512-YJWfHGeOuJnQw4V8NPNHvbSr6S8iDd2Ga6VEukwlRP7tu62ozTxIgokYw8i+rajD/16zz/gK0KYARBpm2qPAmQ==",
"dev": true
},
"node_modules/esbuild": {
"version": "0.19.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz",
"integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==",
"dev": true,
"hasInstallScript": true,
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=12"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.19.12",
"@esbuild/android-arm": "0.19.12",
"@esbuild/android-arm64": "0.19.12",
"@esbuild/android-x64": "0.19.12",
"@esbuild/darwin-arm64": "0.19.12",
"@esbuild/darwin-x64": "0.19.12",
"@esbuild/freebsd-arm64": "0.19.12",
"@esbuild/freebsd-x64": "0.19.12",
"@esbuild/linux-arm": "0.19.12",
"@esbuild/linux-arm64": "0.19.12",
"@esbuild/linux-ia32": "0.19.12",
"@esbuild/linux-loong64": "0.19.12",
"@esbuild/linux-mips64el": "0.19.12",
"@esbuild/linux-ppc64": "0.19.12",
"@esbuild/linux-riscv64": "0.19.12",
"@esbuild/linux-s390x": "0.19.12",
"@esbuild/linux-x64": "0.19.12",
"@esbuild/netbsd-x64": "0.19.12",
"@esbuild/openbsd-x64": "0.19.12",
"@esbuild/sunos-x64": "0.19.12",
"@esbuild/win32-arm64": "0.19.12",
"@esbuild/win32-ia32": "0.19.12",
"@esbuild/win32-x64": "0.19.12"
}
},
"node_modules/typescript": {
"version": "5.4.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz",
"integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}

19
plugins/package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "plugins",
"version": "1.0.0",
"description": "",
"main": "src/index.ts",
"scripts": {
"build": "pnpm build:tsc && pnpm build:wasm",
"build:tsc": "tsc --noEmit && node esbuild.js",
"build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm"
},
"keywords": [],
"author": "",
"license": "AGPL-3.0",
"devDependencies": {
"@extism/js-pdk": "^1.0.1",
"esbuild": "^0.19.6",
"typescript": "^5.3.2"
}
}

12
plugins/src/index.d.ts vendored Normal file
View file

@ -0,0 +1,12 @@
declare module 'main' {
export function filterFileName(): I32;
export function actionAddToAlbum(): I32;
export function actionArchive(): I32;
}
declare module 'extism:host' {
interface user {
updateAsset(ptr: PTR): I32;
addAssetToAlbum(ptr: PTR): I32;
}
}

71
plugins/src/index.ts Normal file
View file

@ -0,0 +1,71 @@
const { updateAsset, addAssetToAlbum } = Host.getFunctions();
function parseInput() {
return JSON.parse(Host.inputString());
}
function returnOutput(output: any) {
Host.outputString(JSON.stringify(output));
return 0;
}
export function filterFileName() {
const input = parseInput();
const { data, config } = input;
const { pattern, matchType = 'contains', caseSensitive = false } = config;
const fileName = data.asset.originalFileName || data.asset.fileName || '';
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
let passed = false;
if (matchType === 'exact') {
passed = searchName === searchPattern;
} else if (matchType === 'regex') {
const flags = caseSensitive ? '' : 'i';
const regex = new RegExp(searchPattern, flags);
passed = regex.test(fileName);
} else {
// contains
passed = searchName.includes(searchPattern);
}
return returnOutput({ passed });
}
export function actionAddToAlbum() {
const input = parseInput();
const { authToken, config, data } = input;
const { albumId } = config;
const ptr = Memory.fromString(
JSON.stringify({
authToken,
assetId: data.asset.id,
albumId: albumId,
})
);
addAssetToAlbum(ptr.offset);
ptr.free();
return returnOutput({ success: true });
}
export function actionArchive() {
const input = parseInput();
const { authToken, data } = input;
const ptr = Memory.fromString(
JSON.stringify({
authToken,
id: data.asset.id,
visibility: 'archive',
})
);
updateAsset(ptr.offset);
ptr.free();
return returnOutput({ success: true });
}

24
plugins/tsconfig.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "es2020", // Specify ECMAScript target version
"module": "commonjs", // Specify module code generation
"lib": [
"es2020"
], // Specify a list of library files to be included in the compilation
"types": [
"@extism/js-pdk",
"./src/index.d.ts"
], // Specify a list of type definition files to be included in the compilation
"strict": true, // Enable all strict type-checking options
"esModuleInterop": true, // Enables compatibility with Babel-style module imports
"skipLibCheck": true, // Skip type checking of declaration files
"allowJs": true, // Allow JavaScript files to be compiled
"noEmit": true // Do not emit outputs (no .js or .d.ts files)
},
"include": [
"src/**/*.ts" // Include all TypeScript files in src directory
],
"exclude": [
"node_modules" // Exclude the node_modules directory
]
}

View file

@ -299,8 +299,23 @@ importers:
specifier: ^5.3.3
version: 5.9.3
plugins:
devDependencies:
'@extism/js-pdk':
specifier: ^1.0.1
version: 1.1.1
esbuild:
specifier: ^0.19.6
version: 0.19.12
typescript:
specifier: ^5.3.2
version: 5.9.3
server:
dependencies:
'@extism/extism':
specifier: 2.0.0-rc13
version: 2.0.0-rc13
'@nestjs/bullmq':
specifier: ^11.0.1
version: 11.0.4(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(bullmq@5.62.1)
@ -367,6 +382,9 @@ importers:
'@socket.io/redis-adapter':
specifier: ^8.3.0
version: 8.3.0(socket.io-adapter@2.5.5)
ajv:
specifier: ^8.17.1
version: 8.17.1
archiver:
specifier: ^7.0.0
version: 7.0.1
@ -430,6 +448,9 @@ importers:
js-yaml:
specifier: ^4.1.0
version: 4.1.0
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
kysely:
specifier: 0.28.2
version: 0.28.2
@ -569,6 +590,9 @@ importers:
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
'@types/jsonwebtoken':
specifier: ^9.0.10
version: 9.0.10
'@types/lodash':
specifier: ^4.14.197
version: 4.17.20
@ -2572,6 +2596,12 @@ packages:
resolution: {integrity: sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@extism/extism@2.0.0-rc13':
resolution: {integrity: sha512-iQ3mrPKOC0WMZ94fuJrKbJmMyz4LQ9Abf8gd4F5ShxKWa+cRKcVzk0EqRQsp5xXsQ2dO3zJTiA6eTc4Ihf7k+A==}
'@extism/js-pdk@1.1.1':
resolution: {integrity: sha512-VZLn/dX0ttA1uKk2PZeR/FL3N+nA1S5Vc7E5gdjkR60LuUIwCZT9cYON245V4HowHlBA7YOegh0TLjkx+wNbrA==}
'@faker-js/faker@10.1.0':
resolution: {integrity: sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==}
engines: {node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10'}
@ -4590,6 +4620,9 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
'@types/justified-layout@4.1.4':
resolution: {integrity: sha512-q2ybP0u0NVj87oMnGZOGxY2iUN8ddr48zPOBHBdbOLpsMTA/keGj+93ou+OMCnJk0xewzlNIaVEkxM6VBD3E2w==}
@ -5364,6 +5397,9 @@ packages:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
engines: {node: '>=8.0.0'}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@ -6294,6 +6330,9 @@ packages:
eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@ -7717,12 +7756,22 @@ packages:
jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
jsonwebtoken@9.0.2:
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
engines: {node: '>=12', npm: '>=6'}
just-compare@2.3.0:
resolution: {integrity: sha512-6shoR7HDT+fzfL3gBahx1jZG3hWLrhPAf+l7nCwahDdT9XDtosB9kIF0ZrzUp5QY8dJWfQVr5rnsPqsbvflDzg==}
justified-layout@4.1.0:
resolution: {integrity: sha512-M5FimNMXgiOYerVRGsXZ2YK9YNCaTtwtYp7Hb2308U1Q9TXXHx5G0p08mcVR5O53qf8bWY4NJcPBxE6zuayXSg==}
jwa@1.4.2:
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
jws@3.2.2:
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
kdbush@3.0.0:
resolution: {integrity: sha512-hRkd6/XW4HTsA9vjVpY9tuXJYLSlelnkTmVFu4M9/7MIYQtFcHpbugAU7UbOfjOiVSVYl2fqgBuJ32JUmRo5Ew==}
@ -7922,15 +7971,36 @@ packages:
lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
lodash.isboolean@3.0.3:
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
lodash.isinteger@4.0.4:
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
lodash.isnumber@3.0.3:
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
lodash.memoize@4.1.2:
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lodash.uniq@4.5.0:
resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==}
@ -11043,6 +11113,9 @@ packages:
resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==}
engines: {node: '>= 0.4'}
urlpattern-polyfill@8.0.2:
resolution: {integrity: sha512-Qp95D4TPJl1kC9SKigDcqgyM2VDVO4RiJc2d4qe5GrYm+zbIQCWWKAFaJNQ4BhdFeDGwBmAxqJBwWSJDb9T3BQ==}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
@ -14170,6 +14243,12 @@ snapshots:
'@eslint/core': 0.16.0
levn: 0.4.1
'@extism/extism@2.0.0-rc13': {}
'@extism/js-pdk@1.1.1':
dependencies:
urlpattern-polyfill: 8.0.2
'@faker-js/faker@10.1.0': {}
'@fig/complete-commander@3.2.0(commander@11.1.0)':
@ -16414,6 +16493,11 @@ snapshots:
'@types/json-schema@7.0.15': {}
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 22.19.0
'@types/justified-layout@4.1.4': {}
'@types/keygrip@1.0.6': {}
@ -17389,6 +17473,8 @@ snapshots:
buffer-crc32@1.0.0: {}
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {}
buffer@5.7.1:
@ -18341,6 +18427,10 @@ snapshots:
eastasianwidth@0.2.0: {}
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {}
electron-to-chromium@1.5.243: {}
@ -20155,10 +20245,34 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
jsonwebtoken@9.0.2:
dependencies:
jws: 3.2.2
lodash.includes: 4.3.0
lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4
lodash.isnumber: 3.0.3
lodash.isplainobject: 4.0.6
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.7.3
just-compare@2.3.0: {}
justified-layout@4.1.0: {}
jwa@1.4.2:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@3.2.2:
dependencies:
jwa: 1.4.2
safe-buffer: 5.2.1
kdbush@3.0.0: {}
kdbush@4.0.2: {}
@ -20323,12 +20437,26 @@ snapshots:
lodash.defaults@4.2.0: {}
lodash.includes@4.3.0: {}
lodash.isarguments@3.1.0: {}
lodash.isboolean@3.0.3: {}
lodash.isinteger@4.0.4: {}
lodash.isnumber@3.0.3: {}
lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {}
lodash.memoize@4.1.2: {}
lodash.merge@4.6.2: {}
lodash.once@4.1.1: {}
lodash.uniq@4.5.0: {}
lodash@4.17.21: {}
@ -24145,6 +24273,8 @@ snapshots:
punycode: 1.4.1
qs: 6.14.0
urlpattern-polyfill@8.0.2: {}
use-sync-external-store@1.6.0(react@18.3.1):
dependencies:
react: 18.3.1

View file

@ -4,6 +4,7 @@ packages:
- e2e
- open-api/typescript-sdk
- server
- plugins
- web
- .github
ignoredBuiltDependencies:

View file

@ -48,6 +48,24 @@ RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \
pnpm --filter @immich/sdk --filter @immich/cli build && \
pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned
FROM builder AS plugins
COPY --from=ghcr.io/jdx/mise:2025.11.3 /usr/local/bin/mise /usr/local/bin/mise
WORKDIR /usr/src/app
COPY ./plugins/mise.toml ./plugins/
ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/plugins/mise.toml
RUN mise install --cd plugins
COPY ./plugins ./plugins/
# Build plugins
RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
--mount=type=bind,source=package.json,target=package.json \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
cd plugins && mise run build
FROM ghcr.io/immich-app/base-server-prod:202511041104@sha256:57c0379977fd5521d83cdf661aecd1497c83a9a661ebafe0a5243a09fc1064cb
WORKDIR /usr/src/app
@ -58,6 +76,8 @@ ENV NODE_ENV=production \
COPY --from=server /output/server-pruned ./server
COPY --from=web /usr/src/app/web/build /build/www
COPY --from=cli /output/cli-pruned ./cli
COPY --from=plugins /usr/src/app/plugins/dist /build/corePlugin/dist
COPY --from=plugins /usr/src/app/plugins/manifest.json /build/corePlugin/manifest.json
RUN ln -s ../../cli/bin/immich server/bin/immich
COPY LICENSE /licenses/LICENSE.txt
COPY LICENSE /LICENSE

View file

@ -34,6 +34,7 @@
"email:dev": "email dev -p 3050 --dir src/emails"
},
"dependencies": {
"@extism/extism": "2.0.0-rc13",
"@nestjs/bullmq": "^11.0.1",
"@nestjs/common": "^11.0.4",
"@nestjs/core": "^11.0.4",
@ -56,6 +57,7 @@
"@react-email/components": "^0.5.0",
"@react-email/render": "^1.1.2",
"@socket.io/redis-adapter": "^8.3.0",
"ajv": "^8.17.1",
"archiver": "^7.0.0",
"async-lock": "^1.4.0",
"bcrypt": "^6.0.0",
@ -77,6 +79,7 @@
"i18n-iso-countries": "^7.6.0",
"ioredis": "^5.8.2",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"kysely": "0.28.2",
"kysely-postgres-js": "^3.0.0",
"lodash": "^4.17.21",
@ -124,6 +127,7 @@
"@types/cookie-parser": "^1.4.8",
"@types/express": "^5.0.0",
"@types/fluent-ffmpeg": "^2.1.21",
"@types/jsonwebtoken": "^9.0.10",
"@types/js-yaml": "^4.0.9",
"@types/lodash": "^4.14.197",
"@types/luxon": "^3.6.2",

View file

@ -235,6 +235,7 @@ export const defaults = Object.freeze<SystemConfig>({
[QueueName.VideoConversion]: { concurrency: 1 },
[QueueName.Notification]: { concurrency: 5 },
[QueueName.Ocr]: { concurrency: 1 },
[QueueName.Workflow]: { concurrency: 5 },
},
logging: {
enabled: true,

View file

@ -160,6 +160,8 @@ export const endpointTags: Record<ApiTag, string> = {
[ApiTag.Partners]: 'A partner is a link with another user that allows sharing of assets between two users.',
[ApiTag.People]:
'A person is a collection of faces, which can be favorited and named. A person can also be merged into another person. People are automatically created via the face recognition job.',
[ApiTag.Plugins]:
'A plugin is an installed module that makes filters and actions available for the workflow feature.',
[ApiTag.Search]:
'Endpoints related to searching assets via text, smart search, optical character recognition (OCR), and other filters like person, album, and other metadata. Search endpoints usually support pagination and sorting.',
[ApiTag.Server]:
@ -185,4 +187,6 @@ export const endpointTags: Record<ApiTag, string> = {
[ApiTag.Users]:
'Endpoints for viewing and updating the current users, including product key information, profile picture data, onboarding progress, and more.',
[ApiTag.Views]: 'Endpoints for specialized views, such as the folder view.',
[ApiTag.Workflows]:
'A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution.',
};

View file

@ -18,6 +18,7 @@ import { NotificationController } from 'src/controllers/notification.controller'
import { OAuthController } from 'src/controllers/oauth.controller';
import { PartnerController } from 'src/controllers/partner.controller';
import { PersonController } from 'src/controllers/person.controller';
import { PluginController } from 'src/controllers/plugin.controller';
import { SearchController } from 'src/controllers/search.controller';
import { ServerController } from 'src/controllers/server.controller';
import { SessionController } from 'src/controllers/session.controller';
@ -32,6 +33,7 @@ import { TrashController } from 'src/controllers/trash.controller';
import { UserAdminController } from 'src/controllers/user-admin.controller';
import { UserController } from 'src/controllers/user.controller';
import { ViewController } from 'src/controllers/view.controller';
import { WorkflowController } from 'src/controllers/workflow.controller';
export const controllers = [
ApiKeyController,
@ -54,6 +56,7 @@ export const controllers = [
OAuthController,
PartnerController,
PersonController,
PluginController,
SearchController,
ServerController,
SessionController,
@ -68,4 +71,5 @@ export const controllers = [
UserAdminController,
UserController,
ViewController,
WorkflowController,
];

View file

@ -0,0 +1,36 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { PluginResponseDto } from 'src/dtos/plugin.dto';
import { Permission } from 'src/enum';
import { Authenticated } from 'src/middleware/auth.guard';
import { PluginService } from 'src/services/plugin.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Plugins')
@Controller('plugins')
export class PluginController {
constructor(private service: PluginService) {}
@Get()
@Authenticated({ permission: Permission.PluginRead })
@Endpoint({
summary: 'List all plugins',
description: 'Retrieve a list of plugins available to the authenticated user.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
getPlugins(): Promise<PluginResponseDto[]> {
return this.service.getAll();
}
@Get(':id')
@Authenticated({ permission: Permission.PluginRead })
@Endpoint({
summary: 'Retrieve a plugin',
description: 'Retrieve information about a specific plugin by its ID.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
getPlugin(@Param() { id }: UUIDParamDto): Promise<PluginResponseDto> {
return this.service.get(id);
}
}

View file

@ -0,0 +1,76 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { WorkflowCreateDto, WorkflowResponseDto, WorkflowUpdateDto } from 'src/dtos/workflow.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { WorkflowService } from 'src/services/workflow.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags('Workflows')
@Controller('workflows')
export class WorkflowController {
constructor(private service: WorkflowService) {}
@Post()
@Authenticated({ permission: Permission.WorkflowCreate })
@Endpoint({
summary: 'Create a workflow',
description: 'Create a new workflow, the workflow can also be created with empty filters and actions.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
createWorkflow(@Auth() auth: AuthDto, @Body() dto: WorkflowCreateDto): Promise<WorkflowResponseDto> {
return this.service.create(auth, dto);
}
@Get()
@Authenticated({ permission: Permission.WorkflowRead })
@Endpoint({
summary: 'List all workflows',
description: 'Retrieve a list of workflows available to the authenticated user.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
getWorkflows(@Auth() auth: AuthDto): Promise<WorkflowResponseDto[]> {
return this.service.getAll(auth);
}
@Get(':id')
@Authenticated({ permission: Permission.WorkflowRead })
@Endpoint({
summary: 'Retrieve a workflow',
description: 'Retrieve information about a specific workflow by its ID.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
getWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<WorkflowResponseDto> {
return this.service.get(auth, id);
}
@Put(':id')
@Authenticated({ permission: Permission.WorkflowUpdate })
@Endpoint({
summary: 'Update a workflow',
description:
'Update the information of a specific workflow by its ID. This endpoint can be used to update the workflow name, description, trigger type, filters and actions order, etc.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
updateWorkflow(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: WorkflowUpdateDto,
): Promise<WorkflowResponseDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.WorkflowDelete })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Delete a workflow',
description: 'Delete a workflow by its ID.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
deleteWorkflow(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
}

View file

@ -7,6 +7,8 @@ import {
AssetVisibility,
MemoryType,
Permission,
PluginContext,
PluginTriggerType,
SharedLinkType,
SourceType,
UserAvatarColor,
@ -14,7 +16,10 @@ import {
} from 'src/enum';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
import { UserMetadataItem } from 'src/types';
import type { ActionConfig, FilterConfig, JSONSchema } from 'src/types/plugin-schema.types';
export type AuthUser = {
id: string;
@ -277,6 +282,45 @@ export type AssetFace = {
updateId: string;
};
export type Plugin = Selectable<PluginTable>;
export type PluginFilter = Selectable<PluginFilterTable> & {
methodName: string;
title: string;
description: string;
supportedContexts: PluginContext[];
schema: JSONSchema | null;
};
export type PluginAction = Selectable<PluginActionTable> & {
methodName: string;
title: string;
description: string;
supportedContexts: PluginContext[];
schema: JSONSchema | null;
};
export type Workflow = Selectable<WorkflowTable> & {
triggerType: PluginTriggerType;
name: string | null;
description: string;
enabled: boolean;
};
export type WorkflowFilter = Selectable<WorkflowFilterTable> & {
workflowId: string;
filterId: string;
filterConfig: FilterConfig | null;
order: number;
};
export type WorkflowAction = Selectable<WorkflowActionTable> & {
workflowId: string;
actionId: string;
actionConfig: ActionConfig | null;
order: number;
};
const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const;
const userWithPrefixColumns = [
'user2.id',
@ -418,4 +462,15 @@ export const columns = {
'asset_exif.state',
'asset_exif.timeZone',
],
plugin: [
'plugin.id as id',
'plugin.name as name',
'plugin.title as title',
'plugin.description as description',
'plugin.author as author',
'plugin.version as version',
'plugin.wasmPath as wasmPath',
'plugin.createdAt as createdAt',
'plugin.updatedAt as updatedAt',
],
} as const;

View file

@ -57,6 +57,13 @@ export class EnvDto {
@Type(() => Number)
IMMICH_MICROSERVICES_METRICS_PORT?: number;
@ValidateBoolean({ optional: true })
IMMICH_PLUGINS_ENABLED?: boolean;
@Optional()
@Matches(/^\//, { message: 'IMMICH_PLUGINS_INSTALL_FOLDER must be an absolute path' })
IMMICH_PLUGINS_INSTALL_FOLDER?: string;
@IsInt()
@Optional()
@Type(() => Number)

View file

@ -0,0 +1,110 @@
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsEnum,
IsNotEmpty,
IsObject,
IsOptional,
IsSemVer,
IsString,
Matches,
ValidateNested,
} from 'class-validator';
import { PluginContext } from 'src/enum';
import { JSONSchema } from 'src/types/plugin-schema.types';
import { ValidateEnum } from 'src/validation';
class PluginManifestWasmDto {
@IsString()
@IsNotEmpty()
path!: string;
}
class PluginManifestFilterDto {
@IsString()
@IsNotEmpty()
methodName!: string;
@IsString()
@IsNotEmpty()
title!: string;
@IsString()
@IsNotEmpty()
description!: string;
@IsArray()
@ArrayMinSize(1)
@IsEnum(PluginContext, { each: true })
supportedContexts!: PluginContext[];
@IsObject()
@IsOptional()
schema?: JSONSchema;
}
class PluginManifestActionDto {
@IsString()
@IsNotEmpty()
methodName!: string;
@IsString()
@IsNotEmpty()
title!: string;
@IsString()
@IsNotEmpty()
description!: string;
@IsArray()
@ArrayMinSize(1)
@ValidateEnum({ enum: PluginContext, name: 'PluginContext', each: true })
supportedContexts!: PluginContext[];
@IsObject()
@IsOptional()
schema?: JSONSchema;
}
export class PluginManifestDto {
@IsString()
@IsNotEmpty()
@Matches(/^[a-z0-9-]+[a-z0-9]$/, {
message: 'Plugin name must contain only lowercase letters, numbers, and hyphens, and cannot end with a hyphen',
})
name!: string;
@IsString()
@IsNotEmpty()
@IsSemVer()
version!: string;
@IsString()
@IsNotEmpty()
title!: string;
@IsString()
@IsNotEmpty()
description!: string;
@IsString()
@IsNotEmpty()
author!: string;
@ValidateNested()
@Type(() => PluginManifestWasmDto)
wasm!: PluginManifestWasmDto;
@IsArray()
@ValidateNested({ each: true })
@Type(() => PluginManifestFilterDto)
@IsOptional()
filters?: PluginManifestFilterDto[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => PluginManifestActionDto)
@IsOptional()
actions?: PluginManifestActionDto[];
}

View file

@ -0,0 +1,77 @@
import { IsNotEmpty, IsString } from 'class-validator';
import { PluginAction, PluginFilter } from 'src/database';
import { PluginContext } from 'src/enum';
import type { JSONSchema } from 'src/types/plugin-schema.types';
import { ValidateEnum } from 'src/validation';
export class PluginResponseDto {
id!: string;
name!: string;
title!: string;
description!: string;
author!: string;
version!: string;
createdAt!: string;
updatedAt!: string;
filters!: PluginFilterResponseDto[];
actions!: PluginActionResponseDto[];
}
export class PluginFilterResponseDto {
id!: string;
pluginId!: string;
methodName!: string;
title!: string;
description!: string;
@ValidateEnum({ enum: PluginContext, name: 'PluginContext' })
supportedContexts!: PluginContext[];
schema!: JSONSchema | null;
}
export class PluginActionResponseDto {
id!: string;
pluginId!: string;
methodName!: string;
title!: string;
description!: string;
@ValidateEnum({ enum: PluginContext, name: 'PluginContext' })
supportedContexts!: PluginContext[];
schema!: JSONSchema | null;
}
export class PluginInstallDto {
@IsString()
@IsNotEmpty()
manifestPath!: string;
}
export type MapPlugin = {
id: string;
name: string;
title: string;
description: string;
author: string;
version: string;
wasmPath: string;
createdAt: Date;
updatedAt: Date;
filters: PluginFilter[];
actions: PluginAction[];
};
export function mapPlugin(plugin: MapPlugin): PluginResponseDto {
return {
id: plugin.id,
name: plugin.name,
title: plugin.title,
description: plugin.description,
author: plugin.author,
version: plugin.version,
createdAt: plugin.createdAt.toISOString(),
updatedAt: plugin.updatedAt.toISOString(),
filters: plugin.filters,
actions: plugin.actions,
};
}

View file

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

View file

@ -224,6 +224,12 @@ class SystemConfigJobDto implements Record<ConcurrentQueueName, JobSettingsDto>
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.Notification]!: JobSettingsDto;
@ApiProperty({ type: JobSettingsDto })
@ValidateNested()
@IsObject()
@Type(() => JobSettingsDto)
[QueueName.Workflow]!: JobSettingsDto;
}
class SystemConfigLibraryScanDto {

View file

@ -0,0 +1,120 @@
import { Type } from 'class-transformer';
import { IsNotEmpty, IsObject, IsString, IsUUID, ValidateNested } from 'class-validator';
import { WorkflowAction, WorkflowFilter } from 'src/database';
import { PluginTriggerType } from 'src/enum';
import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types';
import { Optional, ValidateBoolean, ValidateEnum } from 'src/validation';
export class WorkflowFilterItemDto {
@IsUUID()
filterId!: string;
@IsObject()
@Optional()
filterConfig?: FilterConfig;
}
export class WorkflowActionItemDto {
@IsUUID()
actionId!: string;
@IsObject()
@Optional()
actionConfig?: ActionConfig;
}
export class WorkflowCreateDto {
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' })
triggerType!: PluginTriggerType;
@IsString()
@IsNotEmpty()
name!: string;
@IsString()
@Optional()
description?: string;
@ValidateBoolean({ optional: true })
enabled?: boolean;
@ValidateNested({ each: true })
@Type(() => WorkflowFilterItemDto)
filters!: WorkflowFilterItemDto[];
@ValidateNested({ each: true })
@Type(() => WorkflowActionItemDto)
actions!: WorkflowActionItemDto[];
}
export class WorkflowUpdateDto {
@IsString()
@IsNotEmpty()
@Optional()
name?: string;
@IsString()
@Optional()
description?: string;
@ValidateBoolean({ optional: true })
enabled?: boolean;
@ValidateNested({ each: true })
@Type(() => WorkflowFilterItemDto)
@Optional()
filters?: WorkflowFilterItemDto[];
@ValidateNested({ each: true })
@Type(() => WorkflowActionItemDto)
@Optional()
actions?: WorkflowActionItemDto[];
}
export class WorkflowResponseDto {
id!: string;
ownerId!: string;
triggerType!: PluginTriggerType;
name!: string | null;
description!: string;
createdAt!: string;
enabled!: boolean;
filters!: WorkflowFilterResponseDto[];
actions!: WorkflowActionResponseDto[];
}
export class WorkflowFilterResponseDto {
id!: string;
workflowId!: string;
filterId!: string;
filterConfig!: FilterConfig | null;
order!: number;
}
export class WorkflowActionResponseDto {
id!: string;
workflowId!: string;
actionId!: string;
actionConfig!: ActionConfig | null;
order!: number;
}
export function mapWorkflowFilter(filter: WorkflowFilter): WorkflowFilterResponseDto {
return {
id: filter.id,
workflowId: filter.workflowId,
filterId: filter.filterId,
filterConfig: filter.filterConfig,
order: filter.order,
};
}
export function mapWorkflowAction(action: WorkflowAction): WorkflowActionResponseDto {
return {
id: action.id,
workflowId: action.workflowId,
actionId: action.actionId,
actionConfig: action.actionConfig,
order: action.order,
};
}

View file

@ -177,6 +177,11 @@ export enum Permission {
PinCodeUpdate = 'pinCode.update',
PinCodeDelete = 'pinCode.delete',
PluginCreate = 'plugin.create',
PluginRead = 'plugin.read',
PluginUpdate = 'plugin.update',
PluginDelete = 'plugin.delete',
ServerAbout = 'server.about',
ServerApkLinks = 'server.apkLinks',
ServerStorage = 'server.storage',
@ -240,6 +245,11 @@ export enum Permission {
UserProfileImageUpdate = 'userProfileImage.update',
UserProfileImageDelete = 'userProfileImage.delete',
WorkflowCreate = 'workflow.create',
WorkflowRead = 'workflow.read',
WorkflowUpdate = 'workflow.update',
WorkflowDelete = 'workflow.delete',
AdminUserCreate = 'adminUser.create',
AdminUserRead = 'adminUser.read',
AdminUserUpdate = 'adminUser.update',
@ -525,6 +535,7 @@ export enum QueueName {
Notification = 'notifications',
BackupDatabase = 'backupDatabase',
Ocr = 'ocr',
Workflow = 'workflow',
}
export enum JobName {
@ -601,6 +612,9 @@ export enum JobName {
// OCR
OcrQueueAll = 'OcrQueueAll',
Ocr = 'Ocr',
// Workflow
WorkflowRun = 'WorkflowRun',
}
export enum QueueCommand {
@ -793,6 +807,7 @@ export enum ApiTag {
NotificationsAdmin = 'Notifications (admin)',
Partners = 'Partners',
People = 'People',
Plugins = 'Plugins',
Search = 'Search',
Server = 'Server',
Sessions = 'Sessions',
@ -807,4 +822,16 @@ export enum ApiTag {
UsersAdmin = 'Users (admin)',
Users = 'Users',
Views = 'Views',
Workflows = 'Workflows',
}
export enum PluginContext {
Asset = 'asset',
Album = 'album',
Person = 'person',
}
export enum PluginTriggerType {
AssetCreate = 'AssetCreate',
PersonRecognized = 'PersonRecognized',
}

37
server/src/plugins.ts Normal file
View file

@ -0,0 +1,37 @@
import { PluginContext, PluginTriggerType } from 'src/enum';
import { JSONSchema } from 'src/types/plugin-schema.types';
export type PluginTrigger = {
name: string;
type: PluginTriggerType;
description: string;
context: PluginContext;
schema: JSONSchema | null;
};
export const pluginTriggers: PluginTrigger[] = [
{
name: 'Asset Uploaded',
type: PluginTriggerType.AssetCreate,
description: 'Triggered when a new asset is uploaded',
context: PluginContext.Asset,
schema: {
type: 'object',
properties: {
assetType: {
type: 'string',
description: 'Type of the asset',
default: 'ALL',
enum: ['Image', 'Video', 'All'],
},
},
},
},
{
name: 'Person Recognized',
type: PluginTriggerType.PersonRecognized,
description: 'Triggered when a person is detected in an asset',
context: PluginContext.Person,
schema: null,
},
];

View file

@ -243,3 +243,12 @@ from
where
"partner"."sharedById" in ($1)
and "partner"."sharedWithId" = $2
-- AccessRepository.workflow.checkOwnerAccess
select
"workflow"."id"
from
"workflow"
where
"workflow"."id" in ($1)
and "workflow"."ownerId" = $2

View file

@ -0,0 +1,159 @@
-- NOTE: This file is auto generated by ./sql-generator
-- PluginRepository.getPlugin
select
"plugin"."id" as "id",
"plugin"."name" as "name",
"plugin"."title" as "title",
"plugin"."description" as "description",
"plugin"."author" as "author",
"plugin"."version" as "version",
"plugin"."wasmPath" as "wasmPath",
"plugin"."createdAt" as "createdAt",
"plugin"."updatedAt" as "updatedAt",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"plugin_filter"
where
"plugin_filter"."pluginId" = "plugin"."id"
) as agg
) as "filters",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"plugin_action"
where
"plugin_action"."pluginId" = "plugin"."id"
) as agg
) as "actions"
from
"plugin"
where
"plugin"."id" = $1
-- PluginRepository.getPluginByName
select
"plugin"."id" as "id",
"plugin"."name" as "name",
"plugin"."title" as "title",
"plugin"."description" as "description",
"plugin"."author" as "author",
"plugin"."version" as "version",
"plugin"."wasmPath" as "wasmPath",
"plugin"."createdAt" as "createdAt",
"plugin"."updatedAt" as "updatedAt",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"plugin_filter"
where
"plugin_filter"."pluginId" = "plugin"."id"
) as agg
) as "filters",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"plugin_action"
where
"plugin_action"."pluginId" = "plugin"."id"
) as agg
) as "actions"
from
"plugin"
where
"plugin"."name" = $1
-- PluginRepository.getAllPlugins
select
"plugin"."id" as "id",
"plugin"."name" as "name",
"plugin"."title" as "title",
"plugin"."description" as "description",
"plugin"."author" as "author",
"plugin"."version" as "version",
"plugin"."wasmPath" as "wasmPath",
"plugin"."createdAt" as "createdAt",
"plugin"."updatedAt" as "updatedAt",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"plugin_filter"
where
"plugin_filter"."pluginId" = "plugin"."id"
) as agg
) as "filters",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"plugin_action"
where
"plugin_action"."pluginId" = "plugin"."id"
) as agg
) as "actions"
from
"plugin"
order by
"plugin"."name"
-- PluginRepository.getFilter
select
*
from
"plugin_filter"
where
"id" = $1
-- PluginRepository.getFiltersByPlugin
select
*
from
"plugin_filter"
where
"pluginId" = $1
-- PluginRepository.getAction
select
*
from
"plugin_action"
where
"id" = $1
-- PluginRepository.getActionsByPlugin
select
*
from
"plugin_action"
where
"pluginId" = $1

View file

@ -0,0 +1,68 @@
-- NOTE: This file is auto generated by ./sql-generator
-- WorkflowRepository.getWorkflow
select
*
from
"workflow"
where
"id" = $1
-- WorkflowRepository.getWorkflowsByOwner
select
*
from
"workflow"
where
"ownerId" = $1
order by
"name"
-- WorkflowRepository.getWorkflowsByTrigger
select
*
from
"workflow"
where
"triggerType" = $1
and "enabled" = $2
-- WorkflowRepository.getWorkflowByOwnerAndTrigger
select
*
from
"workflow"
where
"ownerId" = $1
and "triggerType" = $2
and "enabled" = $3
-- WorkflowRepository.deleteWorkflow
delete from "workflow"
where
"id" = $1
-- WorkflowRepository.getFilters
select
*
from
"workflow_filter"
where
"workflowId" = $1
order by
"order" asc
-- WorkflowRepository.deleteFiltersByWorkflow
delete from "workflow_filter"
where
"workflowId" = $1
-- WorkflowRepository.getActions
select
*
from
"workflow_action"
where
"workflowId" = $1
order by
"order" asc

View file

@ -462,6 +462,26 @@ class TagAccess {
}
}
class WorkflowAccess {
constructor(private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] })
@ChunkedSet({ paramIndex: 1 })
async checkOwnerAccess(userId: string, workflowIds: Set<string>) {
if (workflowIds.size === 0) {
return new Set<string>();
}
return this.db
.selectFrom('workflow')
.select('workflow.id')
.where('workflow.id', 'in', [...workflowIds])
.where('workflow.ownerId', '=', userId)
.execute()
.then((workflows) => new Set(workflows.map((workflow) => workflow.id)));
}
}
@Injectable()
export class AccessRepository {
activity: ActivityAccess;
@ -476,6 +496,7 @@ export class AccessRepository {
stack: StackAccess;
tag: TagAccess;
timeline: TimelineAccess;
workflow: WorkflowAccess;
constructor(@InjectKysely() db: Kysely<DB>) {
this.activity = new ActivityAccess(db);
@ -490,5 +511,6 @@ export class AccessRepository {
this.stack = new StackAccess(db);
this.tag = new TagAccess(db);
this.timeline = new TimelineAccess(db);
this.workflow = new WorkflowAccess(db);
}
}

View file

@ -85,6 +85,7 @@ export interface EnvData {
root: string;
indexHtml: string;
};
corePlugin: string;
};
redis: RedisOptions;
@ -102,6 +103,11 @@ export interface EnvData {
workers: ImmichWorker[];
plugins: {
enabled: boolean;
installFolder?: string;
};
noColor: boolean;
nodeVersion?: string;
}
@ -304,6 +310,7 @@ const getEnv = (): EnvData => {
root: folders.web,
indexHtml: join(folders.web, 'index.html'),
},
corePlugin: join(buildFolder, 'corePlugin'),
},
storage: {
@ -319,6 +326,11 @@ const getEnv = (): EnvData => {
workers,
plugins: {
enabled: !!dto.IMMICH_PLUGINS_ENABLED,
installFolder: dto.IMMICH_PLUGINS_INSTALL_FOLDER,
},
noColor: !!dto.NO_COLOR,
};
};

View file

@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { compareSync, hash } from 'bcrypt';
import jwt from 'jsonwebtoken';
import { createHash, createPublicKey, createVerify, randomBytes, randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
@ -57,4 +58,12 @@ export class CryptoRepository {
randomBytesAsText(bytes: number) {
return randomBytes(bytes).toString('base64').replaceAll(/\W/g, '');
}
signJwt(payload: string | object | Buffer, secret: string, options?: jwt.SignOptions): string {
return jwt.sign(payload, secret, { algorithm: 'HS256', ...options });
}
verifyJwt<T = any>(token: string, secret: string): T {
return jwt.verify(token, secret, { algorithms: ['HS256'] }) as T;
}
}

View file

@ -4,6 +4,7 @@ import { ClassConstructor } from 'class-transformer';
import _ from 'lodash';
import { Socket } from 'socket.io';
import { SystemConfig } from 'src/config';
import { Asset } from 'src/database';
import { EventConfig } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { ImmichWorker, JobStatus, MetadataKey, QueueName, UserAvatarColor, UserStatus } from 'src/enum';
@ -41,6 +42,7 @@ type EventMap = {
AlbumInvite: [{ id: string; userId: string }];
// asset events
AssetCreate: [{ asset: Asset }];
AssetTag: [{ assetId: string }];
AssetUntag: [{ assetId: string }];
AssetHide: [{ assetId: string; userId: string }];

View file

@ -28,6 +28,7 @@ import { OAuthRepository } from 'src/repositories/oauth.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
import { ProcessRepository } from 'src/repositories/process.repository';
import { SearchRepository } from 'src/repositories/search.repository';
import { ServerInfoRepository } from 'src/repositories/server-info.repository';
@ -46,6 +47,7 @@ import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { ViewRepository } from 'src/repositories/view-repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository';
export const repositories = [
AccessRepository,
@ -78,6 +80,7 @@ export const repositories = [
OcrRepository,
PartnerRepository,
PersonRepository,
PluginRepository,
ProcessRepository,
SearchRepository,
SessionRepository,
@ -96,4 +99,5 @@ export const repositories = [
ViewRepository,
VersionHistoryRepository,
WebsocketRepository,
WorkflowRepository,
];

View file

@ -0,0 +1,176 @@
import { Injectable } from '@nestjs/common';
import { Kysely } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { readdir } from 'node:fs/promises';
import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
import { DB } from 'src/schema';
@Injectable()
export class PluginRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
/**
* Loads a plugin from a validated manifest file in a transaction.
* This ensures all plugin, filter, and action operations are atomic.
* @param manifest The validated plugin manifest
* @param basePath The base directory path where the plugin is located
*/
async loadPlugin(manifest: PluginManifestDto, basePath: string) {
return this.db.transaction().execute(async (tx) => {
// Upsert the plugin
const plugin = await tx
.insertInto('plugin')
.values({
name: manifest.name,
title: manifest.title,
description: manifest.description,
author: manifest.author,
version: manifest.version,
wasmPath: `${basePath}/${manifest.wasm.path}`,
})
.onConflict((oc) =>
oc.column('name').doUpdateSet({
title: manifest.title,
description: manifest.description,
author: manifest.author,
version: manifest.version,
wasmPath: `${basePath}/${manifest.wasm.path}`,
}),
)
.returningAll()
.executeTakeFirstOrThrow();
const filters = manifest.filters
? await tx
.insertInto('plugin_filter')
.values(
manifest.filters.map((filter) => ({
pluginId: plugin.id,
methodName: filter.methodName,
title: filter.title,
description: filter.description,
supportedContexts: filter.supportedContexts,
schema: filter.schema,
})),
)
.onConflict((oc) =>
oc.column('methodName').doUpdateSet((eb) => ({
pluginId: eb.ref('excluded.pluginId'),
title: eb.ref('excluded.title'),
description: eb.ref('excluded.description'),
supportedContexts: eb.ref('excluded.supportedContexts'),
schema: eb.ref('excluded.schema'),
})),
)
.returningAll()
.execute()
: [];
const actions = manifest.actions
? await tx
.insertInto('plugin_action')
.values(
manifest.actions.map((action) => ({
pluginId: plugin.id,
methodName: action.methodName,
title: action.title,
description: action.description,
supportedContexts: action.supportedContexts,
schema: action.schema,
})),
)
.onConflict((oc) =>
oc.column('methodName').doUpdateSet((eb) => ({
pluginId: eb.ref('excluded.pluginId'),
title: eb.ref('excluded.title'),
description: eb.ref('excluded.description'),
supportedContexts: eb.ref('excluded.supportedContexts'),
schema: eb.ref('excluded.schema'),
})),
)
.returningAll()
.execute()
: [];
return { plugin, filters, actions };
});
}
async readDirectory(path: string) {
return readdir(path, { withFileTypes: true });
}
@GenerateSql({ params: [DummyValue.UUID] })
getPlugin(id: string) {
return this.db
.selectFrom('plugin')
.select((eb) => [
...columns.plugin,
jsonArrayFrom(
eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'),
).as('filters'),
jsonArrayFrom(
eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'),
).as('actions'),
])
.where('plugin.id', '=', id)
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.STRING] })
getPluginByName(name: string) {
return this.db
.selectFrom('plugin')
.select((eb) => [
...columns.plugin,
jsonArrayFrom(
eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'),
).as('filters'),
jsonArrayFrom(
eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'),
).as('actions'),
])
.where('plugin.name', '=', name)
.executeTakeFirst();
}
@GenerateSql()
getAllPlugins() {
return this.db
.selectFrom('plugin')
.select((eb) => [
...columns.plugin,
jsonArrayFrom(
eb.selectFrom('plugin_filter').selectAll().whereRef('plugin_filter.pluginId', '=', 'plugin.id'),
).as('filters'),
jsonArrayFrom(
eb.selectFrom('plugin_action').selectAll().whereRef('plugin_action.pluginId', '=', 'plugin.id'),
).as('actions'),
])
.orderBy('plugin.name')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getFilter(id: string) {
return this.db.selectFrom('plugin_filter').selectAll().where('id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getFiltersByPlugin(pluginId: string) {
return this.db.selectFrom('plugin_filter').selectAll().where('pluginId', '=', pluginId).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getAction(id: string) {
return this.db.selectFrom('plugin_action').selectAll().where('id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getActionsByPlugin(pluginId: string) {
return this.db.selectFrom('plugin_action').selectAll().where('pluginId', '=', pluginId).execute();
}
}

View file

@ -113,6 +113,10 @@ export class StorageRepository {
}
}
async readTextFile(filepath: string): Promise<string> {
return fs.readFile(filepath, 'utf8');
}
async checkFileExists(filepath: string, mode = constants.F_OK): Promise<boolean> {
try {
await fs.access(filepath, mode);

View file

@ -0,0 +1,139 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Updateable } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DummyValue, GenerateSql } from 'src/decorators';
import { PluginTriggerType } from 'src/enum';
import { DB } from 'src/schema';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
@Injectable()
export class WorkflowRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID] })
getWorkflow(id: string) {
return this.db.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
getWorkflowsByOwner(ownerId: string) {
return this.db.selectFrom('workflow').selectAll().where('ownerId', '=', ownerId).orderBy('name').execute();
}
@GenerateSql({ params: [PluginTriggerType.AssetCreate] })
getWorkflowsByTrigger(type: PluginTriggerType) {
return this.db
.selectFrom('workflow')
.selectAll()
.where('triggerType', '=', type)
.where('enabled', '=', true)
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, PluginTriggerType.AssetCreate] })
getWorkflowByOwnerAndTrigger(ownerId: string, type: PluginTriggerType) {
return this.db
.selectFrom('workflow')
.selectAll()
.where('ownerId', '=', ownerId)
.where('triggerType', '=', type)
.where('enabled', '=', true)
.execute();
}
async createWorkflow(
workflow: Insertable<WorkflowTable>,
filters: Insertable<WorkflowFilterTable>[],
actions: Insertable<WorkflowActionTable>[],
) {
return await this.db.transaction().execute(async (tx) => {
const createdWorkflow = await tx.insertInto('workflow').values(workflow).returningAll().executeTakeFirstOrThrow();
if (filters.length > 0) {
const newFilters = filters.map((filter) => ({
...filter,
workflowId: createdWorkflow.id,
}));
await tx.insertInto('workflow_filter').values(newFilters).execute();
}
if (actions.length > 0) {
const newActions = actions.map((action) => ({
...action,
workflowId: createdWorkflow.id,
}));
await tx.insertInto('workflow_action').values(newActions).execute();
}
return createdWorkflow;
});
}
async updateWorkflow(
id: string,
workflow: Updateable<WorkflowTable>,
filters: Insertable<WorkflowFilterTable>[] | undefined,
actions: Insertable<WorkflowActionTable>[] | undefined,
) {
return await this.db.transaction().execute(async (trx) => {
if (Object.keys(workflow).length > 0) {
await trx.updateTable('workflow').set(workflow).where('id', '=', id).execute();
}
if (filters !== undefined) {
await trx.deleteFrom('workflow_filter').where('workflowId', '=', id).execute();
if (filters.length > 0) {
const filtersWithWorkflowId = filters.map((filter) => ({
...filter,
workflowId: id,
}));
await trx.insertInto('workflow_filter').values(filtersWithWorkflowId).execute();
}
}
if (actions !== undefined) {
await trx.deleteFrom('workflow_action').where('workflowId', '=', id).execute();
if (actions.length > 0) {
const actionsWithWorkflowId = actions.map((action) => ({
...action,
workflowId: id,
}));
await trx.insertInto('workflow_action').values(actionsWithWorkflowId).execute();
}
}
return await trx.selectFrom('workflow').selectAll().where('id', '=', id).executeTakeFirstOrThrow();
});
}
@GenerateSql({ params: [DummyValue.UUID] })
async deleteWorkflow(id: string) {
await this.db.deleteFrom('workflow').where('id', '=', id).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getFilters(workflowId: string) {
return this.db
.selectFrom('workflow_filter')
.selectAll()
.where('workflowId', '=', workflowId)
.orderBy('order', 'asc')
.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async deleteFiltersByWorkflow(workflowId: string) {
await this.db.deleteFrom('workflow_filter').where('workflowId', '=', workflowId).execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
getActions(workflowId: string) {
return this.db
.selectFrom('workflow_action')
.selectAll()
.where('workflowId', '=', workflowId)
.orderBy('order', 'asc')
.execute();
}
}

View file

@ -53,6 +53,7 @@ import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
import { PartnerTable } from 'src/schema/tables/partner.table';
import { PersonAuditTable } from 'src/schema/tables/person-audit.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table';
import { SessionTable } from 'src/schema/tables/session.table';
import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
@ -69,6 +70,7 @@ import { UserMetadataAuditTable } from 'src/schema/tables/user-metadata-audit.ta
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
import { UserTable } from 'src/schema/tables/user.table';
import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
import { Database, Extensions, Generated, Int8 } from 'src/sql-tools';
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
@ -125,6 +127,12 @@ export class ImmichDatabase {
UserMetadataAuditTable,
UserTable,
VersionHistoryTable,
PluginTable,
PluginFilterTable,
PluginActionTable,
WorkflowTable,
WorkflowFilterTable,
WorkflowActionTable,
];
functions = [
@ -231,4 +239,12 @@ export interface DB {
user_metadata_audit: UserMetadataAuditTable;
version_history: VersionHistoryTable;
plugin: PluginTable;
plugin_filter: PluginFilterTable;
plugin_action: PluginActionTable;
workflow: WorkflowTable;
workflow_filter: WorkflowFilterTable;
workflow_action: WorkflowActionTable;
}

View file

@ -0,0 +1,113 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TABLE "plugin" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"name" character varying NOT NULL,
"title" character varying NOT NULL,
"description" character varying NOT NULL,
"author" character varying NOT NULL,
"version" character varying NOT NULL,
"wasmPath" character varying NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT "plugin_name_uq" UNIQUE ("name"),
CONSTRAINT "plugin_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "plugin_name_idx" ON "plugin" ("name");`.execute(db);
await sql`CREATE TABLE "plugin_filter" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"pluginId" uuid NOT NULL,
"methodName" character varying NOT NULL,
"title" character varying NOT NULL,
"description" character varying NOT NULL,
"supportedContexts" character varying[] NOT NULL,
"schema" jsonb,
CONSTRAINT "plugin_filter_pluginId_fkey" FOREIGN KEY ("pluginId") REFERENCES "plugin" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "plugin_filter_methodName_uq" UNIQUE ("methodName"),
CONSTRAINT "plugin_filter_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "plugin_filter_supportedContexts_idx" ON "plugin_filter" USING gin ("supportedContexts");`.execute(
db,
);
await sql`CREATE INDEX "plugin_filter_pluginId_idx" ON "plugin_filter" ("pluginId");`.execute(db);
await sql`CREATE INDEX "plugin_filter_methodName_idx" ON "plugin_filter" ("methodName");`.execute(db);
await sql`CREATE TABLE "plugin_action" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"pluginId" uuid NOT NULL,
"methodName" character varying NOT NULL,
"title" character varying NOT NULL,
"description" character varying NOT NULL,
"supportedContexts" character varying[] NOT NULL,
"schema" jsonb,
CONSTRAINT "plugin_action_pluginId_fkey" FOREIGN KEY ("pluginId") REFERENCES "plugin" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "plugin_action_methodName_uq" UNIQUE ("methodName"),
CONSTRAINT "plugin_action_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "plugin_action_supportedContexts_idx" ON "plugin_action" USING gin ("supportedContexts");`.execute(
db,
);
await sql`CREATE INDEX "plugin_action_pluginId_idx" ON "plugin_action" ("pluginId");`.execute(db);
await sql`CREATE INDEX "plugin_action_methodName_idx" ON "plugin_action" ("methodName");`.execute(db);
await sql`CREATE TABLE "workflow" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"ownerId" uuid NOT NULL,
"triggerType" character varying NOT NULL,
"name" character varying,
"description" character varying NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"enabled" boolean NOT NULL DEFAULT true,
CONSTRAINT "workflow_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "workflow_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "workflow_ownerId_idx" ON "workflow" ("ownerId");`.execute(db);
await sql`CREATE TABLE "workflow_filter" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"workflowId" uuid NOT NULL,
"filterId" uuid NOT NULL,
"filterConfig" jsonb,
"order" integer NOT NULL,
CONSTRAINT "workflow_filter_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "workflow_filter_filterId_fkey" FOREIGN KEY ("filterId") REFERENCES "plugin_filter" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "workflow_filter_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "workflow_filter_filterId_idx" ON "workflow_filter" ("filterId");`.execute(db);
await sql`CREATE INDEX "workflow_filter_workflowId_order_idx" ON "workflow_filter" ("workflowId", "order");`.execute(
db,
);
await sql`CREATE INDEX "workflow_filter_workflowId_idx" ON "workflow_filter" ("workflowId");`.execute(db);
await sql`CREATE TABLE "workflow_action" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"workflowId" uuid NOT NULL,
"actionId" uuid NOT NULL,
"actionConfig" jsonb,
"order" integer NOT NULL,
CONSTRAINT "workflow_action_workflowId_fkey" FOREIGN KEY ("workflowId") REFERENCES "workflow" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "workflow_action_actionId_fkey" FOREIGN KEY ("actionId") REFERENCES "plugin_action" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT "workflow_action_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "workflow_action_actionId_idx" ON "workflow_action" ("actionId");`.execute(db);
await sql`CREATE INDEX "workflow_action_workflowId_order_idx" ON "workflow_action" ("workflowId", "order");`.execute(
db,
);
await sql`CREATE INDEX "workflow_action_workflowId_idx" ON "workflow_action" ("workflowId");`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_plugin_filter_supportedContexts_idx', '{"type":"index","name":"plugin_filter_supportedContexts_idx","sql":"CREATE INDEX \\"plugin_filter_supportedContexts_idx\\" ON \\"plugin_filter\\" (\\"supportedContexts\\") USING gin;"}'::jsonb);`.execute(
db,
);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_plugin_action_supportedContexts_idx', '{"type":"index","name":"plugin_action_supportedContexts_idx","sql":"CREATE INDEX \\"plugin_action_supportedContexts_idx\\" ON \\"plugin_action\\" (\\"supportedContexts\\") USING gin;"}'::jsonb);`.execute(
db,
);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TABLE "workflow";`.execute(db);
await sql`DROP TABLE "workflow_filter";`.execute(db);
await sql`DROP TABLE "workflow_action";`.execute(db);
await sql`DROP TABLE "plugin";`.execute(db);
await sql`DROP TABLE "plugin_filter";`.execute(db);
await sql`DROP TABLE "plugin_action";`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_filter_supportedContexts_idx';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_plugin_action_supportedContexts_idx';`.execute(db);
}

View file

@ -0,0 +1,95 @@
import { PluginContext } from 'src/enum';
import {
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
Index,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
import type { JSONSchema } from 'src/types/plugin-schema.types';
@Table('plugin')
export class PluginTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@Column({ index: true, unique: true })
name!: string;
@Column()
title!: string;
@Column()
description!: string;
@Column()
author!: string;
@Column()
version!: string;
@Column()
wasmPath!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
}
@Index({ columns: ['supportedContexts'], using: 'gin' })
@Table('plugin_filter')
export class PluginFilterTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
@Column({ index: true })
pluginId!: string;
@Column({ index: true, unique: true })
methodName!: string;
@Column()
title!: string;
@Column()
description!: string;
@Column({ type: 'character varying', array: true })
supportedContexts!: Generated<PluginContext[]>;
@Column({ type: 'jsonb', nullable: true })
schema!: JSONSchema | null;
}
@Index({ columns: ['supportedContexts'], using: 'gin' })
@Table('plugin_action')
export class PluginActionTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@ForeignKeyColumn(() => PluginTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
@Column({ index: true })
pluginId!: string;
@Column({ index: true, unique: true })
methodName!: string;
@Column()
title!: string;
@Column()
description!: string;
@Column({ type: 'character varying', array: true })
supportedContexts!: Generated<PluginContext[]>;
@Column({ type: 'jsonb', nullable: true })
schema!: JSONSchema | null;
}

View file

@ -0,0 +1,78 @@
import { PluginTriggerType } from 'src/enum';
import { PluginActionTable, PluginFilterTable } from 'src/schema/tables/plugin.table';
import { UserTable } from 'src/schema/tables/user.table';
import {
Column,
CreateDateColumn,
ForeignKeyColumn,
Generated,
Index,
PrimaryGeneratedColumn,
Table,
Timestamp,
} from 'src/sql-tools';
import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types';
@Table('workflow')
export class WorkflowTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
@Column()
triggerType!: PluginTriggerType;
@Column({ nullable: true })
name!: string | null;
@Column()
description!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@Column({ type: 'boolean', default: true })
enabled!: boolean;
}
@Index({ columns: ['workflowId', 'order'] })
@Index({ columns: ['filterId'] })
@Table('workflow_filter')
export class WorkflowFilterTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
workflowId!: Generated<string>;
@ForeignKeyColumn(() => PluginFilterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
filterId!: string;
@Column({ type: 'jsonb', nullable: true })
filterConfig!: FilterConfig | null;
@Column({ type: 'integer' })
order!: number;
}
@Index({ columns: ['workflowId', 'order'] })
@Index({ columns: ['actionId'] })
@Table('workflow_action')
export class WorkflowActionTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@ForeignKeyColumn(() => WorkflowTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
workflowId!: Generated<string>;
@ForeignKeyColumn(() => PluginActionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
actionId!: string;
@Column({ type: 'jsonb', nullable: true })
actionConfig!: ActionConfig | null;
@Column({ type: 'integer' })
order!: number;
}

View file

@ -426,6 +426,9 @@ export class AssetMediaService extends BaseService {
}
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size });
await this.eventRepository.emit('AssetCreate', { asset });
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: asset.id, source: 'upload' } });
return asset;

View file

@ -35,6 +35,7 @@ import { OAuthRepository } from 'src/repositories/oauth.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
import { ProcessRepository } from 'src/repositories/process.repository';
import { SearchRepository } from 'src/repositories/search.repository';
import { ServerInfoRepository } from 'src/repositories/server-info.repository';
@ -53,6 +54,7 @@ import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { ViewRepository } from 'src/repositories/view-repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository';
import { UserTable } from 'src/schema/tables/user.table';
import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access';
import { getConfig, updateConfig } from 'src/utils/config';
@ -88,6 +90,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
OcrRepository,
PartnerRepository,
PersonRepository,
PluginRepository,
ProcessRepository,
SearchRepository,
ServerInfoRepository,
@ -105,6 +108,8 @@ export const BASE_SERVICE_DEPENDENCIES = [
UserRepository,
VersionHistoryRepository,
ViewRepository,
WebsocketRepository,
WorkflowRepository,
];
@Injectable()
@ -142,6 +147,7 @@ export class BaseService {
protected ocrRepository: OcrRepository,
protected partnerRepository: PartnerRepository,
protected personRepository: PersonRepository,
protected pluginRepository: PluginRepository,
protected processRepository: ProcessRepository,
protected searchRepository: SearchRepository,
protected serverInfoRepository: ServerInfoRepository,
@ -160,6 +166,7 @@ export class BaseService {
protected versionRepository: VersionHistoryRepository,
protected viewRepository: ViewRepository,
protected websocketRepository: WebsocketRepository,
protected workflowRepository: WorkflowRepository,
) {
this.logger.setContext(this.constructor.name);
this.storageCore = StorageCore.create(

View file

@ -23,6 +23,7 @@ import { NotificationService } from 'src/services/notification.service';
import { OcrService } from 'src/services/ocr.service';
import { PartnerService } from 'src/services/partner.service';
import { PersonService } from 'src/services/person.service';
import { PluginService } from 'src/services/plugin.service';
import { QueueService } from 'src/services/queue.service';
import { SearchService } from 'src/services/search.service';
import { ServerService } from 'src/services/server.service';
@ -43,6 +44,7 @@ import { UserAdminService } from 'src/services/user-admin.service';
import { UserService } from 'src/services/user.service';
import { VersionService } from 'src/services/version.service';
import { ViewService } from 'src/services/view.service';
import { WorkflowService } from 'src/services/workflow.service';
export const services = [
ApiKeyService,
@ -70,6 +72,7 @@ export const services = [
OcrService,
PartnerService,
PersonService,
PluginService,
QueueService,
SearchService,
ServerService,
@ -90,4 +93,5 @@ export const services = [
UserService,
VersionService,
ViewService,
WorkflowService,
];

View file

@ -0,0 +1,120 @@
import { CurrentPlugin } from '@extism/extism';
import { UnauthorizedException } from '@nestjs/common';
import { Updateable } from 'kysely';
import { Permission } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetTable } from 'src/schema/tables/asset.table';
import { requireAccess } from 'src/utils/access';
/**
* Plugin host functions that are exposed to WASM plugins via Extism.
* These functions allow plugins to interact with the Immich system.
*/
export class PluginHostFunctions {
constructor(
private assetRepository: AssetRepository,
private albumRepository: AlbumRepository,
private accessRepository: AccessRepository,
private cryptoRepository: CryptoRepository,
private logger: LoggingRepository,
private pluginJwtSecret: string,
) {}
/**
* Creates Extism host function bindings for the plugin.
* These are the functions that WASM plugins can call.
*/
getHostFunctions() {
return {
'extism:host/user': {
updateAsset: (cp: CurrentPlugin, offs: bigint) => this.handleUpdateAsset(cp, offs),
addAssetToAlbum: (cp: CurrentPlugin, offs: bigint) => this.handleAddAssetToAlbum(cp, offs),
},
};
}
/**
* Host function wrapper for updateAsset.
* Reads the input from the plugin, parses it, and calls the actual update function.
*/
private async handleUpdateAsset(cp: CurrentPlugin, offs: bigint) {
const input = JSON.parse(cp.read(offs)!.text());
await this.updateAsset(input);
}
/**
* Host function wrapper for addAssetToAlbum.
* Reads the input from the plugin, parses it, and calls the actual add function.
*/
private async handleAddAssetToAlbum(cp: CurrentPlugin, offs: bigint) {
const input = JSON.parse(cp.read(offs)!.text());
await this.addAssetToAlbum(input);
}
/**
* Validates the JWT token and returns the auth context.
*/
private validateToken(authToken: string): { userId: string } {
try {
const auth = this.cryptoRepository.verifyJwt<{ userId: string }>(authToken, this.pluginJwtSecret);
if (!auth.userId) {
throw new UnauthorizedException('Invalid token: missing userId');
}
return auth;
} catch (error) {
this.logger.error('Token validation failed:', error);
throw new UnauthorizedException('Invalid token');
}
}
/**
* Updates an asset with the given properties.
*/
async updateAsset(input: { authToken: string } & Updateable<AssetTable> & { id: string }) {
const { authToken, id, ...assetData } = input;
// Validate token
const auth = this.validateToken(authToken);
// Check access to the asset
await requireAccess(this.accessRepository, {
auth: { user: { id: auth.userId } } as any,
permission: Permission.AssetUpdate,
ids: [id],
});
this.logger.log(`Updating asset ${id} -- ${JSON.stringify(assetData)}`);
await this.assetRepository.update({ id, ...assetData });
}
/**
* Adds an asset to an album.
*/
async addAssetToAlbum(input: { authToken: string; assetId: string; albumId: string }) {
const { authToken, assetId, albumId } = input;
// Validate token
const auth = this.validateToken(authToken);
// Check access to both the asset and the album
await requireAccess(this.accessRepository, {
auth: { user: { id: auth.userId } } as any,
permission: Permission.AssetRead,
ids: [assetId],
});
await requireAccess(this.accessRepository, {
auth: { user: { id: auth.userId } } as any,
permission: Permission.AlbumUpdate,
ids: [albumId],
});
this.logger.log(`Adding asset ${assetId} to album ${albumId}`);
await this.albumRepository.addAssetIds(albumId, [assetId]);
return 0;
}
}

View file

@ -0,0 +1,317 @@
import { Plugin as ExtismPlugin, newPlugin } from '@extism/extism';
import { BadRequestException, Injectable } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { validateOrReject } from 'class-validator';
import { join } from 'node:path';
import { Asset, WorkflowAction, WorkflowFilter } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators';
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
import { mapPlugin, PluginResponseDto } from 'src/dtos/plugin.dto';
import { JobName, JobStatus, PluginTriggerType, QueueName } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
import { PluginHostFunctions } from 'src/services/plugin-host.functions';
import { IWorkflowJob, JobItem, JobOf, WorkflowData } from 'src/types';
interface WorkflowContext {
authToken: string;
asset: Asset;
}
interface PluginInput<T = unknown> {
authToken: string;
config: T;
data: {
asset: Asset;
};
}
@Injectable()
export class PluginService extends BaseService {
private pluginJwtSecret!: string;
private loadedPlugins: Map<string, ExtismPlugin> = new Map();
private hostFunctions!: PluginHostFunctions;
@OnEvent({ name: 'AppBootstrap' })
async onBootstrap() {
this.pluginJwtSecret = this.cryptoRepository.randomBytesAsText(32);
await this.loadPluginsFromManifests();
this.hostFunctions = new PluginHostFunctions(
this.assetRepository,
this.albumRepository,
this.accessRepository,
this.cryptoRepository,
this.logger,
this.pluginJwtSecret,
);
await this.loadPlugins();
}
//
// CRUD operations for plugins
//
async getAll(): Promise<PluginResponseDto[]> {
const plugins = await this.pluginRepository.getAllPlugins();
return plugins.map((plugin) => mapPlugin(plugin));
}
async get(id: string): Promise<PluginResponseDto> {
const plugin = await this.pluginRepository.getPlugin(id);
if (!plugin) {
throw new BadRequestException('Plugin not found');
}
return mapPlugin(plugin);
}
///////////////////////////////////////////
// Plugin Loader
//////////////////////////////////////////
async loadPluginsFromManifests(): Promise<void> {
// Load core plugin
const { resourcePaths, plugins } = this.configRepository.getEnv();
const coreManifestPath = `${resourcePaths.corePlugin}/manifest.json`;
const coreManifest = await this.readAndValidateManifest(coreManifestPath);
await this.loadPluginToDatabase(coreManifest, resourcePaths.corePlugin);
this.logger.log(`Successfully processed core plugin: ${coreManifest.name} (version ${coreManifest.version})`);
// Load external plugins
if (plugins.enabled && plugins.installFolder) {
await this.loadExternalPlugins(plugins.installFolder);
}
}
private async loadExternalPlugins(installFolder: string): Promise<void> {
try {
const entries = await this.pluginRepository.readDirectory(installFolder);
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const pluginFolder = join(installFolder, entry.name);
const manifestPath = join(pluginFolder, 'manifest.json');
try {
const manifest = await this.readAndValidateManifest(manifestPath);
await this.loadPluginToDatabase(manifest, pluginFolder);
this.logger.log(`Successfully processed external plugin: ${manifest.name} (version ${manifest.version})`);
} catch (error) {
this.logger.warn(`Failed to load external plugin from ${manifestPath}:`, error);
}
}
} catch (error) {
this.logger.error(`Failed to scan external plugins folder ${installFolder}:`, error);
}
}
private async loadPluginToDatabase(manifest: PluginManifestDto, basePath: string): Promise<void> {
const currentPlugin = await this.pluginRepository.getPluginByName(manifest.name);
if (currentPlugin != null && currentPlugin.version === manifest.version) {
this.logger.log(`Plugin ${manifest.name} is up to date (version ${manifest.version}). Skipping`);
return;
}
const { plugin, filters, actions } = await this.pluginRepository.loadPlugin(manifest, basePath);
this.logger.log(`Upserted plugin: ${plugin.name} (ID: ${plugin.id}, version: ${plugin.version})`);
for (const filter of filters) {
this.logger.log(`Upserted plugin filter: ${filter.methodName} (ID: ${filter.id})`);
}
for (const action of actions) {
this.logger.log(`Upserted plugin action: ${action.methodName} (ID: ${action.id})`);
}
}
private async readAndValidateManifest(manifestPath: string): Promise<PluginManifestDto> {
const content = await this.storageRepository.readTextFile(manifestPath);
const manifestData = JSON.parse(content);
const manifest = plainToInstance(PluginManifestDto, manifestData);
await validateOrReject(manifest, {
whitelist: true,
forbidNonWhitelisted: true,
});
return manifest;
}
///////////////////////////////////////////
// Plugin Execution
///////////////////////////////////////////
private async loadPlugins() {
const plugins = await this.pluginRepository.getAllPlugins();
for (const plugin of plugins) {
try {
this.logger.debug(`Loading plugin: ${plugin.name} from ${plugin.wasmPath}`);
const extismPlugin = await newPlugin(plugin.wasmPath, {
useWasi: true,
functions: this.hostFunctions.getHostFunctions(),
});
this.loadedPlugins.set(plugin.id, extismPlugin);
this.logger.log(`Successfully loaded plugin: ${plugin.name}`);
} catch (error) {
this.logger.error(`Failed to load plugin ${plugin.name}:`, error);
}
}
}
@OnEvent({ name: 'AssetCreate' })
async handleAssetCreate({ asset }: ArgOf<'AssetCreate'>) {
await this.handleTrigger(PluginTriggerType.AssetCreate, {
ownerId: asset.ownerId,
event: { userId: asset.ownerId, asset },
});
}
private async handleTrigger<T extends PluginTriggerType>(
triggerType: T,
params: { ownerId: string; event: WorkflowData[T] },
): Promise<void> {
const workflows = await this.workflowRepository.getWorkflowByOwnerAndTrigger(params.ownerId, triggerType);
if (workflows.length === 0) {
return;
}
const jobs: JobItem[] = workflows.map((workflow) => ({
name: JobName.WorkflowRun,
data: {
id: workflow.id,
type: triggerType,
event: params.event,
} as IWorkflowJob<T>,
}));
await this.jobRepository.queueAll(jobs);
this.logger.debug(`Queued ${jobs.length} workflow execution jobs for trigger ${triggerType}`);
}
@OnJob({ name: JobName.WorkflowRun, queue: QueueName.Workflow })
async handleWorkflowRun({ id: workflowId, type, event }: JobOf<JobName.WorkflowRun>): Promise<JobStatus> {
try {
const workflow = await this.workflowRepository.getWorkflow(workflowId);
if (!workflow) {
this.logger.error(`Workflow ${workflowId} not found`);
return JobStatus.Failed;
}
const workflowFilters = await this.workflowRepository.getFilters(workflowId);
const workflowActions = await this.workflowRepository.getActions(workflowId);
switch (type) {
case PluginTriggerType.AssetCreate: {
const data = event as WorkflowData[PluginTriggerType.AssetCreate];
const asset = data.asset;
const authToken = this.cryptoRepository.signJwt({ userId: data.userId }, this.pluginJwtSecret);
const context = {
authToken,
asset,
};
const filtersPassed = await this.executeFilters(workflowFilters, context);
if (!filtersPassed) {
return JobStatus.Skipped;
}
await this.executeActions(workflowActions, context);
this.logger.debug(`Workflow ${workflowId} executed successfully`);
return JobStatus.Success;
}
case PluginTriggerType.PersonRecognized: {
this.logger.error('unimplemented');
return JobStatus.Skipped;
}
default: {
this.logger.error(`Unknown workflow trigger type: ${type}`);
return JobStatus.Failed;
}
}
} catch (error) {
this.logger.error(`Error executing workflow ${workflowId}:`, error);
return JobStatus.Failed;
}
}
private async executeFilters(workflowFilters: WorkflowFilter[], context: WorkflowContext): Promise<boolean> {
for (const workflowFilter of workflowFilters) {
const filter = await this.pluginRepository.getFilter(workflowFilter.filterId);
if (!filter) {
this.logger.error(`Filter ${workflowFilter.filterId} not found`);
return false;
}
const pluginInstance = this.loadedPlugins.get(filter.pluginId);
if (!pluginInstance) {
this.logger.error(`Plugin ${filter.pluginId} not loaded`);
return false;
}
const filterInput: PluginInput = {
authToken: context.authToken,
config: workflowFilter.filterConfig,
data: {
asset: context.asset,
},
};
this.logger.debug(`Calling filter ${filter.methodName} with input: ${JSON.stringify(filterInput)}`);
const filterResult = await pluginInstance.call(
filter.methodName,
new TextEncoder().encode(JSON.stringify(filterInput)),
);
if (!filterResult) {
this.logger.error(`Filter ${filter.methodName} returned null`);
return false;
}
const result = JSON.parse(filterResult.text());
if (result.passed === false) {
this.logger.debug(`Filter ${filter.methodName} returned false, stopping workflow execution`);
return false;
}
}
return true;
}
private async executeActions(workflowActions: WorkflowAction[], context: WorkflowContext): Promise<void> {
for (const workflowAction of workflowActions) {
const action = await this.pluginRepository.getAction(workflowAction.actionId);
if (!action) {
throw new Error(`Action ${workflowAction.actionId} not found`);
}
const pluginInstance = this.loadedPlugins.get(action.pluginId);
if (!pluginInstance) {
throw new Error(`Plugin ${action.pluginId} not loaded`);
}
const actionInput: PluginInput = {
authToken: context.authToken,
config: workflowAction.actionConfig,
data: {
asset: context.asset,
},
};
this.logger.debug(`Calling action ${action.methodName} with input: ${JSON.stringify(actionInput)}`);
await pluginInstance.call(action.methodName, JSON.stringify(actionInput));
}
}
}

View file

@ -22,7 +22,7 @@ describe(QueueService.name, () => {
it('should update concurrency', () => {
sut.onConfigUpdate({ newConfig: defaults, oldConfig: {} as SystemConfig });
expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(16);
expect(mocks.job.setConcurrency).toHaveBeenCalledTimes(17);
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FacialRecognition, 1);
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DuplicateDetection, 1);
expect(mocks.job.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BackgroundTask, 5);
@ -97,6 +97,7 @@ describe(QueueService.name, () => {
[QueueName.Notification]: expectedJobStatus,
[QueueName.BackupDatabase]: expectedJobStatus,
[QueueName.Ocr]: expectedJobStatus,
[QueueName.Workflow]: expectedJobStatus,
});
});
});

View file

@ -40,6 +40,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
[QueueName.VideoConversion]: { concurrency: 1 },
[QueueName.Notification]: { concurrency: 5 },
[QueueName.Ocr]: { concurrency: 1 },
[QueueName.Workflow]: { concurrency: 5 },
},
backup: {
database: {

View file

@ -0,0 +1,159 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Workflow } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto';
import {
mapWorkflowAction,
mapWorkflowFilter,
WorkflowCreateDto,
WorkflowResponseDto,
WorkflowUpdateDto,
} from 'src/dtos/workflow.dto';
import { Permission, PluginContext, PluginTriggerType } from 'src/enum';
import { pluginTriggers } from 'src/plugins';
import { BaseService } from 'src/services/base.service';
@Injectable()
export class WorkflowService extends BaseService {
async create(auth: AuthDto, dto: WorkflowCreateDto): Promise<WorkflowResponseDto> {
const trigger = this.getTriggerOrFail(dto.triggerType);
const filterInserts = await this.validateAndMapFilters(dto.filters, trigger.context);
const actionInserts = await this.validateAndMapActions(dto.actions, trigger.context);
const workflow = await this.workflowRepository.createWorkflow(
{
ownerId: auth.user.id,
triggerType: dto.triggerType,
name: dto.name,
description: dto.description || '',
enabled: dto.enabled ?? true,
},
filterInserts,
actionInserts,
);
return this.mapWorkflow(workflow);
}
async getAll(auth: AuthDto): Promise<WorkflowResponseDto[]> {
const workflows = await this.workflowRepository.getWorkflowsByOwner(auth.user.id);
return Promise.all(workflows.map((workflow) => this.mapWorkflow(workflow)));
}
async get(auth: AuthDto, id: string): Promise<WorkflowResponseDto> {
await this.requireAccess({ auth, permission: Permission.WorkflowRead, ids: [id] });
const workflow = await this.findOrFail(id);
return this.mapWorkflow(workflow);
}
async update(auth: AuthDto, id: string, dto: WorkflowUpdateDto): Promise<WorkflowResponseDto> {
await this.requireAccess({ auth, permission: Permission.WorkflowUpdate, ids: [id] });
if (Object.values(dto).filter((prop) => prop !== undefined).length === 0) {
throw new BadRequestException('No fields to update');
}
const workflow = await this.findOrFail(id);
const trigger = this.getTriggerOrFail(workflow.triggerType);
const { filters, actions, ...workflowUpdate } = dto;
const filterInserts = filters && (await this.validateAndMapFilters(filters, trigger.context));
const actionInserts = actions && (await this.validateAndMapActions(actions, trigger.context));
const updatedWorkflow = await this.workflowRepository.updateWorkflow(
id,
workflowUpdate,
filterInserts,
actionInserts,
);
return this.mapWorkflow(updatedWorkflow);
}
async delete(auth: AuthDto, id: string): Promise<void> {
await this.requireAccess({ auth, permission: Permission.WorkflowDelete, ids: [id] });
await this.workflowRepository.deleteWorkflow(id);
}
private async validateAndMapFilters(
filters: Array<{ filterId: string; filterConfig?: any }>,
requiredContext: PluginContext,
) {
for (const dto of filters) {
const filter = await this.pluginRepository.getFilter(dto.filterId);
if (!filter) {
throw new BadRequestException(`Invalid filter ID: ${dto.filterId}`);
}
if (!filter.supportedContexts.includes(requiredContext)) {
throw new BadRequestException(
`Filter "${filter.title}" does not support ${requiredContext} context. Supported contexts: ${filter.supportedContexts.join(', ')}`,
);
}
}
return filters.map((dto, index) => ({
filterId: dto.filterId,
filterConfig: dto.filterConfig || null,
order: index,
}));
}
private async validateAndMapActions(
actions: Array<{ actionId: string; actionConfig?: any }>,
requiredContext: PluginContext,
) {
for (const dto of actions) {
const action = await this.pluginRepository.getAction(dto.actionId);
if (!action) {
throw new BadRequestException(`Invalid action ID: ${dto.actionId}`);
}
if (!action.supportedContexts.includes(requiredContext)) {
throw new BadRequestException(
`Action "${action.title}" does not support ${requiredContext} context. Supported contexts: ${action.supportedContexts.join(', ')}`,
);
}
}
return actions.map((dto, index) => ({
actionId: dto.actionId,
actionConfig: dto.actionConfig || null,
order: index,
}));
}
private getTriggerOrFail(triggerType: PluginTriggerType) {
const trigger = pluginTriggers.find((t) => t.type === triggerType);
if (!trigger) {
throw new BadRequestException(`Invalid trigger type: ${triggerType}`);
}
return trigger;
}
private async findOrFail(id: string) {
const workflow = await this.workflowRepository.getWorkflow(id);
if (!workflow) {
throw new BadRequestException('Workflow not found');
}
return workflow;
}
private async mapWorkflow(workflow: Workflow): Promise<WorkflowResponseDto> {
const filters = await this.workflowRepository.getFilters(workflow.id);
const actions = await this.workflowRepository.getActions(workflow.id);
return {
id: workflow.id,
ownerId: workflow.ownerId,
triggerType: workflow.triggerType,
name: workflow.name,
description: workflow.description,
createdAt: workflow.createdAt.toISOString(),
enabled: workflow.enabled,
filters: filters.map((f) => mapWorkflowFilter(f)),
actions: actions.map((a) => mapWorkflowAction(a)),
};
}
}

View file

@ -1,5 +1,6 @@
import { SystemConfig } from 'src/config';
import { VECTOR_EXTENSIONS } from 'src/constants';
import { Asset } from 'src/database';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@ -11,6 +12,7 @@ import {
ImageFormat,
JobName,
MemoryType,
PluginTriggerType,
QueueName,
StorageFolder,
SyncEntityType,
@ -263,6 +265,23 @@ export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob {
recipientId: string;
}
export interface WorkflowData {
[PluginTriggerType.AssetCreate]: {
userId: string;
asset: Asset;
};
[PluginTriggerType.PersonRecognized]: {
personId: string;
assetId: string;
};
}
export interface IWorkflowJob<T extends PluginTriggerType = PluginTriggerType> {
id: string;
type: T;
event: WorkflowData[T];
}
export interface JobCounts {
active: number;
completed: number;
@ -374,7 +393,10 @@ export type JobItem =
// OCR
| { name: JobName.OcrQueueAll; data: IBaseJob }
| { name: JobName.Ocr; data: IEntityJob };
| { name: JobName.Ocr; data: IEntityJob }
// Workflow
| { name: JobName.WorkflowRun; data: IWorkflowJob };
export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number];

View file

@ -0,0 +1,35 @@
/**
* JSON Schema types for plugin configuration schemas
* Based on JSON Schema Draft 7
*/
export type JSONSchemaType = 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array' | 'null';
export interface JSONSchemaProperty {
type?: JSONSchemaType | JSONSchemaType[];
description?: string;
default?: any;
enum?: any[];
items?: JSONSchemaProperty;
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
additionalProperties?: boolean | JSONSchemaProperty;
}
export interface JSONSchema {
type: 'object';
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
additionalProperties?: boolean;
description?: string;
}
export type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue };
export interface FilterConfig {
[key: string]: ConfigValue;
}
export interface ActionConfig {
[key: string]: ConfigValue;
}

View file

@ -298,6 +298,12 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
return access.stack.checkOwnerAccess(auth.user.id, ids);
}
case Permission.WorkflowRead:
case Permission.WorkflowUpdate:
case Permission.WorkflowDelete: {
return access.workflow.checkOwnerAccess(auth.user.id, ids);
}
default: {
return new Set<string>();
}

View file

@ -36,6 +36,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
import { SearchRepository } from 'src/repositories/search.repository';
import { SessionRepository } from 'src/repositories/session.repository';
import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository';
@ -49,6 +50,7 @@ import { TagRepository } from 'src/repositories/tag.repository';
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository';
import { DB } from 'src/schema';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
@ -380,6 +382,7 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
case OcrRepository:
case PartnerRepository:
case PersonRepository:
case PluginRepository:
case SearchRepository:
case SessionRepository:
case SharedLinkRepository:
@ -389,7 +392,8 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
case SyncCheckpointRepository:
case SystemMetadataRepository:
case UserRepository:
case VersionHistoryRepository: {
case VersionHistoryRepository:
case WorkflowRepository: {
return new key(db);
}
@ -441,13 +445,15 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
case OcrRepository:
case PartnerRepository:
case PersonRepository:
case PluginRepository:
case SessionRepository:
case SyncRepository:
case SyncCheckpointRepository:
case SystemMetadataRepository:
case UserRepository:
case VersionHistoryRepository:
case TagRepository: {
case TagRepository:
case WorkflowRepository: {
return automock(key);
}

View file

@ -0,0 +1,308 @@
import { Kysely } from 'kysely';
import { PluginContext } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
import { DB } from 'src/schema';
import { PluginService } from 'src/services/plugin.service';
import { newMediumService } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
let pluginRepo: PluginRepository;
const setup = (db?: Kysely<DB>) => {
return newMediumService(PluginService, {
database: db || defaultDatabase,
real: [PluginRepository, AccessRepository],
mock: [LoggingRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
pluginRepo = new PluginRepository(defaultDatabase);
});
afterEach(async () => {
await defaultDatabase.deleteFrom('plugin').execute();
});
describe(PluginService.name, () => {
describe('getAll', () => {
it('should return empty array when no plugins exist', async () => {
const { sut } = setup();
const plugins = await sut.getAll();
expect(plugins).toEqual([]);
});
it('should return plugin without filters and actions', async () => {
const { sut } = setup();
const result = await pluginRepo.loadPlugin(
{
name: 'test-plugin',
title: 'Test Plugin',
description: 'A test plugin',
author: 'Test Author',
version: '1.0.0',
wasm: { path: '/path/to/test.wasm' },
},
'/test/base/path',
);
const plugins = await sut.getAll();
expect(plugins).toHaveLength(1);
expect(plugins[0]).toMatchObject({
id: result.plugin.id,
name: 'test-plugin',
description: 'A test plugin',
author: 'Test Author',
version: '1.0.0',
filters: [],
actions: [],
});
});
it('should return plugin with filters and actions', async () => {
const { sut } = setup();
const result = await pluginRepo.loadPlugin(
{
name: 'full-plugin',
title: 'Full Plugin',
description: 'A plugin with filters and actions',
author: 'Test Author',
version: '1.0.0',
wasm: { path: '/path/to/full.wasm' },
filters: [
{
methodName: 'test-filter',
title: 'Test Filter',
description: 'A test filter',
supportedContexts: [PluginContext.Asset],
schema: { type: 'object', properties: {} },
},
],
actions: [
{
methodName: 'test-action',
title: 'Test Action',
description: 'A test action',
supportedContexts: [PluginContext.Asset],
schema: { type: 'object', properties: {} },
},
],
},
'/test/base/path',
);
const plugins = await sut.getAll();
expect(plugins).toHaveLength(1);
expect(plugins[0]).toMatchObject({
id: result.plugin.id,
name: 'full-plugin',
filters: [
{
id: result.filters[0].id,
pluginId: result.plugin.id,
methodName: 'test-filter',
title: 'Test Filter',
description: 'A test filter',
supportedContexts: [PluginContext.Asset],
schema: { type: 'object', properties: {} },
},
],
actions: [
{
id: result.actions[0].id,
pluginId: result.plugin.id,
methodName: 'test-action',
title: 'Test Action',
description: 'A test action',
supportedContexts: [PluginContext.Asset],
schema: { type: 'object', properties: {} },
},
],
});
});
it('should return multiple plugins with their respective filters and actions', async () => {
const { sut } = setup();
await pluginRepo.loadPlugin(
{
name: 'plugin-1',
title: 'Plugin 1',
description: 'First plugin',
author: 'Author 1',
version: '1.0.0',
wasm: { path: '/path/to/plugin1.wasm' },
filters: [
{
methodName: 'filter-1',
title: 'Filter 1',
description: 'Filter for plugin 1',
supportedContexts: [PluginContext.Asset],
schema: undefined,
},
],
},
'/test/base/path',
);
await pluginRepo.loadPlugin(
{
name: 'plugin-2',
title: 'Plugin 2',
description: 'Second plugin',
author: 'Author 2',
version: '2.0.0',
wasm: { path: '/path/to/plugin2.wasm' },
actions: [
{
methodName: 'action-2',
title: 'Action 2',
description: 'Action for plugin 2',
supportedContexts: [PluginContext.Album],
schema: undefined,
},
],
},
'/test/base/path',
);
const plugins = await sut.getAll();
expect(plugins).toHaveLength(2);
expect(plugins[0].name).toBe('plugin-1');
expect(plugins[0].filters).toHaveLength(1);
expect(plugins[0].actions).toHaveLength(0);
expect(plugins[1].name).toBe('plugin-2');
expect(plugins[1].filters).toHaveLength(0);
expect(plugins[1].actions).toHaveLength(1);
});
it('should handle plugin with multiple filters and actions', async () => {
const { sut } = setup();
await pluginRepo.loadPlugin(
{
name: 'multi-plugin',
title: 'Multi Plugin',
description: 'Plugin with multiple items',
author: 'Test Author',
version: '1.0.0',
wasm: { path: '/path/to/multi.wasm' },
filters: [
{
methodName: 'filter-a',
title: 'Filter A',
description: 'First filter',
supportedContexts: [PluginContext.Asset],
schema: undefined,
},
{
methodName: 'filter-b',
title: 'Filter B',
description: 'Second filter',
supportedContexts: [PluginContext.Album],
schema: undefined,
},
],
actions: [
{
methodName: 'action-x',
title: 'Action X',
description: 'First action',
supportedContexts: [PluginContext.Asset],
schema: undefined,
},
{
methodName: 'action-y',
title: 'Action Y',
description: 'Second action',
supportedContexts: [PluginContext.Person],
schema: undefined,
},
],
},
'/test/base/path',
);
const plugins = await sut.getAll();
expect(plugins).toHaveLength(1);
expect(plugins[0].filters).toHaveLength(2);
expect(plugins[0].actions).toHaveLength(2);
});
});
describe('get', () => {
it('should throw error when plugin does not exist', async () => {
const { sut } = setup();
await expect(sut.get('00000000-0000-0000-0000-000000000000')).rejects.toThrow('Plugin not found');
});
it('should return single plugin with filters and actions', async () => {
const { sut } = setup();
const result = await pluginRepo.loadPlugin(
{
name: 'single-plugin',
title: 'Single Plugin',
description: 'A single plugin',
author: 'Test Author',
version: '1.0.0',
wasm: { path: '/path/to/single.wasm' },
filters: [
{
methodName: 'single-filter',
title: 'Single Filter',
description: 'A single filter',
supportedContexts: [PluginContext.Asset],
schema: undefined,
},
],
actions: [
{
methodName: 'single-action',
title: 'Single Action',
description: 'A single action',
supportedContexts: [PluginContext.Asset],
schema: undefined,
},
],
},
'/test/base/path',
);
const pluginResult = await sut.get(result.plugin.id);
expect(pluginResult).toMatchObject({
id: result.plugin.id,
name: 'single-plugin',
filters: [
{
id: result.filters[0].id,
methodName: 'single-filter',
title: 'Single Filter',
},
],
actions: [
{
id: result.actions[0].id,
methodName: 'single-action',
title: 'Single Action',
},
],
});
});
});
});

View file

@ -0,0 +1,697 @@
import { Kysely } from 'kysely';
import { PluginContext, PluginTriggerType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository';
import { DB } from 'src/schema';
import { WorkflowService } from 'src/services/workflow.service';
import { newMediumService } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(WorkflowService, {
database: db || defaultDatabase,
real: [WorkflowRepository, PluginRepository, AccessRepository],
mock: [LoggingRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(WorkflowService.name, () => {
let testPluginId: string;
let testFilterId: string;
let testActionId: string;
beforeAll(async () => {
// Create a test plugin with filters and actions once for all tests
const pluginRepo = new PluginRepository(defaultDatabase);
const result = await pluginRepo.loadPlugin(
{
name: 'test-core-plugin',
title: 'Test Core Plugin',
description: 'A test core plugin for workflow tests',
author: 'Test Author',
version: '1.0.0',
wasm: {
path: '/test/path.wasm',
},
filters: [
{
methodName: 'test-filter',
title: 'Test Filter',
description: 'A test filter',
supportedContexts: [PluginContext.Asset],
schema: undefined,
},
],
actions: [
{
methodName: 'test-action',
title: 'Test Action',
description: 'A test action',
supportedContexts: [PluginContext.Asset],
schema: undefined,
},
],
},
'/plugins/test-core-plugin',
);
testPluginId = result.plugin.id;
testFilterId = result.filters[0].id;
testActionId = result.actions[0].id;
});
afterAll(async () => {
await defaultDatabase.deleteFrom('plugin').where('id', '=', testPluginId).execute();
});
describe('create', () => {
it('should create a workflow without filters or actions', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const workflow = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'A test workflow',
enabled: true,
filters: [],
actions: [],
});
expect(workflow).toMatchObject({
id: expect.any(String),
ownerId: user.id,
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'A test workflow',
enabled: true,
filters: [],
actions: [],
});
});
it('should create a workflow with filters and actions', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const workflow = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow-with-relations',
description: 'A test workflow with filters and actions',
enabled: true,
filters: [
{
filterId: testFilterId,
filterConfig: { key: 'value' },
},
],
actions: [
{
actionId: testActionId,
actionConfig: { action: 'test' },
},
],
});
expect(workflow).toMatchObject({
id: expect.any(String),
ownerId: user.id,
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow-with-relations',
enabled: true,
});
expect(workflow.filters).toHaveLength(1);
expect(workflow.filters[0]).toMatchObject({
id: expect.any(String),
workflowId: workflow.id,
filterId: testFilterId,
filterConfig: { key: 'value' },
order: 0,
});
expect(workflow.actions).toHaveLength(1);
expect(workflow.actions[0]).toMatchObject({
id: expect.any(String),
workflowId: workflow.id,
actionId: testActionId,
actionConfig: { action: 'test' },
order: 0,
});
});
it('should throw error when creating workflow with invalid filter', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
await expect(
sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'invalid-workflow',
description: 'A workflow with invalid filter',
enabled: true,
filters: [
{
filterId: '66da82df-e424-4bf4-b6f3-5d8e71620dae',
filterConfig: { key: 'value' },
},
],
actions: [],
}),
).rejects.toThrow('Invalid filter ID');
});
it('should throw error when creating workflow with invalid action', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
await expect(
sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'invalid-workflow',
description: 'A workflow with invalid action',
enabled: true,
filters: [],
actions: [
{
actionId: '66da82df-e424-4bf4-b6f3-5d8e71620dae',
actionConfig: { action: 'test' },
},
],
}),
).rejects.toThrow('Invalid action ID');
});
it('should throw error when filter does not support trigger context', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
// Create a plugin with a filter that only supports Album context
const pluginRepo = new PluginRepository(defaultDatabase);
const result = await pluginRepo.loadPlugin(
{
name: 'album-only-plugin',
title: 'Album Only Plugin',
description: 'Plugin with album-only filter',
author: 'Test Author',
version: '1.0.0',
wasm: { path: '/test/album-plugin.wasm' },
filters: [
{
methodName: 'album-filter',
title: 'Album Filter',
description: 'A filter that only works with albums',
supportedContexts: [PluginContext.Album],
schema: undefined,
},
],
},
'/plugins/test-core-plugin',
);
await expect(
sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'invalid-context-workflow',
description: 'A workflow with context mismatch',
enabled: true,
filters: [{ filterId: result.filters[0].id }],
actions: [],
}),
).rejects.toThrow('does not support asset context');
});
it('should throw error when action does not support trigger context', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
// Create a plugin with an action that only supports Person context
const pluginRepo = new PluginRepository(defaultDatabase);
const result = await pluginRepo.loadPlugin(
{
name: 'person-only-plugin',
title: 'Person Only Plugin',
description: 'Plugin with person-only action',
author: 'Test Author',
version: '1.0.0',
wasm: { path: '/test/person-plugin.wasm' },
actions: [
{
methodName: 'person-action',
title: 'Person Action',
description: 'An action that only works with persons',
supportedContexts: [PluginContext.Person],
schema: undefined,
},
],
},
'/plugins/test-core-plugin',
);
await expect(
sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'invalid-context-workflow',
description: 'A workflow with context mismatch',
enabled: true,
filters: [],
actions: [{ actionId: result.actions[0].id }],
}),
).rejects.toThrow('does not support asset context');
});
it('should create workflow with multiple filters and actions in correct order', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const workflow = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'multi-step-workflow',
description: 'A workflow with multiple filters and actions',
enabled: true,
filters: [
{ filterId: testFilterId, filterConfig: { step: 1 } },
{ filterId: testFilterId, filterConfig: { step: 2 } },
],
actions: [
{ actionId: testActionId, actionConfig: { step: 1 } },
{ actionId: testActionId, actionConfig: { step: 2 } },
{ actionId: testActionId, actionConfig: { step: 3 } },
],
});
expect(workflow.filters).toHaveLength(2);
expect(workflow.filters[0].order).toBe(0);
expect(workflow.filters[0].filterConfig).toEqual({ step: 1 });
expect(workflow.filters[1].order).toBe(1);
expect(workflow.filters[1].filterConfig).toEqual({ step: 2 });
expect(workflow.actions).toHaveLength(3);
expect(workflow.actions[0].order).toBe(0);
expect(workflow.actions[1].order).toBe(1);
expect(workflow.actions[2].order).toBe(2);
});
});
describe('getAll', () => {
it('should return all workflows for a user', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const workflow1 = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'workflow-1',
description: 'First workflow',
enabled: true,
filters: [],
actions: [],
});
const workflow2 = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'workflow-2',
description: 'Second workflow',
enabled: false,
filters: [],
actions: [],
});
const workflows = await sut.getAll(auth);
expect(workflows).toHaveLength(2);
expect(workflows).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: workflow1.id, name: 'workflow-1' }),
expect.objectContaining({ id: workflow2.id, name: 'workflow-2' }),
]),
);
});
it('should return empty array when user has no workflows', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const workflows = await sut.getAll(auth);
expect(workflows).toEqual([]);
});
it('should not return workflows from other users', async () => {
const { sut, ctx } = setup();
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const auth1 = { user: { id: user1.id } } as any;
const auth2 = { user: { id: user2.id } } as any;
await sut.create(auth1, {
triggerType: PluginTriggerType.AssetCreate,
name: 'user1-workflow',
description: 'User 1 workflow',
enabled: true,
filters: [],
actions: [],
});
const user2Workflows = await sut.getAll(auth2);
expect(user2Workflows).toEqual([]);
});
});
describe('get', () => {
it('should return a specific workflow by id', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'A test workflow',
enabled: true,
filters: [{ filterId: testFilterId, filterConfig: { key: 'value' } }],
actions: [{ actionId: testActionId, actionConfig: { action: 'test' } }],
});
const workflow = await sut.get(auth, created.id);
expect(workflow).toMatchObject({
id: created.id,
name: 'test-workflow',
description: 'A test workflow',
enabled: true,
});
expect(workflow.filters).toHaveLength(1);
expect(workflow.actions).toHaveLength(1);
});
it('should throw error when workflow does not exist', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
await expect(sut.get(auth, '66da82df-e424-4bf4-b6f3-5d8e71620dae')).rejects.toThrow();
});
it('should throw error when user does not have access to workflow', async () => {
const { sut, ctx } = setup();
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const auth1 = { user: { id: user1.id } } as any;
const auth2 = { user: { id: user2.id } } as any;
const workflow = await sut.create(auth1, {
triggerType: PluginTriggerType.AssetCreate,
name: 'private-workflow',
description: 'Private workflow',
enabled: true,
filters: [],
actions: [],
});
await expect(sut.get(auth2, workflow.id)).rejects.toThrow();
});
});
describe('update', () => {
it('should update workflow basic fields', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'original-workflow',
description: 'Original description',
enabled: true,
filters: [],
actions: [],
});
const updated = await sut.update(auth, created.id, {
name: 'updated-workflow',
description: 'Updated description',
enabled: false,
});
expect(updated).toMatchObject({
id: created.id,
name: 'updated-workflow',
description: 'Updated description',
enabled: false,
});
});
it('should update workflow filters', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [{ filterId: testFilterId, filterConfig: { old: 'config' } }],
actions: [],
});
const updated = await sut.update(auth, created.id, {
filters: [
{ filterId: testFilterId, filterConfig: { new: 'config' } },
{ filterId: testFilterId, filterConfig: { second: 'filter' } },
],
});
expect(updated.filters).toHaveLength(2);
expect(updated.filters[0].filterConfig).toEqual({ new: 'config' });
expect(updated.filters[1].filterConfig).toEqual({ second: 'filter' });
});
it('should update workflow actions', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [],
actions: [{ actionId: testActionId, actionConfig: { old: 'config' } }],
});
const updated = await sut.update(auth, created.id, {
actions: [
{ actionId: testActionId, actionConfig: { new: 'config' } },
{ actionId: testActionId, actionConfig: { second: 'action' } },
],
});
expect(updated.actions).toHaveLength(2);
expect(updated.actions[0].actionConfig).toEqual({ new: 'config' });
expect(updated.actions[1].actionConfig).toEqual({ second: 'action' });
});
it('should clear filters when updated with empty array', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [{ filterId: testFilterId, filterConfig: { key: 'value' } }],
actions: [],
});
const updated = await sut.update(auth, created.id, {
filters: [],
});
expect(updated.filters).toHaveLength(0);
});
it('should throw error when no fields to update', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [],
actions: [],
});
await expect(sut.update(auth, created.id, {})).rejects.toThrow('No fields to update');
});
it('should throw error when updating non-existent workflow', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
await expect(
sut.update(auth, 'non-existent-id', {
name: 'updated-name',
}),
).rejects.toThrow();
});
it('should throw error when user does not have access to update workflow', async () => {
const { sut, ctx } = setup();
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const auth1 = { user: { id: user1.id } } as any;
const auth2 = { user: { id: user2.id } } as any;
const workflow = await sut.create(auth1, {
triggerType: PluginTriggerType.AssetCreate,
name: 'private-workflow',
description: 'Private',
enabled: true,
filters: [],
actions: [],
});
await expect(
sut.update(auth2, workflow.id, {
name: 'hacked-workflow',
}),
).rejects.toThrow();
});
it('should throw error when updating with invalid filter', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [],
actions: [],
});
await expect(
sut.update(auth, created.id, {
filters: [{ filterId: 'invalid-filter-id', filterConfig: {} }],
}),
).rejects.toThrow();
});
it('should throw error when updating with invalid action', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const created = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [],
actions: [],
});
await expect(
sut.update(auth, created.id, {
actions: [{ actionId: 'invalid-action-id', actionConfig: {} }],
}),
).rejects.toThrow();
});
});
describe('delete', () => {
it('should delete a workflow', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const workflow = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [],
actions: [],
});
await sut.delete(auth, workflow.id);
await expect(sut.get(auth, workflow.id)).rejects.toThrow('Not found or no workflow.read access');
});
it('should delete workflow with filters and actions', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
const workflow = await sut.create(auth, {
triggerType: PluginTriggerType.AssetCreate,
name: 'test-workflow',
description: 'Test',
enabled: true,
filters: [{ filterId: testFilterId, filterConfig: {} }],
actions: [{ actionId: testActionId, actionConfig: {} }],
});
await sut.delete(auth, workflow.id);
await expect(sut.get(auth, workflow.id)).rejects.toThrow('Not found or no workflow.read access');
});
it('should throw error when deleting non-existent workflow', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = { user: { id: user.id } } as any;
await expect(sut.delete(auth, 'non-existent-id')).rejects.toThrow();
});
it('should throw error when user does not have access to delete workflow', async () => {
const { sut, ctx } = setup();
const { user: user1 } = await ctx.newUser();
const { user: user2 } = await ctx.newUser();
const auth1 = { user: { id: user1.id } } as any;
const auth2 = { user: { id: user2.id } } as any;
const workflow = await sut.create(auth1, {
triggerType: PluginTriggerType.AssetCreate,
name: 'private-workflow',
description: 'Private',
enabled: true,
filters: [],
actions: [],
});
await expect(sut.delete(auth2, workflow.id)).rejects.toThrow();
});
});
});

View file

@ -65,5 +65,9 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
tag: {
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
},
workflow: {
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
},
};
};

View file

@ -72,6 +72,7 @@ const envData: EnvData = {
root: '/build/www',
indexHtml: '/build/www/index.html',
},
corePlugin: '/build/corePlugin',
},
storage: {
@ -86,6 +87,11 @@ const envData: EnvData = {
workers: [ImmichWorker.Api, ImmichWorker.Microservices],
plugins: {
enabled: true,
installFolder: '/app/data/plugins',
},
noColor: false,
};

View file

@ -13,5 +13,7 @@ export const newCryptoRepositoryMock = (): Mocked<RepositoryInterface<CryptoRepo
hashSha1: vitest.fn().mockImplementation((input) => Buffer.from(`${input.toString()} (hashed)`)),
hashFile: vitest.fn().mockImplementation((input) => `${input} (file-hashed)`),
randomBytesAsText: vitest.fn().mockReturnValue(Buffer.from('random-bytes').toString('base64')),
signJwt: vitest.fn().mockReturnValue('mock-jwt-token'),
verifyJwt: vitest.fn().mockImplementation((token) => ({ verified: true, token })),
};
};

View file

@ -49,6 +49,7 @@ export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRe
createZipStream: vitest.fn(),
createReadStream: vitest.fn(),
readFile: vitest.fn(),
readTextFile: vitest.fn(),
createFile: vitest.fn(),
createWriteStream: vitest.fn(),
createOrOverwriteFile: vitest.fn(),

View file

@ -44,6 +44,7 @@ import { OAuthRepository } from 'src/repositories/oauth.repository';
import { OcrRepository } from 'src/repositories/ocr.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { PluginRepository } from 'src/repositories/plugin.repository';
import { ProcessRepository } from 'src/repositories/process.repository';
import { SearchRepository } from 'src/repositories/search.repository';
import { ServerInfoRepository } from 'src/repositories/server-info.repository';
@ -62,6 +63,7 @@ import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { ViewRepository } from 'src/repositories/view-repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { WorkflowRepository } from 'src/repositories/workflow.repository';
import { DB } from 'src/schema';
import { AuthService } from 'src/services/auth.service';
import { BaseService } from 'src/services/base.service';
@ -235,6 +237,7 @@ export type ServiceOverrides = {
oauth: OAuthRepository;
partner: PartnerRepository;
person: PersonRepository;
plugin: PluginRepository;
process: ProcessRepository;
search: SearchRepository;
serverInfo: ServerInfoRepository;
@ -253,6 +256,7 @@ export type ServiceOverrides = {
versionHistory: VersionHistoryRepository;
view: ViewRepository;
websocket: WebsocketRepository;
workflow: WorkflowRepository;
};
type As<T> = T extends RepositoryInterface<infer U> ? U : never;
@ -308,6 +312,7 @@ export const newTestService = <T extends BaseService>(
oauth: automock(OAuthRepository, { args: [loggerMock] }),
partner: automock(PartnerRepository, { strict: false }),
person: automock(PersonRepository, { strict: false }),
plugin: automock(PluginRepository, { strict: true }),
process: automock(ProcessRepository),
search: automock(SearchRepository, { strict: false }),
// eslint-disable-next-line no-sparse-arrays
@ -330,6 +335,7 @@ export const newTestService = <T extends BaseService>(
view: automock(ViewRepository),
// eslint-disable-next-line no-sparse-arrays
websocket: automock(WebsocketRepository, { args: [, loggerMock], strict: false }),
workflow: automock(WorkflowRepository, { strict: true }),
};
const sut = new Service(
@ -363,6 +369,7 @@ export const newTestService = <T extends BaseService>(
overrides.ocr || (mocks.ocr as As<OcrRepository>),
overrides.partner || (mocks.partner as As<PartnerRepository>),
overrides.person || (mocks.person as As<PersonRepository>),
overrides.plugin || (mocks.plugin as As<PluginRepository>),
overrides.process || (mocks.process as As<ProcessRepository>),
overrides.search || (mocks.search as As<SearchRepository>),
overrides.serverInfo || (mocks.serverInfo as As<ServerInfoRepository>),
@ -381,6 +388,7 @@ export const newTestService = <T extends BaseService>(
overrides.versionHistory || (mocks.versionHistory as As<VersionHistoryRepository>),
overrides.view || (mocks.view as As<ViewRepository>),
overrides.websocket || (mocks.websocket as As<WebsocketRepository>),
overrides.workflow || (mocks.workflow as As<WorkflowRepository>),
);
return {

View file

@ -162,6 +162,7 @@ export const getQueueName = derived(t, ($t) => {
[QueueName.Notifications]: $t('notifications'),
[QueueName.BackupDatabase]: $t('admin.backup_database'),
[QueueName.Ocr]: $t('admin.machine_learning_ocr'),
[QueueName.Workflow]: $t('workflow'),
};
return names[name];