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 .
golangci-lint run --fix
npx @biomejs/biome@latest check --write --unsafe .
vet:
go vet ./...
go mod download
go mod tidy
clean:
rm -f $(BINARY_NAME) $(BINARY_NAME)-*
@ -47,24 +46,9 @@ install-tools:
signal-cli --version && \
echo "signal-cli installed successfully to /usr/local/bin/signal-cli"
deps:
go mod download
go mod tidy
check-updates:
@go list -u -m -f '{{if not .Indirect}}{{.Path}} {{.Version}}{{if .Update}} [{{.Update.Version}}]{{end}}{{end}}' all | grep "\[" || echo "All dependencies are up to date"
update:
go get -u ./...
go mod tidy
update-all:
go get -u all
go mod tidy
docker-up:
docker compose -f docker-compose.dev.yml up -d
release:
@if [ ! -f VERSION ]; then \
echo "Error: VERSION file not found"; \

View file

@ -1 +1 @@
0.4.1
1.0.0

View file

@ -20,7 +20,7 @@
</summary>
<div id="apps-list" class="card-content"
hx-get="/fragment/apps"
hx-trigger="load">
hx-trigger="load, reload">
<div class="loading">
<div class="spinner"></div>
</div>

View file

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

View file

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

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 (
"context"
"database/sql"
"embed"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"prism/service/config"
"prism/service/delivery"
"prism/service/integration/proton"
"prism/service/integration/signal"
"prism/service/integration/telegram"
"prism/service/integration/webpush"
"prism/service/notification"
"prism/service/subscription"
"prism/service/util"
"github.com/go-chi/chi/v5"
)
//go:embed templates/*.html
var templates embed.FS
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)
IsEnabled() bool
Health() (linked bool, account string)
}
type AutoSubscriber interface {
AutoSubscribe(appName string, store *subscription.Store, publisher *delivery.Publisher) error
}
type Integrations struct {
Dispatcher *notification.Dispatcher
Publisher *delivery.Publisher
Signal *signal.Integration
Telegram *telegram.Integration
Proton *proton.Integration
store *subscription.Store
logger *slog.Logger
integrations []Integration
}
func Initialize(cfg *config.Config, store *notification.Store, logger *slog.Logger, baseTmpl *template.Template) (*Integrations, *template.Template, error) {
fragmentTmpl := baseTmpl
func Initialize(cfg *config.Config, store *subscription.Store, logger *slog.Logger, baseTmpl *template.Template) (*Integrations, *template.Template, error) {
var err error
fragmentTmpl, err = fragmentTmpl.ParseFS(GetTemplates(), "templates/*.html")
baseTmpl, err = baseTmpl.ParseFS(templates, "templates/*.html")
if err != nil {
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 telegramIntegration *telegram.Integration
dispatcher := notification.NewDispatcher(store, logger)
var integrations []Integration
var protonIntegration *proton.Integration
integrations := []Integration{webpush.NewIntegration(store, logger)}
if cfg.EnableSignal {
signalIntegration = signal.NewIntegration(cfg, store, logger, tmplRenderer)
if signalSender := signalIntegration.GetSender(); signalSender != nil {
dispatcher.RegisterSender(notification.ChannelSignal, signalSender)
tmplRenderer, err := newIntegrationRenderer(baseTmpl, "signal", signal.Templates)
if err != nil {
return nil, nil, err
}
signalIntegration = signal.NewIntegration(cfg, store, logger, tmplRenderer)
integrations = append(integrations, signalIntegration)
}
if cfg.EnableTelegram {
telegramIntegration = telegram.NewIntegration(cfg, store, logger, tmplRenderer)
if telegramSender := telegramIntegration.GetSender(); telegramSender != nil {
dispatcher.RegisterSender(notification.ChannelTelegram, telegramSender)
tmplRenderer, err := newIntegrationRenderer(baseTmpl, "telegram", telegram.Templates)
if err != nil {
return nil, nil, err
}
telegramIntegration = telegram.NewIntegration(store, logger, tmplRenderer, cfg.APIKey)
integrations = append(integrations, telegramIntegration)
}
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)
}
dispatcher.RegisterSender(notification.ChannelWebPush, webpush.NewSender(logger))
integrations = append(integrations, webpush.NewIntegration(store, logger))
return &Integrations{
Dispatcher: dispatcher,
i := &Integrations{
Signal: signalIntegration,
Telegram: telegramIntegration,
Proton: protonIntegration,
store: store,
logger: logger,
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) {
@ -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)
db := store.GetDB()
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)
c := &protonmail.Client{
RootURL: "https://mail.proton.me/api",
AppVersion: "Other",
RootURL: protonAPIURL,
AppVersion: protonAppVersion,
}
var auth *protonmail.Auth

View file

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

View file

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

View file

@ -3,7 +3,8 @@ package proton
import (
"time"
"prism/service/notification"
"prism/service/delivery"
"prism/service/subscription"
"github.com/emersion/hydroxide/protonmail"
)
@ -75,11 +76,11 @@ func (m *Monitor) sendNotification(msg *protonmail.Message) {
subject = "(No subject)"
}
notif := notification.Notification{
notif := delivery.Notification{
Title: from,
Message: subject,
Tag: "proton-" + msg.ID,
Actions: []notification.Action{
Actions: []delivery.Action{
{
ID: "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)
} else {
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) {
app, err := m.dispatcher.GetStore().GetApp(prismTopic)
app, err := m.dispatcher.Store.GetApp(prismTopic)
if err != nil || app == nil {
return
}
hasWebPush := false
for _, sub := range app.Subscriptions {
if sub.Channel == notification.ChannelWebPush {
if sub.Channel == subscription.ChannelWebPush {
hasWebPush = true
break
}
@ -135,13 +136,13 @@ func (m *Monitor) clearNotification(msgID string) {
return
}
notif := notification.Notification{
notif := delivery.Notification{
Tag: "proton-" + msgID,
Title: "",
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)
} else {
m.logger.Debug("Cleared notification", "msgID", msgID)

View file

@ -8,7 +8,7 @@ import (
"prism/service/config"
"prism/service/credentials"
"prism/service/notification"
"prism/service/delivery"
"github.com/emersion/hydroxide/protonmail"
)
@ -16,11 +16,13 @@ import (
const (
pollInterval = 30 * time.Second
prismTopic = "Proton Mail"
protonAPIURL = "https://mail.proton.me/api"
protonAppVersion = "Other"
)
type Monitor struct {
cfg *config.Config
dispatcher *notification.Dispatcher
dispatcher *delivery.Publisher
logger *slog.Logger
credStore *credentials.Store
client *protonmail.Client
@ -31,15 +33,15 @@ type Monitor struct {
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{
cfg: cfg,
dispatcher: dispatcher,
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 {
return err
}

View file

@ -9,7 +9,6 @@ import (
"net/http"
"prism/service/credentials"
"prism/service/notification"
"prism/service/util"
"github.com/emersion/hydroxide/protonmail"
@ -17,18 +16,13 @@ import (
)
//go:embed templates/*.html
var templates embed.FS
func GetTemplates() embed.FS {
return templates
}
var Templates embed.FS
type authHandler struct {
db *sql.DB
apiKey string
logger *slog.Logger
integration *Integration
dispatcher *notification.Dispatcher
}
type protonAuthRequest struct {
@ -50,8 +44,8 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
}
c := &protonmail.Client{
RootURL: "https://mail.proton.me/api",
AppVersion: "Other",
RootURL: protonAPIURL,
AppVersion: protonAppVersion,
}
authInfo, err := c.AuthInfo(req.Email)
if err != nil {
@ -115,24 +109,16 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
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 {
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)
util.JSONError(w, "Authentication failed: "+err.Error(), http.StatusInternalServerError)
return
}
h.logger.Info("Proton monitor started")
if h.integration.handlers != nil {
h.integration.handlers.username = req.Email
if h.integration.Handlers != nil {
h.integration.Handlers.username = req.Email
}
}
@ -165,14 +151,14 @@ func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler)
return
}
handlers.SetDB(db, apiKey)
handlers.DB = db
handlers.APIKey = apiKey
authH := &authHandler{
db: db,
apiKey: apiKey,
logger: logger,
integration: integration,
dispatcher: integration.dispatcher,
}
router.With(auth).Get("/fragment/proton", handlers.HandleFragment)

View file

@ -17,37 +17,23 @@ const (
type Client struct {
ConfigPath string
enabled bool
}
func NewClient() *Client {
configPath := filepath.Join(os.Getenv("HOME"), DefaultConfigPath)
enabled := false
if _, err := exec.LookPath("signal-cli"); err == nil {
enabled = true
if _, err := exec.LookPath("signal-cli"); err != nil {
return nil
}
return &Client{
ConfigPath: configPath,
enabled: enabled,
ConfigPath: filepath.Join(os.Getenv("HOME"), DefaultConfigPath),
}
}
func (c *Client) IsEnabled() bool {
return c.enabled
}
type Account struct {
Number string
UUID string
}
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"}
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) {
if c == nil || !c.enabled {
if c == nil {
return nil, nil
}
@ -113,7 +99,7 @@ func (c *Client) GetLinkedAccount() (*Account, 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")
}
@ -148,7 +134,7 @@ func (c *Client) CreateGroup(name string) (string, 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")
}

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

View file

@ -2,12 +2,13 @@ package signal
import (
"context"
"database/sql"
"fmt"
"log/slog"
"net/http"
"prism/service/config"
"prism/service/notification"
"prism/service/delivery"
"prism/service/subscription"
"prism/service/util"
"github.com/go-chi/chi/v5"
@ -16,54 +17,106 @@ import (
type Integration struct {
cfg *config.Config
client *Client
handlers *Handlers
sender *Sender
Handlers *Handlers
Sender *Sender
Groups *GroupCache
store *subscription.Store
logger *slog.Logger
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()
groups, err := NewGroupCache(store.DB)
if err != nil {
logger.Error("Failed to initialize Signal group cache", "error", err)
}
var sender *Sender
if client.IsEnabled() {
sender = NewSender(client, store, logger)
if client != nil {
sender = NewSender(client, groups, logger)
}
return &Integration{
cfg: cfg,
client: client,
sender: sender,
Sender: sender,
Groups: groups,
store: store,
logger: logger,
tmpl: tmpl,
}
}
func (s *Integration) GetSender() *Sender {
return s.sender
}
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) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, 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) {
if s.handlers != nil && s.handlers.IsEnabled() {
client := s.handlers.GetClient()
if s.Handlers != nil && s.Handlers.Client != nil {
client := s.Handlers.Client
account, _ := client.GetLinkedAccount()
if account != nil {
logger.Info("Signal enabled", "status", "linked", "number", FormatPhoneNumber(account.Number))
logger.Debug("Signal enabled", "status", "linked", "number", FormatPhoneNumber(account.Number))
} 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 {
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 {
if s.handlers == nil {
return false
}
return s.handlers.IsEnabled()
return s.Handlers != nil && s.Handlers.Client != nil
}
func (s *Integration) GetHandlers() *Handlers {
return s.handlers
func (s *Integration) Health() (bool, string) {
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) {
if c == nil || !c.enabled {
if c == nil {
return "", fmt.Errorf("signal-cli not found in PATH")
}

View file

@ -12,11 +12,7 @@ import (
)
//go:embed templates/*.html
var templates embed.FS
func GetTemplates() embed.FS {
return templates
}
var Templates embed.FS
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)

View file

@ -4,20 +4,21 @@ import (
"fmt"
"log/slog"
"prism/service/notification"
"prism/service/delivery"
"prism/service/subscription"
"prism/service/util"
)
type Sender struct {
client *Client
store *notification.Store
groups *GroupCache
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{
client: client,
store: store,
groups: groups,
logger: logger,
}
}
@ -35,7 +36,7 @@ func (s *Sender) IsLinked() (bool, error) {
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 {
return nil, fmt.Errorf("signal integration not enabled")
}
@ -45,25 +46,25 @@ func (s *Sender) CreateDefaultSignalSubscription(appName string) (*notification.
return nil, err
}
signalSub := &notification.SignalSubscription{
signalSub := &subscription.SignalSubscription{
GroupID: groupID,
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)
}
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 {
return notification.NewPermanentError(fmt.Errorf("signal integration not enabled"))
return delivery.NewPermanentError(fmt.Errorf("signal integration not enabled"))
}
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()
@ -72,7 +73,7 @@ func (s *Sender) Send(sub *notification.Subscription, notif notification.Notific
}
if account == nil {
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
@ -87,7 +88,7 @@ func (s *Sender) Send(sub *notification.Subscription, notif notification.Notific
}
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

View file

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

View file

@ -3,29 +3,34 @@ package telegram
import (
"context"
"database/sql"
"fmt"
"log/slog"
"net/http"
"strconv"
"prism/service/config"
"prism/service/credentials"
"prism/service/notification"
"prism/service/delivery"
"prism/service/subscription"
"prism/service/util"
"github.com/go-chi/chi/v5"
)
type Integration struct {
handlers *Handlers
sender *Sender
Handlers *Handlers
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 chatID int64
var sender *Sender
credStore, err := credentials.NewStoreWithLogger(store.GetDB(), cfg.APIKey, logger)
credStore, err := credentials.NewStoreWithLogger(store.DB, apiKey, logger)
if err != nil {
logger.Warn("Failed to initialize credentials store for Telegram", "error", err)
} 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)
}
handlers := NewHandlers(client, chatID, tmpl, logger)
return &Integration{
handlers: handlers,
sender: sender,
Handlers: handlers,
Sender: sender,
db: store.DB,
apiKey: apiKey,
logger: logger,
}
}
func (t *Integration) GetSender() *Sender {
return t.sender
}
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) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler, logger *slog.Logger) {
RegisterRoutes(router, t.Handlers, auth, t.db, t.apiKey, logger, t.OnUnlink)
}
func (t *Integration) Start(ctx context.Context, logger *slog.Logger) {
client := t.handlers.GetClient()
client := t.Handlers.GetClient()
if client == nil {
logger.Info("Telegram not configured", "action", "visit admin UI to configure")
return
@ -80,7 +80,7 @@ func (t *Integration) Start(ctx context.Context, logger *slog.Logger) {
return
}
chatID := t.handlers.chatID
chatID := t.Handlers.chatID
if chatID == 0 {
logger.Info("Telegram bot connected", "bot", bot.Username, "status", "needs_chat_id")
} else {
@ -89,5 +89,60 @@ func (t *Integration) Start(ctx context.Context, logger *slog.Logger) {
}
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
var templates embed.FS
func GetTemplates() embed.FS {
return templates
}
var Templates embed.FS
type linkHandler struct {
db *sql.DB
apiKey string
logger *slog.Logger
onUnlink func()
}
type telegramLinkRequest struct {
@ -76,19 +73,21 @@ func (h *linkHandler) handleUnlink(w http.ResponseWriter, r *http.Request) {
return
}
h.onUnlink()
util.SetToast(w, "Telegram unlinked", "success")
w.Header().Set("Content-Type", "application/json")
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 {
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).Post("/api/v1/telegram/link", linkH.handleLink)

View file

@ -5,7 +5,8 @@ import (
"log/slog"
"strconv"
"prism/service/notification"
"prism/service/delivery"
"prism/service/subscription"
)
func parseInt64(s string) (int64, error) {
@ -14,12 +15,12 @@ func parseInt64(s string) (int64, error) {
type Sender struct {
client *Client
store *notification.Store
store *subscription.Store
logger *slog.Logger
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{
client: client,
store: store,
@ -48,13 +49,13 @@ func (s *Sender) IsLinked() (bool, error) {
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 {
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 == "" {
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
@ -66,7 +67,7 @@ func (s *Sender) Send(sub *notification.Subscription, notif notification.Notific
chatID, err := parseInt64(sub.Telegram.ChatID)
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 {

View file

@ -1,32 +1,22 @@
package webpush
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"log/slog"
"net/http"
"prism/service/notification"
"prism/service/subscription"
"prism/service/util"
"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 {
store *notification.Store
store *subscription.Store
logger *slog.Logger
}
func NewHandlers(store *notification.Store, logger *slog.Logger) *Handlers {
func NewHandlers(store *subscription.Store, logger *slog.Logger) *Handlers {
return &Handlers{
store: store,
logger: logger,
@ -44,7 +34,7 @@ type registerRequest struct {
func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
var req registerRequest
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
}
@ -73,13 +63,7 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
return
}
subID, err := generateSubscriptionID()
if err != nil {
util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err)
return
}
var webPush *notification.WebPushSubscription
var webPush *subscription.WebPushSubscription
if req.P256dh != nil && req.Auth != nil && req.VapidPrivateKey != nil {
normalizedP256dh, err := normalizeP256DH(*req.P256dh)
if err != nil {
@ -99,26 +83,26 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
return
}
webPush = &notification.WebPushSubscription{
webPush = &subscription.WebPushSubscription{
Endpoint: req.PushEndpoint,
P256dh: normalizedP256dh,
Auth: normalizedAuth,
VapidPrivateKey: normalizedKey,
}
} else {
webPush = &notification.WebPushSubscription{
webPush = &subscription.WebPushSubscription{
Endpoint: req.PushEndpoint,
}
}
sub := notification.Subscription{
ID: subID,
sub := subscription.Subscription{
AppName: req.AppName,
Channel: notification.ChannelWebPush,
Channel: subscription.ChannelWebPush,
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)
util.LogAndError(w, h.logger, "Failed to add subscription", http.StatusInternalServerError, err)
return
@ -129,7 +113,7 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
response := map[string]string{
"appName": req.AppName,
"channel": notification.ChannelWebPush.String(),
"channel": subscription.ChannelWebPush.String(),
"subscriptionId": subID,
}
_ = 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) {
subscriptionID := chi.URLParam(r, "subscriptionId")
if subscriptionID == "" {
http.Error(w, "subscriptionId is required", http.StatusBadRequest)
util.JSONError(w, "subscriptionId is required", http.StatusBadRequest)
return
}

View file

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

View file

@ -4,12 +4,12 @@ import (
"log/slog"
"net/http"
"prism/service/notification"
"prism/service/subscription"
"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)
router.Route("/api/v1/webpush/subscriptions", func(r chi.Router) {

View file

@ -7,7 +7,8 @@ import (
"log/slog"
"net/http"
"prism/service/notification"
"prism/service/delivery"
"prism/service/subscription"
webpush "github.com/SherClockHolmes/webpush-go"
)
@ -17,14 +18,12 @@ type Sender struct {
}
func NewSender(logger *slog.Logger) *Sender {
return &Sender{
logger: logger,
}
return &Sender{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 {
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)
@ -35,7 +34,7 @@ func (s *Sender) Send(sub *notification.Subscription, notif notification.Notific
if sub.WebPush.HasEncryption() {
vapidPublicKey, err := deriveVAPIDPublicKey(sub.WebPush.VapidPrivateKey)
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{
@ -47,7 +46,7 @@ func (s *Sender) Send(sub *notification.Subscription, notif notification.Notific
}
resp, err := webpush.SendNotification(payload, subscription, &webpush.Options{
Subscriber: "mailto:lonecloud604@proton.me",
Subscriber: "https://github.com/lone-cloud/prism",
VAPIDPublicKey: vapidPublicKey,
VAPIDPrivateKey: sub.WebPush.VapidPrivateKey,
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"
"net/http"
"prism/service/notification"
"prism/service/subscription"
"prism/service/util"
"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))
}
func sanitizeApps(apps []notification.App) []notification.App {
sanitized := make([]notification.App, len(apps))
func sanitizeApps(apps []subscription.App) []subscription.App {
sanitized := make([]subscription.App, len(apps))
for i, app := range apps {
sanitized[i] = notification.App{
sanitized[i] = subscription.App{
AppName: app.AppName,
Subscriptions: make([]notification.Subscription, len(app.Subscriptions)),
Subscriptions: make([]subscription.Subscription, len(app.Subscriptions)),
}
for j, sub := range app.Subscriptions {
sanitized[i].Subscriptions[j] = sub
if sub.WebPush != nil {
sanitized[i].Subscriptions[j].WebPush = &notification.WebPushSubscription{
sanitized[i].Subscriptions[j].WebPush = &subscription.WebPushSubscription{
Endpoint: sub.WebPush.Endpoint,
}
}

View file

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

View file

@ -2,10 +2,10 @@ package server
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"prism/service/util"
)
type healthResponse struct {
@ -30,47 +30,23 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
resp := healthResponse{
Version: s.version,
Uptime: util.FormatUptime(uptime),
Uptime: formatUptime(uptime),
}
if s.integrations.Signal != nil && s.integrations.Signal.IsEnabled() {
signalClient := s.integrations.Signal.GetHandlers().GetClient()
account, _ := signalClient.GetLinkedAccount()
if account != nil {
resp.Signal = &integrationHealth{
Linked: true,
Account: account.Number,
}
} else {
resp.Signal = &integrationHealth{
Linked: false,
}
}
linked, account := s.integrations.Signal.Health()
resp.Signal = &integrationHealth{Linked: linked, Account: account}
}
if s.integrations.Telegram != nil && s.integrations.Telegram.IsEnabled() {
telegramClient := s.integrations.Telegram.GetHandlers().GetClient()
if telegramClient != nil {
bot, err := telegramClient.GetMe()
if err == nil {
resp.Telegram = &integrationHealth{
Linked: true,
Account: "@" + bot.Username,
}
}
if linked, account := s.integrations.Telegram.Health(); linked {
resp.Telegram = &integrationHealth{Linked: true, Account: account}
}
}
if s.integrations.Proton != nil && s.integrations.Proton.IsEnabled() {
protonHandlers := s.integrations.Proton.GetHandlers()
if protonHandlers != nil && protonHandlers.IsEnabled() {
email, hasCredentials := protonHandlers.LoadFreshCredentials()
if hasCredentials {
resp.Proton = &integrationHealth{
Linked: true,
Account: email,
}
}
if linked, account := s.integrations.Proton.Health(); linked {
resp.Proton = &integrationHealth{Linked: true, Account: account}
}
}
@ -79,3 +55,26 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
s.logger.Error("Failed to encode health response", "error", err)
}
}
func formatUptime(d time.Duration) string {
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
minutes := int(d.Minutes()) % 60
seconds := int(d.Seconds()) % 60
parts := []string{}
if days > 0 {
parts = append(parts, fmt.Sprintf("%dd", days))
}
if hours > 0 {
parts = append(parts, fmt.Sprintf("%dh", hours))
}
if minutes > 0 {
parts = append(parts, fmt.Sprintf("%dm", minutes))
}
if seconds > 0 || len(parts) == 0 {
parts = append(parts, fmt.Sprintf("%ds", seconds))
}
return strings.Join(parts, " ")
}

View file

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

View file

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

View file

@ -2,21 +2,27 @@ package server
import (
"context"
"database/sql"
"embed"
"fmt"
"html/template"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"time"
"prism/service/config"
"prism/service/delivery"
"prism/service/integration"
"prism/service/notification"
"prism/service/subscription"
"prism/service/util"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
_ "modernc.org/sqlite"
)
//go:embed templates/*.html
@ -26,8 +32,8 @@ type Server struct {
startTime time.Time
publicAssets embed.FS
cfg *config.Config
store *notification.Store
dispatcher *notification.Dispatcher
store *subscription.Store
publisher *delivery.Publisher
integrations *integration.Integrations
logger *slog.Logger
router *chi.Mux
@ -37,8 +43,26 @@ type Server struct {
version string
}
func openDB(dbPath string) (*sql.DB, error) {
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
return nil, fmt.Errorf("failed to create database directory: %w", err)
}
db, err := sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
return db, nil
}
func New(cfg *config.Config, publicAssets embed.FS, version string, logger *slog.Logger) (*Server, error) {
store, err := notification.NewStore(cfg.StoragePath)
db, err := openDB(cfg.StoragePath)
if err != nil {
return nil, err
}
store, err := subscription.NewStore(db)
if err != nil {
return nil, fmt.Errorf("failed to create store: %w", err)
}
@ -62,7 +86,7 @@ func New(cfg *config.Config, publicAssets embed.FS, version string, logger *slog
s := &Server{
cfg: cfg,
store: store,
dispatcher: integrations.Dispatcher,
publisher: integrations.Publisher,
integrations: integrations,
logger: logger,
startTime: time.Now(),
@ -98,7 +122,7 @@ func (s *Server) setupRoutes() {
}
})
integration.RegisterAll(s.integrations, r, s.cfg, s.store, s.logger, authMiddleware)
integration.RegisterAll(s.integrations, r, s.cfg, s.logger, authMiddleware)
r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/apps", s.handleFragmentApps)
r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/integrations", s.handleFragmentIntegrations)
@ -168,7 +192,7 @@ func (s *Server) Shutdown() error {
return fmt.Errorf("failed to shutdown http server: %w", err)
}
if err := s.store.Close(); err != nil {
if err := s.store.DB.Close(); err != nil {
return fmt.Errorf("failed to close store: %w", err)
}

View file

@ -45,7 +45,7 @@
</div>
{{else}}
<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>
{{end}}
</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 (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
type Store struct {
db *sql.DB
DB *sql.DB
}
func NewStore(dbPath string) (*Store, error) {
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, fmt.Errorf("failed to create database directory: %w", err)
}
db, err := sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
store := &Store{db: db}
func NewStore(db *sql.DB) (*Store, error) {
store := &Store{DB: db}
if err := store.createTables(); err != nil {
return nil, err
}
return store, nil
}
@ -41,7 +23,7 @@ func (s *Store) createTables() error {
appName TEXT PRIMARY KEY
)`,
`CREATE TABLE IF NOT EXISTS subscriptions (
id TEXT PRIMARY KEY,
id TEXT PRIMARY KEY DEFAULT (hex(randomblob(16))),
appName TEXT NOT NULL,
channel TEXT NOT NULL,
signalGroupId TEXT,
@ -53,17 +35,11 @@ func (s *Store) createTables() error {
vapidPrivateKey TEXT,
FOREIGN KEY(appName) REFERENCES apps(appName) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS signal_groups (
appName TEXT PRIMARY KEY,
groupId TEXT NOT NULL,
account TEXT NOT NULL,
FOREIGN KEY(appName) REFERENCES apps(appName) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS idx_subscriptions_appName ON subscriptions(appName)`,
}
for _, query := range queries {
if _, err := s.db.Exec(query); err != nil {
if _, err := s.DB.Exec(query); err != nil {
return fmt.Errorf("failed to create tables: %w", err)
}
}
@ -71,29 +47,16 @@ func (s *Store) createTables() error {
return nil
}
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) GetDB() *sql.DB {
return s.db
}
func (s *Store) RegisterApp(appName string) error {
_, err := s.db.Exec(`INSERT INTO apps (appName) VALUES (?) ON CONFLICT(appName) DO NOTHING`, appName)
_, err := s.DB.Exec(`INSERT INTO apps (appName) VALUES (?) ON CONFLICT(appName) DO NOTHING`, appName)
return err
}
func (s *Store) AddSubscription(sub Subscription) error {
func (s *Store) AddSubscription(sub Subscription) (string, error) {
if err := s.RegisterApp(sub.AppName); err != nil {
return err
return "", err
}
query := `
INSERT INTO subscriptions (id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey *string
if sub.Signal != nil {
@ -110,13 +73,17 @@ func (s *Store) AddSubscription(sub Subscription) error {
vapidPrivateKey = &sub.WebPush.VapidPrivateKey
}
_, err := s.db.Exec(query, sub.ID, sub.AppName, sub.Channel, signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey)
return err
var id string
err := s.DB.QueryRow(`
INSERT INTO subscriptions (appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id
`, sub.AppName, sub.Channel, signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey).Scan(&id)
return id, err
}
func (s *Store) GetApp(appName string) (*App, error) {
query := `SELECT appName FROM apps WHERE appName = ?`
row := s.db.QueryRow(query, appName)
row := s.DB.QueryRow(`SELECT appName FROM apps WHERE appName = ?`, appName)
var app App
if err := row.Scan(&app.AppName); err == sql.ErrNoRows {
@ -135,12 +102,11 @@ func (s *Store) GetApp(appName string) (*App, error) {
}
func (s *Store) GetSubscriptions(appName string) ([]Subscription, error) {
query := `
rows, err := s.DB.Query(`
SELECT id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey
FROM subscriptions
WHERE appName = ?
`
rows, err := s.db.Query(query, appName)
`, appName)
if err != nil {
return nil, err
}
@ -188,8 +154,7 @@ func (s *Store) GetSubscriptions(appName string) ([]Subscription, error) {
}
func (s *Store) GetAllApps() ([]App, error) {
query := `SELECT appName FROM apps ORDER BY appName`
rows, err := s.db.Query(query)
rows, err := s.DB.Query(`SELECT appName FROM apps ORDER BY appName`)
if err != nil {
return nil, err
}
@ -227,46 +192,22 @@ func (s *Store) GetAllApps() ([]App, error) {
return apps, nil
}
func (s *Store) GetSignalGroup(appName string) (*SignalSubscription, error) {
query := `SELECT groupId, account FROM signal_groups WHERE appName = ?`
row := s.db.QueryRow(query, appName)
var groupID, account string
if err := row.Scan(&groupID, &account); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &SignalSubscription{
GroupID: groupID,
Account: account,
}, nil
}
func (s *Store) SaveSignalGroup(appName string, sub *SignalSubscription) error {
if err := s.RegisterApp(appName); err != nil {
return err
}
_, err := s.db.Exec(`INSERT INTO signal_groups (appName, groupId, account) VALUES (?, ?, ?)
ON CONFLICT(appName) DO UPDATE SET groupId=excluded.groupId, account=excluded.account`, appName, sub.GroupID, sub.Account)
return err
}
func (s *Store) DeleteSubscription(subscriptionID string) error {
_, err := s.db.Exec(`DELETE FROM subscriptions WHERE id = ?`, subscriptionID)
_, err := s.DB.Exec(`DELETE FROM subscriptions WHERE id = ?`, subscriptionID)
return err
}
func (s *Store) DeleteSubscriptionsByChannel(channel Channel) error {
_, err := s.DB.Exec(`DELETE FROM subscriptions WHERE channel = ?`, channel)
return err
}
func (s *Store) GetSubscription(subscriptionID string) (*Subscription, error) {
query := `
row := s.DB.QueryRow(`
SELECT id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey
FROM subscriptions
WHERE id = ?
`
row := s.db.QueryRow(query, subscriptionID)
`, subscriptionID)
var sub Subscription
var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString
@ -309,6 +250,6 @@ func (s *Store) GetSubscription(subscriptionID string) (*Subscription, error) {
}
func (s *Store) RemoveApp(appName string) error {
_, err := s.db.Exec(`DELETE FROM apps WHERE appName = ?`, appName)
_, err := s.DB.Exec(`DELETE FROM apps WHERE appName = ?`, appName)
return err
}

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"
)
func SetToast(w http.ResponseWriter, message, toastType string) {
trigger := map[string]interface{}{
"showToast": map[string]string{
"message": message,
"type": toastType,
},
}
if data, err := json.Marshal(trigger); err == nil {
w.Header().Set("HX-Trigger", string(data))
}
}
func LogAndError(w http.ResponseWriter, logger *slog.Logger, message string, code int, err error, attrs ...any) {
logAttrs := append([]any{"error", err}, attrs...)
logger.Error(message, logAttrs...)

View file

@ -65,7 +65,7 @@ func (h *ColorHandler) Handle(ctx context.Context, r slog.Record) error {
color = colorReset
}
timestamp := r.Time.Format("15:04:05")
timestamp := r.Time.Format("3:04:05 PM")
_, _ = fmt.Fprintf(h.w, "%s%s%s [%s%s%s] %s", //nolint:errcheck
colorGray, timestamp, colorReset,
color, level, colorReset,