mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 08:43:10 -07:00
lock down alpine version, code clean ups, optimize release size with upx
This commit is contained in:
parent
ac40783aa7
commit
fe11ed82af
34 changed files with 662 additions and 319 deletions
4
.github/workflows/release-signal-cli.yml
vendored
4
.github/workflows/release-signal-cli.yml
vendored
|
|
@ -3,6 +3,10 @@ name: Release Signal CLI
|
|||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
56
.github/workflows/release.yml
vendored
56
.github/workflows/release.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
10
Dockerfile
10
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 && \
|
||||
|
|
|
|||
|
|
@ -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 && \
|
||||
|
|
|
|||
2
Makefile
2
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
6
main.go
6
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
17
service/integration/proton/templates/proton-content.html
Normal file
17
service/integration/proton/templates/proton-content.html
Normal 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}}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
22
service/integration/signal/templates/signal-content.html
Normal file
22
service/integration/signal/templates/signal-content.html
Normal 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}}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
27
service/integration/telegram/templates/telegram-content.html
Normal file
27
service/integration/telegram/templates/telegram-content.html
Normal 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}}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 `<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 {
|
||||
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(`<span class="tooltip">Group ID: %s</span>`, m.Signal.GroupID)
|
||||
} 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 {
|
||||
if u, err := url.Parse(m.WebPush.Endpoint); err == nil {
|
||||
itemHTML += fmt.Sprintf(`<span class="app-detail">%s</span>`, u.Hostname())
|
||||
}
|
||||
}
|
||||
|
||||
itemHTML += `</div></div><div class="app-actions">`
|
||||
|
||||
var channelOptions string
|
||||
optionCount := 0
|
||||
|
||||
if signalLinked {
|
||||
channelOptions += fmt.Sprintf(`<option value="signal"%s>Signal</option>`, map[bool]string{true: " selected", false: ""}[isSignal])
|
||||
optionCount++
|
||||
}
|
||||
if telegramConfigured {
|
||||
channelOptions += fmt.Sprintf(`<option value="telegram"%s>Telegram</option>`, map[bool]string{true: " selected", false: ""}[isTelegram])
|
||||
optionCount++
|
||||
}
|
||||
if m.WebPush != nil && m.WebPush.Endpoint != "" {
|
||||
channelOptions += fmt.Sprintf(`<option value="webpush"%s>WebPush</option>`, map[bool]string{true: " selected", false: ""}[isWebPush])
|
||||
optionCount++
|
||||
}
|
||||
|
||||
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
|
||||
item := s.buildAppListItem(m, signalLinked, telegramConfigured)
|
||||
items = append(items, item)
|
||||
}
|
||||
html += `</ul>`
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
29
service/server/template_data.go
Normal file
29
service/server/template_data.go
Normal 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
|
||||
}
|
||||
42
service/server/templates/app-list.html
Normal file
42
service/server/templates/app-list.html
Normal 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}}
|
||||
10
service/server/templates/integration.html
Normal file
10
service/server/templates/integration.html
Normal 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
15
service/util/http.go
Normal 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
27
service/util/template.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue