mirror of
https://github.com/immich-app/immich
synced 2026-08-15 13:03:57 +00:00
feat: webhook workflow action (#29258)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
parent
a876d4a9f1
commit
953ef5c047
14 changed files with 159 additions and 99 deletions
|
|
@ -233,12 +233,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "assetTimeline",
|
||||
"title": "Move to timeline",
|
||||
"description": "Change visibility to timeline",
|
||||
"types": ["AssetV1"]
|
||||
},
|
||||
{
|
||||
"name": "assetVisibility",
|
||||
"title": "Update visibility",
|
||||
|
|
@ -301,100 +295,38 @@
|
|||
}
|
||||
},
|
||||
{
|
||||
"name": "noop1",
|
||||
"title": "DEV: Nested properties",
|
||||
"description": "Example configuration with nested properties",
|
||||
"name": "webhook",
|
||||
"title": "Trigger Webhook",
|
||||
"description": "POST/PUT event data to any URL",
|
||||
"types": ["AssetV1"],
|
||||
"hostFunctions": true,
|
||||
"allowedHosts": ["*"],
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"number1": {
|
||||
"type": "number",
|
||||
"title": "Number 1",
|
||||
"description": "Basic number"
|
||||
},
|
||||
"number2": {
|
||||
"type": "number",
|
||||
"title": "Number 2",
|
||||
"array": true,
|
||||
"description": "List of numbers"
|
||||
},
|
||||
"string1": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"title": "String 1",
|
||||
"description": "Basic string"
|
||||
"title": "URL",
|
||||
"description": "Event data will be PUT/POSTed to this URL as a JSON object"
|
||||
},
|
||||
"string2": {
|
||||
"headerName": {
|
||||
"type": "string",
|
||||
"title": "String 2",
|
||||
"array": true,
|
||||
"description": "List of strings"
|
||||
"title": "Header name",
|
||||
"description": "The name of an additional header to include with the request (e.g. authentication)"
|
||||
},
|
||||
"string3": {
|
||||
"headerValue": {
|
||||
"type": "string",
|
||||
"title": "String 3",
|
||||
"enum": ["choice-1", "choice-2"],
|
||||
"description": "Select from a list"
|
||||
"title": "Header value",
|
||||
"description": "The value of the additional header"
|
||||
},
|
||||
"nested": {
|
||||
"type": "object",
|
||||
"title": "Nested",
|
||||
"description": "Nested properties for nesting",
|
||||
"properties": {
|
||||
"nested1": {
|
||||
"type": "string",
|
||||
"title": "Nested 1",
|
||||
"description": "Nested string"
|
||||
},
|
||||
"nested2": {
|
||||
"type": "number",
|
||||
"title": "Nested 2",
|
||||
"description": "Nested number"
|
||||
},
|
||||
"nested3": {
|
||||
"type": "object",
|
||||
"title": "Nested 3",
|
||||
"description": "Nested again",
|
||||
"properties": {
|
||||
"nested4": {
|
||||
"type": "boolean",
|
||||
"title": "Nested 4",
|
||||
"description": "Nested, nested boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"method": {
|
||||
"type": "string",
|
||||
"title": "Method",
|
||||
"description": "The HTTP method to use in the request",
|
||||
"enum": ["POST", "PUT"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "noop2",
|
||||
"title": "DEV: Album pickers",
|
||||
"description": "Example configuration with album pickers",
|
||||
"types": ["AssetV1"],
|
||||
"schema": {
|
||||
"properties": {
|
||||
"albumId": {
|
||||
"type": "string",
|
||||
"title": "Album ID",
|
||||
"description": "Target album ID",
|
||||
"uiHint": {
|
||||
"type": "AlbumId",
|
||||
"order": 1
|
||||
}
|
||||
},
|
||||
"albumIds": {
|
||||
"type": "string",
|
||||
"title": "Album IDs",
|
||||
"description": "Target album IDs",
|
||||
"array": true,
|
||||
"uiHint": {
|
||||
"type": "AlbumId",
|
||||
"order": 2
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["url"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -155,3 +155,21 @@ export const assetAddToAlbums = wrapper<'assetAddToAlbums'>(({ config, data, fun
|
|||
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] });
|
||||
return {};
|
||||
});
|
||||
|
||||
export const webhook = wrapper<'webhook'>(({ config, data, functions }) => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (config.headerName && config.headerValue) {
|
||||
headers[config.headerName] = config.headerValue;
|
||||
}
|
||||
|
||||
functions.httpRequest(config.url, {
|
||||
method: config.method ?? 'POST',
|
||||
body: JSON.stringify(data.asset),
|
||||
headers,
|
||||
});
|
||||
|
||||
return {};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"esModuleInterop": true, // Enables compatibility with Babel-style module imports
|
||||
"lib": ["es2020"], // Specify a list of library files to be included in the compilation
|
||||
"lib": ["es2020", "DOM"], // Specify a list of library files to be included in the compilation
|
||||
"module": "nodenext", // Specify module code generation
|
||||
"moduleResolution": "nodenext",
|
||||
"noEmit": true, // Do not emit outputs (no .js or .d.ts files)
|
||||
|
|
|
|||
|
|
@ -30,12 +30,23 @@ type HostFunctionResult<T> =
|
|||
|
||||
type QueryParams<T extends (...args: any) => any> = Parameters<T>[0];
|
||||
type AlbumSearchDto = QueryParams<typeof getAllAlbums>;
|
||||
type HttpRequestOptions = {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
};
|
||||
type HttpResponse = {
|
||||
ok: string;
|
||||
status: number;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export const availableFunctions = [
|
||||
'searchAlbums',
|
||||
'createAlbum',
|
||||
'addAssetsToAlbum',
|
||||
'addAssetsToAlbums',
|
||||
'httpRequest',
|
||||
] as const;
|
||||
|
||||
export const hostFunctions = (authToken: string) => {
|
||||
|
|
@ -79,5 +90,11 @@ export const hostFunctions = (authToken: string) => {
|
|||
),
|
||||
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
|
||||
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
|
||||
httpRequest: (url: string, options?: HttpRequestOptions) =>
|
||||
call<[string, HttpRequestOptions | undefined], HttpResponse>(
|
||||
'httpRequest',
|
||||
authToken,
|
||||
[url, options],
|
||||
),
|
||||
} satisfies Record<(typeof availableFunctions)[number], unknown>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -368,6 +368,7 @@ export const columns = {
|
|||
'plugin_method.types',
|
||||
'plugin_method.schema',
|
||||
'plugin_method.hostFunctions',
|
||||
'plugin_method.allowedHosts',
|
||||
'plugin_method.uiHints',
|
||||
],
|
||||
syncAsset: [
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ const PluginManifestMethodSchema = z
|
|||
description: z.string().min(1).describe('Method description'),
|
||||
types: z.array(WorkflowTypeSchema).min(1).describe('Workflow type'),
|
||||
hostFunctions: z.boolean().optional().default(false).describe('Method uses host functions'),
|
||||
allowedHosts: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.default([])
|
||||
.describe('Hostnames the method can access (use * for wildcards)'),
|
||||
schema: PluginManifestMethodSchemaSchema.describe('Schema'),
|
||||
uiHints: z.array(z.string()).optional().describe('Ui hints, for example "filter"'),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ select
|
|||
"plugin_method"."types",
|
||||
"plugin_method"."schema",
|
||||
"plugin_method"."hostFunctions",
|
||||
"plugin_method"."allowedHosts",
|
||||
"plugin_method"."uiHints",
|
||||
"plugin"."name" as "pluginName"
|
||||
from
|
||||
|
|
@ -84,6 +85,7 @@ select
|
|||
"plugin_method"."types",
|
||||
"plugin_method"."schema",
|
||||
"plugin_method"."hostFunctions",
|
||||
"plugin_method"."allowedHosts",
|
||||
"plugin_method"."uiHints",
|
||||
"plugin"."name" as "pluginName"
|
||||
from
|
||||
|
|
@ -120,6 +122,7 @@ select
|
|||
"plugin_method"."types",
|
||||
"plugin_method"."schema",
|
||||
"plugin_method"."hostFunctions",
|
||||
"plugin_method"."allowedHosts",
|
||||
"plugin_method"."uiHints",
|
||||
"plugin"."name" as "pluginName"
|
||||
from
|
||||
|
|
@ -156,6 +159,7 @@ select
|
|||
"plugin_method"."types",
|
||||
"plugin_method"."schema",
|
||||
"plugin_method"."hostFunctions",
|
||||
"plugin_method"."allowedHosts",
|
||||
"plugin_method"."uiHints",
|
||||
"plugin"."name" as "pluginName"
|
||||
from
|
||||
|
|
@ -190,6 +194,7 @@ select
|
|||
"plugin_method"."types",
|
||||
"plugin_method"."schema",
|
||||
"plugin_method"."hostFunctions",
|
||||
"plugin_method"."allowedHosts",
|
||||
"plugin_method"."uiHints"
|
||||
from
|
||||
"plugin_method"
|
||||
|
|
|
|||
|
|
@ -80,7 +80,8 @@ select
|
|||
"plugin_method"."pluginId" as "pluginId",
|
||||
"plugin_method"."name" as "methodName",
|
||||
"plugin_method"."types" as "types",
|
||||
"plugin_method"."hostFunctions"
|
||||
"plugin_method"."hostFunctions",
|
||||
"plugin_method"."allowedHosts"
|
||||
from
|
||||
"workflow_step"
|
||||
inner join "plugin_method" on "plugin_method"."id" = "workflow_step"."pluginMethodId"
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@ export class PluginRepository {
|
|||
description: ref('excluded.description'),
|
||||
types: ref('excluded.types'),
|
||||
hostFunctions: ref('excluded.hostFunctions'),
|
||||
allowedHosts: ref('excluded.allowedHosts'),
|
||||
uiHints: ref('excluded.uiHints'),
|
||||
schema: ref('excluded.schema'),
|
||||
})),
|
||||
|
|
@ -240,7 +241,7 @@ export class PluginRepository {
|
|||
}
|
||||
}
|
||||
|
||||
async callMethod<T>({ pluginKey, methodName }: PluginMethod, input: unknown) {
|
||||
async callMethod<T>({ pluginKey, methodName }: PluginMethod, input: unknown, context?: unknown) {
|
||||
const item = this.pluginMap.get(pluginKey);
|
||||
if (!item) {
|
||||
throw new Error(`No loaded plugin found for ${pluginKey}`);
|
||||
|
|
@ -251,7 +252,7 @@ export class PluginRepository {
|
|||
try {
|
||||
const plugin = await pool.acquire();
|
||||
try {
|
||||
const result = await plugin.call(methodName, JSON.stringify(input));
|
||||
const result = await plugin.call(methodName, JSON.stringify(input), context);
|
||||
return (result ? result.json() : result) as T;
|
||||
} finally {
|
||||
await pool.release(plugin);
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ export class WorkflowRepository {
|
|||
'plugin_method.name as methodName',
|
||||
'plugin_method.types as types',
|
||||
'plugin_method.hostFunctions',
|
||||
'plugin_method.allowedHosts',
|
||||
]),
|
||||
).as('steps'),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "plugin_method" ADD "allowedHosts" character varying[] NOT NULL DEFAULT '{}';`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "plugin_method" DROP COLUMN "allowedHosts";`.execute(db);
|
||||
}
|
||||
|
|
@ -27,6 +27,9 @@ export class PluginMethodTable {
|
|||
@Column({ type: 'boolean', default: false })
|
||||
hostFunctions!: Generated<boolean>;
|
||||
|
||||
@Column({ type: 'character varying', default: [], array: true })
|
||||
allowedHosts!: Generated<string[]>;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
schema!: JsonSchemaDto | null;
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ type ExecuteOptions<T extends WorkflowType> = {
|
|||
|
||||
type AssetTrigger = { userId: string; assetId: string; trigger: WorkflowTrigger };
|
||||
|
||||
type HostContext = {
|
||||
allowedHosts: string[];
|
||||
};
|
||||
|
||||
export class WorkflowExecutionService extends BaseService {
|
||||
private jwtSecret!: string;
|
||||
|
||||
|
|
@ -66,20 +70,48 @@ export class WorkflowExecutionService extends BaseService {
|
|||
|
||||
const albumService = BaseService.create(AlbumService, this);
|
||||
|
||||
const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, args) => albumService.getAll(authDto, ...args));
|
||||
const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, args) => albumService.create(authDto, ...args));
|
||||
const addAssetsToAlbum = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, args) =>
|
||||
const searchAlbums = this.wrap<[dto: GetAlbumsDto]>((authDto, ctx, args) => albumService.getAll(authDto, ...args));
|
||||
const createAlbum = this.wrap<[dto: CreateAlbumDto]>((authDto, ctx, args) => albumService.create(authDto, ...args));
|
||||
const addAssetsToAlbum = this.wrap<[id: string, dto: BulkIdsDto]>((authDto, ctx, args) =>
|
||||
albumService.addAssets(authDto, ...args),
|
||||
);
|
||||
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, args) =>
|
||||
const addAssetsToAlbums = this.wrap<[dto: AlbumsAddAssetsDto]>((authDto, ctx, args) =>
|
||||
albumService.addAssetsToAlbums(authDto, ...args),
|
||||
);
|
||||
const httpRequest = this.wrap<
|
||||
[
|
||||
url: string,
|
||||
options?: {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string;
|
||||
},
|
||||
]
|
||||
>(async (authDto, context, args) => {
|
||||
const hostname = new URL(args[0]).hostname;
|
||||
|
||||
for (const pattern of context.allowedHosts) {
|
||||
const regex = new RegExp(pattern.replaceAll('.', String.raw`\.`).replaceAll('*', '.*'));
|
||||
if (regex.test(hostname)) {
|
||||
const res = await fetch(...args);
|
||||
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
body: await res.text(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Hostname did not match any listed in methods[].allowedHosts in the plugin manifest');
|
||||
});
|
||||
|
||||
const functions = {
|
||||
searchAlbums,
|
||||
createAlbum,
|
||||
addAssetsToAlbum,
|
||||
addAssetsToAlbums,
|
||||
httpRequest,
|
||||
};
|
||||
|
||||
const stubs: typeof functions = {
|
||||
|
|
@ -87,6 +119,7 @@ export class WorkflowExecutionService extends BaseService {
|
|||
createAlbum: dummy,
|
||||
addAssetsToAlbum: dummy,
|
||||
addAssetsToAlbums: dummy,
|
||||
httpRequest: dummy,
|
||||
};
|
||||
|
||||
const plugins = await this.pluginRepository.getForLoad();
|
||||
|
|
@ -121,7 +154,7 @@ export class WorkflowExecutionService extends BaseService {
|
|||
return id + (hostFunctions ? '/worker' : '');
|
||||
}
|
||||
|
||||
private wrap<T>(fn: (authDto: AuthDto, args: T) => Promise<unknown>) {
|
||||
private wrap<T>(fn: (authDto: AuthDto, context: HostContext, args: T) => Promise<unknown>) {
|
||||
return async (plugin: CurrentPlugin, offset: bigint) => {
|
||||
try {
|
||||
const handle = plugin.read(offset);
|
||||
|
|
@ -136,8 +169,9 @@ export class WorkflowExecutionService extends BaseService {
|
|||
throw new Error('authToken is required');
|
||||
}
|
||||
|
||||
const context = plugin.hostContext<HostContext>();
|
||||
const authDto = this.validate(authToken);
|
||||
const response = await fn(authDto, args);
|
||||
const response = await fn(authDto, context, args);
|
||||
|
||||
return plugin.store(JSON.stringify({ success: true, response }));
|
||||
} catch (error: Error | any) {
|
||||
|
|
@ -381,6 +415,10 @@ export class WorkflowExecutionService extends BaseService {
|
|||
data,
|
||||
};
|
||||
|
||||
const context: HostContext = {
|
||||
allowedHosts: step.allowedHosts,
|
||||
};
|
||||
|
||||
if (step.methodName.startsWith('noop')) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -391,6 +429,7 @@ export class WorkflowExecutionService extends BaseService {
|
|||
methodName: step.methodName,
|
||||
},
|
||||
payload,
|
||||
context,
|
||||
);
|
||||
if (result?.changes) {
|
||||
await write(
|
||||
|
|
|
|||
|
|
@ -427,4 +427,32 @@ describe('core plugin', () => {
|
|||
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhook', () => {
|
||||
it('should trigger a webhook on asset upload', async () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
|
||||
const fetchMock = vi.fn(() => Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('') }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const workflow = await createWorkflow({
|
||||
ownerId: user.id,
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
steps: [
|
||||
{
|
||||
method: 'immich-plugin-core#webhook',
|
||||
config: { url: 'http://localhost', method: 'POST' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetTrigger({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue