mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 19:54:44 -07:00
split larger files into multiple, slim down biome config, dont retry for permanent errors, consistently use Link, new chi middleware to fix 401s hanging
This commit is contained in:
parent
48c420d14b
commit
74233957d3
17 changed files with 489 additions and 408 deletions
26
biome.json
26
biome.json
|
|
@ -1,10 +1,5 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
|
||||||
"vcs": {
|
|
||||||
"enabled": true,
|
|
||||||
"clientKind": "git",
|
|
||||||
"useIgnoreFile": true
|
|
||||||
},
|
|
||||||
"files": {
|
"files": {
|
||||||
"includes": [
|
"includes": [
|
||||||
"**/public/**/*.{js,css,html}",
|
"**/public/**/*.{js,css,html}",
|
||||||
|
|
@ -13,28 +8,11 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"enabled": true,
|
"indentStyle": "tab"
|
||||||
"indentStyle": "tab",
|
|
||||||
"lineWidth": 100
|
|
||||||
},
|
|
||||||
"linter": {
|
|
||||||
"enabled": true,
|
|
||||||
"rules": {
|
|
||||||
"recommended": true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"javascript": {
|
"javascript": {
|
||||||
"formatter": {
|
"formatter": {
|
||||||
"quoteStyle": "single",
|
"quoteStyle": "single"
|
||||||
"semicolons": "always"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"css": {
|
|
||||||
"formatter": {
|
|
||||||
"enabled": true
|
|
||||||
},
|
|
||||||
"linter": {
|
|
||||||
"enabled": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,8 @@ document.addEventListener('click', async (e) => {
|
||||||
|
|
||||||
if (statusData.linked) {
|
if (statusData.linked) {
|
||||||
clearInterval(signalLinkingPoll);
|
clearInterval(signalLinkingPoll);
|
||||||
qrContainer.innerHTML = '<p class="auth-status success">Linked! Refreshing...</p>';
|
qrContainer.innerHTML =
|
||||||
|
'<p class="auth-status success">Linked! Refreshing...</p>';
|
||||||
setTimeout(() => location.reload(), 1000);
|
setTimeout(() => location.reload(), 1000);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,8 @@ window.cycleTheme = () => {
|
||||||
function updateButtonText(theme) {
|
function updateButtonText(theme) {
|
||||||
const btn = document.getElementById('theme-toggle');
|
const btn = document.getElementById('theme-toggle');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.textContent = theme === 'system' ? '🌓' : theme === 'light' ? '☀️' : '🌙';
|
btn.textContent =
|
||||||
|
theme === 'system' ? '🌓' : theme === 'light' ? '☀️' : '🌙';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
135
service/integration/proton/auth.go
Normal file
135
service/integration/proton/auth.go
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"prism/service/credentials"
|
||||||
|
|
||||||
|
"github.com/emersion/hydroxide/protonmail"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *Monitor) authenticateAndSetup(credStore *credentials.Store) error {
|
||||||
|
creds, err := credStore.GetProton()
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Debug("Proton credentials not configured", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
m.credStore = credStore
|
||||||
|
m.logger.Info("Starting Proton Mail monitor", "email", creds.Email)
|
||||||
|
|
||||||
|
c := &protonmail.Client{
|
||||||
|
RootURL: "https://mail.proton.me/api",
|
||||||
|
AppVersion: "Other",
|
||||||
|
}
|
||||||
|
|
||||||
|
var auth *protonmail.Auth
|
||||||
|
|
||||||
|
if creds.UID != "" && creds.AccessToken != "" && creds.RefreshToken != "" {
|
||||||
|
auth = &protonmail.Auth{
|
||||||
|
UID: creds.UID,
|
||||||
|
AccessToken: creds.AccessToken,
|
||||||
|
RefreshToken: creds.RefreshToken,
|
||||||
|
Scope: creds.Scope,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = c.Unlock(auth, creds.KeySalts, creds.Password)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Error("Failed to unlock keys - password may have changed", "error", err)
|
||||||
|
if deleteErr := credStore.DeleteIntegration(credentials.IntegrationProton); deleteErr != nil {
|
||||||
|
m.logger.Error("Failed to clear invalid credentials", "error", deleteErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to unlock keys (password changed?): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.logger.Info("Restored Proton session from stored tokens")
|
||||||
|
} else if creds.Password != "" {
|
||||||
|
authInfo, err := c.AuthInfo(creds.Email)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
authResult, err := c.Auth(creds.Email, creds.Password, authInfo)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
auth = authResult
|
||||||
|
|
||||||
|
keySalts, err := c.ListKeySalts()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get key salts: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = c.Unlock(auth, keySalts, creds.Password)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Error("Failed to unlock keys", "error", err)
|
||||||
|
if deleteErr := credStore.DeleteIntegration(credentials.IntegrationProton); deleteErr != nil {
|
||||||
|
m.logger.Error("Failed to clear invalid credentials", "error", deleteErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to unlock keys: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds.KeySalts = keySalts
|
||||||
|
if err := credStore.SaveProton(creds); err != nil {
|
||||||
|
m.logger.Warn("Failed to cache key salts", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.logger.Info("Authenticated and unlocked Proton session")
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("no valid credentials found - need password or tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.setupTokenRefresh(c, auth, creds)
|
||||||
|
m.client = c
|
||||||
|
|
||||||
|
if creds.State != nil {
|
||||||
|
m.eventID = creds.State.LastEventID
|
||||||
|
m.logger.Info("Restored Proton state")
|
||||||
|
} else {
|
||||||
|
m.eventID = auth.EventID
|
||||||
|
if err := m.saveState(creds); err != nil {
|
||||||
|
m.logger.Warn("Failed to save initial state", "error", err)
|
||||||
|
}
|
||||||
|
m.logger.Info("Initialized Proton state")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) setupTokenRefresh(c *protonmail.Client, auth *protonmail.Auth, creds *credentials.ProtonCredentials) {
|
||||||
|
c.ReAuth = func() error {
|
||||||
|
m.logger.Info("Refreshing Proton session tokens")
|
||||||
|
newAuth, err := c.AuthRefresh(auth)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Error("Token refresh failed", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = c.Unlock(newAuth, creds.KeySalts, creds.Password)
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Error("Token refresh failed - cannot unlock keys", "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
auth = newAuth
|
||||||
|
|
||||||
|
updatedCreds, err := m.credStore.GetProton()
|
||||||
|
if err != nil {
|
||||||
|
m.logger.Warn("Failed to get credentials for token update", "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedCreds.UID = newAuth.UID
|
||||||
|
updatedCreds.AccessToken = newAuth.AccessToken
|
||||||
|
updatedCreds.RefreshToken = newAuth.RefreshToken
|
||||||
|
updatedCreds.Scope = newAuth.Scope
|
||||||
|
|
||||||
|
if err := m.credStore.SaveProton(updatedCreds); err != nil {
|
||||||
|
m.logger.Warn("Failed to save refreshed tokens", "error", err)
|
||||||
|
} else {
|
||||||
|
m.logger.Info("Proton tokens refreshed and saved")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -87,7 +87,7 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
integData.StatusText = "Connecting…"
|
integData.StatusText = "Connecting…"
|
||||||
integData.StatusTooltip = email
|
integData.StatusTooltip = email
|
||||||
integData.Open = false
|
integData.Open = false
|
||||||
integData.PollAttrs = `hx-get="/fragment/integrations/proton" hx-trigger="every 2s"`
|
integData.PollAttrs = `hx-get="/fragment/proton" hx-trigger="every 2s" hx-swap="outerHTML"`
|
||||||
contentData.Connected = true
|
contentData.Connected = true
|
||||||
} else {
|
} else {
|
||||||
integData.StatusClass = "connected"
|
integData.StatusClass = "connected"
|
||||||
|
|
|
||||||
130
service/integration/proton/messages.go
Normal file
130
service/integration/proton/messages.go
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
package proton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"prism/service/notification"
|
||||||
|
|
||||||
|
"github.com/emersion/hydroxide/protonmail"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *Monitor) processMessageEvents(events []*protonmail.EventMessage) {
|
||||||
|
for _, evt := range events {
|
||||||
|
switch evt.Action {
|
||||||
|
case protonmail.EventCreate:
|
||||||
|
if evt.Created != nil {
|
||||||
|
msg := evt.Created
|
||||||
|
if msg.Unread == 1 && hasLabel(msg, protonmail.LabelInbox) && msg.Time.Time().After(m.startTime) {
|
||||||
|
if _, seen := m.unseenMessageIDs[msg.ID]; !seen {
|
||||||
|
m.unseenMessageIDs[msg.ID] = time.Now()
|
||||||
|
m.sendNotification(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case protonmail.EventUpdate, protonmail.EventUpdateFlags:
|
||||||
|
if evt.Updated != nil && evt.Updated.Unread != nil && *evt.Updated.Unread == 0 {
|
||||||
|
if _, wasSent := m.unseenMessageIDs[evt.ID]; wasSent {
|
||||||
|
m.clearNotification(evt.ID)
|
||||||
|
}
|
||||||
|
delete(m.unseenMessageIDs, evt.ID)
|
||||||
|
}
|
||||||
|
case protonmail.EventDelete:
|
||||||
|
if _, wasSent := m.unseenMessageIDs[evt.ID]; wasSent {
|
||||||
|
m.clearNotification(evt.ID)
|
||||||
|
}
|
||||||
|
delete(m.unseenMessageIDs, evt.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) cleanupOldMessages() {
|
||||||
|
cutoff := time.Now().Add(-24 * time.Hour)
|
||||||
|
for msgID, notifiedAt := range m.unseenMessageIDs {
|
||||||
|
if notifiedAt.Before(cutoff) {
|
||||||
|
delete(m.unseenMessageIDs, msgID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(m.unseenMessageIDs) > 0 {
|
||||||
|
m.logger.Debug("Cleaned up old message IDs", "remaining", len(m.unseenMessageIDs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasLabel(msg *protonmail.Message, labelID string) bool {
|
||||||
|
for _, id := range msg.LabelIDs {
|
||||||
|
if id == labelID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) sendNotification(msg *protonmail.Message) {
|
||||||
|
from := "Unknown"
|
||||||
|
if msg.Sender != nil {
|
||||||
|
if msg.Sender.Name != "" {
|
||||||
|
from = msg.Sender.Name
|
||||||
|
} else {
|
||||||
|
from = msg.Sender.Address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := msg.Subject
|
||||||
|
if subject == "" {
|
||||||
|
subject = "(No subject)"
|
||||||
|
}
|
||||||
|
|
||||||
|
notif := notification.Notification{
|
||||||
|
Title: from,
|
||||||
|
Message: subject,
|
||||||
|
Tag: "proton-" + msg.ID,
|
||||||
|
Actions: []notification.Action{
|
||||||
|
{
|
||||||
|
ID: "archive",
|
||||||
|
Label: "Archive",
|
||||||
|
Endpoint: "/api/proton/archive",
|
||||||
|
Method: "POST",
|
||||||
|
Data: map[string]any{
|
||||||
|
"uid": msg.ID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "mark-read",
|
||||||
|
Label: "Mark as Read",
|
||||||
|
Endpoint: "/api/proton/mark-read",
|
||||||
|
Method: "POST",
|
||||||
|
Data: map[string]any{
|
||||||
|
"uid": msg.ID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.dispatcher.Send(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) clearNotification(msgID string) {
|
||||||
|
mapping, err := m.dispatcher.GetStore().GetApp(prismTopic)
|
||||||
|
if err != nil || mapping == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if mapping.Channel != notification.ChannelWebPush {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
notif := notification.Notification{
|
||||||
|
Tag: "proton-" + msgID,
|
||||||
|
Title: "",
|
||||||
|
Message: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.dispatcher.Send(prismTopic, notif); err != nil {
|
||||||
|
m.logger.Error("Failed to clear notification", "error", err, "msgID", msgID)
|
||||||
|
} else {
|
||||||
|
m.logger.Debug("Cleared notification", "msgID", msgID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,8 @@ type Monitor struct {
|
||||||
eventID string
|
eventID string
|
||||||
unseenMessageIDs map[string]time.Time
|
unseenMessageIDs map[string]time.Time
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
|
consecutiveErrs int
|
||||||
|
lastConnected time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMonitor(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger) *Monitor {
|
func NewMonitor(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger) *Monitor {
|
||||||
|
|
@ -38,125 +40,16 @@ func NewMonitor(cfg *config.Config, dispatcher *notification.Dispatcher, logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Monitor) Start(ctx context.Context, credStore *credentials.Store) error {
|
func (m *Monitor) Start(ctx context.Context, credStore *credentials.Store) error {
|
||||||
creds, err := credStore.GetProton()
|
if err := m.authenticateAndSetup(credStore); err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
m.logger.Debug("Proton credentials not configured", "error", err)
|
}
|
||||||
|
|
||||||
|
if m.client == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
m.credStore = credStore
|
|
||||||
m.logger.Info("Starting Proton Mail monitor", "email", creds.Email)
|
|
||||||
|
|
||||||
c := &protonmail.Client{
|
|
||||||
RootURL: "https://mail.proton.me/api",
|
|
||||||
AppVersion: "Other",
|
|
||||||
}
|
|
||||||
|
|
||||||
var auth *protonmail.Auth
|
|
||||||
|
|
||||||
if creds.UID != "" && creds.AccessToken != "" && creds.RefreshToken != "" {
|
|
||||||
auth = &protonmail.Auth{
|
|
||||||
UID: creds.UID,
|
|
||||||
AccessToken: creds.AccessToken,
|
|
||||||
RefreshToken: creds.RefreshToken,
|
|
||||||
Scope: creds.Scope,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = c.Unlock(auth, creds.KeySalts, creds.Password)
|
|
||||||
if err != nil {
|
|
||||||
m.logger.Error("Failed to unlock keys - password may have changed", "error", err)
|
|
||||||
if deleteErr := credStore.DeleteIntegration(credentials.IntegrationProton); deleteErr != nil {
|
|
||||||
m.logger.Error("Failed to clear invalid credentials", "error", deleteErr)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("failed to unlock keys (password changed?): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.logger.Info("Restored Proton session from stored tokens")
|
|
||||||
} else if creds.Password != "" {
|
|
||||||
authInfo, err := c.AuthInfo(creds.Email)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
authResult, err := c.Auth(creds.Email, creds.Password, authInfo)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
auth = authResult
|
|
||||||
|
|
||||||
keySalts, err := c.ListKeySalts()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get key salts: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = c.Unlock(auth, keySalts, creds.Password)
|
|
||||||
if err != nil {
|
|
||||||
m.logger.Error("Failed to unlock keys", "error", err)
|
|
||||||
if deleteErr := credStore.DeleteIntegration(credentials.IntegrationProton); deleteErr != nil {
|
|
||||||
m.logger.Error("Failed to clear invalid credentials", "error", deleteErr)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("failed to unlock keys: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
creds.KeySalts = keySalts
|
|
||||||
if err := credStore.SaveProton(creds); err != nil {
|
|
||||||
m.logger.Warn("Failed to cache key salts", "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.logger.Info("Authenticated and unlocked Proton session")
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("no valid credentials found - need password or tokens")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.ReAuth = func() error {
|
|
||||||
newAuth, err := c.AuthRefresh(auth)
|
|
||||||
if err != nil {
|
|
||||||
m.logger.Error("Token refresh failed", "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = c.Unlock(newAuth, creds.KeySalts, creds.Password)
|
|
||||||
if err != nil {
|
|
||||||
m.logger.Error("Token refresh failed - cannot unlock keys", "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
auth = newAuth
|
|
||||||
|
|
||||||
updatedCreds, err := m.credStore.GetProton()
|
|
||||||
if err != nil {
|
|
||||||
m.logger.Warn("Failed to get credentials for token update", "error", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
updatedCreds.UID = newAuth.UID
|
|
||||||
updatedCreds.AccessToken = newAuth.AccessToken
|
|
||||||
updatedCreds.RefreshToken = newAuth.RefreshToken
|
|
||||||
updatedCreds.Scope = newAuth.Scope
|
|
||||||
|
|
||||||
if err := m.credStore.SaveProton(updatedCreds); err != nil {
|
|
||||||
m.logger.Warn("Failed to save refreshed tokens", "error", err)
|
|
||||||
} else {
|
|
||||||
m.logger.Info("Proton tokens refreshed and saved")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
m.client = c
|
|
||||||
m.startTime = time.Now()
|
m.startTime = time.Now()
|
||||||
|
m.lastConnected = time.Now()
|
||||||
if creds.State != nil {
|
|
||||||
m.eventID = creds.State.LastEventID
|
|
||||||
m.logger.Info("Restored Proton state", "eventID", m.eventID)
|
|
||||||
} else {
|
|
||||||
m.eventID = auth.EventID
|
|
||||||
if err := m.saveState(creds); err != nil {
|
|
||||||
m.logger.Warn("Failed to save initial state", "error", err)
|
|
||||||
}
|
|
||||||
m.logger.Info("Initialized Proton state", "eventID", m.eventID)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.unseenMessageIDs = make(map[string]time.Time)
|
m.unseenMessageIDs = make(map[string]time.Time)
|
||||||
|
|
||||||
go m.pollEvents(ctx)
|
go m.pollEvents(ctx)
|
||||||
|
|
@ -175,8 +68,15 @@ func (m *Monitor) pollEvents(ctx context.Context) {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
if err := m.checkEvents(); err != nil {
|
if err := m.checkEventsWithRetry(ctx); err != nil {
|
||||||
m.logger.Error("Failed to check events", "error", err)
|
m.logger.Error("Failed to check events after retries", "error", err, "consecutive_errors", m.consecutiveErrs)
|
||||||
|
m.consecutiveErrs++
|
||||||
|
} else {
|
||||||
|
if m.consecutiveErrs > 0 {
|
||||||
|
m.logger.Info("Proton connection recovered", "downtime", time.Since(m.lastConnected).String())
|
||||||
|
m.consecutiveErrs = 0
|
||||||
|
}
|
||||||
|
m.lastConnected = time.Now()
|
||||||
}
|
}
|
||||||
case <-cleanupTicker.C:
|
case <-cleanupTicker.C:
|
||||||
m.cleanupOldMessages()
|
m.cleanupOldMessages()
|
||||||
|
|
@ -184,6 +84,34 @@ func (m *Monitor) pollEvents(ctx context.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) checkEventsWithRetry(ctx context.Context) error {
|
||||||
|
maxRetries := 3
|
||||||
|
baseDelay := 1 * time.Second
|
||||||
|
|
||||||
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
delay := baseDelay * time.Duration(1<<uint(attempt-1))
|
||||||
|
m.logger.Debug("Retrying event check", "attempt", attempt+1, "delay", delay)
|
||||||
|
select {
|
||||||
|
case <-time.After(delay):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err := m.checkEvents()
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if attempt < maxRetries-1 {
|
||||||
|
m.logger.Warn("Event check failed, will retry", "error", err, "attempt", attempt+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("event check failed after %d attempts", maxRetries)
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Monitor) checkEvents() error {
|
func (m *Monitor) checkEvents() error {
|
||||||
if m.client == nil {
|
if m.client == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -210,47 +138,6 @@ func (m *Monitor) checkEvents() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Monitor) processMessageEvents(events []*protonmail.EventMessage) {
|
|
||||||
for _, evt := range events {
|
|
||||||
switch evt.Action {
|
|
||||||
case protonmail.EventCreate:
|
|
||||||
if evt.Created != nil {
|
|
||||||
msg := evt.Created
|
|
||||||
if msg.Unread == 1 && hasLabel(msg, protonmail.LabelInbox) && msg.Time.Time().After(m.startTime) {
|
|
||||||
if _, seen := m.unseenMessageIDs[msg.ID]; !seen {
|
|
||||||
m.unseenMessageIDs[msg.ID] = time.Now()
|
|
||||||
m.sendNotification(msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case protonmail.EventUpdate, protonmail.EventUpdateFlags:
|
|
||||||
if evt.Updated != nil && evt.Updated.Unread != nil && *evt.Updated.Unread == 0 {
|
|
||||||
if _, wasSent := m.unseenMessageIDs[evt.ID]; wasSent {
|
|
||||||
m.clearNotification(evt.ID)
|
|
||||||
}
|
|
||||||
delete(m.unseenMessageIDs, evt.ID)
|
|
||||||
}
|
|
||||||
case protonmail.EventDelete:
|
|
||||||
if _, wasSent := m.unseenMessageIDs[evt.ID]; wasSent {
|
|
||||||
m.clearNotification(evt.ID)
|
|
||||||
}
|
|
||||||
delete(m.unseenMessageIDs, evt.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Monitor) cleanupOldMessages() {
|
|
||||||
cutoff := time.Now().Add(-24 * time.Hour)
|
|
||||||
for msgID, notifiedAt := range m.unseenMessageIDs {
|
|
||||||
if notifiedAt.Before(cutoff) {
|
|
||||||
delete(m.unseenMessageIDs, msgID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(m.unseenMessageIDs) > 0 {
|
|
||||||
m.logger.Debug("Cleaned up old message IDs", "remaining", len(m.unseenMessageIDs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Monitor) saveState(creds *credentials.ProtonCredentials) error {
|
func (m *Monitor) saveState(creds *credentials.ProtonCredentials) error {
|
||||||
creds.State = &credentials.ProtonState{
|
creds.State = &credentials.ProtonState{
|
||||||
LastEventID: m.eventID,
|
LastEventID: m.eventID,
|
||||||
|
|
@ -258,88 +145,11 @@ func (m *Monitor) saveState(creds *credentials.ProtonCredentials) error {
|
||||||
return m.credStore.SaveProton(creds)
|
return m.credStore.SaveProton(creds)
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasLabel(msg *protonmail.Message, labelID string) bool {
|
|
||||||
for _, id := range msg.LabelIDs {
|
|
||||||
if id == labelID {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Monitor) sendNotification(msg *protonmail.Message) {
|
|
||||||
from := "Unknown"
|
|
||||||
if msg.Sender != nil {
|
|
||||||
if msg.Sender.Name != "" {
|
|
||||||
from = msg.Sender.Name
|
|
||||||
} else {
|
|
||||||
from = msg.Sender.Address
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
subject := msg.Subject
|
|
||||||
if subject == "" {
|
|
||||||
subject = "(No subject)"
|
|
||||||
}
|
|
||||||
|
|
||||||
notif := notification.Notification{
|
|
||||||
Title: from,
|
|
||||||
Message: subject,
|
|
||||||
Tag: "proton-" + msg.ID,
|
|
||||||
Actions: []notification.Action{
|
|
||||||
{
|
|
||||||
ID: "archive",
|
|
||||||
Label: "Archive",
|
|
||||||
Endpoint: "/api/proton/archive",
|
|
||||||
Method: "POST",
|
|
||||||
Data: map[string]any{
|
|
||||||
"uid": msg.ID,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
ID: "mark-read",
|
|
||||||
Label: "Mark as Read",
|
|
||||||
Endpoint: "/api/proton/mark-read",
|
|
||||||
Method: "POST",
|
|
||||||
Data: map[string]any{
|
|
||||||
"uid": msg.ID,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.dispatcher.Send(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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Monitor) clearNotification(msgID string) {
|
|
||||||
mapping, err := m.dispatcher.GetStore().GetApp(prismTopic)
|
|
||||||
if err != nil || mapping == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if mapping.Channel != notification.ChannelWebPush {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
notif := notification.Notification{
|
|
||||||
Tag: "proton-" + msgID,
|
|
||||||
Title: "",
|
|
||||||
Message: "",
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.dispatcher.Send(prismTopic, notif); err != nil {
|
|
||||||
m.logger.Error("Failed to clear notification", "error", err, "msgID", msgID)
|
|
||||||
} else {
|
|
||||||
m.logger.Debug("Cleared notification", "msgID", msgID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Monitor) IsConnected() bool {
|
func (m *Monitor) IsConnected() bool {
|
||||||
return m.client != nil
|
if m.client == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return m.consecutiveErrs < 5
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Monitor) MarkAsRead(msgID string) error {
|
func (m *Monitor) MarkAsRead(msgID string) error {
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,10 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -165,119 +163,3 @@ func (c *Client) SendGroupMessage(groupID, message string) error {
|
||||||
_, err = c.exec("-a", account.Number, "send", "-g", groupID, "--notify-self", "-m", message)
|
_, err = c.exec("-a", account.Number, "send", "-g", groupID, "--notify-self", "-m", message)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) LinkDevice(deviceName string) (string, error) {
|
|
||||||
if c == nil || !c.enabled {
|
|
||||||
return "", fmt.Errorf("signal-cli not found in PATH")
|
|
||||||
}
|
|
||||||
|
|
||||||
if deviceName == "" {
|
|
||||||
deviceName = DefaultDeviceName
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.MkdirAll(c.ConfigPath, 0755); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create config directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dataDir := filepath.Join(c.ConfigPath, "data")
|
|
||||||
if entries, err := os.ReadDir(c.ConfigPath); err == nil {
|
|
||||||
for _, entry := range entries {
|
|
||||||
if entry.IsDir() && strings.HasPrefix(entry.Name(), "+") {
|
|
||||||
accountDir := filepath.Join(c.ConfigPath, entry.Name())
|
|
||||||
os.RemoveAll(accountDir)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
os.RemoveAll(dataDir)
|
|
||||||
|
|
||||||
cmd := exec.Command("signal-cli", "--config", c.ConfigPath, "link", "-n", deviceName)
|
|
||||||
|
|
||||||
stdout, err := cmd.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create stdout pipe: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stderr, err := cmd.StderrPipe()
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create stderr pipe: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to start link command: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var output bytes.Buffer
|
|
||||||
buf := make([]byte, 8192)
|
|
||||||
|
|
||||||
done := make(chan string, 1)
|
|
||||||
errChan := make(chan error, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
n, err := stdout.Read(buf)
|
|
||||||
if n > 0 {
|
|
||||||
output.Write(buf[:n])
|
|
||||||
text := output.String()
|
|
||||||
|
|
||||||
if idx := strings.Index(text, "sgnl://linkdevice"); idx != -1 {
|
|
||||||
end := strings.IndexAny(text[idx:], " \n\r\t")
|
|
||||||
var url string
|
|
||||||
if end == -1 {
|
|
||||||
url = text[idx:]
|
|
||||||
} else {
|
|
||||||
url = text[idx : idx+end]
|
|
||||||
}
|
|
||||||
done <- strings.TrimSpace(url)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
if err != io.EOF {
|
|
||||||
errChan <- err
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
n, err := stderr.Read(buf)
|
|
||||||
if n > 0 {
|
|
||||||
output.Write(buf[:n])
|
|
||||||
text := output.String()
|
|
||||||
|
|
||||||
if idx := strings.Index(text, "sgnl://linkdevice"); idx != -1 {
|
|
||||||
end := strings.IndexAny(text[idx:], " \n\r\t")
|
|
||||||
var url string
|
|
||||||
if end == -1 {
|
|
||||||
url = text[idx:]
|
|
||||||
} else {
|
|
||||||
url = text[idx : idx+end]
|
|
||||||
}
|
|
||||||
done <- strings.TrimSpace(url)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case url := <-done:
|
|
||||||
go func() {
|
|
||||||
io.Copy(io.Discard, stdout)
|
|
||||||
io.Copy(io.Discard, stderr)
|
|
||||||
cmd.Wait()
|
|
||||||
}()
|
|
||||||
return url, nil
|
|
||||||
case err := <-errChan:
|
|
||||||
cmd.Process.Kill()
|
|
||||||
return "", err
|
|
||||||
case <-time.After(5 * time.Minute):
|
|
||||||
cmd.Process.Kill()
|
|
||||||
return "", fmt.Errorf("timeout waiting for QR code URL")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
128
service/integration/signal/link.go
Normal file
128
service/integration/signal/link.go
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
package signal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *Client) LinkDevice(deviceName string) (string, error) {
|
||||||
|
if c == nil || !c.enabled {
|
||||||
|
return "", fmt.Errorf("signal-cli not found in PATH")
|
||||||
|
}
|
||||||
|
|
||||||
|
if deviceName == "" {
|
||||||
|
deviceName = DefaultDeviceName
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(c.ConfigPath, 0755); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create config directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dataDir := filepath.Join(c.ConfigPath, "data")
|
||||||
|
if entries, err := os.ReadDir(c.ConfigPath); err == nil {
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() && strings.HasPrefix(entry.Name(), "+") {
|
||||||
|
accountDir := filepath.Join(c.ConfigPath, entry.Name())
|
||||||
|
os.RemoveAll(accountDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
os.RemoveAll(dataDir)
|
||||||
|
|
||||||
|
cmd := exec.Command("signal-cli", "--config", c.ConfigPath, "link", "-n", deviceName)
|
||||||
|
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create stdout pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stderr, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create stderr pipe: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to start link command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var output bytes.Buffer
|
||||||
|
buf := make([]byte, 8192)
|
||||||
|
|
||||||
|
done := make(chan string, 1)
|
||||||
|
errChan := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
n, err := stdout.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
output.Write(buf[:n])
|
||||||
|
text := output.String()
|
||||||
|
|
||||||
|
if idx := strings.Index(text, "sgnl://linkdevice"); idx != -1 {
|
||||||
|
end := strings.IndexAny(text[idx:], " \n\r\t")
|
||||||
|
var url string
|
||||||
|
if end == -1 {
|
||||||
|
url = text[idx:]
|
||||||
|
} else {
|
||||||
|
url = text[idx : idx+end]
|
||||||
|
}
|
||||||
|
done <- strings.TrimSpace(url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
errChan <- err
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
n, err := stderr.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
output.Write(buf[:n])
|
||||||
|
text := output.String()
|
||||||
|
|
||||||
|
if idx := strings.Index(text, "sgnl://linkdevice"); idx != -1 {
|
||||||
|
end := strings.IndexAny(text[idx:], " \n\r\t")
|
||||||
|
var url string
|
||||||
|
if end == -1 {
|
||||||
|
url = text[idx:]
|
||||||
|
} else {
|
||||||
|
url = text[idx : idx+end]
|
||||||
|
}
|
||||||
|
done <- strings.TrimSpace(url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case url := <-done:
|
||||||
|
go func() {
|
||||||
|
io.Copy(io.Discard, stdout)
|
||||||
|
io.Copy(io.Discard, stderr)
|
||||||
|
cmd.Wait()
|
||||||
|
}()
|
||||||
|
return url, nil
|
||||||
|
case err := <-errChan:
|
||||||
|
cmd.Process.Kill()
|
||||||
|
return "", err
|
||||||
|
case <-time.After(5 * time.Minute):
|
||||||
|
cmd.Process.Kill()
|
||||||
|
return "", fmt.Errorf("timeout waiting for QR code URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,7 +24,7 @@ func NewSender(client *Client, store *notification.Store, logger *slog.Logger) *
|
||||||
|
|
||||||
func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error {
|
func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error {
|
||||||
if s.client == nil {
|
if s.client == nil {
|
||||||
return fmt.Errorf("signal integration not enabled")
|
return notification.NewPermanentError(fmt.Errorf("signal integration not enabled"))
|
||||||
}
|
}
|
||||||
|
|
||||||
account, err := s.client.GetLinkedAccount()
|
account, err := s.client.GetLinkedAccount()
|
||||||
|
|
@ -33,7 +33,7 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica
|
||||||
}
|
}
|
||||||
if account == nil {
|
if account == nil {
|
||||||
s.logger.Error("No linked Signal account found")
|
s.logger.Error("No linked Signal account found")
|
||||||
return fmt.Errorf("no linked Signal account")
|
return notification.NewPermanentError(fmt.Errorf("no linked Signal account"))
|
||||||
}
|
}
|
||||||
|
|
||||||
var signalGroupID string
|
var signalGroupID string
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
{{if .Error}}
|
{{if .Error}}
|
||||||
<p class="channel-not-configured">{{.Error}}</p>
|
<p class="channel-not-configured">{{.Error}}</p>
|
||||||
{{else}}
|
{{else}}
|
||||||
<button id="signal-link-btn" class="btn-primary">Link Device</button>
|
<button id="signal-link-btn" class="btn-primary">Link</button>
|
||||||
<div id="signal-qr-container" class="qr-container" style="display:none;">
|
<div id="signal-qr-container" class="qr-container" style="display:none;">
|
||||||
<p><strong>Scan this QR code with Signal:</strong></p>
|
<p><strong>Scan this QR code with Signal:</strong></p>
|
||||||
<ol class="link-instructions">
|
<ol class="link-instructions">
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,11 @@ func NewSender(client *Client, store *notification.Store, logger *slog.Logger, d
|
||||||
|
|
||||||
func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error {
|
func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error {
|
||||||
if s.client == nil {
|
if s.client == nil {
|
||||||
return fmt.Errorf("telegram integration not enabled")
|
return notification.NewPermanentError(fmt.Errorf("telegram integration not enabled"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.DefaultChatID == 0 {
|
if s.DefaultChatID == 0 {
|
||||||
return fmt.Errorf("no telegram chat configured (set TELEGRAM_CHAT_ID in .env)")
|
return notification.NewPermanentError(fmt.Errorf("no telegram chat configured (set TELEGRAM_CHAT_ID in .env)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
message := notif.Message
|
message := notif.Message
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
<label for="telegram-chat-id">Chat ID:</label>
|
<label for="telegram-chat-id">Chat ID:</label>
|
||||||
<input type="text" id="telegram-chat-id" name="chat_id" placeholder="123456789" required>
|
<input type="text" id="telegram-chat-id" name="chat_id" placeholder="123456789" required>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn-primary">Configure</button>
|
<button type="submit" class="btn-primary">Link</button>
|
||||||
<div id="telegram-auth-status" class="auth-status"></div>
|
<div id="telegram-auth-status" class="auth-status"></div>
|
||||||
</form>
|
</form>
|
||||||
{{else if .Error}}
|
{{else if .Error}}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ func NewSender(logger *slog.Logger) *Sender {
|
||||||
|
|
||||||
func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error {
|
func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error {
|
||||||
if mapping.WebPush == nil {
|
if mapping.WebPush == nil {
|
||||||
return fmt.Errorf("no push endpoint configured for %s", mapping.AppName)
|
return notification.NewPermanentError(fmt.Errorf("no push endpoint configured for %s", mapping.AppName))
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, err := json.Marshal(notif)
|
payload, err := json.Marshal(notif)
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,12 @@ func (d *Dispatcher) sendWithRetry(sender NotificationSender, mapping *Mapping,
|
||||||
}
|
}
|
||||||
|
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
|
||||||
|
if IsPermanent(err) {
|
||||||
|
d.logger.Error("Permanent error, not retrying", "app", appName, "error", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if attempt < maxRetries-1 {
|
if attempt < maxRetries-1 {
|
||||||
delay := baseDelay * time.Duration(1<<uint(attempt))
|
delay := baseDelay * time.Duration(1<<uint(attempt))
|
||||||
d.logger.Warn("Failed to send notification, retrying", "app", appName, "attempt", attempt+1, "error", err, "retryIn", delay)
|
d.logger.Warn("Failed to send notification, retrying", "app", appName, "attempt", attempt+1, "error", err, "retryIn", delay)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,28 @@
|
||||||
package notification
|
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 {
|
type Action struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
|
|
||||||
"prism/service/util"
|
"prism/service/util"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -34,14 +35,14 @@ func loggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||||
next.ServeHTTP(wrapped, r)
|
next.ServeHTTP(ww, r)
|
||||||
|
|
||||||
if !noisyPaths[r.URL.Path] {
|
if !noisyPaths[r.URL.Path] {
|
||||||
logger.Debug("HTTP request",
|
logger.Debug("HTTP request",
|
||||||
"method", r.Method,
|
"method", r.Method,
|
||||||
"path", r.URL.Path,
|
"path", r.URL.Path,
|
||||||
"status", wrapped.statusCode,
|
"status", ww.Status(),
|
||||||
"duration", time.Since(start),
|
"duration", time.Since(start),
|
||||||
"ip", util.GetClientIP(r),
|
"ip", util.GetClientIP(r),
|
||||||
)
|
)
|
||||||
|
|
@ -50,20 +51,6 @@ func loggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type responseWriter struct {
|
|
||||||
http.ResponseWriter
|
|
||||||
statusCode int
|
|
||||||
headerWritten bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rw *responseWriter) WriteHeader(code int) {
|
|
||||||
if !rw.headerWritten {
|
|
||||||
rw.statusCode = code
|
|
||||||
rw.ResponseWriter.WriteHeader(code)
|
|
||||||
rw.headerWritten = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func securityHeadersMiddleware() func(http.Handler) http.Handler {
|
func securityHeadersMiddleware() func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue