diff --git a/Makefile b/Makefile index 2cbf08d..b1f03bb 100644 --- a/Makefile +++ b/Makefile @@ -22,9 +22,8 @@ fix: goimports -w . golangci-lint run --fix npx @biomejs/biome@latest check --write --unsafe . - -vet: - go vet ./... + go mod download + go mod tidy clean: rm -f $(BINARY_NAME) $(BINARY_NAME)-* @@ -47,24 +46,9 @@ install-tools: signal-cli --version && \ echo "signal-cli installed successfully to /usr/local/bin/signal-cli" -deps: - go mod download - go mod tidy - check-updates: @go list -u -m -f '{{if not .Indirect}}{{.Path}} {{.Version}}{{if .Update}} [{{.Update.Version}}]{{end}}{{end}}' all | grep "\[" || echo "All dependencies are up to date" -update: - go get -u ./... - go mod tidy - -update-all: - go get -u all - go mod tidy - -docker-up: - docker compose -f docker-compose.dev.yml up -d - release: @if [ ! -f VERSION ]; then \ echo "Error: VERSION file not found"; \ diff --git a/VERSION b/VERSION index 44bb5d1..afaf360 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.1 \ No newline at end of file +1.0.0 \ No newline at end of file diff --git a/public/index.html b/public/index.html index 5f23a2b..de8e5ec 100644 --- a/public/index.html +++ b/public/index.html @@ -19,8 +19,8 @@ Registered Apps
+ hx-get="/fragment/apps" + hx-trigger="load, reload">
diff --git a/public/integration.js b/public/integration.js index fc0283e..30a1af2 100644 --- a/public/integration.js +++ b/public/integration.js @@ -26,6 +26,10 @@ function reloadIntegrations() { if (integrations) { htmx.trigger(integrations, 'reload'); } + const appsList = document.getElementById('apps-list'); + if (appsList) { + htmx.trigger(appsList, 'reload'); + } } async function handleAuthForm(form, endpoint, statusId, getPayload) { diff --git a/service/credentials/credentials.go b/service/credentials/credentials.go index 0ba955d..d1a3dd6 100644 --- a/service/credentials/credentials.go +++ b/service/credentials/credentials.go @@ -73,10 +73,10 @@ func NewStoreWithLogger(db *sql.DB, masterPassword string, logger *slog.Logger) return nil, err } - if err := store.CheckIntegrity(); err != nil { + if err := store.checkIntegrity(); err != nil { if strings.Contains(err.Error(), "corrupted") { logger.Warn("Credentials corrupted (API_KEY likely changed), clearing all integration credentials", "error", err) - if clearErr := store.ClearAll(); clearErr != nil { + if clearErr := store.clearAll(); clearErr != nil { logger.Error("Failed to clear corrupted credentials", "error", clearErr) } else { logger.Info("Cleared all integration credentials - please reconfigure integrations") @@ -88,16 +88,15 @@ func NewStoreWithLogger(db *sql.DB, masterPassword string, logger *slog.Logger) } func (s *Store) createTable() error { - query := ` + _, err := s.db.Exec(` CREATE TABLE IF NOT EXISTS integration_credentials ( -integration_type TEXT PRIMARY KEY, -credentials_encrypted BLOB NOT NULL, -enabled BOOLEAN NOT NULL DEFAULT 1, -created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -) - ` - _, err := s.db.Exec(query) + integration_type TEXT PRIMARY KEY, + credentials_encrypted BLOB NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `) return err } @@ -151,109 +150,72 @@ func (s *Store) decrypt(ciphertext []byte) ([]byte, error) { } func (s *Store) SaveProton(creds *ProtonCredentials) error { - return s.saveCredentials(IntegrationProton, creds) + return s.save(IntegrationProton, creds) } func (s *Store) GetProton() (*ProtonCredentials, error) { var creds ProtonCredentials - err := s.getCredentials(IntegrationProton, &creds) - if err != nil { - return nil, err - } - return &creds, nil + return &creds, s.load(IntegrationProton, &creds) } func (s *Store) SaveTelegram(creds *TelegramCredentials) error { - return s.saveCredentials(IntegrationTelegram, creds) + return s.save(IntegrationTelegram, creds) } func (s *Store) GetTelegram() (*TelegramCredentials, error) { var creds TelegramCredentials - err := s.getCredentials(IntegrationTelegram, &creds) - if err != nil { - return nil, err - } - return &creds, nil + return &creds, s.load(IntegrationTelegram, &creds) } -func (s *Store) SaveSignal(creds *SignalCredentials) error { - return s.saveCredentials(IntegrationSignal, creds) -} - -func (s *Store) GetSignal() (*SignalCredentials, error) { - var creds SignalCredentials - err := s.getCredentials(IntegrationSignal, &creds) - if err != nil { - return nil, err - } - return &creds, nil -} - -func (s *Store) saveCredentials(integrationType IntegrationType, credentials interface{}) error { +func (s *Store) save(integrationType IntegrationType, credentials interface{}) error { jsonData, err := json.Marshal(credentials) if err != nil { return fmt.Errorf("failed to marshal credentials: %w", err) } - encrypted, err := s.encrypt(jsonData) if err != nil { return fmt.Errorf("failed to encrypt credentials: %w", err) } - - query := ` + _, err = s.db.Exec(` INSERT INTO integration_credentials (integration_type, credentials_encrypted, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(integration_type) DO UPDATE SET credentials_encrypted = excluded.credentials_encrypted, updated_at = CURRENT_TIMESTAMP - ` - _, err = s.db.Exec(query, string(integrationType), encrypted) + `, string(integrationType), encrypted) return err } -func (s *Store) getCredentials(integrationType IntegrationType, dest interface{}) error { - query := ` - SELECT credentials_encrypted - FROM integration_credentials - WHERE integration_type = ? AND enabled = 1 - ` +func (s *Store) load(integrationType IntegrationType, dest interface{}) error { var encrypted []byte - err := s.db.QueryRow(query, string(integrationType)).Scan(&encrypted) + err := s.db.QueryRow(` + SELECT credentials_encrypted FROM integration_credentials + WHERE integration_type = ? AND enabled = 1 + `, string(integrationType)).Scan(&encrypted) if err == sql.ErrNoRows { return fmt.Errorf("integration %s not configured", integrationType) } if err != nil { return err } - decrypted, err := s.decrypt(encrypted) if err != nil { return fmt.Errorf("failed to decrypt credentials: %w", err) } - if err := json.Unmarshal(decrypted, dest); err != nil { return fmt.Errorf("failed to unmarshal credentials: %w", err) } - return nil } func (s *Store) DeleteIntegration(integrationType IntegrationType) error { - query := `DELETE FROM integration_credentials WHERE integration_type = ?` - _, err := s.db.Exec(query, string(integrationType)) - return err -} - -func (s *Store) SetEnabled(integrationType IntegrationType, enabled bool) error { - query := `UPDATE integration_credentials SET enabled = ? WHERE integration_type = ?` - _, err := s.db.Exec(query, enabled, string(integrationType)) + _, err := s.db.Exec(`DELETE FROM integration_credentials WHERE integration_type = ?`, string(integrationType)) return err } func (s *Store) IsEnabled(integrationType IntegrationType) (bool, error) { - query := `SELECT enabled FROM integration_credentials WHERE integration_type = ?` var enabled bool - err := s.db.QueryRow(query, string(integrationType)).Scan(&enabled) + err := s.db.QueryRow(`SELECT enabled FROM integration_credentials WHERE integration_type = ?`, string(integrationType)).Scan(&enabled) if err == sql.ErrNoRows { return false, nil } @@ -263,15 +225,13 @@ func (s *Store) IsEnabled(integrationType IntegrationType) (bool, error) { return enabled, nil } -func (s *Store) ClearAll() error { - query := `DELETE FROM integration_credentials` - _, err := s.db.Exec(query) +func (s *Store) clearAll() error { + _, err := s.db.Exec(`DELETE FROM integration_credentials`) return err } -func (s *Store) CheckIntegrity() error { - query := `SELECT integration_type, credentials_encrypted FROM integration_credentials` - rows, err := s.db.Query(query) +func (s *Store) checkIntegrity() error { + rows, err := s.db.Query(`SELECT integration_type, credentials_encrypted FROM integration_credentials`) if err != nil { return err } diff --git a/service/delivery/errors.go b/service/delivery/errors.go new file mode 100644 index 0000000..3cb4b4d --- /dev/null +++ b/service/delivery/errors.go @@ -0,0 +1,24 @@ +package delivery + +import "errors" + +type PermanentError struct { + Err error +} + +func (e *PermanentError) Error() string { + return e.Err.Error() +} + +func (e *PermanentError) Unwrap() error { + return e.Err +} + +func NewPermanentError(err error) error { + return &PermanentError{Err: err} +} + +func IsPermanent(err error) bool { + var permErr *PermanentError + return errors.As(err, &permErr) +} diff --git a/service/delivery/notification.go b/service/delivery/notification.go new file mode 100644 index 0000000..39b4d28 --- /dev/null +++ b/service/delivery/notification.go @@ -0,0 +1,16 @@ +package delivery + +type Action struct { + ID string `json:"id"` + Label string `json:"label"` + Endpoint string `json:"endpoint"` + Method string `json:"method"` + Data map[string]any `json:"data,omitempty"` +} + +type Notification struct { + Title string `json:"title,omitempty"` + Message string `json:"message"` + Tag string `json:"tag,omitempty"` + Actions []Action `json:"actions,omitempty"` +} diff --git a/service/delivery/publisher.go b/service/delivery/publisher.go new file mode 100644 index 0000000..39f4fd7 --- /dev/null +++ b/service/delivery/publisher.go @@ -0,0 +1,125 @@ +package delivery + +import ( + "log/slog" + "time" + + "prism/service/subscription" + "prism/service/util" +) + +type NotificationSender interface { + Send(sub *subscription.Subscription, notif Notification) error +} + +type Publisher struct { + Store *subscription.Store + senders map[subscription.Channel]NotificationSender + autoSubscribeFn func(string) error + logger *slog.Logger +} + +func NewPublisher(store *subscription.Store, logger *slog.Logger, autoSubscribeFn func(string) error) *Publisher { + return &Publisher{ + Store: store, + senders: make(map[subscription.Channel]NotificationSender), + autoSubscribeFn: autoSubscribeFn, + logger: logger, + } +} + +func (p *Publisher) RegisterSender(channel subscription.Channel, sender NotificationSender) { + p.senders[channel] = sender +} + +func (p *Publisher) DeregisterSender(channel subscription.Channel) { + delete(p.senders, channel) +} + +func (p *Publisher) HasChannel(channel subscription.Channel) bool { + _, ok := p.senders[channel] + return ok +} + +func (p *Publisher) IsValidChannel(channel subscription.Channel) bool { + return p.HasChannel(channel) +} + +func (p *Publisher) Publish(appName string, notif Notification) error { + app, err := p.Store.GetApp(appName) + if err != nil { + return util.LogError(p.logger, "Failed to get app", err, "app", appName) + } + + if app == nil || len(app.Subscriptions) == 0 { + if p.autoSubscribeFn != nil { + if err := p.autoSubscribeFn(appName); err != nil { + p.logger.Warn("Failed to auto-configure subscription", "app", appName, "error", err) + } + app, err = p.Store.GetApp(appName) + if err != nil { + return util.LogError(p.logger, "Failed to get app", err, "app", appName) + } + } + } + + if app == nil || len(app.Subscriptions) == 0 { + p.logger.Warn("No subscriptions found for app, dropping notification", "app", appName) + return nil + } + + var lastErr error + successCount := 0 + + for _, sub := range app.Subscriptions { + sender, ok := p.senders[sub.Channel] + if !ok { + p.logger.Debug("Skipping subscription for disabled channel", "channel", sub.Channel, "subscriptionID", sub.ID) + continue + } + + if err := p.sendWithRetry(sender, &sub, notif, appName, sub.ID); err != nil { + lastErr = err + } else { + successCount++ + } + } + + if successCount == 0 && lastErr != nil { + return lastErr + } + + return nil +} + +func (p *Publisher) sendWithRetry(sender NotificationSender, sub *subscription.Subscription, notif Notification, appName, subscriptionID string) error { + maxRetries := 10 + baseDelay := 500 * time.Millisecond + + var lastErr error + for attempt := 0; attempt < maxRetries; attempt++ { + err := sender.Send(sub, notif) + if err == nil { + if attempt > 0 { + p.logger.Info("Notification sent after retry", "app", appName, "subscriptionID", subscriptionID, "attempt", attempt+1) + } + return nil + } + + lastErr = err + + if IsPermanent(err) { + p.logger.Error("Permanent error, not retrying", "app", appName, "subscriptionID", subscriptionID, "error", err) + return err + } + + if attempt < maxRetries-1 { + delay := baseDelay * time.Duration(1< 0 { - d.logger.Info("Notification sent after retry", "app", appName, "subscriptionID", subscriptionID, "attempt", attempt+1) - } - return nil - } - - lastErr = err - - if IsPermanent(err) { - d.logger.Error("Permanent error, not retrying", "app", appName, "subscriptionID", subscriptionID, "error", err) - return err - } - - if attempt < maxRetries-1 { - delay := baseDelay * time.Duration(1< 0 { channels = append(channels, ChannelState{ - Channel: string(notification.ChannelWebPush), - Label: notification.ChannelWebPush.Label(), + Channel: string(subscription.ChannelWebPush), + Label: subscription.ChannelWebPush.Label(), Active: true, Toggleable: false, Subscriptions: webPushSubs, diff --git a/service/server/handlers_health.go b/service/server/handlers_health.go index 3b08443..d847d9f 100644 --- a/service/server/handlers_health.go +++ b/service/server/handlers_health.go @@ -2,10 +2,10 @@ package server import ( "encoding/json" + "fmt" "net/http" + "strings" "time" - - "prism/service/util" ) type healthResponse struct { @@ -30,47 +30,23 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { resp := healthResponse{ Version: s.version, - Uptime: util.FormatUptime(uptime), + Uptime: formatUptime(uptime), } if s.integrations.Signal != nil && s.integrations.Signal.IsEnabled() { - signalClient := s.integrations.Signal.GetHandlers().GetClient() - account, _ := signalClient.GetLinkedAccount() - if account != nil { - resp.Signal = &integrationHealth{ - Linked: true, - Account: account.Number, - } - } else { - resp.Signal = &integrationHealth{ - Linked: false, - } - } + linked, account := s.integrations.Signal.Health() + resp.Signal = &integrationHealth{Linked: linked, Account: account} } if s.integrations.Telegram != nil && s.integrations.Telegram.IsEnabled() { - telegramClient := s.integrations.Telegram.GetHandlers().GetClient() - if telegramClient != nil { - bot, err := telegramClient.GetMe() - if err == nil { - resp.Telegram = &integrationHealth{ - Linked: true, - Account: "@" + bot.Username, - } - } + if linked, account := s.integrations.Telegram.Health(); linked { + resp.Telegram = &integrationHealth{Linked: true, Account: account} } } if s.integrations.Proton != nil && s.integrations.Proton.IsEnabled() { - protonHandlers := s.integrations.Proton.GetHandlers() - if protonHandlers != nil && protonHandlers.IsEnabled() { - email, hasCredentials := protonHandlers.LoadFreshCredentials() - if hasCredentials { - resp.Proton = &integrationHealth{ - Linked: true, - Account: email, - } - } + if linked, account := s.integrations.Proton.Health(); linked { + resp.Proton = &integrationHealth{Linked: true, Account: account} } } @@ -79,3 +55,26 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { s.logger.Error("Failed to encode health response", "error", err) } } + +func formatUptime(d time.Duration) string { + days := int(d.Hours()) / 24 + hours := int(d.Hours()) % 24 + minutes := int(d.Minutes()) % 60 + seconds := int(d.Seconds()) % 60 + + parts := []string{} + if days > 0 { + parts = append(parts, fmt.Sprintf("%dd", days)) + } + if hours > 0 { + parts = append(parts, fmt.Sprintf("%dh", hours)) + } + if minutes > 0 { + parts = append(parts, fmt.Sprintf("%dm", minutes)) + } + if seconds > 0 || len(parts) == 0 { + parts = append(parts, fmt.Sprintf("%ds", seconds)) + } + + return strings.Join(parts, " ") +} diff --git a/service/server/handlers_ntfy.go b/service/server/handlers_ntfy.go index bb410a0..0816454 100644 --- a/service/server/handlers_ntfy.go +++ b/service/server/handlers_ntfy.go @@ -10,7 +10,7 @@ import ( "strings" "time" - "prism/service/notification" + "prism/service/delivery" "prism/service/util" "github.com/go-chi/chi/v5" @@ -20,25 +20,25 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) { appName := chi.URLParam(r, "appName") decodedAppName, err := url.PathUnescape(appName) if err != nil { - http.Error(w, "Invalid app name", http.StatusBadRequest) + util.JSONError(w, "Invalid app name", http.StatusBadRequest) return } appName = decodedAppName if appName == "" || strings.Contains(appName, "/") { - http.Error(w, "Invalid app name", http.StatusBadRequest) + util.JSONError(w, "Invalid app name", http.StatusBadRequest) return } body, err := io.ReadAll(r.Body) if err != nil { - http.Error(w, "Failed to read body", http.StatusBadRequest) + util.JSONError(w, "Failed to read body", http.StatusBadRequest) return } message, title := parseNtfyPayload(r, body) if message == "" { - http.Error(w, "Message required", http.StatusBadRequest) + util.JSONError(w, "Message required", http.StatusBadRequest) return } @@ -46,12 +46,12 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) { title = "" } - notif := notification.Notification{ + notif := delivery.Notification{ Title: title, Message: message, } - if err := s.dispatcher.Send(appName, notif); err != nil { + if err := s.publisher.Publish(appName, notif); err != nil { util.LogAndError(w, s.logger, "Failed to send notification", http.StatusInternalServerError, err, "app", appName) return } diff --git a/service/server/handlers_subscriptions.go b/service/server/handlers_subscriptions.go index 4c98e35..4333879 100644 --- a/service/server/handlers_subscriptions.go +++ b/service/server/handlers_subscriptions.go @@ -4,7 +4,7 @@ import ( "fmt" "net/http" - "prism/service/notification" + "prism/service/subscription" "prism/service/util" "github.com/go-chi/chi/v5" @@ -34,8 +34,8 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request ChatID: r.FormValue("chat_id"), } - channel := notification.Channel(form.Channel) - if !s.dispatcher.IsValidChannel(channel) { + channel := subscription.Channel(form.Channel) + if !s.publisher.IsValidChannel(channel) { util.SetToast(w, fmt.Sprintf("Invalid or unavailable channel: %s", form.Channel), "error") s.handleFragmentApps(w, r) return @@ -56,40 +56,33 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request } } - subID, err := notification.GenerateSubscriptionID() - if err != nil { - util.LogAndError(w, s.logger, "Failed to generate subscription ID", http.StatusInternalServerError, err) - return - } - - sub := notification.Subscription{ - ID: subID, + sub := subscription.Subscription{ AppName: appName, Channel: channel, } switch channel { - case notification.ChannelSignal: + case subscription.ChannelSignal: if s.integrations.Signal == nil || !s.integrations.Signal.IsEnabled() { util.SetToast(w, "Signal not configured", "error") s.handleFragmentApps(w, r) return } - client := s.integrations.Signal.GetHandlers().GetClient() + client := s.integrations.Signal.Handlers.Client account, err := client.GetLinkedAccount() if err != nil || account == nil { - util.SetToast(w, "Signal not linked - configure in Integrations below", "error") + util.SetToast(w, "Signal not linked - configure its integration below", "error") s.handleFragmentApps(w, r) return } if form.GroupID != "" { - sub.Signal = ¬ification.SignalSubscription{ + sub.Signal = &subscription.SignalSubscription{ GroupID: form.GroupID, Account: account.Number, } } else { - cachedGroup, err := s.store.GetSignalGroup(appName) + cachedGroup, err := s.integrations.Signal.Groups.Get(appName) if err != nil { util.LogAndError(w, s.logger, "Failed to check for cached Signal group", http.StatusInternalServerError, err) return @@ -98,7 +91,7 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request if cachedGroup != nil && cachedGroup.Account == account.Number { sub.Signal = cachedGroup } else { - signalSub, err := s.integrations.Signal.GetSender().CreateDefaultSignalSubscription(appName) + signalSub, err := s.integrations.Signal.Sender.CreateDefaultSignalSubscription(appName) if err != nil { util.LogAndError(w, s.logger, "Failed to create Signal subscription", http.StatusInternalServerError, err) return @@ -107,15 +100,15 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request } } - case notification.ChannelTelegram: + case subscription.ChannelTelegram: if s.integrations.Telegram == nil || !s.integrations.Telegram.IsEnabled() { util.SetToast(w, "Telegram not configured", "error") s.handleFragmentApps(w, r) return } - chatID := s.integrations.Telegram.GetHandlers().GetChatID() + chatID := s.integrations.Telegram.Handlers.GetChatID() if chatID == 0 { - util.SetToast(w, "Telegram not linked - configure in Integrations below", "error") + util.SetToast(w, "Telegram not linked - configure its integration below", "error") s.handleFragmentApps(w, r) return } @@ -123,7 +116,7 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request chatID = 0 fmt.Sscanf(form.ChatID, "%d", &chatID) } - sub.Telegram = ¬ification.TelegramSubscription{ + sub.Telegram = &subscription.TelegramSubscription{ ChatID: fmt.Sprintf("%d", chatID), } @@ -132,7 +125,7 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request return } - if err := s.store.AddSubscription(sub); err != nil { + if _, err := s.store.AddSubscription(sub); err != nil { util.LogAndError(w, s.logger, "Failed to create subscription", http.StatusInternalServerError, err) return } @@ -160,7 +153,7 @@ func (s *Server) handleDeleteSubscription(w http.ResponseWriter, r *http.Request } message := fmt.Sprintf("%s channel disabled", sub.Channel.Label()) - if sub.Channel == notification.ChannelWebPush { + if sub.Channel == subscription.ChannelWebPush { message = fmt.Sprintf("%s channel deleted", sub.Channel.Label()) } util.SetToast(w, message, "success") diff --git a/service/server/server.go b/service/server/server.go index d394a99..4543b07 100644 --- a/service/server/server.go +++ b/service/server/server.go @@ -2,21 +2,27 @@ package server import ( "context" + "database/sql" "embed" "fmt" "html/template" "io/fs" "log/slog" "net/http" + "os" + "path/filepath" "time" "prism/service/config" + "prism/service/delivery" "prism/service/integration" - "prism/service/notification" + "prism/service/subscription" "prism/service/util" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + + _ "modernc.org/sqlite" ) //go:embed templates/*.html @@ -26,8 +32,8 @@ type Server struct { startTime time.Time publicAssets embed.FS cfg *config.Config - store *notification.Store - dispatcher *notification.Dispatcher + store *subscription.Store + publisher *delivery.Publisher integrations *integration.Integrations logger *slog.Logger router *chi.Mux @@ -37,8 +43,26 @@ type Server struct { version string } +func openDB(dbPath string) (*sql.DB, error) { + if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { + return nil, fmt.Errorf("failed to create database directory: %w", err) + } + db, err := sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)") + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + return db, nil +} + func New(cfg *config.Config, publicAssets embed.FS, version string, logger *slog.Logger) (*Server, error) { - store, err := notification.NewStore(cfg.StoragePath) + db, err := openDB(cfg.StoragePath) + if err != nil { + return nil, err + } + + store, err := subscription.NewStore(db) if err != nil { return nil, fmt.Errorf("failed to create store: %w", err) } @@ -62,7 +86,7 @@ func New(cfg *config.Config, publicAssets embed.FS, version string, logger *slog s := &Server{ cfg: cfg, store: store, - dispatcher: integrations.Dispatcher, + publisher: integrations.Publisher, integrations: integrations, logger: logger, startTime: time.Now(), @@ -98,7 +122,7 @@ func (s *Server) setupRoutes() { } }) - integration.RegisterAll(s.integrations, r, s.cfg, s.store, s.logger, authMiddleware) + integration.RegisterAll(s.integrations, r, s.cfg, s.logger, authMiddleware) r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/apps", s.handleFragmentApps) r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/integrations", s.handleFragmentIntegrations) @@ -168,7 +192,7 @@ func (s *Server) Shutdown() error { return fmt.Errorf("failed to shutdown http server: %w", err) } - if err := s.store.Close(); err != nil { + if err := s.store.DB.Close(); err != nil { return fmt.Errorf("failed to close store: %w", err) } diff --git a/service/server/templates/app-list.html b/service/server/templates/app-list.html index 94eec7d..9dd74ce 100644 --- a/service/server/templates/app-list.html +++ b/service/server/templates/app-list.html @@ -45,7 +45,7 @@
{{else}}
- No channels enabled + No integrations linked
{{end}} diff --git a/service/subscription/channel.go b/service/subscription/channel.go new file mode 100644 index 0000000..8ac119b --- /dev/null +++ b/service/subscription/channel.go @@ -0,0 +1,39 @@ +package subscription + +type Channel string + +const ( + ChannelSignal Channel = "signal" + ChannelWebPush Channel = "webpush" + ChannelTelegram Channel = "telegram" +) + +func (c Channel) String() string { + return string(c) +} + +func (c Channel) Label() string { + switch c { + case ChannelSignal: + return "Signal" + case ChannelWebPush: + return "WebPush" + case ChannelTelegram: + return "Telegram" + default: + return string(c) + } +} + +func (c Channel) IsAvailable(signalEnabled bool, telegramEnabled bool) bool { + switch c { + case ChannelWebPush: + return true + case ChannelSignal: + return signalEnabled + case ChannelTelegram: + return telegramEnabled + default: + return false + } +} diff --git a/service/notification/store.go b/service/subscription/store.go similarity index 66% rename from service/notification/store.go rename to service/subscription/store.go index 5805903..a70bbda 100644 --- a/service/notification/store.go +++ b/service/subscription/store.go @@ -1,37 +1,19 @@ -package notification +package subscription import ( "database/sql" "fmt" - "os" - "path/filepath" - - _ "modernc.org/sqlite" ) type Store struct { - db *sql.DB + DB *sql.DB } -func NewStore(dbPath string) (*Store, error) { - dir := filepath.Dir(dbPath) - if err := os.MkdirAll(dir, 0755); err != nil { - return nil, fmt.Errorf("failed to create database directory: %w", err) - } - - db, err := sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)") - if err != nil { - return nil, fmt.Errorf("failed to open database: %w", err) - } - - db.SetMaxOpenConns(1) - db.SetMaxIdleConns(1) - - store := &Store{db: db} +func NewStore(db *sql.DB) (*Store, error) { + store := &Store{DB: db} if err := store.createTables(); err != nil { return nil, err } - return store, nil } @@ -41,7 +23,7 @@ func (s *Store) createTables() error { appName TEXT PRIMARY KEY )`, `CREATE TABLE IF NOT EXISTS subscriptions ( - id TEXT PRIMARY KEY, + id TEXT PRIMARY KEY DEFAULT (hex(randomblob(16))), appName TEXT NOT NULL, channel TEXT NOT NULL, signalGroupId TEXT, @@ -53,17 +35,11 @@ func (s *Store) createTables() error { vapidPrivateKey TEXT, FOREIGN KEY(appName) REFERENCES apps(appName) ON DELETE CASCADE )`, - `CREATE TABLE IF NOT EXISTS signal_groups ( - appName TEXT PRIMARY KEY, - groupId TEXT NOT NULL, - account TEXT NOT NULL, - FOREIGN KEY(appName) REFERENCES apps(appName) ON DELETE CASCADE - )`, `CREATE INDEX IF NOT EXISTS idx_subscriptions_appName ON subscriptions(appName)`, } for _, query := range queries { - if _, err := s.db.Exec(query); err != nil { + if _, err := s.DB.Exec(query); err != nil { return fmt.Errorf("failed to create tables: %w", err) } } @@ -71,29 +47,16 @@ func (s *Store) createTables() error { return nil } -func (s *Store) Close() error { - return s.db.Close() -} - -func (s *Store) GetDB() *sql.DB { - return s.db -} - func (s *Store) RegisterApp(appName string) error { - _, err := s.db.Exec(`INSERT INTO apps (appName) VALUES (?) ON CONFLICT(appName) DO NOTHING`, appName) + _, err := s.DB.Exec(`INSERT INTO apps (appName) VALUES (?) ON CONFLICT(appName) DO NOTHING`, appName) return err } -func (s *Store) AddSubscription(sub Subscription) error { +func (s *Store) AddSubscription(sub Subscription) (string, error) { if err := s.RegisterApp(sub.AppName); err != nil { - return err + return "", err } - query := ` - INSERT INTO subscriptions (id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ` - var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey *string if sub.Signal != nil { @@ -110,13 +73,17 @@ func (s *Store) AddSubscription(sub Subscription) error { vapidPrivateKey = &sub.WebPush.VapidPrivateKey } - _, err := s.db.Exec(query, sub.ID, sub.AppName, sub.Channel, signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey) - return err + var id string + err := s.DB.QueryRow(` + INSERT INTO subscriptions (appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id + `, sub.AppName, sub.Channel, signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey).Scan(&id) + + return id, err } func (s *Store) GetApp(appName string) (*App, error) { - query := `SELECT appName FROM apps WHERE appName = ?` - row := s.db.QueryRow(query, appName) + row := s.DB.QueryRow(`SELECT appName FROM apps WHERE appName = ?`, appName) var app App if err := row.Scan(&app.AppName); err == sql.ErrNoRows { @@ -135,12 +102,11 @@ func (s *Store) GetApp(appName string) (*App, error) { } func (s *Store) GetSubscriptions(appName string) ([]Subscription, error) { - query := ` + rows, err := s.DB.Query(` SELECT id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey FROM subscriptions WHERE appName = ? - ` - rows, err := s.db.Query(query, appName) + `, appName) if err != nil { return nil, err } @@ -188,8 +154,7 @@ func (s *Store) GetSubscriptions(appName string) ([]Subscription, error) { } func (s *Store) GetAllApps() ([]App, error) { - query := `SELECT appName FROM apps ORDER BY appName` - rows, err := s.db.Query(query) + rows, err := s.DB.Query(`SELECT appName FROM apps ORDER BY appName`) if err != nil { return nil, err } @@ -227,46 +192,22 @@ func (s *Store) GetAllApps() ([]App, error) { return apps, nil } -func (s *Store) GetSignalGroup(appName string) (*SignalSubscription, error) { - query := `SELECT groupId, account FROM signal_groups WHERE appName = ?` - row := s.db.QueryRow(query, appName) - - var groupID, account string - if err := row.Scan(&groupID, &account); err != nil { - if err == sql.ErrNoRows { - return nil, nil - } - return nil, err - } - - return &SignalSubscription{ - GroupID: groupID, - Account: account, - }, nil -} - -func (s *Store) SaveSignalGroup(appName string, sub *SignalSubscription) error { - if err := s.RegisterApp(appName); err != nil { - return err - } - - _, err := s.db.Exec(`INSERT INTO signal_groups (appName, groupId, account) VALUES (?, ?, ?) - ON CONFLICT(appName) DO UPDATE SET groupId=excluded.groupId, account=excluded.account`, appName, sub.GroupID, sub.Account) +func (s *Store) DeleteSubscription(subscriptionID string) error { + _, err := s.DB.Exec(`DELETE FROM subscriptions WHERE id = ?`, subscriptionID) return err } -func (s *Store) DeleteSubscription(subscriptionID string) error { - _, err := s.db.Exec(`DELETE FROM subscriptions WHERE id = ?`, subscriptionID) +func (s *Store) DeleteSubscriptionsByChannel(channel Channel) error { + _, err := s.DB.Exec(`DELETE FROM subscriptions WHERE channel = ?`, channel) return err } func (s *Store) GetSubscription(subscriptionID string) (*Subscription, error) { - query := ` + row := s.DB.QueryRow(` SELECT id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey FROM subscriptions WHERE id = ? - ` - row := s.db.QueryRow(query, subscriptionID) + `, subscriptionID) var sub Subscription var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString @@ -309,6 +250,6 @@ func (s *Store) GetSubscription(subscriptionID string) (*Subscription, error) { } func (s *Store) RemoveApp(appName string) error { - _, err := s.db.Exec(`DELETE FROM apps WHERE appName = ?`, appName) + _, err := s.DB.Exec(`DELETE FROM apps WHERE appName = ?`, appName) return err } diff --git a/service/subscription/subscription.go b/service/subscription/subscription.go new file mode 100644 index 0000000..57b9c4f --- /dev/null +++ b/service/subscription/subscription.go @@ -0,0 +1,35 @@ +package subscription + +type WebPushSubscription struct { + Endpoint string `json:"endpoint"` + P256dh string `json:"p256dh,omitempty"` + Auth string `json:"auth,omitempty"` + VapidPrivateKey string `json:"vapidPrivateKey,omitempty"` +} + +func (w *WebPushSubscription) HasEncryption() bool { + return w.P256dh != "" && w.Auth != "" && w.VapidPrivateKey != "" +} + +type SignalSubscription struct { + GroupID string `json:"groupId"` + Account string `json:"account"` +} + +type TelegramSubscription struct { + ChatID string `json:"chatId"` +} + +type Subscription struct { + ID string `json:"id"` + AppName string `json:"appName"` + Channel Channel `json:"channel"` + Signal *SignalSubscription `json:"signal,omitempty"` + WebPush *WebPushSubscription `json:"webPush,omitempty"` + Telegram *TelegramSubscription `json:"telegram,omitempty"` +} + +type App struct { + AppName string `json:"appName"` + Subscriptions []Subscription `json:"subscriptions"` +} diff --git a/service/util/format.go b/service/util/format.go deleted file mode 100644 index 85f697f..0000000 --- a/service/util/format.go +++ /dev/null @@ -1,30 +0,0 @@ -package util - -import ( - "fmt" - "strings" - "time" -) - -func FormatUptime(d time.Duration) string { - days := int(d.Hours()) / 24 - hours := int(d.Hours()) % 24 - minutes := int(d.Minutes()) % 60 - seconds := int(d.Seconds()) % 60 - - parts := []string{} - if days > 0 { - parts = append(parts, fmt.Sprintf("%dd", days)) - } - if hours > 0 { - parts = append(parts, fmt.Sprintf("%dh", hours)) - } - if minutes > 0 { - parts = append(parts, fmt.Sprintf("%dm", minutes)) - } - if seconds > 0 || len(parts) == 0 { - parts = append(parts, fmt.Sprintf("%ds", seconds)) - } - - return strings.Join(parts, " ") -} diff --git a/service/util/htmx.go b/service/util/htmx.go new file mode 100644 index 0000000..6ef1e1b --- /dev/null +++ b/service/util/htmx.go @@ -0,0 +1,18 @@ +package util + +import ( + "encoding/json" + "net/http" +) + +func SetToast(w http.ResponseWriter, message, toastType string) { + trigger := map[string]interface{}{ + "showToast": map[string]string{ + "message": message, + "type": toastType, + }, + } + if data, err := json.Marshal(trigger); err == nil { + w.Header().Set("HX-Trigger", string(data)) + } +} diff --git a/service/util/http.go b/service/util/http.go index 3699344..bff7de3 100644 --- a/service/util/http.go +++ b/service/util/http.go @@ -6,18 +6,6 @@ import ( "net/http" ) -func SetToast(w http.ResponseWriter, message, toastType string) { - trigger := map[string]interface{}{ - "showToast": map[string]string{ - "message": message, - "type": toastType, - }, - } - if data, err := json.Marshal(trigger); err == nil { - w.Header().Set("HX-Trigger", string(data)) - } -} - func LogAndError(w http.ResponseWriter, logger *slog.Logger, message string, code int, err error, attrs ...any) { logAttrs := append([]any{"error", err}, attrs...) logger.Error(message, logAttrs...) diff --git a/service/util/log.go b/service/util/log.go index 466efa0..4fe8866 100644 --- a/service/util/log.go +++ b/service/util/log.go @@ -65,7 +65,7 @@ func (h *ColorHandler) Handle(ctx context.Context, r slog.Record) error { color = colorReset } - timestamp := r.Time.Format("15:04:05") + timestamp := r.Time.Format("3:04:05 PM") _, _ = fmt.Fprintf(h.w, "%s%s%s [%s%s%s] %s", //nolint:errcheck colorGray, timestamp, colorReset, color, level, colorReset,