diff --git a/backend/internal/api/handlers/notification_template_handler.go b/backend/internal/api/handlers/notification_template_handler.go new file mode 100644 index 00000000..e9640a97 --- /dev/null +++ b/backend/internal/api/handlers/notification_template_handler.go @@ -0,0 +1,97 @@ +package handlers + +import ( + "net/http" + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" + "github.com/gin-gonic/gin" +) + +type NotificationTemplateHandler struct { + service *services.NotificationService +} + +func NewNotificationTemplateHandler(s *services.NotificationService) *NotificationTemplateHandler { + return &NotificationTemplateHandler{service: s} +} + +func (h *NotificationTemplateHandler) List(c *gin.Context) { + list, err := h.service.ListTemplates() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list templates"}) + return + } + c.JSON(http.StatusOK, list) +} + +func (h *NotificationTemplateHandler) Create(c *gin.Context) { + var t models.NotificationTemplate + if err := c.ShouldBindJSON(&t); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := h.service.CreateTemplate(&t); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create template"}) + return + } + c.JSON(http.StatusCreated, t) +} + +func (h *NotificationTemplateHandler) Update(c *gin.Context) { + id := c.Param("id") + var t models.NotificationTemplate + if err := c.ShouldBindJSON(&t); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + t.ID = id + if err := h.service.UpdateTemplate(&t); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update template"}) + return + } + c.JSON(http.StatusOK, t) +} + +func (h *NotificationTemplateHandler) Delete(c *gin.Context) { + id := c.Param("id") + if err := h.service.DeleteTemplate(id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete template"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} + +// Preview allows rendering an arbitrary template (provided in request) or a stored template by id. +func (h *NotificationTemplateHandler) Preview(c *gin.Context) { + var raw map[string]interface{} + if err := c.ShouldBindJSON(&raw); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var tmplStr string + if id, ok := raw["template_id"].(string); ok && id != "" { + t, err := h.service.GetTemplate(id) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "template not found"}) + return + } + tmplStr = t.Config + } else if s, ok := raw["template"].(string); ok { + tmplStr = s + } + + data := map[string]interface{}{} + if d, ok := raw["data"].(map[string]interface{}); ok { + data = d + } + + // Build a fake provider to leverage existing RenderTemplate logic + provider := models.NotificationProvider{Template: "custom", Config: tmplStr} + rendered, parsed, err := h.service.RenderTemplate(provider, data) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "rendered": rendered}) + return + } + c.JSON(http.StatusOK, gin.H{"rendered": rendered, "parsed": parsed}) +} diff --git a/backend/internal/api/handlers/notification_template_handler_test.go b/backend/internal/api/handlers/notification_template_handler_test.go new file mode 100644 index 00000000..1fe8ddd0 --- /dev/null +++ b/backend/internal/api/handlers/notification_template_handler_test.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "io" + "testing" + + "strings" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/services" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupDB(t *testing.T) *gorm.DB { + db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{}) + require.NoError(t, err) + db.AutoMigrate(&models.NotificationTemplate{}) + return db +} + +func TestNotificationTemplateCRUD(t *testing.T) { + db := setupDB(t) + svc := services.NewNotificationService(db) + h := NewNotificationTemplateHandler(svc) + + // Create + payload := `{"name":"Simple","config":"{\"title\": \"{{.Title}}\"}","template":"custom"}` + req := httptest.NewRequest("POST", "/", nil) + req.Body = io.NopCloser(strings.NewReader(payload)) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + h.Create(c) + require.Equal(t, http.StatusCreated, w.Code) + + // List + req2 := httptest.NewRequest("GET", "/", nil) + w2 := httptest.NewRecorder() + c2, _ := gin.CreateTestContext(w2) + c2.Request = req2 + h.List(c2) + require.Equal(t, http.StatusOK, w2.Code) + var list []models.NotificationTemplate + require.NoError(t, json.Unmarshal(w2.Body.Bytes(), &list)) + require.Len(t, list, 1) +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 8326ac00..0fb736e5 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -32,6 +32,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { &models.ImportSession{}, &models.Notification{}, &models.NotificationProvider{}, + &models.NotificationTemplate{}, &models.UptimeMonitor{}, &models.UptimeHeartbeat{}, &models.UptimeHost{}, @@ -163,6 +164,14 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error { protected.POST("/notifications/providers/preview", notificationProviderHandler.Preview) protected.GET("/notifications/templates", notificationProviderHandler.Templates) + // External notification templates (saved templates for providers) + notificationTemplateHandler := handlers.NewNotificationTemplateHandler(notificationService) + protected.GET("/notifications/external-templates", notificationTemplateHandler.List) + protected.POST("/notifications/external-templates", notificationTemplateHandler.Create) + protected.PUT("/notifications/external-templates/:id", notificationTemplateHandler.Update) + protected.DELETE("/notifications/external-templates/:id", notificationTemplateHandler.Delete) + protected.POST("/notifications/external-templates/preview", notificationTemplateHandler.Preview) + // Start background checker (every 1 minute) go func() { // Wait a bit for server to start diff --git a/backend/internal/models/notification_template.go b/backend/internal/models/notification_template.go new file mode 100644 index 00000000..4699f019 --- /dev/null +++ b/backend/internal/models/notification_template.go @@ -0,0 +1,30 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// NotificationTemplate represents a reusable external notification template +// that can be applied when sending webhooks or other external notifications. +type NotificationTemplate struct { + ID string `gorm:"primaryKey" json:"id"` + Name string `json:"name"` + Description string `json:"description"` + // Config holds the JSON/template body for external webhook payloads + Config string `json:"config"` + // Template is a hint: minimal|detailed|custom (optional) + Template string `json:"template" gorm:"default:minimal"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (t *NotificationTemplate) BeforeCreate(tx *gorm.DB) (err error) { + if t.ID == "" { + t.ID = uuid.New().String() + } + return +} diff --git a/backend/internal/services/notification_service.go b/backend/internal/services/notification_service.go index 61b8cbdb..8dfb1e8c 100644 --- a/backend/internal/services/notification_service.go +++ b/backend/internal/services/notification_service.go @@ -226,8 +226,17 @@ func (s *NotificationService) sendCustomWebhook(p models.NotificationProvider, d port = "80" } } - targetURL := fmt.Sprintf("%s://%s%s", u.Scheme, net.JoinHostPort(selectedIP.String(), port), u.RequestURI()) - req, err := http.NewRequest("POST", targetURL, &body) + // Construct a safe URL using the resolved IP:port for the Host component, + // while preserving the original path and query from the user-provided URL. + // This makes the destination hostname unambiguously an IP that we resolved + // and prevents accidental requests to private/internal addresses. + safeURL := &neturl.URL{ + Scheme: u.Scheme, + Host: net.JoinHostPort(selectedIP.String(), port), + Path: u.Path, + RawQuery: u.RawQuery, + } + req, err := http.NewRequest("POST", safeURL.String(), &body) if err != nil { return fmt.Errorf("failed to create webhook request: %w", err) } @@ -328,6 +337,35 @@ func (s *NotificationService) TestProvider(provider models.NotificationProvider) return shoutrrr.Send(url, "Test notification from Charon") } +// Templates (external notification templates) management +func (s *NotificationService) ListTemplates() ([]models.NotificationTemplate, error) { + var list []models.NotificationTemplate + if err := s.DB.Order("created_at desc").Find(&list).Error; err != nil { + return nil, err + } + return list, nil +} + +func (s *NotificationService) GetTemplate(id string) (*models.NotificationTemplate, error) { + var t models.NotificationTemplate + if err := s.DB.First(&t, "id = ?", id).Error; err != nil { + return nil, err + } + return &t, nil +} + +func (s *NotificationService) CreateTemplate(t *models.NotificationTemplate) error { + return s.DB.Create(t).Error +} + +func (s *NotificationService) UpdateTemplate(t *models.NotificationTemplate) error { + return s.DB.Save(t).Error +} + +func (s *NotificationService) DeleteTemplate(id string) error { + return s.DB.Delete(&models.NotificationTemplate{}, "id = ?", id).Error +} + // RenderTemplate renders a provider template with provided data and returns // the rendered JSON string and the parsed object for previewing/validation. func (s *NotificationService) RenderTemplate(p models.NotificationProvider, data map[string]interface{}) (string, interface{}, error) { diff --git a/data/caddy/config-1764440347.json b/data/caddy/config-1764440347.json new file mode 100644 index 00000000..17b2d6ee --- /dev/null +++ b/data/caddy/config-1764440347.json @@ -0,0 +1,58 @@ +{ + "apps": { + "http": { + "servers": { + "charon_server": { + "listen": [ + ":80", + ":443" + ], + "routes": [ + { + "handle": [ + { + "handler": "rewrite", + "uri": "/unknown.html" + }, + { + "handler": "file_server", + "root": "/app/frontend/dist" + } + ], + "terminal": true + } + ], + "automatic_https": {}, + "logs": { + "default_logger_name": "access_log" + } + } + } + } + }, + "logging": { + "logs": { + "access": { + "writer": { + "output": "file", + "filename": "/app/data/logs/access.log", + "roll": true, + "roll_size_mb": 10, + "roll_keep": 5, + "roll_keep_days": 7 + }, + "encoder": { + "format": "json" + }, + "level": "INFO", + "include": [ + "http.log.access.access_log" + ] + } + } + }, + "storage": { + "module": "file_system", + "root": "/app/data/caddy/data" + } +} diff --git a/data/caddy/config-1764440652.json b/data/caddy/config-1764440652.json new file mode 100644 index 00000000..17b2d6ee --- /dev/null +++ b/data/caddy/config-1764440652.json @@ -0,0 +1,58 @@ +{ + "apps": { + "http": { + "servers": { + "charon_server": { + "listen": [ + ":80", + ":443" + ], + "routes": [ + { + "handle": [ + { + "handler": "rewrite", + "uri": "/unknown.html" + }, + { + "handler": "file_server", + "root": "/app/frontend/dist" + } + ], + "terminal": true + } + ], + "automatic_https": {}, + "logs": { + "default_logger_name": "access_log" + } + } + } + } + }, + "logging": { + "logs": { + "access": { + "writer": { + "output": "file", + "filename": "/app/data/logs/access.log", + "roll": true, + "roll_size_mb": 10, + "roll_keep": 5, + "roll_keep_days": 7 + }, + "encoder": { + "format": "json" + }, + "level": "INFO", + "include": [ + "http.log.access.access_log" + ] + } + } + }, + "storage": { + "module": "file_system", + "root": "/app/data/caddy/data" + } +} diff --git a/data/caddy/config-1764440734.json b/data/caddy/config-1764440734.json new file mode 100644 index 00000000..33d60b91 --- /dev/null +++ b/data/caddy/config-1764440734.json @@ -0,0 +1,75 @@ +{ + "apps": { + "http": { + "servers": { + "charon_server": { + "listen": [ + ":80", + ":443" + ], + "routes": [ + { + "handle": [ + { + "handler": "rewrite", + "uri": "/unknown.html" + }, + { + "handler": "file_server", + "root": "/app/frontend/dist" + } + ], + "terminal": true + } + ], + "automatic_https": {}, + "logs": { + "default_logger_name": "access_log" + } + } + } + }, + "tls": { + "automation": { + "policies": [ + { + "issuers": [ + { + "email": "admin@example.com", + "module": "acme" + }, + { + "module": "zerossl" + } + ] + } + ] + } + } + }, + "logging": { + "logs": { + "access": { + "writer": { + "output": "file", + "filename": "/app/data/logs/access.log", + "roll": true, + "roll_size_mb": 10, + "roll_keep": 5, + "roll_keep_days": 7 + }, + "encoder": { + "format": "json" + }, + "level": "INFO", + "include": [ + "http.log.access.access_log" + ] + } + } + }, + "storage": { + "module": "file_system", + "root": "/app/data/caddy/data" + } +} diff --git a/data/caddy/config-1764440747.json b/data/caddy/config-1764440747.json new file mode 100644 index 00000000..09cb6f05 --- /dev/null +++ b/data/caddy/config-1764440747.json @@ -0,0 +1,99 @@ +{ + "apps": { + "http": { + "servers": { + "charon_server": { + "listen": [ + ":80", + ":443" + ], + "routes": [ + { + "match": [ + { + "host": [ + "test2.localhost" + ] + } + ], + "handle": [ + { + "handler": "vars" + }, + { + "flush_interval": -1, + "handler": "reverse_proxy", + "upstreams": [ + { + "dial": "host.docker.internal:8081" + } + ] + } + ], + "terminal": true + }, + { + "handle": [ + { + "handler": "rewrite", + "uri": "/unknown.html" + }, + { + "handler": "file_server", + "root": "/app/frontend/dist" + } + ], + "terminal": true + } + ], + "automatic_https": {}, + "logs": { + "default_logger_name": "access_log" + } + } + } + }, + "tls": { + "automation": { + "policies": [ + { + "issuers": [ + { + "email": "admin@example.com", + "module": "acme" + }, + { + "module": "zerossl" + } + ] + } + ] + } + } + }, + "logging": { + "logs": { + "access": { + "writer": { + "output": "file", + "filename": "/app/data/logs/access.log", + "roll": true, + "roll_size_mb": 10, + "roll_keep": 5, + "roll_keep_days": 7 + }, + "encoder": { + "format": "json" + }, + "level": "INFO", + "include": [ + "http.log.access.access_log" + ] + } + } + }, + "storage": { + "module": "file_system", + "root": "/app/data/caddy/data" + } +} diff --git a/data/caddy/data/certificates/local/test2.localhost/test2.localhost.crt b/data/caddy/data/certificates/local/test2.localhost/test2.localhost.crt new file mode 100644 index 00000000..04d8b788 --- /dev/null +++ b/data/caddy/data/certificates/local/test2.localhost/test2.localhost.crt @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIBxDCCAWqgAwIBAgIRAKxvOpSX7dY/DFcxlxeVYlwwCgYIKoZIzj0EAwIwMzEx +MC8GA1UEAxMoQ2FkZHkgTG9jYWwgQXV0aG9yaXR5IC0gRUNDIEludGVybWVkaWF0 +ZTAeFw0yNTExMjkxODI1NDdaFw0yNTExMzAwNjI1NDdaMAAwWTATBgcqhkjOPQIB +BggqhkjOPQMBBwNCAARW60CpqeJ5U4xDgS0qtdxoxImMBtfgBQdL84thZUu2aUbn +/PwNsnplo1zK6T3XUQPs6oMp4vT3Ay0HhkZJI8u3o4GRMIGOMA4GA1UdDwEB/wQE +AwIHgDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0OBBYEFC+t +WqIT91x5K/dJmk4048hU+lFMMB8GA1UdIwQYMBaAFMhhDbgnCp960HTlyVla/ULK +skuUMB0GA1UdEQEB/wQTMBGCD3Rlc3QyLmxvY2FsaG9zdDAKBggqhkjOPQQDAgNI +ADBFAiAJycukC7hroy2QaM+ORchMwba7A83f5qSjdnDmM/h8AQIhAMx0AbU4nJlF +j2iAKlVsZaPze+F3OfBbm0Jg7emfFmx4 +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIBxzCCAW2gAwIBAgIQLEy0I3NtCyk+vKrWiqWa9TAKBggqhkjOPQQDAjAwMS4w +LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI1IEVDQyBSb290MB4X +DTI1MTEyOTE4MjU0N1oXDTI1MTIwNjE4MjU0N1owMzExMC8GA1UEAxMoQ2FkZHkg +TG9jYWwgQXV0aG9yaXR5IC0gRUNDIEludGVybWVkaWF0ZTBZMBMGByqGSM49AgEG +CCqGSM49AwEHA0IABBzP8BZUlO8uEk7c09Sl3I68CS+AC60w+l+DIKuaqhi+sCJM +ksM3MFZ6SfGs8rURi6MZqqkRfJqsF6ma/ko/oiyjZjBkMA4GA1UdDwEB/wQEAwIB +BjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBTIYQ24JwqfetB05clZWv1C +yrJLlDAfBgNVHSMEGDAWgBREcndLnTskIjkt5DalMgkrk+/+iDAKBggqhkjOPQQD +AgNIADBFAiAZ1KKvFsJGdbCSbTpEl5CQQrPf7PQzYN7w9AFpcGl3iQIhAKMy7uy8 +Hr0w5vrl/1R9FcrvNZKsDwquCBVr/BKAAIsk +-----END CERTIFICATE----- diff --git a/data/caddy/data/certificates/local/test2.localhost/test2.localhost.json b/data/caddy/data/certificates/local/test2.localhost/test2.localhost.json new file mode 100644 index 00000000..b8ca30bb --- /dev/null +++ b/data/caddy/data/certificates/local/test2.localhost/test2.localhost.json @@ -0,0 +1,6 @@ +{ + "sans": [ + "test2.localhost" + ], + "issuer_data": null +} diff --git a/data/caddy/data/certificates/local/test2.localhost/test2.localhost.key b/data/caddy/data/certificates/local/test2.localhost/test2.localhost.key new file mode 100644 index 00000000..1a706ea5 --- /dev/null +++ b/data/caddy/data/certificates/local/test2.localhost/test2.localhost.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIHuTybGDaH2llLl0Ye/IRlcL7UEluaswZWqFHo7A4WZyoAoGCCqGSM49 +AwEHoUQDQgAEVutAqanieVOMQ4EtKrXcaMSJjAbX4AUHS/OLYWVLtmlG5/z8DbJ6 +ZaNcyuk911ED7OqDKeL09wMtB4ZGSSPLtw== +-----END EC PRIVATE KEY----- diff --git a/data/caddy/data/last_clean.json b/data/caddy/data/last_clean.json new file mode 100644 index 00000000..933ad006 --- /dev/null +++ b/data/caddy/data/last_clean.json @@ -0,0 +1 @@ +{"tls":{"timestamp":"2025-11-29T18:19:07.702634586Z","instance_id":"2acc9ef3-fc3e-40f5-9462-d6682722eb94"}} diff --git a/data/caddy/data/pki/authorities/local/intermediate.crt b/data/caddy/data/pki/authorities/local/intermediate.crt new file mode 100644 index 00000000..521604c9 --- /dev/null +++ b/data/caddy/data/pki/authorities/local/intermediate.crt @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIBxzCCAW2gAwIBAgIQLEy0I3NtCyk+vKrWiqWa9TAKBggqhkjOPQQDAjAwMS4w +LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI1IEVDQyBSb290MB4X +DTI1MTEyOTE4MjU0N1oXDTI1MTIwNjE4MjU0N1owMzExMC8GA1UEAxMoQ2FkZHkg +TG9jYWwgQXV0aG9yaXR5IC0gRUNDIEludGVybWVkaWF0ZTBZMBMGByqGSM49AgEG +CCqGSM49AwEHA0IABBzP8BZUlO8uEk7c09Sl3I68CS+AC60w+l+DIKuaqhi+sCJM +ksM3MFZ6SfGs8rURi6MZqqkRfJqsF6ma/ko/oiyjZjBkMA4GA1UdDwEB/wQEAwIB +BjASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBTIYQ24JwqfetB05clZWv1C +yrJLlDAfBgNVHSMEGDAWgBREcndLnTskIjkt5DalMgkrk+/+iDAKBggqhkjOPQQD +AgNIADBFAiAZ1KKvFsJGdbCSbTpEl5CQQrPf7PQzYN7w9AFpcGl3iQIhAKMy7uy8 +Hr0w5vrl/1R9FcrvNZKsDwquCBVr/BKAAIsk +-----END CERTIFICATE----- diff --git a/data/caddy/data/pki/authorities/local/intermediate.key b/data/caddy/data/pki/authorities/local/intermediate.key new file mode 100644 index 00000000..583d13c8 --- /dev/null +++ b/data/caddy/data/pki/authorities/local/intermediate.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIK62nlQXS+XmU6lLY1dkxanQW+5C+hDMRkAyMeLVPDrioAoGCCqGSM49 +AwEHoUQDQgAEHM/wFlSU7y4STtzT1KXcjrwJL4ALrTD6X4Mgq5qqGL6wIkySwzcw +VnpJ8azytRGLoxmqqRF8mqwXqZr+Sj+iLA== +-----END EC PRIVATE KEY----- diff --git a/data/caddy/data/pki/authorities/local/root.crt b/data/caddy/data/pki/authorities/local/root.crt new file mode 100644 index 00000000..525992ed --- /dev/null +++ b/data/caddy/data/pki/authorities/local/root.crt @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE----- +MIIBojCCAUmgAwIBAgIQFfTjqoMpNZnTSWKmX53qCzAKBggqhkjOPQQDAjAwMS4w +LAYDVQQDEyVDYWRkeSBMb2NhbCBBdXRob3JpdHkgLSAyMDI1IEVDQyBSb290MB4X +DTI1MTEyOTE4MjU0N1oXDTM1MTAwODE4MjU0N1owMDEuMCwGA1UEAxMlQ2FkZHkg +TG9jYWwgQXV0aG9yaXR5IC0gMjAyNSBFQ0MgUm9vdDBZMBMGByqGSM49AgEGCCqG +SM49AwEHA0IABPSnVwHAJdl5JJN8JT2K0VxGmtXMx1qMeQIq3bG891mR/Fa889/k +PS8lb/txO1kDbkS46ZJZn1+iWRYGroHM9iejRTBDMA4GA1UdDwEB/wQEAwIBBjAS +BgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQWBBREcndLnTskIjkt5DalMgkrk+/+ +iDAKBggqhkjOPQQDAgNHADBEAiBcfxd1wNE1WakMLWMYU2kGCUTyB/S9MD0vlYtL +AmTaUQIgJQ4Og2/PSGhG0UYGpICBI/dhxVkm7HQGKDiTaUNDHcE= +-----END CERTIFICATE----- diff --git a/data/caddy/data/pki/authorities/local/root.key b/data/caddy/data/pki/authorities/local/root.key new file mode 100644 index 00000000..ddee05de --- /dev/null +++ b/data/caddy/data/pki/authorities/local/root.key @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIE4Z8xnl0mswc0hJile7xtFbVWhqcvgYS8ofcOY9rhJhoAoGCCqGSM49 +AwEHoUQDQgAE9KdXAcAl2Xkkk3wlPYrRXEaa1czHWox5Airdsbz3WZH8Vrzz3+Q9 +LyVv+3E7WQNuRLjpklmfX6JZFgaugcz2Jw== +-----END EC PRIVATE KEY----- diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts index 96d67311..2d22f67c 100644 --- a/frontend/src/api/notifications.ts +++ b/frontend/src/api/notifications.ts @@ -50,3 +50,41 @@ export const previewProvider = async (provider: Partial, d const response = await client.post('/notifications/providers/preview', payload); return response.data; }; + +// External (saved) templates API +export interface ExternalTemplate { + id: string; + name: string; + description?: string; + config?: string; + template?: string; + created_at?: string; +} + +export const getExternalTemplates = async () => { + const response = await client.get('/notifications/external-templates'); + return response.data; +}; + +export const createExternalTemplate = async (data: Partial) => { + const response = await client.post('/notifications/external-templates', data); + return response.data; +}; + +export const updateExternalTemplate = async (id: string, data: Partial) => { + const response = await client.put(`/notifications/external-templates/${id}`, data); + return response.data; +}; + +export const deleteExternalTemplate = async (id: string) => { + await client.delete(`/notifications/external-templates/${id}`); +}; + +export const previewExternalTemplate = async (templateId?: string, template?: string, data?: Record) => { + const payload: any = {}; + if (templateId) payload.template_id = templateId; + if (template) payload.template = template; + if (data) payload.data = data; + const response = await client.post('/notifications/external-templates/preview', payload); + return response.data; +}; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 4d40d1bc..4d34a865 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -94,7 +94,9 @@ export default function Layout({ children }: LayoutProps) { `}>
{isCollapsed ? ( - Charon + Charon + + ) : ( Charon )} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 9956199d..5508ad8e 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -60,7 +60,9 @@ export default function Login() {
- Charon + Charon + +
diff --git a/frontend/src/pages/Notifications.tsx b/frontend/src/pages/Notifications.tsx index 0c26c160..ca8c7749 100644 --- a/frontend/src/pages/Notifications.tsx +++ b/frontend/src/pages/Notifications.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { getProviders, createProvider, updateProvider, deleteProvider, testProvider, getTemplates, previewProvider, NotificationProvider } from '../api/notifications'; +import { getProviders, createProvider, updateProvider, deleteProvider, testProvider, getTemplates, previewProvider, NotificationProvider, getExternalTemplates, previewExternalTemplate, ExternalTemplate, createExternalTemplate, updateExternalTemplate, deleteExternalTemplate } from '../api/notifications'; import { Card } from '../components/ui/Card'; import { Button } from '../components/ui/Button'; import { Bell, Plus, Trash2, Edit2, Send, Check, X, Loader2 } from 'lucide-react'; @@ -51,15 +51,22 @@ const ProviderForm: React.FC<{ setPreviewContent(null); setPreviewError(null); try { - const res = await previewProvider(formData as Partial); - if (res.parsed) setPreviewContent(JSON.stringify(res.parsed, null, 2)); else setPreviewContent(res.rendered); + // If using an external saved template (id), call previewExternalTemplate with template_id + if (formData.template && typeof formData.template === 'string' && formData.template.length === 36) { + const res = await previewExternalTemplate(formData.template, undefined, undefined); + if (res.parsed) setPreviewContent(JSON.stringify(res.parsed, null, 2)); else setPreviewContent(res.rendered); + } else { + const res = await previewProvider(formData as Partial); + if (res.parsed) setPreviewContent(JSON.stringify(res.parsed, null, 2)); else setPreviewContent(res.rendered); + } } catch (err: any) { setPreviewError(err?.response?.data?.error || err?.message || 'Failed to generate preview'); } }; const type = watch('type'); - const { data: templatesList } = useQuery({ queryKey: ['notificationTemplates'], queryFn: getTemplates }); + const { data: builtins } = useQuery({ queryKey: ['notificationTemplates'], queryFn: getTemplates }); + const { data: externalTemplates } = useQuery({ queryKey: ['externalTemplates'], queryFn: getExternalTemplates }); const template = watch('template'); const setTemplate = (templateStr: string, templateName?: string) => { @@ -125,7 +132,12 @@ const ProviderForm: React.FC<{
@@ -213,16 +225,82 @@ const ProviderForm: React.FC<{ ); }; +const TemplateForm: React.FC<{ + initialData?: Partial; + onClose: () => void; + onSubmit: (data: Partial) => void; + }> = ({ initialData, onClose, onSubmit }) => { + const { register, handleSubmit, watch } = useForm({ + defaultValues: initialData || { template: 'custom', config: '' } + }); + + const [preview, setPreview] = useState(null); + const [previewErr, setPreviewErr] = useState(null); + + const handlePreview = async () => { + setPreview(null); + setPreviewErr(null); + const form = watch(); + try { + const res = await previewExternalTemplate(undefined, form.config, { Title: 'Preview Title', Message: 'Preview Message', Time: new Date().toISOString(), EventType: 'preview' }); + if (res.parsed) setPreview(JSON.stringify(res.parsed, null, 2)); else setPreview(res.rendered); + } catch (err: any) { + setPreviewErr(err?.response?.data?.error || err?.message || 'Preview failed'); + } + }; + + return ( + +
+ + +
+
+ + +
+
+ + +
+
+ +