feat: Web Push notifications

- 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
This commit is contained in:
Simon
2026-04-23 11:44:28 +02:00
parent a765d2d088
commit 7d464c21eb
8 changed files with 344 additions and 7 deletions
+5 -1
View File
@@ -6,6 +6,7 @@ const path = require('path');
const routes = require('./src/routes'); const routes = require('./src/routes');
const { router: filesRouter } = require('./src/files'); const { router: filesRouter } = require('./src/files');
const teacherRouter = require('./src/teacher'); const teacherRouter = require('./src/teacher');
const { startNotificationScheduler } = require('./src/push');
if (!process.env.JWT_SECRET) { if (!process.env.JWT_SECRET) {
console.error('FATAL: JWT_SECRET environment variable is not set.'); 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('/reset-password', html('reset-password.html'));
app.get('/{*path}', html('index.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();
});
+79 -4
View File
@@ -1,13 +1,13 @@
{ {
"name": "info1", "name": "info1",
"version": "1.0.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "info1", "name": "info1",
"version": "1.0.0", "version": "0.1.0",
"license": "ISC", "license": "MIT",
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-sqlite3": "^12.9.0", "better-sqlite3": "^12.9.0",
@@ -20,7 +20,8 @@
"multer": "^2.1.1", "multer": "^2.1.1",
"otplib": "^13.4.0", "otplib": "^13.4.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"resend": "^6.12.0" "resend": "^6.12.0",
"web-push": "^3.6.7"
} }
}, },
"node_modules/@noble/hashes": { "node_modules/@noble/hashes": {
@@ -119,6 +120,15 @@
"node": ">= 0.6" "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": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -149,6 +159,18 @@
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT" "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": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -212,6 +234,12 @@
"readable-stream": "^3.4.0" "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": { "node_modules/body-parser": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -869,6 +897,15 @@
"node": ">=18.0.0" "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": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -889,6 +926,19 @@
"url": "https://opencollective.com/express" "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": { "node_modules/iconv-lite": {
"version": "0.7.2", "version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -1134,6 +1184,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/minimist": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -1956,6 +2012,25 @@
"node": ">= 0.8" "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": { "node_modules/which-module": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+10 -2
View File
@@ -5,7 +5,14 @@
"scripts": { "scripts": {
"start": "node index.js" "start": "node index.js"
}, },
"keywords": ["dashboard", "education", "class", "self-hosted", "express", "sqlite"], "keywords": [
"dashboard",
"education",
"class",
"self-hosted",
"express",
"sqlite"
],
"author": "lulinretrograde", "author": "lulinretrograde",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
@@ -25,6 +32,7 @@
"multer": "^2.1.1", "multer": "^2.1.1",
"otplib": "^13.4.0", "otplib": "^13.4.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"resend": "^6.12.0" "resend": "^6.12.0",
"web-push": "^3.6.7"
} }
} }
+94
View File
@@ -1631,6 +1631,16 @@ footer {
</div> </div>
</div> </div>
<div class="settings-section" id="push-section">
<div class="settings-label">Push-Benachrichtigungen</div>
<div id="push-status" style="font-size:13px;color:var(--text-muted);margin-bottom:8px">Wird geprüft…</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn-save" id="btn-push-enable" style="font-size:12px;padding:5px 12px;display:none" onclick="enablePush()">Aktivieren</button>
<button class="btn-cancel" id="btn-push-disable" style="font-size:12px;padding:5px 12px;display:none" onclick="disablePush()">Deaktivieren</button>
<button class="btn-cancel" id="btn-push-test" style="font-size:12px;padding:5px 12px;display:none" onclick="testPush()">Test senden</button>
</div>
</div>
<div class="settings-section"> <div class="settings-section">
<div class="settings-label">Kalender-Export (iCal)</div> <div class="settings-label">Kalender-Export (iCal)</div>
<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Abonniere deinen Stundenplan, Hausaufgaben und Events in Google Calendar, Apple Calendar oder Outlook.</p> <p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Abonniere deinen Stundenplan, Hausaufgaben und Events in Google Calendar, Apple Calendar oder Outlook.</p>
@@ -2165,6 +2175,7 @@ function openSettings(){
document.getElementById('settings-overlay').style.display='flex'; document.getElementById('settings-overlay').style.display='flex';
document.getElementById('user-dropdown')?.classList.remove('open'); document.getElementById('user-dropdown')?.classList.remove('open');
load2FAStatus(); load2FAStatus();
initPushUI();
if(currentRole==='teacher'){ if(currentRole==='teacher'){
document.getElementById('teacher-subject-section').style.display=''; document.getElementById('teacher-subject-section').style.display='';
loadMySubjects(); loadMySubjects();
@@ -2369,6 +2380,89 @@ async function deleteAccount(){
else toast(r.error,'error'); 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 ─────────────────────────────────────────────── // ── ICAL EXPORT ───────────────────────────────────────────────
async function getIcalUrl(regen=false){ async function getIcalUrl(regen=false){
const r=await api('POST', regen?'ical-token/regenerate':'ical-token'); const r=await api('POST', regen?'ical-token/regenerate':'ical-token');
+26
View File
@@ -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 => { self.addEventListener('fetch', e => {
const url = new URL(e.request.url); const url = new URL(e.request.url);
+12
View File
@@ -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 users ADD COLUMN ical_token TEXT`); } catch {}
try { db.exec(`ALTER TABLE grades ADD COLUMN created_at TEXT DEFAULT (datetime('now'))`); } 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(` db.exec(`
CREATE TABLE IF NOT EXISTS password_resets ( CREATE TABLE IF NOT EXISTS password_resets (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
+80
View File
@@ -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 };
+38
View File
@@ -10,6 +10,7 @@ const db = require('./db');
const { signToken, requireAuth, COOKIE_OPTIONS } = require('./auth'); const { signToken, requireAuth, COOKIE_OPTIONS } = require('./auth');
const { deleteUserFiles } = require('./files'); const { deleteUserFiles } = require('./files');
const { sendVerifyMail, sendPasswordResetMail } = require('./mailer'); const { sendVerifyMail, sendPasswordResetMail } = require('./mailer');
const { sendToUser, vapidPublicKey } = require('./push');
const STORAGE_DIR = path.resolve(__dirname, '../storage'); const STORAGE_DIR = path.resolve(__dirname, '../storage');
@@ -892,6 +893,43 @@ router.get('/ical/:token.ics', (req, res) => {
res.send(L.join('\r\n')); 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 --- // --- SESSION MANAGEMENT ---
router.post('/me/logout-other', requireAuth, (req, res) => { router.post('/me/logout-other', requireAuth, (req, res) => {
const newVer = (req.user.token_version | 0) + 1; const newVer = (req.user.token_version | 0) + 1;