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(`
+ return fmt.Sprintf(`
Unlink and remove device
@@ -64,9 +66,10 @@ func (s *Server) getSignalInfoHTML() string {
- Tap "Unlink Device"
- `, s.cfg.DeviceName)
+
`, 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 // Error writing to ResponseWriter is handled by HTTP server
+ _, _ = fmt.Fprint(w, ``) //nolint:errcheck
for _, m := range mappings {
isSignal := m.Channel == notification.ChannelSignal
isWebhook := m.Channel == notification.ChannelWebhook
@@ -132,7 +134,7 @@ func (s *Server) handleFragmentEndpoints(w http.ResponseWriter, r *http.Request)
}
if m.GroupID != nil && isSignal {
- html += fmt.Sprintf(`%s`, *m.GroupID)
+ html += fmt.Sprintf(`Groud ID: %s`, *m.GroupID)
}
html += ``
@@ -152,7 +154,7 @@ func (s *Server) handleFragmentEndpoints(w http.ResponseWriter, r *http.Request)
`, m.Endpoint)
- _, _ = fmt.Fprint(w, html) //nolint:errcheck // Error writing to ResponseWriter is handled by HTTP server
+ _, _ = fmt.Fprint(w, html) //nolint:errcheck
}
- _, _ = fmt.Fprint(w, `
`) //nolint:errcheck // Error writing to ResponseWriter is handled by HTTP server
+ _, _ = 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 @@
+
+