Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
bd5d30b
Initial plan
Copilot Oct 17, 2025
e68ef34
Add backend notification system with Discord and Email support
Copilot Oct 17, 2025
a4168bb
Add frontend notification settings UI
Copilot Oct 17, 2025
025c42c
Add notification system documentation
Copilot Oct 17, 2025
768fb8a
Improve Gmail security documentation for App Passwords
Copilot Oct 17, 2025
fc43077
Merge branch 'main' into copilot/add-notification-support
kmendell Oct 17, 2025
e688160
Fix CodeQL security issues in notification service
Copilot Oct 17, 2025
47ad9ee
format
kmendell Oct 17, 2025
2aa5a90
lints
kmendell Oct 17, 2025
3764ba3
frontend
kmendell Oct 17, 2025
827f332
more frontend tweaks
kmendell Oct 17, 2025
4df8f12
better frontend
kmendell Oct 17, 2025
59b2fb0
lints
kmendell Oct 17, 2025
5e620d6
sanitize headers
kmendell Oct 17, 2025
81b1822
proper email handling
kmendell Oct 17, 2025
4e98d84
refactor
kmendell Oct 17, 2025
4e62bc3
cleanup
kmendell Oct 17, 2025
dfc3525
remove autocomplete
kmendell Oct 17, 2025
3d31cde
Merge branch 'main' into copilot/add-notification-support
kmendell Oct 17, 2025
41ab1ae
Merge branch 'main' into copilot/add-notification-support
kmendell Oct 18, 2025
8f876c8
Merge branch 'main' into copilot/add-notification-support
kmendell Oct 19, 2025
6e62118
add email templates
kmendell Oct 19, 2025
9a6e9e0
Merge branch 'main' into copilot/add-notification-support
kmendell Oct 20, 2025
5d080e0
Merge branch 'main' into copilot/add-notification-support
kmendell Oct 21, 2025
2296e20
fix lockfile
kmendell Oct 21, 2025
e4666aa
Merge branch 'main' into copilot/add-notification-support
kmendell Oct 21, 2025
643b0c4
fix lockfile
kmendell Oct 21, 2025
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
2 changes: 2 additions & 0 deletions backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ require (
github.com/docker/distribution v2.8.3+incompatible
github.com/docker/docker v28.5.1+incompatible
github.com/docker/go-connections v0.6.0
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/fsnotify/fsnotify v1.9.0
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.11.0
Expand Down
4 changes: 4 additions & 0 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@ github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+m
github.com/dvsekhvalnov/jose2go v0.0.0-20170216131308-f21a8cedbbae/go.mod h1:7BvyPhdbLxMXIYTFPLsyJRFMsKmOZnQmzh6Gb+uquuM=
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg=
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
Expand Down
121 changes: 121 additions & 0 deletions backend/internal/api/notification_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package api

import (
"net/http"

"github.com/gin-gonic/gin"
"github.com/ofkm/arcane-backend/internal/dto"
"github.com/ofkm/arcane-backend/internal/middleware"
"github.com/ofkm/arcane-backend/internal/services"
)

type NotificationHandler struct {
notificationService *services.NotificationService
}

func NewNotificationHandler(group *gin.RouterGroup, notificationService *services.NotificationService, authMiddleware *middleware.AuthMiddleware) {
handler := &NotificationHandler{
notificationService: notificationService,
}

notifications := group.Group("/environments/:id/notifications")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: The :id parameter from the environment route is never used in any handler methods, creating inconsistency between the API design and implementation

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/internal/api/notification_handler.go
Line: 21:21

Comment:
**logic:** The `:id` parameter from the environment route is never used in any handler methods, creating inconsistency between the API design and implementation

How can I resolve this? If you propose a fix, please make it concise.

notifications.Use(authMiddleware.WithAdminRequired().Add())
{
notifications.GET("/settings", handler.GetAllSettings)
notifications.GET("/settings/:provider", handler.GetSettings)
notifications.POST("/settings", handler.CreateOrUpdateSettings)
notifications.DELETE("/settings/:provider", handler.DeleteSettings)
notifications.POST("/test/:provider", handler.TestNotification)
}
}

func (h *NotificationHandler) GetAllSettings(c *gin.Context) {
settings, err := h.notificationService.GetAllSettings(c.Request.Context())
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Consider extracting environment ID from URL path if notifications should be environment-scoped: envID := c.Param("id")

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/internal/api/notification_handler.go
Line: 33:33

Comment:
**style:** Consider extracting environment ID from URL path if notifications should be environment-scoped: `envID := c.Param("id")`

How can I resolve this? If you propose a fix, please make it concise.

if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}

// Map to DTOs
responses := make([]dto.NotificationSettingsResponse, len(settings))
for i, setting := range settings {
responses[i] = dto.NotificationSettingsResponse{
ID: setting.ID,
Provider: setting.Provider,
Enabled: setting.Enabled,
Config: setting.Config,
}
}

c.JSON(http.StatusOK, responses)
}

func (h *NotificationHandler) GetSettings(c *gin.Context) {
provider := c.Param("provider")

settings, err := h.notificationService.GetSettingsByProvider(c.Request.Context(), provider)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Settings not found"})
return
}

response := dto.NotificationSettingsResponse{
ID: settings.ID,
Provider: settings.Provider,
Enabled: settings.Enabled,
Config: settings.Config,
}

c.JSON(http.StatusOK, response)
}

func (h *NotificationHandler) CreateOrUpdateSettings(c *gin.Context) {
var req dto.NotificationSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

settings, err := h.notificationService.CreateOrUpdateSettings(
c.Request.Context(),
req.Provider,
req.Enabled,
req.Config,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}

response := dto.NotificationSettingsResponse{
ID: settings.ID,
Provider: settings.Provider,
Enabled: settings.Enabled,
Config: settings.Config,
}

c.JSON(http.StatusOK, response)
}

func (h *NotificationHandler) DeleteSettings(c *gin.Context) {
provider := c.Param("provider")

if err := h.notificationService.DeleteSettings(c.Request.Context(), provider); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}

c.JSON(http.StatusOK, gin.H{"message": "Settings deleted successfully"})
}

func (h *NotificationHandler) TestNotification(c *gin.Context) {
provider := c.Param("provider")
testType := c.DefaultQuery("type", "simple") // "simple" or "image-update"

if err := h.notificationService.TestNotification(c.Request.Context(), provider, testType); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}

c.JSON(http.StatusOK, gin.H{"message": "Test notification sent successfully"})
}
1 change: 1 addition & 0 deletions backend/internal/bootstrap/router_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ func setupRouter(cfg *config.Config, appServices *Services) *gin.Engine {
api.NewSystemHandler(apiGroup, appServices.Docker, appServices.System, authMiddleware, cfg)
api.NewUpdaterHandler(apiGroup, appServices.Updater, authMiddleware)
api.NewVolumeHandler(apiGroup, appServices.Docker, appServices.Volume, authMiddleware)
api.NewNotificationHandler(apiGroup, appServices.Notification, authMiddleware)
api.NewSettingsHandler(apiGroup, appServices.Settings, appServices.SettingsSearch, authMiddleware)
api.NewCustomizeHandler(apiGroup, appServices.CustomizeSearch, authMiddleware)

Expand Down
4 changes: 3 additions & 1 deletion backend/internal/bootstrap/services_bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type Services struct {
Updater *services.UpdaterService
Event *services.EventService
Version *services.VersionService
Notification *services.NotificationService
}

func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config, httpClient *http.Client) (svcs *Services, dockerSrvice *services.DockerClientService, err error) {
Expand All @@ -50,7 +51,8 @@ func initializeServices(ctx context.Context, db *database.DB, cfg *config.Config
svcs.Docker = dockerClient
svcs.User = services.NewUserService(db)
svcs.ContainerRegistry = services.NewContainerRegistryService(db)
svcs.ImageUpdate = services.NewImageUpdateService(db, svcs.Settings, svcs.ContainerRegistry, svcs.Docker, svcs.Event)
svcs.Notification = services.NewNotificationService(db, cfg)
svcs.ImageUpdate = services.NewImageUpdateService(db, svcs.Settings, svcs.ContainerRegistry, svcs.Docker, svcs.Event, svcs.Notification)
svcs.Image = services.NewImageService(db, svcs.Docker, svcs.ContainerRegistry, svcs.ImageUpdate, svcs.Event)
svcs.Project = services.NewProjectService(db, svcs.Settings, svcs.Event, svcs.Image)
svcs.Environment = services.NewEnvironmentService(db, httpClient)
Expand Down
16 changes: 16 additions & 0 deletions backend/internal/dto/notification_dto.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package dto

import "github.com/ofkm/arcane-backend/internal/models"

type NotificationSettingsRequest struct {
Provider string `json:"provider" binding:"required"`
Enabled bool `json:"enabled"`
Config models.JSON `json:"config" binding:"required"`
}

type NotificationSettingsResponse struct {
ID uint `json:"id"`
Provider string `json:"provider"`
Enabled bool `json:"enabled"`
Config models.JSON `json:"config"`
}
65 changes: 65 additions & 0 deletions backend/internal/models/notification.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package models

import (
"time"
)

type NotificationProvider string

const (
NotificationProviderDiscord NotificationProvider = "discord"
NotificationProviderEmail NotificationProvider = "email"
)

type EmailTLSMode string

const (
EmailTLSModeNone EmailTLSMode = "none"
EmailTLSModeStartTLS EmailTLSMode = "starttls"
EmailTLSModeSSL EmailTLSMode = "ssl"
)

type NotificationSettings struct {
ID uint `json:"id" gorm:"primaryKey"`
Provider string `json:"provider" gorm:"not null;index"`
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Provider field should use NotificationProvider type instead of string for better type safety and validation

Context Used: Rule from dashboard - GoLang Best Practices

Follow idiomatic Go patterns and conventions
Handle errors explicitly, don’t ... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/internal/models/notification.go
Line: 24:24

Comment:
**style:** Provider field should use NotificationProvider type instead of string for better type safety and validation

**Context Used:** Rule from `dashboard` - GoLang Best Practices

Follow idiomatic Go patterns and conventions
Handle errors explicitly, don’t ... ([source](https://app.greptile.com/review/custom-context?memory=214b40a8-9695-4738-986d-5949b5d65ff1))

How can I resolve this? If you propose a fix, please make it concise.

Enabled bool `json:"enabled" gorm:"default:false"`
Config JSON `json:"config" gorm:"type:jsonb"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

func (NotificationSettings) TableName() string {
return "notification_settings"
}

type NotificationLog struct {
ID uint `json:"id" gorm:"primaryKey"`
Provider string `json:"provider" gorm:"not null;index"`
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Provider field should use NotificationProvider type instead of string for consistency

Context Used: Rule from dashboard - GoLang Best Practices

Follow idiomatic Go patterns and conventions
Handle errors explicitly, don’t ... (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/internal/models/notification.go
Line: 37:37

Comment:
**style:** Provider field should use NotificationProvider type instead of string for consistency

**Context Used:** Rule from `dashboard` - GoLang Best Practices

Follow idiomatic Go patterns and conventions
Handle errors explicitly, don’t ... ([source](https://app.greptile.com/review/custom-context?memory=214b40a8-9695-4738-986d-5949b5d65ff1))

How can I resolve this? If you propose a fix, please make it concise.

ImageRef string `json:"imageRef" gorm:"not null"`
Status string `json:"status" gorm:"not null"`
Error *string `json:"error,omitempty"`
Metadata JSON `json:"metadata" gorm:"type:jsonb"`
SentAt time.Time `json:"sentAt" gorm:"not null;index"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

func (NotificationLog) TableName() string {
return "notification_logs"
}

type DiscordConfig struct {
WebhookURL string `json:"webhookUrl"`
Username string `json:"username,omitempty"`
AvatarURL string `json:"avatarUrl,omitempty"`
}

type EmailConfig struct {
SMTPHost string `json:"smtpHost"`
SMTPPort int `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"`
SMTPPassword string `json:"smtpPassword"`
FromAddress string `json:"fromAddress"`
ToAddresses []string `json:"toAddresses"`
TLSMode EmailTLSMode `json:"tlsMode"`
}
34 changes: 23 additions & 11 deletions backend/internal/services/image_update_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,12 @@ import (
)

type ImageUpdateService struct {
db *database.DB
settingsService *SettingsService
registryService *ContainerRegistryService
dockerService *DockerClientService
eventService *EventService
db *database.DB
settingsService *SettingsService
registryService *ContainerRegistryService
dockerService *DockerClientService
eventService *EventService
notificationService *NotificationService
}

type ImageParts struct {
Expand All @@ -32,13 +33,14 @@ type ImageParts struct {
Tag string
}

func NewImageUpdateService(db *database.DB, settingsService *SettingsService, registryService *ContainerRegistryService, dockerService *DockerClientService, eventService *EventService) *ImageUpdateService {
func NewImageUpdateService(db *database.DB, settingsService *SettingsService, registryService *ContainerRegistryService, dockerService *DockerClientService, eventService *EventService, notificationService *NotificationService) *ImageUpdateService {
return &ImageUpdateService{
db: db,
settingsService: settingsService,
registryService: registryService,
dockerService: dockerService,
eventService: eventService,
db: db,
settingsService: settingsService,
registryService: registryService,
dockerService: dockerService,
eventService: eventService,
notificationService: notificationService,
}
}

Expand Down Expand Up @@ -102,6 +104,16 @@ func (s *ImageUpdateService) CheckImageUpdate(ctx context.Context, imageRef stri
slog.String("imageRef", imageRef),
slog.String("error", saveErr.Error()))
}

// Send notification if update is available
if digestResult.HasUpdate && s.notificationService != nil {
if notifErr := s.notificationService.SendImageUpdateNotification(ctx, imageRef, digestResult); notifErr != nil {
slog.WarnContext(ctx, "Failed to send update notification",
slog.String("imageRef", imageRef),
slog.String("error", notifErr.Error()))
}
}

return digestResult, nil
}

Expand Down
Loading
Loading