mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
fix: multiple-webhook-headers and nextcloud talk provider (#296)
* feat(webhook): replace single secret header pair with webhookHeaders array schema * feat(webhook): iterate webhookHeaders array; keep backward compat for legacy secret fields * feat(webhook): replace single header pair with dynamic useFieldArray header list Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(webhook): add field labels to header key/value inputs * fix(webhook): guard reserved headers; align delete button with inputs * feat(nextcloud): add nextcloud to provider_kind enum * feat(nextcloud): add ProviderKind entry and HMAC-signed provider * feat(nextcloud): add Zod schema and form component * feat(nextcloud): wire nextcloud into form registry and UI provider list Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(nextcloud): register sendNextcloud in provider dispatch map * fix: multiple webhook headers and nextcloud talk notification provider * test(webhook): update e2e to use Add Header button and Header Value label * fix: webhook.ts --------- Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
charles-gauthereau
parent
2cd5bda416
commit
af3df7f72a
@@ -31,8 +31,9 @@ const invalidChannelName = "Webhook E2E Invalid";
|
||||
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
// await create(page, "Webhook", optionalChannelName, async (page) => {
|
||||
// await page.getByLabel(/Webhook URL/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_URL"));
|
||||
// await page.getByRole("button", { name: "Add Header" }).click();
|
||||
// await page.getByLabel(/^Header Name$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER"));
|
||||
// await page.getByLabel(/^Secret Value$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET"));
|
||||
// await page.getByLabel(/^Header Value$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET"));
|
||||
// });
|
||||
// await submit(page);
|
||||
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
@@ -49,8 +50,9 @@ test.describe.serial("Invalid channel", () => {
|
||||
await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
|
||||
await create(page, "Webhook", invalidChannelName, async (page) => {
|
||||
await page.getByLabel(/Webhook URL/).fill("https://webhook.example.com/api/wrong-webhook");
|
||||
await page.getByRole("button", { name: "Add Header" }).click();
|
||||
await page.getByLabel(/^Header Name$/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_SECRET_HEADER"));
|
||||
await page.getByLabel(/^Secret Value$/).fill("wrong-webhook-secret");
|
||||
await page.getByLabel(/^Header Value$/).fill("wrong-webhook-secret");
|
||||
});
|
||||
await submit(page);
|
||||
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
|
||||
|
||||
+5
@@ -6,6 +6,7 @@ import {TelegramChannelConfigSchema} from "./providers/notifications/forms/teleg
|
||||
import {GotifyChannelConfigSchema} from "./providers/notifications/forms/gotify.schema";
|
||||
import {NtfyChannelConfigSchema} from "./providers/notifications/forms/ntfy.schema";
|
||||
import {WebhookChannelConfigSchema} from "./providers/notifications/forms/webhook.schema";
|
||||
import {NextcloudChannelConfigSchema} from "./providers/notifications/forms/nextcloud.schema";
|
||||
import {S3ChannelConfigSchema} from "./providers/storages/forms/s3.schema";
|
||||
import {GoogleDriveChannelConfigSchema} from "./providers/storages/forms/google-drive.schema";
|
||||
import {LocalChannelConfigSchema} from "./providers/storages/forms/local.schema";
|
||||
@@ -48,6 +49,10 @@ export const NotificationChannelFormSchema = z.discriminatedUnion("provider", [
|
||||
provider: z.literal("webhook"),
|
||||
config: WebhookChannelConfigSchema,
|
||||
}),
|
||||
BaseChannelFormSchema.extend({
|
||||
provider: z.literal("nextcloud"),
|
||||
config: NextcloudChannelConfigSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
type NotifierNextcloudFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierNextcloudForm = ({ form }: NotifierNextcloudFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.nextcloudUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nextcloud URL *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. https://cloud.example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.nextcloudBotToken"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bot Token *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. j3yujpuh" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.nextcloudBotSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Bot Secret *</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="HMAC signing secret" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const NextcloudChannelConfigSchema = z.object({
|
||||
nextcloudUrl: z.string().url("Must be a valid URL"),
|
||||
nextcloudBotToken: z.string().min(1, "Bot token is required"),
|
||||
nextcloudBotSecret: z.string().min(1, "Bot secret is required"),
|
||||
});
|
||||
+112
-62
@@ -1,72 +1,122 @@
|
||||
import { UseFormReturn } from "react-hook-form";
|
||||
"use client";
|
||||
|
||||
import { useFieldArray, UseFormReturn } from "react-hook-form";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
type NotifierWebhookFormProps = {
|
||||
form: UseFormReturn<any, any, any>;
|
||||
form: UseFormReturn<any, any, any>;
|
||||
};
|
||||
|
||||
export const NotifierWebhookForm = ({ form }: NotifierWebhookFormProps) => {
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL *</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. https://example.com/api/webhook" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookSecretHeader"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Header Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="e.g. X-Webhook-Secret" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookSecret"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Secret Value</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput {...field} placeholder="e.g. webhook-secret" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
If provided, the secret will be sent in the specified header (defaults
|
||||
to <code>X-Webhook-Secret</code>).
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "config.webhookHeaders",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Separator className="my-1" />
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="config.webhookUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Webhook URL *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="e.g. https://example.com/api/webhook"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">Custom Headers</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => append({ key: "", value: "" })}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No custom headers. Add headers to send with each webhook request.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{fields.map((headerField, index) => (
|
||||
<div key={headerField.id} className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`config.webhookHeaders.${index}.key`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Header Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="e.g. X-Api-Key"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`config.webhookHeaders.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Header Value</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="Header value"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="self-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+12
-3
@@ -1,7 +1,16 @@
|
||||
import {z} from "zod";
|
||||
import { z } from "zod";
|
||||
|
||||
export const WebhookChannelConfigSchema = z.object({
|
||||
webhookUrl: z.string().url("Must be a valid URL"),
|
||||
webhookSecretHeader: z.string().optional(),
|
||||
webhookSecret: z.string().optional(),
|
||||
webhookHeaders: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string().min(1, "Header name is required"),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export type WebhookChannelConfig = z.infer<typeof WebhookChannelConfigSchema>;
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
import {
|
||||
NotifierWebhookForm
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/webhook.form";
|
||||
import {
|
||||
NotifierNextcloudForm
|
||||
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/nextcloud.form";
|
||||
import {
|
||||
notificationProviders,
|
||||
} from "@/components/wrappers/dashboard/admin/channels/helpers/notification";
|
||||
@@ -87,6 +90,8 @@ export const renderChannelForm = (provider: string | undefined, form: UseFormRet
|
||||
return <NotifierNtfyForm form={form}/>;
|
||||
case "webhook":
|
||||
return <NotifierWebhookForm form={form}/>;
|
||||
case "nextcloud":
|
||||
return <NotifierNextcloudForm form={form}/>;
|
||||
case "s3":
|
||||
return <StorageS3Form form={form}/>
|
||||
case "google-drive":
|
||||
|
||||
@@ -13,6 +13,7 @@ export const notificationProviders: ProviderIconTypes[] = [
|
||||
{value: "gotify", label: "Gotify", icon: GotifyIcon},
|
||||
{value: "ntfy", label: "ntfy.sh", icon: NtfyIcon},
|
||||
{value: "webhook", label: "Webhook", icon: WebhookIcon},
|
||||
{value: "nextcloud", label: "Nextcloud Talk", icon: NextcloudIcon},
|
||||
{value: "microsoft-teams", label: "Microsoft Teams", icon: MSTeamsIcon, preview: true}
|
||||
]
|
||||
|
||||
@@ -530,3 +531,28 @@ export function MSTeamsIcon(props: SVGProps<SVGSVGElement>) {
|
||||
</svg>)
|
||||
}
|
||||
|
||||
export function NextcloudIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlSpace="preserve"
|
||||
viewBox="0 0 512 512"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M512 80.5v352c0 44.2-35.8 80-80 80H80c-44.2 0-80-35.8-80-80v-352C0 36.3 35.8.5 80 .5h352c44.2 0 80 35.8 80 80"
|
||||
style={{
|
||||
fillRule: "evenodd",
|
||||
clipRule: "evenodd",
|
||||
fill: "#0082c9",
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d="M256 128.5c-70.4 0-127.5 57.1-127.5 127.5S185.6 383.5 256 383.5c23.3 0 46.2-6.5 66.1-18.6 15.7 6.2 50.8 24.7 59.1 16.7 8.7-8.3-10.2-47.6-14.8-62.1 11.1-19.3 17-41.1 17-63.4.1-70.5-57-127.6-127.4-127.6m0 48.5c43.7 0 79 35.4 79 79 0 43.7-35.4 79-79 79s-79-35.4-79-79 35.3-79 79-79"
|
||||
style={{
|
||||
fill: "#fff",
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TYPE "public"."provider_kind" ADD VALUE 'nextcloud';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -393,6 +393,13 @@
|
||||
"when": 1779342014802,
|
||||
"tag": "0055_yielding_justin_hammer",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 56,
|
||||
"version": "7",
|
||||
"when": 1779380470656,
|
||||
"tag": "0056_lazy_cyclops",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import {z} from "zod";
|
||||
import {OrganizationInvitation} from "@/db/schema/05_invitation";
|
||||
|
||||
|
||||
export const providerKindEnum = pgEnum('provider_kind', ['slack', 'smtp', 'discord', 'telegram', 'gotify', 'ntfy', 'webhook']);
|
||||
export const providerKindEnum = pgEnum('provider_kind', ['slack', 'smtp', 'discord', 'telegram', 'gotify', 'ntfy', 'webhook', 'nextcloud']);
|
||||
|
||||
export const notificationChannel = pgTable('notification_channel', {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {sendTelegram} from "@/features/notifications/providers/telegram";
|
||||
import {sendGotify} from "@/features/notifications/providers/gotify";
|
||||
import {sendNtfy} from "@/features/notifications/providers/ntfy";
|
||||
import {sendWebhook} from "@/features/notifications/providers/webhook";
|
||||
import {sendNextcloud} from "@/features/notifications/providers/nextcloud";
|
||||
|
||||
const handlers: Record<
|
||||
ProviderKind,
|
||||
@@ -18,7 +19,8 @@ const handlers: Record<
|
||||
telegram: sendTelegram,
|
||||
gotify: sendGotify,
|
||||
ntfy: sendNtfy,
|
||||
webhook: sendWebhook
|
||||
webhook: sendWebhook,
|
||||
nextcloud: sendNextcloud,
|
||||
};
|
||||
|
||||
export async function dispatchViaProvider(
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createHmac, randomBytes } from "crypto";
|
||||
import type { EventPayload, DispatchResult } from "../types";
|
||||
|
||||
type NextcloudConfig = {
|
||||
nextcloudUrl: string;
|
||||
nextcloudBotToken: string;
|
||||
nextcloudBotSecret: string;
|
||||
};
|
||||
|
||||
function formatPayloadData(data: unknown): string {
|
||||
if (!data) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof data === "string") {
|
||||
return data;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(data, null, 2);
|
||||
} catch {
|
||||
return String(data);
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendNextcloud(
|
||||
config: NextcloudConfig,
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const {
|
||||
nextcloudUrl,
|
||||
nextcloudBotToken,
|
||||
nextcloudBotSecret,
|
||||
} = config;
|
||||
|
||||
const payloadData = formatPayloadData(payload.data);
|
||||
|
||||
const messageParts = [
|
||||
`[${payload.level.toUpperCase()}] ${payload.title}`,
|
||||
payload.message,
|
||||
];
|
||||
|
||||
if (payloadData) {
|
||||
messageParts.push(`Payload:\n${payloadData}`);
|
||||
}
|
||||
|
||||
const message = messageParts.join("\n\n");
|
||||
|
||||
const random = randomBytes(32).toString("hex");
|
||||
|
||||
const signature = createHmac("sha256", nextcloudBotSecret)
|
||||
.update(random + message)
|
||||
.digest("hex");
|
||||
|
||||
const baseUrl = nextcloudUrl.replace(/\/$/, "");
|
||||
|
||||
const res = await fetch(
|
||||
`${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${nextcloudBotToken}/message`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"OCS-APIRequest": "true",
|
||||
"X-Nextcloud-Talk-Bot-Random": random,
|
||||
"X-Nextcloud-Talk-Bot-Signature": signature,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
|
||||
throw new Error(
|
||||
`Nextcloud error: ${res.status} ${err}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
provider: "nextcloud",
|
||||
message: "Sent to Nextcloud Talk",
|
||||
response: await res.text(),
|
||||
};
|
||||
}
|
||||
@@ -1,24 +1,38 @@
|
||||
import type {EventPayload, DispatchResult} from '../types';
|
||||
import type { EventPayload, DispatchResult } from '../types';
|
||||
|
||||
type WebhookConfig = {
|
||||
webhookUrl: string;
|
||||
webhookHeaders?: { key: string; value: string }[];
|
||||
webhookSecret?: string;
|
||||
webhookSecretHeader?: string;
|
||||
};
|
||||
|
||||
export async function sendWebhook(
|
||||
config: { webhookUrl: string; webhookSecret?: string; webhookSecretHeader?: string },
|
||||
config: WebhookConfig,
|
||||
payload: EventPayload
|
||||
): Promise<DispatchResult> {
|
||||
const {webhookUrl, webhookSecret, webhookSecretHeader} = config;
|
||||
const { webhookUrl, webhookHeaders, webhookSecret, webhookSecretHeader } = config;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Portabase-Notifier/1.0'
|
||||
'User-Agent': 'Portabase-Notifier/1.0',
|
||||
};
|
||||
|
||||
if (webhookSecret) {
|
||||
// New format: iterate custom headers array
|
||||
if (webhookHeaders && webhookHeaders.length > 0) {
|
||||
const RESERVED = new Set(['content-type', 'user-agent']);
|
||||
for (const { key, value } of webhookHeaders) {
|
||||
if (key && !RESERVED.has(key.toLowerCase())) headers[key] = value;
|
||||
}
|
||||
} else if (webhookSecret) {
|
||||
// Legacy format: single secret header pair
|
||||
headers[webhookSecretHeader || 'X-Webhook-Secret'] = webhookSecret;
|
||||
}
|
||||
|
||||
const res = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
headers: headers,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ProviderKind = 'slack' | 'smtp' | 'discord' | 'telegram' | 'gotify' | 'ntfy' | 'webhook';
|
||||
export type ProviderKind = 'slack' | 'smtp' | 'discord' | 'telegram' | 'gotify' | 'ntfy' | 'webhook' | 'nextcloud';
|
||||
|
||||
export interface DispatchResult {
|
||||
success: boolean;
|
||||
|
||||
Reference in New Issue
Block a user