mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 08:43:10 -07:00
code cleanups and refactors
This commit is contained in:
parent
42689045f4
commit
b5740b801e
47 changed files with 901 additions and 1023 deletions
20
Makefile
20
Makefile
|
|
@ -22,9 +22,8 @@ fix:
|
|||
goimports -w .
|
||||
golangci-lint run --fix
|
||||
npx @biomejs/biome@latest check --write --unsafe .
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
go mod download
|
||||
go mod tidy
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY_NAME) $(BINARY_NAME)-*
|
||||
|
|
@ -47,24 +46,9 @@ install-tools:
|
|||
signal-cli --version && \
|
||||
echo "signal-cli installed successfully to /usr/local/bin/signal-cli"
|
||||
|
||||
deps:
|
||||
go mod download
|
||||
go mod tidy
|
||||
|
||||
check-updates:
|
||||
@go list -u -m -f '{{if not .Indirect}}{{.Path}} {{.Version}}{{if .Update}} [{{.Update.Version}}]{{end}}{{end}}' all | grep "\[" || echo "All dependencies are up to date"
|
||||
|
||||
update:
|
||||
go get -u ./...
|
||||
go mod tidy
|
||||
|
||||
update-all:
|
||||
go get -u all
|
||||
go mod tidy
|
||||
|
||||
docker-up:
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
|
||||
release:
|
||||
@if [ ! -f VERSION ]; then \
|
||||
echo "Error: VERSION file not found"; \
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.4.1
|
||||
1.0.0
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
24
service/delivery/errors.go
Normal file
24
service/delivery/errors.go
Normal 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)
|
||||
}
|
||||
16
service/delivery/notification.go
Normal file
16
service/delivery/notification.go
Normal 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"`
|
||||
}
|
||||
125
service/delivery/publisher.go
Normal file
125
service/delivery/publisher.go
Normal 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
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
package integration
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed templates/*.html
|
||||
var templates embed.FS
|
||||
|
||||
func GetTemplates() embed.FS {
|
||||
return templates
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
47
service/integration/signal/groups.go
Normal file
47
service/integration/signal/groups.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 := ¬ification.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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = ¬ification.WebPushSubscription{
|
||||
webPush = &subscription.WebPushSubscription{
|
||||
Endpoint: req.PushEndpoint,
|
||||
P256dh: normalizedP256dh,
|
||||
Auth: normalizedAuth,
|
||||
VapidPrivateKey: normalizedKey,
|
||||
}
|
||||
} else {
|
||||
webPush = ¬ification.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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, "" }
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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 = ¬ification.WebPushSubscription{
|
||||
sanitized[i].Subscriptions[j].WebPush = &subscription.WebPushSubscription{
|
||||
Endpoint: sub.WebPush.Endpoint,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, " ")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"prism/service/notification"
|
||||
"prism/service/subscription"
|
||||
"prism/service/util"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
|
@ -34,8 +34,8 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request
|
|||
ChatID: r.FormValue("chat_id"),
|
||||
}
|
||||
|
||||
channel := notification.Channel(form.Channel)
|
||||
if !s.dispatcher.IsValidChannel(channel) {
|
||||
channel := subscription.Channel(form.Channel)
|
||||
if !s.publisher.IsValidChannel(channel) {
|
||||
util.SetToast(w, fmt.Sprintf("Invalid or unavailable channel: %s", form.Channel), "error")
|
||||
s.handleFragmentApps(w, r)
|
||||
return
|
||||
|
|
@ -56,40 +56,33 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request
|
|||
}
|
||||
}
|
||||
|
||||
subID, err := notification.GenerateSubscriptionID()
|
||||
if err != nil {
|
||||
util.LogAndError(w, s.logger, "Failed to generate subscription ID", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
sub := notification.Subscription{
|
||||
ID: subID,
|
||||
sub := subscription.Subscription{
|
||||
AppName: appName,
|
||||
Channel: channel,
|
||||
}
|
||||
|
||||
switch channel {
|
||||
case notification.ChannelSignal:
|
||||
case subscription.ChannelSignal:
|
||||
if s.integrations.Signal == nil || !s.integrations.Signal.IsEnabled() {
|
||||
util.SetToast(w, "Signal not configured", "error")
|
||||
s.handleFragmentApps(w, r)
|
||||
return
|
||||
}
|
||||
client := s.integrations.Signal.GetHandlers().GetClient()
|
||||
client := s.integrations.Signal.Handlers.Client
|
||||
account, err := client.GetLinkedAccount()
|
||||
if err != nil || account == nil {
|
||||
util.SetToast(w, "Signal not linked - configure in Integrations below", "error")
|
||||
util.SetToast(w, "Signal not linked - configure its integration below", "error")
|
||||
s.handleFragmentApps(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if form.GroupID != "" {
|
||||
sub.Signal = ¬ification.SignalSubscription{
|
||||
sub.Signal = &subscription.SignalSubscription{
|
||||
GroupID: form.GroupID,
|
||||
Account: account.Number,
|
||||
}
|
||||
} else {
|
||||
cachedGroup, err := s.store.GetSignalGroup(appName)
|
||||
cachedGroup, err := s.integrations.Signal.Groups.Get(appName)
|
||||
if err != nil {
|
||||
util.LogAndError(w, s.logger, "Failed to check for cached Signal group", http.StatusInternalServerError, err)
|
||||
return
|
||||
|
|
@ -98,7 +91,7 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request
|
|||
if cachedGroup != nil && cachedGroup.Account == account.Number {
|
||||
sub.Signal = cachedGroup
|
||||
} else {
|
||||
signalSub, err := s.integrations.Signal.GetSender().CreateDefaultSignalSubscription(appName)
|
||||
signalSub, err := s.integrations.Signal.Sender.CreateDefaultSignalSubscription(appName)
|
||||
if err != nil {
|
||||
util.LogAndError(w, s.logger, "Failed to create Signal subscription", http.StatusInternalServerError, err)
|
||||
return
|
||||
|
|
@ -107,15 +100,15 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request
|
|||
}
|
||||
}
|
||||
|
||||
case notification.ChannelTelegram:
|
||||
case subscription.ChannelTelegram:
|
||||
if s.integrations.Telegram == nil || !s.integrations.Telegram.IsEnabled() {
|
||||
util.SetToast(w, "Telegram not configured", "error")
|
||||
s.handleFragmentApps(w, r)
|
||||
return
|
||||
}
|
||||
chatID := s.integrations.Telegram.GetHandlers().GetChatID()
|
||||
chatID := s.integrations.Telegram.Handlers.GetChatID()
|
||||
if chatID == 0 {
|
||||
util.SetToast(w, "Telegram not linked - configure in Integrations below", "error")
|
||||
util.SetToast(w, "Telegram not linked - configure its integration below", "error")
|
||||
s.handleFragmentApps(w, r)
|
||||
return
|
||||
}
|
||||
|
|
@ -123,7 +116,7 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request
|
|||
chatID = 0
|
||||
fmt.Sscanf(form.ChatID, "%d", &chatID)
|
||||
}
|
||||
sub.Telegram = ¬ification.TelegramSubscription{
|
||||
sub.Telegram = &subscription.TelegramSubscription{
|
||||
ChatID: fmt.Sprintf("%d", chatID),
|
||||
}
|
||||
|
||||
|
|
@ -132,7 +125,7 @@ func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request
|
|||
return
|
||||
}
|
||||
|
||||
if err := s.store.AddSubscription(sub); err != nil {
|
||||
if _, err := s.store.AddSubscription(sub); err != nil {
|
||||
util.LogAndError(w, s.logger, "Failed to create subscription", http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
|
@ -160,7 +153,7 @@ func (s *Server) handleDeleteSubscription(w http.ResponseWriter, r *http.Request
|
|||
}
|
||||
|
||||
message := fmt.Sprintf("%s channel disabled", sub.Channel.Label())
|
||||
if sub.Channel == notification.ChannelWebPush {
|
||||
if sub.Channel == subscription.ChannelWebPush {
|
||||
message = fmt.Sprintf("%s channel deleted", sub.Channel.Label())
|
||||
}
|
||||
util.SetToast(w, message, "success")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
39
service/subscription/channel.go
Normal file
39
service/subscription/channel.go
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
35
service/subscription/subscription.go
Normal file
35
service/subscription/subscription.go
Normal 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"`
|
||||
}
|
||||
|
|
@ -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
18
service/util/htmx.go
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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...)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue