diff --git a/packages/plugin-core/manifest.json b/packages/plugin-core/manifest.json index 15d705b885..3508a3ece9 100644 --- a/packages/plugin-core/manifest.json +++ b/packages/plugin-core/manifest.json @@ -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"] } } ] diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts index 164f33d72b..cf438b583e 100644 --- a/packages/plugin-core/src/index.ts +++ b/packages/plugin-core/src/index.ts @@ -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 = { + '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 {}; +}); diff --git a/packages/plugin-core/tsconfig.json b/packages/plugin-core/tsconfig.json index 24aab4851c..d48ff581db 100644 --- a/packages/plugin-core/tsconfig.json +++ b/packages/plugin-core/tsconfig.json @@ -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) diff --git a/packages/plugin-sdk/src/host-functions.ts b/packages/plugin-sdk/src/host-functions.ts index 9bfe073a69..9d52630438 100644 --- a/packages/plugin-sdk/src/host-functions.ts +++ b/packages/plugin-sdk/src/host-functions.ts @@ -30,12 +30,23 @@ type HostFunctionResult = type QueryParams any> = Parameters[0]; type AlbumSearchDto = QueryParams; +type HttpRequestOptions = { + method?: string; + headers?: Record; + 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>; }; diff --git a/server/src/database.ts b/server/src/database.ts index 7f51acef2d..1770b9d720 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -368,6 +368,7 @@ export const columns = { 'plugin_method.types', 'plugin_method.schema', 'plugin_method.hostFunctions', + 'plugin_method.allowedHosts', 'plugin_method.uiHints', ], syncAsset: [ diff --git a/server/src/dtos/plugin-manifest.dto.ts b/server/src/dtos/plugin-manifest.dto.ts index b175c6e1bb..67da9e231d 100644 --- a/server/src/dtos/plugin-manifest.dto.ts +++ b/server/src/dtos/plugin-manifest.dto.ts @@ -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"'), }) diff --git a/server/src/queries/plugin.repository.sql b/server/src/queries/plugin.repository.sql index 824602ba86..58d6c1fd92 100644 --- a/server/src/queries/plugin.repository.sql +++ b/server/src/queries/plugin.repository.sql @@ -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" diff --git a/server/src/queries/workflow.repository.sql b/server/src/queries/workflow.repository.sql index fb62a51a73..86c26f5360 100644 --- a/server/src/queries/workflow.repository.sql +++ b/server/src/queries/workflow.repository.sql @@ -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" diff --git a/server/src/repositories/plugin.repository.ts b/server/src/repositories/plugin.repository.ts index 154266b965..2b177a38c3 100644 --- a/server/src/repositories/plugin.repository.ts +++ b/server/src/repositories/plugin.repository.ts @@ -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({ pluginKey, methodName }: PluginMethod, input: unknown) { + async callMethod({ 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); diff --git a/server/src/repositories/workflow.repository.ts b/server/src/repositories/workflow.repository.ts index 9ceef72a50..7a39e78ce9 100644 --- a/server/src/repositories/workflow.repository.ts +++ b/server/src/repositories/workflow.repository.ts @@ -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'), ]) diff --git a/server/src/schema/migrations/1782414436633-AddPluginMethodAllowedHosts.ts b/server/src/schema/migrations/1782414436633-AddPluginMethodAllowedHosts.ts new file mode 100644 index 0000000000..bbbbf8b83c --- /dev/null +++ b/server/src/schema/migrations/1782414436633-AddPluginMethodAllowedHosts.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "plugin_method" ADD "allowedHosts" character varying[] NOT NULL DEFAULT '{}';`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "plugin_method" DROP COLUMN "allowedHosts";`.execute(db); +} diff --git a/server/src/schema/tables/plugin-method.table.ts b/server/src/schema/tables/plugin-method.table.ts index 10cd7b449e..c80c9ab878 100644 --- a/server/src/schema/tables/plugin-method.table.ts +++ b/server/src/schema/tables/plugin-method.table.ts @@ -27,6 +27,9 @@ export class PluginMethodTable { @Column({ type: 'boolean', default: false }) hostFunctions!: Generated; + @Column({ type: 'character varying', default: [], array: true }) + allowedHosts!: Generated; + @Column({ type: 'jsonb', nullable: true }) schema!: JsonSchemaDto | null; diff --git a/server/src/services/workflow-execution.service.ts b/server/src/services/workflow-execution.service.ts index 221c3d4f5d..372bf04e71 100644 --- a/server/src/services/workflow-execution.service.ts +++ b/server/src/services/workflow-execution.service.ts @@ -42,6 +42,10 @@ type ExecuteOptions = { 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; + 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(fn: (authDto: AuthDto, args: T) => Promise) { + private wrap(fn: (authDto: AuthDto, context: HostContext, args: T) => Promise) { 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(); 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( diff --git a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts index 02556f66aa..970ed4f222 100644 --- a/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts +++ b/server/test/medium/specs/workflow/workflow-core-plugin.spec.ts @@ -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(); + }); + }); });