Compare commits

...

9 Commits

Author SHA1 Message Date
Ben Beckford cff8065e5f chore: update core plugin header 2026-06-25 10:47:09 -07:00
Ben Beckford 961ab7b150 chore: clean up webhook plugin method 2026-06-25 08:10:00 -07:00
Ben Beckford 1037fcc07e chore: update workflow method wrapper type 2026-06-24 21:40:27 -07:00
Ben Beckford db9dc73006 Merge branch 'main' into feat/workflow-webhooks 2026-06-24 21:37:17 -07:00
Ben Beckford c80303d4d5 feat(server): allow plugins to specify allowed hostnames 2026-06-24 21:17:45 -07:00
Ben Beckford 11f61f23ba Merge branch 'main' into feat/workflow-webhooks 2026-06-23 13:00:07 -07:00
Ben Beckford 226fab849c chore: use extism http in workflow webhook method 2026-06-23 12:58:59 -07:00
Ben Beckford d39bd2e6cc feat: support PUT in webhook action 2026-06-23 11:14:58 -07:00
Ben Beckford e4cf79263b feat: webhook workflow action 2026-06-22 00:01:04 -07:00
10 changed files with 82 additions and 4 deletions
+35
View File
@@ -5,6 +5,7 @@
"description": "Core workflow capabilities for Immich",
"author": "Immich Team",
"wasmPath": "dist/plugin.wasm",
"allowedHosts": ["*"],
"templates": [
{
"name": "screenshots-smart-album",
@@ -300,6 +301,40 @@
"required": ["albumIds"]
}
},
{
"name": "webhook",
"title": "Trigger Webhook",
"description": "POST/PUT event data to any URL",
"types": ["AssetV1"],
"hostFunctions": true,
"schema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"title": "URL",
"description": "Event data will be PUT/POSTed to this URL as a JSON object"
},
"headerName": {
"type": "string",
"title": "Header name",
"description": "The name of an additional header to include with the request (e.g. authentication)"
},
"headerValue": {
"type": "string",
"title": "Header value",
"description": "The value of the additional header"
},
"method": {
"type": "string",
"title": "Method",
"description": "The HTTP method to use in the request",
"enum": ["POST", "PUT"]
}
},
"required": ["url"]
}
},
{
"name": "noop1",
"title": "DEV: Nested properties",
+1
View File
@@ -24,4 +24,5 @@ declare module 'main' {
export function assetTimeline(): I32;
// export function assetTrash(): I32;
export function assetAddToAlbums(): I32;
export function webhook(): I32;
}
+18
View File
@@ -173,3 +173,21 @@ export const assetAddToAlbums = () => {
return {};
});
};
export const webhook = () => {
return wrapper<'webhook'>(({ config, data }) => {
const headers = new Headers({ 'Content-Type': 'application/json' });
if (config.headerName && config.headerValue) {
headers.set(config.headerName, config.headerValue);
}
fetch(config.url, {
method: config.method ?? 'POST',
body: JSON.stringify(data),
headers,
});
return {};
});
};
+1 -1
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)
+1
View File
@@ -58,6 +58,7 @@ const PluginManifestSchema = z
wasmPath: z.string().min(1).describe('WASM file path'),
author: z.string().min(1).describe('Plugin author'),
methods: z.array(PluginManifestMethodSchema).optional().default([]).describe('Plugin methods'),
allowedHosts: z.array(z.string()).optional().default([]).describe('Hostnames the plugin can access'),
templates: z
.array(PluginManifestTemplateSchema)
.optional()
+5
View File
@@ -6,6 +6,7 @@ select
"plugin"."name",
"plugin"."version",
"plugin"."wasmBytes",
"plugin"."allowedHosts",
(
select
coalesce(json_agg(agg), '[]')
@@ -36,6 +37,7 @@ select
"plugin"."createdAt",
"plugin"."updatedAt",
"plugin"."templates",
"plugin"."allowedHosts",
(
select
coalesce(json_agg(agg), '[]')
@@ -72,6 +74,7 @@ select
"plugin"."createdAt",
"plugin"."updatedAt",
"plugin"."templates",
"plugin"."allowedHosts",
(
select
coalesce(json_agg(agg), '[]')
@@ -108,6 +111,7 @@ select
"plugin"."createdAt",
"plugin"."updatedAt",
"plugin"."templates",
"plugin"."allowedHosts",
(
select
coalesce(json_agg(agg), '[]')
@@ -144,6 +148,7 @@ select
"plugin"."createdAt",
"plugin"."updatedAt",
"plugin"."templates",
"plugin"."allowedHosts",
(
select
coalesce(json_agg(agg), '[]')
+6 -1
View File
@@ -20,6 +20,7 @@ export type PluginHostFunction = (callContext: CallContext, input: bigint) => Pr
export type PluginLoadOptions = {
runInWorker?: boolean;
functions?: Record<string, PluginHostFunction>;
allowedHosts?: string[];
};
export type PluginMethodSearchResponse = {
@@ -60,6 +61,7 @@ export class PluginRepository {
'plugin.name',
'plugin.version',
'plugin.wasmBytes',
'plugin.allowedHosts',
jsonArrayFrom(
eb
.selectFrom('plugin_method')
@@ -82,6 +84,7 @@ export class PluginRepository {
'plugin.createdAt',
'plugin.updatedAt',
'plugin.templates',
'plugin.allowedHosts',
jsonArrayFrom(
eb
.selectFrom('plugin_method')
@@ -159,6 +162,7 @@ export class PluginRepository {
wasmBytes: eb.ref('excluded.wasmBytes'),
templates: eb.ref('excluded.templates'),
sha256hash: eb.ref('excluded.sha256hash'),
allowedHosts: eb.ref('excluded.allowedHosts'),
})),
)
.returning(['id', 'name'])
@@ -202,7 +206,7 @@ export class PluginRepository {
});
}
async load({ key, label, wasmBytes }: PluginLoad, { runInWorker, functions }: PluginLoadOptions) {
async load({ key, label, wasmBytes }: PluginLoad, { runInWorker, functions, allowedHosts }: PluginLoadOptions) {
const data = new Uint8Array(wasmBytes.buffer, wasmBytes.byteOffset, wasmBytes.byteLength);
const logger = LoggingRepository.create(`Plugin:${label}`);
const pool = createPool<ExtismPlugin>(
@@ -216,6 +220,7 @@ export class PluginRepository {
functions: {
'extism:host/user': functions ?? {},
},
allowedHosts,
logger: {
trace: (message) => logger.verbose(message),
info: (message) => logger.log(message),
@@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "plugin" ADD "allowedHosts" character varying[] NOT NULL DEFAULT '{}';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "plugin" DROP COLUMN "allowedHosts";`.execute(db);
}
+3
View File
@@ -43,6 +43,9 @@ export class PluginTable {
@Column({ type: 'bytea' })
sha256hash!: Buffer;
@Column({ type: 'character varying', default: [], array: true })
allowedHosts!: Generated<string[]>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@@ -90,7 +90,7 @@ export class WorkflowExecutionService extends BaseService {
};
const plugins = await this.pluginRepository.getForLoad();
for (const { id, name, version, wasmBytes, methods } of plugins) {
for (const { id, name, version, wasmBytes, methods, allowedHosts } of plugins) {
const method = methods.some(({ hostFunctions }) => !hostFunctions);
if (method) {
const label = `${name}@${version}`;
@@ -108,7 +108,7 @@ export class WorkflowExecutionService extends BaseService {
const label = `${name}@${version}/worker`;
const key = this.getPluginKey({ id, hostFunctions: true });
try {
await this.pluginRepository.load({ key, label, wasmBytes }, { runInWorker: true, functions });
await this.pluginRepository.load({ key, label, wasmBytes }, { runInWorker: true, functions, allowedHosts });
this.logger.log(`Loaded plugin with host functions: ${label}`);
} catch (error) {
this.logger.error(`Unable to load plugin with host functions ${label} (${id})`, error);
@@ -214,6 +214,7 @@ export class WorkflowExecutionService extends BaseService {
author: manifest.author,
version: manifest.version,
templates: manifest.templates,
allowedHosts: manifest.allowedHosts,
wasmBytes,
sha256hash,
},