clean up unused admin routes, expose existing apps via new endpoint, update schema to allow an app to have many subscription channels

This commit is contained in:
Egor 2026-02-15 21:19:40 -08:00
parent fdb2de1b0f
commit 6fb5babf87
29 changed files with 1242 additions and 787 deletions

View file

@ -173,15 +173,11 @@ prism_api_key: "Bearer YOUR_API_KEY_HERE"
Reboot your Home Assistant system and you'll then be able to send Signal notifications to yourself by using this notify prism action.
## API Reference
## Sending Notifications
### Send Notification
Send notifications via HTTP POST to `/{appName}`:
#### POST /{appName}
Send a notification to a specific app. Messages are routed based on your app configuration in the web UI.
JSON format:
**JSON format:**
```bash
curl -X POST http://localhost:8080/my-app \
@ -190,7 +186,7 @@ curl -X POST http://localhost:8080/my-app \
-d '{"title": "Alert", "message": "Something happened"}'
```
Plain text (ntfy-compatible):
**Plain text (ntfy-compatible):**
```bash
curl -X POST http://localhost:8080/my-app \
@ -198,52 +194,7 @@ curl -X POST http://localhost:8080/my-app \
-d "Simple message text"
```
**App Routing:**
- Configure which integration(s) receive messages from each app via the web UI
- Apps can route to Signal, Telegram, WebPush, or multiple destinations
- Special apps like "Proton Mail" are created automatically
### WebPush/Webhook Management
#### POST /api/v1/webpush/app
Register or update a WebPush subscription or plain webhook.
Encrypted WebPush (all crypto fields required):
```bash
curl -X POST http://localhost:8080/api/v1/webpush/app \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"appName": "my-app",
"pushEndpoint": "https://updates.push.services.mozilla.org/...",
"p256dh": "base64-encoded-key",
"auth": "base64-encoded-auth",
"vapidPrivateKey": "base64-encoded-vapid-key"
}'
```
Plain HTTP webhook (no encryption):
```bash
curl -X POST http://localhost:8080/api/v1/webpush/app \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"appName": "my-app",
"pushEndpoint": "https://your-server.com/webhook"
}'
```
#### DELETE /api/v1/webpush/app/{appName}
Unregister a WebPush subscription (clears WebPush settings, reverts to Signal).
```bash
curl -X DELETE http://localhost:8080/api/v1/webpush/app/my-app \
-H "Authorization: Bearer YOUR_API_KEY"
```
Messages are routed based on your app's subscriptions configured in the web UI. Apps can have multiple subscriptions (Signal + WebPush + Telegram) and will receive notifications on all configured channels.
## Monitoring

View file

@ -1 +1 @@
0.3.0
0.4.0

View file

@ -148,7 +148,6 @@ details[open] > .card-header::before {
}
/* Tooltips */
.channel-badge:has(.tooltip),
.integration-status:has(.tooltip) {
cursor: help;
}
@ -172,7 +171,7 @@ details[open] > .card-header::before {
transition: opacity 0.2s;
pointer-events: none;
z-index: 10;
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.2);
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.3);
}
.tooltip::after {
@ -232,18 +231,11 @@ details[open] > .card-header::before {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: 0.5rem;
}
.app-name {
font-size: 1.1em;
}
.app-channel {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9em;
font-size: 1.2em;
}
.channel-badge {
@ -252,26 +244,69 @@ details[open] > .card-header::before {
font-size: 0.85em;
font-weight: 500;
position: relative;
border: none;
background: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
min-width: 5rem;
}
.channel-signal {
.badge-active {
cursor: pointer;
transition: filter 0.2s ease;
}
.badge-active:hover {
filter: brightness(0.9);
}
.badge-active.htmx-request {
opacity: 0.6;
cursor: wait;
pointer-events: none;
}
.badge-inactive {
cursor: pointer;
border: 0.0625rem dashed currentColor;
transition: filter 0.2s ease;
}
.badge-inactive:hover {
filter: brightness(1.2);
}
.badge-inactive.htmx-request {
opacity: 0.6;
cursor: wait;
pointer-events: none;
}
.channel-signal.badge-active {
background: var(--accent);
color: var(--text-on-color);
}
.channel-webpush {
.channel-signal.badge-inactive {
color: var(--accent);
background: transparent;
}
.channel-webpush.badge-active {
background: var(--success);
color: var(--text-on-color);
}
.channel-telegram {
background: #0088cc;
.channel-telegram.badge-active {
background: var(--accent);
color: var(--text-on-color);
}
.app-detail {
color: var(--text-secondary);
font-size: 0.85em;
.channel-telegram.badge-inactive {
color: var(--accent);
background: transparent;
}
.app-actions {
@ -280,20 +315,6 @@ details[open] > .card-header::before {
align-items: center;
}
.channel-form {
display: inline;
}
.channel-select {
padding: 0.375rem 0.5rem;
background: var(--bg-secondary);
color: var(--text-primary);
border: 0.0625rem solid var(--border-color);
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.9em;
}
.btn-delete {
padding: 0.375rem 0.75rem;
background: var(--error);
@ -309,6 +330,23 @@ details[open] > .card-header::before {
filter: brightness(0.85);
}
.btn-delete-sub {
background: none;
border: none;
color: inherit;
font-size: 1.1em;
font-weight: bold;
cursor: pointer;
padding: 0 0.25rem;
margin-left: 0.25rem;
opacity: 0.7;
transition: opacity 0.2s ease;
}
.btn-delete-sub:hover {
opacity: 1;
}
/* Loading Spinner */
.loading {
color: var(--text-secondary);
@ -401,8 +439,9 @@ details[open] > .integration-header::before {
}
.integration-status.unlinked {
background: var(--text-secondary);
color: var(--text-on-color);
background: var(--bg-secondary);
color: var(--text-primary);
border: 0.0625rem solid var(--border-color);
}
.link-instructions {
@ -477,8 +516,9 @@ details[open] > .integration-header::before {
}
.btn-secondary {
background: var(--text-secondary);
color: var(--text-on-color);
background: var(--bg-secondary);
color: var(--text-primary);
border: 0.0625rem solid var(--border-color);
}
.btn-danger {
@ -533,3 +573,73 @@ details[open] > .integration-header::before {
height: auto;
display: block;
}
/* Toast Notifications */
#toast-container {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 0.5rem;
pointer-events: none;
}
.toast {
background: var(--bg-secondary);
color: var(--text-primary);
padding: 0.875rem 1.25rem;
border-radius: 0.375rem;
box-shadow:
0 0.25rem 0.5rem rgba(0, 0, 0, 0.1),
0 0 0 0.0625rem var(--border-color);
min-width: 15rem;
max-width: 25rem;
pointer-events: auto;
animation: toastSlideIn 0.3s ease;
display: flex;
align-items: center;
gap: 0.75rem;
}
.toast.success {
background: var(--success);
color: var(--text-on-color);
}
.toast.error {
background: var(--error);
color: var(--text-on-color);
}
.toast.info {
background: var(--accent);
color: var(--text-on-color);
}
.toast.hiding {
animation: toastSlideOut 0.3s ease forwards;
}
@keyframes toastSlideIn {
from {
transform: translateX(calc(100% + 1.5rem));
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes toastSlideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(calc(100% + 1.5rem));
opacity: 0;
}
}

View file

@ -10,12 +10,13 @@
<link rel="stylesheet" href="/index.css?v={{.Version}}">
<script src="/htmx.min.js"></script>
<script src="/theme.js?v={{.Version}}"></script>
<script src="/toast.js?v={{.Version}}"></script>
<script src="/integration.js?v={{.Version}}"></script>
</head>
<body>
<details class="card" open>
<summary class="card-header">
<span class="integration-name">Registered Applications</span>
<span class="integration-name">Registered Apps</span>
</summary>
<div id="apps-list" class="card-content"
hx-get="/fragment/apps"
@ -28,9 +29,11 @@
<div id="integrations"
hx-get="/fragment/integrations"
hx-trigger="load">
hx-trigger="load, reload">
</div>
<button id="theme-toggle" class="theme-toggle"></button>
<div id="toast-container"></div>
<button type="button" id="theme-toggle" class="theme-toggle"></button>
</body>
</html>

View file

@ -17,10 +17,17 @@ document.addEventListener('DOMContentLoaded', () => {
if (action === 'link-signal') linkSignal(btn);
else if (action === 'delete-telegram') deleteTelegram(btn);
else if (action === 'delete-proton') deleteProton(btn);
else if (action === 'reload') location.reload();
else if (action === 'reload') reloadIntegrations();
});
});
function reloadIntegrations() {
const integrations = document.getElementById('integrations');
if (integrations) {
htmx.trigger(integrations, 'reload');
}
}
async function handleAuthForm(form, endpoint, statusId, getPayload) {
const status = document.getElementById(statusId);
const btn = form.querySelector('button[type="submit"]');
@ -41,7 +48,8 @@ async function handleAuthForm(form, endpoint, statusId, getPayload) {
});
if (response.ok) {
location.reload();
checkToast(response);
reloadIntegrations();
} else {
const error = await response.json();
showError(error.error || 'Failed to save');
@ -51,10 +59,24 @@ async function handleAuthForm(form, endpoint, statusId, getPayload) {
}
}
function checkToast(response) {
const trigger = response.headers.get('HX-Trigger');
if (trigger) {
try {
const data = JSON.parse(trigger);
if (data.showToast && window.showToast) {
showToast(data.showToast.message, data.showToast.type);
}
} catch (_e) {
// Ignore
}
}
}
async function submitTelegramAuth(e) {
await handleAuthForm(
e.target,
'/api/v1/telegram/auth',
'/api/v1/telegram/link',
'telegram-auth-status',
(fd) => ({
bot_token: fd.get('bot_token'),
@ -66,7 +88,7 @@ async function submitTelegramAuth(e) {
async function submitTelegramChatId(e) {
await handleAuthForm(
e.target,
'/api/v1/telegram/auth',
'/api/v1/telegram/link',
'telegram-chatid-status',
(fd, form) => ({
bot_token: form.dataset.botToken,
@ -120,7 +142,11 @@ async function linkSignal(btn) {
qrCode.src = qrUrl;
qrContainer.style.display = 'block';
let statusCheckInProgress = false;
signalLinkingPoll = setInterval(async () => {
if (statusCheckInProgress) return;
statusCheckInProgress = true;
try {
const statusResp = await fetch('/api/v1/signal/status');
const statusData = await statusResp.json();
@ -129,10 +155,13 @@ async function linkSignal(btn) {
clearInterval(signalLinkingPoll);
qrContainer.innerHTML =
'<p class="auth-status success">Linked! Refreshing...</p>';
setTimeout(() => location.reload(), 1000);
showToast('Signal linked', 'success');
setTimeout(() => reloadIntegrations(), 1000);
}
} catch (err) {
console.error('Status check failed:', err);
} finally {
statusCheckInProgress = false;
}
}, 2000);
} catch (err) {
@ -146,10 +175,11 @@ async function deleteTelegram(btn) {
btn.disabled = true;
try {
const response = await fetch('/api/v1/telegram/auth', { method: 'DELETE' });
const response = await fetch('/api/v1/telegram/link', { method: 'DELETE' });
if (response.ok) {
location.reload();
checkToast(response);
reloadIntegrations();
} else {
const error = await response.json();
alert(`Error: ${error.error || 'Failed to unlink integration'}`);
@ -170,7 +200,8 @@ async function deleteProton(btn) {
const response = await fetch('/api/v1/proton/auth', { method: 'DELETE' });
if (response.ok) {
location.reload();
checkToast(response);
reloadIntegrations();
} else {
const error = await response.json();
alert(`Error: ${error.error || 'Failed to unlink integration'}`);

33
public/toast.js Normal file
View file

@ -0,0 +1,33 @@
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('hiding');
setTimeout(() => toast.remove(), 300);
}, 3000);
}
document.addEventListener('DOMContentLoaded', () => {
document.body.addEventListener('htmx:afterRequest', (event) => {
const xhr = event.detail.xhr;
const triggerHeader = xhr.getResponseHeader('HX-Trigger');
if (triggerHeader) {
try {
const trigger = JSON.parse(triggerHeader);
if (trigger.showToast) {
showToast(trigger.showToast.message, trigger.showToast.type);
}
} catch (_e) {
// Not JSON, ignore
}
}
});
});

View file

@ -109,12 +109,20 @@ func (m *Monitor) sendNotification(msg *protonmail.Message) {
}
func (m *Monitor) clearNotification(msgID string) {
mapping, err := m.dispatcher.GetStore().GetApp(prismTopic)
if err != nil || mapping == nil {
app, err := m.dispatcher.GetStore().GetApp(prismTopic)
if err != nil || app == nil {
return
}
if mapping.Channel != notification.ChannelWebPush {
hasWebPush := false
for _, sub := range app.Subscriptions {
if sub.Channel == notification.ChannelWebPush {
hasWebPush = true
break
}
}
if !hasWebPush {
return
}

View file

@ -116,10 +116,10 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
}
if h.dispatcher != nil {
if err := h.dispatcher.RegisterApp(prismTopic); err != nil {
h.logger.Warn("Failed to auto-create Proton Mail app mapping", "error", err)
if _, err := h.dispatcher.CreateAppWithDefaultSubscription(prismTopic); err != nil {
h.logger.Warn("Failed to auto-create Proton Mail app with subscription", "error", err)
} else {
h.logger.Info("Created Proton Mail app mapping")
h.logger.Info("Created Proton Mail app with default subscription")
}
}
@ -136,6 +136,7 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
}
}
util.SetToast(w, "Proton Mail linked", "success")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
@ -154,6 +155,7 @@ func (h *authHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
return
}
util.SetToast(w, "Proton Mail unlinked", "success")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
}

View file

@ -63,7 +63,7 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
} else if account == nil {
integData.StatusClass = "disconnected"
integData.StatusText = "Unlinked"
integData.StatusTooltip = "Click to link device with Signal"
integData.StatusTooltip = "Click Link button below to link"
integData.Open = true
} else {
integData.StatusClass = "connected"

View file

@ -24,7 +24,10 @@ type Integration struct {
func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration {
client := NewClient()
sender := NewSender(client, store, logger)
var sender *Sender
if client.IsEnabled() {
sender = NewSender(client, store, logger)
}
return &Integration{
cfg: cfg,
client: client,

View file

@ -22,11 +22,44 @@ func NewSender(client *Client, store *notification.Store, logger *slog.Logger) *
}
}
func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error {
func (s *Sender) IsLinked() (bool, error) {
if s.client == nil {
return false, nil
}
account, err := s.client.GetLinkedAccount()
if err != nil {
return false, err
}
return account != nil, nil
}
func (s *Sender) CreateDefaultSignalSubscription(appName string) (*notification.SignalSubscription, error) {
if s.client == nil {
return nil, fmt.Errorf("signal integration not enabled")
}
groupID, account, err := s.client.CreateGroup(appName)
if err != nil {
return nil, err
}
return &notification.SignalSubscription{
GroupID: groupID,
Account: account,
}, nil
}
func (s *Sender) Send(sub *notification.Subscription, notif notification.Notification) error {
if s.client == nil {
return notification.NewPermanentError(fmt.Errorf("signal integration not enabled"))
}
if sub.Signal == nil {
return notification.NewPermanentError(fmt.Errorf("no signal subscription data"))
}
account, err := s.client.GetLinkedAccount()
if err != nil {
return util.LogError(s.logger, "Failed to get linked account", err)
@ -37,32 +70,21 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica
}
var signalGroupID string
needsNewGroup := mapping.Signal == nil || mapping.Signal.GroupID == ""
needsNewGroup := sub.Signal.GroupID == ""
if !needsNewGroup && mapping.Signal.Account != account.Number {
if !needsNewGroup && sub.Signal.Account != account.Number {
s.logger.Info("Signal account changed, recreating group",
"app", mapping.AppName,
"oldAccount", mapping.Signal.Account,
"app", sub.AppName,
"oldAccount", sub.Signal.Account,
"newAccount", account.Number)
needsNewGroup = true
}
if needsNewGroup {
newGroupID, accountNumber, err := s.client.CreateGroup(mapping.AppName)
if err != nil {
return util.LogError(s.logger, "Failed to create group", err, "app", mapping.AppName)
return notification.NewPermanentError(fmt.Errorf("signal group not configured for subscription %s", sub.ID))
}
signalGroupID = newGroupID
if err := s.store.UpdateSignal(mapping.AppName, &notification.SignalSubscription{
GroupID: signalGroupID,
Account: accountNumber,
}); err != nil {
return util.LogError(s.logger, "Failed to persist signal group", err)
}
} else {
signalGroupID = mapping.Signal.GroupID
}
signalGroupID = sub.Signal.GroupID
message := notif.Message
if notif.Title != "" {

View file

@ -20,19 +20,19 @@ func GetTemplates() embed.FS {
return templates
}
type authHandler struct {
type linkHandler struct {
db *sql.DB
apiKey string
logger *slog.Logger
}
type telegramAuthRequest struct {
type telegramLinkRequest struct {
BotToken string `json:"bot_token"`
ChatID string `json:"chat_id"`
}
func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
var req telegramAuthRequest
func (h *linkHandler) handleLink(w http.ResponseWriter, r *http.Request) {
var req telegramLinkRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
util.JSONError(w, "Invalid request body", http.StatusBadRequest)
return
@ -59,11 +59,12 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
return
}
util.SetToast(w, "Telegram linked", "success")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
}
func (h *authHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
func (h *linkHandler) handleUnlink(w http.ResponseWriter, r *http.Request) {
credStore, err := credentials.NewStore(h.db, h.apiKey)
if err != nil {
util.LogAndError(w, h.logger, "Failed to initialize credentials store", http.StatusInternalServerError, err)
@ -75,6 +76,7 @@ func (h *authHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
return
}
util.SetToast(w, "Telegram unlinked", "success")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
}
@ -86,9 +88,9 @@ func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler)
handlers.SetDB(db, apiKey)
authH := &authHandler{db: db, apiKey: apiKey, logger: logger}
linkH := &linkHandler{db: db, apiKey: apiKey, logger: logger}
router.With(auth).Get("/fragment/telegram", handlers.HandleFragment)
router.With(auth).Post("/api/v1/telegram/auth", authH.handleAuth)
router.With(auth).Delete("/api/v1/telegram/auth", authH.handleDelete)
router.With(auth).Post("/api/v1/telegram/link", linkH.handleLink)
router.With(auth).Delete("/api/v1/telegram/link", linkH.handleUnlink)
}

View file

@ -3,10 +3,15 @@ package telegram
import (
"fmt"
"log/slog"
"strconv"
"prism/service/notification"
)
func parseInt64(s string) (int64, error) {
return strconv.ParseInt(s, 10, 64)
}
type Sender struct {
client *Client
store *notification.Store
@ -23,13 +28,33 @@ func NewSender(client *Client, store *notification.Store, logger *slog.Logger, d
}
}
func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error {
func (s *Sender) GetChatID() int64 {
return s.DefaultChatID
}
func (s *Sender) IsLinked() (bool, error) {
if s.client == nil {
return false, nil
}
if !s.client.IsAvailable() {
return false, nil
}
if s.DefaultChatID == 0 {
return false, nil
}
return true, nil
}
func (s *Sender) Send(sub *notification.Subscription, notif notification.Notification) error {
if s.client == nil {
return notification.NewPermanentError(fmt.Errorf("telegram integration not enabled"))
}
if s.DefaultChatID == 0 {
return notification.NewPermanentError(fmt.Errorf("no telegram chat configured (set TELEGRAM_CHAT_ID in .env)"))
if sub.Telegram == nil || sub.Telegram.ChatID == "" {
return notification.NewPermanentError(fmt.Errorf("no telegram chat configured for subscription"))
}
message := notif.Message
@ -37,10 +62,15 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica
message = fmt.Sprintf("<b>%s</b>\n%s", notif.Title, notif.Message)
}
fullMessage := fmt.Sprintf("<b>%s</b>\n\n%s", mapping.AppName, message)
fullMessage := fmt.Sprintf("<b>%s</b>\n\n%s", sub.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)
chatID, err := parseInt64(sub.Telegram.ChatID)
if err != nil {
return notification.NewPermanentError(fmt.Errorf("invalid chat ID: %w", err))
}
if err := s.client.SendMessage(chatID, fullMessage); err != nil {
s.logger.Error("Failed to send telegram message", "chatID", chatID, "error", err)
return err
}

View file

@ -1,6 +1,8 @@
package webpush
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"log/slog"
"net/http"
@ -12,6 +14,14 @@ import (
"github.com/go-chi/chi/v5"
)
func generateSubscriptionID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
type Handlers struct {
store *notification.Store
logger *slog.Logger
@ -49,7 +59,7 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
return
}
existing, err := h.store.GetApp(req.AppName)
subID, err := generateSubscriptionID()
if err != nil {
util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err)
return
@ -69,47 +79,47 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
}
}
if existing != nil {
if err := h.store.UpdateWebPush(req.AppName, webPush); err != nil {
util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err)
sub := notification.Subscription{
ID: subID,
AppName: req.AppName,
Channel: notification.ChannelWebPush,
WebPush: webPush,
}
if err := h.store.AddSubscription(sub); err != nil {
util.LogAndError(w, h.logger, "Failed to add subscription", http.StatusInternalServerError, err)
return
}
h.logger.Info("Updated webpush for existing endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint)
} else {
channel := notification.ChannelWebPush
if err := h.store.Register(req.AppName, &channel, nil, webPush); err != nil {
util.LogAndError(w, h.logger, "Failed to register webpush endpoint", http.StatusInternalServerError, err)
return
}
h.logger.Info("Registered new webpush endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint)
}
h.logger.Info("Added webpush subscription", "app", req.AppName, "subscriptionID", subID, "pushEndpoint", req.PushEndpoint)
w.Header().Set("Content-Type", "application/json")
response := map[string]string{
"appName": req.AppName,
"channel": notification.ChannelWebPush.String(),
"subscriptionId": subID,
}
_ = json.NewEncoder(w).Encode(response)
}
func (h *Handlers) HandleUnregister(w http.ResponseWriter, r *http.Request) {
appName := chi.URLParam(r, "appName")
if appName == "" {
http.Error(w, "appName is required", http.StatusBadRequest)
subscriptionID := chi.URLParam(r, "subscriptionId")
if subscriptionID == "" {
http.Error(w, "subscriptionId is required", http.StatusBadRequest)
return
}
if err := h.store.ClearWebPush(appName); err != nil {
util.LogAndError(w, h.logger, "Failed to clear webpush endpoint", http.StatusInternalServerError, err)
if err := h.store.DeleteSubscription(subscriptionID); err != nil {
util.LogAndError(w, h.logger, "Failed to delete subscription", http.StatusInternalServerError, err)
return
}
h.logger.Info("Cleared webpush subscription", "app", appName)
h.logger.Info("Deleted webpush subscription", "subscriptionID", subscriptionID)
w.Header().Set("Content-Type", "application/json")
response := map[string]string{
"status": "unregistered",
"appName": appName,
"status": "deleted",
"subscriptionId": subscriptionID,
}
_ = json.NewEncoder(w).Encode(response)
}

View file

@ -12,9 +12,9 @@ import (
func RegisterRoutes(router *chi.Mux, store *notification.Store, logger *slog.Logger, authMiddleware func(http.Handler) http.Handler) {
handlers := NewHandlers(store, logger)
router.Route("/api/v1/webpush/app", func(r chi.Router) {
router.Route("/api/v1/webpush/subscriptions", func(r chi.Router) {
r.Use(authMiddleware)
r.Post("/", handlers.HandleRegister)
r.Delete("/{appName}", handlers.HandleUnregister)
r.Delete("/{subscriptionId}", handlers.HandleUnregister)
})
}

View file

@ -22,9 +22,9 @@ func NewSender(logger *slog.Logger) *Sender {
}
}
func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error {
if mapping.WebPush == nil {
return notification.NewPermanentError(fmt.Errorf("no push endpoint configured for %s", mapping.AppName))
func (s *Sender) Send(sub *notification.Subscription, notif notification.Notification) error {
if sub.WebPush == nil {
return notification.NewPermanentError(fmt.Errorf("no push endpoint configured for subscription %s", sub.ID))
}
payload, err := json.Marshal(notif)
@ -32,17 +32,17 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica
return fmt.Errorf("failed to marshal notification: %w", err)
}
if mapping.WebPush.HasEncryption() {
if sub.WebPush.HasEncryption() {
subscription := &webpush.Subscription{
Endpoint: mapping.WebPush.Endpoint,
Endpoint: sub.WebPush.Endpoint,
Keys: webpush.Keys{
P256dh: mapping.WebPush.P256dh,
Auth: mapping.WebPush.Auth,
P256dh: sub.WebPush.P256dh,
Auth: sub.WebPush.Auth,
},
}
resp, err := webpush.SendNotification(payload, subscription, &webpush.Options{
VAPIDPrivateKey: mapping.WebPush.VapidPrivateKey,
VAPIDPrivateKey: sub.WebPush.VapidPrivateKey,
TTL: 86400,
})
if err != nil {
@ -54,9 +54,9 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica
return fmt.Errorf("webpush returned status %d", resp.StatusCode)
}
s.logger.Debug("Sent encrypted webpush notification", "app", mapping.AppName, "url", mapping.WebPush.Endpoint)
s.logger.Debug("Sent encrypted webpush notification", "app", sub.AppName, "url", sub.WebPush.Endpoint)
} else {
resp, err := http.Post(mapping.WebPush.Endpoint, "application/json", bytes.NewBuffer(payload))
resp, err := http.Post(sub.WebPush.Endpoint, "application/json", bytes.NewBuffer(payload))
if err != nil {
return fmt.Errorf("failed to send webhook: %w", err)
}
@ -66,7 +66,7 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
}
s.logger.Debug("Sent plain webhook notification", "app", mapping.AppName, "url", mapping.WebPush.Endpoint)
s.logger.Debug("Sent plain webhook notification", "app", sub.AppName, "url", sub.WebPush.Endpoint)
}
return nil

View file

@ -1,6 +1,8 @@
package notification
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log/slog"
"time"
@ -9,7 +11,19 @@ import (
)
type NotificationSender interface {
Send(mapping *Mapping, notif Notification) error
Send(sub *Subscription, notif Notification) error
}
type linkedChecker interface {
IsLinked() (bool, error)
}
type signalAutoConfigurer interface {
CreateDefaultSignalSubscription(appName string) (*SignalSubscription, error)
}
type telegramSender interface {
GetChatID() int64
}
type Dispatcher struct {
@ -60,54 +74,65 @@ func (d *Dispatcher) IsValidChannel(channel Channel) bool {
return channel.IsAvailable(d.HasSignal(), d.HasTelegram())
}
func (d *Dispatcher) RegisterApp(appName string) error {
availableChannels := d.GetAvailableChannels()
return d.store.RegisterDefault(appName, availableChannels)
}
func (d *Dispatcher) Send(appName string, notif Notification) error {
mapping, err := d.store.GetApp(appName)
app, err := d.store.GetApp(appName)
if err != nil {
return util.LogError(d.logger, "Failed to get app mapping", err, "app", appName)
return util.LogError(d.logger, "Failed to get app", err, "app", appName)
}
if mapping == nil {
d.logger.Info("Registering new app", "app", appName)
availableChannels := d.GetAvailableChannels()
if err := d.store.RegisterDefault(appName, availableChannels); err != nil {
return util.LogError(d.logger, "Failed to register app", err, "app", appName)
}
mapping, err = d.store.GetApp(appName)
if app == nil {
d.logger.Info("Registering new app and auto-configuring subscription", "app", appName)
app, err = d.CreateAppWithDefaultSubscription(appName)
if err != nil {
return util.LogError(d.logger, "Failed to get mapping after registration", err, "app", appName)
}
if mapping == nil {
return fmt.Errorf("mapping still nil after registration for app: %s", appName)
d.logger.Warn("Failed to auto-configure subscription", "app", appName, "error", err)
return nil
}
}
sender, ok := d.senders[mapping.Channel]
if len(app.Subscriptions) == 0 {
d.logger.Info("App has no subscriptions, attempting to auto-configure", "app", appName)
app, err = d.CreateAppWithDefaultSubscription(appName)
if err != nil {
d.logger.Warn("Failed to auto-configure subscription", "app", appName, "error", err)
return nil
}
}
var lastErr error
successCount := 0
for _, sub := range app.Subscriptions {
sender, ok := d.senders[sub.Channel]
if !ok {
d.logger.Error("No sender registered for channel", "channel", mapping.Channel)
return fmt.Errorf("no sender for channel: %s", mapping.Channel)
d.logger.Error("No sender for channel", "channel", sub.Channel, "subscriptionID", sub.ID)
lastErr = fmt.Errorf("no sender for channel: %s", sub.Channel)
continue
}
return d.sendWithRetry(sender, mapping, notif, appName)
if err := d.sendWithRetry(sender, &sub, notif, appName, sub.ID); err != nil {
lastErr = err
} else {
successCount++
}
}
if successCount == 0 && lastErr != nil {
return lastErr
}
return nil
}
func (d *Dispatcher) sendWithRetry(sender NotificationSender, mapping *Mapping, notif Notification, appName string) error {
func (d *Dispatcher) sendWithRetry(sender NotificationSender, sub *Subscription, notif Notification, appName, subscriptionID string) error {
maxRetries := 10
baseDelay := 500 * time.Millisecond
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
err := sender.Send(mapping, notif)
err := sender.Send(sub, notif)
if err == nil {
if attempt > 0 {
d.logger.Info("Notification sent after retry", "app", appName, "attempt", attempt+1)
d.logger.Info("Notification sent after retry", "app", appName, "subscriptionID", subscriptionID, "attempt", attempt+1)
}
return nil
}
@ -115,17 +140,141 @@ func (d *Dispatcher) sendWithRetry(sender NotificationSender, mapping *Mapping,
lastErr = err
if IsPermanent(err) {
d.logger.Error("Permanent error, not retrying", "app", appName, "error", err)
d.logger.Error("Permanent error, not retrying", "app", appName, "subscriptionID", subscriptionID, "error", err)
return err
}
if attempt < maxRetries-1 {
delay := baseDelay * time.Duration(1<<uint(attempt))
d.logger.Warn("Failed to send notification, retrying", "app", appName, "attempt", attempt+1, "error", err, "retryIn", delay)
d.logger.Warn("Failed to send notification, retrying", "app", appName, "subscriptionID", subscriptionID, "attempt", attempt+1, "error", err, "retryIn", delay)
time.Sleep(delay)
}
}
d.logger.Error("Failed to send notification after retries", "app", appName, "attempts", maxRetries, "error", lastErr)
d.logger.Error("Failed to send notification after retries", "app", appName, "subscriptionID", subscriptionID, "attempts", maxRetries, "error", lastErr)
return lastErr
}
func (d *Dispatcher) CreateAppWithDefaultSubscription(appName string) (*App, error) {
app, signalErr := d.trySignalAutoConfig(appName)
if signalErr == nil {
return app, nil
}
d.logger.Warn("Signal auto-config failed, trying Telegram", "app", appName, "error", signalErr)
app, telegramErr := d.tryTelegramAutoConfig(appName)
if telegramErr == nil {
return app, nil
}
return nil, fmt.Errorf("signal auto-config failed: %v; telegram unavailable: %w", signalErr, telegramErr)
}
func (d *Dispatcher) trySignalAutoConfig(appName string) (*App, error) {
sender, ok := d.senders[ChannelSignal]
if !ok {
return nil, fmt.Errorf("signal sender not found")
}
linkChecker, ok := sender.(linkedChecker)
if !ok {
return nil, fmt.Errorf("sender does not implement signal linked check")
}
linked, err := linkChecker.IsLinked()
if err != nil {
return nil, fmt.Errorf("failed to check Signal link status: %w", err)
}
if !linked {
return nil, fmt.Errorf("no linked Signal account")
}
autoConfigurer, ok := sender.(signalAutoConfigurer)
if !ok {
return nil, fmt.Errorf("sender does not implement signal auto-configuration")
}
signalSub, err := autoConfigurer.CreateDefaultSignalSubscription(appName)
if err != nil {
return nil, err
}
subID, err := GenerateSubscriptionID()
if err != nil {
return nil, err
}
sub := Subscription{ID: subID, AppName: appName, Channel: ChannelSignal, Signal: signalSub}
if err := d.store.AddSubscription(sub); err != nil {
return nil, err
}
d.logger.Info("Auto-configured Signal subscription", "app", appName, "subscriptionID", subID)
return d.store.GetApp(appName)
}
func (d *Dispatcher) tryTelegramAutoConfig(appName string) (*App, error) {
chatID, err := d.getTelegramChatID()
if err != nil || chatID == "" {
return nil, fmt.Errorf("telegram not linked or no chat ID configured: %w", err)
}
subID, err := GenerateSubscriptionID()
if err != nil {
return nil, err
}
sub := Subscription{
ID: subID,
AppName: appName,
Channel: ChannelTelegram,
Telegram: &TelegramSubscription{ChatID: chatID},
}
if err := d.store.AddSubscription(sub); err != nil {
return nil, err
}
d.logger.Info("Auto-configured Telegram subscription", "app", appName, "subscriptionID", subID, "chatID", chatID)
return d.store.GetApp(appName)
}
func (d *Dispatcher) getTelegramChatID() (string, error) {
sender, ok := d.senders[ChannelTelegram]
if !ok {
return "", fmt.Errorf("telegram sender not found")
}
linkChecker, ok := sender.(linkedChecker)
if !ok {
return "", fmt.Errorf("sender does not implement telegram linked check")
}
linked, err := linkChecker.IsLinked()
if err != nil {
return "", err
}
if !linked {
return "", fmt.Errorf("telegram not linked")
}
tgSender, ok := sender.(telegramSender)
if !ok {
return "", fmt.Errorf("sender does not implement telegram interface")
}
chatID := tgSender.GetChatID()
if chatID == 0 {
return "", fmt.Errorf("no chat ID configured")
}
return fmt.Sprintf("%d", chatID), nil
}
func GenerateSubscriptionID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}

View file

@ -77,10 +77,10 @@ func (c Channel) IsAvailable(signalEnabled bool, telegramEnabled bool) bool {
}
type WebPushSubscription struct {
Endpoint string
P256dh string
Auth string
VapidPrivateKey string
Endpoint string `json:"endpoint"`
P256dh string `json:"p256dh,omitempty"`
Auth string `json:"auth,omitempty"`
VapidPrivateKey string `json:"vapidPrivateKey,omitempty"`
}
func (w *WebPushSubscription) HasEncryption() bool {
@ -88,13 +88,24 @@ func (w *WebPushSubscription) HasEncryption() bool {
}
type SignalSubscription struct {
GroupID string
Account string
GroupID string `json:"groupId"`
Account string `json:"account"`
}
type Mapping struct {
Signal *SignalSubscription
WebPush *WebPushSubscription
AppName string
Channel Channel
type TelegramSubscription struct {
ChatID string `json:"chatId"`
}
type Subscription struct {
ID string `json:"id"`
AppName string `json:"appName"`
Channel Channel `json:"channel"`
Signal *SignalSubscription `json:"signal,omitempty"`
WebPush *WebPushSubscription `json:"webPush,omitempty"`
Telegram *TelegramSubscription `json:"telegram,omitempty"`
}
type App struct {
AppName string `json:"appName"`
Subscriptions []Subscription `json:"subscriptions"`
}

View file

@ -19,13 +19,13 @@ func NewStore(dbPath string) (*Store, error) {
return nil, fmt.Errorf("failed to create database directory: %w", err)
}
db, err := sql.Open("sqlite", dbPath+"?_busy_timeout=5000&cache=shared")
// foreign_keys(1): enable FK constraints (disabled by default in SQLite)
// _busy_timeout=5000: wait up to 5s when DB is locked (default=0, fails immediately)
db, err := sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)&_busy_timeout=5000")
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
db.SetMaxOpenConns(1)
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
@ -39,22 +39,38 @@ func NewStore(dbPath string) (*Store, error) {
}
func (s *Store) createTables() error {
query := `
CREATE TABLE IF NOT EXISTS mappings (
appName TEXT PRIMARY KEY,
queries := []string{
`CREATE TABLE IF NOT EXISTS apps (
appName TEXT PRIMARY KEY
)`,
`CREATE TABLE IF NOT EXISTS subscriptions (
id TEXT PRIMARY KEY,
appName TEXT NOT NULL,
channel TEXT NOT NULL,
signalGroupId TEXT,
signalAccount TEXT,
channel TEXT NOT NULL DEFAULT 'webpush',
telegramChatId TEXT,
pushEndpoint TEXT,
p256dh TEXT,
auth TEXT,
vapidPrivateKey TEXT
)
`
_, err := s.db.Exec(query)
if err != nil {
vapidPrivateKey TEXT,
FOREIGN KEY(appName) REFERENCES apps(appName) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS signal_groups (
appName TEXT PRIMARY KEY,
groupId TEXT NOT NULL,
account TEXT NOT NULL,
FOREIGN KEY(appName) REFERENCES apps(appName) ON DELETE CASCADE
)`,
`CREATE INDEX IF NOT EXISTS idx_subscriptions_appName ON subscriptions(appName)`,
}
for _, query := range queries {
if _, err := s.db.Exec(query); err != nil {
return fmt.Errorf("failed to create tables: %w", err)
}
}
return nil
}
@ -66,69 +82,189 @@ func (s *Store) GetDB() *sql.DB {
return s.db
}
func (s *Store) Register(appName string, channel *Channel, signal *SignalSubscription, webPush *WebPushSubscription) error {
var signalGroupID, signalAccount *string
if signal != nil {
signalGroupID = &signal.GroupID
signalAccount = &signal.Account
}
var pushEndpoint, p256dh, auth, vapidPrivateKey *string
if webPush != nil {
pushEndpoint = &webPush.Endpoint
p256dh = &webPush.P256dh
auth = &webPush.Auth
vapidPrivateKey = &webPush.VapidPrivateKey
}
ch := ChannelWebPush
if channel != nil {
ch = *channel
}
query := `
INSERT INTO mappings (appName, signalGroupId, signalAccount, channel, pushEndpoint, p256dh, auth, vapidPrivateKey)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(appName) DO UPDATE SET
channel = excluded.channel,
signalGroupId = COALESCE(excluded.signalGroupId, mappings.signalGroupId),
signalAccount = COALESCE(excluded.signalAccount, mappings.signalAccount),
pushEndpoint = COALESCE(excluded.pushEndpoint, mappings.pushEndpoint),
p256dh = COALESCE(excluded.p256dh, mappings.p256dh),
auth = COALESCE(excluded.auth, mappings.auth),
vapidPrivateKey = COALESCE(excluded.vapidPrivateKey, mappings.vapidPrivateKey)
`
_, err := s.db.Exec(query, appName, signalGroupID, signalAccount, ch, pushEndpoint, p256dh, auth, vapidPrivateKey)
func (s *Store) RegisterApp(appName string) error {
query := `INSERT INTO apps (appName) VALUES (?) ON CONFLICT(appName) DO NOTHING`
_, err := s.db.Exec(query, appName)
return err
}
func (s *Store) RegisterDefault(appName string, availableChannels []Channel) error {
existing, _ := s.GetApp(appName)
if existing != nil && (existing.Signal != nil || existing.WebPush != nil) {
return fmt.Errorf("app %s already exists with subscriptions, refusing to overwrite", appName)
func (s *Store) AddSubscription(sub Subscription) error {
if err := s.RegisterApp(sub.AppName); err != nil {
return err
}
var channel Channel
if len(availableChannels) > 0 {
channel = availableChannels[0]
} else {
channel = ChannelWebPush
query := `
INSERT INTO subscriptions (id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey *string
if sub.Signal != nil {
signalGroupID = &sub.Signal.GroupID
signalAccount = &sub.Signal.Account
}
if sub.Telegram != nil {
telegramChatID = &sub.Telegram.ChatID
}
if sub.WebPush != nil {
pushEndpoint = &sub.WebPush.Endpoint
p256dh = &sub.WebPush.P256dh
auth = &sub.WebPush.Auth
vapidPrivateKey = &sub.WebPush.VapidPrivateKey
}
return s.Register(appName, &channel, nil, nil)
_, err := s.db.Exec(query, sub.ID, sub.AppName, sub.Channel, signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey)
return err
}
func (s *Store) GetApp(appName string) (*Mapping, error) {
query := `
SELECT appName, signalGroupId, signalAccount, channel, pushEndpoint, p256dh, auth, vapidPrivateKey
FROM mappings
WHERE appName = ?
`
func (s *Store) GetApp(appName string) (*App, error) {
query := `SELECT appName FROM apps WHERE appName = ?`
row := s.db.QueryRow(query, appName)
var m Mapping
var signalGroupID, signalAccount, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString
err := row.Scan(&m.AppName, &signalGroupID, &signalAccount, &m.Channel, &pushEndpoint, &p256dh, &auth, &vapidPrivateKey)
var app App
if err := row.Scan(&app.AppName); err == sql.ErrNoRows {
return nil, nil
} else if err != nil {
return nil, err
}
subs, err := s.GetSubscriptions(appName)
if err != nil {
return nil, err
}
app.Subscriptions = subs
return &app, nil
}
func (s *Store) GetSubscriptions(appName string) ([]Subscription, error) {
query := `
SELECT id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey
FROM subscriptions
WHERE appName = ?
`
rows, err := s.db.Query(query, appName)
if err != nil {
return nil, err
}
defer rows.Close()
var subscriptions []Subscription
for rows.Next() {
var sub Subscription
var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString
if err := rows.Scan(&sub.ID, &sub.AppName, &sub.Channel, &signalGroupID, &signalAccount, &telegramChatID, &pushEndpoint, &p256dh, &auth, &vapidPrivateKey); err != nil {
return nil, err
}
if signalGroupID.Valid && signalAccount.Valid {
sub.Signal = &SignalSubscription{
GroupID: signalGroupID.String,
Account: signalAccount.String,
}
}
if telegramChatID.Valid {
sub.Telegram = &TelegramSubscription{
ChatID: telegramChatID.String,
}
}
if pushEndpoint.Valid {
sub.WebPush = &WebPushSubscription{
Endpoint: pushEndpoint.String,
}
if p256dh.Valid {
sub.WebPush.P256dh = p256dh.String
}
if auth.Valid {
sub.WebPush.Auth = auth.String
}
if vapidPrivateKey.Valid {
sub.WebPush.VapidPrivateKey = vapidPrivateKey.String
}
}
subscriptions = append(subscriptions, sub)
}
return subscriptions, rows.Err()
}
func (s *Store) GetAllApps() ([]App, error) {
query := `SELECT appName FROM apps ORDER BY appName`
rows, err := s.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var apps []App
for rows.Next() {
var app App
if err := rows.Scan(&app.AppName); err != nil {
return nil, err
}
subs, err := s.GetSubscriptions(app.AppName)
if err != nil {
return nil, err
}
app.Subscriptions = subs
apps = append(apps, app)
}
return apps, rows.Err()
}
func (s *Store) GetSignalGroup(appName string) (*SignalSubscription, error) {
query := `SELECT groupId, account FROM signal_groups WHERE appName = ?`
row := s.db.QueryRow(query, appName)
var groupID, account string
if err := row.Scan(&groupID, &account); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
return &SignalSubscription{
GroupID: groupID,
Account: account,
}, nil
}
func (s *Store) SaveSignalGroup(appName string, sub *SignalSubscription) error {
if err := s.RegisterApp(appName); err != nil {
return err
}
query := `INSERT INTO signal_groups (appName, groupId, account) VALUES (?, ?, ?)
ON CONFLICT(appName) DO UPDATE SET groupId=excluded.groupId, account=excluded.account`
_, err := s.db.Exec(query, appName, sub.GroupID, sub.Account)
return err
}
func (s *Store) DeleteSubscription(subscriptionID string) error {
query := `DELETE FROM subscriptions WHERE id = ?`
_, err := s.db.Exec(query, subscriptionID)
return err
}
func (s *Store) GetSubscription(subscriptionID string) (*Subscription, error) {
query := `
SELECT id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey
FROM subscriptions
WHERE id = ?
`
row := s.db.QueryRow(query, subscriptionID)
var sub Subscription
var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString
err := row.Scan(&sub.ID, &sub.AppName, &sub.Channel, &signalGroupID, &signalAccount, &telegramChatID, &pushEndpoint, &p256dh, &auth, &vapidPrivateKey)
if err == sql.ErrNoRows {
return nil, nil
}
@ -137,117 +273,35 @@ func (s *Store) GetApp(appName string) (*Mapping, error) {
}
if signalGroupID.Valid && signalAccount.Valid {
m.Signal = &SignalSubscription{
sub.Signal = &SignalSubscription{
GroupID: signalGroupID.String,
Account: signalAccount.String,
}
}
if pushEndpoint.Valid {
m.WebPush = &WebPushSubscription{
Endpoint: pushEndpoint.String,
}
if p256dh.Valid {
m.WebPush.P256dh = p256dh.String
}
if auth.Valid {
m.WebPush.Auth = auth.String
}
if vapidPrivateKey.Valid {
m.WebPush.VapidPrivateKey = vapidPrivateKey.String
}
}
return &m, nil
}
func (s *Store) GetAllMappings() ([]Mapping, error) {
query := `
SELECT appName, signalGroupId, signalAccount, channel, pushEndpoint, p256dh, auth, vapidPrivateKey
FROM mappings
`
rows, err := s.db.Query(query)
if err != nil {
return nil, err
}
defer rows.Close()
var mappings []Mapping
for rows.Next() {
var m Mapping
var signalGroupID, signalAccount, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString
if err := rows.Scan(&m.AppName, &signalGroupID, &signalAccount, &m.Channel, &pushEndpoint, &p256dh, &auth, &vapidPrivateKey); err != nil {
return nil, err
}
if signalGroupID.Valid && signalAccount.Valid {
m.Signal = &SignalSubscription{
GroupID: signalGroupID.String,
Account: signalAccount.String,
if telegramChatID.Valid {
sub.Telegram = &TelegramSubscription{
ChatID: telegramChatID.String,
}
}
if pushEndpoint.Valid {
m.WebPush = &WebPushSubscription{
sub.WebPush = &WebPushSubscription{
Endpoint: pushEndpoint.String,
}
if p256dh.Valid {
m.WebPush.P256dh = p256dh.String
sub.WebPush.P256dh = p256dh.String
}
if auth.Valid {
m.WebPush.Auth = auth.String
sub.WebPush.Auth = auth.String
}
if vapidPrivateKey.Valid {
m.WebPush.VapidPrivateKey = vapidPrivateKey.String
sub.WebPush.VapidPrivateKey = vapidPrivateKey.String
}
}
mappings = append(mappings, m)
}
return mappings, rows.Err()
}
func (s *Store) UpdateChannel(appName string, channel Channel) error {
query := `UPDATE mappings SET channel = ? WHERE appName = ?`
_, err := s.db.Exec(query, channel, appName)
return err
}
func (s *Store) UpdateSignal(appName string, signal *SignalSubscription) error {
if signal == nil {
return fmt.Errorf("signal cannot be nil")
}
query := `UPDATE mappings SET signalGroupId = ?, signalAccount = ? WHERE appName = ?`
_, err := s.db.Exec(query, signal.GroupID, signal.Account, appName)
return err
}
func (s *Store) UpdateWebPush(appName string, webPush *WebPushSubscription) error {
if webPush == nil {
return fmt.Errorf("webPush cannot be nil")
}
query := `
UPDATE mappings
SET pushEndpoint = ?, p256dh = ?, auth = ?, vapidPrivateKey = ?
WHERE appName = ?
`
_, err := s.db.Exec(query, webPush.Endpoint, webPush.P256dh, webPush.Auth, webPush.VapidPrivateKey, appName)
return err
return &sub, nil
}
func (s *Store) RemoveApp(appName string) error {
query := `DELETE FROM mappings WHERE appName = ?`
_, err := s.db.Exec(query, appName)
return err
}
func (s *Store) ClearWebPush(appName string) error {
query := `
UPDATE mappings
SET pushEndpoint = NULL, p256dh = NULL, auth = NULL, vapidPrivateKey = NULL, channel = 'signal'
WHERE appName = ?
`
_, err := s.db.Exec(query, appName)
_, err := s.db.Exec(`DELETE FROM apps WHERE appName = ?`, appName)
return err
}

View file

@ -1,54 +0,0 @@
package server
import (
"net/http"
"prism/service/notification"
"prism/service/util"
"github.com/go-chi/chi/v5"
)
func (s *Server) handleDeleteAppAction(w http.ResponseWriter, r *http.Request) {
app := chi.URLParam(r, "appName")
if app == "" {
http.Error(w, "Missing app parameter", http.StatusBadRequest)
return
}
if err := s.store.RemoveApp(app); err != nil {
util.LogAndError(w, s.logger, "Failed to delete app", http.StatusInternalServerError, err)
return
}
w.Header().Set("Content-Type", "text/html")
s.handleFragmentApps(w, r)
}
func (s *Server) handleToggleChannelAction(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Invalid form data", http.StatusBadRequest)
return
}
app := r.FormValue("app")
channel := r.FormValue("channel")
if app == "" || channel == "" {
http.Error(w, "Missing app or channel parameter", http.StatusBadRequest)
return
}
if !s.dispatcher.IsValidChannel(notification.Channel(channel)) {
http.Error(w, "Invalid channel", http.StatusBadRequest)
return
}
if err := s.store.UpdateChannel(app, notification.Channel(channel)); err != nil {
util.LogAndError(w, s.logger, "Failed to update channel", http.StatusInternalServerError, err)
return
}
w.Header().Set("Content-Type", "text/html")
s.handleFragmentApps(w, r)
}

View file

@ -1,152 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"time"
"prism/service/notification"
"prism/service/util"
"github.com/go-chi/chi/v5"
)
func (s *Server) handleGetMappings(w http.ResponseWriter, r *http.Request) {
mappings, err := s.store.GetAllMappings()
if err != nil {
util.LogAndError(w, s.logger, "Internal server error", http.StatusInternalServerError, err)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(mappings); err != nil {
s.logger.Error("Failed to encode response", "error", err)
}
}
type createMappingRequest struct {
App string `json:"app"`
Channel notification.Channel `json:"channel"`
PushEndpoint *string `json:"pushEndpoint,omitempty"`
}
func (s *Server) handleCreateMapping(w http.ResponseWriter, r *http.Request) {
var req createMappingRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.App == "" {
http.Error(w, "Missing required fields", http.StatusBadRequest)
return
}
if req.Channel == "" {
req.Channel = notification.ChannelWebPush
}
if req.Channel == notification.ChannelWebPush && req.PushEndpoint == nil {
http.Error(w, "pushEndpoint required for webpush channel", http.StatusBadRequest)
return
}
var webPush *notification.WebPushSubscription
if req.Channel == notification.ChannelWebPush && req.PushEndpoint != nil {
webPush = &notification.WebPushSubscription{
Endpoint: *req.PushEndpoint,
}
}
if err := s.store.Register(req.App, &req.Channel, nil, webPush); err != nil {
util.LogAndError(w, s.logger, "Failed to create mapping", http.StatusInternalServerError, err)
return
}
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(map[string]string{"status": "created"}); err != nil {
s.logger.Error("Failed to encode response", "error", err)
}
}
func (s *Server) handleDeleteMapping(w http.ResponseWriter, r *http.Request) {
appName := chi.URLParam(r, "appName")
if appName == "" {
http.Error(w, "Missing appName parameter", http.StatusBadRequest)
return
}
if err := s.store.RemoveApp(appName); err != nil {
util.LogAndError(w, s.logger, "Failed to delete mapping", http.StatusInternalServerError, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
type updateChannelRequest struct {
Channel notification.Channel `json:"channel"`
UpEndpoint *string `json:"upEndpoint,omitempty"`
}
func (s *Server) handleUpdateChannel(w http.ResponseWriter, r *http.Request) {
appName := chi.URLParam(r, "appName")
if appName == "" {
http.Error(w, "Missing appName parameter", http.StatusBadRequest)
return
}
var req updateChannelRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Channel == notification.ChannelWebPush && req.UpEndpoint == nil {
http.Error(w, "upEndpoint required for webpush channel", http.StatusBadRequest)
return
}
if err := s.store.UpdateChannel(appName, req.Channel); err != nil {
util.LogAndError(w, s.logger, "Failed to update channel", http.StatusInternalServerError, err)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]string{"status": "updated"}); err != nil {
s.logger.Error("Failed to encode response", "error", err)
}
}
func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) {
mappings, err := s.store.GetAllMappings()
if err != nil {
util.LogAndError(w, s.logger, "Failed to get mappings", http.StatusInternalServerError, err)
return
}
uptime := time.Since(s.startTime)
stats := map[string]any{
"uptime": util.FormatUptime(uptime),
"uptimeSeconds": int(uptime.Seconds()),
"mappingsCount": len(mappings),
"signalCount": countByChannel(mappings, notification.ChannelSignal),
"webpushCount": countByChannel(mappings, notification.ChannelWebPush),
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(stats); err != nil {
s.logger.Error("Failed to encode response", "error", err)
}
}
func countByChannel(mappings []notification.Mapping, channel notification.Channel) int {
count := 0
for _, m := range mappings {
if m.Channel == channel {
count++
}
}
return count
}

View file

@ -0,0 +1,37 @@
package server
import (
"encoding/json"
"net/http"
"prism/service/util"
"github.com/go-chi/chi/v5"
)
func (s *Server) handleDeleteApp(w http.ResponseWriter, r *http.Request) {
app := chi.URLParam(r, "appName")
if app == "" {
http.Error(w, "Missing app parameter", http.StatusBadRequest)
return
}
if err := s.store.RemoveApp(app); err != nil {
util.LogAndError(w, s.logger, "Failed to delete app", http.StatusInternalServerError, err)
return
}
util.SetToast(w, "App deleted", "success")
s.handleFragmentApps(w, r)
}
func (s *Server) handleGetApps(w http.ResponseWriter, r *http.Request) {
apps, err := s.store.GetAllApps()
if err != nil {
util.LogAndError(w, s.logger, "Failed to get apps", http.StatusInternalServerError, err)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(apps)
}

View file

@ -2,32 +2,35 @@ package server
import (
"bytes"
"fmt"
"net/http"
"net/url"
"prism/service/integration/signal"
"prism/service/notification"
"prism/service/util"
)
type AppListItem struct {
AppName string
Channel string
ChannelBadge string
ChannelConfigured bool
Tooltip string
Hostname string
ChannelOptions []SelectOption
Channels []ChannelState
}
type SelectOption struct {
Value string
type ChannelState struct {
Channel string
Label string
Selected bool
Active bool
Toggleable bool
Subscriptions []SubscriptionItem
}
type SubscriptionItem struct {
ID string
Tooltip string
Hostname string
}
func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
mappings, err := s.store.GetAllMappings()
apps, err := s.store.GetAllApps()
if err != nil {
util.LogAndError(w, s.logger, "Internal server error", http.StatusInternalServerError, err)
return
@ -35,8 +38,10 @@ func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
data := s.buildAppListData(apps)
var buf bytes.Buffer
if err := s.fragmentTmpl.ExecuteTemplate(&buf, "app-list.html", s.buildAppListData(mappings)); err != nil {
if err := s.fragmentTmpl.ExecuteTemplate(&buf, "app-list.html", data); err != nil {
util.LogAndError(w, s.logger, "Failed to execute template", http.StatusInternalServerError, err)
return
}
@ -44,112 +49,111 @@ func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(buf.Bytes())
}
func (s *Server) buildAppListData(mappings []notification.Mapping) []AppListItem {
if len(mappings) == 0 {
func (s *Server) buildAppListData(apps []notification.App) []AppListItem {
if len(apps) == 0 {
return nil
}
signalLinked := false
if s.integrations.Signal != nil {
handlers := s.integrations.Signal.GetHandlers()
if handlers != nil {
account, _ := handlers.GetClient().GetLinkedAccount()
signalLinked = account != nil
}
}
telegramLinked := false
var telegramInfo string
if s.integrations.Telegram != nil {
signalEnabled := s.integrations.Signal != nil && s.integrations.Signal.IsEnabled()
telegramEnabled := s.integrations.Telegram != nil && s.integrations.Telegram.IsEnabled()
telegramBotName := ""
if telegramEnabled {
handlers := s.integrations.Telegram.GetHandlers()
if handlers != nil && handlers.GetClient() != nil {
chatID := handlers.GetChatID()
telegramLinked = chatID != 0
if telegramLinked {
if bot, err := handlers.GetClient().GetMe(); err == nil {
telegramInfo = "@" + bot.Username
if handlers != nil {
client := handlers.GetClient()
if client != nil {
if bot, err := client.GetMe(); err == nil && bot != nil && bot.Username != "" {
telegramBotName = "@" + bot.Username
}
}
}
}
items := make([]AppListItem, 0, len(mappings))
for _, m := range mappings {
item := s.buildAppListItem(m, signalLinked, telegramLinked, telegramInfo)
items := make([]AppListItem, 0, len(apps))
for _, app := range apps {
channels := []ChannelState{}
if signalEnabled {
var signalSub *notification.Subscription
for i := range app.Subscriptions {
if app.Subscriptions[i].Channel == notification.ChannelSignal {
signalSub = &app.Subscriptions[i]
break
}
}
state := ChannelState{
Channel: string(notification.ChannelSignal),
Label: notification.ChannelSignal.Label(),
Active: signalSub != nil,
Toggleable: true,
}
if signalSub != nil && signalSub.Signal != nil {
state.Subscriptions = []SubscriptionItem{{
ID: signalSub.ID,
Tooltip: signal.FormatPhoneNumber(signalSub.Signal.Account),
}}
}
channels = append(channels, state)
}
if telegramEnabled {
var telegramSub *notification.Subscription
for i := range app.Subscriptions {
if app.Subscriptions[i].Channel == notification.ChannelTelegram {
telegramSub = &app.Subscriptions[i]
break
}
}
state := ChannelState{
Channel: string(notification.ChannelTelegram),
Label: notification.ChannelTelegram.Label(),
Active: telegramSub != nil,
Toggleable: true,
}
if telegramSub != nil {
state.Subscriptions = []SubscriptionItem{{
ID: telegramSub.ID,
Tooltip: telegramBotName,
}}
}
channels = append(channels, state)
}
webPushSubs := []SubscriptionItem{}
for _, sub := range app.Subscriptions {
if sub.Channel == notification.ChannelWebPush && sub.WebPush != nil {
item := SubscriptionItem{
ID: sub.ID,
Tooltip: sub.WebPush.Endpoint,
}
if u, err := url.Parse(sub.WebPush.Endpoint); err == nil {
item.Hostname = u.Hostname()
}
webPushSubs = append(webPushSubs, item)
}
}
if len(webPushSubs) > 0 {
channels = append(channels, ChannelState{
Channel: string(notification.ChannelWebPush),
Label: notification.ChannelWebPush.Label(),
Active: true,
Toggleable: false,
Subscriptions: webPushSubs,
})
}
item := AppListItem{
AppName: app.AppName,
Channels: channels,
}
items = append(items, item)
}
return items
}
func (s *Server) buildAppListItem(m notification.Mapping, signalLinked, telegramLinked bool, telegramInfo string) 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 && telegramLinked {
item.ChannelBadge = m.Channel.Label()
item.Tooltip = telegramInfo
item.ChannelConfigured = true
} else {
item.ChannelBadge = "Unlinked"
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, telegramLinked)
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 s.integrations.Signal != nil {
options = append(options, SelectOption{
Value: notification.ChannelSignal.String(),
Label: notification.ChannelSignal.Label(),
Selected: isSignal,
})
}
if s.integrations.Telegram != nil {
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
}

View file

@ -1,8 +1,10 @@
package server
import (
"bytes"
"encoding/json"
"io"
"mime"
"net/http"
"net/url"
"strings"
@ -16,10 +18,12 @@ import (
func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
appName := chi.URLParam(r, "appName")
if appName == "" {
appName = chi.URLParam(r, "endpoint")
decodedAppName, err := url.PathUnescape(appName)
if err != nil {
http.Error(w, "Invalid app name", http.StatusBadRequest)
return
}
appName, _ = url.QueryUnescape(appName)
appName = decodedAppName
if appName == "" || strings.Contains(appName, "/") {
http.Error(w, "Invalid app name", http.StatusBadRequest)
return
@ -31,49 +35,7 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
return
}
var message, title string
contentType := r.Header.Get("Content-Type")
if strings.Contains(contentType, "application/json") {
var payload struct {
Title string `json:"title"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &payload); err == nil {
message = payload.Message
title = payload.Title
} else {
message = string(body)
}
} else if strings.Contains(contentType, "application/x-www-form-urlencoded") {
if values, err := url.ParseQuery(string(body)); err == nil {
message = values.Get("message")
if message == "" {
message = string(body)
}
if title == "" {
if t := values.Get("title"); t != "" {
title = t
} else if t := values.Get("t"); t != "" {
title = t
}
}
} else {
message = string(body)
}
} else {
message = string(body)
}
if title == "" {
title = r.Header.Get("X-Title")
if title == "" {
title = r.Header.Get("Title")
}
if title == "" {
title = r.Header.Get("t")
}
}
message, title := parseNtfyPayload(r, body)
if message == "" {
http.Error(w, "Message required", http.StatusBadRequest)
@ -110,6 +72,57 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
}
}
func parseNtfyPayload(r *http.Request, body []byte) (string, string) {
var message, title string
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
mediaType = ""
}
switch mediaType {
case "application/json":
var payload struct {
Title string `json:"title"`
Message string `json:"message"`
}
if err := json.Unmarshal(body, &payload); err == nil {
message = payload.Message
title = payload.Title
} else {
message = string(body)
}
case "application/x-www-form-urlencoded":
r.Body = io.NopCloser(bytes.NewReader(body))
if err := r.ParseForm(); err == nil {
message = r.PostForm.Get("message")
if message == "" {
message = string(body)
}
title = firstNonEmpty(r.PostForm.Get("title"), r.PostForm.Get("t"))
} else {
message = string(body)
}
default:
message = string(body)
}
if title == "" {
title = firstNonEmpty(r.Header.Get("X-Title"), r.Header.Get("Title"), r.Header.Get("t"))
}
return message, title
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func truncate(s string, max int) string {
if len(s) <= max {
return s

View file

@ -0,0 +1,168 @@
package server
import (
"fmt"
"net/http"
"prism/service/notification"
"prism/service/util"
"github.com/go-chi/chi/v5"
)
type subscriptionFormData struct {
Channel string
GroupID string
ChatID string
}
func (s *Server) handleCreateSubscription(w http.ResponseWriter, r *http.Request) {
appName := chi.URLParam(r, "appName")
if appName == "" {
http.Error(w, "Missing app parameter", http.StatusBadRequest)
return
}
if err := r.ParseForm(); err != nil {
util.JSONError(w, "Invalid form data", http.StatusBadRequest)
return
}
form := subscriptionFormData{
Channel: r.FormValue("channel"),
GroupID: r.FormValue("group_id"),
ChatID: r.FormValue("chat_id"),
}
channel := notification.Channel(form.Channel)
if !s.dispatcher.IsValidChannel(channel) {
util.SetToast(w, fmt.Sprintf("Invalid or unavailable channel: %s", form.Channel), "error")
s.handleFragmentApps(w, r)
return
}
app, err := s.store.GetApp(appName)
if err != nil {
util.LogAndError(w, s.logger, "Failed to load app subscriptions", http.StatusInternalServerError, err)
return
}
if app != nil {
for _, existingSub := range app.Subscriptions {
if existingSub.Channel == channel {
util.SetToast(w, fmt.Sprintf("%s already enabled", channel.Label()), "error")
s.handleFragmentApps(w, r)
return
}
}
}
subID, err := notification.GenerateSubscriptionID()
if err != nil {
util.LogAndError(w, s.logger, "Failed to generate subscription ID", http.StatusInternalServerError, err)
return
}
sub := notification.Subscription{
ID: subID,
AppName: appName,
Channel: channel,
}
switch channel {
case notification.ChannelSignal:
if s.integrations.Signal == nil || !s.integrations.Signal.IsEnabled() {
util.SetToast(w, "Signal not configured", "error")
s.handleFragmentApps(w, r)
return
}
client := s.integrations.Signal.GetHandlers().GetClient()
account, err := client.GetLinkedAccount()
if err != nil || account == nil {
util.SetToast(w, "Signal not linked - configure in Integrations below", "error")
s.handleFragmentApps(w, r)
return
}
if form.GroupID != "" {
sub.Signal = &notification.SignalSubscription{
GroupID: form.GroupID,
Account: account.Number,
}
} else {
cachedGroup, err := s.store.GetSignalGroup(appName)
if err != nil {
util.LogAndError(w, s.logger, "Failed to check for cached Signal group", http.StatusInternalServerError, err)
return
}
if cachedGroup != nil && cachedGroup.Account == account.Number {
sub.Signal = cachedGroup
} else {
signalSub, err := s.integrations.Signal.GetSender().CreateDefaultSignalSubscription(appName)
if err != nil {
util.LogAndError(w, s.logger, "Failed to create Signal subscription", http.StatusInternalServerError, err)
return
}
sub.Signal = signalSub
if err := s.store.SaveSignalGroup(appName, signalSub); err != nil {
s.logger.Warn("Failed to cache Signal group", "error", err)
}
}
}
case notification.ChannelTelegram:
if s.integrations.Telegram == nil || !s.integrations.Telegram.IsEnabled() {
util.SetToast(w, "Telegram not configured", "error")
s.handleFragmentApps(w, r)
return
}
chatID := s.integrations.Telegram.GetHandlers().GetChatID()
if chatID == 0 {
util.SetToast(w, "Telegram not linked - configure in Integrations below", "error")
s.handleFragmentApps(w, r)
return
}
if form.ChatID != "" {
chatID = 0
fmt.Sscanf(form.ChatID, "%d", &chatID)
}
sub.Telegram = &notification.TelegramSubscription{
ChatID: fmt.Sprintf("%d", chatID),
}
default:
util.JSONError(w, "Unsupported channel for manual subscription", http.StatusBadRequest)
return
}
if err := s.store.AddSubscription(sub); err != nil {
util.LogAndError(w, s.logger, "Failed to create subscription", http.StatusInternalServerError, err)
return
}
util.SetToast(w, fmt.Sprintf("%s enabled", channel.Label()), "success")
s.handleFragmentApps(w, r)
}
func (s *Server) handleDeleteSubscription(w http.ResponseWriter, r *http.Request) {
subscriptionID := chi.URLParam(r, "subscriptionId")
if subscriptionID == "" {
http.Error(w, "Missing subscription ID", http.StatusBadRequest)
return
}
sub, err := s.store.GetSubscription(subscriptionID)
if err != nil {
util.LogAndError(w, s.logger, "Failed to get subscription", http.StatusInternalServerError, err)
return
}
if err := s.store.DeleteSubscription(subscriptionID); err != nil {
util.LogAndError(w, s.logger, "Failed to delete subscription", http.StatusInternalServerError, err)
return
}
util.SetToast(w, fmt.Sprintf("%s disabled", sub.Channel.Label()), "success")
s.handleFragmentApps(w, r)
}

View file

@ -99,30 +99,20 @@ func (s *Server) setupRoutes() {
util.LogAndError(w, s.logger, "Internal Server Error", http.StatusInternalServerError, err)
}
})
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/favicon.webp", http.StatusMovedPermanently)
})
integration.RegisterAll(s.integrations, r, s.cfg, s.store, s.logger, authMiddleware)
r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/apps", s.handleFragmentApps)
r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/integrations", s.handleFragmentIntegrations)
r.Route("/action", func(r chi.Router) {
r.Route("/apps", func(r chi.Router) {
r.Use(authMiddleware(s.cfg.APIKey))
r.Delete("/app/{appName}", s.handleDeleteAppAction)
r.Post("/toggle-channel", s.handleToggleChannelAction)
})
r.Route("/api/v1/admin", func(r chi.Router) {
r.Use(authMiddleware(s.cfg.APIKey))
r.Get("/mappings", s.handleGetMappings)
r.Post("/mappings", s.handleCreateMapping)
r.Delete("/mappings/{appName}", s.handleDeleteMapping)
r.Put("/mappings/{appName}/channel", s.handleUpdateChannel)
r.Get("/stats", s.handleGetStats)
r.Delete("/{appName}", s.handleDeleteApp)
r.Post("/{appName}/subscriptions", s.handleCreateSubscription)
r.Delete("/{appName}/subscriptions/{subscriptionId}", s.handleDeleteSubscription)
})
r.With(authMiddleware(s.cfg.APIKey)).Get("/api/v1/apps", s.handleGetApps)
r.With(authMiddleware(s.cfg.APIKey)).Get("/api/v1/health", s.handleHealth)
r.With(authMiddleware(s.cfg.APIKey)).Post("/{appName}", s.handleNtfyPublish)

View file

@ -1,38 +1,56 @@
{{if .}}
<ul class="app-list">
{{range .}}
{{$app := .}}
<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>
{{if .Channels}}
<div class="app-subscriptions">
{{range .Channels}}
{{$channel := .}}
{{if .Toggleable}}
{{if .Active}}
<button type="button" class="channel-badge channel-{{.Channel}} badge-active"
hx-delete="/apps/{{$app.AppName}}/subscriptions/{{(index .Subscriptions 0).ID}}"
hx-target="#apps-list"
hx-swap="innerHTML">
{{.Label}}
<span class="tooltip">{{if (index .Subscriptions 0).Tooltip}}{{(index .Subscriptions 0).Tooltip}}{{else}}Click to disable{{end}}</span>
</button>
{{else}}
<span class="channel-badge channel-not-configured">
{{.ChannelBadge}}
<button type="button" class="channel-badge channel-{{.Channel}} badge-inactive"
hx-post="/apps/{{$app.AppName}}/subscriptions"
hx-target="#apps-list"
hx-swap="innerHTML"
hx-vals='{"channel":"{{.Channel}}"}'>
+ {{.Label}}
<span class="tooltip">Click to enable</span>
</button>
{{end}}
{{else}}
{{range .Subscriptions}}
<span class="channel-badge channel-{{$channel.Channel}} badge-subscribed">
{{$channel.Label}}{{if .Hostname}} ({{.Hostname}}){{end}}
{{if .Tooltip}}<span class="tooltip">{{.Tooltip}}</span>{{end}}
<button type="button" class="btn-delete-sub"
hx-delete="/apps/{{$app.AppName}}/subscriptions/{{.ID}}"
hx-target="#apps-list"
hx-swap="innerHTML"
hx-confirm="Delete this subscription?">×</button>
</span>
{{end}}
{{if .Hostname}}
<span class="app-detail">{{.Hostname}}</span>
{{end}}
{{end}}
</div>
{{else}}
<div class="app-no-subscriptions">
<span class="channel-badge channel-not-configured">No channels enabled</span>
</div>
{{end}}
</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" hx-confirm="Delete {{.AppName}}?">Delete</button>
<button type="button" class="btn-delete" hx-delete="/apps/{{.AppName}}" hx-target="#apps-list" hx-swap="innerHTML" hx-confirm="Delete {{.AppName}} and all subscriptions?">Delete</button>
</div>
</li>
{{end}}

View file

@ -77,8 +77,8 @@ func isLocalIP(addr string) bool {
}
func GetClientIP(r *http.Request) string {
host, _, _ := net.SplitHostPort(r.RemoteAddr)
if host == "" {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil || host == "" {
return r.RemoteAddr
}
return host

View file

@ -6,6 +6,18 @@ import (
"net/http"
)
func SetToast(w http.ResponseWriter, message, toastType string) {
trigger := map[string]interface{}{
"showToast": map[string]string{
"message": message,
"type": toastType,
},
}
if data, err := json.Marshal(trigger); err == nil {
w.Header().Set("HX-Trigger", string(data))
}
}
func LogAndError(w http.ResponseWriter, logger *slog.Logger, message string, code int, err error, attrs ...any) {
logAttrs := append([]any{"error", err}, attrs...)
logger.Error(message, logAttrs...)