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:
-
- - Run:
docker compose run protonmail-bridge init
- - In the bridge CLI, use:
logout
- - Then:
exit to close the bridge
-
- `
- openAttr = ""
+ integData.StatusClass = "connected"
+ integData.StatusText = "Linked"
+ integData.StatusTooltip = h.username
+ integData.Open = false
+ contentData.Connected = true
} else {
- content = `
- Setup Instructions:
-
- - Run:
docker compose run --rm protonmail-bridge init
- - At the prompt, use:
login and enter your Proton Mail credentials
- - Run:
info to get your IMAP username and password
- - Add credentials to
.env and restart
-
- See full setup guide
- `
- openAttr = " open"
+ integData.StatusClass = "disconnected"
+ integData.StatusText = "Unlinked"
+ integData.Open = true
+ contentData.Connected = false
}
- html := fmt.Sprintf(`
-
- %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:
+
+ - Run:
docker compose run protonmail-bridge init
+ - In the bridge CLI, use:
logout
+ - Then:
exit to close the bridge
+
+{{else}}
+Setup Instructions:
+
+ - Run:
docker compose run --rm protonmail-bridge init
+ - At the prompt, use:
login and enter your Proton Mail credentials
+ - Run:
info to get your IMAP username and password
+ - Add credentials to
.env and restart
+
+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:
-
- - Open Signal on your phone
- - Go to Settings → Linked Devices
- - Find and remove %s
-
- `, 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:
-
- - Open Signal on your phone
- - Go to Settings → Linked Devices
- - Scan the QR code below
-
-
-

-
- `, qrCode)
+ contentData.QRCode = qrCode
}
- openAttr = " open"
- pollAttrs = ` hx-get="/fragment/signal" hx-trigger="every 3s" hx-swap="outerHTML"`
}
- html := fmt.Sprintf(`
-
- %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:
+
+ - Open Signal on your phone
+ - Go to Settings → Linked Devices
+ - Find and remove {{.DeviceName}}
+
+{{else}}
+ {{if .Error}}
+Error generating QR code: {{.Error}}
+ {{else}}
+Link your Signal (or Molly) account:
+
+ - Open Signal on your phone
+ - Go to Settings → Linked Devices
+ - Scan the QR code below
+
+
+

+
+ {{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:
-
- - Message @BotFather on Telegram
- - Send
/newbot and follow the prompts
- - Copy the bot token and add to
.env: TELEGRAM_BOT_TOKEN=your-token
- - Message @userinfobot to get your Chat ID
- - Add to
.env: TELEGRAM_CHAT_ID=your-chat-id
- - Restart Prism
-
- 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:
-
- - Message @userinfobot on Telegram to get your Chat ID
- - Add to
.env: TELEGRAM_CHAT_ID=your-chat-id
- - Restart Prism
-
- `
- 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:
-
- - Remove
TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID from .env
- - Restart:
docker compose restart prism
-
- `
- openAttr = ""
+ integData.StatusClass = "connected"
+ integData.StatusText = "Linked"
+ integData.StatusTooltip = "@" + bot.Username
+ integData.Open = false
}
}
- html := fmt.Sprintf(`
-
- %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:
+
+ - Message @BotFather on Telegram
+ - Send
/newbot and follow the prompts
+ - Copy the bot token and add to
.env: TELEGRAM_BOT_TOKEN=your-token
+ - Message @userinfobot to get your Chat ID
+ - Add to
.env: TELEGRAM_CHAT_ID=your-chat-id
+ - Restart Prism
+
+See full setup guide
+{{else if .Error}}
+Error: {{.Error}}
+{{else if .NeedsChatID}}
+Complete Setup:
+
+ - Message @userinfobot on Telegram to get your Chat ID
+ - Add to
.env: TELEGRAM_CHAT_ID=your-chat-id
+ - Restart Prism
+
+{{else}}
+Unlink Instructions:
+
+ - Remove
TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID from .env
+ - Restart:
docker compose restart prism
+
+{{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 += ``
+ items := make([]AppListItem, 0, len(mappings))
for _, m := range mappings {
- isSignal := m.Channel == notification.ChannelSignal
- isWebPush := m.Channel == notification.ChannelWebPush
- isTelegram := m.Channel == notification.ChannelTelegram
- channelBadge := ""
- channelTooltip := ""
-
- if isSignal && m.Signal != nil && m.Signal.GroupID != "" {
- channelBadge = "Signal"
- channelTooltip = fmt.Sprintf(`Group ID: %s`, m.Signal.GroupID)
- } else if isWebPush && m.WebPush != nil && m.WebPush.Endpoint != "" {
- channelBadge = "WebPush"
- channelTooltip = fmt.Sprintf(`%s`, m.WebPush.Endpoint)
- } else if isTelegram && telegramConfigured {
- channelBadge = "Telegram"
- } else {
- channelBadge = "Not Configured"
- if isSignal {
- channelTooltip = `No Signal group created yet. Will auto-create on first notification.`
- } else if isTelegram {
- channelTooltip = `Set TELEGRAM_CHAT_ID in .env`
- } else {
- channelTooltip = `No WebPush endpoint registered.`
- }
- }
-
- itemHTML := fmt.Sprintf(`-
-
-
%s
-
`, m.AppName)
-
- if channelBadge != "Not Configured" {
- itemHTML += fmt.Sprintf(`%s%s`, m.Channel, channelBadge, channelTooltip)
- } else {
- itemHTML += fmt.Sprintf(`%s%s`, channelBadge, channelTooltip)
- }
-
- if m.WebPush != nil && isWebPush {
- if u, err := url.Parse(m.WebPush.Endpoint); err == nil {
- itemHTML += fmt.Sprintf(`%s`, u.Hostname())
- }
- }
-
- itemHTML += `
`
-
- var channelOptions string
- optionCount := 0
-
- if signalLinked {
- channelOptions += fmt.Sprintf(``, map[bool]string{true: " selected", false: ""}[isSignal])
- optionCount++
- }
- if telegramConfigured {
- channelOptions += fmt.Sprintf(``, map[bool]string{true: " selected", false: ""}[isTelegram])
- optionCount++
- }
- if m.WebPush != nil && m.WebPush.Endpoint != "" {
- channelOptions += fmt.Sprintf(``, map[bool]string{true: " selected", false: ""}[isWebPush])
- optionCount++
- }
-
- if optionCount > 1 {
- itemHTML += fmt.Sprintf(``, m.AppName, channelOptions)
- }
-
- itemHTML += fmt.Sprintf(`
`, m.AppName)
-
- html += itemHTML
+ item := s.buildAppListItem(m, signalLinked, telegramConfigured)
+ items = append(items, item)
}
- 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 @@
+
+
+ {{.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
+}