From 7d464c21eb739d8e54ff5656212dd37b4afa04fd Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 23 Apr 2026 11:44:28 +0200 Subject: [PATCH] feat: Web Push notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VAPID-based push via web-push package - push_subscriptions table: endpoint + keys per user (upsert on conflict) - GET /api/push/vapid-key — public key for subscribe flow - POST/DELETE /api/push/subscribe — store/remove subscription - POST /api/push/test — manual test notification - Hourly scheduler: notifies users day before homework due + countdown expires - SW: push event handler shows notification; notificationclick opens /app - Settings: Push section with enable/disable/test buttons, auto-detects browser support and VAPID availability --- index.js | 6 ++- package-lock.json | 83 +++++++++++++++++++++++++++++++++++++++-- package.json | 12 +++++- public/app.html | 94 +++++++++++++++++++++++++++++++++++++++++++++++ public/sw.js | 26 +++++++++++++ src/db.js | 12 ++++++ src/push.js | 80 ++++++++++++++++++++++++++++++++++++++++ src/routes.js | 38 +++++++++++++++++++ 8 files changed, 344 insertions(+), 7 deletions(-) create mode 100644 src/push.js diff --git a/index.js b/index.js index d89440b..bc3b7bb 100644 --- a/index.js +++ b/index.js @@ -6,6 +6,7 @@ const path = require('path'); const routes = require('./src/routes'); const { router: filesRouter } = require('./src/files'); const teacherRouter = require('./src/teacher'); +const { startNotificationScheduler } = require('./src/push'); if (!process.env.JWT_SECRET) { console.error('FATAL: JWT_SECRET environment variable is not set.'); @@ -51,4 +52,7 @@ app.get('/app', html('app.html')); app.get('/reset-password', html('reset-password.html')); app.get('/{*path}', html('index.html')); -app.listen(PORT, '127.0.0.1', () => console.log(`info1 läuft auf :${PORT}`)); +app.listen(PORT, '127.0.0.1', () => { + console.log(`info1 läuft auf :${PORT}`); + startNotificationScheduler(); +}); diff --git a/package-lock.json b/package-lock.json index 220cd09..7694269 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { "name": "info1", - "version": "1.0.0", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "info1", - "version": "1.0.0", - "license": "ISC", + "version": "0.1.0", + "license": "MIT", "dependencies": { "bcryptjs": "^3.0.3", "better-sqlite3": "^12.9.0", @@ -20,7 +20,8 @@ "multer": "^2.1.1", "otplib": "^13.4.0", "qrcode": "^1.5.4", - "resend": "^6.12.0" + "resend": "^6.12.0", + "web-push": "^3.6.7" } }, "node_modules/@noble/hashes": { @@ -119,6 +120,15 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -149,6 +159,18 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -212,6 +234,12 @@ "readable-stream": "^3.4.0" } }, + "node_modules/bn.js": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", + "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -869,6 +897,15 @@ "node": ">=18.0.0" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -889,6 +926,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -1134,6 +1184,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -1956,6 +2012,25 @@ "node": ">= 0.8" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", diff --git a/package.json b/package.json index b3ebb5f..53d2eb0 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,14 @@ "scripts": { "start": "node index.js" }, - "keywords": ["dashboard", "education", "class", "self-hosted", "express", "sqlite"], + "keywords": [ + "dashboard", + "education", + "class", + "self-hosted", + "express", + "sqlite" + ], "author": "lulinretrograde", "license": "MIT", "repository": { @@ -25,6 +32,7 @@ "multer": "^2.1.1", "otplib": "^13.4.0", "qrcode": "^1.5.4", - "resend": "^6.12.0" + "resend": "^6.12.0", + "web-push": "^3.6.7" } } diff --git a/public/app.html b/public/app.html index 836b4dc..e09750d 100644 --- a/public/app.html +++ b/public/app.html @@ -1631,6 +1631,16 @@ footer { +
+
Push-Benachrichtigungen
+
Wird geprüft…
+
+ + + +
+
+
Kalender-Export (iCal)

Abonniere deinen Stundenplan, Hausaufgaben und Events in Google Calendar, Apple Calendar oder Outlook.

@@ -2165,6 +2175,7 @@ function openSettings(){ document.getElementById('settings-overlay').style.display='flex'; document.getElementById('user-dropdown')?.classList.remove('open'); load2FAStatus(); + initPushUI(); if(currentRole==='teacher'){ document.getElementById('teacher-subject-section').style.display=''; loadMySubjects(); @@ -2369,6 +2380,89 @@ async function deleteAccount(){ else toast(r.error,'error'); } +// ── WEB PUSH ────────────────────────────────────────────────── +let _pushReg = null; + +function urlBase64ToUint8Array(b64) { + const pad = '='.repeat((4 - b64.length % 4) % 4); + const raw = atob((b64 + pad).replace(/-/g, '+').replace(/_/g, '/')); + return new Uint8Array([...raw].map(c => c.charCodeAt(0))); +} + +async function initPushUI() { + const section = document.getElementById('push-section'); + if (!('serviceWorker' in navigator) || !('PushManager' in window)) { + section.style.display = 'none'; return; + } + const { key } = await api('GET', 'push/vapid-key').catch(() => ({})); + if (!key) { section.style.display = 'none'; return; } + + const reg = await navigator.serviceWorker.ready; + _pushReg = reg; + const existing = await reg.pushManager.getSubscription(); + updatePushUI(existing); +} + +function updatePushUI(sub) { + const status = document.getElementById('push-status'); + const btnEn = document.getElementById('btn-push-enable'); + const btnDis = document.getElementById('btn-push-disable'); + const btnTest = document.getElementById('btn-push-test'); + if (sub) { + status.textContent = 'Aktiv – du erhältst Benachrichtigungen bei fälligen Hausaufgaben und Countdowns.'; + status.style.color = 'var(--green)'; + btnEn.style.display = 'none'; + btnDis.style.display = ''; + btnTest.style.display = ''; + } else { + status.textContent = 'Deaktiviert.'; + status.style.color = 'var(--text-muted)'; + btnEn.style.display = ''; + btnDis.style.display = 'none'; + btnTest.style.display = 'none'; + } +} + +async function enablePush() { + try { + const { key } = await api('GET', 'push/vapid-key'); + const perm = await Notification.requestPermission(); + if (perm !== 'granted') { toast('Benachrichtigungen verweigert', 'error'); return; } + const reg = _pushReg || await navigator.serviceWorker.ready; + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(key), + }); + const { endpoint, keys } = sub.toJSON(); + const r = await api('POST', 'push/subscribe', { endpoint, keys }); + if (r.error) { toast(r.error, 'error'); return; } + updatePushUI(sub); + toast('Push-Benachrichtigungen aktiviert ✓'); + } catch (e) { + toast('Fehler: ' + e.message, 'error'); + } +} + +async function disablePush() { + try { + const reg = _pushReg || await navigator.serviceWorker.ready; + const sub = await reg.pushManager.getSubscription(); + if (sub) { + await api('DELETE', 'push/subscribe', { endpoint: sub.endpoint }); + await sub.unsubscribe(); + } + updatePushUI(null); + toast('Push-Benachrichtigungen deaktiviert'); + } catch (e) { + toast('Fehler: ' + e.message, 'error'); + } +} + +async function testPush() { + await api('POST', 'push/test'); + toast('Test-Benachrichtigung gesendet'); +} + // ── ICAL EXPORT ─────────────────────────────────────────────── async function getIcalUrl(regen=false){ const r=await api('POST', regen?'ical-token/regenerate':'ical-token'); diff --git a/public/sw.js b/public/sw.js index 6bd46ac..b525456 100644 --- a/public/sw.js +++ b/public/sw.js @@ -28,6 +28,32 @@ self.addEventListener('activate', e => { ); }); +self.addEventListener('push', e => { + let data = { title: 'Klassenportal', body: '' }; + try { data = e.data ? e.data.json() : data; } catch {} + e.waitUntil( + self.registration.showNotification(data.title, { + body: data.body, + icon: '/icons/icon-192.png', + badge: '/icons/icon-192.png', + tag: data.tag || 'kp', + renotify: true, + data: { url: data.url || '/app' }, + }) + ); +}); + +self.addEventListener('notificationclick', e => { + e.notification.close(); + const url = e.notification.data?.url || '/app'; + e.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => { + const found = list.find(c => c.url.includes(self.location.origin)); + return found ? found.focus() : clients.openWindow(url); + }) + ); +}); + self.addEventListener('fetch', e => { const url = new URL(e.request.url); diff --git a/src/db.js b/src/db.js index 596913c..0d25b6f 100644 --- a/src/db.js +++ b/src/db.js @@ -251,6 +251,18 @@ try { db.exec(`ALTER TABLE absences ADD COLUMN teacher_id INTEGER`); } catch {} try { db.exec(`ALTER TABLE users ADD COLUMN ical_token TEXT`); } catch {} try { db.exec(`ALTER TABLE grades ADD COLUMN created_at TEXT DEFAULT (datetime('now'))`); } catch {} +db.exec(` + CREATE TABLE IF NOT EXISTS push_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + endpoint TEXT NOT NULL UNIQUE, + keys TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) + ); + CREATE INDEX IF NOT EXISTS idx_push_user ON push_subscriptions(user_id); +`); + db.exec(` CREATE TABLE IF NOT EXISTS password_resets ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/src/push.js b/src/push.js new file mode 100644 index 0000000..38d172b --- /dev/null +++ b/src/push.js @@ -0,0 +1,80 @@ +const webpush = require('web-push'); +const db = require('./db'); + +if (process.env.VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY) { + webpush.setVapidDetails( + process.env.VAPID_SUBJECT || 'mailto:admin@localhost', + process.env.VAPID_PUBLIC_KEY, + process.env.VAPID_PRIVATE_KEY + ); +} + +async function sendToUser(userId, payload) { + const subs = db.prepare('SELECT id, endpoint, keys FROM push_subscriptions WHERE user_id = ?').all(userId); + for (const sub of subs) { + try { + await webpush.sendNotification( + { endpoint: sub.endpoint, keys: JSON.parse(sub.keys) }, + typeof payload === 'string' ? payload : JSON.stringify(payload) + ); + } catch (err) { + if (err.statusCode === 410 || err.statusCode === 404) { + db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(sub.id); + } + } + } +} + +async function sendToAll(payload) { + const subs = db.prepare('SELECT id, user_id, endpoint, keys FROM push_subscriptions').all(); + for (const sub of subs) { + try { + await webpush.sendNotification( + { endpoint: sub.endpoint, keys: JSON.parse(sub.keys) }, + typeof payload === 'string' ? payload : JSON.stringify(payload) + ); + } catch (err) { + if (err.statusCode === 410 || err.statusCode === 404) { + db.prepare('DELETE FROM push_subscriptions WHERE id = ?').run(sub.id); + } + } + } +} + +function startNotificationScheduler() { + if (!process.env.VAPID_PUBLIC_KEY) return; + + setInterval(async () => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const tomorrowStr = tomorrow.toISOString().slice(0, 10); + + // Homework due tomorrow + const hwUsers = db.prepare(` + SELECT DISTINCT user_id FROM homework + WHERE due_date = ? AND done = 0 + `).all(tomorrowStr); + + for (const { user_id } of hwUsers) { + const items = db.prepare("SELECT title, subject FROM homework WHERE user_id = ? AND due_date = ? AND done = 0").all(user_id, tomorrowStr); + const body = items.length === 1 + ? `${items[0].subject ? items[0].subject + ': ' : ''}${items[0].title}` + : `${items.length} Aufgaben fällig`; + await sendToUser(user_id, { title: '📚 Hausaufgaben morgen fällig', body, tag: 'hw-due' }); + } + + // Countdowns expiring tomorrow + const cdUsers = db.prepare(` + SELECT DISTINCT user_id FROM countdowns WHERE target_date = ? + `).all(tomorrowStr); + + for (const { user_id } of cdUsers) { + const items = db.prepare('SELECT title FROM countdowns WHERE user_id = ? AND target_date = ?').all(user_id, tomorrowStr); + for (const c of items) { + await sendToUser(user_id, { title: '⏳ Morgen: ' + c.title, body: 'Dein Countdown endet morgen', tag: 'cd-' + c.title }); + } + } + }, 60 * 60 * 1000); // check every hour +} + +module.exports = { sendToUser, sendToAll, startNotificationScheduler, vapidPublicKey: process.env.VAPID_PUBLIC_KEY }; diff --git a/src/routes.js b/src/routes.js index 6945e63..8ac82b5 100644 --- a/src/routes.js +++ b/src/routes.js @@ -10,6 +10,7 @@ const db = require('./db'); const { signToken, requireAuth, COOKIE_OPTIONS } = require('./auth'); const { deleteUserFiles } = require('./files'); const { sendVerifyMail, sendPasswordResetMail } = require('./mailer'); +const { sendToUser, vapidPublicKey } = require('./push'); const STORAGE_DIR = path.resolve(__dirname, '../storage'); @@ -892,6 +893,43 @@ router.get('/ical/:token.ics', (req, res) => { res.send(L.join('\r\n')); }); +// --- WEB PUSH --- +router.get('/push/vapid-key', (req, res) => { + if (!vapidPublicKey) return res.status(503).json({ error: 'Push not configured' }); + res.json({ key: vapidPublicKey }); +}); + +router.post('/push/subscribe', requireAuth, (req, res) => { + const { endpoint, keys } = req.body; + if (!endpoint || !keys) return res.status(400).json({ error: 'endpoint and keys required' }); + try { + db.prepare(` + INSERT INTO push_subscriptions (user_id, endpoint, keys) + VALUES (?, ?, ?) + ON CONFLICT(endpoint) DO UPDATE SET user_id=excluded.user_id, keys=excluded.keys + `).run(req.user.id, endpoint, JSON.stringify(keys)); + res.json({ ok: true }); + } catch (e) { + res.status(500).json({ error: 'Speichern fehlgeschlagen' }); + } +}); + +router.delete('/push/subscribe', requireAuth, (req, res) => { + const { endpoint } = req.body; + if (endpoint) { + db.prepare('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?').run(req.user.id, endpoint); + } else { + db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(req.user.id); + } + res.json({ ok: true }); +}); + +// Test notification (dev only convenience) +router.post('/push/test', requireAuth, async (req, res) => { + await sendToUser(req.user.id, { title: '🔔 Klassenportal', body: 'Push-Benachrichtigungen funktionieren!', tag: 'test' }); + res.json({ ok: true }); +}); + // --- SESSION MANAGEMENT --- router.post('/me/logout-other', requireAuth, (req, res) => { const newVer = (req.user.token_version | 0) + 1;