mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 08:43:10 -07:00
use air for fast reloads, cleap code and make it work again (TODO proton)
This commit is contained in:
parent
73356a9fed
commit
a8d8325971
22 changed files with 225 additions and 230 deletions
15
.air.toml
Normal file
15
.air.toml
Normal 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
|
||||
|
|
@ -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
1
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
|||
signal-cli/
|
||||
data/
|
||||
tmp/
|
||||
*.db
|
||||
.env
|
||||
/prism
|
||||
|
|
|
|||
6
Makefile
6
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 .
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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:-}
|
||||
|
|
|
|||
|
|
@ -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:-}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
2
main.go
2
main.go
|
|
@ -20,7 +20,7 @@ var (
|
|||
)
|
||||
|
||||
func init() {
|
||||
_ = godotenv.Load() //nolint:errcheck // .env is optional
|
||||
_ = godotenv.Load() //nolint:errcheck
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -26,18 +24,13 @@
|
|||
|
||||
<div class="card">
|
||||
<h2>Signal</h2>
|
||||
<div id="signal-info"
|
||||
hx-get="/fragment/signal-info"
|
||||
hx-trigger="load, accountLinked from:body"
|
||||
hx-indicator="#signal-info">
|
||||
<div id="signal-info"
|
||||
hx-get="/fragment/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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue