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:
Charles GTE
2026-05-21 19:45:08 +02:00
committed by GitHub
co-authored by Claude Sonnet 4.6 charles-gauthereau
parent 2cd5bda416
commit af3df7f72a
16 changed files with 3017 additions and 76 deletions
+4 -2
View File
@@ -31,8 +31,9 @@ const invalidChannelName = "Webhook E2E Invalid";
// await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible(); // await expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
// await create(page, "Webhook", optionalChannelName, async (page) => { // await create(page, "Webhook", optionalChannelName, async (page) => {
// await page.getByLabel(/Webhook URL/).fill(getEnv("E2E_NOTIFICATION_WEBHOOK_URL")); // 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(/^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 submit(page);
// await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible(); // 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 expect(page.getByRole("heading", {name: "Notification channels"})).toBeVisible();
await create(page, "Webhook", invalidChannelName, async (page) => { await create(page, "Webhook", invalidChannelName, async (page) => {
await page.getByLabel(/Webhook URL/).fill("https://webhook.example.com/api/wrong-webhook"); 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(/^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 submit(page);
await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible(); await expect(page.getByText("Notification channel has been successfully created.")).toBeVisible();
@@ -6,6 +6,7 @@ import {TelegramChannelConfigSchema} from "./providers/notifications/forms/teleg
import {GotifyChannelConfigSchema} from "./providers/notifications/forms/gotify.schema"; import {GotifyChannelConfigSchema} from "./providers/notifications/forms/gotify.schema";
import {NtfyChannelConfigSchema} from "./providers/notifications/forms/ntfy.schema"; import {NtfyChannelConfigSchema} from "./providers/notifications/forms/ntfy.schema";
import {WebhookChannelConfigSchema} from "./providers/notifications/forms/webhook.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 {S3ChannelConfigSchema} from "./providers/storages/forms/s3.schema";
import {GoogleDriveChannelConfigSchema} from "./providers/storages/forms/google-drive.schema"; import {GoogleDriveChannelConfigSchema} from "./providers/storages/forms/google-drive.schema";
import {LocalChannelConfigSchema} from "./providers/storages/forms/local.schema"; import {LocalChannelConfigSchema} from "./providers/storages/forms/local.schema";
@@ -48,6 +49,10 @@ export const NotificationChannelFormSchema = z.discriminatedUnion("provider", [
provider: z.literal("webhook"), provider: z.literal("webhook"),
config: WebhookChannelConfigSchema, config: WebhookChannelConfigSchema,
}), }),
BaseChannelFormSchema.extend({
provider: z.literal("nextcloud"),
config: NextcloudChannelConfigSchema,
}),
]); ]);
export const StorageChannelFormSchema = z.discriminatedUnion("provider", [ export const StorageChannelFormSchema = z.discriminatedUnion("provider", [
@@ -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>
)}
/>
</>
);
};
@@ -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"),
});
@@ -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 { import {
FormControl, FormControl,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator"; 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 = { type NotifierWebhookFormProps = {
form: UseFormReturn<any, any, any>; form: UseFormReturn<any, any, any>;
}; };
export const NotifierWebhookForm = ({ form }: NotifierWebhookFormProps) => { export const NotifierWebhookForm = ({ form }: NotifierWebhookFormProps) => {
return ( const { fields, append, remove } = useFieldArray({
<> control: form.control,
<Separator className="my-1" /> name: "config.webhookHeaders",
<FormField });
control={form.control}
name="config.webhookUrl" return (
render={({ field }) => ( <>
<FormItem> <Separator className="my-1" />
<FormLabel>Webhook URL *</FormLabel> <FormField
<FormControl> control={form.control}
<Input {...field} placeholder="e.g. https://example.com/api/webhook" /> name="config.webhookUrl"
</FormControl> render={({ field }) => (
<FormMessage /> <FormItem>
</FormItem> <FormLabel>Webhook URL *</FormLabel>
)} <FormControl>
/> <Input
<div className="flex gap-4"> {...field}
<div className="flex-1"> placeholder="e.g. https://example.com/api/webhook"
<FormField />
control={form.control} </FormControl>
name="config.webhookSecretHeader" <FormMessage />
render={({ field }) => ( </FormItem>
<FormItem> )}
<FormLabel>Header Name</FormLabel> />
<FormControl>
<Input {...field} placeholder="e.g. X-Webhook-Secret" /> <div className="space-y-3">
</FormControl> <div className="flex items-center justify-between">
<FormMessage /> <Label className="text-sm font-medium">Custom Headers</Label>
</FormItem> <Button
)} type="button"
/> variant="outline"
</div> size="sm"
<div className="flex-1"> onClick={() => append({ key: "", value: "" })}
<FormField >
control={form.control} <Plus className="w-4 h-4 mr-2" />
name="config.webhookSecret" Add Header
render={({ field }) => ( </Button>
<FormItem> </div>
<FormLabel>Secret Value</FormLabel>
<FormControl> {fields.length === 0 && (
<PasswordInput {...field} placeholder="e.g. webhook-secret" /> <p className="text-xs text-muted-foreground">
</FormControl> No custom headers. Add headers to send with each webhook request.
<FormMessage /> </p>
</FormItem> )}
)}
/> <div className="space-y-2">
</div> {fields.map((headerField, index) => (
</div> <div key={headerField.id} className="flex gap-2">
<p className="text-xs text-muted-foreground mt-2"> <div className="flex-1">
If provided, the secret will be sent in the specified header (defaults <FormField
to <code>X-Webhook-Secret</code>). control={form.control}
</p> 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>
</>
);
}; };
@@ -1,7 +1,16 @@
import {z} from "zod"; import { z } from "zod";
export const WebhookChannelConfigSchema = z.object({ export const WebhookChannelConfigSchema = z.object({
webhookUrl: z.string().url("Must be a valid URL"), webhookUrl: z.string().url("Must be a valid URL"),
webhookSecretHeader: z.string().optional(), webhookHeaders: z
webhookSecret: z.string().optional(), .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 { import {
NotifierWebhookForm NotifierWebhookForm
} from "@/components/wrappers/dashboard/admin/channels/channel/channel-form/providers/notifications/forms/webhook.form"; } 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 { import {
notificationProviders, notificationProviders,
} from "@/components/wrappers/dashboard/admin/channels/helpers/notification"; } from "@/components/wrappers/dashboard/admin/channels/helpers/notification";
@@ -87,6 +90,8 @@ export const renderChannelForm = (provider: string | undefined, form: UseFormRet
return <NotifierNtfyForm form={form}/>; return <NotifierNtfyForm form={form}/>;
case "webhook": case "webhook":
return <NotifierWebhookForm form={form}/>; return <NotifierWebhookForm form={form}/>;
case "nextcloud":
return <NotifierNextcloudForm form={form}/>;
case "s3": case "s3":
return <StorageS3Form form={form}/> return <StorageS3Form form={form}/>
case "google-drive": case "google-drive":
@@ -13,6 +13,7 @@ export const notificationProviders: ProviderIconTypes[] = [
{value: "gotify", label: "Gotify", icon: GotifyIcon}, {value: "gotify", label: "Gotify", icon: GotifyIcon},
{value: "ntfy", label: "ntfy.sh", icon: NtfyIcon}, {value: "ntfy", label: "ntfy.sh", icon: NtfyIcon},
{value: "webhook", label: "Webhook", icon: WebhookIcon}, {value: "webhook", label: "Webhook", icon: WebhookIcon},
{value: "nextcloud", label: "Nextcloud Talk", icon: NextcloudIcon},
{value: "microsoft-teams", label: "Microsoft Teams", icon: MSTeamsIcon, preview: true} {value: "microsoft-teams", label: "Microsoft Teams", icon: MSTeamsIcon, preview: true}
] ]
@@ -530,3 +531,28 @@ export function MSTeamsIcon(props: SVGProps<SVGSVGElement>) {
</svg>) </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>
);
}
+1
View File
@@ -0,0 +1 @@
ALTER TYPE "public"."provider_kind" ADD VALUE 'nextcloud';
File diff suppressed because it is too large Load Diff
+7
View File
@@ -393,6 +393,13 @@
"when": 1779342014802, "when": 1779342014802,
"tag": "0055_yielding_justin_hammer", "tag": "0055_yielding_justin_hammer",
"breakpoints": true "breakpoints": true
},
{
"idx": 56,
"version": "7",
"when": 1779380470656,
"tag": "0056_lazy_cyclops",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -7,7 +7,7 @@ import {z} from "zod";
import {OrganizationInvitation} from "@/db/schema/05_invitation"; 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', { export const notificationChannel = pgTable('notification_channel', {
id: uuid("id").defaultRandom().primaryKey(), id: uuid("id").defaultRandom().primaryKey(),
@@ -7,6 +7,7 @@ import {sendTelegram} from "@/features/notifications/providers/telegram";
import {sendGotify} from "@/features/notifications/providers/gotify"; import {sendGotify} from "@/features/notifications/providers/gotify";
import {sendNtfy} from "@/features/notifications/providers/ntfy"; import {sendNtfy} from "@/features/notifications/providers/ntfy";
import {sendWebhook} from "@/features/notifications/providers/webhook"; import {sendWebhook} from "@/features/notifications/providers/webhook";
import {sendNextcloud} from "@/features/notifications/providers/nextcloud";
const handlers: Record< const handlers: Record<
ProviderKind, ProviderKind,
@@ -18,7 +19,8 @@ const handlers: Record<
telegram: sendTelegram, telegram: sendTelegram,
gotify: sendGotify, gotify: sendGotify,
ntfy: sendNtfy, ntfy: sendNtfy,
webhook: sendWebhook webhook: sendWebhook,
nextcloud: sendNextcloud,
}; };
export async function dispatchViaProvider( 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( export async function sendWebhook(
config: { webhookUrl: string; webhookSecret?: string; webhookSecretHeader?: string }, config: WebhookConfig,
payload: EventPayload payload: EventPayload
): Promise<DispatchResult> { ): Promise<DispatchResult> {
const {webhookUrl, webhookSecret, webhookSecretHeader} = config; const { webhookUrl, webhookHeaders, webhookSecret, webhookSecretHeader } = config;
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', '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; headers[webhookSecretHeader || 'X-Webhook-Secret'] = webhookSecret;
} }
const res = await fetch(webhookUrl, { const res = await fetch(webhookUrl, {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
headers: headers, headers,
}); });
if (!res.ok) { if (!res.ok) {
+1 -1
View File
@@ -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 { export interface DispatchResult {
success: boolean; success: boolean;