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.
|
Reboot your Home Assistant system and you'll then be able to send Signal notifications to yourself by using this notify prism action.
|
||||||
|
|
||||||
## API Reference
|
## Sending Notifications
|
||||||
|
|
||||||
### Send Notification
|
Send notifications via HTTP POST to `/{appName}`:
|
||||||
|
|
||||||
#### POST /{appName}
|
**JSON format:**
|
||||||
|
|
||||||
Send a notification to a specific app. Messages are routed based on your app configuration in the web UI.
|
|
||||||
|
|
||||||
JSON format:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8080/my-app \
|
curl -X POST http://localhost:8080/my-app \
|
||||||
|
|
@ -190,7 +186,7 @@ curl -X POST http://localhost:8080/my-app \
|
||||||
-d '{"title": "Alert", "message": "Something happened"}'
|
-d '{"title": "Alert", "message": "Something happened"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Plain text (ntfy-compatible):
|
**Plain text (ntfy-compatible):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8080/my-app \
|
curl -X POST http://localhost:8080/my-app \
|
||||||
|
|
@ -198,52 +194,7 @@ curl -X POST http://localhost:8080/my-app \
|
||||||
-d "Simple message text"
|
-d "Simple message text"
|
||||||
```
|
```
|
||||||
|
|
||||||
**App Routing:**
|
Messages are routed based on your app's subscriptions configured in the web UI. Apps can have multiple subscriptions (Signal + WebPush + Telegram) and will receive notifications on all configured channels.
|
||||||
- Configure which integration(s) receive messages from each app via the web UI
|
|
||||||
- Apps can route to Signal, Telegram, WebPush, or multiple destinations
|
|
||||||
- Special apps like "Proton Mail" are created automatically
|
|
||||||
|
|
||||||
### WebPush/Webhook Management
|
|
||||||
|
|
||||||
#### POST /api/v1/webpush/app
|
|
||||||
|
|
||||||
Register or update a WebPush subscription or plain webhook.
|
|
||||||
|
|
||||||
Encrypted WebPush (all crypto fields required):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/api/v1/webpush/app \
|
|
||||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"appName": "my-app",
|
|
||||||
"pushEndpoint": "https://updates.push.services.mozilla.org/...",
|
|
||||||
"p256dh": "base64-encoded-key",
|
|
||||||
"auth": "base64-encoded-auth",
|
|
||||||
"vapidPrivateKey": "base64-encoded-vapid-key"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Plain HTTP webhook (no encryption):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST http://localhost:8080/api/v1/webpush/app \
|
|
||||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{
|
|
||||||
"appName": "my-app",
|
|
||||||
"pushEndpoint": "https://your-server.com/webhook"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
#### DELETE /api/v1/webpush/app/{appName}
|
|
||||||
|
|
||||||
Unregister a WebPush subscription (clears WebPush settings, reverts to Signal).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X DELETE http://localhost:8080/api/v1/webpush/app/my-app \
|
|
||||||
-H "Authorization: Bearer YOUR_API_KEY"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Monitoring
|
## Monitoring
|
||||||
|
|
||||||
|
|
|
||||||
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 */
|
/* Tooltips */
|
||||||
.channel-badge:has(.tooltip),
|
|
||||||
.integration-status:has(.tooltip) {
|
.integration-status:has(.tooltip) {
|
||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
|
@ -172,7 +171,7 @@ details[open] > .card-header::before {
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.2);
|
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip::after {
|
.tooltip::after {
|
||||||
|
|
@ -232,18 +231,11 @@ details[open] > .card-header::before {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-name {
|
.app-name {
|
||||||
font-size: 1.1em;
|
font-size: 1.2em;
|
||||||
}
|
|
||||||
|
|
||||||
.app-channel {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-badge {
|
.channel-badge {
|
||||||
|
|
@ -252,26 +244,69 @@ details[open] > .card-header::before {
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-signal {
|
.badge-active {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-active:hover {
|
||||||
|
filter: brightness(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-active.htmx-request {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-inactive {
|
||||||
|
cursor: pointer;
|
||||||
|
border: 0.0625rem dashed currentColor;
|
||||||
|
transition: filter 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-inactive:hover {
|
||||||
|
filter: brightness(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-inactive.htmx-request {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-signal.badge-active {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--text-on-color);
|
color: var(--text-on-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-webpush {
|
.channel-signal.badge-inactive {
|
||||||
|
color: var(--accent);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-webpush.badge-active {
|
||||||
background: var(--success);
|
background: var(--success);
|
||||||
color: var(--text-on-color);
|
color: var(--text-on-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-telegram {
|
.channel-telegram.badge-active {
|
||||||
background: #0088cc;
|
background: var(--accent);
|
||||||
color: var(--text-on-color);
|
color: var(--text-on-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-detail {
|
.channel-telegram.badge-inactive {
|
||||||
color: var(--text-secondary);
|
color: var(--accent);
|
||||||
font-size: 0.85em;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-actions {
|
.app-actions {
|
||||||
|
|
@ -280,20 +315,6 @@ details[open] > .card-header::before {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-form {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.channel-select {
|
|
||||||
padding: 0.375rem 0.5rem;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
color: var(--text-primary);
|
|
||||||
border: 0.0625rem solid var(--border-color);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete {
|
.btn-delete {
|
||||||
padding: 0.375rem 0.75rem;
|
padding: 0.375rem 0.75rem;
|
||||||
background: var(--error);
|
background: var(--error);
|
||||||
|
|
@ -309,6 +330,23 @@ details[open] > .card-header::before {
|
||||||
filter: brightness(0.85);
|
filter: brightness(0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-delete-sub {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 1.1em;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-sub:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
/* Loading Spinner */
|
/* Loading Spinner */
|
||||||
.loading {
|
.loading {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|
@ -401,8 +439,9 @@ details[open] > .integration-header::before {
|
||||||
}
|
}
|
||||||
|
|
||||||
.integration-status.unlinked {
|
.integration-status.unlinked {
|
||||||
background: var(--text-secondary);
|
background: var(--bg-secondary);
|
||||||
color: var(--text-on-color);
|
color: var(--text-primary);
|
||||||
|
border: 0.0625rem solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-instructions {
|
.link-instructions {
|
||||||
|
|
@ -477,8 +516,9 @@ details[open] > .integration-header::before {
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: var(--text-secondary);
|
background: var(--bg-secondary);
|
||||||
color: var(--text-on-color);
|
color: var(--text-primary);
|
||||||
|
border: 0.0625rem solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
|
|
@ -533,3 +573,73 @@ details[open] > .integration-header::before {
|
||||||
height: auto;
|
height: auto;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toast Notifications */
|
||||||
|
#toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0.875rem 1.25rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow:
|
||||||
|
0 0.25rem 0.5rem rgba(0, 0, 0, 0.1),
|
||||||
|
0 0 0 0.0625rem var(--border-color);
|
||||||
|
min-width: 15rem;
|
||||||
|
max-width: 25rem;
|
||||||
|
pointer-events: auto;
|
||||||
|
animation: toastSlideIn 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.success {
|
||||||
|
background: var(--success);
|
||||||
|
color: var(--text-on-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.error {
|
||||||
|
background: var(--error);
|
||||||
|
color: var(--text-on-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.info {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-on-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.hiding {
|
||||||
|
animation: toastSlideOut 0.3s ease forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toastSlideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(calc(100% + 1.5rem));
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toastSlideOut {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(calc(100% + 1.5rem));
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,13 @@
|
||||||
<link rel="stylesheet" href="/index.css?v={{.Version}}">
|
<link rel="stylesheet" href="/index.css?v={{.Version}}">
|
||||||
<script src="/htmx.min.js"></script>
|
<script src="/htmx.min.js"></script>
|
||||||
<script src="/theme.js?v={{.Version}}"></script>
|
<script src="/theme.js?v={{.Version}}"></script>
|
||||||
|
<script src="/toast.js?v={{.Version}}"></script>
|
||||||
<script src="/integration.js?v={{.Version}}"></script>
|
<script src="/integration.js?v={{.Version}}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<details class="card" open>
|
<details class="card" open>
|
||||||
<summary class="card-header">
|
<summary class="card-header">
|
||||||
<span class="integration-name">Registered Applications</span>
|
<span class="integration-name">Registered Apps</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div id="apps-list" class="card-content"
|
<div id="apps-list" class="card-content"
|
||||||
hx-get="/fragment/apps"
|
hx-get="/fragment/apps"
|
||||||
|
|
@ -28,9 +29,11 @@
|
||||||
|
|
||||||
<div id="integrations"
|
<div id="integrations"
|
||||||
hx-get="/fragment/integrations"
|
hx-get="/fragment/integrations"
|
||||||
hx-trigger="load">
|
hx-trigger="load, reload">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="theme-toggle" class="theme-toggle"></button>
|
<div id="toast-container"></div>
|
||||||
|
|
||||||
|
<button type="button" id="theme-toggle" class="theme-toggle"></button>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
if (action === 'link-signal') linkSignal(btn);
|
if (action === 'link-signal') linkSignal(btn);
|
||||||
else if (action === 'delete-telegram') deleteTelegram(btn);
|
else if (action === 'delete-telegram') deleteTelegram(btn);
|
||||||
else if (action === 'delete-proton') deleteProton(btn);
|
else if (action === 'delete-proton') deleteProton(btn);
|
||||||
else if (action === 'reload') location.reload();
|
else if (action === 'reload') reloadIntegrations();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function reloadIntegrations() {
|
||||||
|
const integrations = document.getElementById('integrations');
|
||||||
|
if (integrations) {
|
||||||
|
htmx.trigger(integrations, 'reload');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAuthForm(form, endpoint, statusId, getPayload) {
|
async function handleAuthForm(form, endpoint, statusId, getPayload) {
|
||||||
const status = document.getElementById(statusId);
|
const status = document.getElementById(statusId);
|
||||||
const btn = form.querySelector('button[type="submit"]');
|
const btn = form.querySelector('button[type="submit"]');
|
||||||
|
|
@ -41,7 +48,8 @@ async function handleAuthForm(form, endpoint, statusId, getPayload) {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
location.reload();
|
checkToast(response);
|
||||||
|
reloadIntegrations();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
showError(error.error || 'Failed to save');
|
showError(error.error || 'Failed to save');
|
||||||
|
|
@ -51,10 +59,24 @@ async function handleAuthForm(form, endpoint, statusId, getPayload) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkToast(response) {
|
||||||
|
const trigger = response.headers.get('HX-Trigger');
|
||||||
|
if (trigger) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(trigger);
|
||||||
|
if (data.showToast && window.showToast) {
|
||||||
|
showToast(data.showToast.message, data.showToast.type);
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submitTelegramAuth(e) {
|
async function submitTelegramAuth(e) {
|
||||||
await handleAuthForm(
|
await handleAuthForm(
|
||||||
e.target,
|
e.target,
|
||||||
'/api/v1/telegram/auth',
|
'/api/v1/telegram/link',
|
||||||
'telegram-auth-status',
|
'telegram-auth-status',
|
||||||
(fd) => ({
|
(fd) => ({
|
||||||
bot_token: fd.get('bot_token'),
|
bot_token: fd.get('bot_token'),
|
||||||
|
|
@ -66,7 +88,7 @@ async function submitTelegramAuth(e) {
|
||||||
async function submitTelegramChatId(e) {
|
async function submitTelegramChatId(e) {
|
||||||
await handleAuthForm(
|
await handleAuthForm(
|
||||||
e.target,
|
e.target,
|
||||||
'/api/v1/telegram/auth',
|
'/api/v1/telegram/link',
|
||||||
'telegram-chatid-status',
|
'telegram-chatid-status',
|
||||||
(fd, form) => ({
|
(fd, form) => ({
|
||||||
bot_token: form.dataset.botToken,
|
bot_token: form.dataset.botToken,
|
||||||
|
|
@ -120,7 +142,11 @@ async function linkSignal(btn) {
|
||||||
qrCode.src = qrUrl;
|
qrCode.src = qrUrl;
|
||||||
qrContainer.style.display = 'block';
|
qrContainer.style.display = 'block';
|
||||||
|
|
||||||
|
let statusCheckInProgress = false;
|
||||||
signalLinkingPoll = setInterval(async () => {
|
signalLinkingPoll = setInterval(async () => {
|
||||||
|
if (statusCheckInProgress) return;
|
||||||
|
|
||||||
|
statusCheckInProgress = true;
|
||||||
try {
|
try {
|
||||||
const statusResp = await fetch('/api/v1/signal/status');
|
const statusResp = await fetch('/api/v1/signal/status');
|
||||||
const statusData = await statusResp.json();
|
const statusData = await statusResp.json();
|
||||||
|
|
@ -129,10 +155,13 @@ async function linkSignal(btn) {
|
||||||
clearInterval(signalLinkingPoll);
|
clearInterval(signalLinkingPoll);
|
||||||
qrContainer.innerHTML =
|
qrContainer.innerHTML =
|
||||||
'<p class="auth-status success">Linked! Refreshing...</p>';
|
'<p class="auth-status success">Linked! Refreshing...</p>';
|
||||||
setTimeout(() => location.reload(), 1000);
|
showToast('Signal linked', 'success');
|
||||||
|
setTimeout(() => reloadIntegrations(), 1000);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Status check failed:', err);
|
console.error('Status check failed:', err);
|
||||||
|
} finally {
|
||||||
|
statusCheckInProgress = false;
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -146,10 +175,11 @@ async function deleteTelegram(btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/telegram/auth', { method: 'DELETE' });
|
const response = await fetch('/api/v1/telegram/link', { method: 'DELETE' });
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
location.reload();
|
checkToast(response);
|
||||||
|
reloadIntegrations();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(`Error: ${error.error || 'Failed to unlink integration'}`);
|
alert(`Error: ${error.error || 'Failed to unlink integration'}`);
|
||||||
|
|
@ -170,7 +200,8 @@ async function deleteProton(btn) {
|
||||||
const response = await fetch('/api/v1/proton/auth', { method: 'DELETE' });
|
const response = await fetch('/api/v1/proton/auth', { method: 'DELETE' });
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
location.reload();
|
checkToast(response);
|
||||||
|
reloadIntegrations();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
alert(`Error: ${error.error || 'Failed to unlink integration'}`);
|
alert(`Error: ${error.error || 'Failed to unlink integration'}`);
|
||||||
|
|
|
||||||
33
public/toast.js
Normal file
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) {
|
func (m *Monitor) clearNotification(msgID string) {
|
||||||
mapping, err := m.dispatcher.GetStore().GetApp(prismTopic)
|
app, err := m.dispatcher.GetStore().GetApp(prismTopic)
|
||||||
if err != nil || mapping == nil {
|
if err != nil || app == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if mapping.Channel != notification.ChannelWebPush {
|
hasWebPush := false
|
||||||
|
for _, sub := range app.Subscriptions {
|
||||||
|
if sub.Channel == notification.ChannelWebPush {
|
||||||
|
hasWebPush = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasWebPush {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,10 +116,10 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.dispatcher != nil {
|
if h.dispatcher != nil {
|
||||||
if err := h.dispatcher.RegisterApp(prismTopic); err != nil {
|
if _, err := h.dispatcher.CreateAppWithDefaultSubscription(prismTopic); err != nil {
|
||||||
h.logger.Warn("Failed to auto-create Proton Mail app mapping", "error", err)
|
h.logger.Warn("Failed to auto-create Proton Mail app with subscription", "error", err)
|
||||||
} else {
|
} else {
|
||||||
h.logger.Info("Created Proton Mail app mapping")
|
h.logger.Info("Created Proton Mail app with default subscription")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,6 +136,7 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
util.SetToast(w, "Proton Mail linked", "success")
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||||
}
|
}
|
||||||
|
|
@ -154,6 +155,7 @@ func (h *authHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
util.SetToast(w, "Proton Mail unlinked", "success")
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
|
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ func (h *Handlers) HandleFragment(w http.ResponseWriter, r *http.Request) {
|
||||||
} else if account == nil {
|
} else if account == nil {
|
||||||
integData.StatusClass = "disconnected"
|
integData.StatusClass = "disconnected"
|
||||||
integData.StatusText = "Unlinked"
|
integData.StatusText = "Unlinked"
|
||||||
integData.StatusTooltip = "Click to link device with Signal"
|
integData.StatusTooltip = "Click Link button below to link"
|
||||||
integData.Open = true
|
integData.Open = true
|
||||||
} else {
|
} else {
|
||||||
integData.StatusClass = "connected"
|
integData.StatusClass = "connected"
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,10 @@ type Integration struct {
|
||||||
|
|
||||||
func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration {
|
func NewIntegration(cfg *config.Config, store *notification.Store, logger *slog.Logger, tmpl *util.TemplateRenderer) *Integration {
|
||||||
client := NewClient()
|
client := NewClient()
|
||||||
sender := NewSender(client, store, logger)
|
var sender *Sender
|
||||||
|
if client.IsEnabled() {
|
||||||
|
sender = NewSender(client, store, logger)
|
||||||
|
}
|
||||||
return &Integration{
|
return &Integration{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
client: client,
|
client: client,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
if s.client == nil {
|
||||||
return notification.NewPermanentError(fmt.Errorf("signal integration not enabled"))
|
return notification.NewPermanentError(fmt.Errorf("signal integration not enabled"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sub.Signal == nil {
|
||||||
|
return notification.NewPermanentError(fmt.Errorf("no signal subscription data"))
|
||||||
|
}
|
||||||
|
|
||||||
account, err := s.client.GetLinkedAccount()
|
account, err := s.client.GetLinkedAccount()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return util.LogError(s.logger, "Failed to get linked account", err)
|
return util.LogError(s.logger, "Failed to get linked account", err)
|
||||||
|
|
@ -37,33 +70,22 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica
|
||||||
}
|
}
|
||||||
|
|
||||||
var signalGroupID string
|
var signalGroupID string
|
||||||
needsNewGroup := mapping.Signal == nil || mapping.Signal.GroupID == ""
|
needsNewGroup := sub.Signal.GroupID == ""
|
||||||
|
|
||||||
if !needsNewGroup && mapping.Signal.Account != account.Number {
|
if !needsNewGroup && sub.Signal.Account != account.Number {
|
||||||
s.logger.Info("Signal account changed, recreating group",
|
s.logger.Info("Signal account changed, recreating group",
|
||||||
"app", mapping.AppName,
|
"app", sub.AppName,
|
||||||
"oldAccount", mapping.Signal.Account,
|
"oldAccount", sub.Signal.Account,
|
||||||
"newAccount", account.Number)
|
"newAccount", account.Number)
|
||||||
needsNewGroup = true
|
needsNewGroup = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if needsNewGroup {
|
if needsNewGroup {
|
||||||
newGroupID, accountNumber, err := s.client.CreateGroup(mapping.AppName)
|
return notification.NewPermanentError(fmt.Errorf("signal group not configured for subscription %s", sub.ID))
|
||||||
if err != nil {
|
|
||||||
return util.LogError(s.logger, "Failed to create group", err, "app", mapping.AppName)
|
|
||||||
}
|
|
||||||
signalGroupID = newGroupID
|
|
||||||
|
|
||||||
if err := s.store.UpdateSignal(mapping.AppName, ¬ification.SignalSubscription{
|
|
||||||
GroupID: signalGroupID,
|
|
||||||
Account: accountNumber,
|
|
||||||
}); err != nil {
|
|
||||||
return util.LogError(s.logger, "Failed to persist signal group", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
signalGroupID = mapping.Signal.GroupID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signalGroupID = sub.Signal.GroupID
|
||||||
|
|
||||||
message := notif.Message
|
message := notif.Message
|
||||||
if notif.Title != "" {
|
if notif.Title != "" {
|
||||||
message = fmt.Sprintf("%s\n\n%s", notif.Title, notif.Message)
|
message = fmt.Sprintf("%s\n\n%s", notif.Title, notif.Message)
|
||||||
|
|
|
||||||
|
|
@ -20,19 +20,19 @@ func GetTemplates() embed.FS {
|
||||||
return templates
|
return templates
|
||||||
}
|
}
|
||||||
|
|
||||||
type authHandler struct {
|
type linkHandler struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
apiKey string
|
apiKey string
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
type telegramAuthRequest struct {
|
type telegramLinkRequest struct {
|
||||||
BotToken string `json:"bot_token"`
|
BotToken string `json:"bot_token"`
|
||||||
ChatID string `json:"chat_id"`
|
ChatID string `json:"chat_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
|
func (h *linkHandler) handleLink(w http.ResponseWriter, r *http.Request) {
|
||||||
var req telegramAuthRequest
|
var req telegramLinkRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
util.JSONError(w, "Invalid request body", http.StatusBadRequest)
|
util.JSONError(w, "Invalid request body", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|
@ -59,11 +59,12 @@ func (h *authHandler) handleAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
util.SetToast(w, "Telegram linked", "success")
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
json.NewEncoder(w).Encode(map[string]string{"status": "success"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *authHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
func (h *linkHandler) handleUnlink(w http.ResponseWriter, r *http.Request) {
|
||||||
credStore, err := credentials.NewStore(h.db, h.apiKey)
|
credStore, err := credentials.NewStore(h.db, h.apiKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
util.LogAndError(w, h.logger, "Failed to initialize credentials store", http.StatusInternalServerError, err)
|
util.LogAndError(w, h.logger, "Failed to initialize credentials store", http.StatusInternalServerError, err)
|
||||||
|
|
@ -75,6 +76,7 @@ func (h *authHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
util.SetToast(w, "Telegram unlinked", "success")
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
|
json.NewEncoder(w).Encode(map[string]string{"status": "deleted"})
|
||||||
}
|
}
|
||||||
|
|
@ -86,9 +88,9 @@ func RegisterRoutes(router *chi.Mux, handlers *Handlers, auth func(http.Handler)
|
||||||
|
|
||||||
handlers.SetDB(db, apiKey)
|
handlers.SetDB(db, apiKey)
|
||||||
|
|
||||||
authH := &authHandler{db: db, apiKey: apiKey, logger: logger}
|
linkH := &linkHandler{db: db, apiKey: apiKey, logger: logger}
|
||||||
|
|
||||||
router.With(auth).Get("/fragment/telegram", handlers.HandleFragment)
|
router.With(auth).Get("/fragment/telegram", handlers.HandleFragment)
|
||||||
router.With(auth).Post("/api/v1/telegram/auth", authH.handleAuth)
|
router.With(auth).Post("/api/v1/telegram/link", linkH.handleLink)
|
||||||
router.With(auth).Delete("/api/v1/telegram/auth", authH.handleDelete)
|
router.With(auth).Delete("/api/v1/telegram/link", linkH.handleUnlink)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,15 @@ package telegram
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"prism/service/notification"
|
"prism/service/notification"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func parseInt64(s string) (int64, error) {
|
||||||
|
return strconv.ParseInt(s, 10, 64)
|
||||||
|
}
|
||||||
|
|
||||||
type Sender struct {
|
type Sender struct {
|
||||||
client *Client
|
client *Client
|
||||||
store *notification.Store
|
store *notification.Store
|
||||||
|
|
@ -23,13 +28,33 @@ func NewSender(client *Client, store *notification.Store, logger *slog.Logger, d
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error {
|
func (s *Sender) GetChatID() int64 {
|
||||||
|
return s.DefaultChatID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sender) IsLinked() (bool, error) {
|
||||||
|
if s.client == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.client.IsAvailable() {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.DefaultChatID == 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Sender) Send(sub *notification.Subscription, notif notification.Notification) error {
|
||||||
if s.client == nil {
|
if s.client == nil {
|
||||||
return notification.NewPermanentError(fmt.Errorf("telegram integration not enabled"))
|
return notification.NewPermanentError(fmt.Errorf("telegram integration not enabled"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.DefaultChatID == 0 {
|
if sub.Telegram == nil || sub.Telegram.ChatID == "" {
|
||||||
return notification.NewPermanentError(fmt.Errorf("no telegram chat configured (set TELEGRAM_CHAT_ID in .env)"))
|
return notification.NewPermanentError(fmt.Errorf("no telegram chat configured for subscription"))
|
||||||
}
|
}
|
||||||
|
|
||||||
message := notif.Message
|
message := notif.Message
|
||||||
|
|
@ -37,10 +62,15 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica
|
||||||
message = fmt.Sprintf("<b>%s</b>\n%s", notif.Title, notif.Message)
|
message = fmt.Sprintf("<b>%s</b>\n%s", notif.Title, notif.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
fullMessage := fmt.Sprintf("<b>%s</b>\n\n%s", mapping.AppName, message)
|
fullMessage := fmt.Sprintf("<b>%s</b>\n\n%s", sub.AppName, message)
|
||||||
|
|
||||||
if err := s.client.SendMessage(s.DefaultChatID, fullMessage); err != nil {
|
chatID, err := parseInt64(sub.Telegram.ChatID)
|
||||||
s.logger.Error("Failed to send telegram message", "chatID", s.DefaultChatID, "error", err)
|
if err != nil {
|
||||||
|
return notification.NewPermanentError(fmt.Errorf("invalid chat ID: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.client.SendMessage(chatID, fullMessage); err != nil {
|
||||||
|
s.logger.Error("Failed to send telegram message", "chatID", chatID, "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package webpush
|
package webpush
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -12,6 +14,14 @@ import (
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func generateSubscriptionID() (string, error) {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
store *notification.Store
|
store *notification.Store
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
|
@ -49,7 +59,7 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
existing, err := h.store.GetApp(req.AppName)
|
subID, err := generateSubscriptionID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err)
|
util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
|
|
@ -69,47 +79,47 @@ func (h *Handlers) HandleRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if existing != nil {
|
sub := notification.Subscription{
|
||||||
if err := h.store.UpdateWebPush(req.AppName, webPush); err != nil {
|
ID: subID,
|
||||||
util.LogAndError(w, h.logger, "Internal server error", http.StatusInternalServerError, err)
|
AppName: req.AppName,
|
||||||
return
|
Channel: notification.ChannelWebPush,
|
||||||
}
|
WebPush: webPush,
|
||||||
h.logger.Info("Updated webpush for existing endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint)
|
|
||||||
} else {
|
|
||||||
channel := notification.ChannelWebPush
|
|
||||||
if err := h.store.Register(req.AppName, &channel, nil, webPush); err != nil {
|
|
||||||
util.LogAndError(w, h.logger, "Failed to register webpush endpoint", http.StatusInternalServerError, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
h.logger.Info("Registered new webpush endpoint", "app", req.AppName, "pushEndpoint", req.PushEndpoint)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := h.store.AddSubscription(sub); err != nil {
|
||||||
|
util.LogAndError(w, h.logger, "Failed to add subscription", http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.logger.Info("Added webpush subscription", "app", req.AppName, "subscriptionID", subID, "pushEndpoint", req.PushEndpoint)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
response := map[string]string{
|
response := map[string]string{
|
||||||
"appName": req.AppName,
|
"appName": req.AppName,
|
||||||
"channel": notification.ChannelWebPush.String(),
|
"channel": notification.ChannelWebPush.String(),
|
||||||
|
"subscriptionId": subID,
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(w).Encode(response)
|
_ = json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) HandleUnregister(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleUnregister(w http.ResponseWriter, r *http.Request) {
|
||||||
appName := chi.URLParam(r, "appName")
|
subscriptionID := chi.URLParam(r, "subscriptionId")
|
||||||
if appName == "" {
|
if subscriptionID == "" {
|
||||||
http.Error(w, "appName is required", http.StatusBadRequest)
|
http.Error(w, "subscriptionId is required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.store.ClearWebPush(appName); err != nil {
|
if err := h.store.DeleteSubscription(subscriptionID); err != nil {
|
||||||
util.LogAndError(w, h.logger, "Failed to clear webpush endpoint", http.StatusInternalServerError, err)
|
util.LogAndError(w, h.logger, "Failed to delete subscription", http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.logger.Info("Cleared webpush subscription", "app", appName)
|
h.logger.Info("Deleted webpush subscription", "subscriptionID", subscriptionID)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
response := map[string]string{
|
response := map[string]string{
|
||||||
"status": "unregistered",
|
"status": "deleted",
|
||||||
"appName": appName,
|
"subscriptionId": subscriptionID,
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(w).Encode(response)
|
_ = json.NewEncoder(w).Encode(response)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,9 @@ import (
|
||||||
func RegisterRoutes(router *chi.Mux, store *notification.Store, logger *slog.Logger, authMiddleware func(http.Handler) http.Handler) {
|
func RegisterRoutes(router *chi.Mux, store *notification.Store, logger *slog.Logger, authMiddleware func(http.Handler) http.Handler) {
|
||||||
handlers := NewHandlers(store, logger)
|
handlers := NewHandlers(store, logger)
|
||||||
|
|
||||||
router.Route("/api/v1/webpush/app", func(r chi.Router) {
|
router.Route("/api/v1/webpush/subscriptions", func(r chi.Router) {
|
||||||
r.Use(authMiddleware)
|
r.Use(authMiddleware)
|
||||||
r.Post("/", handlers.HandleRegister)
|
r.Post("/", handlers.HandleRegister)
|
||||||
r.Delete("/{appName}", handlers.HandleUnregister)
|
r.Delete("/{subscriptionId}", handlers.HandleUnregister)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,9 @@ func NewSender(logger *slog.Logger) *Sender {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notification) error {
|
func (s *Sender) Send(sub *notification.Subscription, notif notification.Notification) error {
|
||||||
if mapping.WebPush == nil {
|
if sub.WebPush == nil {
|
||||||
return notification.NewPermanentError(fmt.Errorf("no push endpoint configured for %s", mapping.AppName))
|
return notification.NewPermanentError(fmt.Errorf("no push endpoint configured for subscription %s", sub.ID))
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, err := json.Marshal(notif)
|
payload, err := json.Marshal(notif)
|
||||||
|
|
@ -32,17 +32,17 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica
|
||||||
return fmt.Errorf("failed to marshal notification: %w", err)
|
return fmt.Errorf("failed to marshal notification: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if mapping.WebPush.HasEncryption() {
|
if sub.WebPush.HasEncryption() {
|
||||||
subscription := &webpush.Subscription{
|
subscription := &webpush.Subscription{
|
||||||
Endpoint: mapping.WebPush.Endpoint,
|
Endpoint: sub.WebPush.Endpoint,
|
||||||
Keys: webpush.Keys{
|
Keys: webpush.Keys{
|
||||||
P256dh: mapping.WebPush.P256dh,
|
P256dh: sub.WebPush.P256dh,
|
||||||
Auth: mapping.WebPush.Auth,
|
Auth: sub.WebPush.Auth,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := webpush.SendNotification(payload, subscription, &webpush.Options{
|
resp, err := webpush.SendNotification(payload, subscription, &webpush.Options{
|
||||||
VAPIDPrivateKey: mapping.WebPush.VapidPrivateKey,
|
VAPIDPrivateKey: sub.WebPush.VapidPrivateKey,
|
||||||
TTL: 86400,
|
TTL: 86400,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -54,9 +54,9 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica
|
||||||
return fmt.Errorf("webpush returned status %d", resp.StatusCode)
|
return fmt.Errorf("webpush returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Debug("Sent encrypted webpush notification", "app", mapping.AppName, "url", mapping.WebPush.Endpoint)
|
s.logger.Debug("Sent encrypted webpush notification", "app", sub.AppName, "url", sub.WebPush.Endpoint)
|
||||||
} else {
|
} else {
|
||||||
resp, err := http.Post(mapping.WebPush.Endpoint, "application/json", bytes.NewBuffer(payload))
|
resp, err := http.Post(sub.WebPush.Endpoint, "application/json", bytes.NewBuffer(payload))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to send webhook: %w", err)
|
return fmt.Errorf("failed to send webhook: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -66,7 +66,7 @@ func (s *Sender) Send(mapping *notification.Mapping, notif notification.Notifica
|
||||||
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
|
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Debug("Sent plain webhook notification", "app", mapping.AppName, "url", mapping.WebPush.Endpoint)
|
s.logger.Debug("Sent plain webhook notification", "app", sub.AppName, "url", sub.WebPush.Endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package notification
|
package notification
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -9,7 +11,19 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type NotificationSender interface {
|
type NotificationSender interface {
|
||||||
Send(mapping *Mapping, notif Notification) error
|
Send(sub *Subscription, notif Notification) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type linkedChecker interface {
|
||||||
|
IsLinked() (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type signalAutoConfigurer interface {
|
||||||
|
CreateDefaultSignalSubscription(appName string) (*SignalSubscription, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type telegramSender interface {
|
||||||
|
GetChatID() int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type Dispatcher struct {
|
type Dispatcher struct {
|
||||||
|
|
@ -60,54 +74,65 @@ func (d *Dispatcher) IsValidChannel(channel Channel) bool {
|
||||||
return channel.IsAvailable(d.HasSignal(), d.HasTelegram())
|
return channel.IsAvailable(d.HasSignal(), d.HasTelegram())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Dispatcher) RegisterApp(appName string) error {
|
|
||||||
availableChannels := d.GetAvailableChannels()
|
|
||||||
return d.store.RegisterDefault(appName, availableChannels)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Dispatcher) Send(appName string, notif Notification) error {
|
func (d *Dispatcher) Send(appName string, notif Notification) error {
|
||||||
mapping, err := d.store.GetApp(appName)
|
app, err := d.store.GetApp(appName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return util.LogError(d.logger, "Failed to get app mapping", err, "app", appName)
|
return util.LogError(d.logger, "Failed to get app", err, "app", appName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if mapping == nil {
|
if app == nil {
|
||||||
d.logger.Info("Registering new app", "app", appName)
|
d.logger.Info("Registering new app and auto-configuring subscription", "app", appName)
|
||||||
|
app, err = d.CreateAppWithDefaultSubscription(appName)
|
||||||
availableChannels := d.GetAvailableChannels()
|
|
||||||
if err := d.store.RegisterDefault(appName, availableChannels); err != nil {
|
|
||||||
return util.LogError(d.logger, "Failed to register app", err, "app", appName)
|
|
||||||
}
|
|
||||||
|
|
||||||
mapping, err = d.store.GetApp(appName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return util.LogError(d.logger, "Failed to get mapping after registration", err, "app", appName)
|
d.logger.Warn("Failed to auto-configure subscription", "app", appName, "error", err)
|
||||||
}
|
return nil
|
||||||
|
|
||||||
if mapping == nil {
|
|
||||||
return fmt.Errorf("mapping still nil after registration for app: %s", appName)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sender, ok := d.senders[mapping.Channel]
|
if len(app.Subscriptions) == 0 {
|
||||||
if !ok {
|
d.logger.Info("App has no subscriptions, attempting to auto-configure", "app", appName)
|
||||||
d.logger.Error("No sender registered for channel", "channel", mapping.Channel)
|
app, err = d.CreateAppWithDefaultSubscription(appName)
|
||||||
return fmt.Errorf("no sender for channel: %s", mapping.Channel)
|
if err != nil {
|
||||||
|
d.logger.Warn("Failed to auto-configure subscription", "app", appName, "error", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return d.sendWithRetry(sender, mapping, notif, appName)
|
var lastErr error
|
||||||
|
successCount := 0
|
||||||
|
|
||||||
|
for _, sub := range app.Subscriptions {
|
||||||
|
sender, ok := d.senders[sub.Channel]
|
||||||
|
if !ok {
|
||||||
|
d.logger.Error("No sender for channel", "channel", sub.Channel, "subscriptionID", sub.ID)
|
||||||
|
lastErr = fmt.Errorf("no sender for channel: %s", sub.Channel)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := d.sendWithRetry(sender, &sub, notif, appName, sub.ID); err != nil {
|
||||||
|
lastErr = err
|
||||||
|
} else {
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if successCount == 0 && lastErr != nil {
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Dispatcher) sendWithRetry(sender NotificationSender, mapping *Mapping, notif Notification, appName string) error {
|
func (d *Dispatcher) sendWithRetry(sender NotificationSender, sub *Subscription, notif Notification, appName, subscriptionID string) error {
|
||||||
maxRetries := 10
|
maxRetries := 10
|
||||||
baseDelay := 500 * time.Millisecond
|
baseDelay := 500 * time.Millisecond
|
||||||
|
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
err := sender.Send(mapping, notif)
|
err := sender.Send(sub, notif)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
d.logger.Info("Notification sent after retry", "app", appName, "attempt", attempt+1)
|
d.logger.Info("Notification sent after retry", "app", appName, "subscriptionID", subscriptionID, "attempt", attempt+1)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -115,17 +140,141 @@ func (d *Dispatcher) sendWithRetry(sender NotificationSender, mapping *Mapping,
|
||||||
lastErr = err
|
lastErr = err
|
||||||
|
|
||||||
if IsPermanent(err) {
|
if IsPermanent(err) {
|
||||||
d.logger.Error("Permanent error, not retrying", "app", appName, "error", err)
|
d.logger.Error("Permanent error, not retrying", "app", appName, "subscriptionID", subscriptionID, "error", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if attempt < maxRetries-1 {
|
if attempt < maxRetries-1 {
|
||||||
delay := baseDelay * time.Duration(1<<uint(attempt))
|
delay := baseDelay * time.Duration(1<<uint(attempt))
|
||||||
d.logger.Warn("Failed to send notification, retrying", "app", appName, "attempt", attempt+1, "error", err, "retryIn", delay)
|
d.logger.Warn("Failed to send notification, retrying", "app", appName, "subscriptionID", subscriptionID, "attempt", attempt+1, "error", err, "retryIn", delay)
|
||||||
time.Sleep(delay)
|
time.Sleep(delay)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
d.logger.Error("Failed to send notification after retries", "app", appName, "attempts", maxRetries, "error", lastErr)
|
d.logger.Error("Failed to send notification after retries", "app", appName, "subscriptionID", subscriptionID, "attempts", maxRetries, "error", lastErr)
|
||||||
return lastErr
|
return lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *Dispatcher) CreateAppWithDefaultSubscription(appName string) (*App, error) {
|
||||||
|
app, signalErr := d.trySignalAutoConfig(appName)
|
||||||
|
if signalErr == nil {
|
||||||
|
return app, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
d.logger.Warn("Signal auto-config failed, trying Telegram", "app", appName, "error", signalErr)
|
||||||
|
|
||||||
|
app, telegramErr := d.tryTelegramAutoConfig(appName)
|
||||||
|
if telegramErr == nil {
|
||||||
|
return app, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("signal auto-config failed: %v; telegram unavailable: %w", signalErr, telegramErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dispatcher) trySignalAutoConfig(appName string) (*App, error) {
|
||||||
|
sender, ok := d.senders[ChannelSignal]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("signal sender not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
linkChecker, ok := sender.(linkedChecker)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("sender does not implement signal linked check")
|
||||||
|
}
|
||||||
|
|
||||||
|
linked, err := linkChecker.IsLinked()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check Signal link status: %w", err)
|
||||||
|
}
|
||||||
|
if !linked {
|
||||||
|
return nil, fmt.Errorf("no linked Signal account")
|
||||||
|
}
|
||||||
|
|
||||||
|
autoConfigurer, ok := sender.(signalAutoConfigurer)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("sender does not implement signal auto-configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
signalSub, err := autoConfigurer.CreateDefaultSignalSubscription(appName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
subID, err := GenerateSubscriptionID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sub := Subscription{ID: subID, AppName: appName, Channel: ChannelSignal, Signal: signalSub}
|
||||||
|
if err := d.store.AddSubscription(sub); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
d.logger.Info("Auto-configured Signal subscription", "app", appName, "subscriptionID", subID)
|
||||||
|
return d.store.GetApp(appName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dispatcher) tryTelegramAutoConfig(appName string) (*App, error) {
|
||||||
|
chatID, err := d.getTelegramChatID()
|
||||||
|
if err != nil || chatID == "" {
|
||||||
|
return nil, fmt.Errorf("telegram not linked or no chat ID configured: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subID, err := GenerateSubscriptionID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sub := Subscription{
|
||||||
|
ID: subID,
|
||||||
|
AppName: appName,
|
||||||
|
Channel: ChannelTelegram,
|
||||||
|
Telegram: &TelegramSubscription{ChatID: chatID},
|
||||||
|
}
|
||||||
|
if err := d.store.AddSubscription(sub); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
d.logger.Info("Auto-configured Telegram subscription", "app", appName, "subscriptionID", subID, "chatID", chatID)
|
||||||
|
return d.store.GetApp(appName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dispatcher) getTelegramChatID() (string, error) {
|
||||||
|
sender, ok := d.senders[ChannelTelegram]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("telegram sender not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
linkChecker, ok := sender.(linkedChecker)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("sender does not implement telegram linked check")
|
||||||
|
}
|
||||||
|
|
||||||
|
linked, err := linkChecker.IsLinked()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !linked {
|
||||||
|
return "", fmt.Errorf("telegram not linked")
|
||||||
|
}
|
||||||
|
|
||||||
|
tgSender, ok := sender.(telegramSender)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("sender does not implement telegram interface")
|
||||||
|
}
|
||||||
|
|
||||||
|
chatID := tgSender.GetChatID()
|
||||||
|
if chatID == 0 {
|
||||||
|
return "", fmt.Errorf("no chat ID configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%d", chatID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateSubscriptionID() (string, error) {
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,10 +77,10 @@ func (c Channel) IsAvailable(signalEnabled bool, telegramEnabled bool) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebPushSubscription struct {
|
type WebPushSubscription struct {
|
||||||
Endpoint string
|
Endpoint string `json:"endpoint"`
|
||||||
P256dh string
|
P256dh string `json:"p256dh,omitempty"`
|
||||||
Auth string
|
Auth string `json:"auth,omitempty"`
|
||||||
VapidPrivateKey string
|
VapidPrivateKey string `json:"vapidPrivateKey,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *WebPushSubscription) HasEncryption() bool {
|
func (w *WebPushSubscription) HasEncryption() bool {
|
||||||
|
|
@ -88,13 +88,24 @@ func (w *WebPushSubscription) HasEncryption() bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
type SignalSubscription struct {
|
type SignalSubscription struct {
|
||||||
GroupID string
|
GroupID string `json:"groupId"`
|
||||||
Account string
|
Account string `json:"account"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mapping struct {
|
type TelegramSubscription struct {
|
||||||
Signal *SignalSubscription
|
ChatID string `json:"chatId"`
|
||||||
WebPush *WebPushSubscription
|
}
|
||||||
AppName string
|
|
||||||
Channel Channel
|
type Subscription struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
AppName string `json:"appName"`
|
||||||
|
Channel Channel `json:"channel"`
|
||||||
|
Signal *SignalSubscription `json:"signal,omitempty"`
|
||||||
|
WebPush *WebPushSubscription `json:"webPush,omitempty"`
|
||||||
|
Telegram *TelegramSubscription `json:"telegram,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
AppName string `json:"appName"`
|
||||||
|
Subscriptions []Subscription `json:"subscriptions"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,13 @@ func NewStore(dbPath string) (*Store, error) {
|
||||||
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
return nil, fmt.Errorf("failed to create database directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := sql.Open("sqlite", dbPath+"?_busy_timeout=5000&cache=shared")
|
// foreign_keys(1): enable FK constraints (disabled by default in SQLite)
|
||||||
|
// _busy_timeout=5000: wait up to 5s when DB is locked (default=0, fails immediately)
|
||||||
|
db, err := sql.Open("sqlite", dbPath+"?_pragma=foreign_keys(1)&_busy_timeout=5000")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
db.SetMaxOpenConns(1)
|
|
||||||
|
|
||||||
if err := db.Ping(); err != nil {
|
if err := db.Ping(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -39,22 +39,38 @@ func NewStore(dbPath string) (*Store, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) createTables() error {
|
func (s *Store) createTables() error {
|
||||||
query := `
|
queries := []string{
|
||||||
CREATE TABLE IF NOT EXISTS mappings (
|
`CREATE TABLE IF NOT EXISTS apps (
|
||||||
appName TEXT PRIMARY KEY,
|
appName TEXT PRIMARY KEY
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS subscriptions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
appName TEXT NOT NULL,
|
||||||
|
channel TEXT NOT NULL,
|
||||||
signalGroupId TEXT,
|
signalGroupId TEXT,
|
||||||
signalAccount TEXT,
|
signalAccount TEXT,
|
||||||
channel TEXT NOT NULL DEFAULT 'webpush',
|
telegramChatId TEXT,
|
||||||
pushEndpoint TEXT,
|
pushEndpoint TEXT,
|
||||||
p256dh TEXT,
|
p256dh TEXT,
|
||||||
auth TEXT,
|
auth TEXT,
|
||||||
vapidPrivateKey TEXT
|
vapidPrivateKey TEXT,
|
||||||
)
|
FOREIGN KEY(appName) REFERENCES apps(appName) ON DELETE CASCADE
|
||||||
`
|
)`,
|
||||||
_, err := s.db.Exec(query)
|
`CREATE TABLE IF NOT EXISTS signal_groups (
|
||||||
if err != nil {
|
appName TEXT PRIMARY KEY,
|
||||||
return fmt.Errorf("failed to create tables: %w", err)
|
groupId TEXT NOT NULL,
|
||||||
|
account TEXT NOT NULL,
|
||||||
|
FOREIGN KEY(appName) REFERENCES apps(appName) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_subscriptions_appName ON subscriptions(appName)`,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, query := range queries {
|
||||||
|
if _, err := s.db.Exec(query); err != nil {
|
||||||
|
return fmt.Errorf("failed to create tables: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -66,69 +82,189 @@ func (s *Store) GetDB() *sql.DB {
|
||||||
return s.db
|
return s.db
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Register(appName string, channel *Channel, signal *SignalSubscription, webPush *WebPushSubscription) error {
|
func (s *Store) RegisterApp(appName string) error {
|
||||||
var signalGroupID, signalAccount *string
|
query := `INSERT INTO apps (appName) VALUES (?) ON CONFLICT(appName) DO NOTHING`
|
||||||
if signal != nil {
|
_, err := s.db.Exec(query, appName)
|
||||||
signalGroupID = &signal.GroupID
|
|
||||||
signalAccount = &signal.Account
|
|
||||||
}
|
|
||||||
|
|
||||||
var pushEndpoint, p256dh, auth, vapidPrivateKey *string
|
|
||||||
if webPush != nil {
|
|
||||||
pushEndpoint = &webPush.Endpoint
|
|
||||||
p256dh = &webPush.P256dh
|
|
||||||
auth = &webPush.Auth
|
|
||||||
vapidPrivateKey = &webPush.VapidPrivateKey
|
|
||||||
}
|
|
||||||
|
|
||||||
ch := ChannelWebPush
|
|
||||||
if channel != nil {
|
|
||||||
ch = *channel
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `
|
|
||||||
INSERT INTO mappings (appName, signalGroupId, signalAccount, channel, pushEndpoint, p256dh, auth, vapidPrivateKey)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(appName) DO UPDATE SET
|
|
||||||
channel = excluded.channel,
|
|
||||||
signalGroupId = COALESCE(excluded.signalGroupId, mappings.signalGroupId),
|
|
||||||
signalAccount = COALESCE(excluded.signalAccount, mappings.signalAccount),
|
|
||||||
pushEndpoint = COALESCE(excluded.pushEndpoint, mappings.pushEndpoint),
|
|
||||||
p256dh = COALESCE(excluded.p256dh, mappings.p256dh),
|
|
||||||
auth = COALESCE(excluded.auth, mappings.auth),
|
|
||||||
vapidPrivateKey = COALESCE(excluded.vapidPrivateKey, mappings.vapidPrivateKey)
|
|
||||||
`
|
|
||||||
_, err := s.db.Exec(query, appName, signalGroupID, signalAccount, ch, pushEndpoint, p256dh, auth, vapidPrivateKey)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) RegisterDefault(appName string, availableChannels []Channel) error {
|
func (s *Store) AddSubscription(sub Subscription) error {
|
||||||
existing, _ := s.GetApp(appName)
|
if err := s.RegisterApp(sub.AppName); err != nil {
|
||||||
if existing != nil && (existing.Signal != nil || existing.WebPush != nil) {
|
return err
|
||||||
return fmt.Errorf("app %s already exists with subscriptions, refusing to overwrite", appName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var channel Channel
|
query := `
|
||||||
if len(availableChannels) > 0 {
|
INSERT INTO subscriptions (id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey)
|
||||||
channel = availableChannels[0]
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
} else {
|
`
|
||||||
channel = ChannelWebPush
|
|
||||||
|
var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey *string
|
||||||
|
|
||||||
|
if sub.Signal != nil {
|
||||||
|
signalGroupID = &sub.Signal.GroupID
|
||||||
|
signalAccount = &sub.Signal.Account
|
||||||
|
}
|
||||||
|
if sub.Telegram != nil {
|
||||||
|
telegramChatID = &sub.Telegram.ChatID
|
||||||
|
}
|
||||||
|
if sub.WebPush != nil {
|
||||||
|
pushEndpoint = &sub.WebPush.Endpoint
|
||||||
|
p256dh = &sub.WebPush.P256dh
|
||||||
|
auth = &sub.WebPush.Auth
|
||||||
|
vapidPrivateKey = &sub.WebPush.VapidPrivateKey
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.Register(appName, &channel, nil, nil)
|
_, err := s.db.Exec(query, sub.ID, sub.AppName, sub.Channel, signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetApp(appName string) (*Mapping, error) {
|
func (s *Store) GetApp(appName string) (*App, error) {
|
||||||
query := `
|
query := `SELECT appName FROM apps WHERE appName = ?`
|
||||||
SELECT appName, signalGroupId, signalAccount, channel, pushEndpoint, p256dh, auth, vapidPrivateKey
|
|
||||||
FROM mappings
|
|
||||||
WHERE appName = ?
|
|
||||||
`
|
|
||||||
row := s.db.QueryRow(query, appName)
|
row := s.db.QueryRow(query, appName)
|
||||||
|
|
||||||
var m Mapping
|
var app App
|
||||||
var signalGroupID, signalAccount, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString
|
if err := row.Scan(&app.AppName); err == sql.ErrNoRows {
|
||||||
err := row.Scan(&m.AppName, &signalGroupID, &signalAccount, &m.Channel, &pushEndpoint, &p256dh, &auth, &vapidPrivateKey)
|
return nil, nil
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
subs, err := s.GetSubscriptions(appName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
app.Subscriptions = subs
|
||||||
|
|
||||||
|
return &app, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetSubscriptions(appName string) ([]Subscription, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE appName = ?
|
||||||
|
`
|
||||||
|
rows, err := s.db.Query(query, appName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var subscriptions []Subscription
|
||||||
|
for rows.Next() {
|
||||||
|
var sub Subscription
|
||||||
|
var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString
|
||||||
|
|
||||||
|
if err := rows.Scan(&sub.ID, &sub.AppName, &sub.Channel, &signalGroupID, &signalAccount, &telegramChatID, &pushEndpoint, &p256dh, &auth, &vapidPrivateKey); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if signalGroupID.Valid && signalAccount.Valid {
|
||||||
|
sub.Signal = &SignalSubscription{
|
||||||
|
GroupID: signalGroupID.String,
|
||||||
|
Account: signalAccount.String,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if telegramChatID.Valid {
|
||||||
|
sub.Telegram = &TelegramSubscription{
|
||||||
|
ChatID: telegramChatID.String,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pushEndpoint.Valid {
|
||||||
|
sub.WebPush = &WebPushSubscription{
|
||||||
|
Endpoint: pushEndpoint.String,
|
||||||
|
}
|
||||||
|
if p256dh.Valid {
|
||||||
|
sub.WebPush.P256dh = p256dh.String
|
||||||
|
}
|
||||||
|
if auth.Valid {
|
||||||
|
sub.WebPush.Auth = auth.String
|
||||||
|
}
|
||||||
|
if vapidPrivateKey.Valid {
|
||||||
|
sub.WebPush.VapidPrivateKey = vapidPrivateKey.String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptions = append(subscriptions, sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
return subscriptions, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetAllApps() ([]App, error) {
|
||||||
|
query := `SELECT appName FROM apps ORDER BY appName`
|
||||||
|
rows, err := s.db.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var apps []App
|
||||||
|
for rows.Next() {
|
||||||
|
var app App
|
||||||
|
if err := rows.Scan(&app.AppName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
subs, err := s.GetSubscriptions(app.AppName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
app.Subscriptions = subs
|
||||||
|
|
||||||
|
apps = append(apps, app)
|
||||||
|
}
|
||||||
|
|
||||||
|
return apps, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetSignalGroup(appName string) (*SignalSubscription, error) {
|
||||||
|
query := `SELECT groupId, account FROM signal_groups WHERE appName = ?`
|
||||||
|
row := s.db.QueryRow(query, appName)
|
||||||
|
|
||||||
|
var groupID, account string
|
||||||
|
if err := row.Scan(&groupID, &account); err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SignalSubscription{
|
||||||
|
GroupID: groupID,
|
||||||
|
Account: account,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SaveSignalGroup(appName string, sub *SignalSubscription) error {
|
||||||
|
if err := s.RegisterApp(appName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `INSERT INTO signal_groups (appName, groupId, account) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(appName) DO UPDATE SET groupId=excluded.groupId, account=excluded.account`
|
||||||
|
_, err := s.db.Exec(query, appName, sub.GroupID, sub.Account)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) DeleteSubscription(subscriptionID string) error {
|
||||||
|
query := `DELETE FROM subscriptions WHERE id = ?`
|
||||||
|
_, err := s.db.Exec(query, subscriptionID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetSubscription(subscriptionID string) (*Subscription, error) {
|
||||||
|
query := `
|
||||||
|
SELECT id, appName, channel, signalGroupId, signalAccount, telegramChatId, pushEndpoint, p256dh, auth, vapidPrivateKey
|
||||||
|
FROM subscriptions
|
||||||
|
WHERE id = ?
|
||||||
|
`
|
||||||
|
row := s.db.QueryRow(query, subscriptionID)
|
||||||
|
|
||||||
|
var sub Subscription
|
||||||
|
var signalGroupID, signalAccount, telegramChatID, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString
|
||||||
|
|
||||||
|
err := row.Scan(&sub.ID, &sub.AppName, &sub.Channel, &signalGroupID, &signalAccount, &telegramChatID, &pushEndpoint, &p256dh, &auth, &vapidPrivateKey)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -137,117 +273,35 @@ func (s *Store) GetApp(appName string) (*Mapping, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if signalGroupID.Valid && signalAccount.Valid {
|
if signalGroupID.Valid && signalAccount.Valid {
|
||||||
m.Signal = &SignalSubscription{
|
sub.Signal = &SignalSubscription{
|
||||||
GroupID: signalGroupID.String,
|
GroupID: signalGroupID.String,
|
||||||
Account: signalAccount.String,
|
Account: signalAccount.String,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if telegramChatID.Valid {
|
||||||
|
sub.Telegram = &TelegramSubscription{
|
||||||
|
ChatID: telegramChatID.String,
|
||||||
|
}
|
||||||
|
}
|
||||||
if pushEndpoint.Valid {
|
if pushEndpoint.Valid {
|
||||||
m.WebPush = &WebPushSubscription{
|
sub.WebPush = &WebPushSubscription{
|
||||||
Endpoint: pushEndpoint.String,
|
Endpoint: pushEndpoint.String,
|
||||||
}
|
}
|
||||||
if p256dh.Valid {
|
if p256dh.Valid {
|
||||||
m.WebPush.P256dh = p256dh.String
|
sub.WebPush.P256dh = p256dh.String
|
||||||
}
|
}
|
||||||
if auth.Valid {
|
if auth.Valid {
|
||||||
m.WebPush.Auth = auth.String
|
sub.WebPush.Auth = auth.String
|
||||||
}
|
}
|
||||||
if vapidPrivateKey.Valid {
|
if vapidPrivateKey.Valid {
|
||||||
m.WebPush.VapidPrivateKey = vapidPrivateKey.String
|
sub.WebPush.VapidPrivateKey = vapidPrivateKey.String
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return &m, nil
|
return &sub, nil
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) GetAllMappings() ([]Mapping, error) {
|
|
||||||
query := `
|
|
||||||
SELECT appName, signalGroupId, signalAccount, channel, pushEndpoint, p256dh, auth, vapidPrivateKey
|
|
||||||
FROM mappings
|
|
||||||
`
|
|
||||||
rows, err := s.db.Query(query)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
var mappings []Mapping
|
|
||||||
for rows.Next() {
|
|
||||||
var m Mapping
|
|
||||||
var signalGroupID, signalAccount, pushEndpoint, p256dh, auth, vapidPrivateKey sql.NullString
|
|
||||||
if err := rows.Scan(&m.AppName, &signalGroupID, &signalAccount, &m.Channel, &pushEndpoint, &p256dh, &auth, &vapidPrivateKey); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if signalGroupID.Valid && signalAccount.Valid {
|
|
||||||
m.Signal = &SignalSubscription{
|
|
||||||
GroupID: signalGroupID.String,
|
|
||||||
Account: signalAccount.String,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if pushEndpoint.Valid {
|
|
||||||
m.WebPush = &WebPushSubscription{
|
|
||||||
Endpoint: pushEndpoint.String,
|
|
||||||
}
|
|
||||||
if p256dh.Valid {
|
|
||||||
m.WebPush.P256dh = p256dh.String
|
|
||||||
}
|
|
||||||
if auth.Valid {
|
|
||||||
m.WebPush.Auth = auth.String
|
|
||||||
}
|
|
||||||
if vapidPrivateKey.Valid {
|
|
||||||
m.WebPush.VapidPrivateKey = vapidPrivateKey.String
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mappings = append(mappings, m)
|
|
||||||
}
|
|
||||||
|
|
||||||
return mappings, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) UpdateChannel(appName string, channel Channel) error {
|
|
||||||
query := `UPDATE mappings SET channel = ? WHERE appName = ?`
|
|
||||||
_, err := s.db.Exec(query, channel, appName)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) UpdateSignal(appName string, signal *SignalSubscription) error {
|
|
||||||
if signal == nil {
|
|
||||||
return fmt.Errorf("signal cannot be nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `UPDATE mappings SET signalGroupId = ?, signalAccount = ? WHERE appName = ?`
|
|
||||||
_, err := s.db.Exec(query, signal.GroupID, signal.Account, appName)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) UpdateWebPush(appName string, webPush *WebPushSubscription) error {
|
|
||||||
if webPush == nil {
|
|
||||||
return fmt.Errorf("webPush cannot be nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `
|
|
||||||
UPDATE mappings
|
|
||||||
SET pushEndpoint = ?, p256dh = ?, auth = ?, vapidPrivateKey = ?
|
|
||||||
WHERE appName = ?
|
|
||||||
`
|
|
||||||
_, err := s.db.Exec(query, webPush.Endpoint, webPush.P256dh, webPush.Auth, webPush.VapidPrivateKey, appName)
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) RemoveApp(appName string) error {
|
func (s *Store) RemoveApp(appName string) error {
|
||||||
query := `DELETE FROM mappings WHERE appName = ?`
|
_, err := s.db.Exec(`DELETE FROM apps WHERE appName = ?`, appName)
|
||||||
_, err := s.db.Exec(query, appName)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) ClearWebPush(appName string) error {
|
|
||||||
query := `
|
|
||||||
UPDATE mappings
|
|
||||||
SET pushEndpoint = NULL, p256dh = NULL, auth = NULL, vapidPrivateKey = NULL, channel = 'signal'
|
|
||||||
WHERE appName = ?
|
|
||||||
`
|
|
||||||
_, err := s.db.Exec(query, appName)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
"prism/service/integration/signal"
|
||||||
"prism/service/notification"
|
"prism/service/notification"
|
||||||
"prism/service/util"
|
"prism/service/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AppListItem struct {
|
type AppListItem struct {
|
||||||
AppName string
|
AppName string
|
||||||
Channel string
|
Channels []ChannelState
|
||||||
ChannelBadge string
|
|
||||||
ChannelConfigured bool
|
|
||||||
Tooltip string
|
|
||||||
Hostname string
|
|
||||||
ChannelOptions []SelectOption
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type SelectOption struct {
|
type ChannelState struct {
|
||||||
Value string
|
Channel string
|
||||||
Label string
|
Label string
|
||||||
Selected bool
|
Active bool
|
||||||
|
Toggleable bool
|
||||||
|
Subscriptions []SubscriptionItem
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubscriptionItem struct {
|
||||||
|
ID string
|
||||||
|
Tooltip string
|
||||||
|
Hostname string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
|
||||||
mappings, err := s.store.GetAllMappings()
|
apps, err := s.store.GetAllApps()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
util.LogAndError(w, s.logger, "Internal server error", http.StatusInternalServerError, err)
|
util.LogAndError(w, s.logger, "Internal server error", http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
|
|
@ -35,8 +38,10 @@ func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
|
|
||||||
|
data := s.buildAppListData(apps)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if err := s.fragmentTmpl.ExecuteTemplate(&buf, "app-list.html", s.buildAppListData(mappings)); err != nil {
|
if err := s.fragmentTmpl.ExecuteTemplate(&buf, "app-list.html", data); err != nil {
|
||||||
util.LogAndError(w, s.logger, "Failed to execute template", http.StatusInternalServerError, err)
|
util.LogAndError(w, s.logger, "Failed to execute template", http.StatusInternalServerError, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -44,112 +49,111 @@ func (s *Server) handleFragmentApps(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write(buf.Bytes())
|
_, _ = w.Write(buf.Bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) buildAppListData(mappings []notification.Mapping) []AppListItem {
|
func (s *Server) buildAppListData(apps []notification.App) []AppListItem {
|
||||||
if len(mappings) == 0 {
|
if len(apps) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
signalLinked := false
|
signalEnabled := s.integrations.Signal != nil && s.integrations.Signal.IsEnabled()
|
||||||
if s.integrations.Signal != nil {
|
telegramEnabled := s.integrations.Telegram != nil && s.integrations.Telegram.IsEnabled()
|
||||||
handlers := s.integrations.Signal.GetHandlers()
|
telegramBotName := ""
|
||||||
if handlers != nil {
|
if telegramEnabled {
|
||||||
account, _ := handlers.GetClient().GetLinkedAccount()
|
|
||||||
signalLinked = account != nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
telegramLinked := false
|
|
||||||
var telegramInfo string
|
|
||||||
if s.integrations.Telegram != nil {
|
|
||||||
handlers := s.integrations.Telegram.GetHandlers()
|
handlers := s.integrations.Telegram.GetHandlers()
|
||||||
if handlers != nil && handlers.GetClient() != nil {
|
if handlers != nil {
|
||||||
chatID := handlers.GetChatID()
|
client := handlers.GetClient()
|
||||||
telegramLinked = chatID != 0
|
if client != nil {
|
||||||
if telegramLinked {
|
if bot, err := client.GetMe(); err == nil && bot != nil && bot.Username != "" {
|
||||||
if bot, err := handlers.GetClient().GetMe(); err == nil {
|
telegramBotName = "@" + bot.Username
|
||||||
telegramInfo = "@" + bot.Username
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items := make([]AppListItem, 0, len(mappings))
|
items := make([]AppListItem, 0, len(apps))
|
||||||
for _, m := range mappings {
|
for _, app := range apps {
|
||||||
item := s.buildAppListItem(m, signalLinked, telegramLinked, telegramInfo)
|
channels := []ChannelState{}
|
||||||
|
|
||||||
|
if signalEnabled {
|
||||||
|
var signalSub *notification.Subscription
|
||||||
|
for i := range app.Subscriptions {
|
||||||
|
if app.Subscriptions[i].Channel == notification.ChannelSignal {
|
||||||
|
signalSub = &app.Subscriptions[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state := ChannelState{
|
||||||
|
Channel: string(notification.ChannelSignal),
|
||||||
|
Label: notification.ChannelSignal.Label(),
|
||||||
|
Active: signalSub != nil,
|
||||||
|
Toggleable: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if signalSub != nil && signalSub.Signal != nil {
|
||||||
|
state.Subscriptions = []SubscriptionItem{{
|
||||||
|
ID: signalSub.ID,
|
||||||
|
Tooltip: signal.FormatPhoneNumber(signalSub.Signal.Account),
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
channels = append(channels, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
if telegramEnabled {
|
||||||
|
var telegramSub *notification.Subscription
|
||||||
|
for i := range app.Subscriptions {
|
||||||
|
if app.Subscriptions[i].Channel == notification.ChannelTelegram {
|
||||||
|
telegramSub = &app.Subscriptions[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state := ChannelState{
|
||||||
|
Channel: string(notification.ChannelTelegram),
|
||||||
|
Label: notification.ChannelTelegram.Label(),
|
||||||
|
Active: telegramSub != nil,
|
||||||
|
Toggleable: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if telegramSub != nil {
|
||||||
|
state.Subscriptions = []SubscriptionItem{{
|
||||||
|
ID: telegramSub.ID,
|
||||||
|
Tooltip: telegramBotName,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
channels = append(channels, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
webPushSubs := []SubscriptionItem{}
|
||||||
|
for _, sub := range app.Subscriptions {
|
||||||
|
if sub.Channel == notification.ChannelWebPush && sub.WebPush != nil {
|
||||||
|
item := SubscriptionItem{
|
||||||
|
ID: sub.ID,
|
||||||
|
Tooltip: sub.WebPush.Endpoint,
|
||||||
|
}
|
||||||
|
if u, err := url.Parse(sub.WebPush.Endpoint); err == nil {
|
||||||
|
item.Hostname = u.Hostname()
|
||||||
|
}
|
||||||
|
webPushSubs = append(webPushSubs, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(webPushSubs) > 0 {
|
||||||
|
channels = append(channels, ChannelState{
|
||||||
|
Channel: string(notification.ChannelWebPush),
|
||||||
|
Label: notification.ChannelWebPush.Label(),
|
||||||
|
Active: true,
|
||||||
|
Toggleable: false,
|
||||||
|
Subscriptions: webPushSubs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
item := AppListItem{
|
||||||
|
AppName: app.AppName,
|
||||||
|
Channels: channels,
|
||||||
|
}
|
||||||
items = append(items, item)
|
items = append(items, item)
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) buildAppListItem(m notification.Mapping, signalLinked, telegramLinked bool, telegramInfo string) AppListItem {
|
|
||||||
isSignal := m.Channel == notification.ChannelSignal
|
|
||||||
isWebPush := m.Channel == notification.ChannelWebPush
|
|
||||||
isTelegram := m.Channel == notification.ChannelTelegram
|
|
||||||
|
|
||||||
item := AppListItem{
|
|
||||||
AppName: m.AppName,
|
|
||||||
Channel: string(m.Channel),
|
|
||||||
}
|
|
||||||
|
|
||||||
if isSignal && m.Signal != nil && m.Signal.GroupID != "" {
|
|
||||||
item.ChannelBadge = m.Channel.Label()
|
|
||||||
item.Tooltip = fmt.Sprintf("Group ID: %s", m.Signal.GroupID)
|
|
||||||
item.ChannelConfigured = true
|
|
||||||
} else if isWebPush && m.WebPush != nil && m.WebPush.Endpoint != "" {
|
|
||||||
item.ChannelBadge = m.Channel.Label()
|
|
||||||
item.Tooltip = m.WebPush.Endpoint
|
|
||||||
item.ChannelConfigured = true
|
|
||||||
if u, err := url.Parse(m.WebPush.Endpoint); err == nil {
|
|
||||||
item.Hostname = u.Hostname()
|
|
||||||
}
|
|
||||||
} else if isTelegram && telegramLinked {
|
|
||||||
item.ChannelBadge = m.Channel.Label()
|
|
||||||
item.Tooltip = telegramInfo
|
|
||||||
item.ChannelConfigured = true
|
|
||||||
} else {
|
|
||||||
item.ChannelBadge = "Unlinked"
|
|
||||||
item.ChannelConfigured = false
|
|
||||||
if isSignal {
|
|
||||||
item.Tooltip = "No Signal group created yet. Will auto-create on first notification."
|
|
||||||
} else if isTelegram {
|
|
||||||
item.Tooltip = "Set TELEGRAM_CHAT_ID in .env"
|
|
||||||
} else {
|
|
||||||
item.Tooltip = "No WebPush endpoint registered."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
item.ChannelOptions = s.buildChannelOptions(m, signalLinked, telegramLinked)
|
|
||||||
return item
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) buildChannelOptions(m notification.Mapping, signalLinked, telegramConfigured bool) []SelectOption {
|
|
||||||
isSignal := m.Channel == notification.ChannelSignal
|
|
||||||
isWebPush := m.Channel == notification.ChannelWebPush
|
|
||||||
isTelegram := m.Channel == notification.ChannelTelegram
|
|
||||||
|
|
||||||
var options []SelectOption
|
|
||||||
|
|
||||||
if s.integrations.Signal != nil {
|
|
||||||
options = append(options, SelectOption{
|
|
||||||
Value: notification.ChannelSignal.String(),
|
|
||||||
Label: notification.ChannelSignal.Label(),
|
|
||||||
Selected: isSignal,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if s.integrations.Telegram != nil {
|
|
||||||
options = append(options, SelectOption{
|
|
||||||
Value: notification.ChannelTelegram.String(),
|
|
||||||
Label: notification.ChannelTelegram.Label(),
|
|
||||||
Selected: isTelegram,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if m.WebPush != nil && m.WebPush.Endpoint != "" {
|
|
||||||
options = append(options, SelectOption{
|
|
||||||
Value: notification.ChannelWebPush.String(),
|
|
||||||
Label: notification.ChannelWebPush.Label(),
|
|
||||||
Selected: isWebPush,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -16,10 +18,12 @@ import (
|
||||||
|
|
||||||
func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
|
||||||
appName := chi.URLParam(r, "appName")
|
appName := chi.URLParam(r, "appName")
|
||||||
if appName == "" {
|
decodedAppName, err := url.PathUnescape(appName)
|
||||||
appName = chi.URLParam(r, "endpoint")
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid app name", http.StatusBadRequest)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
appName, _ = url.QueryUnescape(appName)
|
appName = decodedAppName
|
||||||
if appName == "" || strings.Contains(appName, "/") {
|
if appName == "" || strings.Contains(appName, "/") {
|
||||||
http.Error(w, "Invalid app name", http.StatusBadRequest)
|
http.Error(w, "Invalid app name", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|
@ -31,49 +35,7 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var message, title string
|
message, title := parseNtfyPayload(r, body)
|
||||||
contentType := r.Header.Get("Content-Type")
|
|
||||||
|
|
||||||
if strings.Contains(contentType, "application/json") {
|
|
||||||
var payload struct {
|
|
||||||
Title string `json:"title"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(body, &payload); err == nil {
|
|
||||||
message = payload.Message
|
|
||||||
title = payload.Title
|
|
||||||
} else {
|
|
||||||
message = string(body)
|
|
||||||
}
|
|
||||||
} else if strings.Contains(contentType, "application/x-www-form-urlencoded") {
|
|
||||||
if values, err := url.ParseQuery(string(body)); err == nil {
|
|
||||||
message = values.Get("message")
|
|
||||||
if message == "" {
|
|
||||||
message = string(body)
|
|
||||||
}
|
|
||||||
if title == "" {
|
|
||||||
if t := values.Get("title"); t != "" {
|
|
||||||
title = t
|
|
||||||
} else if t := values.Get("t"); t != "" {
|
|
||||||
title = t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
message = string(body)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
message = string(body)
|
|
||||||
}
|
|
||||||
|
|
||||||
if title == "" {
|
|
||||||
title = r.Header.Get("X-Title")
|
|
||||||
if title == "" {
|
|
||||||
title = r.Header.Get("Title")
|
|
||||||
}
|
|
||||||
if title == "" {
|
|
||||||
title = r.Header.Get("t")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if message == "" {
|
if message == "" {
|
||||||
http.Error(w, "Message required", http.StatusBadRequest)
|
http.Error(w, "Message required", http.StatusBadRequest)
|
||||||
|
|
@ -110,6 +72,57 @@ func (s *Server) handleNtfyPublish(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseNtfyPayload(r *http.Request, body []byte) (string, string) {
|
||||||
|
var message, title string
|
||||||
|
|
||||||
|
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||||
|
if err != nil {
|
||||||
|
mediaType = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mediaType {
|
||||||
|
case "application/json":
|
||||||
|
var payload struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &payload); err == nil {
|
||||||
|
message = payload.Message
|
||||||
|
title = payload.Title
|
||||||
|
} else {
|
||||||
|
message = string(body)
|
||||||
|
}
|
||||||
|
case "application/x-www-form-urlencoded":
|
||||||
|
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||||
|
if err := r.ParseForm(); err == nil {
|
||||||
|
message = r.PostForm.Get("message")
|
||||||
|
if message == "" {
|
||||||
|
message = string(body)
|
||||||
|
}
|
||||||
|
title = firstNonEmpty(r.PostForm.Get("title"), r.PostForm.Get("t"))
|
||||||
|
} else {
|
||||||
|
message = string(body)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
message = string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if title == "" {
|
||||||
|
title = firstNonEmpty(r.Header.Get("X-Title"), r.Header.Get("Title"), r.Header.Get("t"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return message, title
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func truncate(s string, max int) string {
|
func truncate(s string, max int) string {
|
||||||
if len(s) <= max {
|
if len(s) <= max {
|
||||||
return s
|
return s
|
||||||
|
|
|
||||||
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)
|
util.LogAndError(w, s.logger, "Internal Server Error", http.StatusInternalServerError, err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.Redirect(w, r, "/favicon.webp", http.StatusMovedPermanently)
|
|
||||||
})
|
|
||||||
|
|
||||||
integration.RegisterAll(s.integrations, r, s.cfg, s.store, s.logger, authMiddleware)
|
integration.RegisterAll(s.integrations, r, s.cfg, s.store, s.logger, authMiddleware)
|
||||||
|
|
||||||
r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/apps", s.handleFragmentApps)
|
r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/apps", s.handleFragmentApps)
|
||||||
r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/integrations", s.handleFragmentIntegrations)
|
r.With(authMiddleware(s.cfg.APIKey)).Get("/fragment/integrations", s.handleFragmentIntegrations)
|
||||||
|
|
||||||
r.Route("/action", func(r chi.Router) {
|
r.Route("/apps", func(r chi.Router) {
|
||||||
r.Use(authMiddleware(s.cfg.APIKey))
|
r.Use(authMiddleware(s.cfg.APIKey))
|
||||||
r.Delete("/app/{appName}", s.handleDeleteAppAction)
|
r.Delete("/{appName}", s.handleDeleteApp)
|
||||||
r.Post("/toggle-channel", s.handleToggleChannelAction)
|
r.Post("/{appName}/subscriptions", s.handleCreateSubscription)
|
||||||
})
|
r.Delete("/{appName}/subscriptions/{subscriptionId}", s.handleDeleteSubscription)
|
||||||
|
|
||||||
r.Route("/api/v1/admin", func(r chi.Router) {
|
|
||||||
r.Use(authMiddleware(s.cfg.APIKey))
|
|
||||||
r.Get("/mappings", s.handleGetMappings)
|
|
||||||
r.Post("/mappings", s.handleCreateMapping)
|
|
||||||
r.Delete("/mappings/{appName}", s.handleDeleteMapping)
|
|
||||||
r.Put("/mappings/{appName}/channel", s.handleUpdateChannel)
|
|
||||||
r.Get("/stats", s.handleGetStats)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.With(authMiddleware(s.cfg.APIKey)).Get("/api/v1/apps", s.handleGetApps)
|
||||||
r.With(authMiddleware(s.cfg.APIKey)).Get("/api/v1/health", s.handleHealth)
|
r.With(authMiddleware(s.cfg.APIKey)).Get("/api/v1/health", s.handleHealth)
|
||||||
|
|
||||||
r.With(authMiddleware(s.cfg.APIKey)).Post("/{appName}", s.handleNtfyPublish)
|
r.With(authMiddleware(s.cfg.APIKey)).Post("/{appName}", s.handleNtfyPublish)
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,56 @@
|
||||||
{{if .}}
|
{{if .}}
|
||||||
<ul class="app-list">
|
<ul class="app-list">
|
||||||
{{range .}}
|
{{range .}}
|
||||||
|
{{$app := .}}
|
||||||
<li class="app-item">
|
<li class="app-item">
|
||||||
<div class="app-info">
|
<div class="app-info">
|
||||||
<div class="app-name"><strong>{{.AppName}}</strong></div>
|
<div class="app-name"><strong>{{.AppName}}</strong></div>
|
||||||
<div class="app-channel">
|
{{if .Channels}}
|
||||||
{{if .ChannelConfigured}}
|
<div class="app-subscriptions">
|
||||||
<span class="channel-badge channel-{{.Channel}}">
|
{{range .Channels}}
|
||||||
{{.ChannelBadge}}
|
{{$channel := .}}
|
||||||
{{if .Tooltip}}<span class="tooltip">{{.Tooltip}}</span>{{end}}
|
{{if .Toggleable}}
|
||||||
</span>
|
{{if .Active}}
|
||||||
{{else}}
|
<button type="button" class="channel-badge channel-{{.Channel}} badge-active"
|
||||||
<span class="channel-badge channel-not-configured">
|
hx-delete="/apps/{{$app.AppName}}/subscriptions/{{(index .Subscriptions 0).ID}}"
|
||||||
{{.ChannelBadge}}
|
hx-target="#apps-list"
|
||||||
{{if .Tooltip}}<span class="tooltip">{{.Tooltip}}</span>{{end}}
|
hx-swap="innerHTML">
|
||||||
</span>
|
{{.Label}}
|
||||||
{{end}}
|
<span class="tooltip">{{if (index .Subscriptions 0).Tooltip}}{{(index .Subscriptions 0).Tooltip}}{{else}}Click to disable{{end}}</span>
|
||||||
{{if .Hostname}}
|
</button>
|
||||||
<span class="app-detail">{{.Hostname}}</span>
|
{{else}}
|
||||||
|
<button type="button" class="channel-badge channel-{{.Channel}} badge-inactive"
|
||||||
|
hx-post="/apps/{{$app.AppName}}/subscriptions"
|
||||||
|
hx-target="#apps-list"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-vals='{"channel":"{{.Channel}}"}'>
|
||||||
|
+ {{.Label}}
|
||||||
|
<span class="tooltip">Click to enable</span>
|
||||||
|
</button>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
{{range .Subscriptions}}
|
||||||
|
<span class="channel-badge channel-{{$channel.Channel}} badge-subscribed">
|
||||||
|
{{$channel.Label}}{{if .Hostname}} ({{.Hostname}}){{end}}
|
||||||
|
{{if .Tooltip}}<span class="tooltip">{{.Tooltip}}</span>{{end}}
|
||||||
|
<button type="button" class="btn-delete-sub"
|
||||||
|
hx-delete="/apps/{{$app.AppName}}/subscriptions/{{.ID}}"
|
||||||
|
hx-target="#apps-list"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-confirm="Delete this subscription?">×</button>
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="app-no-subscriptions">
|
||||||
|
<span class="channel-badge channel-not-configured">No channels enabled</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="app-actions">
|
<div class="app-actions">
|
||||||
{{if gt (len .ChannelOptions) 1}}
|
<button type="button" class="btn-delete" hx-delete="/apps/{{.AppName}}" hx-target="#apps-list" hx-swap="innerHTML" hx-confirm="Delete {{.AppName}} and all subscriptions?">Delete</button>
|
||||||
<form class="channel-form">
|
|
||||||
<input type="hidden" name="app" value="{{.AppName}}" />
|
|
||||||
<select class="channel-select" name="channel" hx-post="/action/toggle-channel" hx-target="#apps-list" hx-swap="innerHTML" hx-include="closest form">
|
|
||||||
{{range .ChannelOptions}}
|
|
||||||
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
|
|
||||||
{{end}}
|
|
||||||
</select>
|
|
||||||
</form>
|
|
||||||
{{end}}
|
|
||||||
<button class="btn-delete" hx-delete="/action/app/{{.AppName}}" hx-target="#apps-list" hx-swap="innerHTML" hx-confirm="Delete {{.AppName}}?">Delete</button>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -77,8 +77,8 @@ func isLocalIP(addr string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetClientIP(r *http.Request) string {
|
func GetClientIP(r *http.Request) string {
|
||||||
host, _, _ := net.SplitHostPort(r.RemoteAddr)
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
if host == "" {
|
if err != nil || host == "" {
|
||||||
return r.RemoteAddr
|
return r.RemoteAddr
|
||||||
}
|
}
|
||||||
return host
|
return host
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,18 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func SetToast(w http.ResponseWriter, message, toastType string) {
|
||||||
|
trigger := map[string]interface{}{
|
||||||
|
"showToast": map[string]string{
|
||||||
|
"message": message,
|
||||||
|
"type": toastType,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if data, err := json.Marshal(trigger); err == nil {
|
||||||
|
w.Header().Set("HX-Trigger", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func LogAndError(w http.ResponseWriter, logger *slog.Logger, message string, code int, err error, attrs ...any) {
|
func LogAndError(w http.ResponseWriter, logger *slog.Logger, message string, code int, err error, attrs ...any) {
|
||||||
logAttrs := append([]any{"error", err}, attrs...)
|
logAttrs := append([]any{"error", err}, attrs...)
|
||||||
logger.Error(message, logAttrs...)
|
logger.Error(message, logAttrs...)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue