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:
lone-cloud 2026-02-10 17:10:02 -08:00
parent 48c420d14b
commit 74233957d3
17 changed files with 489 additions and 408 deletions

View file

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

View file

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

View file

@ -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' ? '☀️' : '🌙';
} }
} }

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

View file

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

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

View file

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

View file

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

View 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")
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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