use air for fast reloads, cleap code and make it work again (TODO proton)

This commit is contained in:
lone-cloud 2026-02-01 23:38:29 -08:00
parent 73356a9fed
commit a8d8325971
22 changed files with 225 additions and 230 deletions

15
.air.toml Normal file
View file

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

View file

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

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
signal-cli/
data/
tmp/
*.db
.env
/prism

View file

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

View file

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

View file

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

View file

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

View file

@ -10,7 +10,6 @@ type Config struct {
Port int
APIKey string
VerboseLogging bool
AllowInsecureHTTP bool
RateLimit int
DeviceName string
@ -35,7 +34,6 @@ type Config struct {
EndpointPrefixNtfy string
EndpointPrefixUP string
ActionMarkRead string
StoragePath string
}
@ -44,7 +42,6 @@ func Load() (*Config, error) {
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),
DeviceName: getEnvString("DEVICE_NAME", "Prism"),
@ -68,7 +65,6 @@ func Load() (*Config, error) {
EndpointPrefixNtfy: "ntfy-",
EndpointPrefixUP: "up-",
ActionMarkRead: "mark-read",
StoragePath: getEnvString("STORAGE_PATH", "./data/prism.db"),
}

View file

@ -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,13 +100,25 @@ 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)
@ -106,9 +127,10 @@ func (d *Dispatcher) sendGroupMessage(groupID string, notif Notification) error
params := map[string]interface{}{
"groupId": groupID,
"message": message,
"notify-self": true,
}
_, err := d.signalClient.Call("sendGroupMessage", params)
_, err = d.signalClient.CallWithAccount("send", params, account.Number)
return err
}

View file

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

View file

@ -4,7 +4,6 @@ import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"prism/internal/config"
@ -19,6 +18,7 @@ type Monitor struct {
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
}

View file

@ -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(`
<div class="status-item %s">Proton Mail: %s%s</div>`, protonClass, protonStatus, protonTooltip)
<div class="status-item %s">Proton Mail: %s</div>`, protonClass, protonStatus)
}
html += `</div>
@ -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(`<details class="unlink-details">
return fmt.Sprintf(`<div id="signal-info"><details class="unlink-details">
<summary class="unlink-summary">Unlink and remove device</summary>
<div class="unlink-instructions">
<ol>
@ -64,9 +66,10 @@ func (s *Server) getSignalInfoHTML() string {
<li>Tap <strong>"Unlink Device"</strong></li>
</ol>
</div>
</details>`, s.cfg.DeviceName)
</details></div>`, s.cfg.DeviceName)
}
return s.getQRCodeHTML()
return fmt.Sprintf(`<div id="signal-info" hx-get="/fragment/signal-info" hx-trigger="every 3s">%s</div>`, s.getQRCodeHTML())
}
func (s *Server) getLinkedAccount() *signal.AccountInfo {
@ -80,8 +83,7 @@ func (s *Server) getQRCodeHTML() string {
return `<p>Account already linked</p>`
}
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 `<p>Signal daemon is starting up, please refresh in a few seconds...</p>`
@ -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, `<p>No endpoints registered</p>`) //nolint:errcheck // Error writing to ResponseWriter is handled by HTTP server
_, _ = fmt.Fprint(w, `<p>No endpoints registered</p>`) //nolint:errcheck
return
}
_, _ = fmt.Fprint(w, `<ul class="endpoint-list">`) //nolint:errcheck // Error writing to ResponseWriter is handled by HTTP server
_, _ = fmt.Fprint(w, `<ul class="endpoint-list">`) //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(`<span class="endpoint-detail">%s</span>`, *m.GroupID)
html += fmt.Sprintf(`<span class="endpoint-detail">Groud ID: %s</span>`, *m.GroupID)
}
html += `</div></div><div class="endpoint-actions">`
@ -152,7 +154,7 @@ func (s *Server) handleFragmentEndpoints(w http.ResponseWriter, r *http.Request)
<button class="btn-delete" hx-delete="/action/delete-endpoint" hx-target="#endpoints-list" hx-swap="innerHTML" hx-include="closest form">Delete</button>
</form></div></li>`, 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, `</ul>`) //nolint:errcheck // Error writing to ResponseWriter is handled by HTTP server
_, _ = fmt.Fprint(w, `</ul>`) //nolint:errcheck
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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))
if lanIP := util.GetLANIP(); lanIP != "" {
s.logger.Debug(fmt.Sprintf("Network: http://%s:%d", lanIP, s.cfg.Port))
}
s.logger.Info("")
errCh := make(chan error, 1)
go func() {

View file

@ -12,14 +12,16 @@ 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,
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{}
}

View file

@ -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,8 +38,7 @@ func VerifyAPIKey(r *http.Request, apiKey string, allowInsecureHTTP bool) bool {
return false
}
// Enforce HTTPS for non-local connections unless explicitly allowed
if !allowInsecureHTTP {
// Enforce HTTPS for non-local connections
proto := r.Header.Get("X-Forwarded-Proto")
if proto == "" {
if r.TLS != nil {
@ -53,7 +52,6 @@ func VerifyAPIKey(r *http.Request, apiKey string, allowInsecureHTTP bool) bool {
if proto != "https" && !isLocalIP(clientIP) {
return false
}
}
return subtle.ConstantTimeCompare([]byte(password), []byte(apiKey)) == 1
}

View file

@ -20,7 +20,7 @@ var (
)
func init() {
_ = godotenv.Load() //nolint:errcheck // .env is optional
_ = godotenv.Load() //nolint:errcheck
}
func main() {

View file

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

View file

@ -12,12 +12,10 @@
<script src="/theme.js"></script>
</head>
<body>
<button id="theme-toggle" class="theme-toggle"></button>
<div class="card">
<div id="health-status"
hx-get="/fragment/health"
hx-trigger="load">
hx-trigger="load, every 10s">
<div class="loading">
<div class="spinner"></div>
</div>
@ -28,16 +26,11 @@
<h2>Signal</h2>
<div id="signal-info"
hx-get="/fragment/signal-info"
hx-trigger="load, accountLinked from:body"
hx-indicator="#signal-info">
hx-trigger="load, every 3s">
<div class="loading">
<div class="spinner"></div>
</div>
</div>
<div id="qr-section"
hx-get="/fragment/signal-info"
hx-trigger="accountLinked from:body"
hx-swap="none"></div>
</div>
<div class="card">
@ -50,5 +43,7 @@
</div>
</div>
</div>
<button id="theme-toggle" class="theme-toggle"></button>
</body>
</html>