lock down alpine version, code clean ups, optimize release size with upx

This commit is contained in:
lone-cloud 2026-02-05 22:19:38 -08:00
parent ac40783aa7
commit fe11ed82af
34 changed files with 662 additions and 319 deletions

View file

@ -3,6 +3,10 @@ name: Release Signal CLI
on:
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
release:
runs-on: ubuntu-latest

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 := `<span class="integration-status disconnected">Unlinked</span>`
var content string
var openAttr string
var contentData ProtonContentData
var integData IntegrationData
integData.Name = "Proton Mail"
if h.monitor.IsConnected() {
statusBadge = fmt.Sprintf(`<span class="integration-status connected">Linked<span class="tooltip">%s</span></span>`, h.username)
content = `
<p><strong>Unlink Instructions:</strong></p>
<ol class="link-instructions">
<li>Run: <code>docker compose run protonmail-bridge init</code></li>
<li>In the bridge CLI, use: <code>logout</code></li>
<li>Then: <code>exit</code> to close the bridge</li>
</ol>
`
openAttr = ""
integData.StatusClass = "connected"
integData.StatusText = "Linked"
integData.StatusTooltip = h.username
integData.Open = false
contentData.Connected = true
} else {
content = `
<p><strong>Setup Instructions:</strong></p>
<ol class="link-instructions">
<li>Run: <code>docker compose run --rm protonmail-bridge init</code></li>
<li>At the prompt, use: <code>login</code> and enter your Proton Mail credentials</li>
<li>Run: <code>info</code> to get your IMAP username and password</li>
<li>Add credentials to <code>.env</code> and restart</li>
</ol>
<p class="text-muted">See <a href="https://github.com/lone-cloud/prism#proton-mail" target="_blank">full setup guide</a></p>
`
openAttr = " open"
integData.StatusClass = "disconnected"
integData.StatusText = "Unlinked"
integData.Open = true
contentData.Connected = false
}
html := fmt.Sprintf(`<details class="integration-card"%s>
<summary class="integration-header">
<span class="integration-name">Proton Mail</span>
%s
</summary>
<div class="integration-content">%s</div>
</details>`, 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 {

View file

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

View file

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

View file

@ -0,0 +1,17 @@
{{if .Connected}}
<p><strong>Unlink Instructions:</strong></p>
<ol class="link-instructions">
<li>Run: <code>docker compose run protonmail-bridge init</code></li>
<li>In the bridge CLI, use: <code>logout</code></li>
<li>Then: <code>exit</code> to close the bridge</li>
</ol>
{{else}}
<p><strong>Setup Instructions:</strong></p>
<ol class="link-instructions">
<li>Run: <code>docker compose run --rm protonmail-bridge init</code></li>
<li>At the prompt, use: <code>login</code> and enter your Proton Mail credentials</li>
<li>Run: <code>info</code> to get your IMAP username and password</li>
<li>Add credentials to <code>.env</code> and restart</li>
</ol>
<p class="text-muted">See <a href="https://github.com/lone-cloud/prism#proton-mail" target="_blank">full setup guide</a></p>
{{end}}

View file

@ -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(`<span class="integration-status connected">Linked<span class="tooltip">%s</span></span>`, FormatPhoneNumber(account.Number))
content = fmt.Sprintf(`
<p><strong>Unlink Instructions:</strong></p>
<ol class="link-instructions">
<li>Open Signal on your phone</li>
<li>Go to Settings Linked Devices</li>
<li>Find and remove <strong>%s</strong></li>
</ol>
`, 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 = `<span class="integration-status unlinked">Unlinked</span>`
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(`<p>Error generating QR code: %s</p>`, err)
contentData.Error = err.Error()
} else {
content = fmt.Sprintf(`
<p><strong>Link your Signal (or <a href="https://molly.im" target="_blank" rel="noopener">Molly</a>) account:</strong></p>
<ol class="link-instructions">
<li>Open Signal on your phone</li>
<li>Go to Settings Linked Devices</li>
<li>Scan the QR code below</li>
</ol>
<div class="qr-code-container">
<img src="%s" alt="Signal QR Code" class="qr-code"/>
</div>
`, qrCode)
contentData.QRCode = qrCode
}
openAttr = " open"
pollAttrs = ` hx-get="/fragment/signal" hx-trigger="every 3s" hx-swap="outerHTML"`
}
html := fmt.Sprintf(`<details class="integration-card"%s%s>
<summary class="integration-header">
<span class="integration-name">Signal</span>
%s
</summary>
<div class="integration-content">%s</div>
</details>`, 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 {

View file

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

View file

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

View file

@ -0,0 +1,22 @@
{{if .Linked}}
<p><strong>Unlink Instructions:</strong></p>
<ol class="link-instructions">
<li>Open Signal on your phone</li>
<li>Go to Settings → Linked Devices</li>
<li>Find and remove <strong>{{.DeviceName}}</strong></li>
</ol>
{{else}}
{{if .Error}}
<p>Error generating QR code: {{.Error}}</p>
{{else}}
<p><strong>Link your Signal (or <a href="https://molly.im" target="_blank" rel="noopener">Molly</a>) account:</strong></p>
<ol class="link-instructions">
<li>Open Signal on your phone</li>
<li>Go to Settings → Linked Devices</li>
<li>Scan the QR code below</li>
</ol>
<div class="qr-code-container">
<img src="{{.QRCode}}" alt="Signal QR Code" class="qr-code"/>
</div>
{{end}}
{{end}}

View file

@ -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 = `<span class="integration-status disconnected">Not Configured</span>`
content = `
<p><strong>Setup Instructions:</strong></p>
<ol class="link-instructions">
<li>Message <a href="https://t.me/BotFather" target="_blank">@BotFather</a> on Telegram</li>
<li>Send <code>/newbot</code> and follow the prompts</li>
<li>Copy the bot token and add to <code>.env</code>: <code>TELEGRAM_BOT_TOKEN=your-token</code></li>
<li>Message <a href="https://t.me/userinfobot" target="_blank">@userinfobot</a> to get your Chat ID</li>
<li>Add to <code>.env</code>: <code>TELEGRAM_CHAT_ID=your-chat-id</code></li>
<li>Restart Prism</li>
</ol>
<p class="text-muted">See <a href="https://github.com/lone-cloud/prism#telegram" target="_blank">full setup guide</a></p>
`
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 = `<span class="integration-status disconnected">Error</span>`
content = fmt.Sprintf(`<p>Error: %s</p>`, err)
openAttr = " open"
integData.StatusClass = "disconnected"
integData.StatusText = "Error"
integData.Open = true
contentData.Error = err.Error()
} else if h.chatID == 0 {
statusBadge = fmt.Sprintf(`<span class="integration-status disconnected">Needs Chat ID<span class="tooltip">@%s</span></span>`, bot.Username)
content = `
<p><strong>Complete Setup:</strong></p>
<ol class="link-instructions">
<li>Message <a href="https://t.me/userinfobot" target="_blank">@userinfobot</a> on Telegram to get your Chat ID</li>
<li>Add to <code>.env</code>: <code>TELEGRAM_CHAT_ID=your-chat-id</code></li>
<li>Restart Prism</li>
</ol>
`
openAttr = " open"
integData.StatusClass = "disconnected"
integData.StatusText = "Needs Chat ID"
integData.StatusTooltip = "@" + bot.Username
integData.Open = true
contentData.NeedsChatID = true
} else {
statusBadge = fmt.Sprintf(`<span class="integration-status connected">Linked<span class="tooltip">@%s</span></span>`, bot.Username)
content = `
<p><strong>Unlink Instructions:</strong></p>
<ol class="link-instructions">
<li>Remove <code>TELEGRAM_BOT_TOKEN</code> and <code>TELEGRAM_CHAT_ID</code> from <code>.env</code></li>
<li>Restart: <code>docker compose restart prism</code></li>
</ol>
`
openAttr = ""
integData.StatusClass = "connected"
integData.StatusText = "Linked"
integData.StatusTooltip = "@" + bot.Username
integData.Open = false
}
}
html := fmt.Sprintf(`<details class="integration-card"%s>
<summary class="integration-header">
<span class="integration-name">Telegram</span>
%s
</summary>
<div class="integration-content">%s</div>
</details>`, 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 {

View file

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

View file

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

View file

@ -37,7 +37,9 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica
message = fmt.Sprintf("<b>%s</b>\n%s", notif.Title, notif.Message)
}
if err := s.client.SendMessage(s.DefaultChatID, message); err != nil {
fullMessage := fmt.Sprintf("📱 <b>%s</b>\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
}

View file

@ -0,0 +1,27 @@
{{if .NotConfigured}}
<p><strong>Setup Instructions:</strong></p>
<ol class="link-instructions">
<li>Message <a href="https://t.me/BotFather" target="_blank">@BotFather</a> on Telegram</li>
<li>Send <code>/newbot</code> and follow the prompts</li>
<li>Copy the bot token and add to <code>.env</code>: <code>TELEGRAM_BOT_TOKEN=your-token</code></li>
<li>Message <a href="https://t.me/userinfobot" target="_blank">@userinfobot</a> to get your Chat ID</li>
<li>Add to <code>.env</code>: <code>TELEGRAM_CHAT_ID=your-chat-id</code></li>
<li>Restart Prism</li>
</ol>
<p class="text-muted">See <a href="https://github.com/lone-cloud/prism#telegram" target="_blank">full setup guide</a></p>
{{else if .Error}}
<p>Error: {{.Error}}</p>
{{else if .NeedsChatID}}
<p><strong>Complete Setup:</strong></p>
<ol class="link-instructions">
<li>Message <a href="https://t.me/userinfobot" target="_blank">@userinfobot</a> on Telegram to get your Chat ID</li>
<li>Add to <code>.env</code>: <code>TELEGRAM_CHAT_ID=your-chat-id</code></li>
<li>Restart Prism</li>
</ol>
{{else}}
<p><strong>Unlink Instructions:</strong></p>
<ol class="link-instructions">
<li>Remove <code>TELEGRAM_BOT_TOKEN</code> and <code>TELEGRAM_CHAT_ID</code> from <code>.env</code></li>
<li>Restart: <code>docker compose restart prism</code></li>
</ol>
{{end}}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
}
func (s *Server) getAppsListHTML(mappings []notification.Mapping) string {
_, _ = w.Write(buf.Bytes())
}
func (s *Server) buildAppListData(mappings []notification.Mapping) []AppListItem {
if len(mappings) == 0 {
return `<p>No apps registered yet. See <a href="https://github.com/lone-cloud/prism?tab=readme-ov-file#real-world-examples" target="_blank" rel="noopener noreferrer">real-world examples</a> to get started.</p>`
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 += `<ul class="app-list">`
items := make([]AppListItem, 0, len(mappings))
for _, m := range mappings {
item := s.buildAppListItem(m, signalLinked, telegramConfigured)
items = append(items, item)
}
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
channelBadge := ""
channelTooltip := ""
item := AppListItem{
AppName: m.AppName,
Channel: string(m.Channel),
}
if isSignal && m.Signal != nil && m.Signal.GroupID != "" {
channelBadge = "Signal"
channelTooltip = fmt.Sprintf(`<span class="tooltip">Group ID: %s</span>`, 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 != "" {
channelBadge = "WebPush"
channelTooltip = fmt.Sprintf(`<span class="tooltip">%s</span>`, m.WebPush.Endpoint)
} else if isTelegram && telegramConfigured {
channelBadge = "Telegram"
} else {
channelBadge = "Not Configured"
if isSignal {
channelTooltip = `<span class="tooltip">No Signal group created yet. Will auto-create on first notification.</span>`
} else if isTelegram {
channelTooltip = `<span class="tooltip">Set TELEGRAM_CHAT_ID in .env</span>`
} else {
channelTooltip = `<span class="tooltip">No WebPush endpoint registered.</span>`
}
}
itemHTML := fmt.Sprintf(`<li class="app-item">
<div class="app-info">
<div class="app-name"><strong>%s</strong></div>
<div class="app-channel">`, m.AppName)
if channelBadge != "Not Configured" {
itemHTML += fmt.Sprintf(`<span class="channel-badge channel-%s">%s%s</span>`, m.Channel, channelBadge, channelTooltip)
} else {
itemHTML += fmt.Sprintf(`<span class="channel-badge" style="background: var(--error); color: var(--text-on-color);">%s%s</span>`, channelBadge, channelTooltip)
}
if m.WebPush != nil && isWebPush {
item.ChannelBadge = m.Channel.Label()
item.Tooltip = m.WebPush.Endpoint
item.ChannelConfigured = true
if u, err := url.Parse(m.WebPush.Endpoint); err == nil {
itemHTML += fmt.Sprintf(`<span class="app-detail">%s</span>`, u.Hostname())
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."
}
}
itemHTML += `</div></div><div class="app-actions">`
item.ChannelOptions = s.buildChannelOptions(m, signalLinked, telegramConfigured)
return item
}
var channelOptions string
optionCount := 0
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 {
channelOptions += fmt.Sprintf(`<option value="signal"%s>Signal</option>`, map[bool]string{true: " selected", false: ""}[isSignal])
optionCount++
options = append(options, SelectOption{
Value: notification.ChannelSignal.String(),
Label: notification.ChannelSignal.Label(),
Selected: isSignal,
})
}
if telegramConfigured {
channelOptions += fmt.Sprintf(`<option value="telegram"%s>Telegram</option>`, map[bool]string{true: " selected", false: ""}[isTelegram])
optionCount++
options = append(options, SelectOption{
Value: notification.ChannelTelegram.String(),
Label: notification.ChannelTelegram.Label(),
Selected: isTelegram,
})
}
if m.WebPush != nil && m.WebPush.Endpoint != "" {
channelOptions += fmt.Sprintf(`<option value="webpush"%s>WebPush</option>`, map[bool]string{true: " selected", false: ""}[isWebPush])
optionCount++
options = append(options, SelectOption{
Value: notification.ChannelWebPush.String(),
Label: notification.ChannelWebPush.Label(),
Selected: isWebPush,
})
}
if optionCount > 1 {
itemHTML += fmt.Sprintf(`<form style="display: inline;">
<input type="hidden" name="app" value="%s" />
<select class="channel-select" name="channel" hx-post="/action/toggle-channel" hx-target="#apps-list" hx-swap="innerHTML" hx-include="closest form">
%s
</select>
</form>`, m.AppName, channelOptions)
}
itemHTML += fmt.Sprintf(`<button class="btn-delete" hx-delete="/action/app/%s" hx-target="#apps-list" hx-swap="innerHTML">Delete</button></div></li>`, m.AppName)
html += itemHTML
}
html += `</ul>`
return html
return options
}

View file

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

View file

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

View file

@ -0,0 +1,42 @@
{{if .}}
<ul class="app-list">
{{range .}}
<li class="app-item">
<div class="app-info">
<div class="app-name"><strong>{{.AppName}}</strong></div>
<div class="app-channel">
{{if .ChannelConfigured}}
<span class="channel-badge channel-{{.Channel}}">
{{.ChannelBadge}}
{{if .Tooltip}}<span class="tooltip">{{.Tooltip}}</span>{{end}}
</span>
{{else}}
<span class="channel-badge channel-not-configured">
{{.ChannelBadge}}
{{if .Tooltip}}<span class="tooltip">{{.Tooltip}}</span>{{end}}
</span>
{{end}}
{{if .Hostname}}
<span class="app-detail">{{.Hostname}}</span>
{{end}}
</div>
</div>
<div class="app-actions">
{{if gt (len .ChannelOptions) 1}}
<form class="channel-form">
<input type="hidden" name="app" value="{{.AppName}}" />
<select class="channel-select" name="channel" hx-post="/action/toggle-channel" hx-target="#apps-list" hx-swap="innerHTML" hx-include="closest form">
{{range .ChannelOptions}}
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
{{end}}
</select>
</form>
{{end}}
<button class="btn-delete" hx-delete="/action/app/{{.AppName}}" hx-target="#apps-list" hx-swap="innerHTML">Delete</button>
</div>
</li>
{{end}}
</ul>
{{else}}
<p>No apps registered yet. See <a href="https://github.com/lone-cloud/prism?tab=readme-ov-file#real-world-examples" target="_blank" rel="noopener noreferrer">real-world examples</a> to get started.</p>
{{end}}

View file

@ -0,0 +1,10 @@
<details class="integration-card"{{if .Open}} open{{end}}{{if .PollAttrs}} {{.PollAttrs}}{{end}}>
<summary class="integration-header">
<span class="integration-name">{{.Name}}</span>
<span class="integration-status {{.StatusClass}}">
{{.StatusText}}
{{if .StatusTooltip}}<span class="tooltip">{{.StatusTooltip}}</span>{{end}}
</span>
</summary>
<div class="integration-content">{{.Content}}</div>
</details>

15
service/util/http.go Normal file
View file

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

27
service/util/template.go Normal file
View file

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