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}}