Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
# @emdash-cms/plugin-webhook-notifier
## 0.1.2
### Patch Changes
- [#463](https://github.com/emdash-cms/emdash/pull/463) [`701d0ca`](https://github.com/emdash-cms/emdash/commit/701d0caee88c16e072c5c34b189bc1df3cb34fd0) Thanks [@sakibmd](https://github.com/sakibmd)! - fix: replace code block with section in webhook notifier payload preview
- Updated dependencies [[`156ba73`](https://github.com/emdash-cms/emdash/commit/156ba7350070400e5877e3a54d33486cd0d33640), [`80a895b`](https://github.com/emdash-cms/emdash/commit/80a895b1def1bf8794f56e151e5ad7675225fae4), [`da957ce`](https://github.com/emdash-cms/emdash/commit/da957ce8ec18953995e6e00e0a38e5d830f1a381), [`fcd8b7b`](https://github.com/emdash-cms/emdash/commit/fcd8b7bebbd4342de6ca1d782a3ae4d42d1be913), [`8ac15a4`](https://github.com/emdash-cms/emdash/commit/8ac15a4ee450552f763d3c6d9d097941c57b8300), [`ba2b020`](https://github.com/emdash-cms/emdash/commit/ba2b0204d274cf1bbf89f724a99797660733203c), [`0b108cf`](https://github.com/emdash-cms/emdash/commit/0b108cf6286e5b41c134bbeca8a6cc834756b190), [`1989e8b`](https://github.com/emdash-cms/emdash/commit/1989e8b4c432a05d022baf2196dec2680b2e2fd0), [`e190324`](https://github.com/emdash-cms/emdash/commit/e1903248e0fccb1b34d0620b33e4f06eccdfe2a6), [`724191c`](https://github.com/emdash-cms/emdash/commit/724191cf96d5d79b22528a167de8c45146fb0746), [`ed28089`](https://github.com/emdash-cms/emdash/commit/ed28089bd296e1633ea048c7ca667cb5341f6aa6), [`a293708`](https://github.com/emdash-cms/emdash/commit/a2937083f8f74e32ad1b0383d9f22b20e18d7237), [`c75cc5b`](https://github.com/emdash-cms/emdash/commit/c75cc5b82cb678c5678859b249d545e12be6fd97), [`6ebb797`](https://github.com/emdash-cms/emdash/commit/6ebb7975be00a4d756cdb56955c88395840e3fec), [`d421ee2`](https://github.com/emdash-cms/emdash/commit/d421ee2cedfe48748148912ac7766fd841757dd6), [`391caf4`](https://github.com/emdash-cms/emdash/commit/391caf4a0f404f323b97c5d7f54f4a4d96aef349), [`6474dae`](https://github.com/emdash-cms/emdash/commit/6474daee29b6d0be289c995755658755d93316b1), [`30c9a96`](https://github.com/emdash-cms/emdash/commit/30c9a96404e913ea8b3039ef4a5bc70541647eec), [`122c236`](https://github.com/emdash-cms/emdash/commit/122c2364fc4cfc9082f036f9affcee13d9b00511), [`5320321`](https://github.com/emdash-cms/emdash/commit/5320321f5ee1c1f456b1c8c054f2d0232be58ecd), [`8f44ec2`](https://github.com/emdash-cms/emdash/commit/8f44ec23a4b23f636f9689c075d29edfa4962c7c), [`b712ae3`](https://github.com/emdash-cms/emdash/commit/b712ae3e5d8aec45e4d7a0f20f273795f7122715), [`9cb5a28`](https://github.com/emdash-cms/emdash/commit/9cb5a28001cc8e6d650ec6b45c9ea091a4e9e3c2), [`7ee7d95`](https://github.com/emdash-cms/emdash/commit/7ee7d95ee32df2b1915144030569382fe97aef3d), [`e1014ef`](https://github.com/emdash-cms/emdash/commit/e1014eff18301ff68ac75d19157d3500ebe890c5), [`4d4ac53`](https://github.com/emdash-cms/emdash/commit/4d4ac536eeb664b7d0ca9f1895a51960a47ecafe), [`476cb3a`](https://github.com/emdash-cms/emdash/commit/476cb3a585d30acb2d4d172f94c5d2b4e5b6377b), [`87b0439`](https://github.com/emdash-cms/emdash/commit/87b0439927454a275833992de4244678b47b9aa3), [`dd708b1`](https://github.com/emdash-cms/emdash/commit/dd708b1c0c35d43761f89a87cba74b3c0ecb777e), [`befaeec`](https://github.com/emdash-cms/emdash/commit/befaeecfefd968d14693e96e3cdaa691ffabe7d3), [`c92e7e6`](https://github.com/emdash-cms/emdash/commit/c92e7e6907a575d134a69ebbeed531b99569d599), [`2ba1f1f`](https://github.com/emdash-cms/emdash/commit/2ba1f1f8d1ff773889f980af35391187e3705f17), [`a13c4ec`](https://github.com/emdash-cms/emdash/commit/a13c4ec6e362abecdae62abe64b1aebebc06aaae), [`a5e0603`](https://github.com/emdash-cms/emdash/commit/a5e0603b1910481d042f5a22dd19a60c76da7197)]:
- emdash@0.2.0
## 0.1.1
### Patch Changes
- [#363](https://github.com/emdash-cms/emdash/pull/363) [`91e31fb`](https://github.com/emdash-cms/emdash/commit/91e31fb2cab4c0470088c5d61bab6e2028821569) Thanks [@ascorbic](https://github.com/ascorbic)! - Fixes sandboxed plugin entries failing when package exports point to unbuilt TypeScript source. Adds build-time and bundle-time validation to catch misconfigured plugin exports early.
- Updated dependencies [[`422018a`](https://github.com/emdash-cms/emdash/commit/422018aeb227dffe3da7bfc772d86f9ce9c2bcd1), [`4221ba4`](https://github.com/emdash-cms/emdash/commit/4221ba48bc87ab9fa0b1bae144f6f2920beb4f5a), [`9269759`](https://github.com/emdash-cms/emdash/commit/9269759674bf254863f37d4cf1687fae56082063), [`d6cfc43`](https://github.com/emdash-cms/emdash/commit/d6cfc437f23e3e435a8862cab17d2c19363847d7), [`1bcfc50`](https://github.com/emdash-cms/emdash/commit/1bcfc502112d8756e34a720b8a170eb5486b425a), [`8c693b5`](https://github.com/emdash-cms/emdash/commit/8c693b582d7c5e29bd138161e81d9c8affb53689), [`5b3e33c`](https://github.com/emdash-cms/emdash/commit/5b3e33c26bc2eb30ab2a032960a5d57eb06f148a), [`9d10d27`](https://github.com/emdash-cms/emdash/commit/9d10d2791fe16be901d9d138e434bd79cf9335c4), [`91e31fb`](https://github.com/emdash-cms/emdash/commit/91e31fb2cab4c0470088c5d61bab6e2028821569), [`f112ac4`](https://github.com/emdash-cms/emdash/commit/f112ac48194d1c2302e93756d54b116d3d207c22), [`e9a6f7a`](https://github.com/emdash-cms/emdash/commit/e9a6f7ac3ceeaf5c2d0a557e4cf6cab5f3d7d764), [`b297fdd`](https://github.com/emdash-cms/emdash/commit/b297fdd88dadcabeb93f47abea9f24f70b7d4b71), [`d211452`](https://github.com/emdash-cms/emdash/commit/d2114523a55021f65ee46e44e11157b06334819e), [`8e28cfc`](https://github.com/emdash-cms/emdash/commit/8e28cfc5d66f58f0fb91aa35c02afdd426bb6555), [`38af118`](https://github.com/emdash-cms/emdash/commit/38af118ad517fd9aa83064368543bf64bc32c08a)]:
- emdash@0.1.1
## 0.1.0
### Minor Changes
- [#14](https://github.com/emdash-cms/emdash/pull/14) [`755b501`](https://github.com/emdash-cms/emdash/commit/755b5017906811f97f78f4c0b5a0b62e67b52ec4) Thanks [@ascorbic](https://github.com/ascorbic)! - First beta release
### Patch Changes
- Updated dependencies [[`755b501`](https://github.com/emdash-cms/emdash/commit/755b5017906811f97f78f4c0b5a0b62e67b52ec4)]:
- emdash@0.1.0
## 0.0.3
### Patch Changes
- Updated dependencies [[`3c319ed`](https://github.com/emdash-cms/emdash/commit/3c319ed6411a595e6974a86bc58c2a308b91c214)]:
- emdash@0.0.3
## 0.0.2
### Patch Changes
- Updated dependencies [[`b09bfd5`](https://github.com/emdash-cms/emdash/commit/b09bfd51cece5e88fe8314668a591ab11de36b4d)]:
- emdash@0.0.2

View File

@@ -0,0 +1,45 @@
{
"name": "@emdash-cms/plugin-webhook-notifier",
"version": "0.1.2",
"description": "Webhook notification plugin for EmDash CMS - posts to external URLs on content changes",
"type": "module",
"main": "dist/index.mjs",
"exports": {
".": {
"import": "./dist/index.mjs",
"types": "./dist/index.d.mts"
},
"./sandbox": "./dist/sandbox-entry.mjs"
},
"files": [
"dist"
],
"keywords": [
"emdash",
"cms",
"plugin",
"webhook",
"notifications",
"integration"
],
"author": "Matt Kane",
"license": "MIT",
"peerDependencies": {
"emdash": "workspace:>=0.2.0"
},
"devDependencies": {
"tsdown": "catalog:",
"typescript": "catalog:"
},
"scripts": {
"build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean",
"dev": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --watch",
"typecheck": "tsgo --noEmit"
},
"optionalDependencies": {},
"repository": {
"type": "git",
"url": "git+https://github.com/emdash-cms/emdash.git",
"directory": "packages/plugins/webhook-notifier"
}
}

View File

@@ -0,0 +1,50 @@
/**
* Webhook Notifier Plugin for EmDash CMS
*
* Posts to external URLs when content changes occur.
*
* Features:
* - Configurable webhook URLs (admin settings)
* - Secret token for authentication (encrypted)
* - Retry logic with exponential backoff
* - Event filtering by collection and action
* - Manual trigger via API route
*
* Demonstrates:
* - network:fetch:any capability (unrestricted outbound for user-configured URLs)
* - settings.secret() for encrypted tokens
* - apiRoutes for custom endpoints
* - content:afterDelete hook
* - Hook dependencies (runs after audit-log)
* - errorPolicy: "continue" (don't block save on webhook failure)
*/
import type { PluginDescriptor } from "emdash";
export interface WebhookPayload {
event: "content:create" | "content:update" | "content:delete" | "media:upload";
timestamp: string;
collection?: string;
resourceId: string;
resourceType: "content" | "media";
data?: Record<string, unknown>;
metadata?: Record<string, unknown>;
}
/**
* Create the webhook notifier plugin descriptor
*/
export function webhookNotifierPlugin(): PluginDescriptor {
return {
id: "webhook-notifier",
version: "0.1.0",
format: "standard",
entrypoint: "@emdash-cms/plugin-webhook-notifier/sandbox",
capabilities: ["network:fetch:any"],
storage: {
deliveries: { indexes: ["timestamp", "webhookUrl", "status"] },
},
adminPages: [{ path: "/settings", label: "Webhook Settings", icon: "send" }],
adminWidgets: [{ id: "status", title: "Webhooks", size: "third" }],
};
}

View File

@@ -0,0 +1,602 @@
/**
* Sandbox Entry Point -- Webhook Notifier
*
* Canonical plugin implementation using the standard format.
* Runs in both trusted (in-process) and sandboxed (isolate) modes.
*/
import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";
interface ContentSaveEvent {
content: Record<string, unknown>;
collection: string;
isNew: boolean;
}
interface ContentDeleteEvent {
id: string;
collection: string;
}
interface MediaUploadEvent {
media: { id: string };
}
interface WebhookPayload {
event: string;
timestamp: string;
collection?: string;
resourceId: string;
resourceType: "content" | "media";
data?: Record<string, unknown>;
metadata?: Record<string, unknown>;
}
// ── SSRF protection ──
const IPV6_BRACKET_PATTERN = /^\[|\]$/g;
const BLOCKED_HOSTNAMES = new Set(["localhost", "metadata.google.internal", "[::1]"]);
const PRIVATE_RANGES = [
{ start: (127 << 24) >>> 0, end: ((127 << 24) | 0x00ffffff) >>> 0 },
{ start: (10 << 24) >>> 0, end: ((10 << 24) | 0x00ffffff) >>> 0 },
{
start: ((172 << 24) | (16 << 16)) >>> 0,
end: ((172 << 24) | (31 << 16) | 0xffff) >>> 0,
},
{
start: ((192 << 24) | (168 << 16)) >>> 0,
end: ((192 << 24) | (168 << 16) | 0xffff) >>> 0,
},
{
start: ((169 << 24) | (254 << 16)) >>> 0,
end: ((169 << 24) | (254 << 16) | 0xffff) >>> 0,
},
{ start: 0, end: 0x00ffffff },
];
function validateWebhookUrl(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error("Invalid webhook URL");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`Webhook URL scheme '${parsed.protocol}' is not allowed`);
}
const hostname = parsed.hostname.replace(IPV6_BRACKET_PATTERN, "");
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
throw new Error("Webhook URLs targeting internal hosts are not allowed");
}
const parts = hostname.split(".");
if (parts.length === 4) {
const nums = parts.map(Number);
if (nums.every((n) => !isNaN(n) && n >= 0 && n <= 255)) {
const ip = ((nums[0]! << 24) | (nums[1]! << 16) | (nums[2]! << 8) | nums[3]!) >>> 0;
if (PRIVATE_RANGES.some((r) => ip >= r.start && ip <= r.end)) {
throw new Error("Webhook URLs targeting private IP addresses are not allowed");
}
}
}
if (
hostname === "::1" ||
hostname.startsWith("fe80:") ||
hostname.startsWith("fc") ||
hostname.startsWith("fd")
) {
throw new Error("Webhook URLs targeting internal addresses are not allowed");
}
}
// ── Webhook delivery ──
type FetchFn = (url: string, init?: RequestInit) => Promise<Response>;
type LogFn = PluginContext["log"];
async function sendWebhook(
fetchFn: FetchFn,
log: LogFn,
url: string,
payload: WebhookPayload,
token: string | undefined,
maxRetries: number,
): Promise<{ success: boolean; status?: number; error?: string }> {
validateWebhookUrl(url);
let lastError: string | undefined;
let lastStatus: number | undefined;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-EmDash-Event": payload.event,
};
if (token) headers["Authorization"] = `Bearer ${token}`;
const response = await fetchFn(url, {
method: "POST",
headers,
body: JSON.stringify(payload),
});
lastStatus = response.status;
if (response.ok) {
log.info(`Delivered ${payload.event} to ${url} (${response.status})`);
return { success: true, status: response.status };
}
lastError = `HTTP ${response.status}: ${response.statusText}`;
log.warn(`Attempt ${attempt}/${maxRetries} failed: ${lastError}`);
} catch (error) {
lastError = error instanceof Error ? error.message : "Unknown error";
log.warn(`Attempt ${attempt}/${maxRetries} failed: ${lastError}`);
}
if (attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, 100 * Math.pow(2, attempt - 1)));
}
}
log.error(`Failed to deliver ${payload.event} after ${maxRetries} attempts`);
return { success: false, status: lastStatus, error: lastError };
}
// ── Helpers ──
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function getString(value: unknown, key: string): string | undefined {
if (!isRecord(value)) return undefined;
const v = value[key];
return typeof v === "string" ? v : undefined;
}
const MAX_RETRIES = 3;
async function getConfig(ctx: PluginContext) {
const url = await ctx.kv.get<string>("settings:webhookUrl");
const token = await ctx.kv.get<string>("settings:secretToken");
const enabled = await ctx.kv.get<boolean>("settings:enabled");
return { url, token, enabled };
}
function getFetchFn(ctx: PluginContext): FetchFn {
if (!ctx.http) {
throw new Error("Webhook notifier requires network:fetch capability");
}
return ctx.http.fetch;
}
// ── Plugin definition ──
export default definePlugin({
hooks: {
"content:afterSave": {
priority: 210,
timeout: 10000,
dependencies: ["audit-log"],
errorPolicy: "continue",
handler: async (event: ContentSaveEvent, ctx: PluginContext) => {
const { url, token, enabled } = await getConfig(ctx);
if (enabled === false || !url) return;
const contentId =
typeof event.content.id === "string" ? event.content.id : String(event.content.id);
const payload: WebhookPayload = {
event: event.isNew ? "content:create" : "content:update",
timestamp: new Date().toISOString(),
collection: event.collection,
resourceId: contentId,
resourceType: "content",
metadata: {
slug: event.content.slug,
status: event.content.status,
},
};
await sendWebhook(getFetchFn(ctx), ctx.log, url, payload, token ?? undefined, MAX_RETRIES);
},
},
"content:afterDelete": {
priority: 210,
timeout: 10000,
dependencies: ["audit-log"],
errorPolicy: "continue",
handler: async (event: ContentDeleteEvent, ctx: PluginContext) => {
const { url, token, enabled } = await getConfig(ctx);
if (enabled === false || !url) return;
const payload: WebhookPayload = {
event: "content:delete",
timestamp: new Date().toISOString(),
collection: event.collection,
resourceId: event.id,
resourceType: "content",
};
await sendWebhook(getFetchFn(ctx), ctx.log, url, payload, token ?? undefined, MAX_RETRIES);
},
},
"media:afterUpload": {
priority: 210,
timeout: 10000,
errorPolicy: "continue",
handler: async (event: MediaUploadEvent, ctx: PluginContext) => {
const { url, token, enabled } = await getConfig(ctx);
if (enabled === false || !url) return;
const payload: WebhookPayload = {
event: "media:upload",
timestamp: new Date().toISOString(),
resourceId: event.media.id,
resourceType: "media",
};
await sendWebhook(getFetchFn(ctx), ctx.log, url, payload, token ?? undefined, MAX_RETRIES);
},
},
},
routes: {
admin: {
handler: async (
routeCtx: { input: unknown; request: { url: string } },
ctx: PluginContext,
) => {
const interaction = routeCtx.input as {
type: string;
page?: string;
action_id?: string;
value?: string;
values?: Record<string, unknown>;
};
if (interaction.type === "page_load" && interaction.page === "widget:webhook-status") {
return buildStatusWidget(ctx);
}
if (interaction.type === "page_load" && interaction.page === "/settings") {
return buildSettingsPage(ctx);
}
if (interaction.type === "form_submit" && interaction.action_id === "save_settings") {
return saveSettings(ctx, interaction.values ?? {});
}
if (interaction.type === "block_action" && interaction.action_id === "test_webhook") {
return testWebhook(ctx);
}
return { blocks: [] };
},
},
status: {
handler: async (_routeCtx: { input: unknown; request: unknown }, ctx: PluginContext) => {
try {
const url = await ctx.kv.get<string>("settings:webhookUrl");
const enabled = await ctx.kv.get<boolean>("settings:enabled");
const deliveries = ctx.storage.deliveries!;
const successful = await deliveries.count({ status: "success" });
const failed = await deliveries.count({ status: "failed" });
const pending = await deliveries.count({ status: "pending" });
return {
configured: !!url,
enabled: enabled ?? true,
stats: { successful, failed, pending },
};
} catch (error) {
ctx.log.error("Failed to get status", error);
return {
configured: false,
enabled: true,
stats: { successful: 0, failed: 0, pending: 0 },
};
}
},
},
settings: {
handler: async (_routeCtx: { input: unknown; request: unknown }, ctx: PluginContext) => {
try {
const settings = await ctx.kv.list("settings:");
const map: Record<string, unknown> = {};
for (const entry of settings) {
map[entry.key.replace("settings:", "")] = entry.value;
}
return {
webhookUrl: typeof map.webhookUrl === "string" ? map.webhookUrl : "",
enabled: typeof map.enabled === "boolean" ? map.enabled : true,
includeData: typeof map.includeData === "boolean" ? map.includeData : false,
events: typeof map.events === "string" ? map.events : "all",
};
} catch (error) {
ctx.log.error("Failed to get settings", error);
return { webhookUrl: "", enabled: true, includeData: false, events: "all" };
}
},
},
"settings/save": {
handler: async (routeCtx: { input: unknown; request: unknown }, ctx: PluginContext) => {
try {
const input = isRecord(routeCtx.input) ? routeCtx.input : {};
if (typeof input.webhookUrl === "string")
await ctx.kv.set("settings:webhookUrl", input.webhookUrl);
if (typeof input.enabled === "boolean")
await ctx.kv.set("settings:enabled", input.enabled);
if (typeof input.includeData === "boolean")
await ctx.kv.set("settings:includeData", input.includeData);
if (typeof input.events === "string") await ctx.kv.set("settings:events", input.events);
return { success: true };
} catch (error) {
ctx.log.error("Failed to save settings", error);
return { success: false, error: String(error) };
}
},
},
test: {
handler: async (routeCtx: { input: unknown; request: unknown }, ctx: PluginContext) => {
const testUrl = getString(routeCtx.input, "url");
if (!testUrl) return { success: false, error: "No webhook URL provided" };
const token = await ctx.kv.get<string>("settings:secretToken");
const testPayload: WebhookPayload = {
event: "content:create",
timestamp: new Date().toISOString(),
resourceId: "test-" + Date.now(),
resourceType: "content",
metadata: { test: true, message: "Webhook test from EmDash CMS" },
};
const result = await sendWebhook(
getFetchFn(ctx),
ctx.log,
testUrl,
testPayload,
token ?? undefined,
1,
);
return {
success: result.success,
status: result.status,
error: result.error,
payload: testPayload,
};
},
},
},
});
// ── Block Kit admin helpers ──
async function buildStatusWidget(ctx: PluginContext) {
try {
const url = await ctx.kv.get<string>("settings:webhookUrl");
const enabled = await ctx.kv.get<boolean>("settings:enabled");
const isConfigured = !!url && enabled !== false;
let successful = 0;
let failed = 0;
let pending = 0;
try {
const deliveries = ctx.storage.deliveries!;
successful = await deliveries.count({ status: "success" });
failed = await deliveries.count({ status: "failed" });
pending = await deliveries.count({ status: "pending" });
} catch {
// Storage not available yet
}
const blocks: unknown[] = [
{
type: "fields",
fields: [
{
label: "Status",
value: isConfigured ? "Active" : "Not Configured",
},
{
label: "Endpoint",
value: url ? url : "None",
},
],
},
];
if (isConfigured) {
blocks.push({
type: "stats",
stats: [
{ label: "Delivered", value: String(successful) },
{ label: "Failed", value: String(failed) },
{ label: "Pending", value: String(pending) },
],
});
} else {
blocks.push({
type: "context",
text: "Configure a webhook URL in settings to start sending events.",
});
}
return { blocks };
} catch (error) {
ctx.log.error("Failed to build status widget", error);
return { blocks: [{ type: "context", text: "Failed to load webhook status" }] };
}
}
async function buildSettingsPage(ctx: PluginContext) {
try {
const webhookUrl = (await ctx.kv.get<string>("settings:webhookUrl")) ?? "";
const enabled = (await ctx.kv.get<boolean>("settings:enabled")) ?? true;
const includeData = (await ctx.kv.get<boolean>("settings:includeData")) ?? false;
const events = (await ctx.kv.get<string>("settings:events")) ?? "all";
const payloadPreview = JSON.stringify(
{
event: "content:create",
timestamp: new Date().toISOString(),
collection: "posts",
resourceId: "abc123",
resourceType: "content",
...(includeData && {
data: { title: "Example Post", slug: "example-post" },
}),
metadata: { slug: "example-post", status: "published" },
},
null,
2,
);
return {
blocks: [
{ type: "header", text: "Webhook Settings" },
{
type: "context",
text: "Send notifications to external services when content changes.",
},
{ type: "divider" },
{
type: "form",
block_id: "webhook-settings",
fields: [
{
type: "text_input",
action_id: "webhookUrl",
label: "Webhook URL",
initial_value: webhookUrl,
},
{
type: "secret_input",
action_id: "secretToken",
label: "Secret Token",
},
{
type: "toggle",
action_id: "enabled",
label: "Enable Webhooks",
initial_value: enabled,
},
{
type: "select",
action_id: "events",
label: "Events to Send",
options: [
{ label: "All events", value: "all" },
{ label: "Content changes only", value: "content" },
{ label: "Media uploads only", value: "media" },
],
initial_value: events,
},
{
type: "toggle",
action_id: "includeData",
label: "Include Content Data",
initial_value: includeData,
},
],
submit: { label: "Save Settings", action_id: "save_settings" },
},
{ type: "divider" },
{ type: "section", text: "**Payload Preview**" },
{ type: "section", text: "```json\n" + payloadPreview + "\n```" },
{
type: "actions",
elements: [
{
type: "button",
text: "Test Webhook",
action_id: "test_webhook",
style: "primary",
},
],
},
],
};
} catch (error) {
ctx.log.error("Failed to build settings page", error);
return { blocks: [{ type: "context", text: "Failed to load settings" }] };
}
}
async function saveSettings(ctx: PluginContext, values: Record<string, unknown>) {
try {
if (typeof values.webhookUrl === "string")
await ctx.kv.set("settings:webhookUrl", values.webhookUrl);
if (typeof values.secretToken === "string" && values.secretToken !== "")
await ctx.kv.set("settings:secretToken", values.secretToken);
if (typeof values.enabled === "boolean") await ctx.kv.set("settings:enabled", values.enabled);
if (typeof values.events === "string") await ctx.kv.set("settings:events", values.events);
if (typeof values.includeData === "boolean")
await ctx.kv.set("settings:includeData", values.includeData);
return {
...(await buildSettingsPage(ctx)),
toast: { message: "Settings saved", type: "success" },
};
} catch (error) {
ctx.log.error("Failed to save settings", error);
return {
blocks: [{ type: "banner", style: "error", text: "Failed to save settings" }],
toast: { message: "Failed to save settings", type: "error" },
};
}
}
async function testWebhook(ctx: PluginContext) {
const url = await ctx.kv.get<string>("settings:webhookUrl");
if (!url) {
return {
blocks: [{ type: "banner", style: "warning", text: "Enter a webhook URL first." }],
toast: { message: "No webhook URL configured", type: "error" },
};
}
const token = await ctx.kv.get<string>("settings:secretToken");
const testPayload: WebhookPayload = {
event: "content:create",
timestamp: new Date().toISOString(),
resourceId: "test-" + Date.now(),
resourceType: "content",
metadata: { test: true, message: "Webhook test from EmDash CMS" },
};
try {
const result = await sendWebhook(
getFetchFn(ctx),
ctx.log,
url,
testPayload,
token ?? undefined,
1,
);
if (result.success) {
return {
...(await buildSettingsPage(ctx)),
toast: { message: `Test sent -- HTTP ${result.status}`, type: "success" },
};
}
return {
...(await buildSettingsPage(ctx)),
toast: {
message: `Test failed: ${result.error ?? "Unknown error"}`,
type: "error",
},
};
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return {
...(await buildSettingsPage(ctx)),
toast: { message: `Test failed: ${msg}`, type: "error" },
};
}
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}