From a8d832597108a03a906a3c1f652d56462cb21df2 Mon Sep 17 00:00:00 2001 From: lone-cloud Date: Sun, 1 Feb 2026 23:38:29 -0800 Subject: [PATCH] use air for fast reloads, cleap code and make it work again (TODO proton) --- .air.toml | 15 +++++ .env.example | 4 -- .gitignore | 1 + Makefile | 6 +- README.md | 4 +- docker-compose.dev.yml | 1 - docker-compose.yml | 1 - internal/config/config.go | 24 ++++---- internal/notification/dispatcher.go | 36 +++++++++--- internal/proton/actions.go | 36 ------------ internal/proton/monitor.go | 82 ++++++++++++++++++---------- internal/server/handlers_fragment.go | 30 +++++----- internal/server/handlers_ntfy.go | 64 +++++++++++----------- internal/server/handlers_proton.go | 20 ++++--- internal/server/handlers_webhook.go | 6 +- internal/server/middleware.go | 12 ++-- internal/server/server.go | 44 +++++---------- internal/signal/link.go | 15 +++-- internal/util/auth.go | 26 ++++----- main.go | 2 +- public/index.css | 9 +-- public/index.html | 17 ++---- 22 files changed, 225 insertions(+), 230 deletions(-) create mode 100644 .air.toml delete mode 100644 internal/proton/actions.go diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..1b562a7 --- /dev/null +++ b/.air.toml @@ -0,0 +1,15 @@ +root = "." +tmp_dir = "tmp" + +[build] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ." + include_ext = ["go", "html", "css", "js"] + exclude_dir = ["tmp", "data", "signal-cli"] + delay = 1000 + +[log] + time = false + +[screen] + clear_on_rebuild = false diff --git a/.env.example b/.env.example index a8a0f02..3141fac 100644 --- a/.env.example +++ b/.env.example @@ -5,10 +5,6 @@ # Default: 8080 # PORT=8080 -# Optional: Allow HTTP connections without HTTPS -# Default: false -# ALLOW_INSECURE_HTTP=true - # Optional: Rate limit for requests (per 15 minute window) # Default: 100 (requests per 15 minutes) # RATE_LIMIT=100 diff --git a/.gitignore b/.gitignore index 09af0b8..2c3692b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ signal-cli/ data/ +tmp/ *.db .env /prism diff --git a/Makefile b/Makefile index a110fcb..efa2511 100644 --- a/Makefile +++ b/Makefile @@ -11,14 +11,12 @@ all: fmt lint build build: go build -ldflags="-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o $(BINARY_NAME) . -build-linux: - GOOS=linux GOARCH=arm64 go build -ldflags="-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o $(BINARY_NAME)-linux-arm64 . - run: build ./$(BINARY_NAME) dev: - go run . + @which air > /dev/null || (echo "Installing air..." && go install github.com/air-verse/air@latest) + air fmt: gofmt -s -w . diff --git a/README.md b/README.md index 43b202b..96786e6 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ Prism monitors a Proton Mail account via the local bridge and forwards email ale Add a rest notification configuration (eg. add to configuration.yaml) to Home Assistant like: -```bash +```yaml notify: - platform: rest name: Prism @@ -166,7 +166,7 @@ notify: Authorization: !secret prism_api_key ``` -Note how Home Assistant is also a self-hosted server. As such, it is advisable to turn on `ALLOW_INSECURE_HTTP` environment variable for Prism and to refer to it by its LAN IP address. +Since Home Assistant and Prism are both on your local network, HTTP is allowed automatically - no additional configuration needed. Add your API_KEY to your secrets.yaml: diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 853d9a4..86e191b 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -10,7 +10,6 @@ services: - PORT=8080 - API_KEY=${API_KEY:-} - VERBOSE_LOGGING=${VERBOSE_LOGGING:-false} - - ALLOW_INSECURE_HTTP=${ALLOW_INSECURE_HTTP:-false} - RATE_LIMIT=${RATE_LIMIT:-100} - PROTON_IMAP_USERNAME=${PROTON_IMAP_USERNAME:-} - PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-} diff --git a/docker-compose.yml b/docker-compose.yml index 0391337..172a54b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,6 @@ services: - PORT=8080 - API_KEY=${API_KEY:-} - VERBOSE_LOGGING=${VERBOSE_LOGGING:-false} - - ALLOW_INSECURE_HTTP=${ALLOW_INSECURE_HTTP:-false} - RATE_LIMIT=${RATE_LIMIT:-100} - PROTON_IMAP_USERNAME=${PROTON_IMAP_USERNAME:-} - PROTON_IMAP_PASSWORD=${PROTON_IMAP_PASSWORD:-} diff --git a/internal/config/config.go b/internal/config/config.go index f525bb0..13ef5ed 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,11 +7,10 @@ import ( ) type Config struct { - Port int - APIKey string - VerboseLogging bool - AllowInsecureHTTP bool - RateLimit int + Port int + APIKey string + VerboseLogging bool + RateLimit int DeviceName string PrismEndpointPrefix string @@ -35,17 +34,15 @@ type Config struct { EndpointPrefixNtfy string EndpointPrefixUP string - ActionMarkRead string - StoragePath string + StoragePath string } func Load() (*Config, error) { cfg := &Config{ - Port: getEnvInt("PORT", 8080), - APIKey: os.Getenv("API_KEY"), - VerboseLogging: getEnvBool("VERBOSE_LOGGING", false), - AllowInsecureHTTP: getEnvBool("ALLOW_INSECURE_HTTP", false), - RateLimit: getEnvInt("RATE_LIMIT", 100), + Port: getEnvInt("PORT", 8080), + APIKey: os.Getenv("API_KEY"), + VerboseLogging: getEnvBool("VERBOSE_LOGGING", false), + RateLimit: getEnvInt("RATE_LIMIT", 100), DeviceName: getEnvString("DEVICE_NAME", "Prism"), SignalCLISocketPath: getEnvString("SIGNAL_CLI_SOCKET", "./data/signal-cli.sock"), @@ -68,8 +65,7 @@ func Load() (*Config, error) { EndpointPrefixNtfy: "ntfy-", EndpointPrefixUP: "up-", - ActionMarkRead: "mark-read", - StoragePath: getEnvString("STORAGE_PATH", "./data/prism.db"), + StoragePath: getEnvString("STORAGE_PATH", "./data/prism.db"), } cfg.PrismEndpointPrefix = fmt.Sprintf("[%s:", cfg.DeviceName) diff --git a/internal/notification/dispatcher.go b/internal/notification/dispatcher.go index 1ed4e33..9e062d3 100644 --- a/internal/notification/dispatcher.go +++ b/internal/notification/dispatcher.go @@ -78,11 +78,20 @@ func (d *Dispatcher) sendSignal(mapping *Mapping, notif Notification) error { } func (d *Dispatcher) createGroup(appName string) (string, error) { - params := map[string]interface{}{ - "name": appName, + account, err := d.signalClient.GetLinkedAccount() + if err != nil { + return "", fmt.Errorf("failed to get linked account: %w", err) + } + if account == nil { + return "", fmt.Errorf("no linked Signal account") } - result, err := d.signalClient.Call("createGroup", params) + params := map[string]interface{}{ + "name": appName, + "member": []string{}, + } + + result, err := d.signalClient.CallWithAccount("updateGroup", params, account.Number) if err != nil { return "", err } @@ -91,24 +100,37 @@ func (d *Dispatcher) createGroup(appName string) (string, error) { GroupID string `json:"groupId"` } if err := json.Unmarshal(result, &response); err != nil { - return "", fmt.Errorf("failed to parse createGroup response: %w", err) + return "", fmt.Errorf("failed to parse updateGroup response: %w", err) + } + + if response.GroupID == "" { + return "", fmt.Errorf("empty groupId in response") } return response.GroupID, nil } func (d *Dispatcher) sendGroupMessage(groupID string, notif Notification) error { + account, err := d.signalClient.GetLinkedAccount() + if err != nil { + return fmt.Errorf("failed to get linked account: %w", err) + } + if account == nil { + return fmt.Errorf("no linked Signal account") + } + message := notif.Message if notif.Title != "" { message = fmt.Sprintf("%s\n\n%s", notif.Title, notif.Message) } params := map[string]interface{}{ - "groupId": groupID, - "message": message, + "groupId": groupID, + "message": message, + "notify-self": true, } - _, err := d.signalClient.Call("sendGroupMessage", params) + _, err = d.signalClient.CallWithAccount("send", params, account.Number) return err } diff --git a/internal/proton/actions.go b/internal/proton/actions.go deleted file mode 100644 index 61058e9..0000000 --- a/internal/proton/actions.go +++ /dev/null @@ -1,36 +0,0 @@ -package proton - -import ( - "fmt" - "log/slog" - - "prism/internal/config" -) - -type ActionHandler struct { - monitor *Monitor - cfg *config.Config - logger *slog.Logger -} - -func NewActionHandler(monitor *Monitor, cfg *config.Config, logger *slog.Logger) *ActionHandler { - return &ActionHandler{ - monitor: monitor, - cfg: cfg, - logger: logger, - } -} - -func (h *ActionHandler) HandleAction(actionType, messageID string) error { - if actionType != h.cfg.ActionMarkRead { - return fmt.Errorf("unknown action type: %s", actionType) - } - - if err := h.monitor.MarkAsRead(messageID); err != nil { - h.logger.Error("Failed to mark message as read", "messageID", messageID, "error", err) - return fmt.Errorf("failed to mark as read: %w", err) - } - - h.logger.Info("Marked message as read", "messageID", messageID) - return nil -} diff --git a/internal/proton/monitor.go b/internal/proton/monitor.go index 1094b7b..77971c1 100644 --- a/internal/proton/monitor.go +++ b/internal/proton/monitor.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log/slog" - "strings" "time" "prism/internal/config" @@ -15,10 +14,11 @@ import ( ) type Monitor struct { - cfg *config.Config - dispatcher *notification.Dispatcher - logger *slog.Logger - client *client.Client + cfg *config.Config + dispatcher *notification.Dispatcher + logger *slog.Logger + client *client.Client + monitorStartTime time.Time } func NewMonitor(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger) *Monitor { @@ -89,6 +89,11 @@ func (m *Monitor) monitor(ctx context.Context) error { return fmt.Errorf("failed to select inbox: %w", err) } + if m.monitorStartTime.IsZero() { + m.monitorStartTime = time.Now() + m.logger.Info("Monitor start time set", "time", m.monitorStartTime) + } + updates := make(chan client.Update, 10) m.client.Updates = updates @@ -141,24 +146,23 @@ func (m *Monitor) handleNewMessages() error { criteria := imap.NewSearchCriteria() criteria.WithoutFlags = []string{m.cfg.IMAPSeenFlag} - seqNums, err := m.client.Search(criteria) + uids, err := m.client.UidSearch(criteria) if err != nil { return fmt.Errorf("search failed: %w", err) } - if len(seqNums) == 0 { + if len(uids) == 0 { return nil } seqSet := new(imap.SeqSet) - seqSet.AddNum(seqNums...) + seqSet.AddNum(uids...) messages := make(chan *imap.Message, 10) done := make(chan error, 1) - section := &imap.BodySectionName{} go func() { - done <- m.client.Fetch(seqSet, []imap.FetchItem{imap.FetchEnvelope, section.FetchItem()}, messages) + done <- m.client.UidFetch(seqSet, []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid}, messages) }() for msg := range messages { @@ -179,25 +183,43 @@ func (m *Monitor) processMessage(msg *imap.Message) error { return nil } + if msg.Envelope.Date.Before(m.monitorStartTime) { + m.logger.Debug("Skipping old email", "date", msg.Envelope.Date, "subject", msg.Envelope.Subject) + return nil + } + + var from string + if len(msg.Envelope.From) > 0 { + addr := msg.Envelope.From[0] + if addr.PersonalName != "" { + from = addr.PersonalName + } else { + from = addr.Address() + } + } else { + from = "Unknown sender" + } + subject := msg.Envelope.Subject - if !strings.HasPrefix(subject, m.cfg.PrismEndpointPrefix) { - return nil + if subject == "" { + subject = "No subject" } - parts := strings.SplitN(subject, "]", 2) - if len(parts) != 2 { - return nil - } - - endpoint := strings.TrimSpace(parts[0]) - endpoint = strings.TrimPrefix(endpoint, m.cfg.PrismEndpointPrefix) - endpoint = m.cfg.EndpointPrefixProton + endpoint - - message := strings.TrimSpace(parts[1]) + endpoint := m.cfg.EndpointPrefixProton + m.cfg.ProtonPrismTopic notif := notification.Notification{ - Title: m.cfg.ProtonPrismTopic, - Message: message, + Title: from, + Message: subject, + Actions: []notification.Action{ + { + ID: "mark-read", + Endpoint: "/api/proton-mail/mark-read", + Method: "POST", + Data: map[string]interface{}{ + "uid": msg.Uid, + }, + }, + }, } if err := m.dispatcher.Send(endpoint, notif); err != nil { @@ -205,20 +227,24 @@ func (m *Monitor) processMessage(msg *imap.Message) error { return err } - m.logger.Info("Processed Proton Mail notification", "endpoint", endpoint) + m.logger.Info("Processed Proton Mail notification", "from", from, "subject", subject) return nil } -func (m *Monitor) MarkAsRead(messageID string) error { +func (m *Monitor) MarkAsRead(uid uint32) error { if m.client == nil { return fmt.Errorf("not connected") } seqSet := new(imap.SeqSet) - seqSet.AddNum(1) + seqSet.AddNum(uid) item := imap.FormatFlagsOp(imap.AddFlags, true) flags := []interface{}{m.cfg.IMAPSeenFlag} - return m.client.Store(seqSet, item, flags, nil) + return m.client.UidStore(seqSet, item, flags, nil) +} + +func (m *Monitor) IsConnected() bool { + return m.client != nil } diff --git a/internal/server/handlers_fragment.go b/internal/server/handlers_fragment.go index 89a37da..0f26814 100644 --- a/internal/server/handlers_fragment.go +++ b/internal/server/handlers_fragment.go @@ -34,10 +34,12 @@ func (s *Server) handleFragmentHealth(w http.ResponseWriter, r *http.Request) { if hasProton { protonStatus := "Disconnected" protonClass := "status-error" - protonTooltip := "" - // TODO: check actual IMAP connection status with protonMonitor + if s.protonMonitor.IsConnected() { + protonStatus = "Connected" + protonClass = "status-ok" + } html += fmt.Sprintf(` -
Proton Mail: %s%s
`, protonClass, protonStatus, protonTooltip) +
Proton Mail: %s
`, protonClass, protonStatus) } html += ` @@ -50,12 +52,12 @@ func (s *Server) handleFragmentHealth(w http.ResponseWriter, r *http.Request) { func (s *Server) handleFragmentSignalInfo(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") - _, _ = fmt.Fprint(w, s.getSignalInfoHTML()) //nolint:errcheck // Error writing to ResponseWriter is handled by HTTP server + _, _ = fmt.Fprint(w, s.getSignalInfoHTML()) //nolint:errcheck } func (s *Server) getSignalInfoHTML() string { if s.getLinkedAccount() != nil { - return fmt.Sprintf(``, s.cfg.DeviceName) } - return s.getQRCodeHTML() + + return fmt.Sprintf(`
%s
`, s.getQRCodeHTML()) } func (s *Server) getLinkedAccount() *signal.AccountInfo { @@ -80,8 +83,7 @@ func (s *Server) getQRCodeHTML() string { return `

Account already linked

` } - linkDevice := signal.NewLinkDevice(signal.NewClient(s.cfg.SignalCLISocketPath)) - qrCode, err := linkDevice.GenerateQR() + qrCode, err := s.linkDevice.GenerateQR() if err != nil { s.logger.Error("Failed to generate QR code", "error", err) return `

Signal daemon is starting up, please refresh in a few seconds...

` @@ -105,11 +107,11 @@ func (s *Server) handleFragmentEndpoints(w http.ResponseWriter, r *http.Request) w.Header().Set("Content-Type", "text/html") if len(mappings) == 0 { - _, _ = fmt.Fprint(w, `

No endpoints registered

`) //nolint:errcheck // Error writing to ResponseWriter is handled by HTTP server + _, _ = fmt.Fprint(w, `

No endpoints registered

`) //nolint:errcheck return } - _, _ = fmt.Fprint(w, ``) //nolint:errcheck } diff --git a/internal/server/handlers_ntfy.go b/internal/server/handlers_ntfy.go index 2f88654..1206ba7 100644 --- a/internal/server/handlers_ntfy.go +++ b/internal/server/handlers_ntfy.go @@ -15,19 +15,49 @@ import ( func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) { topic := chi.URLParam(r, "endpoint") + if topic == "" { + topic = chi.URLParam(r, "topic") + } + topic, _ = url.QueryUnescape(topic) if topic == "" || strings.Contains(topic, "/") { http.Error(w, "Invalid topic", http.StatusBadRequest) return } body, err := io.ReadAll(r.Body) - if err != nil || len(body) == 0 { - http.Error(w, "Message required", http.StatusBadRequest) + if err != nil { + http.Error(w, "Failed to read body", http.StatusBadRequest) return } message := string(body) - title := extractTitle(r, message) + if message == "" { + http.Error(w, "Message required", http.StatusBadRequest) + return + } + title := r.Header.Get("X-Title") + if title == "" { + title = r.Header.Get("Title") + } + if title == "" { + title = r.Header.Get("t") + } + + contentType := r.Header.Get("Content-Type") + if strings.Contains(contentType, "application/x-www-form-urlencoded") { + if values, err := url.ParseQuery(message); err == nil { + if m := values.Get("message"); m != "" { + message = m + } + if title == "" { + if t := values.Get("title"); t != "" { + title = t + } else if t := values.Get("t"); t != "" { + title = t + } + } + } + } if title == topic { title = "" @@ -48,7 +78,6 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) { s.logger.Debug("Sent ntfy message", "topic", topic, "preview", truncate(message, 50)) - // Return ntfy-compatible response w.Header().Set("Content-Type", "application/json") response := map[string]interface{}{ "id": time.Now().UnixNano(), @@ -62,33 +91,6 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) { } } -func extractTitle(r *http.Request, message string) string { - // Extract title from headers (ntfy supports multiple headers) - title := r.Header.Get("X-Title") - if title == "" { - title = r.Header.Get("Title") - } - if title == "" { - title = r.Header.Get("t") - } - - contentType := r.Header.Get("Content-Type") - - if strings.Contains(contentType, "application/x-www-form-urlencoded") { - if values, err := url.ParseQuery(message); err == nil { - if title == "" { - if t := values.Get("title"); t != "" { - title = t - } else if t := values.Get("t"); t != "" { - title = t - } - } - } - } - - return title -} - func truncate(s string, max int) string { if len(s) <= max { return s diff --git a/internal/server/handlers_proton.go b/internal/server/handlers_proton.go index c1301a6..20b90fa 100644 --- a/internal/server/handlers_proton.go +++ b/internal/server/handlers_proton.go @@ -6,7 +6,7 @@ import ( ) type markReadRequest struct { - UID int `json:"uid"` + UID uint32 `json:"uid"` } func (s *Server) handleProtonMarkRead(w http.ResponseWriter, r *http.Request) { @@ -14,23 +14,29 @@ func (s *Server) handleProtonMarkRead(w http.ResponseWriter, r *http.Request) { if err := json.NewDecoder(r.Body).Decode(&req); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "Invalid request body"}) //nolint:errcheck // Error encoding response is not critical + _ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid request body"}) //nolint:errcheck return } if req.UID == 0 { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "uid (number) is required"}) //nolint:errcheck // Error encoding response is not critical + _ = json.NewEncoder(w).Encode(map[string]string{"error": "uid (number) is required"}) //nolint:errcheck return } - // TODO: Implement mark as read using UID - // For now, this is a placeholder since the IMAP implementation needs the sequence number - s.logger.Debug("Mark email as read requested", "uid", req.UID) + if err := s.protonMonitor.MarkAsRead(req.UID); err != nil { + s.logger.Error("failed to mark email as read", "uid", req.UID, "error", err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "failed to mark as read"}) //nolint:errcheck + return + } + + s.logger.Info("marked email as read", "uid", req.UID) w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(map[string]bool{"success": true}); err != nil { - s.logger.Error("Failed to encode response", "error", err) + s.logger.Error("failed to encode response", "error", err) } } diff --git a/internal/server/handlers_webhook.go b/internal/server/handlers_webhook.go index 46779e1..94d2670 100644 --- a/internal/server/handlers_webhook.go +++ b/internal/server/handlers_webhook.go @@ -23,14 +23,14 @@ func (s *Server) handleWebhookRegister(w http.ResponseWriter, r *http.Request) { if req.AppName == "" || req.UpEndpoint == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "appName and upEndpoint are required"}) //nolint:errcheck // Error encoding response is not critical + _ = json.NewEncoder(w).Encode(map[string]string{"error": "appName and upEndpoint are required"}) //nolint:errcheck return } if _, err := url.Parse(req.UpEndpoint); err != nil { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "Invalid upEndpoint URL"}) //nolint:errcheck // Error encoding response is not critical + _ = json.NewEncoder(w).Encode(map[string]string{"error": "Invalid upEndpoint URL"}) //nolint:errcheck return } @@ -40,7 +40,7 @@ func (s *Server) handleWebhookRegister(w http.ResponseWriter, r *http.Request) { s.logger.Error("Failed to register webhook endpoint", "error", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "Internal server error"}) //nolint:errcheck // Error encoding response is not critical + _ = json.NewEncoder(w).Encode(map[string]string{"error": "Internal server error"}) //nolint:errcheck return } diff --git a/internal/server/middleware.go b/internal/server/middleware.go index 1e217b6..f86369e 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -11,10 +11,10 @@ import ( "golang.org/x/time/rate" ) -func authMiddleware(apiKey string, allowInsecureHTTP bool) func(http.Handler) http.Handler { +func authMiddleware(apiKey string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !util.VerifyAPIKey(r, apiKey, allowInsecureHTTP) { + if !util.VerifyAPIKey(r, apiKey) { w.Header().Set("WWW-Authenticate", `Basic realm="Prism Admin - Username: any, Password: API_KEY"`) http.Error(w, "Unauthorized", http.StatusUnauthorized) return @@ -53,12 +53,10 @@ func (rw *responseWriter) WriteHeader(code int) { rw.ResponseWriter.WriteHeader(code) } -func securityHeadersMiddleware(allowInsecureHTTP bool) func(http.Handler) http.Handler { +func securityHeadersMiddleware() func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("X-Frame-Options", "DENY") - w.Header().Set("X-XSS-Protection", "1; mode=block") w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; form-action 'self'; frame-ancestors 'none'; object-src 'none'") @@ -77,14 +75,14 @@ var ( visitorsMu sync.RWMutex ) -func rateLimitMiddleware(rps int, allowInsecureHTTP bool) func(http.Handler) http.Handler { +func rateLimitMiddleware(rps int) func(http.Handler) http.Handler { go cleanupVisitors() return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ip := util.GetClientIP(r) - if allowInsecureHTTP || util.IsLocalhost(ip) { + if util.IsLocalhost(ip) { next.ServeHTTP(w, r) return } diff --git a/internal/server/server.go b/internal/server/server.go index ccd27b2..b9dd0ce 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -22,8 +22,8 @@ type Server struct { store *notification.Store dispatcher *notification.Dispatcher protonMonitor *proton.Monitor - actionHandler *proton.ActionHandler signalDaemon *signal.Daemon + linkDevice *signal.LinkDevice logger *slog.Logger router *chi.Mux httpServer *http.Server @@ -42,17 +42,17 @@ func New(cfg *config.Config) (*Server, error) { dispatcher := notification.NewDispatcher(store, signalClient, logger) protonMonitor := proton.NewMonitor(cfg, dispatcher, logger) - actionHandler := proton.NewActionHandler(protonMonitor, cfg, logger) signalDaemon := signal.NewDaemon(cfg.SignalCLIBinaryPath, cfg.SignalCLIDataPath, cfg.SignalCLISocketPath) + linkDevice := signal.NewLinkDevice(signalClient, cfg.DeviceName) s := &Server{ cfg: cfg, store: store, dispatcher: dispatcher, protonMonitor: protonMonitor, - actionHandler: actionHandler, signalDaemon: signalDaemon, + linkDevice: linkDevice, logger: logger, startTime: time.Now(), } @@ -68,32 +68,29 @@ func (s *Server) setupRoutes() { r.Use(middleware.RealIP) r.Use(middleware.Recoverer) r.Use(loggingMiddleware(s.logger)) - r.Use(securityHeadersMiddleware(s.cfg.AllowInsecureHTTP)) + r.Use(securityHeadersMiddleware()) r.Use(middleware.Timeout(5 * time.Second)) r.Use(middleware.Compress(5)) r.Use(middleware.StripSlashes) - r.Use(rateLimitMiddleware(s.cfg.RateLimit, s.cfg.AllowInsecureHTTP)) + r.Use(rateLimitMiddleware(s.cfg.RateLimit)) - r.Get("/", func(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "./public/index.html") - }) r.Handle("/*", http.StripPrefix("/", http.FileServer(http.Dir("./public")))) r.Route("/fragment", func(r chi.Router) { - r.Use(authMiddleware(s.cfg.APIKey, s.cfg.AllowInsecureHTTP)) + r.Use(authMiddleware(s.cfg.APIKey)) r.Get("/health", s.handleFragmentHealth) r.Get("/signal-info", s.handleFragmentSignalInfo) r.Get("/endpoints", s.handleFragmentEndpoints) }) r.Route("/action", func(r chi.Router) { - r.Use(authMiddleware(s.cfg.APIKey, s.cfg.AllowInsecureHTTP)) + r.Use(authMiddleware(s.cfg.APIKey)) r.Delete("/delete-endpoint", s.handleDeleteEndpointAction) r.Post("/toggle-channel", s.handleToggleChannelAction) }) r.Route("/admin", func(r chi.Router) { - r.Use(authMiddleware(s.cfg.APIKey, s.cfg.AllowInsecureHTTP)) + r.Use(authMiddleware(s.cfg.APIKey)) r.Get("/mappings", s.handleGetMappings) r.Post("/mappings", s.handleCreateMapping) r.Delete("/mappings/{endpoint}", s.handleDeleteMapping) @@ -101,30 +98,17 @@ func (s *Server) setupRoutes() { r.Get("/stats", s.handleGetStats) }) - r.Route("/ntfy", func(r chi.Router) { - r.Use(authMiddleware(s.cfg.APIKey, s.cfg.AllowInsecureHTTP)) - r.Post("/{endpoint}", s.handleNtfyPublish) - }) - - r.Route("/webhook", func(r chi.Router) { - r.Use(authMiddleware(s.cfg.APIKey, s.cfg.AllowInsecureHTTP)) - r.Post("/register", s.handleWebhookRegister) - }) - r.Route("/api/webhook", func(r chi.Router) { - r.Use(authMiddleware(s.cfg.APIKey, s.cfg.AllowInsecureHTTP)) + r.Use(authMiddleware(s.cfg.APIKey)) r.Post("/register", s.handleWebhookRegister) }) r.Route("/api/proton-mail", func(r chi.Router) { - r.Use(authMiddleware(s.cfg.APIKey, s.cfg.AllowInsecureHTTP)) + r.Use(authMiddleware(s.cfg.APIKey)) r.Post("/mark-read", s.handleProtonMarkRead) }) - r.Route("/proton", func(r chi.Router) { - r.Use(authMiddleware(s.cfg.APIKey, s.cfg.AllowInsecureHTTP)) - r.Post("/mark-read", s.handleProtonMarkRead) - }) + r.With(authMiddleware(s.cfg.APIKey)).Post("/{topic}", s.handleNtfyPublish) s.router = r } @@ -150,14 +134,12 @@ func (s *Server) Start(ctx context.Context) error { IdleTimeout: 60 * time.Second, } - s.logger.Info("") s.logger.Info("Prism running on:") - s.logger.Info(fmt.Sprintf(" Local: http://localhost:%d", s.cfg.Port)) + s.logger.Info(fmt.Sprintf("Local: http://localhost:%d", s.cfg.Port)) if lanIP := util.GetLANIP(); lanIP != "" { - s.logger.Debug(fmt.Sprintf(" Network: http://%s:%d", lanIP, s.cfg.Port)) + s.logger.Debug(fmt.Sprintf("Network: http://%s:%d", lanIP, s.cfg.Port)) } - s.logger.Info("") errCh := make(chan error, 1) go func() { diff --git a/internal/signal/link.go b/internal/signal/link.go index b5b4487..9d59435 100644 --- a/internal/signal/link.go +++ b/internal/signal/link.go @@ -12,15 +12,17 @@ import ( type LinkDevice struct { client *Client + deviceName string qrCode string generatedAt time.Time ttl time.Duration } -func NewLinkDevice(client *Client) *LinkDevice { +func NewLinkDevice(client *Client, deviceName string) *LinkDevice { return &LinkDevice{ - client: client, - ttl: 10 * time.Minute, + client: client, + deviceName: deviceName, + ttl: 10 * time.Minute, } } @@ -48,9 +50,8 @@ func (l *LinkDevice) GenerateQR() (string, error) { return "", fmt.Errorf("empty device link URI") } - // Generate QR code via qrserver.com API qrURL := fmt.Sprintf("https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=%s", url.QueryEscape(uri)) - //nolint:gosec // QR code generation URL is from trusted API + //nolint:gosec resp, err := http.Get(qrURL) if err != nil { return "", fmt.Errorf("failed to fetch QR code: %w", err) @@ -62,7 +63,6 @@ func (l *LinkDevice) GenerateQR() (string, error) { return "", fmt.Errorf("failed to read QR code: %w", err) } - // Convert to base64 data URL base64Data := base64.StdEncoding.EncodeToString(qrData) l.qrCode = fmt.Sprintf("data:image/png;base64,%s", base64Data) l.generatedAt = time.Now() @@ -75,12 +75,11 @@ func (l *LinkDevice) GenerateQR() (string, error) { func (l *LinkDevice) finishLink(uri string) { params := map[string]interface{}{ "deviceLinkUri": uri, + "deviceName": l.deviceName, } - // This blocks until device is linked or times out _, err := l.client.Call("finishLink", params) if err == nil { - // Clear cache on successful link l.qrCode = "" l.generatedAt = time.Time{} } diff --git a/internal/util/auth.go b/internal/util/auth.go index f5004fe..96fb1c8 100644 --- a/internal/util/auth.go +++ b/internal/util/auth.go @@ -8,7 +8,7 @@ import ( "strings" ) -func VerifyAPIKey(r *http.Request, apiKey string, allowInsecureHTTP bool) bool { +func VerifyAPIKey(r *http.Request, apiKey string) bool { auth := r.Header.Get("Authorization") if auth == "" { return false @@ -38,21 +38,19 @@ func VerifyAPIKey(r *http.Request, apiKey string, allowInsecureHTTP bool) bool { return false } - // Enforce HTTPS for non-local connections unless explicitly allowed - if !allowInsecureHTTP { - proto := r.Header.Get("X-Forwarded-Proto") - if proto == "" { - if r.TLS != nil { - proto = "https" - } else { - proto = "http" - } + // Enforce HTTPS for non-local connections + proto := r.Header.Get("X-Forwarded-Proto") + if proto == "" { + if r.TLS != nil { + proto = "https" + } else { + proto = "http" } + } - clientIP := GetClientIP(r) - if proto != "https" && !isLocalIP(clientIP) { - return false - } + clientIP := GetClientIP(r) + if proto != "https" && !isLocalIP(clientIP) { + return false } return subtle.ConstantTimeCompare([]byte(password), []byte(apiKey)) == 1 diff --git a/main.go b/main.go index 6c5e29c..61f1f14 100644 --- a/main.go +++ b/main.go @@ -20,7 +20,7 @@ var ( ) func init() { - _ = godotenv.Load() //nolint:errcheck // .env is optional + _ = godotenv.Load() //nolint:errcheck } func main() { diff --git a/public/index.css b/public/index.css index 3ad93d3..00d94fd 100644 --- a/public/index.css +++ b/public/index.css @@ -160,22 +160,19 @@ h2 { } .theme-toggle { - position: fixed; - bottom: 1.5rem; - right: 1.5rem; + float: right; background: transparent; border: none; cursor: pointer; font-size: 1.5rem; padding: 0; line-height: 1; - opacity: 0.4; + opacity: 0.6; transition: opacity 0.2s; - z-index: 100; } .theme-toggle:hover { - opacity: 0.8; + opacity: 1; } .status-ok { diff --git a/public/index.html b/public/index.html index 95553e7..966ef19 100644 --- a/public/index.html +++ b/public/index.html @@ -12,12 +12,10 @@ - -
+ hx-trigger="load, every 10s">
@@ -26,18 +24,13 @@

Signal

-
+
-
@@ -50,5 +43,7 @@
+ +