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. 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} **JSON format:**
Send a notification to a specific app. Messages are routed based on your app configuration in the web UI.
JSON format:
```bash ```bash
curl -X POST http://localhost:8080/my-app \ 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"}' -d '{"title": "Alert", "message": "Something happened"}'
``` ```
Plain text (ntfy-compatible): **Plain text (ntfy-compatible):**
```bash ```bash
curl -X POST http://localhost:8080/my-app \ curl -X POST http://localhost:8080/my-app \
@ -198,52 +194,7 @@ curl -X POST http://localhost:8080/my-app \
-d "Simple message text" -d "Simple message text"
``` ```
**App Routing:** 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.
- 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"
```
## Monitoring ## Monitoring

View file

@ -1 +1 @@
0.3.0 0.4.0

View file

@ -148,7 +148,6 @@ details[open] > .card-header::before {
} }
/* Tooltips */ /* Tooltips */
.channel-badge:has(.tooltip),
.integration-status:has(.tooltip) { .integration-status:has(.tooltip) {
cursor: help; cursor: help;
} }
@ -172,7 +171,7 @@ details[open] > .card-header::before {
transition: opacity 0.2s; transition: opacity 0.2s;
pointer-events: none; pointer-events: none;
z-index: 10; 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 { .tooltip::after {
@ -232,18 +231,11 @@ details[open] > .card-header::before {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.5rem;
} }
.app-name { .app-name {
font-size: 1.1em; font-size: 1.2em;
}
.app-channel {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9em;
} }
.channel-badge { .channel-badge {
@ -252,26 +244,69 @@ details[open] > .card-header::before {
font-size: 0.85em; font-size: 0.85em;
font-weight: 500; font-weight: 500;
position: relative; 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); background: var(--accent);
color: var(--text-on-color); color: var(--text-on-color);
} }
.channel-webpush { .channel-signal.badge-inactive {
color: var(--accent);
background: transparent;
}
.channel-webpush.badge-active {
background: var(--success); background: var(--success);
color: var(--text-on-color); color: var(--text-on-color);
} }
.channel-telegram { .channel-telegram.badge-active {
background: #0088cc; background: var(--accent);
color: var(--text-on-color); color: var(--text-on-color);
} }
.app-detail { .channel-telegram.badge-inactive {
color: var(--text-secondary); color: var(--accent);
font-size: 0.85em; background: transparent;
} }
.app-actions { .app-actions {
@ -280,20 +315,6 @@ details[open] > .card-header::before {
align-items: center; 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 { .btn-delete {
padding: 0.375rem 0.75rem; padding: 0.375rem 0.75rem;
background: var(--error); background: var(--error);
@ -309,6 +330,23 @@ details[open] > .card-header::before {
filter: brightness(0.85); 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 Spinner */
.loading { .loading {
color: var(--text-secondary); color: var(--text-secondary);
@ -401,8 +439,9 @@ details[open] > .integration-header::before {
} }
.integration-status.unlinked { .integration-status.unlinked {
background: var(--text-secondary); background: var(--bg-secondary);
color: var(--text-on-color); color: var(--text-primary);
border: 0.0625rem solid var(--border-color);
} }
.link-instructions { .link-instructions {
@ -477,8 +516,9 @@ details[open] > .integration-header::before {
} }
.btn-secondary { .btn-secondary {
background: var(--text-secondary); background: var(--bg-secondary);
color: var(--text-on-color); color: var(--text-primary);
border: 0.0625rem solid var(--border-color);
} }
.btn-danger { .btn-danger {
@ -533,3 +573,73 @@ details[open] > .integration-header::before {
height: auto; height: auto;
display: block; 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}}"> <link rel="stylesheet" href="/index.css?v={{.Version}}">
<script src="/htmx.min.js"></script> <script src="/htmx.min.js"></script>
<script src="/theme.js?v={{.Version}}"></script> <script src="/theme.js?v={{.Version}}"></script>
<script src="/toast.js?v={{.Version}}"></script>
<script src="/integration.js?v={{.Version}}"></script> <script src="/integration.js?v={{.Version}}"></script>
</head> </head>
<body> <body>
<details class="card" open> <details class="card" open>
<summary class="card-header"> <summary class="card-header">
<span class="integration-name">Registered Applications</span> <span class="integration-name">Registered Apps</span>
</summary> </summary>
<div id="apps-list" class="card-content" <div id="apps-list" class="card-content"
hx-get="/fragment/apps" hx-get="/fragment/apps"
@ -28,9 +29,11 @@
<div id="integrations" <div id="integrations"
hx-get="/fragment/integrations" hx-get="/fragment/integrations"
hx-trigger="load"> hx-trigger="load, reload">
</div> </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> </body>
</html> </html>

View file

@ -17,10 +17,17 @@ document.addEventListener('DOMContentLoaded', () => {
if (action === 'link-signal') linkSignal(btn); if (action === 'link-signal') linkSignal(btn);
else if (action === 'delete-telegram') deleteTelegram(btn); else if (action === 'delete-telegram') deleteTelegram(btn);
else if (action === 'delete-proton') deleteProton(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) { async function handleAuthForm(form, endpoint, statusId, getPayload) {
const status = document.getElementById(statusId); const status = document.getElementById(statusId);
const btn = form.querySelector('button[type="submit"]'); const btn = form.querySelector('button[type="submit"]');
@ -41,7 +48,8 @@ async function handleAuthForm(form, endpoint, statusId, getPayload) {
}); });
if (response.ok) { if (response.ok) {
location.reload(); checkToast(response);
reloadIntegrations();
} else { } else {
const error = await response.json(); const error = await response.json();
showError(error.error || 'Failed to save'); 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) { async function submitTelegramAuth(e) {
await handleAuthForm( await handleAuthForm(
e.target, e.target,
'/api/v1/telegram/auth', '/api/v1/telegram/link',
'telegram-auth-status', 'telegram-auth-status',
(fd) => ({ (fd) => ({
bot_token: fd.get('bot_token'), bot_token: fd.get('bot_token'),
@ -66,7 +88,7 @@ async function submitTelegramAuth(e) {
async function submitTelegramChatId(e) { async function submitTelegramChatId(e) {
await handleAuthForm( await handleAuthForm(
e.target, e.target,
'/api/v1/telegram/auth', '/api/v1/telegram/link',
'telegram-chatid-status', 'telegram-chatid-status',
(fd, form) => ({ (fd, form) => ({
bot_token: form.dataset.botToken, bot_token: form.dataset.botToken,
@ -120,7 +142,11 @@ async function linkSignal(btn) {
qrCode.src = qrUrl; qrCode.src = qrUrl;
qrContainer.style.display = 'block'; qrContainer.style.display = 'block';
let statusCheckInProgress = false;
signalLinkingPoll = setInterval(async () => { signalLinkingPoll = setInterval(async () => {
if (statusCheckInProgress) return;
statusCheckInProgress = true;
try { try {
const statusResp = await fetch('/api/v1/signal/status'); const statusResp = await fetch('/api/v1/signal/status');
const statusData = await statusResp.json(); const statusData = await statusResp.json();
@ -129,10 +155,13 @@ async function linkSignal(btn) {
clearInterval(signalLinkingPoll); clearInterval(signalLinkingPoll);
qrContainer.innerHTML = qrContainer.innerHTML =
'<p class="auth-status success">Linked! Refreshing...</p>'; '<p class="auth-status success">Linked! Refreshing...</p>';
setTimeout(() => location.reload(), 1000); showToast('Signal linked', 'success');
setTimeout(() => reloadIntegrations(), 1000);
} }
} catch (err) { } catch (err) {
console.error('Status check failed:', err); console.error('Status check failed:', err);
} finally {
statusCheckInProgress = false;
} }
}, 2000); }, 2000);
} catch (err) { } catch (err) {
@ -146,10 +175,11 @@ async function deleteTelegram(btn) {
btn.disabled = true; btn.disabled = true;
try { try {
const response = await fetch('/api/v1/telegram/auth', { method: 'DELETE' }); const response = await fetch('/api/v1/telegram/link', { method: 'DELETE' });
if (response.ok) { if (response.ok) {
location.reload(); checkToast(response);
reloadIntegrations();
} else { } else {
const error = await response.json(); const error = await response.json();
alert(`Error: ${error.error || 'Failed to unlink integration'}`); 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' }); const response = await fetch('/api/v1/proton/auth', { method: 'DELETE' });
if (response.ok) { if (response.ok) {
location.reload(); checkToast(response);
reloadIntegrations();
} else { } else {
const error = await response.json(); const error = await response.json();
alert(`Error: ${error.error || 'Failed to unlink integration'}`); 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) { func (m *Monitor) clearNotification(msgID string) {
mapping, err := m.dispatcher.GetStore().GetApp(prismTopic) app, err := m.dispatcher.GetStore().GetApp(prismTopic)
if err != nil || mapping == nil { if err != nil || app == nil {
return return
} }
if mapping.Channel != notification.ChannelWebPush { hasWebPush := false
for _, sub := range app.Subscriptions {
if sub.Channel == notification.ChannelWebPush {
hasWebPush = true
break
}
}
if !hasWebPush {
return return
} }

View file

@ -116,10 +116,10 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
} }
if h.dispatcher != nil { if h.dispatcher != nil {
if err := h.dispatcher.RegisterApp(prismTopic); err != nil { if _, err := h.dispatcher.CreateAppWithDefaultSubscription(prismTopic); err != nil {
h.logger.Warn("Failed to auto-create Proton Mail app mapping", "error", err) h.logger.Warn("Failed to auto-create Proton Mail app with subscription", "error", err)
} else { } 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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "success"}) json.NewEncoder(w).Encode(map[string]string{"status": "success"})
} }
@ -154,6 +155,7 @@ func (h *authHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
return return
} }
util.SetToast(w, "Proton Mail unlinked", "success")
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) 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 { } else if account == nil {
integData.StatusClass = "disconnected" integData.StatusClass = "disconnected"
integData.StatusText = "Unlinked" integData.StatusText = "Unlinked"
integData.StatusTooltip = "Click to link device with Signal" integData.StatusTooltip = "Click Link button below to link"
integData.Open = true integData.Open = true
} else { } else {
integData.StatusClass = "connected" 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 { func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration {
client := NewClient() client := NewClient()
sender := NewSender(client, store, logger) var sender *Sender
if client.IsEnabled() {
sender = NewSender(client, store, logger)
}
return &Integration{ return &Integration{
cfg: cfg, cfg: cfg,
client: client, 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 { if s.client == nil {
return notification.NewPermanentError(fmt.Errorf("signal integration not enabled")) 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() account, err := s.client.GetLinkedAccount()
if err != nil { if err != nil {
return util.LogError(s.logger, "Failed to get linked account", err) return util.LogError(s.logger, "Failed to get linked account", err)
@ -37,33 +70,22 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica
} }
var signalGroupID string 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", s.logger.Info("Signal account changed, recreating group",
"app", mapping.AppName, "app", sub.AppName,
"oldAccount", mapping.Signal.Account, "oldAccount", sub.Signal.Account,
"newAccount", account.Number) "newAccount", account.Number)
needsNewGroup = true needsNewGroup = true
} }
if needsNewGroup { if needsNewGroup {
newGroupID, accountNumber, err := s.client.CreateGroup(mapping.AppName) return notification.NewPermanentError(fmt.Errorf("signal group not configured for subscription %s", sub.ID))
if err != nil {
return util.LogError(s.logger, "Failed to create group", err, "app", mapping.AppName)
}
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 message := notif.Message
if notif.Title != "" { if notif.Title != "" {
message = fmt.Sprintf("%s\n\n%s", notif.Title, notif.Message) message = fmt.Sprintf("%s\n\n%s", notif.Title, notif.Message)

View file

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

View file

@ -3,10 +3,15 @@ package telegram
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"strconv"
"prism/service/notification" "prism/service/notification"
) )
func parseInt64(s string) (int64, error) {
return strconv.ParseInt(s, 10, 64)
}
type Sender struct { type Sender struct {
client *Client client *Client
store *notification.Store 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 { if s.client == nil {
return notification.NewPermanentError(fmt.Errorf("telegram integration not enabled")) return notification.NewPermanentError(fmt.Errorf("telegram integration not enabled"))
} }
if s.DefaultChatID == 0 { if sub.Telegram == nil || sub.Telegram.ChatID == "" {
return notification.NewPermanentError(fmt.Errorf("no telegram chat configured (set TELEGRAM_CHAT_ID in .env)")) return notification.NewPermanentError(fmt.Errorf("no telegram chat configured for subscription"))
} }
message := notif.Message 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) 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 { chatID, err := parseInt64(sub.Telegram.ChatID)
s.logger.Error("Failed to send telegram message", "chatID", s.DefaultChatID, "error", err) 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 return err
} }

View file

@ -1,6 +1,8 @@
package webpush package webpush
import ( import (
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"log/slog" "log/slog"
"net/http" "net/http"
@ -12,6 +14,14 @@ import (
"github.com/go-chi/chi/v5" "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 { type Handlers struct {
store *notification.Store store *notification.Store
logger *slog.Logger logger *slog.Logger
@ -49,7 +59,7 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
return return
} }
existing, err := h.store.GetApp(req.AppName) subID, err := generateSubscriptionID()
if err != nil { if err != nil {
util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err) util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err)
return return
@ -69,47 +79,47 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
} }
} }
if existing != nil { sub := notification.Subscription{
if err := h.store.UpdateWebPush(req.AppName, webPush); err != nil { ID: subID,
util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err) AppName: req.AppName,
return Channel: notification.ChannelWebPush,
} WebPush: webPush,
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)
} }
if err := h.store.AddSubscription(sub); err != nil {
util.LogAndError(w, h.logger, "Failed to add subscription", http.StatusInternalServerError, err)
return
}
h.logger.Info("Added webpush subscription", "app", req.AppName, "subscriptionID", subID, "pushEndpoint", req.PushEndpoint)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
response := map[string]string{ response := map[string]string{
"appName": req.AppName, "appName": req.AppName,
"channel": notification.ChannelWebPush.String(), "channel": notification.ChannelWebPush.String(),
"subscriptionId": subID,
} }
_ = json.NewEncoder(w).Encode(response) _ = json.NewEncoder(w).Encode(response)
} }
func (h *Handlers) HandleUnregister(w http.ResponseWriter, r *http.Request) { func (h *Handlers) HandleUnregister(w http.ResponseWriter, r *http.Request) {
appName := chi.URLParam(r, "appName") subscriptionID := chi.URLParam(r, "subscriptionId")
if appName == "" { if subscriptionID == "" {
http.Error(w, "appName is required", http.StatusBadRequest) http.Error(w, "subscriptionId is required", http.StatusBadRequest)
return return
} }
if err := h.store.ClearWebPush(appName); err != nil { if err := h.store.DeleteSubscription(subscriptionID); err != nil {
util.LogAndError(w, h.logger, "Failed to clear webpush endpoint", http.StatusInternalServerError, err) util.LogAndError(w, h.logger, "Failed to delete subscription", http.StatusInternalServerError, err)
return return
} }
h.logger.Info("Cleared webpush subscription", "app", appName) h.logger.Info("Deleted webpush subscription", "subscriptionID", subscriptionID)
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
response := map[string]string{ response := map[string]string{
"status": "unregistered", "status": "deleted",
"appName": appName, "subscriptionId": subscriptionID,
} }
_ = json.NewEncoder(w).Encode(response) _ = 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) { func RegisterRoutes(router *chi.Mux, store *notification.Store, logger *slog.Logger, authMiddleware func(http.Handler) http.Handler) {
handlers := NewHandlers(store, logger) 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.Use(authMiddleware)
r.Post("/", handlers.HandleRegister) 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 { func (s *Sender) Send(sub *notification.Subscription, notif notification.Notification) error {
if mapping.WebPush == nil { if sub.WebPush == nil {
return notification.NewPermanentError(fmt.Errorf("no push endpoint configured for %s", mapping.AppName)) return notification.NewPermanentError(fmt.Errorf("no push endpoint configured for subscription %s", sub.ID))
} }
payload, err := json.Marshal(notif) 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) return fmt.Errorf("failed to marshal notification: %w", err)
} }
if mapping.WebPush.HasEncryption() { if sub.WebPush.HasEncryption() {
subscription := &webpush.Subscription{ subscription := &webpush.Subscription{
Endpoint: mapping.WebPush.Endpoint, Endpoint: sub.WebPush.Endpoint,
Keys: webpush.Keys{ Keys: webpush.Keys{
P256dh: mapping.WebPush.P256dh, P256dh: sub.WebPush.P256dh,
Auth: mapping.WebPush.Auth, Auth: sub.WebPush.Auth,
}, },
} }
resp, err := webpush.SendNotification(payload, subscription, &webpush.Options{ resp, err := webpush.SendNotification(payload, subscription, &webpush.Options{
VAPIDPrivateKey: mapping.WebPush.VapidPrivateKey, VAPIDPrivateKey: sub.WebPush.VapidPrivateKey,
TTL: 86400, TTL: 86400,
}) })
if err != nil { 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) 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 { } 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 { if err != nil {
return fmt.Errorf("failed to send webhook: %w", err) 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) 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 return nil

View file

@ -1,6 +1,8 @@
package notification package notification
import ( import (
"crypto/rand"
"encoding/hex"
"fmt" "fmt"
"log/slog" "log/slog"
"time" "time"
@ -9,7 +11,19 @@ import (
) )
type NotificationSender interface { 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 { type Dispatcher struct {
@ -60,54 +74,65 @@ func (d *Dispatcher) IsValidChannel(channel Channel) bool {
return channel.IsAvailable(d.HasSignal(), d.HasTelegram()) 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 { func (d *Dispatcher) Send(appName string, notif Notification) error {
mapping, err := d.store.GetApp(appName) app, err := d.store.GetApp(appName)
if err != nil { 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 { if app == nil {
d.logger.Info("Registering new app", "app", appName) d.logger.Info("Registering new app and auto-configuring subscription", "app", appName)
app, err = d.CreateAppWithDefaultSubscription(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 err != nil { if err != nil {
return util.LogError(d.logger, "Failed to get mapping after registration", err, "app", appName) d.logger.Warn("Failed to auto-configure subscription", "app", appName, "error", err)
} return nil
if mapping == nil {
return fmt.Errorf("mapping still nil after registration for app: %s", appName)
} }
} }
sender, ok := d.senders[mapping.Channel] if len(app.Subscriptions) == 0 {
if !ok { d.logger.Info("App has no subscriptions, attempting to auto-configure", "app", appName)
d.logger.Error("No sender registered for channel", "channel", mapping.Channel) app, err = d.CreateAppWithDefaultSubscription(appName)
return fmt.Errorf("no sender for channel: %s", mapping.Channel) if err != nil {
d.logger.Warn("Failed to auto-configure subscription", "app", appName, "error", err)
return nil
}
} }
return d.sendWithRetry(sender, mapping, notif, appName) var lastErr error
successCount := 0
for _, sub := range app.Subscriptions {
sender, ok := d.senders[sub.Channel]
if !ok {
d.logger.Error("No sender for channel", "channel", sub.Channel, "subscriptionID", sub.ID)
lastErr = fmt.Errorf("no sender for channel: %s", sub.Channel)
continue
}
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 maxRetries := 10
baseDelay := 500 * time.Millisecond baseDelay := 500 * time.Millisecond
var lastErr error var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ { for attempt := 0; attempt < maxRetries; attempt++ {
err := sender.Send(mapping, notif) err := sender.Send(sub, notif)
if err == nil { if err == nil {
if attempt > 0 { 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 return nil
} }
@ -115,17 +140,141 @@ func (d *Dispatcher) sendWithRetry(sender NotificationSender, mapping *Mapping,
lastErr = err lastErr = err
if IsPermanent(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 return err
} }
if attempt < maxRetries-1 { if attempt < maxRetries-1 {
delay := baseDelay * time.Duration(1<<uint(attempt)) 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) 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 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 { type WebPushSubscription struct {
Endpoint string Endpoint string `json:"endpoint"`
P256dh string P256dh string `json:"p256dh,omitempty"`
Auth string Auth string `json:"auth,omitempty"`
VapidPrivateKey string VapidPrivateKey string `json:"vapidPrivateKey,omitempty"`
} }
func (w *WebPushSubscription) HasEncryption() bool { func (w *WebPushSubscription) HasEncryption() bool {
@ -88,13 +88,24 @@ func (w *WebPushSubscription) HasEncryption() bool {
} }
type SignalSubscription struct { type SignalSubscription struct {
GroupID string GroupID string `json:"groupId"`
Account string Account string `json:"account"`
} }
type Mapping struct { type TelegramSubscription struct {
Signal *SignalSubscription ChatID string `json:"chatId"`
WebPush *WebPushSubscription }
AppName string
Channel Channel 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) 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 { if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err) return nil, fmt.Errorf("failed to open database: %w", err)
} }
db.SetMaxOpenConns(1)
if err := db.Ping(); err != nil { if err := db.Ping(); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err) 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 { func (s *Store) createTables() error {
query := ` queries := []string{
CREATE TABLE IF NOT EXISTS mappings ( `CREATE TABLE IF NOT EXISTS apps (
appName TEXT PRIMARY KEY, appName TEXT PRIMARY KEY
)`,
`CREATE TABLE IF NOT EXISTS subscriptions (
id TEXT PRIMARY KEY,
appName TEXT NOT NULL,
channel TEXT NOT NULL,
signalGroupId TEXT, signalGroupId TEXT,
signalAccount TEXT, signalAccount TEXT,
channel TEXT NOT NULL DEFAULT 'webpush', telegramChatId TEXT,
pushEndpoint TEXT, pushEndpoint TEXT,
p256dh TEXT, p256dh TEXT,
auth TEXT, auth TEXT,
vapidPrivateKey TEXT vapidPrivateKey TEXT,
) FOREIGN KEY(appName) REFERENCES apps(appName) ON DELETE CASCADE
` )`,
_, err := s.db.Exec(query) `CREATE TABLE IF NOT EXISTS signal_groups (
if err != nil { appName TEXT PRIMARY KEY,
return fmt.Errorf("failed to create tables: %w", err) 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 return nil
} }
@ -66,69 +82,189 @@ func (s *Store) GetDB() *sql.DB {
return s.db return s.db
} }
func (s *Store) Register(appName string, channel *Channel, signal *SignalSubscription, webPush *WebPushSubscription) error { func (s *Store) RegisterApp(appName string) error {
var signalGroupID, signalAccount *string query := `INSERT INTO apps (appName) VALUES (?) ON CONFLICT(appName) DO NOTHING`
if signal != nil { _, err := s.db.Exec(query, appName)
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)
return err return err
} }
func (s *Store) RegisterDefault(appName string, availableChannels []Channel) error { func (s *Store) AddSubscription(sub Subscription) error {
existing, _ := s.GetApp(appName) if err := s.RegisterApp(sub.AppName); err != nil {
if existing != nil && (existing.Signal != nil || existing.WebPush != nil) { return err
return fmt.Errorf("app %s already exists with subscriptions, refusing to overwrite", appName)
} }
var channel Channel query := `
if len(availableChannels) > 0 { INSERT INTO subscriptions (id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey)
channel = availableChannels[0] VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
} else { `
channel = ChannelWebPush
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) { func (s *Store) GetApp(appName string) (*App, error) {
query := ` query := `SELECT appName FROM apps WHERE appName = ?`
SELECT appName, signalGroupId, signalAccount, channel, pushEndpoint, p256dh, auth, vapidPrivateKey
FROM mappings
WHERE appName = ?
`
row := s.db.QueryRow(query, appName) row := s.db.QueryRow(query, appName)
var m Mapping var app App
var signalGroupID, signalAccount, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString if err := row.Scan(&app.AppName); err == sql.ErrNoRows {
err := row.Scan(&m.AppName, &signalGroupID, &signalAccount, &m.Channel, &pushEndpoint, &p256dh, &auth, &vapidPrivateKey) 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 { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@ -137,117 +273,35 @@ func (s *Store) GetApp(appName string) (*Mapping, error) {
} }
if signalGroupID.Valid && signalAccount.Valid { if signalGroupID.Valid && signalAccount.Valid {
m.Signal = &SignalSubscription{ sub.Signal = &SignalSubscription{
GroupID: signalGroupID.String, GroupID: signalGroupID.String,
Account: signalAccount.String, Account: signalAccount.String,
} }
} }
if telegramChatID.Valid {
sub.Telegram = &TelegramSubscription{
ChatID: telegramChatID.String,
}
}
if pushEndpoint.Valid { if pushEndpoint.Valid {
m.WebPush = &WebPushSubscription{ sub.WebPush = &WebPushSubscription{
Endpoint: pushEndpoint.String, Endpoint: pushEndpoint.String,
} }
if p256dh.Valid { if p256dh.Valid {
m.WebPush.P256dh = p256dh.String sub.WebPush.P256dh = p256dh.String
} }
if auth.Valid { if auth.Valid {
m.WebPush.Auth = auth.String sub.WebPush.Auth = auth.String
} }
if vapidPrivateKey.Valid { if vapidPrivateKey.Valid {
m.WebPush.VapidPrivateKey = vapidPrivateKey.String sub.WebPush.VapidPrivateKey = vapidPrivateKey.String
} }
} }
return &m, nil return &sub, 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 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
}
}
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
} }
func (s *Store) RemoveApp(appName string) error { func (s *Store) RemoveApp(appName string) error {
query := `DELETE FROM mappings WHERE appName = ?` _, err := s.db.Exec(`DELETE FROM apps WHERE appName = ?`, 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)
return err 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 ( import (
"bytes" "bytes"
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"prism/service/integration/signal"
"prism/service/notification" "prism/service/notification"
"prism/service/util" "prism/service/util"
) )
type AppListItem struct { type AppListItem struct {
AppName string AppName string
Channel string Channels []ChannelState
ChannelBadge string
ChannelConfigured bool
Tooltip string
Hostname string
ChannelOptions []SelectOption
} }
type SelectOption struct { type ChannelState struct {
Value string Channel string
Label 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) { func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
mappings, err := s.store.GetAllMappings() apps, err := s.store.GetAllApps()
if err != nil { if err != nil {
util.LogAndError(w, s.logger, "Internal server error", http.StatusInternalServerError, err) util.LogAndError(w, s.logger, "Internal server error", http.StatusInternalServerError, err)
return return
@ -35,8 +38,10 @@ func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") w.Header().Set("Content-Type", "text/html")
data := s.buildAppListData(apps)
var buf bytes.Buffer 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) util.LogAndError(w, s.logger, "Failed to execute template", http.StatusInternalServerError, err)
return return
} }
@ -44,112 +49,111 @@ func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(buf.Bytes()) _, _ = w.Write(buf.Bytes())
} }
func (s *Server) buildAppListData(mappings []notification.Mapping) []AppListItem { func (s *Server) buildAppListData(apps []notification.App) []AppListItem {
if len(mappings) == 0 { if len(apps) == 0 {
return nil return nil
} }
signalLinked := false signalEnabled := s.integrations.Signal != nil && s.integrations.Signal.IsEnabled()
if s.integrations.Signal != nil { telegramEnabled := s.integrations.Telegram != nil && s.integrations.Telegram.IsEnabled()
handlers := s.integrations.Signal.GetHandlers() telegramBotName := ""
if handlers != nil { if telegramEnabled {
account, _ := handlers.GetClient().GetLinkedAccount()
signalLinked = account != nil
}
}
telegramLinked := false
var telegramInfo string
if s.integrations.Telegram != nil {
handlers := s.integrations.Telegram.GetHandlers() handlers := s.integrations.Telegram.GetHandlers()
if handlers != nil && handlers.GetClient() != nil { if handlers != nil {
chatID := handlers.GetChatID() client := handlers.GetClient()
telegramLinked = chatID != 0 if client != nil {
if telegramLinked { if bot, err := client.GetMe(); err == nil && bot != nil && bot.Username != "" {
if bot, err := handlers.GetClient().GetMe(); err == nil { telegramBotName = "@" + bot.Username
telegramInfo = "@" + bot.Username
} }
} }
} }
} }
items := make([]AppListItem, 0, len(mappings)) items := make([]AppListItem, 0, len(apps))
for _, m := range mappings { for _, app := range apps {
item := s.buildAppListItem(m, signalLinked, telegramLinked, telegramInfo) 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) items = append(items, item)
} }
return items 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 package server
import ( import (
"bytes"
"encoding/json" "encoding/json"
"io" "io"
"mime"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@ -16,10 +18,12 @@ import (
func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) { func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
appName := chi.URLParam(r, "appName") appName := chi.URLParam(r, "appName")
if appName == "" { decodedAppName, err := url.PathUnescape(appName)
appName = chi.URLParam(r, "endpoint") if err != nil {
http.Error(w, "Invalid app name", http.StatusBadRequest)
return
} }
appName, _ = url.QueryUnescape(appName) appName = decodedAppName
if appName == "" || strings.Contains(appName, "/") { if appName == "" || strings.Contains(appName, "/") {
http.Error(w, "Invalid app name", http.StatusBadRequest) http.Error(w, "Invalid app name", http.StatusBadRequest)
return return
@ -31,49 +35,7 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
return return
} }
var message, title string message, title := parseNtfyPayload(r, body)
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")
}
}
if message == "" { if message == "" {
http.Error(w, "Message required", http.StatusBadRequest) 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 { func truncate(s string, max int) string {
if len(s) <= max { if len(s) <= max {
return s 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) 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) 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/apps", s.handleFragmentApps)
r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/integrations", s.handleFragmentIntegrations) 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.Use(authMiddleware(s.cfg.APIKey))
r.Delete("/app/{appName}", s.handleDeleteAppAction) r.Delete("/{appName}", s.handleDeleteApp)
r.Post("/toggle-channel", s.handleToggleChannelAction) r.Post("/{appName}/subscriptions", s.handleCreateSubscription)
}) r.Delete("/{appName}/subscriptions/{subscriptionId}", s.handleDeleteSubscription)
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.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)).Get("/api/v1/health", s.handleHealth)
r.With(authMiddleware(s.cfg.APIKey)).Post("/{appName}", s.handleNtfyPublish) r.With(authMiddleware(s.cfg.APIKey)).Post("/{appName}", s.handleNtfyPublish)

View file

@ -1,38 +1,56 @@
{{if .}} {{if .}}
<ul class="app-list"> <ul class="app-list">
{{range .}} {{range .}}
{{$app := .}}
<li class="app-item"> <li class="app-item">
<div class="app-info"> <div class="app-info">
<div class="app-name"><strong>{{.AppName}}</strong></div> <div class="app-name"><strong>{{.AppName}}</strong></div>
<div class="app-channel"> {{if .Channels}}
{{if .ChannelConfigured}} <div class="app-subscriptions">
<span class="channel-badge channel-{{.Channel}}"> {{range .Channels}}
{{.ChannelBadge}} {{$channel := .}}
{{if .Tooltip}}<span class="tooltip">{{.Tooltip}}</span>{{end}} {{if .Toggleable}}
</span> {{if .Active}}
{{else}} <button type="button" class="channel-badge channel-{{.Channel}} badge-active"
<span class="channel-badge channel-not-configured"> hx-delete="/apps/{{$app.AppName}}/subscriptions/{{(index .Subscriptions 0).ID}}"
{{.ChannelBadge}} hx-target="#apps-list"
{{if .Tooltip}}<span class="tooltip">{{.Tooltip}}</span>{{end}} hx-swap="innerHTML">
</span> {{.Label}}
{{end}} <span class="tooltip">{{if (index .Subscriptions 0).Tooltip}}{{(index .Subscriptions 0).Tooltip}}{{else}}Click to disable{{end}}</span>
{{if .Hostname}} </button>
<span class="app-detail">{{.Hostname}}</span> {{else}}
<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}}
{{end}}
{{end}} {{end}}
</div> </div>
{{else}}
<div class="app-no-subscriptions">
<span class="channel-badge channel-not-configured">No channels enabled</span>
</div>
{{end}}
</div> </div>
<div class="app-actions"> <div class="app-actions">
{{if gt (len .ChannelOptions) 1}} <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>
<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>
</div> </div>
</li> </li>
{{end}} {{end}}

View file

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

View file

@ -6,6 +6,18 @@ import (
"net/http" "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) { func LogAndError(w http.ResponseWriter, logger *slog.Logger, message string, code int, err error, attrs ...any) {
logAttrs := append([]any{"error", err}, attrs...) logAttrs := append([]any{"error", err}, attrs...)
logger.Error(message, logAttrs...) logger.Error(message, logAttrs...)