From 07ce79b43923564add10af938f74bf6ed4e5f60a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:37:02 +0000 Subject: [PATCH 01/10] fix(deps): update non-major-updates --- frontend/package-lock.json | 20 ++++++++++---------- frontend/package.json | 6 +++--- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8a4949e7..8e11104c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,14 +19,14 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", - "i18next": "^25.10.8", + "i18next": "^25.10.9", "i18next-browser-languagedetector": "^8.2.1", - "lucide-react": "^1.0.1", + "lucide-react": "^1.6.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-hook-form": "^7.72.0", "react-hot-toast": "^2.6.0", - "react-i18next": "^16.6.5", + "react-i18next": "^16.6.6", "react-router-dom": "^7.13.2", "tailwind-merge": "^3.5.0", "tldts": "^7.0.27" @@ -8160,9 +8160,9 @@ } }, "node_modules/lucide-react": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.0.1.tgz", - "integrity": "sha512-lih7tKEczCYOQjVEzpFuxEuNzlwf+1yhvlMlEkGWJM3va8Pugv8bYXc/pRtcjPncaP7k84X0Pt/71ufxvqEPtQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.6.0.tgz", + "integrity": "sha512-YxLKVCOF5ZDI1AhKQE5IBYMY9y/Nr4NT15+7QEWpsTSVCdn4vmZhww+6BP76jWYjQx8rSz1Z+gGme1f+UycWEw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -9767,9 +9767,9 @@ } }, "node_modules/react-i18next": { - "version": "16.6.5", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.5.tgz", - "integrity": "sha512-bfdJhmyjQCXtU9CLcGMn3a1V5/jTeUX/x29cOhlS1Lolm/epRtm24gnYsltxArsc29ow3klSJEijjfYXc5kxjg==", + "version": "16.6.6", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.6.tgz", + "integrity": "sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.29.2", @@ -9777,7 +9777,7 @@ "use-sync-external-store": "^1.6.0" }, "peerDependencies": { - "i18next": ">= 25.6.2", + "i18next": ">= 25.10.9", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, diff --git a/frontend/package.json b/frontend/package.json index e18a45f6..56faffa8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,14 +38,14 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", - "i18next": "^25.10.8", + "i18next": "^25.10.9", "i18next-browser-languagedetector": "^8.2.1", - "lucide-react": "^1.0.1", + "lucide-react": "^1.6.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-hook-form": "^7.72.0", "react-hot-toast": "^2.6.0", - "react-i18next": "^16.6.5", + "react-i18next": "^16.6.6", "react-router-dom": "^7.13.2", "tailwind-merge": "^3.5.0", "tldts": "^7.0.27" From d90dc5af9820f63958c2b9d498b620550e84d15d Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 24 Mar 2026 20:10:02 +0000 Subject: [PATCH 02/10] fix(deps): update go-toml to v2.3.0 for improved compatibility --- backend/go.mod | 2 +- backend/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/go.mod b/backend/go.mod index 1bc37480..44a2e22b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -70,7 +70,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.3.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect diff --git a/backend/go.sum b/backend/go.sum index 500ab1c9..5c30b306 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -130,8 +130,8 @@ github.com/oschwald/geoip2-golang/v2 v2.1.0 h1:DjnLhNJu9WHwTrmoiQFvgmyJoczhdnm7L github.com/oschwald/geoip2-golang/v2 v2.1.0/go.mod h1:qdVmcPgrTJ4q2eP9tHq/yldMTdp2VMr33uVdFbHBiBc= github.com/oschwald/maxminddb-golang/v2 v2.1.1 h1:lA8FH0oOrM4u7mLvowq8IT6a3Q/qEnqRzLQn9eH5ojc= github.com/oschwald/maxminddb-golang/v2 v2.1.1/go.mod h1:PLdx6PR+siSIoXqqy7C7r3SB3KZnhxWr1Dp6g0Hacl8= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= From 5a2b6fec9db901da2170dfbf9bcef9827ab14147 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 24 Mar 2026 20:25:38 +0000 Subject: [PATCH 03/10] fix(deps): update katex to v0.16.42 for improved functionality --- frontend/package-lock.json | 12 ++++++------ package-lock.json | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8e11104c..17dd8f70 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4890,9 +4890,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7780,9 +7780,9 @@ } }, "node_modules/knip": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/knip/-/knip-6.0.4.tgz", - "integrity": "sha512-r/9F7wcxiFM71WgDFQiToE2hQHwZ/UkGmr74o8eiNFPIg80f7rlQHVrZiRX46Tj2yE3s96wUVNGMnsDMylgInw==", + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/knip/-/knip-6.0.5.tgz", + "integrity": "sha512-+i9e/ZKuYlECB5iIK82NQwnYso4oNLBhzsTbXhSqCG1qfGi6D84GNtRENafmS3C0lABX8Wf3BKM434nPXi2AbQ==", "dev": true, "funding": [ { diff --git a/package-lock.json b/package-lock.json index ea6a44c2..8a0fb3d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1907,9 +1907,9 @@ } }, "node_modules/katex": { - "version": "0.16.40", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.40.tgz", - "integrity": "sha512-1DJcK/L05k1Y9Gf7wMcyuqFOL6BiY3vY0CFcAM/LPRN04NALxcl6u7lOWNsp3f/bCHWxigzQl6FbR95XJ4R84Q==", + "version": "0.16.42", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.42.tgz", + "integrity": "sha512-sZ4jqyEXfHTLEFK+qsFYToa3UZ0rtFcPGwKpyiRYh2NJn8obPWOQ+/u7ux0F6CAU/y78+Mksh1YkxTPXTh47TQ==", "dev": true, "funding": [ "https://opencollective.com/katex", From 86023788aad33972f1ef09f0cef5222d727208cf Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Tue, 24 Mar 2026 21:04:54 +0000 Subject: [PATCH 04/10] feat: add support for Ntfy notification provider - Updated the list of supported notification provider types to include 'ntfy'. - Modified the notification settings UI to accommodate the Ntfy provider, including form fields for topic URL and access token. - Enhanced localization files to include translations for Ntfy-related fields in German, English, Spanish, French, and Chinese. - Implemented tests for the Ntfy notification provider, covering form rendering, CRUD operations, payload contracts, and security measures. - Updated existing tests to account for the new Ntfy provider in various scenarios. --- CHANGELOG.md | 3 + backend/internal/api/handlers/auth_handler.go | 10 +- .../handlers/notification_provider_handler.go | 6 +- .../internal/notifications/feature_flags.go | 1 + .../internal/notifications/http_wrapper.go | 9 +- .../notifications/http_wrapper_test.go | 8 +- backend/internal/notifications/router.go | 2 + backend/internal/notifications/router_test.go | 20 +- .../internal/services/notification_service.go | 22 +- .../notification_service_json_test.go | 93 +++ .../services/notification_service_test.go | 28 + docs/features.md | 2 +- docs/features/notifications.md | 47 ++ ...fy-notification-provider-manual-testing.md | 98 +++ docs/plans/current_spec.md | 660 +++++++++++++---- docs/reports/qa_report_ntfy_notifications.md | 172 +++++ frontend/src/api/notifications.ts | 4 +- ...SecurityNotificationSettingsModal.test.tsx | 2 +- frontend/src/locales/de/translation.json | 7 +- frontend/src/locales/en/translation.json | 7 +- frontend/src/locales/es/translation.json | 7 +- frontend/src/locales/fr/translation.json | 7 +- frontend/src/locales/zh/translation.json | 7 +- frontend/src/pages/Notifications.tsx | 20 +- .../pages/__tests__/Notifications.test.tsx | 6 +- tests/settings/notifications-payload.spec.ts | 17 +- tests/settings/notifications.spec.ts | 4 +- .../ntfy-notification-provider.spec.ts | 681 ++++++++++++++++++ 28 files changed, 1769 insertions(+), 181 deletions(-) create mode 100644 docs/issues/ntfy-notification-provider-manual-testing.md create mode 100644 docs/reports/qa_report_ntfy_notifications.md create mode 100644 tests/settings/ntfy-notification-provider.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 780670df..edcc6bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Notifications:** Added Ntfy notification provider with support for self-hosted and cloud instances, optional Bearer token authentication, and JSON template customization + - **Certificate Deletion**: Clean up expired and unused certificates directly from the Certificates page - Expired Let's Encrypt certificates not attached to any proxy host can now be deleted - Custom and staging certificates remain deletable when not in use @@ -55,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Notifications:** Fixed Pushover token-clearing bug where tokens were silently stripped on provider create/update - **TCP Monitor Creation**: Fixed misleading form UX that caused silent HTTP 500 errors when creating TCP monitors - Corrected URL placeholder to show `host:port` format instead of the incorrect `tcp://host:port` prefix - Added dynamic per-type placeholder and helper text (HTTP monitors show a full URL example; TCP monitors show `host:port`) diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go index 404f0ea7..9eeb4847 100644 --- a/backend/internal/api/handlers/auth_handler.go +++ b/backend/internal/api/handlers/auth_handler.go @@ -126,11 +126,11 @@ func isLocalRequest(c *gin.Context) bool { } // setSecureCookie sets an auth cookie with security best practices -// - HttpOnly: prevents JavaScript access (XSS protection) -// - Secure: always true (all major browsers honour Secure on localhost HTTP; -// HTTP-on-private-IP without TLS is an unsupported deployment) -// - SameSite: Lax for any local/private-network request (regardless of scheme), -// Strict otherwise (public HTTPS only) +// - HttpOnly: prevents JavaScript access (XSS protection) +// - Secure: always true (all major browsers honour Secure on localhost HTTP; +// HTTP-on-private-IP without TLS is an unsupported deployment) +// - SameSite: Lax for any local/private-network request (regardless of scheme), +// Strict otherwise (public HTTPS only) func setSecureCookie(c *gin.Context, name, value string, maxAge int) { scheme := requestScheme(c) sameSite := http.SameSiteStrictMode diff --git a/backend/internal/api/handlers/notification_provider_handler.go b/backend/internal/api/handlers/notification_provider_handler.go index 47e3250c..8b16cf2f 100644 --- a/backend/internal/api/handlers/notification_provider_handler.go +++ b/backend/internal/api/handlers/notification_provider_handler.go @@ -182,7 +182,7 @@ func (h *NotificationProviderHandler) Create(c *gin.Context) { } providerType := strings.ToLower(strings.TrimSpace(req.Type)) - if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" && providerType != "pushover" { + if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" && providerType != "pushover" && providerType != "ntfy" { respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type") return } @@ -242,12 +242,12 @@ func (h *NotificationProviderHandler) Update(c *gin.Context) { } providerType := strings.ToLower(strings.TrimSpace(existing.Type)) - if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" && providerType != "pushover" { + if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" && providerType != "pushover" && providerType != "ntfy" { respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type") return } - if (providerType == "gotify" || providerType == "telegram" || providerType == "slack" || providerType == "pushover") && strings.TrimSpace(req.Token) == "" { + if (providerType == "gotify" || providerType == "telegram" || providerType == "slack" || providerType == "pushover" || providerType == "ntfy") && strings.TrimSpace(req.Token) == "" { // Keep existing token if update payload omits token req.Token = existing.Token } diff --git a/backend/internal/notifications/feature_flags.go b/backend/internal/notifications/feature_flags.go index e5cf0f2c..846a78cb 100644 --- a/backend/internal/notifications/feature_flags.go +++ b/backend/internal/notifications/feature_flags.go @@ -9,5 +9,6 @@ const ( FlagTelegramServiceEnabled = "feature.notifications.service.telegram.enabled" FlagSlackServiceEnabled = "feature.notifications.service.slack.enabled" FlagPushoverServiceEnabled = "feature.notifications.service.pushover.enabled" + FlagNtfyServiceEnabled = "feature.notifications.service.ntfy.enabled" FlagSecurityProviderEventsEnabled = "feature.notifications.security_provider_events.enabled" ) diff --git a/backend/internal/notifications/http_wrapper.go b/backend/internal/notifications/http_wrapper.go index 7ed876ea..e9831e2c 100644 --- a/backend/internal/notifications/http_wrapper.go +++ b/backend/internal/notifications/http_wrapper.go @@ -458,10 +458,11 @@ func readCappedResponseBody(body io.Reader) ([]byte, error) { func sanitizeOutboundHeaders(headers map[string]string) map[string]string { allowed := map[string]struct{}{ - "content-type": {}, - "user-agent": {}, - "x-request-id": {}, - "x-gotify-key": {}, + "content-type": {}, + "user-agent": {}, + "x-request-id": {}, + "x-gotify-key": {}, + "authorization": {}, } sanitized := make(map[string]string) diff --git a/backend/internal/notifications/http_wrapper_test.go b/backend/internal/notifications/http_wrapper_test.go index 3df06cd4..765cfa14 100644 --- a/backend/internal/notifications/http_wrapper_test.go +++ b/backend/internal/notifications/http_wrapper_test.go @@ -255,11 +255,11 @@ func TestSanitizeOutboundHeadersAllowlist(t *testing.T) { "Cookie": "sid=1", }) - if len(headers) != 4 { - t.Fatalf("expected 4 allowed headers, got %d", len(headers)) + if len(headers) != 5 { + t.Fatalf("expected 5 allowed headers, got %d", len(headers)) } - if _, ok := headers["Authorization"]; ok { - t.Fatalf("authorization header must be stripped") + if _, ok := headers["Authorization"]; !ok { + t.Fatalf("authorization header must be allowed for ntfy Bearer auth") } if _, ok := headers["Cookie"]; ok { t.Fatalf("cookie header must be stripped") diff --git a/backend/internal/notifications/router.go b/backend/internal/notifications/router.go index f15142dc..5aa78076 100644 --- a/backend/internal/notifications/router.go +++ b/backend/internal/notifications/router.go @@ -29,6 +29,8 @@ func (r *Router) ShouldUseNotify(providerType string, flags map[string]bool) boo return flags[FlagSlackServiceEnabled] case "pushover": return flags[FlagPushoverServiceEnabled] + case "ntfy": + return flags[FlagNtfyServiceEnabled] default: return false } diff --git a/backend/internal/notifications/router_test.go b/backend/internal/notifications/router_test.go index 4a7fa17d..25395dba 100644 --- a/backend/internal/notifications/router_test.go +++ b/backend/internal/notifications/router_test.go @@ -109,7 +109,7 @@ func TestRouter_ShouldUseNotify_PushoverServiceFlag(t *testing.T) { router := NewRouter() flags := map[string]bool{ - FlagNotifyEngineEnabled: true, + FlagNotifyEngineEnabled: true, FlagPushoverServiceEnabled: true, } @@ -122,3 +122,21 @@ func TestRouter_ShouldUseNotify_PushoverServiceFlag(t *testing.T) { t.Fatalf("expected notify routing disabled for pushover when FlagPushoverServiceEnabled is false") } } + +func TestRouter_ShouldUseNotify_NtfyServiceFlag(t *testing.T) { + router := NewRouter() + + flags := map[string]bool{ + FlagNotifyEngineEnabled: true, + FlagNtfyServiceEnabled: true, + } + + if !router.ShouldUseNotify("ntfy", flags) { + t.Fatalf("expected notify routing enabled for ntfy when FlagNtfyServiceEnabled is true") + } + + flags[FlagNtfyServiceEnabled] = false + if router.ShouldUseNotify("ntfy", flags) { + t.Fatalf("expected notify routing disabled for ntfy when FlagNtfyServiceEnabled is false") + } +} diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index 5ae56532..f54eb48a 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -129,7 +129,7 @@ func validateDiscordProviderURL(providerType, rawURL string) error { // supportsJSONTemplates returns true if the provider type can use JSON templates func supportsJSONTemplates(providerType string) bool { switch strings.ToLower(providerType) { - case "webhook", "discord", "gotify", "slack", "generic", "telegram", "pushover": + case "webhook", "discord", "gotify", "slack", "generic", "telegram", "pushover", "ntfy": return true default: return false @@ -138,7 +138,7 @@ func supportsJSONTemplates(providerType string) bool { func isSupportedNotificationProviderType(providerType string) bool { switch strings.ToLower(strings.TrimSpace(providerType)) { - case "discord", "email", "gotify", "webhook", "telegram", "slack", "pushover": + case "discord", "email", "gotify", "webhook", "telegram", "slack", "pushover", "ntfy": return true default: return false @@ -161,6 +161,8 @@ func (s *NotificationService) isDispatchEnabled(providerType string) bool { return s.getFeatureFlagValue(notifications.FlagSlackServiceEnabled, true) case "pushover": return s.getFeatureFlagValue(notifications.FlagPushoverServiceEnabled, true) + case "ntfy": + return s.getFeatureFlagValue(notifications.FlagNtfyServiceEnabled, true) default: return false } @@ -520,9 +522,13 @@ func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.Noti return fmt.Errorf("pushover emergency priority (2) requires retry and expire parameters; not yet supported") } } + case "ntfy": + if _, hasMessage := jsonPayload["message"]; !hasMessage { + return fmt.Errorf("ntfy payload must include a 'message' field") + } } - if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" || providerType == "slack" || providerType == "pushover" { + if providerType == "gotify" || providerType == "webhook" || providerType == "telegram" || providerType == "slack" || providerType == "pushover" || providerType == "ntfy" { headers := map[string]string{ "Content-Type": "application/json", "User-Agent": "Charon-Notify/1.0", @@ -579,6 +585,12 @@ func (s *NotificationService) sendJSONPayload(ctx context.Context, p models.Noti dispatchURL = decryptedWebhookURL } + if providerType == "ntfy" { + if strings.TrimSpace(p.Token) != "" { + headers["Authorization"] = "Bearer " + strings.TrimSpace(p.Token) + } + } + if providerType == "pushover" { decryptedToken := p.Token if strings.TrimSpace(decryptedToken) == "" { @@ -847,7 +859,7 @@ func (s *NotificationService) CreateProvider(provider *models.NotificationProvid } } - if provider.Type != "gotify" && provider.Type != "telegram" && provider.Type != "slack" { + if provider.Type != "gotify" && provider.Type != "telegram" && provider.Type != "slack" && provider.Type != "ntfy" && provider.Type != "pushover" { provider.Token = "" } @@ -883,7 +895,7 @@ func (s *NotificationService) UpdateProvider(provider *models.NotificationProvid return err } - if provider.Type == "gotify" || provider.Type == "telegram" || provider.Type == "slack" { + if provider.Type == "gotify" || provider.Type == "telegram" || provider.Type == "slack" || provider.Type == "ntfy" || provider.Type == "pushover" { if strings.TrimSpace(provider.Token) == "" { provider.Token = existing.Token } diff --git a/backend/internal/services/notification_service_json_test.go b/backend/internal/services/notification_service_json_test.go index 7c84a3e3..2979cd5e 100644 --- a/backend/internal/services/notification_service_json_test.go +++ b/backend/internal/services/notification_service_json_test.go @@ -661,3 +661,96 @@ func TestSendJSONPayload_Telegram_401ErrorMessage(t *testing.T) { require.Error(t, sendErr) assert.Contains(t, sendErr.Error(), "provider returned status 401") } + +func TestSendJSONPayload_Ntfy_Valid(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + assert.Empty(t, r.Header.Get("Authorization"), "no auth header when token is empty") + + var payload map[string]any + err := json.NewDecoder(r.Body).Decode(&payload) + require.NoError(t, err) + assert.NotNil(t, payload["message"], "ntfy payload should have message field") + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) + require.NoError(t, err) + + svc := NewNotificationService(db, nil) + + provider := models.NotificationProvider{ + Type: "ntfy", + URL: server.URL, + Template: "custom", + Config: `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}}`, + } + + data := map[string]any{ + "Message": "Test notification", + "Title": "Test", + } + + err = svc.sendJSONPayload(context.Background(), provider, data) + assert.NoError(t, err) +} + +func TestSendJSONPayload_Ntfy_WithToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer tk_test123", r.Header.Get("Authorization")) + + var payload map[string]any + err := json.NewDecoder(r.Body).Decode(&payload) + require.NoError(t, err) + assert.NotNil(t, payload["message"]) + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) + require.NoError(t, err) + + svc := NewNotificationService(db, nil) + + provider := models.NotificationProvider{ + Type: "ntfy", + URL: server.URL, + Token: "tk_test123", + Template: "custom", + Config: `{"message": {{toJSON .Message}}, "title": {{toJSON .Title}}}`, + } + + data := map[string]any{ + "Message": "Test notification", + "Title": "Test", + } + + err = svc.sendJSONPayload(context.Background(), provider, data) + assert.NoError(t, err) +} + +func TestSendJSONPayload_Ntfy_MissingMessage(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) + require.NoError(t, err) + + svc := NewNotificationService(db, nil) + + provider := models.NotificationProvider{ + Type: "ntfy", + URL: "http://localhost:9999", + Template: "custom", + Config: `{"title": "Test"}`, + } + + data := map[string]any{ + "Message": "Test", + } + + err = svc.sendJSONPayload(context.Background(), provider, data) + assert.Error(t, err) + assert.Contains(t, err.Error(), "ntfy payload must include a 'message' field") +} diff --git a/backend/internal/services/notification_service_test.go b/backend/internal/services/notification_service_test.go index 6c84b784..1c81dfc1 100644 --- a/backend/internal/services/notification_service_test.go +++ b/backend/internal/services/notification_service_test.go @@ -3878,3 +3878,31 @@ func TestPushoverDispatch_DefaultBaseURL(t *testing.T) { err := svc.sendJSONPayload(ctx, provider, data) require.Error(t, err) } + +func TestIsSupportedNotificationProviderType_Ntfy(t *testing.T) { + assert.True(t, isSupportedNotificationProviderType("ntfy")) + assert.True(t, isSupportedNotificationProviderType("Ntfy")) + assert.True(t, isSupportedNotificationProviderType(" ntfy ")) +} + +func TestIsDispatchEnabled_NtfyDefaultTrue(t *testing.T) { + db := setupNotificationTestDB(t) + _ = db.AutoMigrate(&models.Setting{}) + svc := NewNotificationService(db, nil) + + assert.True(t, svc.isDispatchEnabled("ntfy")) +} + +func TestIsDispatchEnabled_NtfyDisabledByFlag(t *testing.T) { + db := setupNotificationTestDB(t) + _ = db.AutoMigrate(&models.Setting{}) + db.Create(&models.Setting{Key: "feature.notifications.service.ntfy.enabled", Value: "false"}) + svc := NewNotificationService(db, nil) + + assert.False(t, svc.isDispatchEnabled("ntfy")) +} + +func TestSupportsJSONTemplates_Ntfy(t *testing.T) { + assert.True(t, supportsJSONTemplates("ntfy")) + assert.True(t, supportsJSONTemplates("Ntfy")) +} diff --git a/docs/features.md b/docs/features.md index 3ff05722..139348d8 100644 --- a/docs/features.md +++ b/docs/features.md @@ -237,7 +237,7 @@ Watch requests flow through your proxy in real-time. Filter by domain, status co ### 🔔 Notifications -Get alerted when it matters. Charon notifications now run through the Notify HTTP wrapper with support for Discord, Gotify, and Custom Webhook providers. Payload-focused test coverage is included to help catch formatting and delivery regressions before release. +Get alerted when it matters. Charon sends notifications through Discord, Gotify, Ntfy, Pushover, Slack, Email, and Custom Webhook providers. Choose a built-in JSON template or write your own to control exactly what your alerts look like. → [Learn More](features/notifications.md) diff --git a/docs/features/notifications.md b/docs/features/notifications.md index 166db0c8..7f6f496f 100644 --- a/docs/features/notifications.md +++ b/docs/features/notifications.md @@ -19,6 +19,7 @@ Notifications can be triggered by various events: | **Slack** | ✅ Yes | ✅ Webhooks | ✅ Native Formatting | | **Gotify** | ✅ Yes | ✅ HTTP API | ✅ Priority + Extras | | **Pushover** | ✅ Yes | ✅ HTTP API | ✅ Priority + Sound | +| **Ntfy** | ✅ Yes | ✅ HTTP API | ✅ Priority + Tags | | **Custom Webhook** | ✅ Yes | ✅ HTTP API | ✅ Template-Controlled | | **Email** | ❌ No | ✅ SMTP | ✅ HTML Branded Templates | @@ -260,6 +261,52 @@ Pushover delivers push notifications directly to your iOS, Android, or desktop d > **Note:** Emergency priority (`2`) is not supported and will be rejected with a clear error. +### Ntfy + +Ntfy delivers push notifications to your phone or desktop using a simple HTTP-based publish/subscribe model. Works with the free hosted service at [ntfy.sh](https://ntfy.sh) or your own self-hosted instance. + +**Setup:** + +1. Pick a topic name (or use an existing one) on [ntfy.sh](https://ntfy.sh) or your self-hosted server +2. In Charon, go to **Settings** → **Notifications** and click **"Add Provider"** +3. Select **Ntfy** as the service type +4. Enter your server URL (e.g., `https://ntfy.sh` or `https://ntfy.example.com`) +5. Enter your topic name +6. (Optional) Add a Bearer token if your server requires authentication +7. Configure notification triggers and save + +> **Security:** Your Bearer token is stored securely and is never exposed in API responses. + +#### Basic Message + +```json +{ + "topic": "charon-alerts", + "title": "{{.Title}}", + "message": "{{.Message}}" +} +``` + +#### Message with Priority and Tags + +```json +{ + "topic": "charon-alerts", + "title": "{{.Title}}", + "message": "{{.Message}}", + "priority": 4, + "tags": ["rotating_light"] +} +``` + +**Ntfy priority levels:** + +- `1` - Min +- `2` - Low +- `3` - Default +- `4` - High +- `5` - Max (urgent) + ## Planned Provider Expansion Additional providers (for example Telegram) are planned for later staged diff --git a/docs/issues/ntfy-notification-provider-manual-testing.md b/docs/issues/ntfy-notification-provider-manual-testing.md new file mode 100644 index 00000000..51d38eb5 --- /dev/null +++ b/docs/issues/ntfy-notification-provider-manual-testing.md @@ -0,0 +1,98 @@ +--- +title: "Manual Testing: Ntfy Notification Provider" +labels: + - testing + - feature + - frontend + - backend +priority: medium +milestone: "v0.2.0-beta.2" +assignees: [] +--- + +# Manual Testing: Ntfy Notification Provider + +## Description + +Manual testing plan for the Ntfy notification provider feature. Covers UI/UX +validation, dispatch behavior, token security, and edge cases that E2E tests +cannot fully cover. + +## Prerequisites + +- Ntfy instance accessible (cloud: ntfy.sh, or self-hosted) +- Test topic created (e.g., `https://ntfy.sh/charon-test-XXXX`) +- Ntfy mobile/desktop app installed for push verification +- Optional: password-protected topic with access token for auth testing + +## Test Cases + +### UI/UX Validation + +- [ ] Select "Ntfy" from provider type dropdown — token field and "Topic URL" label appear +- [ ] URL placeholder shows `https://ntfy.sh/my-topic` +- [ ] Token label shows "Access Token (optional)" +- [ ] Token field is a password field (dots, not cleartext) +- [ ] JSON template section (minimal/detailed/custom) appears for Ntfy +- [ ] Switching from Ntfy to Discord clears token field and hides it +- [ ] Switching from Discord to Ntfy shows token field again +- [ ] URL field is required — form rejects empty URL submission +- [ ] Keyboard navigation: tab through all Ntfy form fields without focus traps + +### CRUD Operations + +- [ ] Create Ntfy provider with URL only (no token) — succeeds +- [ ] Create Ntfy provider with URL + token — succeeds +- [ ] Edit Ntfy provider: change URL — preserves token (shows "Leave blank to keep") +- [ ] Edit Ntfy provider: clear and re-enter token — updates token +- [ ] Delete Ntfy provider — removed from list +- [ ] Create multiple Ntfy providers with different topics — all coexist + +### Dispatch Verification (Requires Real Ntfy Instance) + +- [ ] Send test notification to ntfy.sh cloud topic — push received on device +- [ ] Send test notification to self-hosted ntfy instance — push received +- [ ] Send test notification with minimal template — message body is correct +- [ ] Send test notification with detailed template — title and body formatted correctly +- [ ] Send test notification with custom JSON template — all fields arrive as specified +- [ ] Token-protected topic with valid token — notification delivered +- [ ] Token-protected topic with no token — notification rejected by ntfy (expected 401) +- [ ] Token-protected topic with invalid token — notification rejected by ntfy (expected 401) + +### Token Security + +- [ ] After creating provider with token: GET provider response has `has_token: true` but no raw token +- [ ] Browser DevTools Network tab: confirm token never appears in any API response body +- [ ] Edit provider: token field is empty (not pre-filled with existing token) +- [ ] Application logs: confirm no token values in backend logs during dispatch + +### Edge Cases + +- [ ] Invalid URL (not http/https) — form validation rejects +- [ ] Self-hosted ntfy URL with non-standard port (e.g., `http://192.168.1.50:8080/alerts`) — accepted and dispatches +- [ ] Very long topic name in URL — accepted +- [ ] Unicode characters in message template — dispatches correctly +- [ ] Feature flag disabled (`feature.notifications.service.ntfy.enabled = false`) — ntfy dispatch silently skipped +- [ ] Network timeout to unreachable ntfy server — error handled gracefully, no crash + +### Accessibility + +- [ ] Screen reader: form field labels announced correctly for Ntfy fields +- [ ] Screen reader: token help text associated via aria-describedby +- [ ] High contrast mode: Ntfy form fields visible and readable +- [ ] Voice access: "Click Topic URL" activates the correct field +- [ ] Keyboard only: complete full CRUD workflow without mouse + +## Acceptance Criteria + +- [ ] All UI/UX tests pass +- [ ] All CRUD operations work correctly +- [ ] At least one real dispatch to ntfy.sh confirmed +- [ ] Token never exposed in API responses or logs +- [ ] No accessibility regressions + +## Related + +- Spec: `docs/plans/current_spec.md` +- QA Report: `docs/reports/qa_report_ntfy_notifications.md` +- E2E Tests: `tests/settings/ntfy-notification-provider.spec.ts` diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 6f75ac16..91fe5bed 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -1,204 +1,592 @@ -# Fix: Frontend Unit Test i18n Failures in BulkDeleteCertificateDialog - -> **Status:** Ready for implementation -> **Severity:** CI-blocking (2 test failures) -> **Scope:** Single test file change - ---- +# Ntfy Notification Provider — Implementation Specification ## 1. Introduction -Two frontend unit tests fail in CI because `BulkDeleteCertificateDialog.test.tsx` contains a local `vi.mock('react-i18next')` that overrides the global mock in the test setup. The local mock returns raw translation keys and JSON-serialized options instead of resolved English strings, causing assertion mismatches. +### Overview + +Add **Ntfy** () as a notification provider in Charon, following +the same wrapper pattern used by Gotify, Telegram, Slack, and Pushover. Ntfy is +an HTTP-based pub/sub notification service that supports self-hosted and +cloud-hosted instances. Users publish messages by POSTing JSON to a topic URL, +optionally with an auth token. ### Objectives -- Fix the 2 failing tests in CI -- Align `BulkDeleteCertificateDialog.test.tsx` with the project's established i18n test pattern -- No behavioral or component changes required +1. Users can create/edit/delete an Ntfy notification provider via the Management UI. +2. Ntfy dispatches support all three template modes (minimal, detailed, custom). +3. Ntfy respects the global notification engine kill-switch and its own per-provider feature flag. +4. Security: auth tokens are stored securely (never exposed in API responses or logs). +5. Full E2E and unit test coverage matching the existing provider test suite. --- ## 2. Research Findings -### 2.1 Failing Tests (from CI log) +### Existing Architecture + +Charon's notification engine does **not** use a Go interface pattern. Instead, it +routes on string type values (`"discord"`, `"gotify"`, `"webhook"`, etc.) across +~15 switch/case + hardcoded lists in both backend and frontend. + +**Key code paths per provider type:** + +| Layer | Location | Mechanism | +|-------|----------|-----------| +| Model | `backend/internal/models/notification_provider.go` | Generic — no per-type changes needed | +| Service — type allowlist | `notification_service.go:139` `isSupportedNotificationProviderType()` | `switch` on type string | +| Service — flag routing | `notification_service.go:148` `isDispatchEnabled()` | `switch` → feature flag lookup | +| Service — dispatch | `notification_service.go:381` `sendJSONPayload()` | Type-specific validation + URL / header construction | +| Feature flags | `notifications/feature_flags.go` | Const strings for settings DB keys | +| Router | `notifications/router.go:10` `ShouldUseNotify()` | `switch` on type → flag map lookup | +| Handler — create validation | `notification_provider_handler.go:185` | Hardcoded `!=` chain | +| Handler — update validation | `notification_provider_handler.go:245` | Hardcoded `!=` chain | +| Handler — URL validation | `notification_provider_handler.go:372` | Slack special-case (optional URL) | +| Frontend — type array | `api/notifications.ts:3` | `SUPPORTED_NOTIFICATION_PROVIDER_TYPES` const | +| Frontend — sanitize | `api/notifications.ts` `sanitizeProviderForWriteAction()` | Token mapping per type | +| Frontend — form | `pages/Notifications.tsx` | `