rebrand: Klassenportal, domain info1.ifb.lol, server status
- Replace INFO1 brand with Klassenportal everywhere (titles, nav, emails, TOTP issuer, recovery codes) - Update domain from info1.simon0x.xyz to info1.ifb.lol - Remove E2EE claims (e2ee.js was deleted, claims were false) - Add GET /api/health endpoint (DB check + uptime) - Add live server status section to landing page - Fix README: domain, title, layout table
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
# Schulapp
|
# Klassenportal
|
||||||
|
|
||||||
Dashboard für die Schulklassen. Stundenplan, Hausaufgaben, Noten, Fehlzeiten, Klassenkalender, Chat und Ende zu Ende verschlüsselte Nachrichten an einem Ort.
|
Privates Dashboard für Schulklassen. Stundenplan, Hausaufgaben, Noten, Fehlzeiten, Klassenkalender und Chat an einem Ort.
|
||||||
|
|
||||||
Läuft unter [info1.simon0x.xyz](https://info1.simon0x.xyz).
|
Läuft unter [info1.ifb.lol](https://info1.ifb.lol).
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
Node.js 20, Express 5, SQLite über `better-sqlite3`, Vanilla JS im Frontend. Kein Buildschritt. Auth per JWT im HttpOnly-Cookie, bcrypt, TOTP-2FA. Mail über Resend. E2EE mit ECDH P-256 und AES-GCM im Browser.
|
Node.js 20, Express 5, SQLite über `better-sqlite3`, Vanilla JS im Frontend. Kein Buildschritt. Auth per JWT im HttpOnly-Cookie, bcrypt, TOTP-2FA. Mail über Resend.
|
||||||
|
|
||||||
## Lokal starten
|
## Lokal starten
|
||||||
|
|
||||||
@@ -26,12 +26,13 @@ Beschränkt auf `@ifb-schulen.com`-Adressen (Regex `IFB_EMAIL_RE` in `src/routes
|
|||||||
|
|
||||||
```
|
```
|
||||||
index.js Express-Bootstrap
|
index.js Express-Bootstrap
|
||||||
|
src/auth.js JWT-Sign/-Verify, requireAuth-Middleware
|
||||||
src/db.js SQLite, Schema, Migrationen
|
src/db.js SQLite, Schema, Migrationen
|
||||||
src/routes.js Auth, Admin, Chat, Kalender, Tickets
|
src/routes.js Auth, Admin, Chat, Kalender, Tickets, Health
|
||||||
src/teacher.js Lehrerendpoints
|
src/teacher.js Lehrerendpoints
|
||||||
src/files.js Dateiablage mit Quota
|
src/files.js Dateiablage mit Quota
|
||||||
src/mailer.js Resend-Wrapper
|
src/mailer.js Resend-Wrapper
|
||||||
public/ HTML-Seiten und E2EE-Primitiven
|
public/ HTML-Seiten (Vanilla JS)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Lizenz
|
## Lizenz
|
||||||
|
|||||||
+2
-2
@@ -5,14 +5,14 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js"
|
"start": "node index.js"
|
||||||
},
|
},
|
||||||
"keywords": ["dashboard", "education", "class", "self-hosted", "e2ee", "express", "sqlite"],
|
"keywords": ["dashboard", "education", "class", "self-hosted", "express", "sqlite"],
|
||||||
"author": "lulinretrograde",
|
"author": "lulinretrograde",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "git+https://github.com/lulinretrograde/ifb-schulapp.git"
|
"url": "git+https://github.com/lulinretrograde/ifb-schulapp.git"
|
||||||
},
|
},
|
||||||
"description": "Self-hosted class dashboard: timetable, homework, grades, shared calendar, class chat, and end-to-end encrypted direct messages. Built on Node.js, Express, SQLite, vanilla JS.",
|
"description": "Self-hosted class dashboard: timetable, homework, grades, shared calendar, and class chat. Built on Node.js, Express, SQLite, vanilla JS.",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"better-sqlite3": "^12.9.0",
|
"better-sqlite3": "^12.9.0",
|
||||||
|
|||||||
+2
-2
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>INFO1 · Admin</title>
|
<title>Klassenportal · Admin</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
@@ -128,7 +128,7 @@ tbody td { padding: 10px 14px; vertical-align: middle; }
|
|||||||
<header>
|
<header>
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<div class="brand-school">Klassenportal</div>
|
<div class="brand-school">Klassenportal</div>
|
||||||
<div class="brand-class">INFO1</div>
|
<div class="brand-class">Klassenportal</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-spacer"></div>
|
<div class="h-spacer"></div>
|
||||||
<span class="admin-badge">Admin-Panel</span>
|
<span class="admin-badge">Admin-Panel</span>
|
||||||
|
|||||||
+10
-15
@@ -3,12 +3,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>INFO1 · Dashboard</title>
|
<title>Klassenportal · Dashboard</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||||
<script src="/e2ee.js"></script>
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--blue: #2563eb; --blue-d: #1d4ed8;
|
--blue: #2563eb; --blue-d: #1d4ed8;
|
||||||
@@ -1018,10 +1017,10 @@ footer {
|
|||||||
<button id="sidebar-btn" class="h-icon-btn" onclick="openSidebar()" title="Menü" aria-label="Seitenleiste öffnen">☰</button>
|
<button id="sidebar-btn" class="h-icon-btn" onclick="openSidebar()" title="Menü" aria-label="Seitenleiste öffnen">☰</button>
|
||||||
|
|
||||||
<div class="brand" onclick="location.href='/'">
|
<div class="brand" onclick="location.href='/'">
|
||||||
<div class="brand-mark">i1</div>
|
<div class="brand-mark">KP</div>
|
||||||
<div class="brand-text">
|
<div class="brand-text">
|
||||||
<span class="brand-sub">Klassenportal</span>
|
<span class="brand-sub">Klassenportal</span>
|
||||||
<span class="brand-name">INFO1</span>
|
<span class="brand-name">Klassenportal</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1426,7 +1425,7 @@ footer {
|
|||||||
<div id="fs-backdrop" onclick="closeOverlay()"></div>
|
<div id="fs-backdrop" onclick="closeOverlay()"></div>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<div class="footer-left">Daten auf <strong>Hetzner-Server, Nürnberg, Deutschland</strong> · EU-DSGVO konform</div>
|
<div class="footer-left">Daten auf <strong>Server in Deutschland</strong> · EU-DSGVO konform</div>
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a href="/datenschutz">Datenschutzerklärung</a>
|
<a href="/datenschutz">Datenschutzerklärung</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -1745,7 +1744,7 @@ function loginUI(username,id,role,subject){
|
|||||||
|
|
||||||
loadSubjectsDatalist();
|
loadSubjectsDatalist();
|
||||||
loadAll();
|
loadAll();
|
||||||
initChat(username, id);
|
initChat(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleDropdown(el){
|
function toggleDropdown(el){
|
||||||
@@ -2216,10 +2215,10 @@ function copyRecoveryCodes(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function downloadRecoveryCodes(){
|
function downloadRecoveryCodes(){
|
||||||
const text='INFO1 Wiederherstellungscodes\n'+new Date().toLocaleString('de-DE')+'\n\n'+_recoveryCodes.join('\n')+'\n\nJeder Code kann nur einmal verwendet werden.';
|
const text='Klassenportal Wiederherstellungscodes\n'+new Date().toLocaleString('de-DE')+'\n\n'+_recoveryCodes.join('\n')+'\n\nJeder Code kann nur einmal verwendet werden.';
|
||||||
const a=document.createElement('a');
|
const a=document.createElement('a');
|
||||||
a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(text);
|
a.href='data:text/plain;charset=utf-8,'+encodeURIComponent(text);
|
||||||
a.download='info1-wiederherstellungscodes.txt';
|
a.download='klassenportal-wiederherstellungscodes.txt';
|
||||||
a.click();
|
a.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2314,7 +2313,7 @@ async function renderChatMsg(m, append) {
|
|||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'chat-msg';
|
div.className = 'chat-msg';
|
||||||
div.dataset.id = m.id;
|
div.dataset.id = m.id;
|
||||||
const displayContent = await E2EE.decrypt(m.content);
|
const displayContent = m.content;
|
||||||
div.innerHTML = `<div class="chat-msg-meta">
|
div.innerHTML = `<div class="chat-msg-meta">
|
||||||
<span class="chat-msg-user${isOwn ? ' own' : ''}">${esc(m.username)}</span>
|
<span class="chat-msg-user${isOwn ? ' own' : ''}">${esc(m.username)}</span>
|
||||||
<span class="chat-msg-time">${chatFmtTime(m.created_at)}</span>
|
<span class="chat-msg-time">${chatFmtTime(m.created_at)}</span>
|
||||||
@@ -2353,10 +2352,7 @@ async function sendChatMsg() {
|
|||||||
const content = inp.value.trim();
|
const content = inp.value.trim();
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
inp.value = '';
|
inp.value = '';
|
||||||
let ciphertext;
|
const r = await api('POST', 'chat', { content });
|
||||||
try { ciphertext = await E2EE.encrypt(content); }
|
|
||||||
catch { toast('Verschlüsselung fehlgeschlagen', 'error'); inp.value = content; return; }
|
|
||||||
const r = await api('POST', 'chat', { content: ciphertext });
|
|
||||||
if (r.error) { toast(r.error, 'error'); inp.value = content; return; }
|
if (r.error) { toast(r.error, 'error'); inp.value = content; return; }
|
||||||
await renderChatMsg(r, true);
|
await renderChatMsg(r, true);
|
||||||
chatLastId = Math.max(chatLastId, r.id);
|
chatLastId = Math.max(chatLastId, r.id);
|
||||||
@@ -2369,9 +2365,8 @@ async function delChatMsg(id) {
|
|||||||
document.querySelector(`.chat-msg[data-id="${id}"]`)?.remove();
|
document.querySelector(`.chat-msg[data-id="${id}"]`)?.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initChat(username, userId) {
|
async function initChat(username) {
|
||||||
chatMyUsername = username;
|
chatMyUsername = username;
|
||||||
await E2EE.init(userId);
|
|
||||||
await loadChat();
|
await loadChat();
|
||||||
pollChat();
|
pollChat();
|
||||||
document.getElementById('chat-input').addEventListener('keydown', e => {
|
document.getElementById('chat-input').addEventListener('keydown', e => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Datenschutzerklärung · INFO1 Dashboard</title>
|
<title>Datenschutzerklärung · Klassenportal</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
@@ -33,17 +33,17 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<span class="brand">INFO1 Dashboard</span>
|
<span class="brand">Klassenportal</span>
|
||||||
<a class="back" href="/">← Zurück</a>
|
<a class="back" href="/">← Zurück</a>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<h1>Datenschutzerklärung</h1>
|
<h1>Datenschutzerklärung</h1>
|
||||||
<p class="subtitle">Zuletzt aktualisiert: April 2026 · Gilt für info1.simon0x.xyz</p>
|
<p class="subtitle">Zuletzt aktualisiert: April 2026 · Gilt für info1.ifb.lol</p>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>1. Verantwortlicher</h2>
|
<h2>1. Verantwortlicher</h2>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
<p>Dieses Dashboard wird privat von einer Privatperson betrieben, ausschließlich für interne Klassenzwecke der Klasse INFO1. Es handelt sich weder um ein kommerzielles Angebot noch um ein offizielles Angebot einer Schule, eines Trägers oder einer Bildungseinrichtung.</p>
|
<p>Dieses Klassenportal wird privat von einer Privatperson betrieben, ausschließlich für interne Klassenzwecke. Es handelt sich weder um ein kommerzielles Angebot noch um ein offizielles Angebot einer Schule, eines Trägers oder einer Bildungseinrichtung.</p>
|
||||||
<p style="margin-top:10px"><strong>Kontakt:</strong> <a href="mailto:kontakt@simon0x.xyz">kontakt@simon0x.xyz</a></p>
|
<p style="margin-top:10px"><strong>Kontakt:</strong> <a href="mailto:kontakt@simon0x.xyz">kontakt@simon0x.xyz</a></p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -71,9 +71,8 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>3. Wo werden die Daten gespeichert?</h2>
|
<h2>3. Wo werden die Daten gespeichert?</h2>
|
||||||
<p>Alle Daten werden in einer SQLite-Datenbank auf einem virtuellen Server der <strong>Hetzner Online GmbH</strong> (Industriestr. 25, 91710 Gunzenhausen, Deutschland) gespeichert. Der Server befindet sich physisch im Rechenzentrum Nürnberg (Deutschland) und unterliegt damit deutschem und europäischem Datenschutzrecht.</p>
|
<p>Alle Daten werden in einer SQLite-Datenbank auf einem virtuellen Server in <strong>Deutschland</strong> gespeichert. Der Server unterliegt deutschem und europäischem Datenschutzrecht.</p>
|
||||||
<p>Es findet <strong>keine Übertragung in Drittländer</strong> (außerhalb der EU/EWR) statt.</p>
|
<p>Es findet <strong>keine Übertragung in Drittländer</strong> (außerhalb der EU/EWR) statt.</p>
|
||||||
<p>Hetzner ist als Auftragsverarbeiter gem. Art. 28 DSGVO tätig. Datenschutzerklärung Hetzner: <a href="https://www.hetzner.com/legal/privacy-policy" target="_blank" rel="noopener">hetzner.com/legal/privacy-policy</a></p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
-197
@@ -1,197 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
// E2EE module — Signal-style hybrid encryption for group chat.
|
|
||||||
// Uses Web Crypto API only. Private keys never leave this device.
|
|
||||||
const E2EE = (() => {
|
|
||||||
const GROUP_ID = 'info1';
|
|
||||||
const IDB_NAME = 'ifb_e2ee_v1';
|
|
||||||
const IDB_STORE = 'k';
|
|
||||||
const sub = crypto.subtle;
|
|
||||||
const te = new TextEncoder();
|
|
||||||
const td = new TextDecoder();
|
|
||||||
const EC = { name: 'ECDH', namedCurve: 'P-256' };
|
|
||||||
|
|
||||||
// ── IndexedDB ─────────────────────────────────────────────────
|
|
||||||
function openIDB() {
|
|
||||||
return new Promise((res, rej) => {
|
|
||||||
const r = indexedDB.open(IDB_NAME, 1);
|
|
||||||
r.onupgradeneeded = e => e.target.result.createObjectStore(IDB_STORE);
|
|
||||||
r.onsuccess = e => res(e.target.result);
|
|
||||||
r.onerror = () => rej(r.error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async function idbGet(key) {
|
|
||||||
const db = await openIDB();
|
|
||||||
return new Promise((res, rej) => {
|
|
||||||
const t = db.transaction(IDB_STORE, 'readonly').objectStore(IDB_STORE).get(key);
|
|
||||||
t.onsuccess = () => res(t.result);
|
|
||||||
t.onerror = () => rej(t.error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async function idbSet(key, val) {
|
|
||||||
const db = await openIDB();
|
|
||||||
return new Promise((res, rej) => {
|
|
||||||
const t = db.transaction(IDB_STORE, 'readwrite').objectStore(IDB_STORE).put(val, key);
|
|
||||||
t.onsuccess = () => res();
|
|
||||||
t.onerror = () => rej(t.error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── REST helper ───────────────────────────────────────────────
|
|
||||||
async function rpc(method, path, body) {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/' + path, {
|
|
||||||
method,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: body ? JSON.stringify(body) : undefined
|
|
||||||
});
|
|
||||||
return r.json();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[E2EE] rpc', method, path, e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Binary helpers ────────────────────────────────────────────
|
|
||||||
function toB64(buf) { return btoa(String.fromCharCode(...new Uint8Array(buf))); }
|
|
||||||
function fromB64(str) { return Uint8Array.from(atob(str), c => c.charCodeAt(0)); }
|
|
||||||
function randomId() {
|
|
||||||
try { return crypto.randomUUID(); }
|
|
||||||
catch { return toB64(crypto.getRandomValues(new Uint8Array(18))); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Key generation & import ───────────────────────────────────
|
|
||||||
const genECDH = () => sub.generateKey(EC, true, ['deriveKey', 'deriveBits']);
|
|
||||||
const importECPub = jwk => sub.importKey('jwk', jwk, EC, true, []);
|
|
||||||
const importECPriv = jwk => sub.importKey('jwk', jwk, EC, true, ['deriveKey', 'deriveBits']);
|
|
||||||
const importAES = raw => sub.importKey('raw', raw, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
||||||
const genAES = () => sub.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
|
||||||
const exportRaw = key => sub.exportKey('raw', key);
|
|
||||||
|
|
||||||
// ECDH(myPriv, theirPub) → HKDF → AES-256-GCM wrap key
|
|
||||||
async function deriveWrapKey(myPriv, theirPub) {
|
|
||||||
const bits = await sub.deriveBits({ name: 'ECDH', public: theirPub }, myPriv, 256);
|
|
||||||
const hk = await sub.importKey('raw', bits, 'HKDF', false, ['deriveKey']);
|
|
||||||
return sub.deriveKey(
|
|
||||||
{ name: 'HKDF', hash: 'SHA-256', salt: te.encode('ifb-e2ee-v1'), info: te.encode(GROUP_ID) },
|
|
||||||
hk, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── AES-GCM encrypt/decrypt ───────────────────────────────────
|
|
||||||
async function aesEncrypt(key, data) {
|
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
||||||
const ct = await sub.encrypt({ name: 'AES-GCM', iv }, key, data);
|
|
||||||
return { iv: toB64(iv), ct: toB64(ct) };
|
|
||||||
}
|
|
||||||
async function aesDecrypt(key, ivB64, ctB64) {
|
|
||||||
return sub.decrypt({ name: 'AES-GCM', iv: fromB64(ivB64) }, key, fromB64(ctB64));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Sender key wrap/unwrap ────────────────────────────────────
|
|
||||||
async function wrapSenderKey(rawKey, myPriv, theirPub) {
|
|
||||||
const wk = await deriveWrapKey(myPriv, theirPub);
|
|
||||||
const { iv, ct } = await aesEncrypt(wk, rawKey);
|
|
||||||
return JSON.stringify({ iv, ct });
|
|
||||||
}
|
|
||||||
async function unwrapSenderKey(encJson, myPriv, distPub) {
|
|
||||||
const { iv, ct } = JSON.parse(encJson);
|
|
||||||
const wk = await deriveWrapKey(myPriv, distPub);
|
|
||||||
return aesDecrypt(wk, iv, ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── State ─────────────────────────────────────────────────────
|
|
||||||
let myKeyPair = null; // { priv: CryptoKey, pub: CryptoKey }
|
|
||||||
let myUserId = null;
|
|
||||||
let activeSK = null; // { key: CryptoKey, kid: string }
|
|
||||||
let kidCache = {}; // kid → CryptoKey
|
|
||||||
|
|
||||||
// ── Core logic ────────────────────────────────────────────────
|
|
||||||
async function init(userId) {
|
|
||||||
myUserId = userId;
|
|
||||||
try {
|
|
||||||
// Load or generate P-256 identity key pair in IndexedDB
|
|
||||||
let privJwk = await idbGet('priv_' + userId);
|
|
||||||
let pubJwk = await idbGet('pub_' + userId);
|
|
||||||
if (!privJwk || !pubJwk) {
|
|
||||||
const kp = await genECDH();
|
|
||||||
privJwk = await sub.exportKey('jwk', kp.privateKey);
|
|
||||||
pubJwk = await sub.exportKey('jwk', kp.publicKey);
|
|
||||||
await idbSet('priv_' + userId, privJwk);
|
|
||||||
await idbSet('pub_' + userId, pubJwk);
|
|
||||||
}
|
|
||||||
myKeyPair = { priv: await importECPriv(privJwk), pub: await importECPub(pubJwk) };
|
|
||||||
|
|
||||||
// Publish public key (server upserts, idempotent)
|
|
||||||
const pubResp = await rpc('POST', 'e2ee/public-key', { public_key_jwk: JSON.stringify(pubJwk) });
|
|
||||||
if (!pubResp || pubResp.error) console.warn('[E2EE] public-key publish failed', pubResp);
|
|
||||||
|
|
||||||
// Fresh sender key per session → forward secrecy
|
|
||||||
await rotateSenderKey();
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[E2EE] init failed', e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function rotateSenderKey() {
|
|
||||||
const kid = randomId();
|
|
||||||
const senderKey = await genAES();
|
|
||||||
const raw = await exportRaw(senderKey);
|
|
||||||
|
|
||||||
// Distribute encrypted copy to every member who has a public key
|
|
||||||
const usersResp = await rpc('GET', 'e2ee/users');
|
|
||||||
const users = Array.isArray(usersResp) ? usersResp : [];
|
|
||||||
|
|
||||||
const dists = [];
|
|
||||||
for (const u of users) {
|
|
||||||
try {
|
|
||||||
const theirPub = await importECPub(JSON.parse(u.public_key_jwk));
|
|
||||||
dists.push({ user_id: u.id, encrypted_key: await wrapSenderKey(raw, myKeyPair.priv, theirPub) });
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
// Always include self (may not be in users list yet)
|
|
||||||
if (!dists.some(d => d.user_id === myUserId)) {
|
|
||||||
dists.push({ user_id: myUserId, encrypted_key: await wrapSenderKey(raw, myKeyPair.priv, myKeyPair.pub) });
|
|
||||||
}
|
|
||||||
|
|
||||||
await rpc('POST', 'e2ee/group-keys', { group_id: GROUP_ID, kid, keys: dists });
|
|
||||||
activeSK = { key: senderKey, kid };
|
|
||||||
kidCache[kid] = senderKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveKid(kid) {
|
|
||||||
if (kidCache[kid]) return kidCache[kid];
|
|
||||||
const row = await rpc('GET', `e2ee/group-key?group_id=${GROUP_ID}&kid=${encodeURIComponent(kid)}`);
|
|
||||||
if (!row || row.error) return null;
|
|
||||||
try {
|
|
||||||
const distPubResp = await rpc('GET', 'e2ee/public-key/' + row.distributor_user_id);
|
|
||||||
if (!distPubResp?.public_key_jwk) return null;
|
|
||||||
const distPub = await importECPub(JSON.parse(distPubResp.public_key_jwk));
|
|
||||||
const rawBuf = await unwrapSenderKey(row.encrypted_key, myKeyPair.priv, distPub);
|
|
||||||
const key = await importAES(rawBuf);
|
|
||||||
kidCache[kid] = key;
|
|
||||||
return key;
|
|
||||||
} catch { return null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function encrypt(plaintext) {
|
|
||||||
if (!activeSK) throw new Error('E2EE not initialized');
|
|
||||||
const { iv, ct } = await aesEncrypt(activeSK.key, te.encode(plaintext));
|
|
||||||
// ts included for replay detection on client side
|
|
||||||
return JSON.stringify({ v: 1, kid: activeSK.kid, iv, ct, ts: Date.now() });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function decrypt(content) {
|
|
||||||
let p;
|
|
||||||
try { p = JSON.parse(content); } catch { return content; } // not encrypted
|
|
||||||
if (!p.v || !p.kid || !p.iv || !p.ct) return content; // legacy plaintext
|
|
||||||
const key = await resolveKid(p.kid);
|
|
||||||
if (!key) return '[Nachricht nicht entschlusselbar]';
|
|
||||||
try {
|
|
||||||
const buf = await aesDecrypt(key, p.iv, p.ct);
|
|
||||||
return td.decode(buf);
|
|
||||||
} catch { return '[Entschlusselung fehlgeschlagen]'; }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { init, encrypt, decrypt, rotateSenderKey };
|
|
||||||
})();
|
|
||||||
+113
-24
@@ -3,8 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>INFO1 · Das Klassen-Cockpit</title>
|
<title>Klassenportal · Das Klassen-Cockpit</title>
|
||||||
<meta name="description" content="INFO1 ist ein privates Klassenportal für die INFO1-Klasse. Stundenplan, Noten, Hausaufgaben, Klassen-Chat und mehr.">
|
<meta name="description" content="Privates Klassenportal für Schüler und Lehrer. Stundenplan, Noten, Hausaufgaben, Klassen-Chat und mehr.">
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||||
@@ -538,6 +538,35 @@ footer {
|
|||||||
flex-wrap: wrap; gap: 12px;
|
flex-wrap: wrap; gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── STATUS ───────────────────── */
|
||||||
|
.status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.status-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--subtle);
|
||||||
|
}
|
||||||
|
.status-dot.ok { background: var(--green); box-shadow: 0 0 0 3px rgba(22,163,74,.15); }
|
||||||
|
.status-dot.err { background: var(--red); box-shadow: 0 0 0 3px rgba(220,38,38,.15); }
|
||||||
|
.status-label { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||||
|
.status-sub { font-size: 12px; color: var(--muted); margin-top: 1px; }
|
||||||
|
@media (max-width: 600px) { .status-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
/* ── REDACT ──────────────────── */
|
/* ── REDACT ──────────────────── */
|
||||||
.redact {
|
.redact {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -581,10 +610,10 @@ footer {
|
|||||||
<nav class="top">
|
<nav class="top">
|
||||||
<div class="nav-inner">
|
<div class="nav-inner">
|
||||||
<a class="brand" href="/">
|
<a class="brand" href="/">
|
||||||
<div class="brand-mark">i1</div>
|
<div class="brand-mark">KP</div>
|
||||||
<div class="brand-text">
|
<div class="brand-text">
|
||||||
<span class="brand-sub">Klassenportal</span>
|
<span class="brand-sub">Klassenportal</span>
|
||||||
<span class="brand-name">INFO1</span>
|
<span class="brand-name">Klassenportal</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="nav-links">
|
<div class="nav-links">
|
||||||
@@ -592,6 +621,7 @@ footer {
|
|||||||
<a href="#ueber">Für wen</a>
|
<a href="#ueber">Für wen</a>
|
||||||
<a href="#sicherheit">Sicherheit</a>
|
<a href="#sicherheit">Sicherheit</a>
|
||||||
<a href="#faq">FAQ</a>
|
<a href="#faq">FAQ</a>
|
||||||
|
<a href="#status">Status</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-right">
|
<div class="nav-right">
|
||||||
<button class="icon-btn" onclick="toggleDark()" title="Dark Mode" aria-label="Dark Mode"><i data-lucide="moon"></i></button>
|
<button class="icon-btn" onclick="toggleDark()" title="Dark Mode" aria-label="Dark Mode"><i data-lucide="moon"></i></button>
|
||||||
@@ -605,7 +635,7 @@ footer {
|
|||||||
<section class="hero">
|
<section class="hero">
|
||||||
<div class="hero-inner">
|
<div class="hero-inner">
|
||||||
<div>
|
<div>
|
||||||
<div class="eyebrow"><i data-lucide="sparkles"></i> Gemacht für die INFO1-Klasse</div>
|
<div class="eyebrow"><i data-lucide="sparkles"></i> Gemacht für Schüler und Lehrer</div>
|
||||||
<h1 class="hero-title">
|
<h1 class="hero-title">
|
||||||
Dein Schulalltag, <span class="accent">an einem Ort.</span>
|
Dein Schulalltag, <span class="accent">an einem Ort.</span>
|
||||||
</h1>
|
</h1>
|
||||||
@@ -624,7 +654,7 @@ footer {
|
|||||||
<div class="hero-trust">
|
<div class="hero-trust">
|
||||||
<span><i data-lucide="check"></i> Nur für IFB-Schüler</span>
|
<span><i data-lucide="check"></i> Nur für IFB-Schüler</span>
|
||||||
<span><i data-lucide="check"></i> 2-Faktor-Schutz</span>
|
<span><i data-lucide="check"></i> 2-Faktor-Schutz</span>
|
||||||
<span><i data-lucide="check"></i> Hetzner · Nürnberg</span>
|
<span><i data-lucide="check"></i> Server in Deutschland</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -715,7 +745,7 @@ footer {
|
|||||||
<div class="feat">
|
<div class="feat">
|
||||||
<div class="feat-icon"><i data-lucide="message-square"></i></div>
|
<div class="feat-icon"><i data-lucide="message-square"></i></div>
|
||||||
<h3>Klassen-Chat</h3>
|
<h3>Klassen-Chat</h3>
|
||||||
<p>Ende-zu-Ende verschlüsselter Austausch mit der Klasse – direkt im Dashboard.</p>
|
<p>Gemeinsamer Echtzeit-Chat mit der Klasse – direkt im Dashboard, kein WhatsApp nötig.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feat">
|
<div class="feat">
|
||||||
<div class="feat-icon"><i data-lucide="folder"></i></div>
|
<div class="feat-icon"><i data-lucide="folder"></i></div>
|
||||||
@@ -753,7 +783,7 @@ footer {
|
|||||||
<div class="sec-eyebrow">Für wen</div>
|
<div class="sec-eyebrow">Für wen</div>
|
||||||
<h2 class="sec-title">Zwei Perspektiven, ein System</h2>
|
<h2 class="sec-title">Zwei Perspektiven, ein System</h2>
|
||||||
<p class="sec-lead">
|
<p class="sec-lead">
|
||||||
Ob du im Klassenzimmer sitzt oder davor stehst – INFO1 passt sich an deine Rolle an.
|
Ob du im Klassenzimmer sitzt oder davor stehst – das Portal passt sich an deine Rolle an.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -766,7 +796,7 @@ footer {
|
|||||||
<li><i data-lucide="check"></i> Persönliches Dashboard mit deinem Stundenplan</li>
|
<li><i data-lucide="check"></i> Persönliches Dashboard mit deinem Stundenplan</li>
|
||||||
<li><i data-lucide="check"></i> Noten-Übersicht mit automatischem Schnitt</li>
|
<li><i data-lucide="check"></i> Noten-Übersicht mit automatischem Schnitt</li>
|
||||||
<li><i data-lucide="check"></i> Hausaufgaben, To-Dos und Countdowns an einem Ort</li>
|
<li><i data-lucide="check"></i> Hausaufgaben, To-Dos und Countdowns an einem Ort</li>
|
||||||
<li><i data-lucide="check"></i> Verschlüsselter Klassen-Chat + Dateispeicher</li>
|
<li><i data-lucide="check"></i> Klassen-Chat + persönlicher Dateispeicher</li>
|
||||||
<li><i data-lucide="check"></i> Shared Kalender für Klausuren & Events</li>
|
<li><i data-lucide="check"></i> Shared Kalender für Klausuren & Events</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -801,8 +831,8 @@ footer {
|
|||||||
<div class="sec-grid">
|
<div class="sec-grid">
|
||||||
<div class="sec-card">
|
<div class="sec-card">
|
||||||
<i data-lucide="shield-check"></i>
|
<i data-lucide="shield-check"></i>
|
||||||
<h4>DSGVO & Hetzner DE</h4>
|
<h4>DSGVO & Deutschland</h4>
|
||||||
<p>Server ausschließlich in Nürnberg. Keine Datenübertragung in Drittländer.</p>
|
<p>Server ausschließlich in Deutschland. Keine Datenübertragung in Drittländer.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="sec-card">
|
<div class="sec-card">
|
||||||
<i data-lucide="key-round"></i>
|
<i data-lucide="key-round"></i>
|
||||||
@@ -810,9 +840,9 @@ footer {
|
|||||||
<p>Optionaler TOTP-Schutz per Authenticator-App für deinen Account.</p>
|
<p>Optionaler TOTP-Schutz per Authenticator-App für deinen Account.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="sec-card">
|
<div class="sec-card">
|
||||||
<i data-lucide="lock"></i>
|
<i data-lucide="zap-off"></i>
|
||||||
<h4>Ende-zu-Ende-Chat</h4>
|
<h4>Kein Tracking</h4>
|
||||||
<p>Chat-Nachrichten werden im Browser verschlüsselt – der Server liest nicht mit.</p>
|
<p>Keine Werbung, keine Analytics, keine Weitergabe. Nur die Daten, die der Betrieb braucht.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="sec-card">
|
<div class="sec-card">
|
||||||
<i data-lucide="mail-check"></i>
|
<i data-lucide="mail-check"></i>
|
||||||
@@ -835,7 +865,7 @@ footer {
|
|||||||
<details class="faq-item">
|
<details class="faq-item">
|
||||||
<summary>Was kostet die Nutzung?</summary>
|
<summary>Was kostet die Nutzung?</summary>
|
||||||
<div class="faq-body">
|
<div class="faq-body">
|
||||||
Nichts. INFO1 ist ein privates Projekt für die Klasse und kostet die Nutzer keinen Cent.
|
Nichts. Das Klassenportal ist ein privates Projekt und kostet die Nutzer keinen Cent.
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<details class="faq-item">
|
<details class="faq-item">
|
||||||
@@ -848,15 +878,14 @@ footer {
|
|||||||
<details class="faq-item">
|
<details class="faq-item">
|
||||||
<summary>Wo liegen meine Daten?</summary>
|
<summary>Wo liegen meine Daten?</summary>
|
||||||
<div class="faq-body">
|
<div class="faq-body">
|
||||||
Auf einem Hetzner-Server in Nürnberg, Deutschland. Es findet keine Übertragung in Drittländer statt.
|
Auf einem Server in Deutschland. Es findet keine Übertragung in Drittländer statt.
|
||||||
Details in der <a href="/datenschutz" style="color:var(--blue);">Datenschutzerklärung</a>.
|
Details in der <a href="/datenschutz" style="color:var(--blue);">Datenschutzerklärung</a>.
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<details class="faq-item">
|
<details class="faq-item">
|
||||||
<summary>Kann der Admin meinen Chat lesen?</summary>
|
<summary>Kann der Admin meinen Chat lesen?</summary>
|
||||||
<div class="faq-body">
|
<div class="faq-body">
|
||||||
Nein. Chat-Nachrichten werden im Browser mit einem Klassen-Schlüssel verschlüsselt, bevor sie den Server erreichen.
|
Ja. Chat-Nachrichten sind für den Admin einsehbar.
|
||||||
Auf dem Server liegen nur Chiffre-Texte.
|
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<details class="faq-item">
|
<details class="faq-item">
|
||||||
@@ -867,7 +896,7 @@ footer {
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<details class="faq-item">
|
<details class="faq-item">
|
||||||
<summary>Läuft INFO1 auch auf dem Handy?</summary>
|
<summary>Läuft das Klassenportal auch auf dem Handy?</summary>
|
||||||
<div class="faq-body">
|
<div class="faq-body">
|
||||||
Ja. Das Dashboard ist voll responsive und funktioniert auf Smartphone, Tablet und Desktop gleich gut.
|
Ja. Das Dashboard ist voll responsive und funktioniert auf Smartphone, Tablet und Desktop gleich gut.
|
||||||
</div>
|
</div>
|
||||||
@@ -876,6 +905,39 @@ footer {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- STATUS -->
|
||||||
|
<section id="status">
|
||||||
|
<div class="section-inner">
|
||||||
|
<div class="sec-head">
|
||||||
|
<div class="sec-eyebrow">Systemstatus</div>
|
||||||
|
<h2 class="sec-title">Alles im grünen Bereich</h2>
|
||||||
|
</div>
|
||||||
|
<div class="status-grid">
|
||||||
|
<div class="status-card">
|
||||||
|
<div class="status-dot" id="st-api"></div>
|
||||||
|
<div>
|
||||||
|
<div class="status-label">API</div>
|
||||||
|
<div class="status-sub" id="st-api-sub">Wird geprüft…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-card">
|
||||||
|
<div class="status-dot" id="st-db"></div>
|
||||||
|
<div>
|
||||||
|
<div class="status-label">Datenbank</div>
|
||||||
|
<div class="status-sub" id="st-db-sub">Wird geprüft…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="status-card">
|
||||||
|
<div class="status-dot" id="st-up"></div>
|
||||||
|
<div>
|
||||||
|
<div class="status-label">Uptime</div>
|
||||||
|
<div class="status-sub" id="st-up-sub">Wird geprüft…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- CTA -->
|
<!-- CTA -->
|
||||||
<section>
|
<section>
|
||||||
<div class="cta-band">
|
<div class="cta-band">
|
||||||
@@ -893,13 +955,13 @@ footer {
|
|||||||
<div class="foot-inner">
|
<div class="foot-inner">
|
||||||
<div class="foot-about">
|
<div class="foot-about">
|
||||||
<a class="brand" href="/">
|
<a class="brand" href="/">
|
||||||
<div class="brand-mark">i1</div>
|
<div class="brand-mark">KP</div>
|
||||||
<div class="brand-text">
|
<div class="brand-text">
|
||||||
<span class="brand-sub">Klassenportal</span>
|
<span class="brand-sub">Klassenportal</span>
|
||||||
<span class="brand-name">INFO1</span>
|
<span class="brand-name">Klassenportal</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<p>Privates Klassenportal für die INFO1-Klasse. Kein offizielles Angebot einer Schule oder eines Trägers. Daten gehostet in Deutschland.</p>
|
<p>Privates Klassenportal für Schüler und Lehrer. Kein offizielles Angebot einer Schule oder eines Trägers. Daten gehostet in Deutschland.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="foot-col">
|
<div class="foot-col">
|
||||||
<h5>Produkt</h5>
|
<h5>Produkt</h5>
|
||||||
@@ -927,8 +989,8 @@ footer {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="foot-bottom">
|
<div class="foot-bottom">
|
||||||
<span>© <span id="y"></span> INFO1 · Privates Klassenportal (inoffiziell)</span>
|
<span>© <span id="y"></span> Klassenportal · Privat & inoffiziell</span>
|
||||||
<span>Daten auf Hetzner, Nürnberg · EU-DSGVO konform</span>
|
<span>Daten in Deutschland · EU-DSGVO konform</span>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@@ -942,6 +1004,33 @@ footer {
|
|||||||
try { if (localStorage.getItem('dark') === '1') document.body.classList.add('dark'); } catch {}
|
try { if (localStorage.getItem('dark') === '1') document.body.classList.add('dark'); } catch {}
|
||||||
|
|
||||||
lucide.createIcons();
|
lucide.createIcons();
|
||||||
|
|
||||||
|
(async function checkStatus() {
|
||||||
|
function fmt(s) {
|
||||||
|
if (s < 60) return s + 's';
|
||||||
|
if (s < 3600) return Math.floor(s / 60) + 'm';
|
||||||
|
if (s < 86400) return Math.floor(s / 3600) + 'h';
|
||||||
|
return Math.floor(s / 86400) + 'd';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/health');
|
||||||
|
const d = await r.json();
|
||||||
|
const ok = d.ok === true;
|
||||||
|
document.getElementById('st-api').className = 'status-dot ' + (ok ? 'ok' : 'err');
|
||||||
|
document.getElementById('st-api-sub').textContent = ok ? 'Online' : 'Fehler';
|
||||||
|
document.getElementById('st-db').className = 'status-dot ' + (ok ? 'ok' : 'err');
|
||||||
|
document.getElementById('st-db-sub').textContent = ok ? 'Online' : 'Nicht erreichbar';
|
||||||
|
document.getElementById('st-up').className = 'status-dot ok';
|
||||||
|
document.getElementById('st-up-sub').textContent = ok ? fmt(d.uptime) : '–';
|
||||||
|
} catch {
|
||||||
|
['st-api','st-db','st-up'].forEach(id => {
|
||||||
|
document.getElementById(id).className = 'status-dot err';
|
||||||
|
});
|
||||||
|
document.getElementById('st-api-sub').textContent = 'Nicht erreichbar';
|
||||||
|
document.getElementById('st-db-sub').textContent = '–';
|
||||||
|
document.getElementById('st-up-sub').textContent = '–';
|
||||||
|
}
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+3
-3
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>INFO1 · Anmelden</title>
|
<title>Klassenportal · Anmelden</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
@@ -194,10 +194,10 @@ footer a:hover { color: #2563eb; }
|
|||||||
|
|
||||||
<div class="brand-row">
|
<div class="brand-row">
|
||||||
<a class="brand" href="/">
|
<a class="brand" href="/">
|
||||||
<div class="brand-mark">i1</div>
|
<div class="brand-mark">KP</div>
|
||||||
<div class="brand-text">
|
<div class="brand-text">
|
||||||
<span class="brand-sub">Klassenportal</span>
|
<span class="brand-sub">Klassenportal</span>
|
||||||
<span class="brand-name">INFO1</span>
|
<span class="brand-name">Klassenportal</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<a class="back-link" href="/">← Dashboard</a>
|
<a class="back-link" href="/">← Dashboard</a>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>INFO1 · Passwort zurücksetzen</title>
|
<title>Klassenportal · Passwort zurücksetzen</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
@@ -70,10 +70,10 @@ footer a { color: #6b7280; text-decoration: none; }
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="brand-row">
|
<div class="brand-row">
|
||||||
<a class="brand" href="/">
|
<a class="brand" href="/">
|
||||||
<div class="brand-mark">i1</div>
|
<div class="brand-mark">KP</div>
|
||||||
<div class="brand-text">
|
<div class="brand-text">
|
||||||
<span class="brand-sub">Klassenportal</span>
|
<span class="brand-sub">Klassenportal</span>
|
||||||
<span class="brand-name">INFO1</span>
|
<span class="brand-name">Klassenportal</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<a class="back-link" href="/login">← Login</a>
|
<a class="back-link" href="/login">← Login</a>
|
||||||
|
|||||||
@@ -234,28 +234,6 @@ try {
|
|||||||
for (const u of withSubj) ins.run(u.id, u.subject);
|
for (const u of withSubj) ins.run(u.id, u.subject);
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS user_keys (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL UNIQUE,
|
|
||||||
public_key_jwk TEXT NOT NULL,
|
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS group_sender_keys (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
group_id TEXT NOT NULL,
|
|
||||||
kid TEXT NOT NULL,
|
|
||||||
recipient_user_id INTEGER NOT NULL,
|
|
||||||
distributor_user_id INTEGER NOT NULL,
|
|
||||||
encrypted_key TEXT NOT NULL,
|
|
||||||
created_at TEXT DEFAULT (datetime('now')),
|
|
||||||
FOREIGN KEY (recipient_user_id) REFERENCES users(id),
|
|
||||||
FOREIGN KEY (distributor_user_id) REFERENCES users(id),
|
|
||||||
UNIQUE(group_id, kid, recipient_user_id)
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Safe migrations
|
// Safe migrations
|
||||||
try { db.exec(`ALTER TABLE grades ADD COLUMN type TEXT DEFAULT 'sonstiges'`); } catch {}
|
try { db.exec(`ALTER TABLE grades ADD COLUMN type TEXT DEFAULT 'sonstiges'`); } catch {}
|
||||||
|
|||||||
+18
-18
@@ -1,8 +1,8 @@
|
|||||||
const { Resend } = require('resend');
|
const { Resend } = require('resend');
|
||||||
|
|
||||||
const apiKey = process.env.RESEND_API_KEY;
|
const apiKey = process.env.RESEND_API_KEY;
|
||||||
const MAIL_FROM = process.env.MAIL_FROM || 'noreply@info1.simon0x.xyz';
|
const MAIL_FROM = process.env.MAIL_FROM || 'noreply@info1.ifb.lol';
|
||||||
const MAIL_FROM_NAME = process.env.MAIL_FROM_NAME || 'INFO1 Portal';
|
const MAIL_FROM_NAME = process.env.MAIL_FROM_NAME || 'Klassenportal';
|
||||||
const APP_URL = process.env.APP_URL || 'http://127.0.0.1:3010';
|
const APP_URL = process.env.APP_URL || 'http://127.0.0.1:3010';
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
@@ -29,11 +29,11 @@ function renderVerifyHtml(link) {
|
|||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="vertical-align:middle">
|
<td style="vertical-align:middle">
|
||||||
<div style="width:40px;height:40px;background:#2563eb;border-radius:9px;color:#ffffff;font-weight:800;font-size:15px;letter-spacing:-0.5px;text-align:center;line-height:40px">i1</div>
|
<div style="width:40px;height:40px;background:#2563eb;border-radius:9px;color:#ffffff;font-weight:800;font-size:15px;letter-spacing:-0.5px;text-align:center;line-height:40px">KP</div>
|
||||||
</td>
|
</td>
|
||||||
<td style="vertical-align:middle;padding-left:12px">
|
<td style="vertical-align:middle;padding-left:12px">
|
||||||
<div style="font-size:10px;font-weight:500;color:#9ca3af;letter-spacing:0.3px;text-transform:uppercase">Klassenportal</div>
|
<div style="font-size:10px;font-weight:500;color:#9ca3af;letter-spacing:0.3px;text-transform:uppercase">Klassenportal</div>
|
||||||
<div style="font-size:17px;font-weight:700;color:#111827;letter-spacing:-0.3px;line-height:1.2">INFO1</div>
|
<div style="font-size:17px;font-weight:700;color:#111827;letter-spacing:-0.3px;line-height:1.2">Klassenportal</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -43,7 +43,7 @@ function renderVerifyHtml(link) {
|
|||||||
<td style="padding:32px">
|
<td style="padding:32px">
|
||||||
<h1 style="margin:0 0 14px 0;font-size:22px;font-weight:700;color:#111827;letter-spacing:-0.3px">E-Mail bestätigen</h1>
|
<h1 style="margin:0 0 14px 0;font-size:22px;font-weight:700;color:#111827;letter-spacing:-0.3px">E-Mail bestätigen</h1>
|
||||||
<p style="margin:0 0 20px 0;font-size:15px;line-height:1.55;color:#374151">
|
<p style="margin:0 0 20px 0;font-size:15px;line-height:1.55;color:#374151">
|
||||||
Willkommen beim INFO1-Klassenportal. Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiviert werden kann.
|
Willkommen beim Klassenportal. Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiviert werden kann.
|
||||||
</p>
|
</p>
|
||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:8px 0 24px 0">
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:8px 0 24px 0">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -69,12 +69,12 @@ function renderVerifyHtml(link) {
|
|||||||
Diese Nachricht wurde automatisch erzeugt. Antworten werden nicht gelesen.
|
Diese Nachricht wurde automatisch erzeugt. Antworten werden nicht gelesen.
|
||||||
</p>
|
</p>
|
||||||
<p style="margin:0;font-size:11px;color:#9ca3af;line-height:1.5">
|
<p style="margin:0;font-size:11px;color:#9ca3af;line-height:1.5">
|
||||||
INFO1 ist ein privates Klassenportal von Schülern für Schüler. Kein offizielles Angebot einer Schule oder eines Trägers.
|
Klassenportal ist ein privates Projekt von Schülern für Schüler. Kein offizielles Angebot einer Schule oder eines Trägers.
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<p style="margin:16px 0 0 0;font-size:11px;color:#9ca3af">INFO1 Klassenportal</p>
|
<p style="margin:16px 0 0 0;font-size:11px;color:#9ca3af">Klassenportal</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -84,18 +84,18 @@ function renderVerifyHtml(link) {
|
|||||||
|
|
||||||
function renderVerifyText(link) {
|
function renderVerifyText(link) {
|
||||||
return [
|
return [
|
||||||
'INFO1 Klassenportal',
|
'Klassenportal',
|
||||||
'',
|
'',
|
||||||
'E-Mail bestätigen',
|
'E-Mail bestätigen',
|
||||||
'',
|
'',
|
||||||
'Willkommen beim INFO1-Klassenportal. Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiviert werden kann.',
|
'Willkommen beim Klassenportal. Bitte bestätige deine E-Mail-Adresse, damit dein Konto aktiviert werden kann.',
|
||||||
'',
|
'',
|
||||||
'Zum Bestätigen diesen Link öffnen:',
|
'Zum Bestätigen diesen Link öffnen:',
|
||||||
link,
|
link,
|
||||||
'',
|
'',
|
||||||
'Der Link ist 24 Stunden gültig. Falls du diese Registrierung nicht angefordert hast, ignoriere bitte diese Nachricht.',
|
'Der Link ist 24 Stunden gültig. Falls du diese Registrierung nicht angefordert hast, ignoriere bitte diese Nachricht.',
|
||||||
'',
|
'',
|
||||||
'INFO1 ist ein privates Klassenportal von Schülern für Schüler. Kein offizielles Angebot einer Schule oder eines Trägers.',
|
'Klassenportal ist ein privates Projekt von Schülern für Schüler. Kein offizielles Angebot einer Schule oder eines Trägers.',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ async function sendVerifyMail(email, token) {
|
|||||||
return resend.emails.send({
|
return resend.emails.send({
|
||||||
from: `${MAIL_FROM_NAME} <${MAIL_FROM}>`,
|
from: `${MAIL_FROM_NAME} <${MAIL_FROM}>`,
|
||||||
to: email,
|
to: email,
|
||||||
subject: 'INFO1: E-Mail bestätigen',
|
subject: 'Klassenportal: E-Mail bestätigen',
|
||||||
html: renderVerifyHtml(link),
|
html: renderVerifyHtml(link),
|
||||||
text: renderVerifyText(link),
|
text: renderVerifyText(link),
|
||||||
});
|
});
|
||||||
@@ -129,11 +129,11 @@ function renderBaseHtml({ heading, intro, ctaText, ctaLink, noticeText }) {
|
|||||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||||
<tr>
|
<tr>
|
||||||
<td style="vertical-align:middle">
|
<td style="vertical-align:middle">
|
||||||
<div style="width:40px;height:40px;background:#2563eb;border-radius:9px;color:#ffffff;font-weight:800;font-size:15px;letter-spacing:-0.5px;text-align:center;line-height:40px">i1</div>
|
<div style="width:40px;height:40px;background:#2563eb;border-radius:9px;color:#ffffff;font-weight:800;font-size:15px;letter-spacing:-0.5px;text-align:center;line-height:40px">KP</div>
|
||||||
</td>
|
</td>
|
||||||
<td style="vertical-align:middle;padding-left:12px">
|
<td style="vertical-align:middle;padding-left:12px">
|
||||||
<div style="font-size:10px;font-weight:500;color:#9ca3af;letter-spacing:0.3px;text-transform:uppercase">Klassenportal</div>
|
<div style="font-size:10px;font-weight:500;color:#9ca3af;letter-spacing:0.3px;text-transform:uppercase">Klassenportal</div>
|
||||||
<div style="font-size:17px;font-weight:700;color:#111827;letter-spacing:-0.3px;line-height:1.2">INFO1</div>
|
<div style="font-size:17px;font-weight:700;color:#111827;letter-spacing:-0.3px;line-height:1.2">Klassenportal</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -160,11 +160,11 @@ function renderBaseHtml({ heading, intro, ctaText, ctaLink, noticeText }) {
|
|||||||
<tr>
|
<tr>
|
||||||
<td style="padding:20px 32px 28px 32px;border-top:1px solid #f1f5f9;background:#fafbfc">
|
<td style="padding:20px 32px 28px 32px;border-top:1px solid #f1f5f9;background:#fafbfc">
|
||||||
<p style="margin:0 0 6px 0;font-size:12px;color:#9ca3af;line-height:1.5">Diese Nachricht wurde automatisch erzeugt. Antworten werden nicht gelesen.</p>
|
<p style="margin:0 0 6px 0;font-size:12px;color:#9ca3af;line-height:1.5">Diese Nachricht wurde automatisch erzeugt. Antworten werden nicht gelesen.</p>
|
||||||
<p style="margin:0;font-size:11px;color:#9ca3af;line-height:1.5">INFO1 ist ein privates Klassenportal von Schülern für Schüler. Kein offizielles Angebot einer Schule oder eines Trägers.</p>
|
<p style="margin:0;font-size:11px;color:#9ca3af;line-height:1.5">Klassenportal ist ein privates Projekt von Schülern für Schüler. Kein offizielles Angebot einer Schule oder eines Trägers.</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<p style="margin:16px 0 0 0;font-size:11px;color:#9ca3af">INFO1 Klassenportal</p>
|
<p style="margin:16px 0 0 0;font-size:11px;color:#9ca3af">Klassenportal</p>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -174,7 +174,7 @@ function renderBaseHtml({ heading, intro, ctaText, ctaLink, noticeText }) {
|
|||||||
|
|
||||||
function renderPasswordResetText(link) {
|
function renderPasswordResetText(link) {
|
||||||
return [
|
return [
|
||||||
'INFO1 Klassenportal',
|
'Klassenportal',
|
||||||
'',
|
'',
|
||||||
'Passwort zurücksetzen',
|
'Passwort zurücksetzen',
|
||||||
'',
|
'',
|
||||||
@@ -183,7 +183,7 @@ function renderPasswordResetText(link) {
|
|||||||
'',
|
'',
|
||||||
'Der Link ist 1 Stunde gültig und kann nur einmal verwendet werden. Falls du keine Zurücksetzung angefordert hast, ignoriere diese Nachricht. Dein bestehendes Passwort bleibt dann unverändert.',
|
'Der Link ist 1 Stunde gültig und kann nur einmal verwendet werden. Falls du keine Zurücksetzung angefordert hast, ignoriere diese Nachricht. Dein bestehendes Passwort bleibt dann unverändert.',
|
||||||
'',
|
'',
|
||||||
'INFO1 ist ein privates Klassenportal von Schülern für Schüler. Kein offizielles Angebot einer Schule oder eines Trägers.',
|
'Klassenportal ist ein privates Projekt von Schülern für Schüler. Kein offizielles Angebot einer Schule oder eines Trägers.',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ async function sendPasswordResetMail(email, token) {
|
|||||||
return resend.emails.send({
|
return resend.emails.send({
|
||||||
from: `${MAIL_FROM_NAME} <${MAIL_FROM}>`,
|
from: `${MAIL_FROM_NAME} <${MAIL_FROM}>`,
|
||||||
to: email,
|
to: email,
|
||||||
subject: 'INFO1: Passwort zurücksetzen',
|
subject: 'Klassenportal: Passwort zurücksetzen',
|
||||||
html: renderBaseHtml({
|
html: renderBaseHtml({
|
||||||
heading: 'Passwort zurücksetzen',
|
heading: 'Passwort zurücksetzen',
|
||||||
intro: 'Jemand hat ein neues Passwort für dein Konto angefordert. Klicke auf den Button, um ein neues Passwort zu setzen.',
|
intro: 'Jemand hat ein neues Passwort für dein Konto angefordert. Klicke auf den Button, um ein neues Passwort zu setzen.',
|
||||||
|
|||||||
+10
-99
@@ -637,104 +637,6 @@ router.delete('/chat/:id', requireAuth, (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- E2EE KEY MANAGEMENT ---
|
|
||||||
|
|
||||||
function validPubKeyJwk(str) {
|
|
||||||
if (typeof str !== 'string' || str.length > 1000) return false;
|
|
||||||
try {
|
|
||||||
const j = JSON.parse(str);
|
|
||||||
return j.kty === 'EC' && j.crv === 'P-256'
|
|
||||||
&& typeof j.x === 'string' && typeof j.y === 'string'
|
|
||||||
&& j.x.length <= 64 && j.y.length <= 64
|
|
||||||
&& !j.d; // reject private key component
|
|
||||||
} catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function validEncryptedKey(str) {
|
|
||||||
if (typeof str !== 'string' || str.length > 2000) return false;
|
|
||||||
try {
|
|
||||||
const p = JSON.parse(str);
|
|
||||||
return typeof p.iv === 'string' && typeof p.ct === 'string'
|
|
||||||
&& p.iv.length <= 32 && p.ct.length <= 1800;
|
|
||||||
} catch { return false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function validKid(kid) {
|
|
||||||
return typeof kid === 'string' && /^[a-zA-Z0-9_\-=+/]{8,128}$/.test(kid);
|
|
||||||
}
|
|
||||||
|
|
||||||
router.post('/e2ee/public-key', requireAuth, (req, res) => {
|
|
||||||
const { public_key_jwk } = req.body;
|
|
||||||
if (!validPubKeyJwk(public_key_jwk)) return res.status(400).json({ error: 'Ungültiger Public Key' });
|
|
||||||
db.prepare(`INSERT OR REPLACE INTO user_keys (user_id, public_key_jwk) VALUES (?, ?)`)
|
|
||||||
.run(req.user.id, public_key_jwk);
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/e2ee/public-key/:userId', requireAuth, (req, res) => {
|
|
||||||
const row = db.prepare('SELECT public_key_jwk FROM user_keys WHERE user_id = ?').get(req.params.userId);
|
|
||||||
if (!row) return res.status(404).json({ error: 'Schlüssel nicht gefunden' });
|
|
||||||
res.json({ public_key_jwk: row.public_key_jwk });
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/e2ee/users', requireAuth, (req, res) => {
|
|
||||||
const users = db.prepare(`
|
|
||||||
SELECT u.id, u.username, k.public_key_jwk
|
|
||||||
FROM users u
|
|
||||||
JOIN user_keys k ON k.user_id = u.id
|
|
||||||
WHERE u.status = 'active'
|
|
||||||
ORDER BY u.id
|
|
||||||
`).all();
|
|
||||||
res.json(users);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/e2ee/group-key', requireAuth, (req, res) => {
|
|
||||||
const { group_id, kid } = req.query;
|
|
||||||
if (!group_id) return res.status(400).json({ error: 'group_id erforderlich' });
|
|
||||||
let row;
|
|
||||||
if (kid) {
|
|
||||||
row = db.prepare(`
|
|
||||||
SELECT kid, encrypted_key, distributor_user_id FROM group_sender_keys
|
|
||||||
WHERE group_id = ? AND kid = ? AND recipient_user_id = ?
|
|
||||||
`).get(group_id, kid, req.user.id);
|
|
||||||
} else {
|
|
||||||
row = db.prepare(`
|
|
||||||
SELECT kid, encrypted_key, distributor_user_id FROM group_sender_keys
|
|
||||||
WHERE group_id = ? AND recipient_user_id = ?
|
|
||||||
ORDER BY created_at DESC LIMIT 1
|
|
||||||
`).get(group_id, req.user.id);
|
|
||||||
}
|
|
||||||
if (!row) return res.status(404).json({ error: 'Schlüssel nicht gefunden' });
|
|
||||||
res.json(row);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/e2ee/group-keys', requireAuth, (req, res) => {
|
|
||||||
const { group_id, kid, keys } = req.body;
|
|
||||||
if (!group_id || !validKid(kid) || !Array.isArray(keys)) {
|
|
||||||
return res.status(400).json({ error: 'Ungültige Anfrage' });
|
|
||||||
}
|
|
||||||
if (keys.length > 500) return res.status(400).json({ error: 'Zu viele Einträge' });
|
|
||||||
const existing = db.prepare('SELECT distributor_user_id FROM group_sender_keys WHERE group_id = ? AND kid = ? LIMIT 1').get(group_id, kid);
|
|
||||||
if (existing && existing.distributor_user_id !== req.user.id) {
|
|
||||||
return res.status(403).json({ error: 'Schlüssel bereits von anderem Nutzer verteilt' });
|
|
||||||
}
|
|
||||||
const stmt = db.prepare(`
|
|
||||||
INSERT OR IGNORE INTO group_sender_keys
|
|
||||||
(group_id, kid, recipient_user_id, distributor_user_id, encrypted_key)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
const distribute = db.transaction(entries => {
|
|
||||||
for (const { user_id, encrypted_key } of entries) {
|
|
||||||
if (!Number.isInteger(user_id) || !validEncryptedKey(encrypted_key)) continue;
|
|
||||||
const target = db.prepare(`SELECT id FROM users WHERE id = ? AND status = 'active'`).get(user_id);
|
|
||||||
if (!target) continue;
|
|
||||||
stmt.run(group_id, kid, user_id, req.user.id, encrypted_key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
distribute(keys);
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- 2FA ---
|
// --- 2FA ---
|
||||||
router.post('/2fa/setup', requireAuth, twoFaSetupLimiter, async (req, res) => {
|
router.post('/2fa/setup', requireAuth, twoFaSetupLimiter, async (req, res) => {
|
||||||
const { password } = req.body || {};
|
const { password } = req.body || {};
|
||||||
@@ -748,7 +650,7 @@ router.post('/2fa/setup', requireAuth, twoFaSetupLimiter, async (req, res) => {
|
|||||||
}
|
}
|
||||||
const secret = generateSecret();
|
const secret = generateSecret();
|
||||||
db.prepare('UPDATE users SET totp_secret = ?, totp_enabled = 0 WHERE id = ?').run(secret, req.user.id);
|
db.prepare('UPDATE users SET totp_secret = ?, totp_enabled = 0 WHERE id = ?').run(secret, req.user.id);
|
||||||
const otpauth = generateURI({ secret, label: user.email, issuer: 'INFO1', type: 'totp' });
|
const otpauth = generateURI({ secret, label: user.email, issuer: 'Klassenportal', type: 'totp' });
|
||||||
try {
|
try {
|
||||||
const qr = await QRCode.toDataURL(otpauth);
|
const qr = await QRCode.toDataURL(otpauth);
|
||||||
res.json({ otpauth, qr, secret });
|
res.json({ otpauth, qr, secret });
|
||||||
@@ -852,4 +754,13 @@ router.get('/subjects', (req, res) => {
|
|||||||
res.json(db.prepare('SELECT id, name FROM subjects ORDER BY name ASC').all());
|
res.json(db.prepare('SELECT id, name FROM subjects ORDER BY name ASC').all());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/health', (req, res) => {
|
||||||
|
try {
|
||||||
|
db.prepare('SELECT 1').get();
|
||||||
|
res.json({ ok: true, uptime: Math.floor(process.uptime()) });
|
||||||
|
} catch {
|
||||||
|
res.status(503).json({ ok: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
Reference in New Issue
Block a user