Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ graph TB
| **WebSocket** | gorilla/websocket | Latest | Real-time log streaming |
| **Crypto** | golang.org/x/crypto | Latest | Password hashing, encryption |
| **Metrics** | Prometheus Client | Latest | Application metrics |
| **Notifications** | github.com/Wikid82/go_notify_yourself | Current | External delivery-engine module (Discord, Slack, Gotify, Pushover, Ntfy, Telegram, generic webhook, and email) consumed via Charon-supplied SSRF/SMTP/template adapters — see Service Layer below |
| **Notifications** | github.com/Wikid82/go_notify_yourself | Current | External delivery-engine module (Discord, Slack, Gotify, Pushover, Ntfy, Telegram, generic webhook, Web Push, and email) consumed via Charon-supplied SSRF/SMTP/template adapters — see Service Layer below |
| **Docker Client** | Docker SDK | Latest | Container discovery |
| **Logging** | Logrus + Lumberjack | Latest | Structured logging with rotation |
| **Backup Archive Encryption** | filippo.io/age | Latest | Passphrase (scrypt) encryption of backup archives; audited, pure Go, streaming AEAD — avoids buffering whole archives in RAM or hand-rolling chunked AES-GCM |
Expand Down Expand Up @@ -347,7 +347,8 @@ fork/offline fallback.
- **CertificateService:** ACME certificate provisioning and renewal
- **DockerService:** Container discovery and monitoring
- **MailService:** SMTP transport and branded HTML templates for certificate-expiry and other system emails
- **NotificationService:** GORM CRUD for providers/templates, event-type-to-provider routing, and feature-flag gating (`internal/services/notification_service.go`); outbound dispatch for all seven provider types (Discord, Slack, Gotify, Pushover, Ntfy, Telegram, generic webhook) plus email is delegated to the external `github.com/Wikid82/go_notify_yourself` module (`v0.2.0+`) through three Charon-supplied adapters — `notify_client_adapter.go` (SSRF-safe HTTP client/URL validation, wired to `internal/network`/`internal/security`), `notify_provider_adapter.go`, and `notify_email_adapter.go` (wraps `MailService` behind the module's `Mailer`/`TemplateRenderer` interfaces). `notify_provider_adapter.go`'s `buildNotifySender` maps a `NotificationProvider` row into a `map[string]any` config (`providerConfigMap`) and constructs the `Sender` by calling the module's self-registering provider factory registry (`notify.New(provider.Type, config)`) rather than a hardcoded per-provider switch/constructor call — `notify_providers_import.go` hand-picks the blank imports (`providers/discord`, `providers/slack`, `providers/gotify`, `providers/pushover`, `providers/ntfy`, `providers/telegram`, `providers/webhook`, `providers/email`) that register those factories at `init()` time, deliberately not importing `providers/all`. Charon's own supported-provider allowlist (`isSupportedNotificationProviderType`, `notification_service.go`) remains independently hardcoded and is not derived from the registry; a unit test asserts it stays a subset of `notify.RegisteredTypes()`. The formerly in-repo delivery engine (`internal/notifications/`) has been removed.
- **NotificationService:** GORM CRUD for providers/templates, event-type-to-provider routing, and feature-flag gating (`internal/services/notification_service.go`); outbound dispatch for all eight provider types (Discord, Slack, Gotify, Pushover, Ntfy, Telegram, generic webhook, Web Push) plus email is delegated to the external `github.com/Wikid82/go_notify_yourself` module (`v0.3.0+`) through three Charon-supplied adapters — `notify_client_adapter.go` (SSRF-safe HTTP client/URL validation, wired to `internal/network`/`internal/security`), `notify_provider_adapter.go`, and `notify_email_adapter.go` (wraps `MailService` behind the module's `Mailer`/`TemplateRenderer` interfaces). `notify_provider_adapter.go`'s `buildNotifySender` maps a `NotificationProvider` row into a `map[string]any` config (`providerConfigMap`) and constructs the `Sender` by calling the module's self-registering provider factory registry (`notify.New(provider.Type, config)`) rather than a hardcoded per-provider switch/constructor call — `notify_providers_import.go` hand-picks the blank imports (`providers/discord`, `providers/slack`, `providers/gotify`, `providers/pushover`, `providers/ntfy`, `providers/telegram`, `providers/webhook`, `providers/webpush`, `providers/email`) that register those factories at `init()` time, deliberately not importing `providers/all`. Charon's own supported-provider allowlist (`isSupportedNotificationProviderType`, `notification_service.go`) remains independently hardcoded and is not derived from the registry; a unit test asserts it stays a subset of `notify.RegisteredTypes()`. The formerly in-repo delivery engine (`internal/notifications/`) has been removed.
- **Web Push** is the one provider type that isn't a single destination: one `NotificationProvider` row (`Type = "webpush"`, a DB-enforced singleton) holds the shared VAPID application identity, while each subscribed browser/device is a row in a separate `WebPushSubscription` model (`internal/models/webpush_subscription.go`) — FK'd to that provider row, with its own `notify_webpush_adapter.go` fan-out path that sends to every subscription individually and auto-prunes rows the push service reports as gone.
- **SettingsService:** Application settings management
- **BackupService:** Format-v2 archive creation (manifest + SHA-256 checksums), configurable cron scheduling, the safe-restore pipeline (validate → pre-restore safety backup → apply → reconcile), and optional age/scrypt archive encryption — see "Backup & Restore Subsystem" below

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" && providerType != "ntfy" {
if providerType != "discord" && providerType != "gotify" && providerType != "webhook" && providerType != "email" && providerType != "telegram" && providerType != "slack" && providerType != "pushover" && providerType != "ntfy" && providerType != "webpush" {
respondSanitizedProviderError(c, http.StatusBadRequest, "UNSUPPORTED_PROVIDER_TYPE", "validation", "Unsupported notification provider type")
return
}

if (providerType == "gotify" || providerType == "telegram" || providerType == "slack" || providerType == "pushover" || providerType == "ntfy") && strings.TrimSpace(req.Token) == "" {
if (providerType == "gotify" || providerType == "telegram" || providerType == "slack" || providerType == "pushover" || providerType == "ntfy" || providerType == "webpush") && strings.TrimSpace(req.Token) == "" {
// Keep existing token if update payload omits token
req.Token = existing.Token
}
Expand Down
198 changes: 198 additions & 0 deletions backend/internal/api/handlers/webpush_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package handlers

import (
"errors"
"net/http"
"strconv"
"time"

"github.com/gin-gonic/gin"

"github.com/Wikid82/charon/backend/internal/services"
)

// WebPushHandler exposes the Web Push provisioning and subscription
// lifecycle endpoints (docs/plans/current_spec.md §3.4). All routes are
// mounted on the existing authenticated `management` route group; the
// Provision route is additionally gated admin-only at the route
// registration layer (routes.go), mirroring the Test/Preview precedent —
// see §3.4.0 for the full authorization-model writeup.
type WebPushHandler struct {
service *services.NotificationService
}

func NewWebPushHandler(service *services.NotificationService) *WebPushHandler {
return &WebPushHandler{service: service}
}

type webPushProvisionRequest struct {
Name string `json:"name"`
VAPIDSubject string `json:"vapid_subject"`
}

type webPushSubscribeRequest struct {
Endpoint string `json:"endpoint"`
Keys struct {
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
} `json:"keys"`
UserAgent string `json:"user_agent"`
}

type webPushSubscriptionResponse struct {
ID string `json:"id"`
Endpoint string `json:"endpoint"`
UserAgent string `json:"user_agent,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastSeenAt time.Time `json:"last_seen_at"`
}

// webPushUserID extracts the authenticated caller's user ID (set by
// AuthMiddleware as a uint, per models.User.ID) and renders it as the
// string WebPushSubscription.UserID/models column expects. On failure it
// writes a 401 itself, matching requireUserID's contract.
func webPushUserID(c *gin.Context) (string, bool) {
userID, ok := requireUserID(c)
if !ok {
return "", false
}
return strconv.FormatUint(uint64(userID), 10), true
}

// Provision generates a new VAPID identity and creates the singleton
// webpush NotificationProvider row (§3.4.1). Admin-only — enforced by
// middleware.RequireRole(models.RoleAdmin) at the route registration layer
// (routes.go), matching the Test/Preview admin-only precedent on this same
// route group; no additional in-handler check is needed.
func (h *WebPushHandler) Provision(c *gin.Context) {
var req webPushProvisionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
return
}

provider, err := h.service.ProvisionWebPush(req.Name, req.VAPIDSubject)
if err != nil {
switch {
case errors.Is(err, services.ErrWebPushAlreadyProvisioned):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrWebPushInvalidRequest):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to provision web push provider"})
}
return
}

provider.HasToken = provider.Token != ""
provider.Token = ""
c.JSON(http.StatusCreated, provider)
}

// VAPIDPublicKey serves the current VAPID public key for
// PushManager.subscribe (§3.4.2). Available to any authenticated
// management-access user, not just admins — a non-admin user's browser can
// still subscribe to receive alerts.
func (h *WebPushHandler) VAPIDPublicKey(c *gin.Context) {
key, err := h.service.GetWebPushVAPIDPublicKey()
if err != nil {
if errors.Is(err, services.ErrWebPushNotProvisioned) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load web push VAPID public key"})
return
}
c.JSON(http.StatusOK, gin.H{"vapid_public_key": key})
}

// Subscribe registers (or idempotently re-registers) the caller's browser
// PushSubscription (§3.4.3).
func (h *WebPushHandler) Subscribe(c *gin.Context) {
userID, ok := webPushUserID(c)
if !ok {
return
}

var req webPushSubscribeRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request payload"})
return
}

sub, created, err := h.service.RegisterWebPushSubscription(userID, services.WebPushSubscribeInput{
Endpoint: req.Endpoint,
P256dh: req.Keys.P256dh,
Auth: req.Keys.Auth,
UserAgent: req.UserAgent,
})
if err != nil {
switch {
case errors.Is(err, services.ErrWebPushNotProvisioned):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrWebPushProviderDisabled):
// Exact text pinned by docs/plans/current_spec.md §3.4.3, kept
// independent of the sentinel error's own (lowercase, Go-idiom)
// message.
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Web Push provider is disabled"})
case errors.Is(err, services.ErrWebPushInvalidRequest):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register web push subscription"})
}
return
}

status := http.StatusOK
if created {
status = http.StatusCreated
}
c.JSON(status, gin.H{"id": sub.ID, "endpoint": sub.Endpoint})
}

// ListSubscriptions returns the caller's own subscriptions only (§3.4.4).
func (h *WebPushHandler) ListSubscriptions(c *gin.Context) {
userID, ok := webPushUserID(c)
if !ok {
return
}

subs, err := h.service.ListWebPushSubscriptionsForUser(userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list web push subscriptions"})
return
}

resp := make([]webPushSubscriptionResponse, 0, len(subs))
for _, sub := range subs {
resp = append(resp, webPushSubscriptionResponse{
ID: sub.ID,
Endpoint: sub.Endpoint,
UserAgent: sub.UserAgent,
CreatedAt: sub.CreatedAt,
LastSeenAt: sub.LastSeenAt,
})
}
c.JSON(http.StatusOK, resp)
}

// Unsubscribe removes a subscription owned by the caller (§3.4.5). Returns
// 404 — never 403 — for a subscription that doesn't exist or isn't owned by
// the caller, to avoid confirming existence of another user's row.
func (h *WebPushHandler) Unsubscribe(c *gin.Context) {
userID, ok := webPushUserID(c)
if !ok {
return
}

id := c.Param("id")
if err := h.service.DeleteWebPushSubscription(userID, id); err != nil {
if errors.Is(err, services.ErrWebPushSubscriptionNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete web push subscription"})
return
}
c.Status(http.StatusNoContent)
}
Loading
Loading