diff --git a/.github/workflows/release-signal-cli.yml b/.github/workflows/release-signal-cli.yml index 94e1b76..c719686 100644 --- a/.github/workflows/release-signal-cli.yml +++ b/.github/workflows/release-signal-cli.yml @@ -3,6 +3,10 @@ name: Release Signal CLI on: workflow_dispatch: +permissions: + contents: read + packages: write + jobs: release: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f040021..10e04d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,12 +3,63 @@ name: Release on: workflow_dispatch: +permissions: + contents: write + packages: write + jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25' + + - name: Extract version + id: version + run: | + echo "tag=$(cat VERSION)" >> $GITHUB_OUTPUT + + - name: Install UPX + run: | + sudo apt-get update + sudo apt-get install -y upx-ucl + + - name: Build binaries + run: | + VERSION=$(cat VERSION) + COMMIT=$(git rev-parse --short HEAD) + + # Linux AMD64 + CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build \ + -ldflags="-s -w -X main.version=$VERSION -X main.commit=$COMMIT" \ + -o prism-linux-amd64 . + upx --best --lzma prism-linux-amd64 + + # Linux ARM64 + sudo apt-get install -y gcc-aarch64-linux-gnu + CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc go build \ + -ldflags="-s -w -X main.version=$VERSION -X main.commit=$COMMIT" \ + -o prism-linux-arm64 . + upx --best --lzma prism-linux-arm64 + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.version.outputs.tag }} + name: Release ${{ steps.version.outputs.tag }} + draft: false + prerelease: false + generate_release_notes: true + files: | + prism-linux-amd64 + prism-linux-arm64 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -19,11 +70,6 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract version - id: version - run: | - echo "tag=$(cat VERSION)" >> $GITHUB_OUTPUT - - name: Build and push Docker image uses: docker/build-push-action@v6 with: diff --git a/Dockerfile b/Dockerfile index f4a0402..a0a0e9b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ -FROM golang:1.25-alpine AS builder +FROM golang:1.25-alpine3.23 AS builder WORKDIR /build -RUN apk add --no-cache git ca-certificates gcc musl-dev +RUN apk add --no-cache git ca-certificates gcc musl-dev upx COPY go.mod go.sum ./ RUN go mod download @@ -12,16 +12,16 @@ COPY . . RUN CGO_ENABLED=1 GOOS=linux go build \ -trimpath \ -ldflags="-w -s -X main.version=$(cat VERSION 2>/dev/null || echo dev) -X main.commit=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" \ - -o prism . + -o prism . && \ + upx --best --lzma prism -FROM alpine:latest +FROM alpine:3.23 RUN apk --no-cache add ca-certificates WORKDIR /app COPY --from=builder /build/prism . -COPY public ./public RUN adduser -D -u 1000 prism && \ mkdir -p /app/data && \ diff --git a/Dockerfile.signal-cli b/Dockerfile.signal-cli index 43b696c..606c4ab 100644 --- a/Dockerfile.signal-cli +++ b/Dockerfile.signal-cli @@ -1,6 +1,11 @@ -FROM alpine:latest +FROM alpine:3.23 -RUN apk add --no-cache signal-cli openjdk21-jre bash su-exec +ARG SIGNAL_CLI_VERSION=0.13.24 + +RUN apk add --no-cache openjdk21-jre bash su-exec curl tar && \ + curl -sSL https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux.tar.gz | tar xz -C /opt && \ + ln -sf /opt/signal-cli-${SIGNAL_CLI_VERSION}/bin/signal-cli /usr/local/bin/signal-cli && \ + apk del curl tar RUN adduser -D -u 1000 signal && \ mkdir -p /home/signal/.signal-cli /home/.local/share/signal-cli && \ diff --git a/Makefile b/Makefile index bcfdd1d..e56d47a 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ export PATH := $(GOBIN):$(PATH) all: fmt lint build build: - go build -ldflags="-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o $(BINARY_NAME) . + go build -ldflags="-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o $(BINARY_NAME) . start: build ./$(BINARY_NAME) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 740482c..a0bb992 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -24,9 +24,7 @@ services: signal-cli: container_name: signal-cli - build: - context: . - dockerfile: Dockerfile.signal-cli + image: ghcr.io/lone-cloud/prism-signal-cli:latest profiles: ['signal'] volumes: - signal-data:/home/.local/share/signal-cli diff --git a/main.go b/main.go index 4cb6418..cbf6b04 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "embed" "fmt" "log/slog" "os" @@ -15,6 +16,9 @@ import ( "github.com/joho/godotenv" ) +//go:embed public/* +var publicAssets embed.FS + var ( version = "dev" commit = "unknown" @@ -46,7 +50,7 @@ func main() { } func runServer(cfg *config.Config, logger *slog.Logger) error { - srv, err := server.New(cfg) + srv, err := server.New(cfg, publicAssets) if err != nil { return fmt.Errorf("failed to create server: %w", err) } diff --git a/public/index.css b/public/index.css index 8ce242a..2935e91 100644 --- a/public/index.css +++ b/public/index.css @@ -158,26 +158,11 @@ a:hover { opacity: 0.8; } -.status { - display: flex; - gap: 1rem; - flex-wrap: wrap; -} - -.status-item { - padding: 0.625rem 1rem; - border-radius: 0.25rem; - font-weight: 500; - position: relative; -} - -.status-item:has(.tooltip), .channel-badge:has(.tooltip), .integration-status:has(.tooltip) { cursor: help; } -.status-item .tooltip, .channel-badge .tooltip, .integration-status .tooltip { visibility: hidden; @@ -191,7 +176,7 @@ a:hover { color: var(--text-primary); padding: 0.375rem 0.625rem; border-radius: 0.25rem; - border: 1px solid var(--border-color); + border: 0.0625rem solid var(--border-color); font-size: 0.875rem; font-weight: 400; white-space: nowrap; @@ -201,7 +186,6 @@ a:hover { box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.2); } -.status-item .tooltip::after, .channel-badge .tooltip::after, .integration-status .tooltip::after { content: ""; @@ -213,7 +197,6 @@ a:hover { border-top-color: var(--bg-secondary); } -.status-item:hover .tooltip, .channel-badge:hover .tooltip, .integration-status:hover .tooltip { visibility: visible; @@ -238,16 +221,6 @@ a:hover { opacity: 1; } -.status-ok { - background: var(--success); - color: var(--text-on-color); -} - -.status-error { - background: var(--error); - color: var(--text-on-color); -} - .app-list { list-style: none; padding: 0; @@ -318,11 +291,15 @@ a:hover { align-items: center; } +.channel-form { + display: inline; +} + .channel-select { padding: 0.375rem 0.5rem; background: var(--bg-secondary); color: var(--text-primary); - border: 1px solid var(--border-color); + border: 0.0625rem solid var(--border-color); border-radius: 0.25rem; cursor: pointer; font-size: 0.9em; @@ -358,23 +335,6 @@ a:hover { filter: brightness(0.9); } -.unlink-details { - margin-top: 1rem; -} - -.unlink-summary { - cursor: pointer; - font-weight: bold; -} - -.unlink-instructions { - margin-top: 1rem; -} - -.unlink-instructions ol { - margin-left: 1.25rem; -} - .link-button { display: inline-block; padding: 0.625rem 1.25rem; @@ -414,12 +374,6 @@ a:hover { } } -.loading-subtitle { - font-size: 0.875rem; - color: var(--text-secondary); - margin-top: 0.5rem; -} - .integration-card { background: var(--bg-secondary); border-radius: 0.5rem; @@ -506,8 +460,18 @@ details[open] > .integration-header::before { .qr-code { height: 22rem; width: 22rem; - border: 2px solid var(--border-color); + border: 0.125rem solid var(--border-color); border-radius: 0.5rem; padding: 0.625rem; - background: var(--bg-secondar); + background: var(--bg-secondary); +} + +.channel-not-configured { + background: var(--error); + color: var(--text-on-color); +} + +.text-muted { + color: var(--text-secondary); + font-size: 0.875rem; } diff --git a/service/integration/integration.go b/service/integration/integration.go index 5738370..98ebe51 100644 --- a/service/integration/integration.go +++ b/service/integration/integration.go @@ -11,6 +11,7 @@ import ( "prism/service/integration/telegram" "prism/service/integration/webpush" "prism/service/notification" + "prism/service/util" "github.com/go-chi/chi/v5" ) @@ -27,9 +28,9 @@ type Integrations struct { integrations []Integration } -func Initialize(cfg *config.Config, store *notification.Store, logger *slog.Logger) *Integrations { - signalIntegration := signal.NewIntegration(cfg, store, logger) - telegramIntegration := telegram.NewIntegration(cfg, store, logger) +func Initialize(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmplRenderer *util.TemplateRenderer) *Integrations { + signalIntegration := signal.NewIntegration(cfg, store, logger, tmplRenderer) + telegramIntegration := telegram.NewIntegration(cfg, store, logger, tmplRenderer) dispatcher := notification.NewDispatcher(store, logger) if signalSender := signalIntegration.GetSender(); signalSender != nil { @@ -43,7 +44,7 @@ func Initialize(cfg *config.Config, store *notification.Store, logger *slog.Logg integrations := []Integration{ signalIntegration, telegramIntegration, - proton.NewIntegration(cfg, dispatcher, logger), + proton.NewIntegration(cfg, dispatcher, logger, tmplRenderer), webpush.NewIntegration(store, logger), } diff --git a/service/integration/proton/handlers.go b/service/integration/proton/handlers.go index 3c253ff..49ff232 100644 --- a/service/integration/proton/handlers.go +++ b/service/integration/proton/handlers.go @@ -2,70 +2,81 @@ package proton import ( "encoding/json" - "fmt" + "html/template" "log/slog" "net/http" + + "prism/service/util" ) type Handlers struct { monitor *Monitor username string logger *slog.Logger + tmpl *util.TemplateRenderer } -func NewHandlers(monitor *Monitor, username string, logger *slog.Logger) *Handlers { +type ProtonContentData struct { + Connected bool +} + +type IntegrationData struct { + Name string + StatusClass string + StatusText string + StatusTooltip string + Content template.HTML + Open bool + PollAttrs string +} + +func NewHandlers(monitor *Monitor, username string, logger *slog.Logger, tmpl *util.TemplateRenderer) *Handlers { return &Handlers{ monitor: monitor, username: username, logger: logger, + tmpl: tmpl, } } func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) { if h.monitor == nil { - return // Proton not enabled + return } w.Header().Set("Content-Type", "text/html") - statusBadge := `Unlinked` - var content string - var openAttr string + var contentData ProtonContentData + var integData IntegrationData + integData.Name = "Proton Mail" if h.monitor.IsConnected() { - statusBadge = fmt.Sprintf(`Linked%s`, h.username) - content = ` -

Unlink Instructions:

- - ` - openAttr = "" + integData.StatusClass = "connected" + integData.StatusText = "Linked" + integData.StatusTooltip = h.username + integData.Open = false + contentData.Connected = true } else { - content = ` -

Setup Instructions:

- -

See full setup guide

- ` - openAttr = " open" + integData.StatusClass = "disconnected" + integData.StatusText = "Unlinked" + integData.Open = true + contentData.Connected = false } - html := fmt.Sprintf(`
- - Proton Mail - %s - -
%s
-
`, openAttr, statusBadge, content) + content, err := h.tmpl.RenderHTML("proton-content.html", contentData) + if err != nil { + util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err) + return + } + integData.Content = content - _, _ = fmt.Fprint(w, html) + html, err := h.tmpl.Render("integration.html", integData) + if err != nil { + util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err) + return + } + + w.Write([]byte(html)) } func (h *Handlers) IsEnabled() bool { diff --git a/service/integration/proton/integration.go b/service/integration/proton/integration.go index 88a8bee..33e5d11 100644 --- a/service/integration/proton/integration.go +++ b/service/integration/proton/integration.go @@ -7,6 +7,7 @@ import ( "prism/service/config" "prism/service/notification" + "prism/service/util" "github.com/go-chi/chi/v5" ) @@ -16,18 +17,20 @@ type Integration struct { dispatcher *notification.Dispatcher logger *slog.Logger handlers *Handlers + tmpl *util.TemplateRenderer } -func NewIntegration(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger) *Integration { +func NewIntegration(cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration { return &Integration{ cfg: cfg, dispatcher: dispatcher, logger: logger, + tmpl: tmpl, } } func (p *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) { - p.handlers = RegisterRoutes(router, p.cfg, p.dispatcher, p.logger, auth) + p.handlers = RegisterRoutes(router, p.cfg, p.dispatcher, p.logger, auth, p.tmpl) } func (p *Integration) Start(ctx context.Context, logger *slog.Logger) { diff --git a/service/integration/proton/routes.go b/service/integration/proton/routes.go index a34e32f..b9dd030 100644 --- a/service/integration/proton/routes.go +++ b/service/integration/proton/routes.go @@ -1,22 +1,31 @@ package proton import ( + "embed" "log/slog" "net/http" "prism/service/config" "prism/service/notification" + "prism/service/util" "github.com/go-chi/chi/v5" ) -func RegisterRoutes(router *chi.Mux, cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger, authMiddleware func(http.Handler) http.Handler) *Handlers { +//go:embed templates/*.html +var templates embed.FS + +func GetTemplates() embed.FS { + return templates +} + +func RegisterRoutes(router *chi.Mux, cfg *config.Config, dispatcher *notification.Dispatcher, logger *slog.Logger, authMiddleware func(http.Handler) http.Handler, tmpl *util.TemplateRenderer) *Handlers { if !cfg.IsProtonEnabled() { return nil } monitor := NewMonitor(cfg, dispatcher, logger) - handlers := NewHandlers(monitor, cfg.ProtonIMAPUsername, logger) + handlers := NewHandlers(monitor, cfg.ProtonIMAPUsername, logger, tmpl) router.With(authMiddleware).Get("/fragment/proton", handlers.HandleFragment) diff --git a/service/integration/proton/templates/proton-content.html b/service/integration/proton/templates/proton-content.html new file mode 100644 index 0000000..eaaa2d9 --- /dev/null +++ b/service/integration/proton/templates/proton-content.html @@ -0,0 +1,17 @@ +{{if .Connected}} +

Unlink Instructions:

+ +{{else}} +

Setup Instructions:

+ +

See full setup guide

+{{end}} diff --git a/service/integration/signal/handlers.go b/service/integration/signal/handlers.go index e14d97f..cfad6db 100644 --- a/service/integration/signal/handlers.go +++ b/service/integration/signal/handlers.go @@ -1,78 +1,97 @@ package signal import ( - "fmt" + "html/template" + "log/slog" "net/http" + + "prism/service/util" ) type Handlers struct { client *Client linkDevice *LinkDevice + tmpl *util.TemplateRenderer + logger *slog.Logger } -func NewHandlers(client *Client, linkDevice *LinkDevice) *Handlers { +type SignalContentData struct { + Linked bool + DeviceName string + Error string + QRCode string +} + +type IntegrationData struct { + Name string + StatusClass string + StatusText string + StatusTooltip string + Content template.HTML + Open bool + PollAttrs string +} + +func NewHandlers(client *Client, linkDevice *LinkDevice, tmpl *util.TemplateRenderer, logger *slog.Logger) *Handlers { return &Handlers{ client: client, linkDevice: linkDevice, + tmpl: tmpl, + logger: logger, } } func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) { if h.client == nil { - return // Signal not enabled + return } w.Header().Set("Content-Type", "text/html") account, _ := h.client.GetLinkedAccount() - var content string - var statusBadge string - var openAttr string - var pollAttrs string + + var contentData SignalContentData + var integData IntegrationData + integData.Name = "Signal" if account != nil { - statusBadge = fmt.Sprintf(`Linked%s`, FormatPhoneNumber(account.Number)) - content = fmt.Sprintf(` -

Unlink Instructions:

- - `, h.linkDevice.deviceName) - openAttr = "" - pollAttrs = "" + integData.StatusClass = "connected" + integData.StatusText = "Linked" + integData.StatusTooltip = FormatPhoneNumber(account.Number) + integData.Open = false + integData.PollAttrs = "" + + contentData.Linked = true + contentData.DeviceName = h.linkDevice.deviceName } else { - statusBadge = `Unlinked` + integData.StatusClass = "unlinked" + integData.StatusText = "Unlinked" + integData.Open = true + integData.PollAttrs = `hx-get="/fragment/signal" hx-trigger="every 3s" hx-swap="outerHTML"` + + contentData.Linked = false qrCode, err := h.linkDevice.GenerateQR() if err != nil { - content = fmt.Sprintf(`

Error generating QR code: %s

`, err) + contentData.Error = err.Error() } else { - content = fmt.Sprintf(` -

Link your Signal (or Molly) account:

- -
- Signal QR Code -
- `, qrCode) + contentData.QRCode = qrCode } - openAttr = " open" - pollAttrs = ` hx-get="/fragment/signal" hx-trigger="every 3s" hx-swap="outerHTML"` } - html := fmt.Sprintf(`
- - Signal - %s - -
%s
-
`, openAttr, pollAttrs, statusBadge, content) + content, err := h.tmpl.RenderHTML("signal-content.html", contentData) + if err != nil { + util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err) + return + } + integData.Content = content - _, _ = fmt.Fprint(w, html) + html, err := h.tmpl.Render("integration.html", integData) + if err != nil { + util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err) + return + } + + w.Write([]byte(html)) } func (h *Handlers) IsEnabled() bool { diff --git a/service/integration/signal/integration.go b/service/integration/signal/integration.go index d54bb32..bc64cfc 100644 --- a/service/integration/signal/integration.go +++ b/service/integration/signal/integration.go @@ -7,6 +7,7 @@ import ( "prism/service/config" "prism/service/notification" + "prism/service/util" "github.com/go-chi/chi/v5" ) @@ -15,9 +16,11 @@ type Integration struct { cfg *config.Config handlers *Handlers sender *Sender + tmpl *util.TemplateRenderer + logger *slog.Logger } -func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger) *Integration { +func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration { var sender *Sender if cfg.IsSignalEnabled() { client := NewClient(cfg.SignalSocket) @@ -26,6 +29,8 @@ func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog. return &Integration{ cfg: cfg, sender: sender, + tmpl: tmpl, + logger: logger, } } @@ -34,7 +39,7 @@ func (s *Integration) GetSender() *Sender { } func (s *Integration) RegisterRoutes(router *chi.Mux, auth func(http.Handler) http.Handler) { - s.handlers = RegisterRoutes(router, s.cfg, auth) + s.handlers = RegisterRoutes(router, s.cfg, auth, s.tmpl, s.logger) } func (s *Integration) Start(ctx context.Context, logger *slog.Logger) { diff --git a/service/integration/signal/routes.go b/service/integration/signal/routes.go index 65e6ecf..73162c2 100644 --- a/service/integration/signal/routes.go +++ b/service/integration/signal/routes.go @@ -1,21 +1,31 @@ package signal import ( + "embed" + "log/slog" "net/http" "prism/service/config" + "prism/service/util" "github.com/go-chi/chi/v5" ) -func RegisterRoutes(router *chi.Mux, cfg *config.Config, authMiddleware func(http.Handler) http.Handler) *Handlers { +//go:embed templates/*.html +var templates embed.FS + +func GetTemplates() embed.FS { + return templates +} + +func RegisterRoutes(router *chi.Mux, cfg *config.Config, authMiddleware func(http.Handler) http.Handler, tmpl *util.TemplateRenderer, logger *slog.Logger) *Handlers { if !cfg.IsSignalEnabled() { return nil } client := NewClient(cfg.SignalSocket) linkDevice := NewLinkDevice(client, cfg.DeviceName) - handlers := NewHandlers(client, linkDevice) + handlers := NewHandlers(client, linkDevice, tmpl, logger) router.With(authMiddleware).Get("/fragment/signal", handlers.HandleFragment) diff --git a/service/integration/signal/templates/signal-content.html b/service/integration/signal/templates/signal-content.html new file mode 100644 index 0000000..23ad5b7 --- /dev/null +++ b/service/integration/signal/templates/signal-content.html @@ -0,0 +1,22 @@ +{{if .Linked}} +

Unlink Instructions:

+ +{{else}} + {{if .Error}} +

Error generating QR code: {{.Error}}

+ {{else}} +

Link your Signal (or Molly) account:

+ +
+ Signal QR Code +
+ {{end}} +{{end}} diff --git a/service/integration/telegram/handlers.go b/service/integration/telegram/handlers.go index 560f826..a626611 100644 --- a/service/integration/telegram/handlers.go +++ b/service/integration/telegram/handlers.go @@ -1,83 +1,92 @@ package telegram import ( - "fmt" + "html/template" + "log/slog" "net/http" + + "prism/service/util" ) type Handlers struct { client *Client chatID int64 + tmpl *util.TemplateRenderer + logger *slog.Logger } -func NewHandlers(client *Client, chatID int64) *Handlers { +type TelegramContentData struct { + NotConfigured bool + Error string + NeedsChatID bool +} + +type IntegrationData struct { + Name string + StatusClass string + StatusText string + StatusTooltip string + Content template.HTML + Open bool + PollAttrs string +} + +func NewHandlers(client *Client, chatID int64, tmpl *util.TemplateRenderer, logger *slog.Logger) *Handlers { return &Handlers{ client: client, chatID: chatID, + tmpl: tmpl, + logger: logger, } } func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") - var content string - var statusBadge string - var openAttr string + var contentData TelegramContentData + var integData IntegrationData + integData.Name = "Telegram" if h.client == nil { - statusBadge = `Not Configured` - content = ` -

Setup Instructions:

- -

See full setup guide

- ` - openAttr = " open" + integData.StatusClass = "disconnected" + integData.StatusText = "Not Configured" + integData.Open = true + contentData.NotConfigured = true } else { bot, err := h.client.GetMe() if err != nil { - statusBadge = `Error` - content = fmt.Sprintf(`

Error: %s

`, err) - openAttr = " open" + integData.StatusClass = "disconnected" + integData.StatusText = "Error" + integData.Open = true + contentData.Error = err.Error() } else if h.chatID == 0 { - statusBadge = fmt.Sprintf(`Needs Chat ID@%s`, bot.Username) - content = ` -

Complete Setup:

- - ` - openAttr = " open" + integData.StatusClass = "disconnected" + integData.StatusText = "Needs Chat ID" + integData.StatusTooltip = "@" + bot.Username + integData.Open = true + contentData.NeedsChatID = true } else { - statusBadge = fmt.Sprintf(`Linked@%s`, bot.Username) - content = ` -

Unlink Instructions:

- - ` - openAttr = "" + integData.StatusClass = "connected" + integData.StatusText = "Linked" + integData.StatusTooltip = "@" + bot.Username + integData.Open = false } } - html := fmt.Sprintf(`
- - Telegram - %s - -
%s
-
`, openAttr, statusBadge, content) + content, err := h.tmpl.RenderHTML("telegram-content.html", contentData) + if err != nil { + util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err) + return + } + integData.Content = content - _, _ = fmt.Fprint(w, html) + html, err := h.tmpl.Render("integration.html", integData) + if err != nil { + util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err) + return + } + + w.Write([]byte(html)) } func (h *Handlers) IsEnabled() bool { diff --git a/service/integration/telegram/integration.go b/service/integration/telegram/integration.go index d801403..c14afc3 100644 --- a/service/integration/telegram/integration.go +++ b/service/integration/telegram/integration.go @@ -7,6 +7,7 @@ import ( "prism/service/config" "prism/service/notification" + "prism/service/util" "github.com/go-chi/chi/v5" ) @@ -15,9 +16,10 @@ type Integration struct { cfg *config.Config handlers *Handlers sender *Sender + logger *slog.Logger } -func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger) *Integration { +func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration { client, err := NewClient(cfg.TelegramBotToken) if err != nil { logger.Error("Failed to create telegram client", "error", err) @@ -28,12 +30,13 @@ func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog. sender = NewSender(client, store, logger, cfg.TelegramChatID) } - handlers := NewHandlers(client, cfg.TelegramChatID) + handlers := NewHandlers(client, cfg.TelegramChatID, tmpl, logger) return &Integration{ cfg: cfg, handlers: handlers, sender: sender, + logger: logger, } } diff --git a/service/integration/telegram/routes.go b/service/integration/telegram/routes.go index b3327dc..06a5c57 100644 --- a/service/integration/telegram/routes.go +++ b/service/integration/telegram/routes.go @@ -1,11 +1,19 @@ package telegram import ( + "embed" "net/http" "github.com/go-chi/chi/v5" ) +//go:embed templates/*.html +var templates embed.FS + +func GetTemplates() embed.FS { + return templates +} + func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler) http.Handler) { if handlers == nil { return diff --git a/service/integration/telegram/sender.go b/service/integration/telegram/sender.go index c3ee020..b7dd45c 100644 --- a/service/integration/telegram/sender.go +++ b/service/integration/telegram/sender.go @@ -37,7 +37,9 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica message = fmt.Sprintf("%s\n%s", notif.Title, notif.Message) } - if err := s.client.SendMessage(s.DefaultChatID, message); err != nil { + fullMessage := fmt.Sprintf("📱 %s\n\n%s", mapping.AppName, message) + + if err := s.client.SendMessage(s.DefaultChatID, fullMessage); err != nil { s.logger.Error("Failed to send telegram message", "chatID", s.DefaultChatID, "error", err) return err } diff --git a/service/integration/telegram/templates/telegram-content.html b/service/integration/telegram/templates/telegram-content.html new file mode 100644 index 0000000..646fc3c --- /dev/null +++ b/service/integration/telegram/templates/telegram-content.html @@ -0,0 +1,27 @@ +{{if .NotConfigured}} +

Setup Instructions:

+ +

See full setup guide

+{{else if .Error}} +

Error: {{.Error}}

+{{else if .NeedsChatID}} +

Complete Setup:

+ +{{else}} +

Unlink Instructions:

+ +{{end}} diff --git a/service/integration/webpush/handlers.go b/service/integration/webpush/handlers.go index a17fc85..1b6646c 100644 --- a/service/integration/webpush/handlers.go +++ b/service/integration/webpush/handlers.go @@ -7,6 +7,7 @@ import ( "net/url" "prism/service/notification" + "prism/service/util" "github.com/go-chi/chi/v5" ) @@ -54,8 +55,7 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) { existing, err := h.store.GetApp(req.AppName) if err != nil { - h.logger.Error("Failed to check existing app", "error", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) + util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err) return } @@ -75,8 +75,7 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) { if existing != nil { if err := h.store.UpdateWebPush(req.AppName, webPush); err != nil { - h.logger.Error("Failed to update webpush endpoint", "error", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) + util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err) return } h.logger.Info("Updated webpush for existing endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint) @@ -93,7 +92,7 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") response := map[string]string{ "appName": req.AppName, - "channel": "webpush", + "channel": notification.ChannelWebPush.String(), } _ = json.NewEncoder(w).Encode(response) } diff --git a/service/notification/notification.go b/service/notification/notification.go index 212f38c..fe43d09 100644 --- a/service/notification/notification.go +++ b/service/notification/notification.go @@ -21,6 +21,23 @@ const ( ChannelTelegram Channel = "telegram" ) +func (c Channel) String() string { + return string(c) +} + +func (c Channel) Label() string { + switch c { + case ChannelSignal: + return "Signal" + case ChannelWebPush: + return "WebPush" + case ChannelTelegram: + return "Telegram" + default: + return string(c) + } +} + func (c Channel) IsAvailable(signalEnabled bool, telegramEnabled bool) bool { switch c { case ChannelWebPush: diff --git a/service/server/handler_index.go b/service/server/handler_index.go index e389f2c..54708b5 100644 --- a/service/server/handler_index.go +++ b/service/server/handler_index.go @@ -2,17 +2,12 @@ package server import ( "net/http" + "prism/service/util" ) func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - data := struct { - Version string - }{ - Version: s.version, - } - if err := s.indexTmpl.Execute(w, data); err != nil { - s.logger.Error("failed to execute index template", "error", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) + if err := s.indexTmpl.Execute(w, map[string]string{"Version": s.version}); err != nil { + util.LogAndError(w, s.logger, "Internal Server Error", http.StatusInternalServerError, err) } } diff --git a/service/server/handlers_action.go b/service/server/handlers_action.go index 145f335..41844a9 100644 --- a/service/server/handlers_action.go +++ b/service/server/handlers_action.go @@ -4,6 +4,7 @@ import ( "net/http" "prism/service/notification" + "prism/service/util" "github.com/go-chi/chi/v5" ) @@ -16,8 +17,7 @@ func (s *Server) handleDeleteAppAction(w http.ResponseWriter, r *http.Request) { } if err := s.store.RemoveApp(app); err != nil { - s.logger.Error("Failed to delete app", "error", err) - http.Error(w, "Failed to delete app", http.StatusInternalServerError) + util.LogAndError(w, s.logger, "Failed to delete app", http.StatusInternalServerError, err) return } @@ -45,8 +45,7 @@ func (s *Server) handleToggleChannelAction(w http.ResponseWriter, r *http.Reques } if err := s.store.UpdateChannel(app, notification.Channel(channel)); err != nil { - s.logger.Error("Failed to update channel", "error", err) - http.Error(w, "Failed to update channel", http.StatusInternalServerError) + util.LogAndError(w, s.logger, "Failed to update channel", http.StatusInternalServerError, err) return } diff --git a/service/server/handlers_admin.go b/service/server/handlers_admin.go index faf10a5..309e6cc 100644 --- a/service/server/handlers_admin.go +++ b/service/server/handlers_admin.go @@ -15,8 +15,7 @@ import ( func (s *Server) handleGetMappings(w http.ResponseWriter, r *http.Request) { mappings, err := s.store.GetAllMappings() if err != nil { - s.logger.Error("Failed to get mappings", "error", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) + util.LogAndError(w, s.logger, "Internal server error", http.StatusInternalServerError, err) return } @@ -61,8 +60,7 @@ func (s *Server) handleCreateMapping(w http.ResponseWriter, r *http.Request) { } if err := s.store.Register(req.App, &req.Channel, nil, webPush); err != nil { - s.logger.Error("Failed to register mapping", "error", err) - http.Error(w, "Failed to create mapping", http.StatusInternalServerError) + util.LogAndError(w, s.logger, "Failed to create mapping", http.StatusInternalServerError, err) return } @@ -80,8 +78,7 @@ func (s *Server) handleDeleteMapping(w http.ResponseWriter, r *http.Request) { } if err := s.store.RemoveApp(appName); err != nil { - s.logger.Error("Failed to delete mapping", "error", err) - http.Error(w, "Failed to delete mapping", http.StatusInternalServerError) + util.LogAndError(w, s.logger, "Failed to delete mapping", http.StatusInternalServerError, err) return } diff --git a/service/server/handlers_fragment.go b/service/server/handlers_fragment.go index a8ea426..2a60660 100644 --- a/service/server/handlers_fragment.go +++ b/service/server/handlers_fragment.go @@ -1,28 +1,37 @@ package server import ( + "bytes" "fmt" "net/http" "net/url" "prism/service/notification" + "prism/service/util" ) func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) { mappings, err := s.store.GetAllMappings() if err != nil { - s.logger.Error("Failed to get mappings", "error", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) + util.LogAndError(w, s.logger, "Internal server error", http.StatusInternalServerError, err) return } w.Header().Set("Content-Type", "text/html") - _, _ = fmt.Fprint(w, s.getAppsListHTML(mappings)) //nolint:errcheck + + var buf bytes.Buffer + if err := s.fragmentTmpl.ExecuteTemplate(&buf, "app-list.html", s.buildAppListData(mappings)); err != nil { + s.logger.Error("Failed to execute template", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + _, _ = w.Write(buf.Bytes()) } -func (s *Server) getAppsListHTML(mappings []notification.Mapping) string { +func (s *Server) buildAppListData(mappings []notification.Mapping) []AppListItem { if len(mappings) == 0 { - return `

No apps registered yet. See real-world examples to get started.

` + return nil } signalLinked := false @@ -35,82 +44,82 @@ func (s *Server) getAppsListHTML(mappings []notification.Mapping) string { } telegramConfigured := s.cfg.IsTelegramEnabled() && s.cfg.TelegramChatID != 0 - var html string - html += `` - return html + return items +} + +func (s *Server) buildAppListItem(m notification.Mapping, signalLinked, telegramConfigured bool) AppListItem { + isSignal := m.Channel == notification.ChannelSignal + isWebPush := m.Channel == notification.ChannelWebPush + isTelegram := m.Channel == notification.ChannelTelegram + + item := AppListItem{ + AppName: m.AppName, + Channel: string(m.Channel), + } + + if isSignal && m.Signal != nil && m.Signal.GroupID != "" { + item.ChannelBadge = m.Channel.Label() + item.Tooltip = fmt.Sprintf("Group ID: %s", m.Signal.GroupID) + item.ChannelConfigured = true + } else if isWebPush && m.WebPush != nil && m.WebPush.Endpoint != "" { + item.ChannelBadge = m.Channel.Label() + item.Tooltip = m.WebPush.Endpoint + item.ChannelConfigured = true + if u, err := url.Parse(m.WebPush.Endpoint); err == nil { + item.Hostname = u.Hostname() + } + } else if isTelegram && telegramConfigured { + item.ChannelBadge = m.Channel.Label() + item.ChannelConfigured = true + } else { + item.ChannelBadge = "Not Configured" + item.ChannelConfigured = false + if isSignal { + item.Tooltip = "No Signal group created yet. Will auto-create on first notification." + } else if isTelegram { + item.Tooltip = "Set TELEGRAM_CHAT_ID in .env" + } else { + item.Tooltip = "No WebPush endpoint registered." + } + } + + item.ChannelOptions = s.buildChannelOptions(m, signalLinked, telegramConfigured) + return item +} + +func (s *Server) buildChannelOptions(m notification.Mapping, signalLinked, telegramConfigured bool) []SelectOption { + isSignal := m.Channel == notification.ChannelSignal + isWebPush := m.Channel == notification.ChannelWebPush + isTelegram := m.Channel == notification.ChannelTelegram + + var options []SelectOption + + if signalLinked { + options = append(options, SelectOption{ + Value: notification.ChannelSignal.String(), + Label: notification.ChannelSignal.Label(), + Selected: isSignal, + }) + } + if telegramConfigured { + options = append(options, SelectOption{ + Value: notification.ChannelTelegram.String(), + Label: notification.ChannelTelegram.Label(), + Selected: isTelegram, + }) + } + if m.WebPush != nil && m.WebPush.Endpoint != "" { + options = append(options, SelectOption{ + Value: notification.ChannelWebPush.String(), + Label: notification.ChannelWebPush.Label(), + Selected: isWebPush, + }) + } + + return options } diff --git a/service/server/server.go b/service/server/server.go index 46832d8..ab00392 100644 --- a/service/server/server.go +++ b/service/server/server.go @@ -2,8 +2,10 @@ package server import ( "context" + "embed" "fmt" "html/template" + "io/fs" "log/slog" "net/http" "os" @@ -12,6 +14,9 @@ import ( "prism/service/config" "prism/service/integration" + "prism/service/integration/proton" + "prism/service/integration/signal" + "prism/service/integration/telegram" "prism/service/notification" "prism/service/util" @@ -19,6 +24,9 @@ import ( "github.com/go-chi/chi/v5/middleware" ) +//go:embed templates/*.html +var fragmentTemplates embed.FS + type Server struct { cfg *config.Config store *notification.Store @@ -30,9 +38,11 @@ type Server struct { startTime time.Time version string indexTmpl *template.Template + fragmentTmpl *template.Template + publicAssets embed.FS } -func New(cfg *config.Config) (*Server, error) { +func New(cfg *config.Config, publicAssets embed.FS) (*Server, error) { logger := util.NewLogger(cfg.VerboseLogging) store, err := notification.NewStore(cfg.StoragePath) @@ -40,14 +50,33 @@ func New(cfg *config.Config) (*Server, error) { return nil, fmt.Errorf("failed to create store: %w", err) } - integrations := integration.Initialize(cfg, store, logger) + fragmentTmpl := template.New("") + fragmentTmpl, err = fragmentTmpl.ParseFS(fragmentTemplates, "templates/*.html") + if err != nil { + return nil, fmt.Errorf("failed to parse fragment templates: %w", err) + } + fragmentTmpl, err = fragmentTmpl.ParseFS(signal.GetTemplates(), "templates/*.html") + if err != nil { + return nil, fmt.Errorf("failed to parse signal templates: %w", err) + } + fragmentTmpl, err = fragmentTmpl.ParseFS(telegram.GetTemplates(), "templates/*.html") + if err != nil { + return nil, fmt.Errorf("failed to parse telegram templates: %w", err) + } + fragmentTmpl, err = fragmentTmpl.ParseFS(proton.GetTemplates(), "templates/*.html") + if err != nil { + return nil, fmt.Errorf("failed to parse proton templates: %w", err) + } + + templateRenderer := util.NewTemplateRenderer(fragmentTmpl) + integrations := integration.Initialize(cfg, store, logger, templateRenderer) version := "dev" if versionBytes, err := os.ReadFile("VERSION"); err == nil { version = strings.TrimSpace(string(versionBytes)) } - tmpl, err := template.ParseFiles("public/index.html") + tmpl, err := template.ParseFS(publicAssets, "public/index.html") if err != nil { return nil, fmt.Errorf("failed to parse index template: %w", err) } @@ -61,6 +90,8 @@ func New(cfg *config.Config) (*Server, error) { startTime: time.Now(), version: version, indexTmpl: tmpl, + fragmentTmpl: fragmentTmpl, + publicAssets: publicAssets, } s.setupRoutes() @@ -81,7 +112,13 @@ func (s *Server) setupRoutes() { r.Use(rateLimitMiddleware(s.cfg.RateLimit)) r.Get("/", s.handleIndex) - r.Handle("/*", http.StripPrefix("/", http.FileServer(http.Dir("./public")))) + + publicFS, err := fs.Sub(s.publicAssets, "public") + if err != nil { + s.logger.Error("Failed to create public assets sub-filesystem", "error", err) + } else { + r.Handle("/*", http.FileServer(http.FS(publicFS))) + } integration.RegisterAll(s.integrations, r, s.cfg, s.store, s.logger, authMiddleware) diff --git a/service/server/template_data.go b/service/server/template_data.go new file mode 100644 index 0000000..ae94e5f --- /dev/null +++ b/service/server/template_data.go @@ -0,0 +1,29 @@ +package server + +import "html/template" + +type AppListItem struct { + AppName string + Channel string + ChannelBadge string + ChannelConfigured bool + Tooltip string + Hostname string + ChannelOptions []SelectOption +} + +type SelectOption struct { + Value string + Label string + Selected bool +} + +type IntegrationData struct { + Name string + StatusClass string + StatusText string + StatusTooltip string + Content template.HTML + Open bool + PollAttrs string +} diff --git a/service/server/templates/app-list.html b/service/server/templates/app-list.html new file mode 100644 index 0000000..b54ace8 --- /dev/null +++ b/service/server/templates/app-list.html @@ -0,0 +1,42 @@ +{{if .}} + +{{else}} +

No apps registered yet. See real-world examples to get started.

+{{end}} diff --git a/service/server/templates/integration.html b/service/server/templates/integration.html new file mode 100644 index 0000000..cd5bf12 --- /dev/null +++ b/service/server/templates/integration.html @@ -0,0 +1,10 @@ +
+ + {{.Name}} + + {{.StatusText}} + {{if .StatusTooltip}}{{.StatusTooltip}}{{end}} + + +
{{.Content}}
+
diff --git a/service/util/http.go b/service/util/http.go new file mode 100644 index 0000000..a916d98 --- /dev/null +++ b/service/util/http.go @@ -0,0 +1,15 @@ +package util + +import ( + "log/slog" + "net/http" +) + +func LogAndError(w http.ResponseWriter, logger *slog.Logger, message string, code int, err error) { + if err != nil { + logger.Error(message, "error", err) + } else { + logger.Error(message) + } + http.Error(w, message, code) +} diff --git a/service/util/template.go b/service/util/template.go new file mode 100644 index 0000000..6190beb --- /dev/null +++ b/service/util/template.go @@ -0,0 +1,27 @@ +package util + +import ( + "bytes" + "html/template" +) + +type TemplateRenderer struct { + tmpl *template.Template +} + +func NewTemplateRenderer(tmpl *template.Template) *TemplateRenderer { + return &TemplateRenderer{tmpl: tmpl} +} + +func (tr *TemplateRenderer) Render(name string, data interface{}) (string, error) { + var buf bytes.Buffer + if err := tr.tmpl.ExecuteTemplate(&buf, name, data); err != nil { + return "", err + } + return buf.String(), nil +} + +func (tr *TemplateRenderer) RenderHTML(name string, data interface{}) (template.HTML, error) { + content, err := tr.Render(name, data) + return template.HTML(content), err +}