Compare commits

..

14 Commits

Author SHA1 Message Date
Ben Beckford 83dfe24c36 chore: remove leftover extism allowedhosts references 2026-06-25 22:02:05 -07:00
Ben Beckford 1ba7c15e37 chore: fix webhook method declaration 2026-06-25 16:52:03 -07:00
Ben Beckford fa379f95e4 Merge branch 'main' into feat/workflow-webhooks 2026-06-25 16:24:59 -07:00
Daniel Dietzler 688241a462 feat: plugin-sdk safety all around (#29323) 2026-06-25 18:23:55 -04:00
Ben Beckford 28981a68f8 chore: move allowedHosts to plugin methods and add httpRequest host function 2026-06-25 14:38:17 -07:00
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
25 changed files with 405 additions and 309 deletions
+16 -12
View File
@@ -28,7 +28,7 @@ while getopts 's:m:' flag; do
done
CURRENT_SERVER=$(jq -r '.version' package.json)
if ! NEXT_SERVER=$(pnpm --silent pump "$SERVER_PUMP"); then
if ! NEXT_SERVER=$(pnpm --silent pump "$CURRENT_SERVER" "$SERVER_PUMP"); then
echo "Fatal: failed to pump server version: $NEXT_SERVER" >&2
exit 1
fi
@@ -45,21 +45,25 @@ else
exit 1
fi
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix server
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/cli
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix web
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix e2e
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/sdk
# copy version to open-api spec
mise run //:open-api
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
uv version --directory machine-learning "$NEXT_SERVER"
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix server
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/cli
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix web
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix e2e
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/sdk
./misc/release/archive-version.js "$NEXT_SERVER"
# copy version to open-api spec
mise run //:open-api
uv version --directory machine-learning "$NEXT_SERVER"
./misc/release/archive-version.js "$NEXT_SERVER"
fi
if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
echo "Pumping Mobile: $CURRENT_MOBILE => $NEXT_MOBILE"
+5 -22
View File
@@ -1,24 +1,7 @@
import { pump, PumpInvalidError, PumpUsageError } from './pump.js';
import { pump } from './pump.js';
const [type] = process.argv.slice(2);
const [versionRaw, type] = process.argv.slice(2);
const { message, exitCode } = pump(versionRaw, type);
try {
const nextVersion = pump(type);
console.log(nextVersion);
} catch (error) {
if (error instanceof PumpUsageError) {
console.log(
'Usage: ./pump-wrapper.js <minor|patch|premajor|preminor|prepatch|prerelease|release>',
);
process.exit(1);
}
if (error instanceof PumpInvalidError) {
console.log(
`Invalid pump: ${type}. Pumping from ${error.version} to ${error.newVersion} is not allowed.`,
);
process.exit(1);
}
throw error;
}
console.log(message);
process.exit(exitCode);
+26 -77
View File
@@ -1,74 +1,44 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import semver, { SemVer } from 'semver';
const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
const Files = {
PackageJson: join(PROJECT_ROOT, 'package.json'),
ExampleEnv: join(PROJECT_ROOT, 'docker/example.env'),
Docs: {
Env: join(PROJECT_ROOT, 'docs/docs/install/environment-variables.md'),
Upgrading: join(PROJECT_ROOT, 'docs/docs/install/upgrading.md'),
},
const printUsage = () => {
return {
message:
'Usage: ./pump_cli.js <semver> <minor|patch|premajor|preminor|prepatch|prerelease|release>',
exitCode: 1,
};
};
export class PumpUsageError extends Error {}
export class PumpInvalidError extends Error {
constructor(options) {
super(`Invalid pump`);
this.version = options.version;
this.newVersion = options.newVersion;
}
}
const isPrerelease = (version) => version.prerelease.length > 0;
/**
* @param {string} type
* @param {SemVer} version
* @returns {boolean}
*/
export const pump = (type) => {
const currentVersionRaw = getCurrentVersion();
const nextVersionRaw = getNextVersion(currentVersionRaw, type);
const nextVersion = semver.parse(normalize(nextVersionRaw));
const inc = (version, type) => `v${semver.inc(version, type, {}, 'rc')}`;
if (nextVersion && type === 'release') {
const major = `v${nextVersion.major}`;
// sync major tag references in docs and example env file
findAndReplace(
Files.ExampleEnv,
/^IMMICH_VERSION=v\d+$/m,
`IMMICH_VERSION=${major}`,
);
findAndReplace(
Files.Docs.Env,
/(`IMMICH_VERSION`.*?)`v\d+`/,
`$1\`${major}\``,
);
findAndReplace(Files.Docs.Upgrading, /:v\d+/, `:${major}`);
/** @param {string} version */
const normalize = (version) => {
if (version.startsWith('v')) {
version = version.slice(1);
}
return nextVersionRaw;
return version;
};
const getCurrentVersion = () =>
JSON.parse(readFileSync(Files.PackageJson, 'utf8')).version;
/**
* @param {string} versionRaw
* @param {string} type
*/
export const getNextVersion = (versionRaw, type) => {
export const pump = (versionRaw, type) => {
if (!versionRaw) {
throw new PumpUsageError();
return printUsage();
}
versionRaw = normalize(versionRaw);
const version = semver.parse(versionRaw);
if (!version) {
throw new PumpUsageError();
return printUsage();
}
let newVersionRaw;
@@ -102,19 +72,19 @@ export const getNextVersion = (versionRaw, type) => {
}
default: {
throw new PumpUsageError();
return printUsage();
}
}
if (!newVersionRaw) {
throw new PumpUsageError();
return printUsage();
}
newVersionRaw = normalize(newVersionRaw);
const newVersion = semver.parse(newVersionRaw);
if (!newVersion) {
throw new PumpUsageError();
return printUsage();
}
const invalidUpgrade =
@@ -125,32 +95,11 @@ export const getNextVersion = (versionRaw, type) => {
version.patch !== newVersion.patch);
if (!valid || invalidUpgrade) {
throw new PumpInvalidError({
type,
version: versionRaw,
newVersion: newVersionRaw,
});
return {
message: `Invalid pump: ${type}. Pumping from ${versionRaw} to ${newVersionRaw} is not allowed.`,
exitCode: 1,
};
}
return newVersionRaw;
};
const findAndReplace = (path, pattern, replacement) =>
writeFileSync(path, readFileSync(path, 'utf8').replace(pattern, replacement));
const isPrerelease = (version) => version.prerelease.length > 0;
/**
* @param {SemVer} version
* @returns {boolean}
*/
const inc = (version, type) => `v${semver.inc(version, type, {}, 'rc')}`;
/** @param {string} version */
const normalize = (version) => {
if (version.startsWith('v')) {
version = version.slice(1);
}
return version;
return { message: newVersionRaw, exitCode: 0 };
};
+14 -5
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest';
import { getNextVersion, PumpInvalidError, PumpUsageError } from './pump';
import { pump } from './pump';
describe(getNextVersion.name, () => {
describe(pump.name, () => {
describe('usage', () => {
it.each([
[],
@@ -10,7 +10,10 @@ describe(getNextVersion.name, () => {
['invalid', 'patch'],
['2.7.5', 'major'],
])('should not accept $0, $1 as inputs', (version, type) => {
expect(() => getNextVersion(version, type)).toThrow(PumpUsageError);
expect(pump(version, type)).toEqual({
message: expect.stringContaining('Usage: '),
exitCode: 1,
});
});
});
@@ -55,7 +58,10 @@ describe(getNextVersion.name, () => {
it.each(group.items)(
'should allow a $0 from $1 to $2',
(type, version, next) => {
expect(getNextVersion(version, type)).toEqual(next);
expect(pump(version, type)).toEqual({
message: next,
exitCode: 0,
});
},
);
});
@@ -71,7 +77,10 @@ describe(getNextVersion.name, () => {
['prerelease', 'v3.0.0'],
['release', 'v3.0.0'],
])('should not allow a $0 on $1', (type, version) => {
expect(() => getNextVersion(version, type)).toThrow(PumpInvalidError);
expect(pump(version, type)).toEqual({
message: expect.stringContaining('Invalid pump'),
exitCode: 1,
});
});
});
});
+35
View File
@@ -300,6 +300,41 @@
"required": ["albumIds"]
}
},
{
"name": "webhook",
"title": "Trigger Webhook",
"description": "POST/PUT event data to any URL",
"types": ["AssetV1"],
"hostFunctions": true,
"allowedHosts": ["*"],
"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",
+2 -2
View File
@@ -5,8 +5,8 @@
"main": "src/index.ts",
"scripts": {
"build": "pnpm build:tsc && pnpm build:wasm",
"build:tsc": "mkdir -p dist && echo \"type Manifest = $(cat manifest.json); \nexport default Manifest;\" > dist/manifest.d.ts && tsc --noEmit && node esbuild.js",
"build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm"
"build:tsc": "plugin-sdk prepareBuild && tsc --noEmit && node esbuild.js",
"build:wasm": "extism-js dist/index.js -i dist/index.d.ts -o dist/plugin.wasm"
},
"keywords": [],
"author": "",
-27
View File
@@ -1,27 +0,0 @@
// keep in sync with plugin-sdk/host-functions.ts';
declare module 'extism:host' {
interface user {
searchAlbums(ptr: PTR): I64;
createAlbum(ptr: PTR): I64;
addAssetsToAlbum(ptr: PTR): I64;
addAssetsToAlbums(ptr: PTR): I64;
}
}
// keep in sync with manifest.json
declare module 'main' {
// filters
export function assetFileFilter(): I32;
export function assetMissingTimeZoneFilter(): I32;
export function assetLocationFilter(): I32;
export function assetTypeFilter(): I32;
// updates
export function assetFavorite(): I32;
export function assetVisibility(): I32;
export function assetArchive(): I32;
export function assetLock(): I32;
export function assetTimeline(): I32;
// export function assetTrash(): I32;
export function assetAddToAlbums(): I32;
}
+143 -143
View File
@@ -1,175 +1,175 @@
import { getWrapper } from '@immich/plugin-sdk';
import { AssetVisibility } from '@immich/sdk';
import type manifestType from '../dist/manifest';
import type { Manifest } from '../dist/index.d.ts';
const wrapper = getWrapper<manifestType>();
const wrapper = getWrapper<Manifest>();
export const assetFileFilter = () => {
return wrapper<'assetFileFilter'>(({ data, config }) => {
const { pattern, matchType = 'contains', caseSensitive = false } = config;
export const assetFileFilter = wrapper<'assetFileFilter'>(({ data, config }) => {
const { pattern, matchType = 'contains', caseSensitive = false } = config;
const { asset } = data;
const { asset } = data;
const fileName = asset.originalFileName || '';
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
const fileName = asset.originalFileName || '';
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
switch (matchType) {
case 'contains': {
return { workflow: { continue: searchName.includes(searchPattern) } };
}
case 'exact': {
return { workflow: { continue: searchName === searchPattern } };
}
case 'startsWith': {
return { workflow: { continue: searchName.startsWith(searchPattern) } };
}
case 'regex': {
const flags = caseSensitive ? '' : 'i';
const regex = new RegExp(searchPattern, flags);
return { workflow: { continue: regex.test(fileName) } };
}
default: {
return {};
}
}
});
};
export const assetMissingTimeZoneFilter = () => {
return wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => {
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
const needsTimeZone = config.inverse ? true : false;
return { workflow: { continue: hasTimeZone === needsTimeZone } };
});
};
export const assetLocationFilter = () => {
return wrapper<'assetLocationFilter'>(({ config, data }) => {
if (
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
(config.region?.city && config.region.city !== data.asset.exifInfo?.city)
) {
return { workflow: { continue: false } };
switch (matchType) {
case 'contains': {
return { workflow: { continue: searchName.includes(searchPattern) } };
}
const configLat = Number.parseFloat(config.coordinate?.latitude ?? '');
const configLon = Number.parseFloat(config.coordinate?.longitude ?? '');
if (Number.isNaN(configLat) || Number.isNaN(configLat)) {
return { workflow: { continue: true } };
case 'exact': {
return { workflow: { continue: searchName === searchPattern } };
}
const assetLat = data.asset.exifInfo?.latitude;
const assetLon = data.asset.exifInfo?.longitude;
if (assetLat === undefined || assetLat === null || assetLon === undefined || assetLon === null) {
return { workflow: { continue: false } };
case 'startsWith': {
return { workflow: { continue: searchName.startsWith(searchPattern) } };
}
const earthDiameter = 12742;
const deg = Math.PI / 180;
const delta = Math.asin(
Math.sqrt(
Math.pow(Math.sin((assetLat * deg - configLat * deg) / 2), 2) +
Math.cos(assetLat * deg) *
Math.cos(configLat * deg) *
Math.pow(Math.sin((assetLon * deg - configLon * deg) / 2), 2),
),
);
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
});
};
export const assetTypeFilter = () => {
return wrapper<'assetTypeFilter'>(({ config, data }) => {
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
});
};
export const assetFavorite = () => {
return wrapper<'assetFavorite'>(({ config, data }) => {
const target = config.inverse ? false : true;
if (target !== data.asset.isFavorite) {
return {
changes: {
asset: { isFavorite: target },
},
};
}
});
};
export const assetVisibility = () => {
return wrapper<'assetVisibility'>(({ config }) => ({
changes: { asset: { visibility: config.visibility as AssetVisibility } },
}));
};
export const assetArchive = () => {
return wrapper<'assetArchive'>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Archive } } };
case 'regex': {
const flags = caseSensitive ? '' : 'i';
const regex = new RegExp(searchPattern, flags);
return { workflow: { continue: regex.test(fileName) } };
}
if (config.inverse && data.asset.visibility === AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
default: {
return {};
}
}
});
return {};
});
};
export const assetMissingTimeZoneFilter = wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => {
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
const needsTimeZone = config.inverse ? true : false;
return { workflow: { continue: hasTimeZone === needsTimeZone } };
});
export const assetLock = () => {
return wrapper<'assetLock'>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
}
export const assetLocationFilter = wrapper<'assetLocationFilter'>(({ config, data }) => {
if (
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
(config.region?.city && config.region.city !== data.asset.exifInfo?.city)
) {
return { workflow: { continue: false } };
}
if (config.inverse && data.asset.visibility === AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
}
const configLat = Number.parseFloat(config.coordinate?.latitude ?? '');
const configLon = Number.parseFloat(config.coordinate?.longitude ?? '');
return {};
});
};
if (Number.isNaN(configLat) || Number.isNaN(configLat)) {
return { workflow: { continue: true } };
}
const assetLat = data.asset.exifInfo?.latitude;
const assetLon = data.asset.exifInfo?.longitude;
if (assetLat === undefined || assetLat === null || assetLon === undefined || assetLon === null) {
return { workflow: { continue: false } };
}
const earthDiameter = 12742;
const deg = Math.PI / 180;
const delta = Math.asin(
Math.sqrt(
Math.pow(Math.sin((assetLat * deg - configLat * deg) / 2), 2) +
Math.cos(assetLat * deg) *
Math.cos(configLat * deg) *
Math.pow(Math.sin((assetLon * deg - configLon * deg) / 2), 2),
),
);
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
});
export const assetTypeFilter = wrapper<'assetTypeFilter'>(({ config, data }) => {
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
});
export const assetFavorite = wrapper<'assetFavorite'>(({ config, data }) => {
const target = config.inverse ? false : true;
if (target !== data.asset.isFavorite) {
return {
changes: {
asset: { isFavorite: target },
},
};
}
});
export const assetVisibility = wrapper<'assetVisibility'>(({ config }) => ({
changes: { asset: { visibility: config.visibility as AssetVisibility } },
}));
export const assetArchive = wrapper<'assetArchive'>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Archive } } };
}
if (config.inverse && data.asset.visibility === AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
}
return {};
});
export const assetLock = wrapper<'assetLock'>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
}
if (config.inverse && data.asset.visibility === AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
}
return {};
});
// export const assetTrash = () => {
// // TODO use trash/untrash host functions
// return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
// };
export const assetAddToAlbums = () => {
return wrapper<'assetAddToAlbums'>(({ config, data, functions }) => {
const assetId = data.asset.id;
export const assetAddToAlbums = wrapper<'assetAddToAlbums'>(({ config, data, functions }) => {
const assetId = data.asset.id;
if (config.albumIds.length === 0) {
if (!config.albumName) {
return {};
}
const [existing] = functions.searchAlbums({ name: config.albumName });
if (!existing) {
const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] });
config.albumIds.push(created.id);
return {};
}
config.albumIds.push(existing.id);
}
if (config.albumIds.length === 1) {
functions.addAssetsToAlbum(config.albumIds[0], [assetId]);
if (config.albumIds.length === 0) {
if (!config.albumName) {
return {};
}
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] });
const [existing] = functions.searchAlbums({ name: config.albumName });
if (!existing) {
const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] });
config.albumIds.push(created.id);
return {};
}
config.albumIds.push(existing.id);
}
if (config.albumIds.length === 1) {
functions.addAssetsToAlbum(config.albumIds[0], [assetId]);
return {};
}
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 {};
});
+2 -2
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)
@@ -13,7 +13,7 @@
"skipLibCheck": true, // Skip type checking of declaration files
"strict": true, // Enable all strict type-checking options
"target": "es2020", // Specify ECMAScript target version
"types": ["./src/index.d.ts", "./node_modules/@extism/js-pdk"] // Specify a list of type definition files to be included in the compilation
"types": ["./dist/index.d.ts", "./node_modules/@extism/js-pdk"] // Specify a list of type definition files to be included in the compilation
},
"exclude": [
"node_modules" // Exclude the node_modules directory
+2 -1
View File
@@ -1,11 +1,12 @@
import esbuild from 'esbuild';
esbuild.build({
entryPoints: ['src/index.ts'],
entryPoints: ['src/index.ts', 'src/cli.ts'],
outdir: 'dist',
bundle: true,
sourcemap: false,
minify: false,
format: 'esm',
platform: 'node',
target: ['es2020'],
});
+6
View File
@@ -21,6 +21,9 @@
"files": [
"dist"
],
"bin": {
"plugin-sdk": "./plugin-sdk.mjs"
},
"keywords": [],
"author": "",
"license": "GNU Affero General Public License version 3",
@@ -35,5 +38,8 @@
},
"peerDependencies": {
"@extism/js-pdk": "^1.1.1"
},
"dependencies": {
"commander": "^15.0.0"
}
}
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env node
import "./dist/cli.js";
+43
View File
@@ -0,0 +1,43 @@
import { Command } from 'commander';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { availableFunctions } from 'src/host-functions.js';
const program = new Command('plugin-sdk');
program
.command('prepareBuild')
.description('Generate .d.ts file required for extism')
.argument(
'[manifest]',
"Path to the plugins's manifest file",
'manifest.json',
)
.option('-o --output', 'Output file for generated types', 'dist/index.d.ts')
.action((manifest: string, { output }) => {
const content = readFileSync(manifest, { encoding: 'utf-8' });
const methods = (
JSON.parse(content) as { methods: { name: string }[] }
).methods.map(({ name }) => name);
mkdirSync(dirname(output), { recursive: true });
writeFileSync(
output,
`
declare module 'extism:host' {
interface user {
${availableFunctions.map((functionName) => ` ${functionName}(ptr: PTR): I64;`).join('\n')}
}
}
declare module 'main' {
${methods.map((method) => ` export function ${method}(): I32;`).join('\n')}
}
export type Manifest = ${content};
`,
);
});
program.parse();
+29 -8
View File
@@ -6,14 +6,11 @@ import {
type CreateAlbumDto,
} from '@immich/sdk';
// keep in sync with plugin-core/src/index.d.ts';
declare module 'extism:host' {
interface user {
searchAlbums(ptr: PTR): I64;
createAlbum(ptr: PTR): I64;
addAssetsToAlbum(ptr: PTR): I64;
addAssetsToAlbums(ptr: PTR): I64;
}
interface user extends Record<
(typeof availableFunctions)[number],
(ptr: PTR) => I64
> {}
}
type AlbumsToAssets = {
@@ -33,6 +30,24 @@ 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) => {
const host = Host.getFunctions();
@@ -75,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>;
};
+2 -1
View File
@@ -67,7 +67,8 @@ export const getWrapper =
functions: ReturnType<typeof hostFunctions>;
},
) => WorkflowResponse<L> | undefined,
) => {
) =>
() => {
const input = Host.inputString();
try {
+4
View File
@@ -336,6 +336,10 @@ importers:
version: 6.0.3
packages/plugin-sdk:
dependencies:
commander:
specifier: ^15.0.0
version: 15.0.0
devDependencies:
'@extism/js-pdk':
specifier: ^1.1.1
+1
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: [
+5
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"'),
})
+5
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"
+2 -1
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"
+3 -2
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);
@@ -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(