code cleanups and refactors

This commit is contained in:
Egor 2026-02-26 18:35:11 -08:00
parent 42689045f4
commit b5740b801e
47 changed files with 901 additions and 1023 deletions

View file

@ -22,9 +22,8 @@ fix:
goimports -w . goimports -w .
golangci-lint run --fix golangci-lint run --fix
npx @biomejs/biome@latest check --write --unsafe . npx @biomejs/biome@latest check --write --unsafe .
go mod download
vet: go mod tidy
go vet ./...
clean: clean:
rm -f $(BINARY_NAME) $(BINARY_NAME)-* rm -f $(BINARY_NAME) $(BINARY_NAME)-*
@ -47,24 +46,9 @@ install-tools:
signal-cli --version && \ signal-cli --version && \
echo "signal-cli installed successfully to /usr/local/bin/signal-cli" echo "signal-cli installed successfully to /usr/local/bin/signal-cli"
deps:
go mod download
go mod tidy
check-updates: 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" @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: release:
@if [ ! -f VERSION ]; then \ @if [ ! -f VERSION ]; then \
echo "Error: VERSION file not found"; \ echo "Error: VERSION file not found"; \

View file

@ -1 +1 @@
0.4.1 1.0.0

View file

@ -19,8 +19,8 @@
<span class="integration-name">Registered Apps</span> <span class="integration-name">Registered Apps</span>
</summary> </summary>
<div id="apps-list" class="card-content" <div id="apps-list" class="card-content"
hx-get="/fragment/apps" hx-get="/fragment/apps"
hx-trigger="load"> hx-trigger="load, reload">
<div class="loading"> <div class="loading">
<div class="spinner"></div> <div class="spinner"></div>
</div> </div>

View file

@ -26,6 +26,10 @@ function reloadIntegrations() {
if (integrations) { if (integrations) {
htmx.trigger(integrations, 'reload'); htmx.trigger(integrations, 'reload');
} }
const appsList = document.getElementById('apps-list');
if (appsList) {
htmx.trigger(appsList, 'reload');
}
} }
async function handleAuthForm(form, endpoint, statusId, getPayload) { async function handleAuthForm(form, endpoint, statusId, getPayload) {

View file

@ -73,10 +73,10 @@ func NewStoreWithLogger(db *sql.DB, masterPassword string, logger *slog.Logger)
return nil, err return nil, err
} }
if err := store.CheckIntegrity(); err != nil { if err := store.checkIntegrity(); err != nil {
if strings.Contains(err.Error(), "corrupted") { if strings.Contains(err.Error(), "corrupted") {
logger.Warn("Credentials corrupted (API_KEY likely changed), clearing all integration credentials", "error", err) 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) logger.Error("Failed to clear corrupted credentials", "error", clearErr)
} else { } else {
logger.Info("Cleared all integration credentials - please reconfigure integrations") 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 { func (s *Store) createTable() error {
query := ` _, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS integration_credentials ( CREATE TABLE IF NOT EXISTS integration_credentials (
integration_type TEXT PRIMARY KEY, integration_type TEXT PRIMARY KEY,
credentials_encrypted BLOB NOT NULL, credentials_encrypted BLOB NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT 1, enabled BOOLEAN NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) )
` `)
_, err := s.db.Exec(query)
return err return err
} }
@ -151,109 +150,72 @@ func (s *Store) decrypt(ciphertext []byte) ([]byte, error) {
} }
func (s *Store) SaveProton(creds *ProtonCredentials) error { func (s *Store) SaveProton(creds *ProtonCredentials) error {
return s.saveCredentials(IntegrationProton, creds) return s.save(IntegrationProton, creds)
} }
func (s *Store) GetProton() (*ProtonCredentials, error) { func (s *Store) GetProton() (*ProtonCredentials, error) {
var creds ProtonCredentials var creds ProtonCredentials
err := s.getCredentials(IntegrationProton, &creds) return &creds, s.load(IntegrationProton, &creds)
if err != nil {
return nil, err
}
return &creds, nil
} }
func (s *Store) SaveTelegram(creds *TelegramCredentials) error { func (s *Store) SaveTelegram(creds *TelegramCredentials) error {
return s.saveCredentials(IntegrationTelegram, creds) return s.save(IntegrationTelegram, creds)
} }
func (s *Store) GetTelegram() (*TelegramCredentials, error) { func (s *Store) GetTelegram() (*TelegramCredentials, error) {
var creds TelegramCredentials var creds TelegramCredentials
err := s.getCredentials(IntegrationTelegram, &creds) return &creds, s.load(IntegrationTelegram, &creds)
if err != nil {
return nil, err
}
return &creds, nil
} }
func (s *Store) SaveSignal(creds *SignalCredentials) error { func (s *Store) save(integrationType IntegrationType, credentials interface{}) 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 {
jsonData, err := json.Marshal(credentials) jsonData, err := json.Marshal(credentials)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal credentials: %w", err) return fmt.Errorf("failed to marshal credentials: %w", err)
} }
encrypted, err := s.encrypt(jsonData) encrypted, err := s.encrypt(jsonData)
if err != nil { if err != nil {
return fmt.Errorf("failed to encrypt credentials: %w", err) return fmt.Errorf("failed to encrypt credentials: %w", err)
} }
_, err = s.db.Exec(`
query := `
INSERT INTO integration_credentials (integration_type, credentials_encrypted, updated_at) INSERT INTO integration_credentials (integration_type, credentials_encrypted, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP) VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(integration_type) DO UPDATE SET ON CONFLICT(integration_type) DO UPDATE SET
credentials_encrypted = excluded.credentials_encrypted, credentials_encrypted = excluded.credentials_encrypted,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
` `, string(integrationType), encrypted)
_, err = s.db.Exec(query, string(integrationType), encrypted)
return err return err
} }
func (s *Store) getCredentials(integrationType IntegrationType, dest interface{}) error { func (s *Store) load(integrationType IntegrationType, dest interface{}) error {
query := `
SELECT credentials_encrypted
FROM integration_credentials
WHERE integration_type = ? AND enabled = 1
`
var encrypted []byte 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 { if err == sql.ErrNoRows {
return fmt.Errorf("integration %s not configured", integrationType) return fmt.Errorf("integration %s not configured", integrationType)
} }
if err != nil { if err != nil {
return err return err
} }
decrypted, err := s.decrypt(encrypted) decrypted, err := s.decrypt(encrypted)
if err != nil { if err != nil {
return fmt.Errorf("failed to decrypt credentials: %w", err) return fmt.Errorf("failed to decrypt credentials: %w", err)
} }
if err := json.Unmarshal(decrypted, dest); err != nil { if err := json.Unmarshal(decrypted, dest); err != nil {
return fmt.Errorf("failed to unmarshal credentials: %w", err) return fmt.Errorf("failed to unmarshal credentials: %w", err)
} }
return nil return nil
} }
func (s *Store) DeleteIntegration(integrationType IntegrationType) error { func (s *Store) DeleteIntegration(integrationType IntegrationType) error {
query := `DELETE FROM integration_credentials WHERE integration_type = ?` _, err := s.db.Exec(`DELETE FROM integration_credentials WHERE integration_type = ?`, string(integrationType))
_, 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))
return err return err
} }
func (s *Store) IsEnabled(integrationType IntegrationType) (bool, error) { func (s *Store) IsEnabled(integrationType IntegrationType) (bool, error) {
query := `SELECT enabled FROM integration_credentials WHERE integration_type = ?`
var enabled bool 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 { if err == sql.ErrNoRows {
return false, nil return false, nil
} }
@ -263,15 +225,13 @@ func (s *Store) IsEnabled(integrationType IntegrationType) (bool, error) {
return enabled, nil return enabled, nil
} }
func (s *Store) ClearAll() error { func (s *Store) clearAll() error {
query := `DELETE FROM integration_credentials` _, err := s.db.Exec(`DELETE FROM integration_credentials`)
_, err := s.db.Exec(query)
return err return err
} }
func (s *Store) CheckIntegrity() error { func (s *Store) checkIntegrity() error {
query := `SELECT integration_type, credentials_encrypted FROM integration_credentials` rows, err := s.db.Query(`SELECT integration_type, credentials_encrypted FROM integration_credentials`)
rows, err := s.db.Query(query)
if err != nil { if err != nil {
return err return err
} }

View file

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

View file

@ -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"`
}

View file

@ -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<<uint(attempt))
p.logger.Warn("Failed to send notification, retrying", "app", appName, "subscriptionID", subscriptionID, "attempt", attempt+1, "error", err, "retryIn", delay)
time.Sleep(delay)
}
}
p.logger.Error("Failed to send notification after retries", "app", appName, "subscriptionID", subscriptionID, "attempts", maxRetries, "error", lastErr)
return lastErr
}

View file

@ -1,10 +0,0 @@
package integration
import "embed"
//go:embed templates/*.html
var templates embed.FS
func GetTemplates() embed.FS {
return templates
}

View file

@ -2,99 +2,137 @@ package integration
import ( import (
"context" "context"
"database/sql" "embed"
"fmt" "fmt"
"html/template" "html/template"
"io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
"prism/service/config" "prism/service/config"
"prism/service/delivery"
"prism/service/integration/proton" "prism/service/integration/proton"
"prism/service/integration/signal" "prism/service/integration/signal"
"prism/service/integration/telegram" "prism/service/integration/telegram"
"prism/service/integration/webpush" "prism/service/integration/webpush"
"prism/service/notification" "prism/service/subscription"
"prism/service/util" "prism/service/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
//go:embed templates/*.html
var templates embed.FS
type Integration interface { type Integration interface {
RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, logger *slog.Logger)
Start(ctx context.Context, logger *slog.Logger) Start(ctx context.Context, logger *slog.Logger)
IsEnabled() bool IsEnabled() bool
Health() (linked bool, account string)
}
type AutoSubscriber interface {
AutoSubscribe(appName string, store *subscription.Store, publisher *delivery.Publisher) error
} }
type Integrations struct { type Integrations struct {
Dispatcher *notification.Dispatcher Publisher *delivery.Publisher
Signal *signal.Integration Signal *signal.Integration
Telegram *telegram.Integration Telegram *telegram.Integration
Proton *proton.Integration Proton *proton.Integration
store *subscription.Store
logger *slog.Logger
integrations []Integration integrations []Integration
} }
func Initialize(cfg *config.Config, store *notification.Store, logger *slog.Logger, baseTmpl *template.Template) (*Integrations, *template.Template, error) { func Initialize(cfg *config.Config, store *subscription.Store, logger *slog.Logger, baseTmpl *template.Template) (*Integrations, *template.Template, error) {
fragmentTmpl := baseTmpl
var err error var err error
baseTmpl, err = baseTmpl.ParseFS(templates, "templates/*.html")
fragmentTmpl, err = fragmentTmpl.ParseFS(GetTemplates(), "templates/*.html")
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to parse integration templates: %w", err) return nil, nil, fmt.Errorf("failed to parse integration templates: %w", err)
} }
fragmentTmpl, err = fragmentTmpl.ParseFS(signal.GetTemplates(), "templates/*.html")
if err != nil {
return nil, nil, fmt.Errorf("failed to parse signal templates: %w", err)
}
fragmentTmpl, err = fragmentTmpl.ParseFS(telegram.GetTemplates(), "templates/*.html")
if err != nil {
return nil, nil, fmt.Errorf("failed to parse telegram templates: %w", err)
}
fragmentTmpl, err = fragmentTmpl.ParseFS(proton.GetTemplates(), "templates/*.html")
if err != nil {
return nil, nil, fmt.Errorf("failed to parse proton templates: %w", err)
}
tmplRenderer := util.NewTemplateRenderer(fragmentTmpl)
var signalIntegration *signal.Integration var signalIntegration *signal.Integration
var telegramIntegration *telegram.Integration var telegramIntegration *telegram.Integration
dispatcher := notification.NewDispatcher(store, logger)
var integrations []Integration
var protonIntegration *proton.Integration var protonIntegration *proton.Integration
integrations := []Integration{webpush.NewIntegration(store, logger)}
if cfg.EnableSignal { if cfg.EnableSignal {
signalIntegration = signal.NewIntegration(cfg, store, logger, tmplRenderer) tmplRenderer, err := newIntegrationRenderer(baseTmpl, "signal", signal.Templates)
if signalSender := signalIntegration.GetSender(); signalSender != nil { if err != nil {
dispatcher.RegisterSender(notification.ChannelSignal, signalSender) return nil, nil, err
} }
signalIntegration = signal.NewIntegration(cfg, store, logger, tmplRenderer)
integrations = append(integrations, signalIntegration) integrations = append(integrations, signalIntegration)
} }
if cfg.EnableTelegram { if cfg.EnableTelegram {
telegramIntegration = telegram.NewIntegration(cfg, store, logger, tmplRenderer) tmplRenderer, err := newIntegrationRenderer(baseTmpl, "telegram", telegram.Templates)
if telegramSender := telegramIntegration.GetSender(); telegramSender != nil { if err != nil {
dispatcher.RegisterSender(notification.ChannelTelegram, telegramSender) return nil, nil, err
} }
telegramIntegration = telegram.NewIntegration(store, logger, tmplRenderer, cfg.APIKey)
integrations = append(integrations, telegramIntegration) integrations = append(integrations, telegramIntegration)
} }
if cfg.EnableProton { if cfg.EnableProton {
protonIntegration = proton.NewIntegration(cfg, dispatcher, logger, tmplRenderer, store.GetDB(), cfg.APIKey) tmplRenderer, err := newIntegrationRenderer(baseTmpl, "proton", proton.Templates)
if err != nil {
return nil, nil, err
}
protonIntegration = proton.NewIntegration(cfg, logger, tmplRenderer, store.DB, cfg.APIKey)
integrations = append(integrations, protonIntegration) integrations = append(integrations, protonIntegration)
} }
dispatcher.RegisterSender(notification.ChannelWebPush, webpush.NewSender(logger)) i := &Integrations{
integrations = append(integrations, webpush.NewIntegration(store, logger))
return &Integrations{
Dispatcher: dispatcher,
Signal: signalIntegration, Signal: signalIntegration,
Telegram: telegramIntegration, Telegram: telegramIntegration,
Proton: protonIntegration, Proton: protonIntegration,
store: store,
logger: logger,
integrations: integrations, integrations: integrations,
}, fragmentTmpl, nil }
publisher := delivery.NewPublisher(store, logger, i.autoSubscribeApp)
publisher.RegisterSender(subscription.ChannelWebPush, webpush.NewSender(logger))
if signalIntegration != nil && signalIntegration.Sender != nil {
if linked, err := signalIntegration.Sender.IsLinked(); err == nil && linked {
publisher.RegisterSender(subscription.ChannelSignal, signalIntegration.Sender)
}
}
if telegramIntegration != nil && telegramIntegration.Sender != nil {
publisher.RegisterSender(subscription.ChannelTelegram, telegramIntegration.Sender)
}
i.Publisher = publisher
if telegramIntegration != nil {
telegramIntegration.OnUnlink = func() {
i.Publisher.DeregisterSender(subscription.ChannelTelegram)
if err := i.store.DeleteSubscriptionsByChannel(subscription.ChannelTelegram); err != nil {
i.logger.Error("Failed to delete Telegram subscriptions on unlink", "error", err)
}
}
}
if protonIntegration != nil {
protonIntegration.Publisher = publisher
}
return i, baseTmpl, nil
}
func newIntegrationRenderer(base *template.Template, name string, templates fs.FS) (*util.TemplateRenderer, error) {
integrationTmpl, err := base.Clone()
if err != nil {
return nil, fmt.Errorf("failed to clone base templates for %s: %w", name, err)
}
integrationTmpl, err = integrationTmpl.ParseFS(templates, "templates/*.html")
if err != nil {
return nil, fmt.Errorf("failed to parse %s templates: %w", name, err)
}
return util.NewTemplateRenderer(integrationTmpl), nil
} }
func (i *Integrations) Start(ctx context.Context, logger *slog.Logger) { func (i *Integrations) Start(ctx context.Context, logger *slog.Logger) {
@ -105,10 +143,54 @@ func (i *Integrations) Start(ctx context.Context, logger *slog.Logger) {
} }
} }
func RegisterAll(integrations *Integrations, router *chi.Mux, cfg *config.Config, store *notification.Store, logger *slog.Logger, authMiddleware func(string) func(http.Handler) http.Handler) { func RegisterAll(integrations *Integrations, router *chi.Mux, cfg *config.Config, logger *slog.Logger, authMiddleware func(string) func(http.Handler) http.Handler) {
auth := authMiddleware(cfg.APIKey) auth := authMiddleware(cfg.APIKey)
db := store.GetDB()
for _, integration := range integrations.integrations { for _, integration := range integrations.integrations {
integration.RegisterRoutes(router, auth, db, cfg.APIKey, logger) integration.RegisterRoutes(router, auth, logger)
} }
} }
func (i *Integrations) autoSubscribeApp(appName string) error {
app, err := i.store.GetApp(appName)
if err != nil {
return err
}
if app != nil {
return nil
}
if err := i.store.RegisterApp(appName); err != nil {
return fmt.Errorf("failed to register app %q: %w", appName, err)
}
i.logger.Info("Registering new app and auto-configuring subscription", "app", appName)
for _, integration := range i.integrations {
sub, ok := integration.(AutoSubscriber)
if !ok {
continue
}
if err := sub.AutoSubscribe(appName, i.store, i.Publisher); err == nil {
return nil
} else {
i.logger.Warn("Auto-config failed", "integration", fmt.Sprintf("%T", integration), "app", appName, "error", err)
}
}
return fmt.Errorf("no integration available to auto-configure app %q", appName)
}
func (i *Integrations) IsSignalLinked() bool {
if i.Signal == nil {
return false
}
linked, _ := i.Signal.Health()
return linked
}
func (i *Integrations) IsTelegramLinked() bool {
if i.Telegram == nil {
return false
}
linked, _ := i.Telegram.Health()
return linked
}

View file

@ -19,8 +19,8 @@ func (m *Monitor) authenticateAndSetup(credStore *credentials.Store) error {
m.logger.Info("Starting Proton Mail monitor", "email", creds.Email) m.logger.Info("Starting Proton Mail monitor", "email", creds.Email)
c := &protonmail.Client{ c := &protonmail.Client{
RootURL: "https://mail.proton.me/api", RootURL: protonAPIURL,
AppVersion: "Other", AppVersion: protonAppVersion,
} }
var auth *protonmail.Auth var auth *protonmail.Auth

View file

@ -16,8 +16,8 @@ type Handlers struct {
username string username string
logger *slog.Logger logger *slog.Logger
tmpl *util.TemplateRenderer tmpl *util.TemplateRenderer
db *sql.DB DB *sql.DB
apiKey string APIKey string
} }
type ProtonContentData struct { type ProtonContentData struct {
@ -39,22 +39,17 @@ func NewHandlers(monitor *Monitor, username string, logger *slog.Logger, tmpl *u
username: username, username: username,
logger: logger, logger: logger,
tmpl: tmpl, tmpl: tmpl,
db: nil, DB: nil,
apiKey: "", APIKey: "",
} }
} }
func (h *Handlers) SetDB(db *sql.DB, apiKey string) {
h.db = db
h.apiKey = apiKey
}
func (h *Handlers) LoadFreshCredentials() (string, bool) { func (h *Handlers) LoadFreshCredentials() (string, bool) {
if h.db == nil || h.apiKey == "" { if h.DB == nil || h.APIKey == "" {
return "", false return "", false
} }
credStore, err := credentials.NewStore(h.db, h.apiKey) credStore, err := credentials.NewStore(h.DB, h.APIKey)
if err != nil { if err != nil {
return "", false return "", false
} }
@ -115,10 +110,6 @@ func (h *Handlers) IsEnabled() bool {
return h.monitor != nil return h.monitor != nil
} }
func (h *Handlers) GetMonitor() *Monitor {
return h.monitor
}
type markReadRequest struct { type markReadRequest struct {
UID string `json:"uid"` UID string `json:"uid"`
} }

View file

@ -8,43 +8,42 @@ import (
"prism/service/config" "prism/service/config"
"prism/service/credentials" "prism/service/credentials"
"prism/service/notification" "prism/service/delivery"
"prism/service/util" "prism/service/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
type Integration struct { type Integration struct {
cfg *config.Config cfg *config.Config
dispatcher *notification.Dispatcher Publisher *delivery.Publisher
handlers *Handlers Handlers *Handlers
monitor *Monitor monitor *Monitor
db *sql.DB db *sql.DB
apiKey string apiKey string
} }
func NewIntegration(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger, tmpl *util.TemplateRenderer, db *sql.DB, apiKey string) *Integration { func NewIntegration(cfg *config.Config, logger *slog.Logger, tmpl *util.TemplateRenderer, db *sql.DB, apiKey string) *Integration {
monitor := NewMonitor(cfg, dispatcher, logger) monitor := NewMonitor(cfg, logger)
handlers := NewHandlers(monitor, "", logger, tmpl) handlers := NewHandlers(monitor, "", logger, tmpl)
return &Integration{ return &Integration{
cfg: cfg, cfg: cfg,
dispatcher: dispatcher, Handlers: handlers,
handlers: handlers, monitor: monitor,
monitor: monitor, db: db,
db: db, apiKey: apiKey,
apiKey: apiKey,
} }
} }
func (p *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) { func (p *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, logger *slog.Logger) {
credStore, err := credentials.NewStore(db, apiKey) credStore, err := credentials.NewStore(p.db, p.apiKey)
if err == nil { if err == nil {
if creds, err := credStore.GetProton(); err == nil { if creds, err := credStore.GetProton(); err == nil {
p.handlers.username = creds.Email p.Handlers.username = creds.Email
} }
} }
RegisterRoutes(router, p.handlers, auth, db, apiKey, logger, p) RegisterRoutes(router, p.Handlers, auth, p.db, p.apiKey, logger, p)
} }
func (p *Integration) Start(ctx context.Context, logger *slog.Logger) { func (p *Integration) Start(ctx context.Context, logger *slog.Logger) {
@ -60,13 +59,13 @@ func (p *Integration) Start(ctx context.Context, logger *slog.Logger) {
return return
} }
if err := p.monitor.Start(ctx, credStore); err != nil { if err := p.monitor.Start(ctx, credStore, p.Publisher); err != nil {
logger.Error("Failed to start Proton monitor", "error", err) logger.Error("Failed to start Proton monitor", "error", err)
return return
} }
if p.handlers != nil { if p.Handlers != nil {
p.handlers.username = creds.Email p.Handlers.username = creds.Email
} }
logger.Info("Proton Mail enabled", "email", creds.Email) logger.Info("Proton Mail enabled", "email", creds.Email)
@ -76,6 +75,10 @@ func (p *Integration) IsEnabled() bool {
return p.cfg.EnableProton return p.cfg.EnableProton
} }
func (p *Integration) GetHandlers() *Handlers { func (p *Integration) Health() (bool, string) {
return p.handlers if p.Handlers == nil {
return false, ""
}
email, ok := p.Handlers.LoadFreshCredentials()
return ok, email
} }

View file

@ -3,7 +3,8 @@ package proton
import ( import (
"time" "time"
"prism/service/notification" "prism/service/delivery"
"prism/service/subscription"
"github.com/emersion/hydroxide/protonmail" "github.com/emersion/hydroxide/protonmail"
) )
@ -75,11 +76,11 @@ func (m *Monitor) sendNotification(msg *protonmail.Message) {
subject = "(No subject)" subject = "(No subject)"
} }
notif := notification.Notification{ notif := delivery.Notification{
Title: from, Title: from,
Message: subject, Message: subject,
Tag: "proton-" + msg.ID, Tag: "proton-" + msg.ID,
Actions: []notification.Action{ Actions: []delivery.Action{
{ {
ID: "delete", ID: "delete",
Label: "Delete", Label: "Delete",
@ -110,7 +111,7 @@ func (m *Monitor) sendNotification(msg *protonmail.Message) {
}, },
} }
if err := m.dispatcher.Send(prismTopic, notif); err != nil { if err := m.dispatcher.Publish(prismTopic, notif); err != nil {
m.logger.Error("Failed to send notification", "error", err) m.logger.Error("Failed to send notification", "error", err)
} else { } else {
m.logger.Info("Sent notification", "from", from, "subject", subject, "msgID", msg.ID) m.logger.Info("Sent notification", "from", from, "subject", subject, "msgID", msg.ID)
@ -118,14 +119,14 @@ func (m *Monitor) sendNotification(msg *protonmail.Message) {
} }
func (m *Monitor) clearNotification(msgID string) { func (m *Monitor) clearNotification(msgID string) {
app, err := m.dispatcher.GetStore().GetApp(prismTopic) app, err := m.dispatcher.Store.GetApp(prismTopic)
if err != nil || app == nil { if err != nil || app == nil {
return return
} }
hasWebPush := false hasWebPush := false
for _, sub := range app.Subscriptions { for _, sub := range app.Subscriptions {
if sub.Channel == notification.ChannelWebPush { if sub.Channel == subscription.ChannelWebPush {
hasWebPush = true hasWebPush = true
break break
} }
@ -135,13 +136,13 @@ func (m *Monitor) clearNotification(msgID string) {
return return
} }
notif := notification.Notification{ notif := delivery.Notification{
Tag: "proton-" + msgID, Tag: "proton-" + msgID,
Title: "", Title: "",
Message: "", Message: "",
} }
if err := m.dispatcher.Send(prismTopic, notif); err != nil { if err := m.dispatcher.Publish(prismTopic, notif); err != nil {
m.logger.Error("Failed to clear notification", "error", err, "msgID", msgID) m.logger.Error("Failed to clear notification", "error", err, "msgID", msgID)
} else { } else {
m.logger.Debug("Cleared notification", "msgID", msgID) m.logger.Debug("Cleared notification", "msgID", msgID)

View file

@ -8,19 +8,21 @@ import (
"prism/service/config" "prism/service/config"
"prism/service/credentials" "prism/service/credentials"
"prism/service/notification" "prism/service/delivery"
"github.com/emersion/hydroxide/protonmail" "github.com/emersion/hydroxide/protonmail"
) )
const ( const (
pollInterval = 30 * time.Second pollInterval = 30 * time.Second
prismTopic = "Proton Mail" prismTopic = "Proton Mail"
protonAPIURL = "https://mail.proton.me/api"
protonAppVersion = "Other"
) )
type Monitor struct { type Monitor struct {
cfg *config.Config cfg *config.Config
dispatcher *notification.Dispatcher dispatcher *delivery.Publisher
logger *slog.Logger logger *slog.Logger
credStore *credentials.Store credStore *credentials.Store
client *protonmail.Client client *protonmail.Client
@ -31,15 +33,15 @@ type Monitor struct {
lastConnected time.Time lastConnected time.Time
} }
func NewMonitor(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger) *Monitor { func NewMonitor(cfg *config.Config, logger *slog.Logger) *Monitor {
return &Monitor{ return &Monitor{
cfg: cfg, cfg: cfg,
dispatcher: dispatcher, logger: logger,
logger: logger,
} }
} }
func (m *Monitor) Start(ctx context.Context, credStore *credentials.Store) error { func (m *Monitor) Start(ctx context.Context, credStore *credentials.Store, publisher *delivery.Publisher) error {
m.dispatcher = publisher
if err := m.authenticateAndSetup(credStore); err != nil { if err := m.authenticateAndSetup(credStore); err != nil {
return err return err
} }

View file

@ -9,7 +9,6 @@ import (
"net/http" "net/http"
"prism/service/credentials" "prism/service/credentials"
"prism/service/notification"
"prism/service/util" "prism/service/util"
"github.com/emersion/hydroxide/protonmail" "github.com/emersion/hydroxide/protonmail"
@ -17,18 +16,13 @@ import (
) )
//go:embed templates/*.html //go:embed templates/*.html
var templates embed.FS var Templates embed.FS
func GetTemplates() embed.FS {
return templates
}
type authHandler struct { type authHandler struct {
db *sql.DB db *sql.DB
apiKey string apiKey string
logger *slog.Logger logger *slog.Logger
integration *Integration integration *Integration
dispatcher *notification.Dispatcher
} }
type protonAuthRequest struct { type protonAuthRequest struct {
@ -50,8 +44,8 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
} }
c := &protonmail.Client{ c := &protonmail.Client{
RootURL: "https://mail.proton.me/api", RootURL: protonAPIURL,
AppVersion: "Other", AppVersion: protonAppVersion,
} }
authInfo, err := c.AuthInfo(req.Email) authInfo, err := c.AuthInfo(req.Email)
if err != nil { if err != nil {
@ -115,24 +109,16 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
return return
} }
if h.dispatcher != nil {
if _, err := h.dispatcher.CreateAppWithDefaultSubscription(prismTopic); err != nil {
h.logger.Warn("Failed to auto-create Proton Mail app with subscription", "error", err)
} else {
h.logger.Info("Created Proton Mail app with default subscription")
}
}
if h.integration != nil { if h.integration != nil {
ctx := context.Background() ctx := context.Background()
if err := h.integration.monitor.Start(ctx, credStore); err != nil { if err := h.integration.monitor.Start(ctx, credStore, h.integration.Publisher); err != nil {
h.logger.Error("Failed to start Proton monitor after auth", "error", err) h.logger.Error("Failed to start Proton monitor after auth", "error", err)
util.JSONError(w, "Authentication failed: "+err.Error(), http.StatusInternalServerError) util.JSONError(w, "Authentication failed: "+err.Error(), http.StatusInternalServerError)
return return
} }
h.logger.Info("Proton monitor started") h.logger.Info("Proton monitor started")
if h.integration.handlers != nil { if h.integration.Handlers != nil {
h.integration.handlers.username = req.Email h.integration.Handlers.username = req.Email
} }
} }
@ -165,14 +151,14 @@ func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler)
return return
} }
handlers.SetDB(db, apiKey) handlers.DB = db
handlers.APIKey = apiKey
authH := &authHandler{ authH := &authHandler{
db: db, db: db,
apiKey: apiKey, apiKey: apiKey,
logger: logger, logger: logger,
integration: integration, integration: integration,
dispatcher: integration.dispatcher,
} }
router.With(auth).Get("/fragment/proton", handlers.HandleFragment) router.With(auth).Get("/fragment/proton", handlers.HandleFragment)

View file

@ -17,37 +17,23 @@ const (
type Client struct { type Client struct {
ConfigPath string ConfigPath string
enabled bool
} }
func NewClient() *Client { func NewClient() *Client {
configPath := filepath.Join(os.Getenv("HOME"), DefaultConfigPath) if _, err := exec.LookPath("signal-cli"); err != nil {
return nil
enabled := false
if _, err := exec.LookPath("signal-cli"); err == nil {
enabled = true
} }
return &Client{ return &Client{
ConfigPath: configPath, ConfigPath: filepath.Join(os.Getenv("HOME"), DefaultConfigPath),
enabled: enabled,
} }
} }
func (c *Client) IsEnabled() bool {
return c.enabled
}
type Account struct { type Account struct {
Number string Number string
UUID string UUID string
} }
func (c *Client) exec(args ...string) ([]byte, error) { func (c *Client) exec(args ...string) ([]byte, error) {
if !c.enabled {
return nil, fmt.Errorf("signal-cli not found in PATH")
}
baseArgs := []string{"--config", c.ConfigPath, "--output=json"} baseArgs := []string{"--config", c.ConfigPath, "--output=json"}
cmd := exec.Command("signal-cli", append(baseArgs, args...)...) cmd := exec.Command("signal-cli", append(baseArgs, args...)...)
@ -66,7 +52,7 @@ func (c *Client) exec(args ...string) ([]byte, error) {
} }
func (c *Client) GetLinkedAccount() (*Account, error) { func (c *Client) GetLinkedAccount() (*Account, error) {
if c == nil || !c.enabled { if c == nil {
return nil, nil return nil, nil
} }
@ -113,7 +99,7 @@ func (c *Client) GetLinkedAccount() (*Account, error) {
} }
func (c *Client) CreateGroup(name string) (string, string, error) { func (c *Client) CreateGroup(name string) (string, string, error) {
if c == nil || !c.enabled { if c == nil {
return "", "", fmt.Errorf("signal client not initialized") return "", "", fmt.Errorf("signal client not initialized")
} }
@ -148,7 +134,7 @@ func (c *Client) CreateGroup(name string) (string, string, error) {
} }
func (c *Client) SendGroupMessage(groupID, message string) error { func (c *Client) SendGroupMessage(groupID, message string) error {
if c == nil || !c.enabled { if c == nil {
return fmt.Errorf("signal client not initialized") return fmt.Errorf("signal client not initialized")
} }

View file

@ -0,0 +1,47 @@
package signal
import (
"database/sql"
"fmt"
"prism/service/subscription"
)
type GroupCache struct {
db *sql.DB
}
func NewGroupCache(db *sql.DB) (*GroupCache, error) {
c := &GroupCache{db: db}
_, err := db.Exec(`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
)`)
if err != nil {
return nil, fmt.Errorf("failed to create signal_groups table: %w", err)
}
return c, nil
}
func (c *GroupCache) Get(appName string) (*subscription.SignalSubscription, error) {
var groupID, account string
err := c.db.QueryRow(`SELECT groupId, account FROM signal_groups WHERE appName = ?`, appName).Scan(&groupID, &account)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &subscription.SignalSubscription{GroupID: groupID, Account: account}, nil
}
func (c *GroupCache) Save(appName string, sub *subscription.SignalSubscription) error {
_, err := c.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,
)
return err
}

View file

@ -10,7 +10,7 @@ import (
) )
type Handlers struct { type Handlers struct {
client *Client Client *Client
tmpl *util.TemplateRenderer tmpl *util.TemplateRenderer
logger *slog.Logger logger *slog.Logger
} }
@ -33,7 +33,7 @@ type IntegrationData struct {
func NewHandlers(client *Client, tmpl *util.TemplateRenderer, logger *slog.Logger) *Handlers { func NewHandlers(client *Client, tmpl *util.TemplateRenderer, logger *slog.Logger) *Handlers {
return &Handlers{ return &Handlers{
client: client, Client: client,
tmpl: tmpl, tmpl: tmpl,
logger: logger, logger: logger,
} }
@ -46,14 +46,14 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
var integData IntegrationData var integData IntegrationData
integData.Name = "Signal" integData.Name = "Signal"
if h.client == nil || !h.client.IsEnabled() { if h.Client == nil {
integData.StatusClass = "disconnected" integData.StatusClass = "disconnected"
integData.StatusText = "Not Available" integData.StatusText = "Not Available"
integData.StatusTooltip = "signal-cli not found in PATH" integData.StatusTooltip = "signal-cli not found in PATH"
integData.Open = true integData.Open = true
contentData.Error = "signal-cli not found. Download from: https://github.com/AsamK/signal-cli/releases" contentData.Error = "signal-cli not found. Download from: https://github.com/AsamK/signal-cli/releases"
} else { } else {
account, err := h.client.GetLinkedAccount() account, err := h.Client.GetLinkedAccount()
if err != nil { if err != nil {
integData.StatusClass = "disconnected" integData.StatusClass = "disconnected"
integData.StatusText = "Error" integData.StatusText = "Error"
@ -91,16 +91,8 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(html)) w.Write([]byte(html))
} }
func (h *Handlers) IsEnabled() bool {
return h.client != nil && h.client.IsEnabled()
}
func (h *Handlers) GetClient() *Client {
return h.client
}
func (h *Handlers) HandleLinkDevice(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleLinkDevice(w http.ResponseWriter, r *http.Request) {
if h.client == nil || !h.client.IsEnabled() { if h.Client == nil {
util.JSONError(w, "signal-cli not available", http.StatusServiceUnavailable) util.JSONError(w, "signal-cli not available", http.StatusServiceUnavailable)
return return
} }
@ -112,7 +104,7 @@ func (h *Handlers) HandleLinkDevice(w http.ResponseWriter, r *http.Request) {
req.DeviceName = DefaultDeviceName req.DeviceName = DefaultDeviceName
} }
qrCode, err := h.client.LinkDevice(req.DeviceName) qrCode, err := h.Client.LinkDevice(req.DeviceName)
if err != nil { if err != nil {
h.logger.Error("Failed to generate link code", "error", err) h.logger.Error("Failed to generate link code", "error", err)
util.JSONError(w, err.Error(), http.StatusInternalServerError) util.JSONError(w, err.Error(), http.StatusInternalServerError)
@ -127,12 +119,12 @@ func (h *Handlers) HandleLinkDevice(w http.ResponseWriter, r *http.Request) {
} }
func (h *Handlers) HandleLinkStatus(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleLinkStatus(w http.ResponseWriter, r *http.Request) {
if h.client == nil || !h.client.IsEnabled() { if h.Client == nil {
util.JSONError(w, "signal-cli not available", http.StatusServiceUnavailable) util.JSONError(w, "signal-cli not available", http.StatusServiceUnavailable)
return return
} }
account, err := h.client.GetLinkedAccount() account, err := h.Client.GetLinkedAccount()
if err != nil { if err != nil {
h.logger.Error("Failed to get linked account", "error", err) h.logger.Error("Failed to get linked account", "error", err)
util.JSONError(w, err.Error(), http.StatusInternalServerError) util.JSONError(w, err.Error(), http.StatusInternalServerError)

View file

@ -2,12 +2,13 @@ package signal
import ( import (
"context" "context"
"database/sql" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"prism/service/config" "prism/service/config"
"prism/service/notification" "prism/service/delivery"
"prism/service/subscription"
"prism/service/util" "prism/service/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -16,54 +17,106 @@ import (
type Integration struct { type Integration struct {
cfg *config.Config cfg *config.Config
client *Client client *Client
handlers *Handlers Handlers *Handlers
sender *Sender Sender *Sender
Groups *GroupCache
store *subscription.Store
logger *slog.Logger
tmpl *util.TemplateRenderer tmpl *util.TemplateRenderer
} }
func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration { func NewIntegration(cfg *config.Config, store *subscription.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration {
client := NewClient() client := NewClient()
groups, err := NewGroupCache(store.DB)
if err != nil {
logger.Error("Failed to initialize Signal group cache", "error", err)
}
var sender *Sender var sender *Sender
if client.IsEnabled() { if client != nil {
sender = NewSender(client, store, logger) sender = NewSender(client, groups, logger)
} }
return &Integration{ return &Integration{
cfg: cfg, cfg: cfg,
client: client, client: client,
sender: sender, Sender: sender,
Groups: groups,
store: store,
logger: logger,
tmpl: tmpl, tmpl: tmpl,
} }
} }
func (s *Integration) GetSender() *Sender { func (s *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, logger *slog.Logger) {
return s.sender s.Handlers = RegisterRoutes(router, s.cfg, auth, s.tmpl, logger, s.client)
}
func (s *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) {
s.handlers = RegisterRoutes(router, s.cfg, auth, s.tmpl, logger, s.client)
} }
func (s *Integration) Start(ctx context.Context, logger *slog.Logger) { func (s *Integration) Start(ctx context.Context, logger *slog.Logger) {
if s.handlers != nil && s.handlers.IsEnabled() { if s.Handlers != nil && s.Handlers.Client != nil {
client := s.handlers.GetClient() client := s.Handlers.Client
account, _ := client.GetLinkedAccount() account, _ := client.GetLinkedAccount()
if account != nil { if account != nil {
logger.Info("Signal enabled", "status", "linked", "number", FormatPhoneNumber(account.Number)) logger.Debug("Signal enabled", "status", "linked", "number", FormatPhoneNumber(account.Number))
} else { } else {
logger.Info("Signal enabled", "status", "unlinked", "action", "visit admin UI to link") logger.Debug("Signal enabled", "status", "unlinked", "action", "visit admin UI to link")
} }
} else { } else {
logger.Info("Signal disabled", "reason", "signal-cli not found in PATH") logger.Debug("Signal disabled", "reason", "signal-cli not found in PATH")
} }
} }
func (s *Integration) IsEnabled() bool { func (s *Integration) IsEnabled() bool {
if s.handlers == nil { return s.Handlers != nil && s.Handlers.Client != nil
return false
}
return s.handlers.IsEnabled()
} }
func (s *Integration) GetHandlers() *Handlers { func (s *Integration) Health() (bool, string) {
return s.handlers if s.Handlers == nil || s.Handlers.Client == nil {
return false, ""
}
account, _ := s.Handlers.Client.GetLinkedAccount()
if account == nil {
return false, ""
}
return true, account.Number
}
func (s *Integration) AutoSubscribe(appName string, store *subscription.Store, publisher *delivery.Publisher) error {
if s.Sender == nil {
return fmt.Errorf("signal-cli not available")
}
linked, err := s.Sender.IsLinked()
if err != nil {
return fmt.Errorf("failed to check Signal link status: %w", err)
}
if !linked {
return fmt.Errorf("no linked Signal account")
}
if !publisher.HasChannel(subscription.ChannelSignal) {
publisher.RegisterSender(subscription.ChannelSignal, s.Sender)
}
var signalSub *subscription.SignalSubscription
if cachedGroup, err := s.Groups.Get(appName); err != nil {
s.logger.Warn("Failed to check for cached Signal group", "error", err)
} else if cachedGroup != nil {
s.logger.Debug("Reusing cached Signal group", "app", appName)
signalSub = cachedGroup
}
if signalSub == nil {
if signalSub, err = s.Sender.CreateDefaultSignalSubscription(appName); err != nil {
return err
}
}
subID, err := store.AddSubscription(subscription.Subscription{
AppName: appName,
Channel: subscription.ChannelSignal,
Signal: signalSub,
})
if err != nil {
return err
}
s.logger.Info("Auto-configured Signal subscription", "app", appName, "subscriptionID", subID)
return nil
} }

View file

@ -12,7 +12,7 @@ import (
) )
func (c *Client) LinkDevice(deviceName string) (string, error) { func (c *Client) LinkDevice(deviceName string) (string, error) {
if c == nil || !c.enabled { if c == nil {
return "", fmt.Errorf("signal-cli not found in PATH") return "", fmt.Errorf("signal-cli not found in PATH")
} }

View file

@ -12,11 +12,7 @@ import (
) )
//go:embed templates/*.html //go:embed templates/*.html
var templates embed.FS var Templates embed.FS
func GetTemplates() embed.FS {
return templates
}
func RegisterRoutes(router *chi.Mux, cfg *config.Config, authMiddleware func(http.Handler) http.Handler, tmpl *util.TemplateRenderer, logger *slog.Logger, client *Client) *Handlers { func RegisterRoutes(router *chi.Mux, cfg *config.Config, authMiddleware func(http.Handler) http.Handler, tmpl *util.TemplateRenderer, logger *slog.Logger, client *Client) *Handlers {
handlers := NewHandlers(client, tmpl, logger) handlers := NewHandlers(client, tmpl, logger)

View file

@ -4,20 +4,21 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"prism/service/notification" "prism/service/delivery"
"prism/service/subscription"
"prism/service/util" "prism/service/util"
) )
type Sender struct { type Sender struct {
client *Client client *Client
store *notification.Store groups *GroupCache
logger *slog.Logger logger *slog.Logger
} }
func NewSender(client *Client, store *notification.Store, logger *slog.Logger) *Sender { func NewSender(client *Client, groups *GroupCache, logger *slog.Logger) *Sender {
return &Sender{ return &Sender{
client: client, client: client,
store: store, groups: groups,
logger: logger, logger: logger,
} }
} }
@ -35,7 +36,7 @@ func (s *Sender) IsLinked() (bool, error) {
return account != nil, nil return account != nil, nil
} }
func (s *Sender) CreateDefaultSignalSubscription(appName string) (*notification.SignalSubscription, error) { func (s *Sender) CreateDefaultSignalSubscription(appName string) (*subscription.SignalSubscription, error) {
if s.client == nil { if s.client == nil {
return nil, fmt.Errorf("signal integration not enabled") return nil, fmt.Errorf("signal integration not enabled")
} }
@ -45,25 +46,25 @@ func (s *Sender) CreateDefaultSignalSubscription(appName string) (*notification.
return nil, err return nil, err
} }
signalSub := &notification.SignalSubscription{ signalSub := &subscription.SignalSubscription{
GroupID: groupID, GroupID: groupID,
Account: account, Account: account,
} }
if err := s.store.SaveSignalGroup(appName, signalSub); err != nil { if err := s.groups.Save(appName, signalSub); err != nil {
s.logger.Warn("Failed to cache Signal group", "error", err) s.logger.Warn("Failed to cache Signal group", "error", err)
} }
return signalSub, nil return signalSub, nil
} }
func (s *Sender) Send(sub *notification.Subscription, notif notification.Notification) error { func (s *Sender) Send(sub *subscription.Subscription, notif delivery.Notification) error {
if s.client == nil { if s.client == nil {
return notification.NewPermanentError(fmt.Errorf("signal integration not enabled")) return delivery.NewPermanentError(fmt.Errorf("signal integration not enabled"))
} }
if sub.Signal == nil { if sub.Signal == nil {
return notification.NewPermanentError(fmt.Errorf("no signal subscription data")) return delivery.NewPermanentError(fmt.Errorf("no signal subscription data"))
} }
account, err := s.client.GetLinkedAccount() account, err := s.client.GetLinkedAccount()
@ -72,7 +73,7 @@ func (s *Sender) Send(sub *notification.Subscription, notif notification.Notific
} }
if account == nil { if account == nil {
s.logger.Error("No linked Signal account found") s.logger.Error("No linked Signal account found")
return notification.NewPermanentError(fmt.Errorf("no linked Signal account")) return delivery.NewPermanentError(fmt.Errorf("no linked Signal account"))
} }
var signalGroupID string var signalGroupID string
@ -87,7 +88,7 @@ func (s *Sender) Send(sub *notification.Subscription, notif notification.Notific
} }
if needsNewGroup { if needsNewGroup {
return notification.NewPermanentError(fmt.Errorf("signal group not configured for subscription %s", sub.ID)) return delivery.NewPermanentError(fmt.Errorf("signal group not configured for subscription %s", sub.ID))
} }
signalGroupID = sub.Signal.GroupID signalGroupID = sub.Signal.GroupID

View file

@ -16,8 +16,8 @@ type Handlers struct {
chatID int64 chatID int64
tmpl *util.TemplateRenderer tmpl *util.TemplateRenderer
logger *slog.Logger logger *slog.Logger
db *sql.DB DB *sql.DB
apiKey string APIKey string
} }
type TelegramContentData struct { type TelegramContentData struct {
@ -41,22 +41,17 @@ func NewHandlers(client *Client, chatID int64, tmpl *util.TemplateRenderer, logg
chatID: chatID, chatID: chatID,
tmpl: tmpl, tmpl: tmpl,
logger: logger, logger: logger,
db: nil, DB: nil,
apiKey: "", APIKey: "",
} }
} }
func (h *Handlers) SetDB(db *sql.DB, apiKey string) {
h.db = db
h.apiKey = apiKey
}
func (h *Handlers) loadFreshCredentials() (*Client, int64, bool) { func (h *Handlers) loadFreshCredentials() (*Client, int64, bool) {
if h.db == nil || h.apiKey == "" { if h.DB == nil || h.APIKey == "" {
return nil, 0, false return nil, 0, false
} }
credStore, err := credentials.NewStore(h.db, h.apiKey) credStore, err := credentials.NewStore(h.DB, h.APIKey)
if err != nil { if err != nil {
return nil, 0, false return nil, 0, false
} }
@ -137,10 +132,6 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(html)) w.Write([]byte(html))
} }
func (h *Handlers) IsEnabled() bool {
return true
}
func (h *Handlers) GetClient() *Client { func (h *Handlers) GetClient() *Client {
client, _, _ := h.loadFreshCredentials() client, _, _ := h.loadFreshCredentials()
return client return client

View file

@ -3,29 +3,34 @@ package telegram
import ( import (
"context" "context"
"database/sql" "database/sql"
"fmt"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv" "strconv"
"prism/service/config"
"prism/service/credentials" "prism/service/credentials"
"prism/service/notification" "prism/service/delivery"
"prism/service/subscription"
"prism/service/util" "prism/service/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
type Integration struct { type Integration struct {
handlers *Handlers Handlers *Handlers
sender *Sender Sender *Sender
OnUnlink func()
db *sql.DB
apiKey string
logger *slog.Logger
} }
func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration { func NewIntegration(store *subscription.Store, logger *slog.Logger, tmpl *util.TemplateRenderer, apiKey string) *Integration {
var client *Client var client *Client
var chatID int64 var chatID int64
var sender *Sender var sender *Sender
credStore, err := credentials.NewStoreWithLogger(store.GetDB(), cfg.APIKey, logger) credStore, err := credentials.NewStoreWithLogger(store.DB, apiKey, logger)
if err != nil { if err != nil {
logger.Warn("Failed to initialize credentials store for Telegram", "error", err) logger.Warn("Failed to initialize credentials store for Telegram", "error", err)
} else { } else {
@ -43,32 +48,27 @@ func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.
} }
} }
if client != nil { if client != nil && chatID != 0 {
sender = NewSender(client, store, logger, chatID) sender = NewSender(client, store, logger, chatID)
} }
handlers := NewHandlers(client, chatID, tmpl, logger) handlers := NewHandlers(client, chatID, tmpl, logger)
return &Integration{ return &Integration{
handlers: handlers, Handlers: handlers,
sender: sender, Sender: sender,
db: store.DB,
apiKey: apiKey,
logger: logger,
} }
} }
func (t *Integration) GetSender() *Sender { func (t *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, logger *slog.Logger) {
return t.sender RegisterRoutes(router, t.Handlers, auth, t.db, t.apiKey, logger, t.OnUnlink)
}
func (t *Integration) GetHandlers() *Handlers {
return t.handlers
}
func (t *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) {
RegisterRoutes(router, t.handlers, auth, db, apiKey, logger)
} }
func (t *Integration) Start(ctx context.Context, logger *slog.Logger) { func (t *Integration) Start(ctx context.Context, logger *slog.Logger) {
client := t.handlers.GetClient() client := t.Handlers.GetClient()
if client == nil { if client == nil {
logger.Info("Telegram not configured", "action", "visit admin UI to configure") logger.Info("Telegram not configured", "action", "visit admin UI to configure")
return return
@ -80,7 +80,7 @@ func (t *Integration) Start(ctx context.Context, logger *slog.Logger) {
return return
} }
chatID := t.handlers.chatID chatID := t.Handlers.chatID
if chatID == 0 { if chatID == 0 {
logger.Info("Telegram bot connected", "bot", bot.Username, "status", "needs_chat_id") logger.Info("Telegram bot connected", "bot", bot.Username, "status", "needs_chat_id")
} else { } else {
@ -89,5 +89,60 @@ func (t *Integration) Start(ctx context.Context, logger *slog.Logger) {
} }
func (t *Integration) IsEnabled() bool { func (t *Integration) IsEnabled() bool {
return t.handlers != nil && t.handlers.GetClient() != nil return t.Handlers != nil && t.Handlers.GetClient() != nil
}
func (t *Integration) Health() (bool, string) {
if t.Handlers == nil {
return false, ""
}
client := t.Handlers.GetClient()
if client == nil {
return false, ""
}
bot, err := client.GetMe()
if err != nil {
return false, ""
}
return true, "@" + bot.Username
}
func (t *Integration) AutoSubscribe(appName string, store *subscription.Store, publisher *delivery.Publisher) error {
credStore, err := credentials.NewStore(t.db, t.apiKey)
if err != nil {
return fmt.Errorf("failed to load credentials: %w", err)
}
creds, err := credStore.GetTelegram()
if err != nil || creds == nil {
return fmt.Errorf("telegram not configured")
}
if creds.ChatID == "" {
return fmt.Errorf("no chat ID configured")
}
chatID, err := strconv.ParseInt(creds.ChatID, 10, 64)
if err != nil {
return fmt.Errorf("invalid chat ID: %w", err)
}
if !publisher.HasChannel(subscription.ChannelTelegram) {
client, err := NewClient(creds.BotToken)
if err != nil {
return fmt.Errorf("failed to create telegram client: %w", err)
}
sender := NewSender(client, store, t.logger, chatID)
t.Sender = sender
publisher.RegisterSender(subscription.ChannelTelegram, sender)
}
subID, err := store.AddSubscription(subscription.Subscription{
AppName: appName,
Channel: subscription.ChannelTelegram,
Telegram: &subscription.TelegramSubscription{ChatID: fmt.Sprintf("%d", chatID)},
})
if err != nil {
return err
}
t.logger.Info("Auto-configured Telegram subscription", "app", appName, "subscriptionID", subID, "chatID", chatID)
return nil
} }

View file

@ -14,16 +14,13 @@ import (
) )
//go:embed templates/*.html //go:embed templates/*.html
var templates embed.FS var Templates embed.FS
func GetTemplates() embed.FS {
return templates
}
type linkHandler struct { type linkHandler struct {
db *sql.DB db *sql.DB
apiKey string apiKey string
logger *slog.Logger logger *slog.Logger
onUnlink func()
} }
type telegramLinkRequest struct { type telegramLinkRequest struct {
@ -76,19 +73,21 @@ func (h *linkHandler) handleUnlink(w http.ResponseWriter, r *http.Request) {
return return
} }
h.onUnlink()
util.SetToast(w, "Telegram unlinked", "success") util.SetToast(w, "Telegram unlinked", "success")
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
} }
func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) { func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger, onUnlink func()) {
if handlers == nil { if handlers == nil {
return return
} }
handlers.SetDB(db, apiKey) handlers.DB = db
handlers.APIKey = apiKey
linkH := &linkHandler{db: db, apiKey: apiKey, logger: logger} linkH := &linkHandler{db: db, apiKey: apiKey, logger: logger, onUnlink: onUnlink}
router.With(auth).Get("/fragment/telegram", handlers.HandleFragment) router.With(auth).Get("/fragment/telegram", handlers.HandleFragment)
router.With(auth).Post("/api/v1/telegram/link", linkH.handleLink) router.With(auth).Post("/api/v1/telegram/link", linkH.handleLink)

View file

@ -5,7 +5,8 @@ import (
"log/slog" "log/slog"
"strconv" "strconv"
"prism/service/notification" "prism/service/delivery"
"prism/service/subscription"
) )
func parseInt64(s string) (int64, error) { func parseInt64(s string) (int64, error) {
@ -14,12 +15,12 @@ func parseInt64(s string) (int64, error) {
type Sender struct { type Sender struct {
client *Client client *Client
store *notification.Store store *subscription.Store
logger *slog.Logger logger *slog.Logger
DefaultChatID int64 DefaultChatID int64
} }
func NewSender(client *Client, store *notification.Store, logger *slog.Logger, defaultChatID int64) *Sender { func NewSender(client *Client, store *subscription.Store, logger *slog.Logger, defaultChatID int64) *Sender {
return &Sender{ return &Sender{
client: client, client: client,
store: store, store: store,
@ -48,13 +49,13 @@ func (s *Sender) IsLinked() (bool, error) {
return true, nil return true, nil
} }
func (s *Sender) Send(sub *notification.Subscription, notif notification.Notification) error { func (s *Sender) Send(sub *subscription.Subscription, notif delivery.Notification) error {
if s.client == nil { if s.client == nil {
return notification.NewPermanentError(fmt.Errorf("telegram integration not enabled")) return delivery.NewPermanentError(fmt.Errorf("telegram integration not enabled"))
} }
if sub.Telegram == nil || sub.Telegram.ChatID == "" { if sub.Telegram == nil || sub.Telegram.ChatID == "" {
return notification.NewPermanentError(fmt.Errorf("no telegram chat configured for subscription")) return delivery.NewPermanentError(fmt.Errorf("no telegram chat configured for subscription"))
} }
message := notif.Message message := notif.Message
@ -66,7 +67,7 @@ func (s *Sender) Send(sub *notification.Subscription, notif notification.Notific
chatID, err := parseInt64(sub.Telegram.ChatID) chatID, err := parseInt64(sub.Telegram.ChatID)
if err != nil { if err != nil {
return notification.NewPermanentError(fmt.Errorf("invalid chat ID: %w", err)) return delivery.NewPermanentError(fmt.Errorf("invalid chat ID: %w", err))
} }
if err := s.client.SendMessage(chatID, fullMessage); err != nil { if err := s.client.SendMessage(chatID, fullMessage); err != nil {

View file

@ -1,32 +1,22 @@
package webpush package webpush
import ( import (
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
"prism/service/notification" "prism/service/subscription"
"prism/service/util" "prism/service/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
func generateSubscriptionID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
type Handlers struct { type Handlers struct {
store *notification.Store store *subscription.Store
logger *slog.Logger logger *slog.Logger
} }
func NewHandlers(store *notification.Store, logger *slog.Logger) *Handlers { func NewHandlers(store *subscription.Store, logger *slog.Logger) *Handlers {
return &Handlers{ return &Handlers{
store: store, store: store,
logger: logger, logger: logger,
@ -44,7 +34,7 @@ type registerRequest struct {
func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
var req registerRequest var req registerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest) util.JSONError(w, "Invalid request body", http.StatusBadRequest)
return return
} }
@ -73,13 +63,7 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
return return
} }
subID, err := generateSubscriptionID() var webPush *subscription.WebPushSubscription
if err != nil {
util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err)
return
}
var webPush *notification.WebPushSubscription
if req.P256dh != nil && req.Auth != nil && req.VapidPrivateKey != nil { if req.P256dh != nil && req.Auth != nil && req.VapidPrivateKey != nil {
normalizedP256dh, err := normalizeP256DH(*req.P256dh) normalizedP256dh, err := normalizeP256DH(*req.P256dh)
if err != nil { if err != nil {
@ -99,26 +83,26 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
return return
} }
webPush = &notification.WebPushSubscription{ webPush = &subscription.WebPushSubscription{
Endpoint: req.PushEndpoint, Endpoint: req.PushEndpoint,
P256dh: normalizedP256dh, P256dh: normalizedP256dh,
Auth: normalizedAuth, Auth: normalizedAuth,
VapidPrivateKey: normalizedKey, VapidPrivateKey: normalizedKey,
} }
} else { } else {
webPush = &notification.WebPushSubscription{ webPush = &subscription.WebPushSubscription{
Endpoint: req.PushEndpoint, Endpoint: req.PushEndpoint,
} }
} }
sub := notification.Subscription{ sub := subscription.Subscription{
ID: subID,
AppName: req.AppName, AppName: req.AppName,
Channel: notification.ChannelWebPush, Channel: subscription.ChannelWebPush,
WebPush: webPush, WebPush: webPush,
} }
if err := h.store.AddSubscription(sub); err != nil { subID, err := h.store.AddSubscription(sub)
if err != nil {
h.logger.Warn("Failed to add webpush subscription", "app", req.AppName, "error", err) h.logger.Warn("Failed to add webpush subscription", "app", req.AppName, "error", err)
util.LogAndError(w, h.logger, "Failed to add subscription", http.StatusInternalServerError, err) util.LogAndError(w, h.logger, "Failed to add subscription", http.StatusInternalServerError, err)
return return
@ -129,7 +113,7 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
response := map[string]string{ response := map[string]string{
"appName": req.AppName, "appName": req.AppName,
"channel": notification.ChannelWebPush.String(), "channel": subscription.ChannelWebPush.String(),
"subscriptionId": subID, "subscriptionId": subID,
} }
_ = json.NewEncoder(w).Encode(response) _ = json.NewEncoder(w).Encode(response)
@ -138,7 +122,7 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
func (h *Handlers) HandleUnregister(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleUnregister(w http.ResponseWriter, r *http.Request) {
subscriptionID := chi.URLParam(r, "subscriptionId") subscriptionID := chi.URLParam(r, "subscriptionId")
if subscriptionID == "" { if subscriptionID == "" {
http.Error(w, "subscriptionId is required", http.StatusBadRequest) util.JSONError(w, "subscriptionId is required", http.StatusBadRequest)
return return
} }

View file

@ -2,28 +2,27 @@ package webpush
import ( import (
"context" "context"
"database/sql"
"log/slog" "log/slog"
"net/http" "net/http"
"prism/service/notification" "prism/service/subscription"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
type Integration struct { type Integration struct {
store *notification.Store store *subscription.Store
logger *slog.Logger logger *slog.Logger
} }
func NewIntegration(store *notification.Store, logger *slog.Logger) *Integration { func NewIntegration(store *subscription.Store, logger *slog.Logger) *Integration {
return &Integration{ return &Integration{
store: store, store: store,
logger: logger, logger: logger,
} }
} }
func (w *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, db *sql.DB, apiKey string, logger *slog.Logger) { func (w *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, logger *slog.Logger) {
RegisterRoutes(router, w.store, w.logger, auth) RegisterRoutes(router, w.store, w.logger, auth)
} }
@ -32,3 +31,5 @@ func (w *Integration) Start(ctx context.Context, logger *slog.Logger) {}
func (w *Integration) IsEnabled() bool { func (w *Integration) IsEnabled() bool {
return true return true
} }
func (w *Integration) Health() (bool, string) { return false, "" }

View file

@ -4,12 +4,12 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"prism/service/notification" "prism/service/subscription"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
func RegisterRoutes(router *chi.Mux, store *notification.Store, logger *slog.Logger, authMiddleware func(http.Handler) http.Handler) { func RegisterRoutes(router *chi.Mux, store *subscription.Store, logger *slog.Logger, authMiddleware func(http.Handler) http.Handler) {
handlers := NewHandlers(store, logger) handlers := NewHandlers(store, logger)
router.Route("/api/v1/webpush/subscriptions", func(r chi.Router) { router.Route("/api/v1/webpush/subscriptions", func(r chi.Router) {

View file

@ -7,7 +7,8 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"prism/service/notification" "prism/service/delivery"
"prism/service/subscription"
webpush "github.com/SherClockHolmes/webpush-go" webpush "github.com/SherClockHolmes/webpush-go"
) )
@ -17,14 +18,12 @@ type Sender struct {
} }
func NewSender(logger *slog.Logger) *Sender { func NewSender(logger *slog.Logger) *Sender {
return &Sender{ return &Sender{logger: logger}
logger: logger,
}
} }
func (s *Sender) Send(sub *notification.Subscription, notif notification.Notification) error { func (s *Sender) Send(sub *subscription.Subscription, notif delivery.Notification) error {
if sub.WebPush == nil { if sub.WebPush == nil {
return notification.NewPermanentError(fmt.Errorf("no push endpoint configured for subscription %s", sub.ID)) return delivery.NewPermanentError(fmt.Errorf("no push endpoint configured for subscription %s", sub.ID))
} }
payload, err := json.Marshal(notif) payload, err := json.Marshal(notif)
@ -35,7 +34,7 @@ func (s *Sender) Send(sub *notification.Subscription, notif notification.Notific
if sub.WebPush.HasEncryption() { if sub.WebPush.HasEncryption() {
vapidPublicKey, err := deriveVAPIDPublicKey(sub.WebPush.VapidPrivateKey) vapidPublicKey, err := deriveVAPIDPublicKey(sub.WebPush.VapidPrivateKey)
if err != nil { if err != nil {
return notification.NewPermanentError(fmt.Errorf("invalid webpush VAPID key for subscription %s: %w", sub.ID, err)) return delivery.NewPermanentError(fmt.Errorf("invalid webpush VAPID key for subscription %s: %w", sub.ID, err))
} }
subscription := &webpush.Subscription{ subscription := &webpush.Subscription{
@ -47,7 +46,7 @@ func (s *Sender) Send(sub *notification.Subscription, notif notification.Notific
} }
resp, err := webpush.SendNotification(payload, subscription, &webpush.Options{ resp, err := webpush.SendNotification(payload, subscription, &webpush.Options{
Subscriber: "mailto:lonecloud604@proton.me", Subscriber: "https://github.com/lone-cloud/prism",
VAPIDPublicKey: vapidPublicKey, VAPIDPublicKey: vapidPublicKey,
VAPIDPrivateKey: sub.WebPush.VapidPrivateKey, VAPIDPrivateKey: sub.WebPush.VapidPrivateKey,
TTL: 86400, TTL: 86400,

View file

@ -1,291 +0,0 @@
package notification
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log/slog"
"time"
"prism/service/util"
)
type NotificationSender interface {
Send(sub *Subscription, notif Notification) error
}
type linkedChecker interface {
IsLinked() (bool, error)
}
type signalAutoConfigurer interface {
CreateDefaultSignalSubscription(appName string) (*SignalSubscription, error)
}
type telegramSender interface {
GetChatID() int64
}
type Dispatcher struct {
store *Store
senders map[Channel]NotificationSender
logger *slog.Logger
}
func NewDispatcher(store *Store, logger *slog.Logger) *Dispatcher {
return &Dispatcher{
store: store,
senders: make(map[Channel]NotificationSender),
logger: logger,
}
}
func (d *Dispatcher) RegisterSender(channel Channel, sender NotificationSender) {
d.senders[channel] = sender
}
func (d *Dispatcher) GetStore() *Store {
return d.store
}
func (d *Dispatcher) HasSignal() bool {
_, ok := d.senders[ChannelSignal]
return ok
}
func (d *Dispatcher) HasTelegram() bool {
_, ok := d.senders[ChannelTelegram]
return ok
}
func (d *Dispatcher) GetAvailableChannels() []Channel {
var channels []Channel
if d.HasSignal() {
channels = append(channels, ChannelSignal)
}
if d.HasTelegram() {
channels = append(channels, ChannelTelegram)
}
channels = append(channels, ChannelWebPush)
return channels
}
func (d *Dispatcher) IsValidChannel(channel Channel) bool {
return channel.IsAvailable(d.HasSignal(), d.HasTelegram())
}
func (d *Dispatcher) Send(appName string, notif Notification) error {
app, err := d.store.GetApp(appName)
if err != nil {
return util.LogError(d.logger, "Failed to get app", err, "app", appName)
}
if app == nil {
d.logger.Info("Registering new app and auto-configuring subscription", "app", appName)
app, err = d.CreateAppWithDefaultSubscription(appName)
if err != nil {
d.logger.Warn("Failed to auto-configure subscription", "app", appName, "error", err)
return nil
}
}
if len(app.Subscriptions) == 0 {
d.logger.Info("App has no subscriptions, attempting to auto-configure", "app", appName)
app, err = d.CreateAppWithDefaultSubscription(appName)
if err != nil {
d.logger.Warn("Failed to auto-configure subscription", "app", appName, "error", err)
return nil
}
}
var lastErr error
successCount := 0
for _, sub := range app.Subscriptions {
sender, ok := d.senders[sub.Channel]
if !ok {
d.logger.Debug("Skipping subscription for disabled channel", "channel", sub.Channel, "subscriptionID", sub.ID)
continue
}
if err := d.sendWithRetry(sender, &sub, notif, appName, sub.ID); err != nil {
lastErr = err
} else {
successCount++
}
}
if successCount == 0 && lastErr != nil {
return lastErr
}
return nil
}
func (d *Dispatcher) sendWithRetry(sender NotificationSender, sub *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 {
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<<uint(attempt))
d.logger.Warn("Failed to send notification, retrying", "app", appName, "subscriptionID", subscriptionID, "attempt", attempt+1, "error", err, "retryIn", delay)
time.Sleep(delay)
}
}
d.logger.Error("Failed to send notification after retries", "app", appName, "subscriptionID", subscriptionID, "attempts", maxRetries, "error", lastErr)
return lastErr
}
func (d *Dispatcher) CreateAppWithDefaultSubscription(appName string) (*App, error) {
app, signalErr := d.trySignalAutoConfig(appName)
if signalErr == nil {
return app, nil
}
d.logger.Warn("Signal auto-config failed, trying Telegram", "app", appName, "error", signalErr)
app, telegramErr := d.tryTelegramAutoConfig(appName)
if telegramErr == nil {
return app, nil
}
return nil, fmt.Errorf("signal auto-config failed: %v; telegram unavailable: %w", signalErr, telegramErr)
}
func (d *Dispatcher) trySignalAutoConfig(appName string) (*App, error) {
sender, ok := d.senders[ChannelSignal]
if !ok {
return nil, fmt.Errorf("signal sender not found")
}
linkChecker, ok := sender.(linkedChecker)
if !ok {
return nil, fmt.Errorf("sender does not implement signal linked check")
}
linked, err := linkChecker.IsLinked()
if err != nil {
return nil, fmt.Errorf("failed to check Signal link status: %w", err)
}
if !linked {
return nil, fmt.Errorf("no linked Signal account")
}
autoConfigurer, ok := sender.(signalAutoConfigurer)
if !ok {
return nil, fmt.Errorf("sender does not implement signal auto-configuration")
}
var signalSub *SignalSubscription
cachedGroup, err := d.store.GetSignalGroup(appName)
if err != nil {
d.logger.Warn("Failed to check for cached Signal group", "error", err)
}
if cachedGroup != nil {
d.logger.Debug("Reusing cached Signal group", "app", appName)
signalSub = cachedGroup
} else {
signalSub, err = autoConfigurer.CreateDefaultSignalSubscription(appName)
if err != nil {
return nil, err
}
}
subID, err := GenerateSubscriptionID()
if err != nil {
return nil, err
}
sub := Subscription{ID: subID, AppName: appName, Channel: ChannelSignal, Signal: signalSub}
if err := d.store.AddSubscription(sub); err != nil {
return nil, err
}
d.logger.Info("Auto-configured Signal subscription", "app", appName, "subscriptionID", subID)
return d.store.GetApp(appName)
}
func (d *Dispatcher) tryTelegramAutoConfig(appName string) (*App, error) {
chatID, err := d.getTelegramChatID()
if err != nil || chatID == "" {
return nil, fmt.Errorf("telegram not linked or no chat ID configured: %w", err)
}
subID, err := GenerateSubscriptionID()
if err != nil {
return nil, err
}
sub := Subscription{
ID: subID,
AppName: appName,
Channel: ChannelTelegram,
Telegram: &TelegramSubscription{ChatID: chatID},
}
if err := d.store.AddSubscription(sub); err != nil {
return nil, err
}
d.logger.Info("Auto-configured Telegram subscription", "app", appName, "subscriptionID", subID, "chatID", chatID)
return d.store.GetApp(appName)
}
func (d *Dispatcher) getTelegramChatID() (string, error) {
sender, ok := d.senders[ChannelTelegram]
if !ok {
return "", fmt.Errorf("telegram sender not found")
}
linkChecker, ok := sender.(linkedChecker)
if !ok {
return "", fmt.Errorf("sender does not implement telegram linked check")
}
linked, err := linkChecker.IsLinked()
if err != nil {
return "", err
}
if !linked {
return "", fmt.Errorf("telegram not linked")
}
tgSender, ok := sender.(telegramSender)
if !ok {
return "", fmt.Errorf("sender does not implement telegram interface")
}
chatID := tgSender.GetChatID()
if chatID == 0 {
return "", fmt.Errorf("no chat ID configured")
}
return fmt.Sprintf("%d", chatID), nil
}
func GenerateSubscriptionID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

View file

@ -1,111 +0,0 @@
package notification
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)
}
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"`
}
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
}
}
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"`
}

View file

@ -5,7 +5,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"prism/service/notification" "prism/service/subscription"
"prism/service/util" "prism/service/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -38,17 +38,17 @@ func (s *Server) handleGetApps(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(sanitizeApps(apps)) json.NewEncoder(w).Encode(sanitizeApps(apps))
} }
func sanitizeApps(apps []notification.App) []notification.App { func sanitizeApps(apps []subscription.App) []subscription.App {
sanitized := make([]notification.App, len(apps)) sanitized := make([]subscription.App, len(apps))
for i, app := range apps { for i, app := range apps {
sanitized[i] = notification.App{ sanitized[i] = subscription.App{
AppName: app.AppName, AppName: app.AppName,
Subscriptions: make([]notification.Subscription, len(app.Subscriptions)), Subscriptions: make([]subscription.Subscription, len(app.Subscriptions)),
} }
for j, sub := range app.Subscriptions { for j, sub := range app.Subscriptions {
sanitized[i].Subscriptions[j] = sub sanitized[i].Subscriptions[j] = sub
if sub.WebPush != nil { if sub.WebPush != nil {
sanitized[i].Subscriptions[j].WebPush = &notification.WebPushSubscription{ sanitized[i].Subscriptions[j].WebPush = &subscription.WebPushSubscription{
Endpoint: sub.WebPush.Endpoint, Endpoint: sub.WebPush.Endpoint,
} }
} }

View file

@ -5,7 +5,7 @@ import (
"net/http" "net/http"
"prism/service/integration/signal" "prism/service/integration/signal"
"prism/service/notification" "prism/service/subscription"
"prism/service/util" "prism/service/util"
) )
@ -48,16 +48,16 @@ func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(buf.Bytes()) _, _ = w.Write(buf.Bytes())
} }
func (s *Server) buildAppListData(apps []notification.App) []AppListItem { func (s *Server) buildAppListData(apps []subscription.App) []AppListItem {
if len(apps) == 0 { if len(apps) == 0 {
return nil return nil
} }
signalEnabled := s.integrations.Signal != nil && s.integrations.Signal.IsEnabled() signalEnabled := s.integrations.IsSignalLinked()
telegramEnabled := s.integrations.Telegram != nil && s.integrations.Telegram.IsEnabled() telegramEnabled := s.integrations.IsTelegramLinked()
telegramBotName := "" telegramBotName := ""
if telegramEnabled { if telegramEnabled {
handlers := s.integrations.Telegram.GetHandlers() handlers := s.integrations.Telegram.Handlers
if handlers != nil { if handlers != nil {
client := handlers.GetClient() client := handlers.GetClient()
if client != nil { if client != nil {
@ -73,17 +73,17 @@ func (s *Server) buildAppListData(apps []notification.App) []AppListItem {
channels := []ChannelState{} channels := []ChannelState{}
if signalEnabled { if signalEnabled {
var signalSub *notification.Subscription var signalSub *subscription.Subscription
for i := range app.Subscriptions { for i := range app.Subscriptions {
if app.Subscriptions[i].Channel == notification.ChannelSignal { if app.Subscriptions[i].Channel == subscription.ChannelSignal {
signalSub = &app.Subscriptions[i] signalSub = &app.Subscriptions[i]
break break
} }
} }
state := ChannelState{ state := ChannelState{
Channel: string(notification.ChannelSignal), Channel: string(subscription.ChannelSignal),
Label: notification.ChannelSignal.Label(), Label: subscription.ChannelSignal.Label(),
Active: signalSub != nil, Active: signalSub != nil,
Toggleable: true, Toggleable: true,
} }
@ -99,17 +99,17 @@ func (s *Server) buildAppListData(apps []notification.App) []AppListItem {
} }
if telegramEnabled { if telegramEnabled {
var telegramSub *notification.Subscription var telegramSub *subscription.Subscription
for i := range app.Subscriptions { for i := range app.Subscriptions {
if app.Subscriptions[i].Channel == notification.ChannelTelegram { if app.Subscriptions[i].Channel == subscription.ChannelTelegram {
telegramSub = &app.Subscriptions[i] telegramSub = &app.Subscriptions[i]
break break
} }
} }
state := ChannelState{ state := ChannelState{
Channel: string(notification.ChannelTelegram), Channel: string(subscription.ChannelTelegram),
Label: notification.ChannelTelegram.Label(), Label: subscription.ChannelTelegram.Label(),
Active: telegramSub != nil, Active: telegramSub != nil,
Toggleable: true, Toggleable: true,
} }
@ -126,7 +126,7 @@ func (s *Server) buildAppListData(apps []notification.App) []AppListItem {
webPushSubs := []SubscriptionItem{} webPushSubs := []SubscriptionItem{}
for _, sub := range app.Subscriptions { for _, sub := range app.Subscriptions {
if sub.Channel == notification.ChannelWebPush && sub.WebPush != nil { if sub.Channel == subscription.ChannelWebPush && sub.WebPush != nil {
webPushSubs = append(webPushSubs, SubscriptionItem{ webPushSubs = append(webPushSubs, SubscriptionItem{
ID: sub.ID, ID: sub.ID,
}) })
@ -135,8 +135,8 @@ func (s *Server) buildAppListData(apps []notification.App) []AppListItem {
if len(webPushSubs) > 0 { if len(webPushSubs) > 0 {
channels = append(channels, ChannelState{ channels = append(channels, ChannelState{
Channel: string(notification.ChannelWebPush), Channel: string(subscription.ChannelWebPush),
Label: notification.ChannelWebPush.Label(), Label: subscription.ChannelWebPush.Label(),
Active: true, Active: true,
Toggleable: false, Toggleable: false,
Subscriptions: webPushSubs, Subscriptions: webPushSubs,

View file

@ -2,10 +2,10 @@ package server
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"strings"
"time" "time"
"prism/service/util"
) )
type healthResponse struct { type healthResponse struct {
@ -30,47 +30,23 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
resp := healthResponse{ resp := healthResponse{
Version: s.version, Version: s.version,
Uptime: util.FormatUptime(uptime), Uptime: formatUptime(uptime),
} }
if s.integrations.Signal != nil && s.integrations.Signal.IsEnabled() { if s.integrations.Signal != nil && s.integrations.Signal.IsEnabled() {
signalClient := s.integrations.Signal.GetHandlers().GetClient() linked, account := s.integrations.Signal.Health()
account, _ := signalClient.GetLinkedAccount() resp.Signal = &integrationHealth{Linked: linked, Account: account}
if account != nil {
resp.Signal = &integrationHealth{
Linked: true,
Account: account.Number,
}
} else {
resp.Signal = &integrationHealth{
Linked: false,
}
}
} }
if s.integrations.Telegram != nil && s.integrations.Telegram.IsEnabled() { if s.integrations.Telegram != nil && s.integrations.Telegram.IsEnabled() {
telegramClient := s.integrations.Telegram.GetHandlers().GetClient() if linked, account := s.integrations.Telegram.Health(); linked {
if telegramClient != nil { resp.Telegram = &integrationHealth{Linked: true, Account: account}
bot, err := telegramClient.GetMe()
if err == nil {
resp.Telegram = &integrationHealth{
Linked: true,
Account: "@" + bot.Username,
}
}
} }
} }
if s.integrations.Proton != nil && s.integrations.Proton.IsEnabled() { if s.integrations.Proton != nil && s.integrations.Proton.IsEnabled() {
protonHandlers := s.integrations.Proton.GetHandlers() if linked, account := s.integrations.Proton.Health(); linked {
if protonHandlers != nil && protonHandlers.IsEnabled() { resp.Proton = &integrationHealth{Linked: true, Account: account}
email, hasCredentials := protonHandlers.LoadFreshCredentials()
if hasCredentials {
resp.Proton = &integrationHealth{
Linked: true,
Account: email,
}
}
} }
} }
@ -79,3 +55,26 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
s.logger.Error("Failed to encode health response", "error", err) 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, " ")
}

View file

@ -10,7 +10,7 @@ import (
"strings" "strings"
"time" "time"
"prism/service/notification" "prism/service/delivery"
"prism/service/util" "prism/service/util"
"github.com/go-chi/chi/v5" "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") appName := chi.URLParam(r, "appName")
decodedAppName, err := url.PathUnescape(appName) decodedAppName, err := url.PathUnescape(appName)
if err != nil { if err != nil {
http.Error(w, "Invalid app name", http.StatusBadRequest) util.JSONError(w, "Invalid app name", http.StatusBadRequest)
return return
} }
appName = decodedAppName appName = decodedAppName
if appName == "" || strings.Contains(appName, "/") { if appName == "" || strings.Contains(appName, "/") {
http.Error(w, "Invalid app name", http.StatusBadRequest) util.JSONError(w, "Invalid app name", http.StatusBadRequest)
return return
} }
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest) util.JSONError(w, "Failed to read body", http.StatusBadRequest)
return return
} }
message, title := parseNtfyPayload(r, body) message, title := parseNtfyPayload(r, body)
if message == "" { if message == "" {
http.Error(w, "Message required", http.StatusBadRequest) util.JSONError(w, "Message required", http.StatusBadRequest)
return return
} }
@ -46,12 +46,12 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
title = "" title = ""
} }
notif := notification.Notification{ notif := delivery.Notification{
Title: title, Title: title,
Message: message, 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) util.LogAndError(w, s.logger, "Failed to send notification", http.StatusInternalServerError, err, "app", appName)
return return
} }

View file

@ -4,7 +4,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"prism/service/notification" "prism/service/subscription"
"prism/service/util" "prism/service/util"
"github.com/go-chi/chi/v5" "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"), ChatID: r.FormValue("chat_id"),
} }
channel := notification.Channel(form.Channel) channel := subscription.Channel(form.Channel)
if !s.dispatcher.IsValidChannel(channel) { if !s.publisher.IsValidChannel(channel) {
util.SetToast(w, fmt.Sprintf("Invalid or unavailable channel: %s", form.Channel), "error") util.SetToast(w, fmt.Sprintf("Invalid or unavailable channel: %s", form.Channel), "error")
s.handleFragmentApps(w, r) s.handleFragmentApps(w, r)
return return
@ -56,40 +56,33 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request
} }
} }
subID, err := notification.GenerateSubscriptionID() sub := subscription.Subscription{
if err != nil {
util.LogAndError(w, s.logger, "Failed to generate subscription ID", http.StatusInternalServerError, err)
return
}
sub := notification.Subscription{
ID: subID,
AppName: appName, AppName: appName,
Channel: channel, Channel: channel,
} }
switch channel { switch channel {
case notification.ChannelSignal: case subscription.ChannelSignal:
if s.integrations.Signal == nil || !s.integrations.Signal.IsEnabled() { if s.integrations.Signal == nil || !s.integrations.Signal.IsEnabled() {
util.SetToast(w, "Signal not configured", "error") util.SetToast(w, "Signal not configured", "error")
s.handleFragmentApps(w, r) s.handleFragmentApps(w, r)
return return
} }
client := s.integrations.Signal.GetHandlers().GetClient() client := s.integrations.Signal.Handlers.Client
account, err := client.GetLinkedAccount() account, err := client.GetLinkedAccount()
if err != nil || account == nil { 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) s.handleFragmentApps(w, r)
return return
} }
if form.GroupID != "" { if form.GroupID != "" {
sub.Signal = &notification.SignalSubscription{ sub.Signal = &subscription.SignalSubscription{
GroupID: form.GroupID, GroupID: form.GroupID,
Account: account.Number, Account: account.Number,
} }
} else { } else {
cachedGroup, err := s.store.GetSignalGroup(appName) cachedGroup, err := s.integrations.Signal.Groups.Get(appName)
if err != nil { if err != nil {
util.LogAndError(w, s.logger, "Failed to check for cached Signal group", http.StatusInternalServerError, err) util.LogAndError(w, s.logger, "Failed to check for cached Signal group", http.StatusInternalServerError, err)
return return
@ -98,7 +91,7 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request
if cachedGroup != nil && cachedGroup.Account == account.Number { if cachedGroup != nil && cachedGroup.Account == account.Number {
sub.Signal = cachedGroup sub.Signal = cachedGroup
} else { } else {
signalSub, err := s.integrations.Signal.GetSender().CreateDefaultSignalSubscription(appName) signalSub, err := s.integrations.Signal.Sender.CreateDefaultSignalSubscription(appName)
if err != nil { if err != nil {
util.LogAndError(w, s.logger, "Failed to create Signal subscription", http.StatusInternalServerError, err) util.LogAndError(w, s.logger, "Failed to create Signal subscription", http.StatusInternalServerError, err)
return 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() { if s.integrations.Telegram == nil || !s.integrations.Telegram.IsEnabled() {
util.SetToast(w, "Telegram not configured", "error") util.SetToast(w, "Telegram not configured", "error")
s.handleFragmentApps(w, r) s.handleFragmentApps(w, r)
return return
} }
chatID := s.integrations.Telegram.GetHandlers().GetChatID() chatID := s.integrations.Telegram.Handlers.GetChatID()
if chatID == 0 { 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) s.handleFragmentApps(w, r)
return return
} }
@ -123,7 +116,7 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request
chatID = 0 chatID = 0
fmt.Sscanf(form.ChatID, "%d", &chatID) fmt.Sscanf(form.ChatID, "%d", &chatID)
} }
sub.Telegram = &notification.TelegramSubscription{ sub.Telegram = &subscription.TelegramSubscription{
ChatID: fmt.Sprintf("%d", chatID), ChatID: fmt.Sprintf("%d", chatID),
} }
@ -132,7 +125,7 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request
return 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) util.LogAndError(w, s.logger, "Failed to create subscription", http.StatusInternalServerError, err)
return return
} }
@ -160,7 +153,7 @@ func (s *Server) handleDeleteSubscription(w http.ResponseWriter, r *http.Request
} }
message := fmt.Sprintf("%s channel disabled", sub.Channel.Label()) 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()) message = fmt.Sprintf("%s channel deleted", sub.Channel.Label())
} }
util.SetToast(w, message, "success") util.SetToast(w, message, "success")

View file

@ -2,21 +2,27 @@ package server
import ( import (
"context" "context"
"database/sql"
"embed" "embed"
"fmt" "fmt"
"html/template" "html/template"
"io/fs" "io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"path/filepath"
"time" "time"
"prism/service/config" "prism/service/config"
"prism/service/delivery"
"prism/service/integration" "prism/service/integration"
"prism/service/notification" "prism/service/subscription"
"prism/service/util" "prism/service/util"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
_ "modernc.org/sqlite"
) )
//go:embed templates/*.html //go:embed templates/*.html
@ -26,8 +32,8 @@ type Server struct {
startTime time.Time startTime time.Time
publicAssets embed.FS publicAssets embed.FS
cfg *config.Config cfg *config.Config
store *notification.Store store *subscription.Store
dispatcher *notification.Dispatcher publisher *delivery.Publisher
integrations *integration.Integrations integrations *integration.Integrations
logger *slog.Logger logger *slog.Logger
router *chi.Mux router *chi.Mux
@ -37,8 +43,26 @@ type Server struct {
version string 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) { 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 { if err != nil {
return nil, fmt.Errorf("failed to create store: %w", err) 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{ s := &Server{
cfg: cfg, cfg: cfg,
store: store, store: store,
dispatcher: integrations.Dispatcher, publisher: integrations.Publisher,
integrations: integrations, integrations: integrations,
logger: logger, logger: logger,
startTime: time.Now(), 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/apps", s.handleFragmentApps)
r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/integrations", s.handleFragmentIntegrations) 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) 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) return fmt.Errorf("failed to close store: %w", err)
} }

View file

@ -45,7 +45,7 @@
</div> </div>
{{else}} {{else}}
<div class="app-no-subscriptions"> <div class="app-no-subscriptions">
<span class="channel-badge channel-not-configured">No channels enabled</span> <span class="channel-badge channel-not-configured">No integrations linked</span>
</div> </div>
{{end}} {{end}}
</div> </div>

View file

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

View file

@ -1,37 +1,19 @@
package notification package subscription
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
) )
type Store struct { type Store struct {
db *sql.DB DB *sql.DB
} }
func NewStore(dbPath string) (*Store, error) { func NewStore(db *sql.DB) (*Store, error) {
dir := filepath.Dir(dbPath) store := &Store{DB: db}
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}
if err := store.createTables(); err != nil { if err := store.createTables(); err != nil {
return nil, err return nil, err
} }
return store, nil return store, nil
} }
@ -41,7 +23,7 @@ func (s *Store) createTables() error {
appName TEXT PRIMARY KEY appName TEXT PRIMARY KEY
)`, )`,
`CREATE TABLE IF NOT EXISTS subscriptions ( `CREATE TABLE IF NOT EXISTS subscriptions (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY DEFAULT (hex(randomblob(16))),
appName TEXT NOT NULL, appName TEXT NOT NULL,
channel TEXT NOT NULL, channel TEXT NOT NULL,
signalGroupId TEXT, signalGroupId TEXT,
@ -53,17 +35,11 @@ func (s *Store) createTables() error {
vapidPrivateKey TEXT, vapidPrivateKey TEXT,
FOREIGN KEY(appName) REFERENCES apps(appName) ON DELETE CASCADE 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)`, `CREATE INDEX IF NOT EXISTS idx_subscriptions_appName ON subscriptions(appName)`,
} }
for _, query := range queries { 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) return fmt.Errorf("failed to create tables: %w", err)
} }
} }
@ -71,29 +47,16 @@ func (s *Store) createTables() error {
return nil 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 { 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 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 { 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 var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey *string
if sub.Signal != nil { if sub.Signal != nil {
@ -110,13 +73,17 @@ func (s *Store) AddSubscription(sub Subscription) error {
vapidPrivateKey = &sub.WebPush.VapidPrivateKey vapidPrivateKey = &sub.WebPush.VapidPrivateKey
} }
_, err := s.db.Exec(query, sub.ID, sub.AppName, sub.Channel, signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey) var id string
return err 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) { func (s *Store) GetApp(appName string) (*App, error) {
query := `SELECT appName FROM apps WHERE appName = ?` row := s.DB.QueryRow(`SELECT appName FROM apps WHERE appName = ?`, appName)
row := s.db.QueryRow(query, appName)
var app App var app App
if err := row.Scan(&app.AppName); err == sql.ErrNoRows { 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) { 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 SELECT id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey
FROM subscriptions FROM subscriptions
WHERE appName = ? WHERE appName = ?
` `, appName)
rows, err := s.db.Query(query, appName)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -188,8 +154,7 @@ func (s *Store) GetSubscriptions(appName string) ([]Subscription, error) {
} }
func (s *Store) GetAllApps() ([]App, error) { func (s *Store) GetAllApps() ([]App, error) {
query := `SELECT appName FROM apps ORDER BY appName` rows, err := s.DB.Query(`SELECT appName FROM apps ORDER BY appName`)
rows, err := s.db.Query(query)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -227,46 +192,22 @@ func (s *Store) GetAllApps() ([]App, error) {
return apps, nil return apps, nil
} }
func (s *Store) GetSignalGroup(appName string) (*SignalSubscription, error) { func (s *Store) DeleteSubscription(subscriptionID string) error {
query := `SELECT groupId, account FROM signal_groups WHERE appName = ?` _, err := s.DB.Exec(`DELETE FROM subscriptions WHERE id = ?`, subscriptionID)
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)
return err return err
} }
func (s *Store) DeleteSubscription(subscriptionID string) error { func (s *Store) DeleteSubscriptionsByChannel(channel Channel) error {
_, err := s.db.Exec(`DELETE FROM subscriptions WHERE id = ?`, subscriptionID) _, err := s.DB.Exec(`DELETE FROM subscriptions WHERE channel = ?`, channel)
return err return err
} }
func (s *Store) GetSubscription(subscriptionID string) (*Subscription, error) { func (s *Store) GetSubscription(subscriptionID string) (*Subscription, error) {
query := ` row := s.DB.QueryRow(`
SELECT id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey SELECT id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey
FROM subscriptions FROM subscriptions
WHERE id = ? WHERE id = ?
` `, subscriptionID)
row := s.db.QueryRow(query, subscriptionID)
var sub Subscription var sub Subscription
var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString 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 { 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 return err
} }

View file

@ -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"`
}

View file

@ -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, " ")
}

18
service/util/htmx.go Normal file
View file

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

View file

@ -6,18 +6,6 @@ import (
"net/http" "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) { func LogAndError(w http.ResponseWriter, logger *slog.Logger, message string, code int, err error, attrs ...any) {
logAttrs := append([]any{"error", err}, attrs...) logAttrs := append([]any{"error", err}, attrs...)
logger.Error(message, logAttrs...) logger.Error(message, logAttrs...)

View file

@ -65,7 +65,7 @@ func (h *ColorHandler) Handle(ctx context.Context, r slog.Record) error {
color = colorReset 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 _, _ = fmt.Fprintf(h.w, "%s%s%s [%s%s%s] %s", //nolint:errcheck
colorGray, timestamp, colorReset, colorGray, timestamp, colorReset,
color, level, colorReset, color, level, colorReset,