mirror of
https://github.com/lone-cloud/prism
synced 2026-06-03 08:43:10 -07:00
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:
parent
fdb2de1b0f
commit
6fb5babf87
29 changed files with 1242 additions and 787 deletions
59
README.md
59
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.3.0
|
||||
0.4.0
|
||||
182
public/index.css
182
public/index.css
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
33
public/toast.js
Normal 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
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ¬ification.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,33 +70,22 @@ 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)
|
||||
}
|
||||
signalGroupID = newGroupID
|
||||
|
||||
if err := s.store.UpdateSignal(mapping.AppName, ¬ification.SignalSubscription{
|
||||
GroupID: signalGroupID,
|
||||
Account: accountNumber,
|
||||
}); err != nil {
|
||||
return util.LogError(s.logger, "Failed to persist signal group", err)
|
||||
}
|
||||
} else {
|
||||
signalGroupID = mapping.Signal.GroupID
|
||||
return notification.NewPermanentError(fmt.Errorf("signal group not configured for subscription %s", sub.ID))
|
||||
}
|
||||
|
||||
signalGroupID = sub.Signal.GroupID
|
||||
|
||||
message := notif.Message
|
||||
if notif.Title != "" {
|
||||
message = fmt.Sprintf("%s\n\n%s", notif.Title, notif.Message)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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)
|
||||
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("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(),
|
||||
"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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 !ok {
|
||||
d.logger.Error("No sender registered for channel", "channel", mapping.Channel)
|
||||
return fmt.Errorf("no sender for channel: %s", 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
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
return fmt.Errorf("failed to create tables: %w", err)
|
||||
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 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
|
||||
}
|
||||
}
|
||||
|
||||
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 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
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 = ¬ification.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
|
||||
}
|
||||
37
service/server/handlers_apps.go
Normal file
37
service/server/handlers_apps.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
AppName string
|
||||
Channels []ChannelState
|
||||
}
|
||||
|
||||
type SelectOption struct {
|
||||
Value string
|
||||
Label string
|
||||
Selected bool
|
||||
type ChannelState struct {
|
||||
Channel string
|
||||
Label string
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
168
service/server/handlers_subscriptions.go
Normal file
168
service/server/handlers_subscriptions.go
Normal 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 = ¬ification.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 = ¬ification.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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{{else}}
|
||||
<span class="channel-badge channel-not-configured">
|
||||
{{.ChannelBadge}}
|
||||
{{if .Tooltip}}<span class="tooltip">{{.Tooltip}}</span>{{end}}
|
||||
</span>
|
||||
{{end}}
|
||||
{{if .Hostname}}
|
||||
<span class="app-detail">{{.Hostname}}</span>
|
||||
{{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}}
|
||||
<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}}
|
||||
</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}}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue