feat(notify): web push as a notification channel
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m15s

- VAPID keys from VAPID_* or generated once (push_keys, migration 9);
  devices in push_subscriptions, forgotten on 404/410 from the push service
- push column for every event (also due tasks) and the daily summary,
  bundles as one notification; /push/key, /devices, /subscribe,
  /unsubscribe, /test
- settings: switch push on per device, test it, see the devices; the
  service worker shows notifications and focuses MindSift on click
- tests decrypt a sent push like a browser (RFC 8291) and check VAPID

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 21:21:55 +01:00
1 parent 1a49e6b304
commit 38b8c92899
27 files changed
+918 -27

No files matched your search

+7
View File
@@ -75,6 +75,13 @@ SMTP_TLS=true
# true = selbstsignierte Zertifikate akzeptieren (nur für interne Mailserver).
SMTP_SKIP_VERIFY=false
# Web-Push (Benachrichtigungen auf Handy und Desktop, auch bei geschlossener App).
# Ohne Angabe erzeugt MindSift beim ersten Bedarf ein Schlüsselpaar und speichert es in der Datenbank.
# VAPID_SUBJECT: Kontakt für die Push-Dienste (mailto: oder https://); Standard ist BASE_URL, wenn https.
# VAPID_PUBLIC_KEY=
# VAPID_PRIVATE_KEY=
# VAPID_SUBJECT=mailto:admin@example.com
# ─── Impressum und Datenschutz (optional) ──────────────────────────────────────────────────────────
# Werden auf der Anmeldeseite und in den Einstellungen verlinkt. Entweder als Link auf eine externe
# Seite (nur http/https) oder als Markdown-Text, direkt hier oder als Datei (per Volume einbinden).
+1
View File
@@ -7,6 +7,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased]
### Added
- **Push-Benachrichtigungen** (Web Push) auf Handy und Desktop, auch wenn MindSift geschlossen ist: unter Kontoeinstellungen → Alarme pro Gerät einschalten und testen; „Push“ ist eine eigene Spalte für jedes Ereignis (auch fällige Aufgaben) und die Tageszusammenfassung. iPhone und iPad ab iOS 16.4, wenn MindSift zum Home-Bildschirm hinzugefügt wurde. Schlüssel per `VAPID_*` oder automatisch erzeugt. API: `/push/key`, `/push/devices`, `/push/subscribe`, `/push/unsubscribe`, `/push/test`.
- Links auf einzelne Punkte: „Link zu diesem Punkt kopieren“ in der Aktionsblase eines Punkts oder im Link-Dialog (`/link`) über „Punkt dieser Notiz wählen“. Format `[[Notiz#^id|Notiz › Auszug]]`; ein Klick öffnet die Notiz, springt zum Punkt und hebt ihn hervor. Gibt es den Punkt nicht mehr (verschoben, ausgelagert, gelöscht), öffnet sich die Notiz mit einem Hinweis. Wiki-Links stehen im Text jetzt mit ihrem Anzeigetext und sind direkt anklickbar.
### Changed
+5
View File
@@ -125,6 +125,11 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
| GET/POST | `/documents/{id}/shares` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) |
| DELETE | `/documents/{id}/shares/{userId}` | Notiz-Freigabe entfernen |
| POST | `/documents/{id}/extract` | `{blockId, clientId}`: legt aus den Unterpunkten des Punkts eine eigene Notiz an (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht) → `{document, link}`. Die Ursprungsnotiz ändert der Server nicht; der Editor ersetzt den Ast selbst durch `link` (normale eigene Änderung, Strg+Z möglich). Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt |
| GET | `/push/key` | `{enabled, publicKey}` – öffentlicher VAPID-Schlüssel für `pushManager.subscribe` (in der Demo `enabled: false`) |
| GET | `/push/devices` | Eigene Geräte mit Push → `{devices: [{id, endpoint, device, createdAt}]}` |
| POST | `/push/subscribe` | `{endpoint, keys: {p256dh, auth}, device}` – Gerät anmelden (nur `https`-Endpunkte) |
| POST | `/push/unsubscribe` | `{endpoint}` – Gerät abmelden |
| POST | `/push/test` | Test-Benachrichtigung an alle eigenen Geräte → `{devices}`; `400` ohne Gerät |
| GET | `/journal` | Tage des eigenen Journals, neueste zuerst → `{days: [{date, docId, empty}]}` (`empty`: Notiz noch ohne Text; Tage im Papierkorb fehlen) |
| POST | `/journal/{date}` | Notiz des Tages (`JJJJ-MM-TT`) öffnen; gibt es noch keine, wird sie angelegt (Titel z. B. „Freitag, 30. Oktober 2026“, Icon 📅) → Dokument, `201` bei Neuanlage. Die Journal-Notizen sind normale Notizen (Teilen, Suche, Sync) |
| POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) |
+3 -1
View File
@@ -71,6 +71,8 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
| `SMTP_FROM_NAME` | `MindSift` | Anzeigename des Absenders, wenn `SMTP_FROM` nur eine Adresse ist (`From: "MindSift" <adresse>`, der SMTP-Umschlag bleibt die reine Adresse); ein Name in `SMTP_FROM` hat Vorrang |
| `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) |
| `NOTIFY_DIGEST_MINUTES` | `5` | Benachrichtigungen (Freigaben, beobachtete Aufgaben …) werden gesammelt und höchstens alle so viele Minuten gebündelt verschickt |
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` | – | Schlüsselpaar für Web-Push; ohne Angabe einmal erzeugt und in `push_keys` gespeichert |
| `VAPID_SUBJECT` | `BASE_URL` (wenn https) | Kontakt für die Push-Dienste (`mailto:` oder `https://`) |
| `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB` | `15` / `50` | Größtes Bild bzw. größte Audiodatei in Notizen in MB |
| `MEDIA_QUOTA_MB` | `0` | Speicherplatz für Medien pro Benutzer in MB (`0` = unbegrenzt) |
| `MEDIA_DEMO_MAX_MB` | `3` | Nur im Demo-Modus: maximale Dateigröße für Medien-Uploads in MB |
@@ -106,7 +108,7 @@ Bilder und Audio liegen in `$DATA_DIR/media/<docId>/<id>.<ext>`, beschrieben in
## Benachrichtigungen
`internal/api/notify.go`, `internal/storage/notifications.go` (Migration 7). Jede Person wählt pro Ereignisart die Kanäle – eigene E-Mail (Alarm-Adresse, sonst Konto-Adresse) eigener Discord-Webhook und eigener Webhook (`customWebhookUrl`, JSON per POST) aus den Alarmen –, gespeichert als JSON in `users.notification_settings` (`GET /auth/me`, `PATCH /auth/settings` → `notificationSettings`). Ereignisse: `due` (Fälligkeits-Alarm, sofort über den Alarm-Ticker), `shared` (Notiz, Ordner, Aufgabenliste oder Liste geteilt; bei Gruppen alle Mitglieder), `taskDone` (Aufgabe einer geteilten Liste erledigt), `watched` (jede Änderung an einer beobachteten Aufgabe, `task_watchers`, `POST/DELETE /tasks/{id}/watch`). Alles außer `due` landet in `notification_queue`; ein Job im Minutentakt schickt je Person eine gebündelte Nachricht, sobald das älteste Ereignis `NOTIFY_DIGEST_MINUTES` (Standard 5) alt ist (E-Mail als Text + HTML, Discord als ein Embed mit höchstens 10 Zeilen, eigener Webhook als JSON `{"event":"digest","heading","items":[{"kind","title","text"}],"link","timestamp"}`; fällige Aufgaben gehen dort als `{"event":"task_due",…}` einzeln raus), nach 3 Fehlversuchen wird verworfen. Eigene Änderungen benachrichtigen nie den Handelnden, außer bei beobachteten Aufgaben mit `includeOwnWatched`. `paused` stoppt alles, auch Alarme. **Tageszusammenfassung:** zur gewählten Uhrzeit (Server-Zeitzone, innerhalb von 30 Minuten, einmal pro Tag) überfällige, heutige und morgige offene Aufgaben samt Anzahl der folgenden 7 Tage; nichts fällig → keine Nachricht. Im Demo-Modus laufen die Jobs nicht.
`internal/api/notify.go`, `internal/storage/notifications.go` (Migration 7). Jede Person wählt pro Ereignisart die Kanäle – eigene E-Mail (Alarm-Adresse, sonst Konto-Adresse) eigener Discord-Webhook, eigener Webhook (`customWebhookUrl`, JSON per POST) und Web-Push an die eigenen Geräte (`internal/api/push.go`, Migration 9, Tabelle `push_subscriptions`; Geräte, die der Push-Dienst nicht mehr kennt (404/410), werden entfernt) –, gespeichert als JSON in `users.notification_settings` (`GET /auth/me`, `PATCH /auth/settings` → `notificationSettings`). Ereignisse: `due` (Fälligkeits-Alarm, sofort über den Alarm-Ticker), `shared` (Notiz, Ordner, Aufgabenliste oder Liste geteilt; bei Gruppen alle Mitglieder), `taskDone` (Aufgabe einer geteilten Liste erledigt), `watched` (jede Änderung an einer beobachteten Aufgabe, `task_watchers`, `POST/DELETE /tasks/{id}/watch`). Alles außer `due` landet in `notification_queue`; ein Job im Minutentakt schickt je Person eine gebündelte Nachricht, sobald das älteste Ereignis `NOTIFY_DIGEST_MINUTES` (Standard 5) alt ist (E-Mail als Text + HTML, Discord als ein Embed mit höchstens 10 Zeilen, eigener Webhook als JSON `{"event":"digest","heading","items":[{"kind","title","text"}],"link","timestamp"}`; fällige Aufgaben gehen dort als `{"event":"task_due",…}` einzeln raus), nach 3 Fehlversuchen wird verworfen. Eigene Änderungen benachrichtigen nie den Handelnden, außer bei beobachteten Aufgaben mit `includeOwnWatched`. `paused` stoppt alles, auch Alarme. **Tageszusammenfassung:** zur gewählten Uhrzeit (Server-Zeitzone, innerhalb von 30 Minuten, einmal pro Tag) überfällige, heutige und morgige offene Aufgaben samt Anzahl der folgenden 7 Tage; nichts fällig → keine Nachricht. Im Demo-Modus laufen die Jobs nicht.
## Link-Vorschau
+3 -1
View File
@@ -14,6 +14,8 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j
| `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` |
| `notification_queue` | Wartende Benachrichtigungen bis zum gebündelten Versand (Migration 7) | `user_id`, `kind` (`shared`/`taskDone`/`watched`), `payload` (JSON), `created_at`, `attempts` |
| `task_watchers` | Beobachtete Aufgaben (Migration 7) | `task_id`, `user_id`, `created_at` |
| `push_subscriptions` | Geräte mit Web-Push (Migration 9) | `user_id`, `endpoint` (eindeutig), `p256dh`, `auth`, `device`, `created_at` |
| `push_keys` | VAPID-Schlüsselpaar, falls nicht per Umgebung gesetzt (Migration 9) | eine Zeile: `public_key`, `private_key` |
| `journal_days` | Journal: welche Notiz zu welchem Tag gehört (Migration 8) | `user_id`, `day` (`JJJJ-MM-TT`), `doc_id`; Primärschlüssel `(user_id, day)` |
| `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) |
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
@@ -85,7 +87,7 @@ teams 1─n team_members n─1 users
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (Migrationen 3 bis 8: 3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“, 8 „Journal“) genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 (`migrateBaseline`) baut das Basisschema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (Migrationen 3 bis 9: 3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“, 8 „Journal“, 9 „Web-Push“) genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 (`migrateBaseline`) baut das Basisschema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
## Sicherung
+2 -1
View File
@@ -51,7 +51,8 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
* **Aufgaben beobachten:** Beliebige Aufgaben per Klick abonnieren, um über alle Änderungen informiert zu werden.
* **Wiederholungen:** Wiederkehrende Aufgaben (täglich, wöchentlich, monatlich, jährlich) mit automatischer Folgeaufgabe beim Abhaken.
* **Tageszusammenfassung:** Tägliche Übersicht fälliger Aufgaben zur selbst gewählten Uhrzeit per E-Mail/Discord.
* **Tageszusammenfassung:** Tägliche Übersicht fälliger Aufgaben zur selbst gewählten Uhrzeit per E-Mail, Discord, eigenem Webhook oder Push.
* **Push-Benachrichtigungen** auf Handy und Desktop (auch bei geschlossener App; iPhone/iPad als Home-Bildschirm-App), als eigener Kanal je Ereignis.
* **Alarme & Benachrichtigungen:** Fälligkeits-Alarme sofort per E-Mail, Discord oder eigenem Webhook; granulare Benachrichtigungen für Freigaben, erledigte geteilte Aufgaben und beobachtete Aufgaben sowie tägliche Zusammenfassungen zur gewählten Uhrzeit.
<p align="center">
+2
View File
@@ -33,6 +33,8 @@ services:
- NOTIFY_DIGEST_MINUTES=${NOTIFY_DIGEST_MINUTES:-5}
- SMTP_TLS=${SMTP_TLS:-true}
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
# Web-Push: ohne Angabe erzeugt MindSift die Schlüssel selbst
# - VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:admin@example.com}
# Impressum / Datenschutz
- LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-}
- LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-}
+32
View File
@@ -94,3 +94,35 @@ self.addEventListener('fetch', (event) => {
event.respondWith(staleWhileRevalidate(request));
}
});
// Web push: show the notification; a click focuses an open MindSift window or opens a new one
self.addEventListener('push', (event) => {
let msg = {};
try {
msg = event.data ? event.data.json() : {};
} catch {
msg = { body: event.data ? event.data.text() : '' };
}
event.waitUntil(
self.registration.showNotification(msg.title || 'MindSift', {
body: msg.body || '',
icon: '/icon-192x192.png',
badge: '/icon-192x192.png',
tag: msg.tag || undefined,
data: { url: msg.url || '/' },
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = new URL((event.notification.data && event.notification.data.url) || '/', self.location.origin);
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windows) => {
for (const w of windows) {
if (new URL(w.url).origin === target.origin && 'focus' in w) return w.focus();
}
return self.clients.openWindow(target.href);
})
);
});
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useCallback, useEffect, useState, useRef } from 'react';
import {
User as UserIcon,
Bell,
@@ -17,7 +17,7 @@ import {
Pencil,
Copy,
} from 'lucide-react';
import { AuthStatus, NotificationEvents, NotificationSettings, User, UserTheme } from '../../lib/types';
import { AuthStatus, NotificationEvents, NotificationSettings, PushDevice, User, UserTheme } from '../../lib/types';
import { ApiKeysPanel } from './ApiKeysPanel';
import { ServerBackupsCard } from './ServerBackupsCard';
import { saveBlob } from '../../lib/download';
@@ -33,6 +33,7 @@ import { SessionsPanel } from './SessionsPanel';
import { ToggleSwitch } from '../../components/ToggleSwitch';
import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
import { isJournalEnabled, setJournalEnabled } from '../../lib/journal';
import { currentPushSubscription, disablePush, enablePush, pushSupported } from '../../lib/push';
import { FormSheet } from '../../components/FormSheet';
interface UserSettingsViewProps {
@@ -62,21 +63,25 @@ export function buildNotificationSettings(user: User): NotificationSettings {
email: s?.events?.due?.email ?? dueEmailDefault,
discord: s?.events?.due?.discord ?? dueDiscordDefault,
webhook: s?.events?.due?.webhook ?? dueWebhookDefault,
push: s?.events?.due?.push ?? false,
},
shared: {
email: s?.events?.shared?.email ?? true,
discord: s?.events?.shared?.discord ?? false,
webhook: s?.events?.shared?.webhook ?? false,
push: s?.events?.shared?.push ?? false,
},
taskDone: {
email: s?.events?.taskDone?.email ?? false,
discord: s?.events?.taskDone?.discord ?? false,
webhook: s?.events?.taskDone?.webhook ?? false,
push: s?.events?.taskDone?.push ?? false,
},
watched: {
email: s?.events?.watched?.email ?? true,
discord: s?.events?.watched?.discord ?? true,
webhook: s?.events?.watched?.webhook ?? false,
push: s?.events?.watched?.push ?? false,
},
},
dailySummary: {
@@ -85,6 +90,7 @@ export function buildNotificationSettings(user: User): NotificationSettings {
email: s?.dailySummary?.email ?? true,
discord: s?.dailySummary?.discord ?? false,
webhook: s?.dailySummary?.webhook ?? false,
push: s?.dailySummary?.push ?? false,
},
};
}
@@ -271,6 +277,46 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const [savingAlarms, setSavingAlarms] = useState(false);
const [testingChannel, setTestingChannel] = useState<string | null>(null);
// Web push: devices of this account and whether this browser is one of them
const [pushDevices, setPushDevices] = useState<PushDevice[]>([]);
const [pushHere, setPushHere] = useState(false);
const [pushBusy, setPushBusy] = useState(false);
const [pushMsg, setPushMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const reloadPush = useCallback(async () => {
const [devices, sub] = await Promise.all([api.getPushDevices().catch(() => []), currentPushSubscription()]);
setPushDevices(devices);
setPushHere(Boolean(sub && devices.some((d) => d.endpoint === sub.endpoint)));
}, []);
useEffect(() => {
void reloadPush();
}, [reloadPush]);
const togglePushHere = async (on: boolean) => {
setPushBusy(true);
setPushMsg(null);
try {
if (on) await enablePush();
else await disablePush();
await reloadPush();
setPushMsg({ type: 'ok', text: on ? 'Push ist auf diesem Gerät eingeschaltet.' : 'Push ist auf diesem Gerät ausgeschaltet.' });
} catch (err) {
setPushMsg({ type: 'err', text: err instanceof Error ? err.message : 'Push konnte nicht eingeschaltet werden' });
} finally {
setPushBusy(false);
}
};
const sendTestPush = async () => {
setPushBusy(true);
setPushMsg(null);
try {
const res = await api.testPush();
setPushMsg({ type: 'ok', text: `Test-Benachrichtigung an ${res.devices} ${res.devices === 1 ? 'Gerät' : 'Geräte'} gesendet.` });
} catch (err) {
setPushMsg({ type: 'err', text: err instanceof Error ? err.message : 'Test fehlgeschlagen' });
} finally {
setPushBusy(false);
}
};
useEffect(() => {
setDisplayName(currentUser.displayName || '');
setEmail(currentUser.email || '');
@@ -283,7 +329,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}, [currentUser]);
const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord' | 'webhook', value: boolean) => {
const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord' | 'webhook' | 'push', value: boolean) => {
setNotificationSettings((prev) => ({
...prev,
events: {
@@ -1140,23 +1186,28 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
{(() => {
const hasDiscordWebhook = Boolean(discordWebhookUrl.trim());
const hasCustomWebhook = Boolean(customWebhookUrl.trim());
const hasPush = pushDevices.length > 0;
return (
<>
<div className="p-4 bg-canvas border border-border space-y-3">
<div className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-2 sm:gap-4 pb-2 border-b border-border">
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-1 sm:gap-4 pb-2 border-b border-border">
<span className="font-bold text-main">Ereignis</span>
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
<Mail className="w-3.5 h-3.5 text-accent" />
<span>E-Mail</span>
</span>
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
<MessageSquare className="w-3.5 h-3.5 text-accent" />
<span>Discord</span>
</span>
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
<Globe className="w-3.5 h-3.5 text-accent" />
<span>Webhook</span>
</span>
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
<Bell className="w-3.5 h-3.5 text-accent" />
<span>Push</span>
</span>
</div>
<div className="divide-y divide-border">
@@ -1165,17 +1216,17 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
return (
<div
key={row.key}
className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-2 sm:gap-4 py-2.5"
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-1 sm:gap-4 py-2.5"
>
<span className="font-medium text-main">{row.label}</span>
<div className="w-16 flex justify-center">
<div className="w-12 sm:w-16 flex justify-center">
<ToggleSwitch
checked={routing.email}
onChange={(v) => setEventChannel(row.key, 'email', v)}
title={`${row.label} – E-Mail`}
/>
</div>
<div className="w-16 flex justify-center">
<div className="w-12 sm:w-16 flex justify-center">
<ToggleSwitch
checked={routing.discord}
disabled={!hasDiscordWebhook}
@@ -1187,7 +1238,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}
/>
</div>
<div className="w-16 flex justify-center">
<div className="w-12 sm:w-16 flex justify-center">
<ToggleSwitch
checked={routing.webhook}
disabled={!hasCustomWebhook}
@@ -1199,6 +1250,14 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}
/>
</div>
<div className="w-12 sm:w-16 flex justify-center">
<ToggleSwitch
checked={routing.push}
disabled={!hasPush}
onChange={(v) => setEventChannel(row.key, 'push', v)}
title={hasPush ? `${row.label} – Push` : 'Bitte zuerst unten Push auf einem Gerät einschalten'}
/>
</div>
</div>
);
})}
@@ -1307,6 +1366,20 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Push</span>
<ToggleSwitch
checked={notificationSettings.dailySummary.push}
disabled={!hasPush}
onChange={(v) =>
setNotificationSettings((prev) => ({
...prev,
dailySummary: { ...prev.dailySummary, push: v },
}))
}
title={hasPush ? 'Tageszusammenfassung – Push' : 'Bitte zuerst unten Push auf einem Gerät einschalten'}
/>
</div>
</div>
</div>
</div>
@@ -1458,6 +1531,49 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</div>
</div>
{/* Web push: this browser or installed app */}
<div className={`p-4 border transition-colors space-y-3 bg-canvas ${pushHere ? 'border-accent' : 'border-border'}`}>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<Bell className="w-4 h-4 text-accent shrink-0" />
<div className="min-w-0">
<span className="font-bold text-main block">Ziel: Push-Benachrichtigungen</span>
<span className="text-[11px] text-muted">
Systembenachrichtigung auf diesem Gerät, auch wenn MindSift geschlossen ist. Auf iPhone und
iPad nur, wenn MindSift zum Home-Bildschirm hinzugefügt wurde.
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-muted">Dieses Gerät</span>
<ToggleSwitch
checked={pushHere}
disabled={pushBusy || demoMode || !pushSupported()}
onChange={(v) => void togglePushHere(v)}
title={pushSupported() ? 'Push auf diesem Gerät' : 'Dieser Browser unterstützt keine Push-Benachrichtigungen'}
/>
<button
type="button"
onClick={() => void sendTestPush()}
disabled={pushBusy || demoMode || pushDevices.length === 0}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Send className="w-3 h-3 text-accent" />
<span>Test senden</span>
</button>
</div>
</div>
{pushDevices.length > 0 && (
<p className="pl-6 text-[11px] text-faint">
Aktiv auf {pushDevices.length} {pushDevices.length === 1 ? 'Gerät' : 'Geräten'}:{' '}
{pushDevices.map((d) => d.device || 'Gerät').join(', ')}
</p>
)}
{pushMsg && (
<p className={`pl-6 text-[11px] ${pushMsg.type === 'ok' ? 'text-status-ok' : 'text-status-err'}`}>{pushMsg.text}</p>
)}
</div>
<div className="pt-2 border-t border-border flex justify-end">
<button
type="submit"
@@ -13,6 +13,7 @@ vi.mock('../../../lib/api', async () => {
...actual.api,
getMe: vi.fn(async () => ({ isLastAdmin: false })),
getAuthStatus: vi.fn(async () => ({ demoMode: false })),
getPushDevices: vi.fn(async () => []),
updateSettings: vi.fn(async (patch) => ({ ...baseUser, ...patch })),
},
};
@@ -88,10 +89,10 @@ describe('UserSettingsView notification matrix', () => {
paused: true,
includeOwnWatched: true,
events: {
due: { email: true, discord: true, webhook: false },
shared: { email: true, discord: false, webhook: false },
taskDone: { email: true, discord: false, webhook: false },
watched: { email: true, discord: false, webhook: false },
due: { email: true, discord: true, webhook: false, push: false },
shared: { email: true, discord: false, webhook: false, push: false },
taskDone: { email: true, discord: false, webhook: false, push: false },
watched: { email: true, discord: false, webhook: false, push: false },
},
dailySummary: {
enabled: true,
@@ -99,6 +100,7 @@ describe('UserSettingsView notification matrix', () => {
email: true,
discord: true,
webhook: false,
push: false,
},
},
})
+14
View File
@@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest';
import { deviceName, urlBase64ToUint8Array } from '../push';
describe('push helpers', () => {
it('decodes the VAPID key (base64url without padding)', () => {
expect(Array.from(urlBase64ToUint8Array('AQID_-8'))).toEqual([1, 2, 3, 255, 239]);
});
it('names the device', () => {
expect(deviceName('Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0')).toBe('Firefox auf Linux');
expect(deviceName('Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/129.0 Mobile Safari/537.36')).toBe('Chrome auf Android');
expect(deviceName('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1')).toBe('Safari auf iOS');
});
});
+22
View File
@@ -5,6 +5,7 @@ import {
Document,
TrashedDocument,
JournalDay,
PushDevice,
BackupFile,
DataSection,
ApiKeyScope,
@@ -724,6 +725,27 @@ export const api = {
return request<TrashedDocument[]>('/api/v1/documents/trash');
},
async getPushKey(): Promise<{ enabled: boolean; publicKey?: string }> {
return request('/api/v1/push/key');
},
async getPushDevices(): Promise<PushDevice[]> {
const res = await request<{ devices: PushDevice[] }>('/api/v1/push/devices');
return res.devices || [];
},
async subscribePush(sub: PushSubscriptionJSON, device: string): Promise<void> {
await request('/api/v1/push/subscribe', { method: 'POST', body: JSON.stringify({ ...sub, device }) });
},
async unsubscribePush(endpoint: string): Promise<void> {
await request('/api/v1/push/unsubscribe', { method: 'POST', body: JSON.stringify({ endpoint }) });
},
async testPush(): Promise<{ devices: number }> {
return request('/api/v1/push/test', { method: 'POST' });
},
/** Days of the own journal (newest first) with their notes. */
async getJournal(): Promise<JournalDay[]> {
const res = await request<{ days: JournalDay[] }>('/api/v1/journal');
+76
View File
@@ -0,0 +1,76 @@
import { api } from './api';
// Web push in this browser: the service worker shows the notifications, the server sends them over the
// browser's push service (VAPID). iPhones and iPads need MindSift added to the home screen first.
export function pushSupported(): boolean {
return typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
}
/** iOS/iPadOS outside the installed home screen app: push is not offered there */
export function needsHomeScreenInstall(): boolean {
if (typeof navigator === 'undefined') return false;
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const standalone =
window.matchMedia?.('(display-mode: standalone)').matches || (navigator as Navigator & { standalone?: boolean }).standalone === true;
return ios && !standalone;
}
export function deviceName(ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''): string {
const browser = /Edg\//.test(ua) ? 'Edge' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
const os = /Android/.test(ua) ? 'Android' : /iPhone|iPad|iPod/.test(ua) ? 'iOS' : /Windows/.test(ua) ? 'Windows' : /Mac OS X/.test(ua) ? 'macOS' : /Linux/.test(ua) ? 'Linux' : '';
return os ? `${browser} auf ${os}` : browser;
}
export function urlBase64ToUint8Array(base64: string): Uint8Array {
const padded = (base64 + '='.repeat((4 - (base64.length % 4)) % 4)).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(padded);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
async function registration(): Promise<ServiceWorkerRegistration> {
return navigator.serviceWorker.ready;
}
/** The subscription of this browser, if push is switched on here. */
export async function currentPushSubscription(): Promise<PushSubscription | null> {
if (!pushSupported()) return null;
try {
return await (await registration()).pushManager.getSubscription();
} catch {
return null;
}
}
export async function enablePush(): Promise<void> {
if (!pushSupported()) throw new Error('Dieser Browser unterstützt keine Push-Benachrichtigungen');
if (needsHomeScreenInstall()) throw new Error('Auf iPhone und iPad zuerst MindSift „Zum Home-Bildschirm“ hinzufügen und von dort öffnen');
const key = await api.getPushKey();
if (!key.enabled || !key.publicKey) throw new Error('Push ist auf diesem Server nicht verfügbar');
const permission = await Notification.requestPermission();
if (permission !== 'granted') throw new Error('Benachrichtigungen wurden im Browser nicht erlaubt');
const reg = await registration();
let sub = await reg.pushManager.getSubscription();
if (!sub) {
try {
sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(key.publicKey) as BufferSource,
});
} catch {
// e.g. "Registration failed – permission denied": blocked for the site or no push service reachable
throw new Error('Der Browser hat die Anmeldung beim Push-Dienst abgelehnt (Berechtigung blockiert oder Push-Dienst nicht erreichbar)');
}
}
await api.subscribePush(sub.toJSON(), deviceName());
}
export async function disablePush(): Promise<void> {
const sub = await currentPushSubscription();
if (!sub) return;
const endpoint = sub.endpoint;
await sub.unsubscribe().catch(() => false);
await api.unsubscribePush(endpoint).catch(() => {});
}
+10
View File
@@ -50,6 +50,7 @@ export interface NotificationChannelRouting {
email: boolean;
discord: boolean;
webhook: boolean;
push: boolean;
}
export interface NotificationEvents {
@@ -65,6 +66,15 @@ export interface DailySummarySettings {
email: boolean;
discord: boolean;
webhook: boolean;
push: boolean;
}
/** A browser or installed app that receives web push */
export interface PushDevice {
id: number;
endpoint: string;
device: string;
createdAt: string;
}
export interface NotificationSettings {
+1
View File
@@ -3,6 +3,7 @@ module github.com/sandrodach/mindsift
go 1.23
require (
github.com/SherClockHolmes/webpush-go v1.4.0
github.com/go-chi/chi/v5 v5.2.0
github.com/go-chi/cors v1.2.1
github.com/golang-jwt/jwt/v5 v5.2.1
+67 -4
View File
@@ -1,3 +1,5 @@
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
@@ -6,6 +8,7 @@ github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -22,15 +25,75 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
+7
View File
@@ -143,6 +143,9 @@ func (s *Server) startAlarmTicker() {
if set.Events.Due.Webhook {
channels = append(channels, "webhook")
}
if set.Events.Due.Push {
channels = append(channels, "push")
}
if a.AlarmEmail == "" {
a.AlarmEmail = a.UserEmail
}
@@ -166,6 +169,10 @@ func (s *Server) startAlarmTicker() {
sentAny = true
}
}
case "push":
if err := s.sendPush(a.UserID, pushMessage{Title: "⏰ Aufgabe fällig", Body: a.Title + " – " + due, URL: "/", Tag: "task-" + a.TaskID}); err == nil {
sentAny = true
}
case "webhook":
if a.CustomWebhookURL != "" {
if err := s.sendCustomWebhookNotification(a.CustomWebhookURL, "task_due", a.Title, desc, a.DueDate, a.Priority); err == nil {
+30 -3
View File
@@ -190,7 +190,7 @@ func (s *Server) flushNotifications(now time.Time) {
// channelItems collects what goes out on each channel.
type channelItems struct {
email, discord, webhook []notifyItem
email, discord, webhook, push []notifyItem
}
func (c *channelItems) add(ch domain.ChannelChoice, items ...notifyItem) {
@@ -203,6 +203,9 @@ func (c *channelItems) add(ch domain.ChannelChoice, items ...notifyItem) {
if ch.Webhook {
c.webhook = append(c.webhook, items...)
}
if ch.Push {
c.push = append(c.push, items...)
}
}
// deliver sends the items to the user's mail address, Discord webhook and/or own webhook.
@@ -236,6 +239,11 @@ func (s *Server) deliver(userID, heading string, out channelItems) error {
firstErr = err
}
}
if len(out.push) > 0 {
if err := s.sendPush(userID, pushDigest(heading, out.push, s.notifyLink())); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
@@ -254,6 +262,25 @@ func (s *Server) sendNotifyDiscord(webhookURL, heading string, items []notifyIte
return s.sendDiscordDigest(webhookURL, heading, items)
}
// pushDigest: one notification per bundle; a single event is its own title
func pushDigest(heading string, items []notifyItem, link string) pushMessage {
if link == "" {
link = "/"
}
if len(items) == 1 {
return pushMessage{Title: "MindSift", Body: items[0].text, URL: link, Tag: "digest"}
}
lines := make([]string, 0, 5)
for i, it := range items {
if i == 4 {
lines = append(lines, fmt.Sprintf("… und %d weitere", len(items)-4))
break
}
lines = append(lines, "• "+it.text)
}
return pushMessage{Title: "MindSift: " + heading, Body: strings.Join(lines, "\n"), URL: link, Tag: "digest"}
}
func (s *Server) sendNotifyWebhook(webhookURL, heading string, items []notifyItem) error {
if s.notifyWebhookHook != nil {
return s.notifyWebhookHook(webhookURL, heading, items)
@@ -377,7 +404,7 @@ func (s *Server) sendDailySummaries(now time.Time) {
for _, uid := range s.store.ActiveUserIDs() {
set := s.store.NotificationSettingsOf(uid)
ds := set.DailySummary
if set.Paused || !ds.Enabled || (!ds.Email && !ds.Discord && !ds.Webhook) {
if set.Paused || !ds.Enabled || (!ds.Email && !ds.Discord && !ds.Webhook && !ds.Push) {
continue
}
at, err := time.ParseInLocation("2006-01-02 15:04", day+" "+ds.Time, now.Location())
@@ -389,7 +416,7 @@ func (s *Server) sendDailySummaries(now time.Time) {
continue
}
var out channelItems
out.add(domain.ChannelChoice{Email: ds.Email, Discord: ds.Discord, Webhook: ds.Webhook}, items...)
out.add(domain.ChannelChoice{Email: ds.Email, Discord: ds.Discord, Webhook: ds.Webhook, Push: ds.Push}, items...)
if err := s.deliver(uid, "Deine Aufgaben für heute", out); err != nil {
slog.Warn("Tageszusammenfassung konnte nicht verschickt werden", "user_id", uid, "error", err)
}
+1 -1
View File
@@ -134,7 +134,7 @@ func TestOwnChangesPauseAndChannels(t *testing.T) {
// Settings round trip over the profile API
rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", myToken, `{"notificationSettings":{"paused":false,"events":{"shared":{"email":false,"discord":true}},"dailySummary":{"enabled":true,"time":"6:5"}}}`, "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"time":"07:00"`) || !strings.Contains(rec.Body.String(), `"shared":{"email":false,"discord":true,"webhook":false}`) {
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"time":"07:00"`) || !strings.Contains(rec.Body.String(), `"shared":{"email":false,"discord":true,"webhook":false,"push":false}`) {
t.Fatalf("settings: %d %s", rec.Code, rec.Body.String())
}
}
+217
View File
@@ -0,0 +1,217 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
webpush "github.com/SherClockHolmes/webpush-go"
"github.com/sandrodach/mindsift/internal/auth"
)
// Web push: browsers and installed apps (Android, iOS from 16.4 as home screen app, desktop) subscribe
// with the server's VAPID key; notifications reach them even when MindSift is closed. Push is one more
// channel next to e-mail, Discord and the own webhook (notification settings). The VAPID key pair comes
// from VAPID_PUBLIC_KEY/VAPID_PRIVATE_KEY or is generated once and kept in the database.
type pushKeys struct {
once sync.Once
public, priv string
err error
}
func (s *Server) vapidKeys() (string, string, error) {
s.push.once.Do(func() {
if s.cfg.VAPIDPublicKey != "" && s.cfg.VAPIDPrivateKey != "" {
s.push.public, s.push.priv = s.cfg.VAPIDPublicKey, s.cfg.VAPIDPrivateKey
return
}
s.push.public, s.push.priv, s.push.err = s.store.PushKeys(func() (string, string, error) {
priv, pub, err := webpush.GenerateVAPIDKeys()
return pub, priv, err
})
})
return s.push.public, s.push.priv, s.push.err
}
// vapidSubject: contact for the push services (VAPID_SUBJECT, else the https base URL)
func (s *Server) vapidSubject() string {
if s.cfg.VAPIDSubject != "" {
return s.cfg.VAPIDSubject
}
if s.cfg.BaseURLSet && strings.HasPrefix(s.cfg.BaseURL, "https://") {
return strings.TrimRight(s.cfg.BaseURL, "/")
}
return "mailto:push@mindsift.invalid"
}
type pushMessage struct {
Title string `json:"title"`
Body string `json:"body"`
URL string `json:"url"`
Tag string `json:"tag,omitempty"`
}
// sendPush delivers a message to every device of the user; devices the push service no longer knows
// are forgotten. Error when no device could be reached.
func (s *Server) sendPush(userID string, msg pushMessage) error {
if s.notifyPushHook != nil {
return s.notifyPushHook(userID, msg)
}
if s.cfg.DemoMode {
return errDemoOutgoing
}
subs, err := s.store.PushSubscriptions(userID)
if err != nil || len(subs) == 0 {
return err
}
pub, priv, err := s.vapidKeys()
if err != nil {
return err
}
payload, _ := json.Marshal(msg)
client := s.pushHTTPClient
if client == nil {
client = guardedClient(s.allowPrivateWebhooks())
}
var lastErr error
sent := 0
for _, sub := range subs {
resp, err := webpush.SendNotification(payload, &webpush.Subscription{
Endpoint: sub.Endpoint,
Keys: webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth},
}, &webpush.Options{
HTTPClient: client,
Subscriber: s.vapidSubject(),
VAPIDPublicKey: pub,
VAPIDPrivateKey: priv,
TTL: 24 * 3600,
Urgency: webpush.UrgencyHigh,
})
if err != nil {
lastErr = err
continue
}
resp.Body.Close()
switch {
case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone:
s.store.ForgetPushEndpoint(sub.Endpoint)
case resp.StatusCode >= 400:
lastErr = fmt.Errorf("Push-Dienst antwortet mit HTTP %d", resp.StatusCode)
default:
sent++
}
}
if sent == 0 && lastErr != nil {
return lastErr
}
return nil
}
func (s *Server) handlePushKey(w http.ResponseWriter, r *http.Request) {
if s.cfg.DemoMode {
writeJSON(w, http.StatusOK, map[string]interface{}{"enabled": false})
return
}
pub, _, err := s.vapidKeys()
if err != nil {
slog.Error("VAPID-Schlüssel nicht verfügbar", "error", err)
writeError(w, http.StatusInternalServerError, "Push ist auf diesem Server nicht verfügbar")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"enabled": true, "publicKey": pub})
}
func (s *Server) handlePushDevices(w http.ResponseWriter, r *http.Request) {
subs, err := s.store.PushSubscriptions(auth.GetUserID(r.Context()))
if err != nil {
writeError(w, http.StatusInternalServerError, "Geräte konnten nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"devices": subs})
}
func (s *Server) handlePushSubscribe(w http.ResponseWriter, r *http.Request) {
if s.cfg.DemoMode {
writeError(w, http.StatusForbidden, "In der Demo deaktiviert")
return
}
var req struct {
Endpoint string `json:"endpoint"`
Keys struct {
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
} `json:"keys"`
Device string `json:"device"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
if err := validPushEndpoint(req.Endpoint); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.Keys.P256dh == "" || req.Keys.Auth == "" {
writeError(w, http.StatusBadRequest, "Schlüssel des Geräts fehlen")
return
}
device := strings.TrimSpace(req.Device)
if len(device) > 120 {
device = device[:120]
}
if err := s.store.SavePushSubscription(auth.GetUserID(r.Context()), req.Endpoint, req.Keys.P256dh, req.Keys.Auth, device); err != nil {
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gespeichert werden")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handlePushUnsubscribe(w http.ResponseWriter, r *http.Request) {
var req struct {
Endpoint string `json:"endpoint"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Endpoint == "" {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
if err := s.store.DeletePushSubscription(auth.GetUserID(r.Context()), req.Endpoint); err != nil {
writeError(w, http.StatusInternalServerError, "Gerät konnte nicht entfernt werden")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handlePushTest(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
subs, _ := s.store.PushSubscriptions(userID)
if len(subs) == 0 {
writeError(w, http.StatusBadRequest, "Auf keinem Gerät sind Push-Benachrichtigungen eingeschaltet")
return
}
err := s.sendPush(userID, pushMessage{Title: "MindSift", Body: "Test: Push-Benachrichtigungen kommen an.", URL: "/", Tag: "test"})
if err != nil {
if errors.Is(err, errDemoOutgoing) {
writeError(w, http.StatusForbidden, err.Error())
return
}
slog.Warn("Test-Push fehlgeschlagen", "user_id", userID, "error", err)
writeError(w, http.StatusBadGateway, "Push konnte nicht zugestellt werden")
return
}
writeJSON(w, http.StatusOK, map[string]int{"devices": len(subs)})
}
// validPushEndpoint: the push services of the browsers are https URLs on the public internet.
func validPushEndpoint(endpoint string) error {
u, err := url.Parse(endpoint)
if err != nil || u.Scheme != "https" || u.Host == "" || len(endpoint) > 2048 {
return errors.New("Ungültige Push-Adresse")
}
return nil
}
+87
View File
@@ -0,0 +1,87 @@
package api
import (
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"golang.org/x/crypto/hkdf"
)
// A push sent by the server can be decrypted like a browser does (RFC 8291, aes128gcm) and carries
// a VAPID authorization; a device the push service no longer knows (410) is forgotten.
func TestPushPayloadDecryptsLikeABrowser(t *testing.T) {
srv, store, _ := setupTestServer(t)
me, _ := newTestUser(t, store, "geraet@example.com")
// The "browser": its key pair and auth secret
recv, _ := ecdh.P256().GenerateKey(rand.Reader)
authSecret := make([]byte, 16)
_, _ = rand.Read(authSecret)
b64 := base64.RawURLEncoding
var body []byte
var authz string
status := http.StatusCreated
push := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ = io.ReadAll(r.Body)
authz = r.Header.Get("Authorization")
w.WriteHeader(status)
}))
defer push.Close()
srv.pushHTTPClient = push.Client()
endpoint := push.URL + "/send/geraet-1"
if err := store.SavePushSubscription(me.ID, endpoint, b64.EncodeToString(recv.PublicKey().Bytes()), b64.EncodeToString(authSecret), "Test"); err != nil {
t.Fatal(err)
}
if err := srv.sendPush(me.ID, pushMessage{Title: "MindSift", Body: "Steuer fällig", URL: "/"}); err != nil {
t.Fatalf("send: %v", err)
}
if !strings.HasPrefix(authz, "vapid t=") || !strings.Contains(authz, ", k=") {
t.Fatalf("missing VAPID authorization: %q", authz)
}
// aes128gcm header: salt(16) | record size(4) | key id length(1) | sender public key
salt, idLen := body[:16], int(body[20])
senderPub, cipherText := body[21:21+idLen], body[21+idLen:]
sender, err := ecdh.P256().NewPublicKey(senderPub)
if err != nil {
t.Fatalf("sender key: %v", err)
}
shared, _ := recv.ECDH(sender)
info := append(append([]byte("WebPush: info\x00"), recv.PublicKey().Bytes()...), senderPub...)
ikm := make([]byte, 32)
_, _ = io.ReadFull(hkdf.New(sha256.New, shared, authSecret, info), ikm)
cek := make([]byte, 16)
nonce := make([]byte, 12)
_, _ = io.ReadFull(hkdf.New(sha256.New, ikm, salt, []byte("Content-Encoding: aes128gcm\x00")), cek)
_, _ = io.ReadFull(hkdf.New(sha256.New, ikm, salt, []byte("Content-Encoding: nonce\x00")), nonce)
block, _ := aes.NewCipher(cek)
gcm, _ := cipher.NewGCM(block)
plain, err := gcm.Open(nil, nonce, cipherText, nil)
if err != nil {
t.Fatalf("decrypt: %v", err)
}
plain = plain[:strings.LastIndexByte(string(plain), 2)] // padding delimiter
var got pushMessage
if err := json.Unmarshal(plain, &got); err != nil || got.Body != "Steuer fällig" {
t.Fatalf("payload %q: %v", plain, err)
}
// The push service says the device is gone: it is removed
status = http.StatusGone
_ = srv.sendPush(me.ID, pushMessage{Title: "x"})
if subs, _ := store.PushSubscriptions(me.ID); len(subs) != 0 {
t.Fatalf("gone device must be forgotten, still %d", len(subs))
}
}
+79
View File
@@ -0,0 +1,79 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"github.com/sandrodach/mindsift/internal/domain"
)
func TestPushSubscribeKeyAndChannel(t *testing.T) {
srv, store, _ := setupTestServer(t)
srv.cfg.NotifyDigestMinutes = 5
router := srv.Router()
me, token := newTestUser(t, store, "push@example.com")
// The key is generated once and stays the same
var k1, k2 struct {
Enabled bool
PublicKey string
}
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/push/key", token, "", "").Body.Bytes(), &k1)
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/push/key", token, "", "").Body.Bytes(), &k2)
if !k1.Enabled || len(k1.PublicKey) < 80 || k1.PublicKey != k2.PublicKey {
t.Fatalf("key: %+v / %+v", k1, k2)
}
if rec := sessionCall(router, "POST", "/api/v1/push/subscribe", token, `{"endpoint":"http://intern/x","keys":{"p256dh":"a","auth":"b"}}`, ""); rec.Code != http.StatusBadRequest {
t.Fatalf("http endpoint must be refused, got %d", rec.Code)
}
body := `{"endpoint":"https://push.example.com/abc","keys":{"p256dh":"BAAA","auth":"CCCC"},"device":"Firefox auf Linux"}`
if rec := sessionCall(router, "POST", "/api/v1/push/subscribe", token, body, ""); rec.Code != http.StatusOK {
t.Fatalf("subscribe: %d %s", rec.Code, rec.Body.String())
}
rec := sessionCall(router, "GET", "/api/v1/push/devices", token, "", "")
if !strings.Contains(rec.Body.String(), "Firefox auf Linux") || strings.Contains(rec.Body.String(), "CCCC") {
t.Fatalf("devices must list the device without its keys: %s", rec.Body.String())
}
// Push as a channel for watched tasks
var pushed []pushMessage
srv.notifyPushHook = func(userID string, msg pushMessage) error {
if userID == me.ID {
pushed = append(pushed, msg)
}
return nil
}
task, _ := store.CreateThoughtWithDetails(me.ID, "Reifen wechseln", "normal", "manual", "", "", "todo", "")
_ = store.WatchTask(task.ID, me.ID, true)
set := store.NotificationSettingsOf(me.ID)
set.IncludeOwnWatched = true
set.Events.Watched = domain.ChannelChoice{Push: true}
_ = store.SetNotificationSettings(me.ID, set)
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, token, `{"priority":"high"}`, "")
srv.flushNotifications(time.Now().Add(10 * time.Minute))
if len(pushed) != 1 || !strings.Contains(pushed[0].Body, "Reifen wechseln") {
t.Fatalf("expected one push, got %+v", pushed)
}
if rec := sessionCall(router, "POST", "/api/v1/push/unsubscribe", token, `{"endpoint":"https://push.example.com/abc"}`, ""); rec.Code != http.StatusOK {
t.Fatalf("unsubscribe: %d", rec.Code)
}
if rec := sessionCall(router, "POST", "/api/v1/push/test", token, "", ""); rec.Code != http.StatusBadRequest {
t.Fatalf("test without devices: want 400, got %d", rec.Code)
}
}
func TestPushDigestText(t *testing.T) {
one := pushDigest("Neuigkeiten", []notifyItem{{text: "Anna hat „Einkauf“ erledigt"}}, "")
if one.Body != "Anna hat „Einkauf“ erledigt" || one.URL != "/" {
t.Fatalf("single: %+v", one)
}
many := pushDigest("Neuigkeiten", []notifyItem{{text: "a"}, {text: "b"}, {text: "c"}, {text: "d"}, {text: "e"}, {text: "f"}}, "https://x/")
if many.Title != "MindSift: Neuigkeiten" || !strings.HasSuffix(many.Body, "… und 2 weitere") {
t.Fatalf("many: %+v", many)
}
}
+8
View File
@@ -39,6 +39,9 @@ type Server struct {
notifyEmailHook func(to, subject, text, html string) error
notifyDiscordHook func(webhookURL, heading string, items []notifyItem) error
notifyWebhookHook func(webhookURL, heading string, items []notifyItem) error
notifyPushHook func(userID string, msg pushMessage) error
pushHTTPClient *http.Client // tests: reach a local push service
push pushKeys
}
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
@@ -218,6 +221,11 @@ func (s *Server) Router() http.Handler {
r.Put("/api/v1/workspace/tags/{tag}/note", s.handleUpdateTagPageNote)
r.Get("/api/v1/journal", s.handleGetJournal)
r.Get("/api/v1/push/key", s.handlePushKey)
r.Get("/api/v1/push/devices", s.handlePushDevices)
r.Post("/api/v1/push/subscribe", s.handlePushSubscribe)
r.Post("/api/v1/push/unsubscribe", s.handlePushUnsubscribe)
r.Post("/api/v1/push/test", s.handlePushTest)
r.Post("/api/v1/journal/{date}", s.handleOpenJournalDay)
r.Get("/api/v1/documents", s.handleGetDocuments)
r.Post("/api/v1/documents", s.handleCreateDocument)
+8
View File
@@ -54,6 +54,11 @@ type Config struct {
// NotifyDigestMinutes: notifications (except task alarms) are bundled over this many minutes
NotifyDigestMinutes int
// Web push (VAPID_*): keys are generated and stored in the database when not set
VAPIDPublicKey string
VAPIDPrivateKey string
VAPIDSubject string
}
// MediaConfig limits uploads of images and audio recordings in notes. Sizes in MB; QuotaMB 0 = no limit
@@ -241,6 +246,9 @@ func Load() *Config {
TimeoutSeconds: envInt("STT_TIMEOUT_SECONDS", 120, 5, 3600),
},
NotifyDigestMinutes: envInt("NOTIFY_DIGEST_MINUTES", 5, 1, 1440),
VAPIDPublicKey: strings.TrimSpace(os.Getenv("VAPID_PUBLIC_KEY")),
VAPIDPrivateKey: strings.TrimSpace(os.Getenv("VAPID_PRIVATE_KEY")),
VAPIDSubject: strings.TrimSpace(os.Getenv("VAPID_SUBJECT")),
Media: MediaConfig{
MaxImageMB: envInt("MEDIA_MAX_IMAGE_MB", 15, 1, 1000),
MaxAudioMB: envInt("MEDIA_MAX_AUDIO_MB", 50, 1, 2000),
+3 -1
View File
@@ -158,10 +158,11 @@ type ChannelChoice struct {
Email bool `json:"email"`
Discord bool `json:"discord"`
Webhook bool `json:"webhook"` // own webhook (customWebhookUrl), JSON payload
Push bool `json:"push"` // web push to the user's browsers and phones
}
// Any reports whether at least one channel is chosen.
func (c ChannelChoice) Any() bool { return c.Email || c.Discord || c.Webhook }
func (c ChannelChoice) Any() bool { return c.Email || c.Discord || c.Webhook || c.Push }
type DailySummary struct {
Enabled bool `json:"enabled"`
@@ -169,6 +170,7 @@ type DailySummary struct {
Email bool `json:"email"`
Discord bool `json:"discord"`
Webhook bool `json:"webhook"`
Push bool `json:"push"`
}
// DefaultNotificationSettings for users who never saved any; task alarms follow the old alarm channels.
+1
View File
@@ -29,6 +29,7 @@ var migrations = []migration{
{6, "Link-Vorschauen", (*Store).migrateLinkPreviews},
{7, "Benachrichtigungen", (*Store).migrateNotifications},
{8, "Journal", (*Store).migrateJournal},
{9, "Web-Push", (*Store).migratePush},
}
func (s *Store) migrate() error {
+99
View File
@@ -0,0 +1,99 @@
package storage
import (
"database/sql"
"errors"
"time"
)
// Web push: the browsers (devices) of a user that receive notifications, and the server's VAPID key
// pair when it is not given by environment.
type PushSubscription struct {
ID int64 `json:"id"`
UserID string `json:"-"`
Endpoint string `json:"endpoint"`
P256dh string `json:"-"`
Auth string `json:"-"`
Device string `json:"device"`
CreatedAt string `json:"createdAt"`
}
func (s *Store) migratePush() error {
return s.execInTx(
stmt(`CREATE TABLE IF NOT EXISTS push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
device TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`),
stmt(`CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id)`),
stmt(`CREATE TABLE IF NOT EXISTS push_keys (
id INTEGER PRIMARY KEY CHECK (id = 1),
public_key TEXT NOT NULL,
private_key TEXT NOT NULL
)`),
)
}
// SavePushSubscription stores a device; an endpoint already known moves to this user.
func (s *Store) SavePushSubscription(userID, endpoint, p256dh, auth, device string) error {
_, err := s.db.Exec(`INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, device, created_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(endpoint) DO UPDATE SET user_id = excluded.user_id, p256dh = excluded.p256dh,
auth = excluded.auth, device = excluded.device`,
userID, endpoint, p256dh, auth, device, time.Now().UTC().Format(time.RFC3339))
return err
}
func (s *Store) DeletePushSubscription(userID, endpoint string) error {
_, err := s.db.Exec(`DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?`, userID, endpoint)
return err
}
// ForgetPushEndpoint removes a device the push service no longer knows (HTTP 404/410).
func (s *Store) ForgetPushEndpoint(endpoint string) {
_, _ = s.db.Exec(`DELETE FROM push_subscriptions WHERE endpoint = ?`, endpoint)
}
func (s *Store) PushSubscriptions(userID string) ([]PushSubscription, error) {
rows, err := s.db.Query(`SELECT id, user_id, endpoint, p256dh, auth, device, created_at
FROM push_subscriptions WHERE user_id = ? ORDER BY id`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []PushSubscription{}
for rows.Next() {
var p PushSubscription
if err := rows.Scan(&p.ID, &p.UserID, &p.Endpoint, &p.P256dh, &p.Auth, &p.Device, &p.CreatedAt); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// PushKeys returns the stored VAPID key pair; generate is called (once) when there is none yet.
func (s *Store) PushKeys(generate func() (public, private string, err error)) (string, string, error) {
var pub, priv string
err := s.db.QueryRow(`SELECT public_key, private_key FROM push_keys WHERE id = 1`).Scan(&pub, &priv)
if err == nil {
return pub, priv, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return "", "", err
}
if pub, priv, err = generate(); err != nil {
return "", "", err
}
if _, err := s.db.Exec(`INSERT OR IGNORE INTO push_keys (id, public_key, private_key) VALUES (1, ?, ?)`, pub, priv); err != nil {
return "", "", err
}
// Another start may have won the race: use what is stored
err = s.db.QueryRow(`SELECT public_key, private_key FROM push_keys WHERE id = 1`).Scan(&pub, &priv)
return pub, priv, err
}