diff --git a/backend/internal/hub/models/notifications.go b/backend/internal/hub/models/notifications.go index b09c693..875edaa 100644 --- a/backend/internal/hub/models/notifications.go +++ b/backend/internal/hub/models/notifications.go @@ -16,6 +16,7 @@ const ( NotificationTypeDiscord NotificationType = "discord" NotificationTypeGotify NotificationType = "gotify" NotificationTypeSlack NotificationType = "slack" + NotificationTypeTeams NotificationType = "teams" NotificationTypeWebhook NotificationType = "webhook" NotificationTypeCustom NotificationType = "custom" ) diff --git a/backend/internal/hub/notifications/provider/provider.go b/backend/internal/hub/notifications/provider/provider.go index 5b40cb0..e1d8b22 100644 --- a/backend/internal/hub/notifications/provider/provider.go +++ b/backend/internal/hub/notifications/provider/provider.go @@ -16,6 +16,7 @@ func init() { Register(models.NotificationTypeDiscord, DiscordProvider{}) Register(models.NotificationTypeGotify, GotifyProvider{}) Register(models.NotificationTypeSlack, SlackProvider{}) + Register(models.NotificationTypeTeams, TeamsProvider{}) Register(models.NotificationTypeWebhook, WebhookProvider{}) Register(models.NotificationTypeCustom, CustomProvider{}) } diff --git a/backend/internal/hub/notifications/provider/teams.go b/backend/internal/hub/notifications/provider/teams.go new file mode 100644 index 0000000..ade5162 --- /dev/null +++ b/backend/internal/hub/notifications/provider/teams.go @@ -0,0 +1,58 @@ +package provider + +import ( + "errors" + "fmt" + "net/url" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/services/chat/teams" +) + +type TeamsProvider struct{} + +type teamsConfig struct { + Host string `json:"host"` + Title string `json:"title"` +} + +func (TeamsProvider) BuildShoutrrrUrls(rawConfig string) ([]string, error) { + trimmed, err := normalizeRawConfig(rawConfig) + if err != nil { + return nil, err + } + + cfg, err := decodeConfigJSON[teamsConfig](trimmed, "invalid JSON teams config") + if err != nil { + return nil, err + } + + host := strings.TrimSpace(cfg.Host) + if host == "" { + return nil, errors.New("teams config requires host") + } + + webhookURL, err := url.Parse(host) + if err != nil { + return nil, fmt.Errorf("invalid teams host: %w", err) + } + if webhookURL.Scheme != "https" { + return nil, errors.New("teams host must be a valid HTTPS Teams webhook URL") + } + + serviceConfig, err := teams.ConfigFromWebhookURL(webhookURL) + if err != nil { + return nil, fmt.Errorf("invalid teams host: %w", err) + } + + if title := strings.TrimSpace(cfg.Title); title != "" { + serviceConfig.Title = title + } + + serviceURL := serviceConfig.GetURL() + if serviceURL == nil { + return nil, errors.New("invalid teams host") + } + + return []string{serviceURL.String()}, nil +} diff --git a/backend/internal/hub/notifications/provider/teams_test.go b/backend/internal/hub/notifications/provider/teams_test.go new file mode 100644 index 0000000..245462f --- /dev/null +++ b/backend/internal/hub/notifications/provider/teams_test.go @@ -0,0 +1,146 @@ +package provider + +import ( + "net/url" + "strings" + "testing" + + "github.com/nicholas-fedor/shoutrrr" +) + +const validTeamsWebhookURL = "https://contoso.webhook.office.com/webhookb2/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05" + +func TestTeamsProviderBuildShoutrrrUrls(t *testing.T) { + tests := []struct { + name string + raw string + wantErr string + }{ + { + name: "empty config", + raw: " \n ", + wantErr: "notification config is empty", + }, + { + name: "invalid json object", + raw: "{", + wantErr: "invalid JSON teams config", + }, + { + name: "missing host", + raw: `{"title":"Deploy done"}`, + wantErr: "teams config requires host", + }, + { + name: "non-https scheme must be rejected", + raw: `{"host":"http://contoso.webhook.office.com/webhookb2/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05"}`, + wantErr: "teams host must be a valid HTTPS Teams webhook URL", + }, + { + name: "invalid webhook domain", + raw: `{"host":"https://example.com/webhookb2/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05"}`, + wantErr: "invalid teams host", + }, + { //nolint:gosec + name: "direct target string instead of json", + raw: "teams://11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05?host=contoso.webhook.office.com", + wantErr: "invalid JSON teams config", + }, + { //nolint:gosec + name: "json array instead of object", + raw: `[{"host":"` + validTeamsWebhookURL + `"}]`, + wantErr: "invalid JSON teams config", + }, + } + + provider := TeamsProvider{} + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := provider.BuildShoutrrrUrls(tt.raw) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + return + } + + if err != nil { + t.Fatalf("BuildShoutrrrUrls() error = %v", err) + } + if len(got) != 1 { + t.Fatalf("expected one URL, got %v", got) + } + if !strings.HasPrefix(got[0], "teams://") { + t.Fatalf("expected teams URL, got %q", got[0]) + } + }) + } +} + +func TestTeamsProviderBuildsStructuredURL(t *testing.T) { + provider := TeamsProvider{} + + urls, err := provider.BuildShoutrrrUrls(`{"host":"` + validTeamsWebhookURL + `","title":"Deploy done"}`) + if err != nil { + t.Fatalf("BuildShoutrrrUrls() error = %v", err) + } + if len(urls) != 1 { + t.Fatalf("expected 1 URL, got %d", len(urls)) + } + + parsed, err := url.Parse(urls[0]) + if err != nil { + t.Fatalf("failed to parse URL %q: %v", urls[0], err) + } + + if parsed.Scheme != "teams" { + t.Fatalf("expected scheme teams, got %q", parsed.Scheme) + } + if parsed.User == nil || parsed.User.Username() != "11111111-4444-4444-8444-cccccccccccc" { + t.Fatalf("expected group in URL user, got %v", parsed.User) + } + if parsed.Host != "22222222-4444-4444-8444-cccccccccccc" { + t.Fatalf("expected tenant in URL host, got %q", parsed.Host) + } + if parsed.Path != "/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05" { + t.Fatalf("expected webhook path components, got %q", parsed.Path) + } + + query := parsed.Query() + if query.Get("host") != "contoso.webhook.office.com" { + t.Fatalf("expected host query parameter, got %q", query.Get("host")) + } + if query.Get("title") != "Deploy done" { + t.Fatalf("expected title query parameter, got %q", query.Get("title")) + } + + if _, err := shoutrrr.CreateSender(urls...); err != nil { + t.Fatalf("expected Shoutrrr to accept Teams URL: %v", err) + } +} + +func TestTeamsProviderMinimalConfig(t *testing.T) { + provider := TeamsProvider{} + + urls, err := provider.BuildShoutrrrUrls(`{"host":"` + validTeamsWebhookURL + `"}`) + if err != nil { + t.Fatalf("BuildShoutrrrUrls() error = %v", err) + } + if len(urls) != 1 { + t.Fatalf("expected 1 URL, got %d", len(urls)) + } + + parsed, err := url.Parse(urls[0]) + if err != nil { + t.Fatalf("failed to parse URL %q: %v", urls[0], err) + } + + query := parsed.Query() + if query.Get("title") != "" { + t.Fatalf("expected no title, got %q", query.Get("title")) + } +} diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 7ec3020..8d40bf0 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -314,9 +314,14 @@ "notificationSlackAppSettingsNote": "Bot-Name und Symbol können in den Slack-App-Einstellungen unter api.slack.com konfiguriert werden.", "validationNotificationSlackWebhookUrlRequired": "Webhook-URL ist erforderlich", "validationNotificationSlackWebhookUrlInvalid": "Webhook-URL muss eine gültige Slack-Webhook-URL sein", + "title": "Titel", + "notificationTeamsHostPlaceholder": "https://contoso.webhook.office.com/webhookb2/...", + "notificationTeamsTitlePlaceholder": "z. B. \"Deploy status\"", + "validationNotificationTeamsHostRequired": "Webhook-URL ist erforderlich", + "validationNotificationTeamsHostInvalid": "Webhook-URL muss eine gültige eingehende Microsoft-Teams-Webhook-URL sein", "notificationGenericWebhookUrlPlaceholder": "https://api.example.com/webhook", "notificationWebhookHeadersPlaceholder": "Authorization: Bearer token\nX-Orca-Event: deployment", - "notificationCustomShoutrrrUrlPlaceholder": "discord://token@webhook-id?title=Deploy%20done", + "notificationCustomShoutrrrUrlPlaceholder": "discord://token@webhook-id?title=Deploy%20status", "shoutrrrUrl": "Shoutrrr-URL", "shoutrrrDocs": "Shoutrrr-Doku", "httpMethod": "HTTP-Methode", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 495aae7..6bd60a6 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -314,9 +314,14 @@ "notificationSlackAppSettingsNote": "The bot name and icon can be configured in your Slack app settings at api.slack.com.", "validationNotificationSlackWebhookUrlRequired": "Webhook URL is required", "validationNotificationSlackWebhookUrlInvalid": "Webhook URL must be a valid Slack webhook URL", + "title": "Title", + "notificationTeamsHostPlaceholder": "https://contoso.webhook.office.com/webhookb2/...", + "notificationTeamsTitlePlaceholder": "e.g. \"Deploy status\"", + "validationNotificationTeamsHostRequired": "Webhook URL is required", + "validationNotificationTeamsHostInvalid": "Webhook URL must be a valid Microsoft Teams incoming webhook URL", "notificationGenericWebhookUrlPlaceholder": "https://api.example.com/webhook", "notificationWebhookHeadersPlaceholder": "Authorization: Bearer token\nX-Orca-Event: deployment", - "notificationCustomShoutrrrUrlPlaceholder": "discord://token@webhook-id?title=Deploy%20done", + "notificationCustomShoutrrrUrlPlaceholder": "discord://token@webhook-id?title=Deploy%20status", "shoutrrrUrl": "Shoutrrr URL", "shoutrrrDocs": "Shoutrrr docs", "httpMethod": "HTTP Method", diff --git a/frontend/src/components/dialogs/create-notification.tsx b/frontend/src/components/dialogs/create-notification.tsx index f119e9d..8481b73 100644 --- a/frontend/src/components/dialogs/create-notification.tsx +++ b/frontend/src/components/dialogs/create-notification.tsx @@ -58,6 +58,12 @@ import { isValidSlackWebhookUrl, SlackWebhookUrlField, } from "./slack-notification-builder"; +import { + buildTeamsNotificationConfig, + isValidTeamsHost, + TeamsHostField, + TeamsTitleField, +} from "./teams-notification-builder"; import { buildGotifyNotificationConfig, GotifyAppTokenField, @@ -98,6 +104,8 @@ const notificationBaseSchema = z.object({ gotifyPriority: z.string().trim(), gotifyCustomPath: z.string().trim(), slackWebhookUrl: z.string().trim(), + teamsHost: z.string().trim(), + teamsTitle: z.string().trim(), webhookUrl: z.string().trim(), webhookHeaders: z.string(), customShoutrrrUrl: z.string().trim(), @@ -193,6 +201,22 @@ const notificationSchema = notificationBaseSchema.superRefine((value, ctx) => { } } + if (value.type === "teams") { + if (value.teamsHost === "") { + ctx.addIssue({ + code: "custom", + path: ["teamsHost"], + message: m.validationNotificationTeamsHostRequired(), + }); + } else if (!isValidTeamsHost(value.teamsHost)) { + ctx.addIssue({ + code: "custom", + path: ["teamsHost"], + message: m.validationNotificationTeamsHostInvalid(), + }); + } + } + if (value.type === "webhook") { if (value.webhookUrl === "") { ctx.addIssue({ @@ -262,6 +286,18 @@ function buildNotificationConfig(value: NotificationFormValues): string { return config; } + if (value.type === "teams") { + const config = buildTeamsNotificationConfig({ + teamsHost: value.teamsHost, + teamsTitle: value.teamsTitle, + }); + if (!config) { + throw new Error(m.validationNotificationTeamsHostInvalid()); + } + + return config; + } + if (value.type === "gotify") { const config = buildGotifyNotificationConfig({ gotifyServerUrl: value.gotifyServerUrl, @@ -317,6 +353,8 @@ function useNotificationForm() { gotifyPriority: "5", gotifyCustomPath: "", slackWebhookUrl: "", + teamsHost: "", + teamsTitle: "", webhookUrl: "", webhookHeaders: "", customShoutrrrUrl: "", @@ -558,6 +596,19 @@ function NotificationProviderStepContent({ form }: { form: NotificationFormApi } ); } + if (type === "teams") { + return ( + + } /> + + } + /> + + ); + } + if (type === "webhook") { return ( @@ -660,6 +711,8 @@ export default function CreateNotificationDialog() { gotifyPriority: "5", gotifyCustomPath: "", slackWebhookUrl: "", + teamsHost: "", + teamsTitle: "", webhookUrl: "", webhookHeaders: "", customShoutrrrUrl: "", diff --git a/frontend/src/components/dialogs/teams-notification-builder.tsx b/frontend/src/components/dialogs/teams-notification-builder.tsx new file mode 100644 index 0000000..818ea54 --- /dev/null +++ b/frontend/src/components/dialogs/teams-notification-builder.tsx @@ -0,0 +1,107 @@ +import { Field, FieldError } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { m } from "@/lib/paraglide/messages"; + +type TeamsBuilderFieldBinding = { + name: string; + state: { + value: string; + meta: { + isTouched: boolean; + isValid: boolean; + errors: unknown[]; + }; + }; + handleBlur: () => void; + handleChange: (value: string) => void; +}; + +type FieldErrorList = Array<{ message?: string } | undefined>; + +export function TeamsHostField({ field }: { field: TeamsBuilderFieldBinding }) { + const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; + + return ( + + + field.handleChange(event.target.value)} + placeholder={m.notificationTeamsHostPlaceholder()} + /> + {isInvalid && } + + ); +} + +export function TeamsTitleField({ field }: { field: TeamsBuilderFieldBinding }) { + const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; + + return ( + + + field.handleChange(event.target.value)} + placeholder={m.notificationTeamsTitlePlaceholder()} + /> + {isInvalid && } + + ); +} + +export type TeamsNotificationConfig = { + host: string; + title?: string; +}; + +export type TeamsNotificationBuilderValues = { + teamsHost: string; + teamsTitle: string; +}; + +export function isValidTeamsHost(rawUrl: string): boolean { + let parsed: URL; + try { + parsed = new URL(rawUrl.trim()); + } catch { + return false; + } + + if (parsed.protocol !== "https:") { + return false; + } + + if (!/^[a-z0-9.-]+\.webhook\.office\.com$/i.test(parsed.hostname)) { + return false; + } + + const pattern = + /^\/webhookb2\/[0-9a-f-]{36}@[0-9a-f-]{36}\/IncomingWebhook\/[0-9a-f]{32}\/[0-9a-f-]{36}\/[A-Za-z0-9_-]+\/?$/i; + return pattern.test(parsed.pathname); +} + +export function buildTeamsNotificationConfig( + values: TeamsNotificationBuilderValues, +): string | null { + const host = values.teamsHost.trim(); + if (!isValidTeamsHost(host)) { + return null; + } + + const config: TeamsNotificationConfig = { host }; + const title = values.teamsTitle.trim(); + if (title !== "") { + config.title = title; + } + + return JSON.stringify(config); +} diff --git a/frontend/src/lib/notifications.ts b/frontend/src/lib/notifications.ts index 757d1ab..3cf07c4 100644 --- a/frontend/src/lib/notifications.ts +++ b/frontend/src/lib/notifications.ts @@ -1,7 +1,14 @@ import { fetcher } from "./api"; export type NotificationStatus = "unknown" | "success" | "error" | "healthy" | "unhealthy"; -export const notificationTypes = ["discord", "gotify", "slack", "webhook", "custom"] as const; +export const notificationTypes = [ + "discord", + "gotify", + "slack", + "teams", + "webhook", + "custom", +] as const; export type NotificationType = (typeof notificationTypes)[number]; export interface Notification {