feat: webhook workflow action (#29258)

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
Ben Beckford 2026-06-26 07:08:45 -07:00 committed by GitHub
parent a876d4a9f1
commit 953ef5c047
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 159 additions and 99 deletions

View file

@ -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": {
"method": {
"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"
}
}
}
}
}
}
"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"]
}
}
]

View file

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

View file

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

View file

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

View file

@ -368,6 +368,7 @@ export const columns = {
'plugin_method.types',
'plugin_method.schema',
'plugin_method.hostFunctions',
'plugin_method.allowedHosts',
'plugin_method.uiHints',
],
syncAsset: [

View file

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

View file

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

View file

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

View file

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

View file

@ -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'),
])

View file

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

View file

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

View file

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

View file

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