Unser OmniChat OS Script ist eine hochperformante, webbasierte Chat Applikation im modernen Stil, die maximale Geschwindigkeit mit einem intuitiven Benutzererlebnis kombiniert. Entwickelt für Communitys, die Wert auf eine schlanke Infrastruktur (SQLite) und moderne Features (Google Auth) legen. Der OmniChat OS wird beim ersten Aufruf von selbst einbgerichtet . Kein FTP Konfigurations Chaos, kein Datenbank Setup, keine Installationsroutine – einfach Code als index.php hochladen, aufrufen, Admin Passwort setzen, fertig. Der Rest passiert automatisch. Einzig alleine, wenn Anmeldungen via Google Konto erlauben werden sollen, müßte die Google Client ID in den ersten Code Abschnitt eingefügt werden.
🌟 Key Features
- Benutzer & Sicherheit:
- Echte Benutzerkonten mit Passwort – kein Gast kann einen fremden Namen übernehmen.
- Beim ersten Login mit einem neuen Namen wird automatisch ein Account erstellt.
- Passwörter werden mit BCrypt gehasht, nie im Klartext gespeichert. Passwort-Recovery per E-Mail, Passwort jederzeit änderbar.
- Chat:
- Echtzeit-Gruppenchat in mehreren Räumen, optional mit Passwortschutz.
- Private Direktnachrichten zwischen Usern. Reaktionen auf Nachrichten (👍 ❤️ 😂 😮 😢 🔥), Antworten mit Zitatvorschau, Bilder und Dateien direkt im Chat (max. 8 MB, Lightbox für Bilder).
- Nachrichten löschen innerhalb von 3 Minuten mit Live-Countdown.
- Nachrichtensuche mit Treffer Highlighting. Typing-Indicator, 400+ Emojis.
- Verschlüsselung & Echtzeit:
- Ende zu Ende Verschlüsselung via Web Crypto API (ECDH P-256 + AES-GCM 256-bit) – der Server sieht ausschließlich Chiffras.
- Optionaler WebSocket Daemon für echte Echtzeit Übertragung, fällt automatisch auf 1,5 Sekunden-Polling zurück wenn WebSocket nicht verfügbar ist.
- Push-Benachrichtigungen:
- Native Browser Benachrichtigungen auch wenn der Tab im Hintergrund ist.
- Ein Klick auf den 🔔 Button aktiviert sie, ein weiterer Klick deaktiviert.
- Admin-Dashboard:
- Drei Tabs: User verwalten (anzeigen, bannen, kicken, löschen), gemeldete Nachrichten einsehen und bearbeiten, Einstellungen wie Site-Name, Google Login ID und Absender eMail direkt im Browser ändern – ohne Zugriff auf Serverdateien.
- Profilbilder & Profil:
- Eigenes Profilbild hochladen (JPG/PNG/WebP, max. 2 MB). eMail für Passwort-Recovery hinterlegen. Alles über ein Klick auf den eigenen Avatar.
- Sicherheit & Stabilität:
- Rate Limiting auf allen API-Endpunkten (Login, Senden, Upload, allgemein).
- User blockieren und melden. Automatische Schreibrechts Prüfung beim ersten Start mit verständlichen Fehlermeldungen statt blankem 500 Fehler.
🛠️ Technische Details
- – PHP 8.0+ · SQLite3 · kein MySQL
- – Self-Bootstrapping: eine Datei hochladen, fertig
- – SEO optimiert: Meta Tags, Open Graph, Twitter Card, Schema.org
Dieses Premium Tutorial ist exklusiv für unsere Newsletter Abonnenten!
Dieses Tutorial ist zum sofort lesen. Gebe deine eMail Adresse zum Freischalten ein!
<?php
/**
* ╔══════════════════════════════════════════════════╗
* ║ OmniChat OS - v7 - Premium Edition ║
* ║ Made by Dreamcodes.NET ║
* ╚══════════════════════════════════════════════════╝
*/
ini_set('display_errors', 0);
ini_set('log_errors', 1);
error_reporting(E_ALL);
session_start();
$GOOGLE_ID = 'DEINE_GOOGLE_CLIENT_ID.apps.googleusercontent.com';
$UPLOAD_DIR = 'uploads/';
$AVATAR_DIR = 'uploads/avatars/';
$MAX_UPLOAD = 8 * 1024 * 1024;
$MAX_AVATAR = 2 * 1024 * 1024;
$SITE_NAME = 'OmniChat OS';
$SITE_URL = 'https://' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
$MAIL_FROM = 'noreply@' . ($_SERVER['HTTP_HOST'] ?? 'localhost');
define('WS_URL', 'ws://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . ':8080');
define('VAPID_PUBLIC', 'DEIN_VAPID_PUBLIC_KEY_BASE64');
define('VAPID_PRIVATE', 'DEIN_VAPID_PRIVATE_KEY_BASE64');
define('RATE_LOGIN', 5);
define('RATE_SEND', 30);
define('RATE_UPLOAD', 5);
define('RATE_API', 120);
// ##BOOTSTRAP_START##
if (!file_exists('style.css') || !file_exists('basis.js') || !file_exists('sw.js') || !file_exists('ws_server.php')) {
try {
_bootstrap();
} catch (Throwable $e) {
die('<h2 style="font-family:monospace;color:red;padding:20px">OmniChat Bootstrap-Fehler:<br>' . htmlspecialchars($e->getMessage()) . '<br><small>' . htmlspecialchars($e->getFile().':'.$e->getLine()) . '</small></h2>');
}
_self_remove();
}
function _bootstrap() {
$css = <<<'ENDCSS'
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=DM+Mono:wght@400;500&display=swap');
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--void:#07080d;--base:#0b0d14;--surface:#0f1219;
--raised:#141820;--overlay:#1a1f2e;--input:#161b27;--hover:#1e2435;
--gold:#c9a84c;--gold-dim:#9a7a2f;
--gold-glow:rgba(201,168,76,.18);--gold-edge:rgba(201,168,76,.28);
--sap:#3b82f6;--sap-dim:#1d4ed8;
--sap-glow:rgba(59,130,246,.16);--sap-glow-hi:rgba(59,130,246,.35);
--danger:#ef4444;--danger-dim:#991b1b;--success:#10b981;
--bout:#1e3a6e;--bin:#141820;
--tx:#dde3ee;--tx2:#7a8499;--tx3:#3d4559;
--online:#10b981;
--border:rgba(255,255,255,.055);--border-hi:rgba(201,168,76,.32);
--sidebar:310px;--hdr:64px;
--spring:cubic-bezier(.175,.885,.32,1.275);
--expo:cubic-bezier(.19,1,.22,1);
}
html,body{height:100%;width:100%;background:var(--void);color:var(--tx);font-family:'DM Sans',sans-serif;-webkit-font-smoothing:antialiased;overflow:hidden}
body::before{content:'';position:fixed;inset:0;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");opacity:.022;pointer-events:none;z-index:9999}
.orb{position:fixed;border-radius:50%;filter:blur(90px);pointer-events:none;z-index:0;animation:orbDrift ease-in-out infinite alternate}
.orb-1{width:600px;height:600px;top:-200px;left:-150px;background:radial-gradient(circle,rgba(59,130,246,.07) 0%,transparent 70%);animation-duration:18s}
.orb-2{width:500px;height:500px;bottom:-150px;right:-100px;background:radial-gradient(circle,rgba(201,168,76,.06) 0%,transparent 70%);animation-duration:24s;animation-direction:alternate-reverse}
.orb-3{width:320px;height:320px;top:38%;left:28%;background:radial-gradient(circle,rgba(59,130,246,.04) 0%,transparent 70%);animation-duration:31s}
@keyframes orbDrift{from{transform:translate(0,0) scale(1)}to{transform:translate(38px,22px) scale(1.08)}}
::-webkit-scrollbar{width:3px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:rgba(255,255,255,.08);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.16)}
.app-wrap{position:relative;z-index:1;display:flex;width:100%;height:100%;max-width:1440px;margin:0 auto}
.sidebar{width:var(--sidebar);min-width:var(--sidebar);background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;position:relative;z-index:2}
.sb-head{height:var(--hdr);padding:0 18px;display:flex;align-items:center;justify-content:space-between;background:var(--raised);border-bottom:1px solid var(--border);flex-shrink:0}
.u-pill{display:flex;align-items:center;gap:10px}
.u-av{width:36px;height:36px;border-radius:11px;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:14px;color:var(--void);position:relative;box-shadow:0 0 0 1.5px var(--gold-edge),0 4px 16px var(--gold-glow);transition:box-shadow .25s;overflow:hidden;flex-shrink:0}
.u-av img{width:100%;height:100%;object-fit:cover}
.u-av .av-letter{background:linear-gradient(135deg,var(--gold-dim),var(--gold));width:100%;height:100%;display:flex;align-items:center;justify-content:center}
.u-av:hover{box-shadow:0 0 0 1.5px var(--gold),0 4px 24px rgba(201,168,76,.35)}
.u-dot{position:absolute;bottom:-2px;right:-2px;width:10px;height:10px;background:var(--online);border-radius:50%;border:2px solid var(--surface);box-shadow:0 0 8px var(--online);animation:dp 2.5s ease-in-out infinite}
@keyframes dp{0%,100%{box-shadow:0 0 5px var(--online)}50%{box-shadow:0 0 14px var(--online)}}
.u-name{font-size:13px;font-weight:600}.u-stat{font-size:10px;color:var(--online);letter-spacing:.3px}
.hdr-acts{display:flex;gap:2px}
.ib{width:34px;height:34px;border-radius:10px;border:none;background:transparent;color:var(--tx2);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:15px;transition:background .15s,color .15s,transform .1s}
.ib:hover{background:var(--hover);color:var(--tx)}.ib:active{transform:scale(.9)}
.ib.danger:hover{background:rgba(239,68,68,.15);color:var(--danger)}
.sb-srch{padding:10px 12px;border-bottom:1px solid var(--border);flex-shrink:0}
.sb-srch-i{display:flex;align-items:center;gap:8px;background:var(--input);border:1px solid var(--border);border-radius:12px;padding:0 12px;transition:border-color .2s,box-shadow .2s}
.sb-srch-i:focus-within{border-color:var(--gold-edge);box-shadow:0 0 0 3px var(--gold-glow)}
.sb-srch-i span{font-size:12px;color:var(--tx3)}
.sb-srch-i input{flex:1;background:transparent;border:none;outline:none;color:var(--tx);font-size:13px;padding:9px 0;font-family:inherit}
.sb-srch-i input::placeholder{color:var(--tx3)}
.sec-lbl{padding:14px 16px 6px;font-size:9.5px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;color:var(--tx3)}
.room-list{flex:1;overflow-y:auto;padding:4px 8px}
.ri{padding:10px 12px;display:flex;align-items:center;gap:12px;cursor:pointer;border-radius:14px;margin-bottom:2px;border:1px solid transparent;transition:background .15s,border-color .15s,transform .1s;position:relative}
.ri:hover{background:var(--hover)}.ri:active{transform:scale(.99)}
.ri.active{background:linear-gradient(135deg,rgba(59,130,246,.12),rgba(201,168,76,.06));border-color:rgba(59,130,246,.2)}
.ri.active .ri-ic{background:linear-gradient(135deg,var(--sap-dim),var(--sap));box-shadow:0 4px 16px var(--sap-glow);border-color:transparent}
.ri-ic{width:42px;height:42px;border-radius:13px;background:var(--overlay);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;font-size:17px;flex-shrink:0;transition:all .2s;overflow:hidden}
.ri-ic img{width:100%;height:100%;object-fit:cover}
.ri-name{font-size:13.5px;font-weight:600}
.ri-meta{font-size:11px;color:var(--tx3);margin-top:2px;display:flex;align-items:center;gap:5px}
.ri-meta .dot{width:5px;height:5px;border-radius:50%;background:var(--online);box-shadow:0 0 5px var(--online)}
/* User names inside room item */
.room-users-row{
display:flex;flex-wrap:wrap;gap:3px;margin-top:5px;
}
.room-user-name{
font-size:10px;color:var(--tx2);
background:rgba(255,255,255,.06);
border:1px solid rgba(255,255,255,.08);
border-radius:99px;
padding:1px 7px;
white-space:nowrap;
max-width:80px;
overflow:hidden;
text-overflow:ellipsis;
transition:background .15s;
}
.ri:hover .room-user-name{background:rgba(255,255,255,.1)}
.ri.active .room-user-name{background:rgba(59,130,246,.15);border-color:rgba(59,130,246,.25);color:var(--tx)}
.ri-badge{position:absolute;top:10px;right:12px;background:var(--sap);color:#fff;font-size:10px;font-weight:700;padding:2px 6px;border-radius:99px;min-width:18px;text-align:center}
.ri-lock{font-size:10px;color:var(--gold);margin-left:4px}
.ri-dm-av{width:42px;height:42px;border-radius:50%;background:linear-gradient(135deg,var(--sap-dim),var(--sap));display:flex;align-items:center;justify-content:center;font-weight:700;font-size:15px;color:#fff;flex-shrink:0}
.sb-foot{padding:12px 16px;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:center;flex-shrink:0}
.dc-link{font-size:9.5px;font-family:'DM Mono',monospace;color:var(--tx3);text-decoration:none;letter-spacing:.8px;text-transform:uppercase;transition:color .2s}
.dc-link:hover{color:var(--gold)}.dc-link .dcdot{color:var(--gold)}
.chat-view{flex:1;display:flex;flex-direction:column;background:var(--base);background-image:radial-gradient(ellipse at 15% 15%,rgba(59,130,246,.04) 0%,transparent 55%),radial-gradient(ellipse at 85% 85%,rgba(201,168,76,.03) 0%,transparent 55%);overflow:hidden;position:relative}
.ch-hdr{height:var(--hdr);background:var(--surface);border-bottom:1px solid var(--border);padding:0 24px;display:flex;align-items:center;gap:14px;flex-shrink:0;z-index:2;backdrop-filter:blur(16px)}
.ch-ic{width:40px;height:40px;border-radius:13px;background:linear-gradient(135deg,var(--sap-dim),var(--sap));display:flex;align-items:center;justify-content:center;font-size:17px;box-shadow:0 4px 20px var(--sap-glow)}
.ch-name{font-size:15px;font-weight:700;letter-spacing:-.2px}
.ch-status{font-size:11px;color:var(--online);display:flex;align-items:center;gap:5px;margin-top:2px}
.ch-status::before{content:'';width:5px;height:5px;border-radius:50%;background:var(--online);box-shadow:0 0 6px var(--online);animation:dp 2.5s ease-in-out infinite}
.ch-acts{display:flex;gap:3px;margin-left:auto}
.msg-search-bar{padding:8px 16px;background:var(--raised);border-bottom:1px solid var(--border);display:none;align-items:center;gap:10px;animation:slideDown .2s var(--expo)}
.msg-search-bar.show{display:flex}
@keyframes slideDown{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
.msg-search-bar input{flex:1;background:var(--input);border:1px solid var(--border);border-radius:10px;color:var(--tx);font-size:13px;padding:8px 12px;outline:none;font-family:inherit;transition:border-color .2s}
.msg-search-bar input:focus{border-color:var(--gold-edge)}
.msg-search-bar span{font-size:12px;color:var(--tx3);white-space:nowrap}
.search-close{background:transparent;border:none;color:var(--tx2);cursor:pointer;font-size:16px;padding:4px}
.day-div{display:flex;align-items:center;gap:12px;margin:16px 0 8px;color:var(--tx3);font-size:10px;font-weight:500;letter-spacing:.8px;text-transform:uppercase}
.day-div::before,.day-div::after{content:'';flex:1;height:1px;background:linear-gradient(90deg,transparent,var(--border),transparent)}
#chat-box{flex:1;overflow-y:auto;padding:24px 28px;display:flex;flex-direction:column;gap:4px;scroll-behavior:smooth}
.msg-highlight{background:rgba(201,168,76,.25);border-radius:3px;padding:0 2px}
.bubble{
min-width:80px;
max-width:68%;
padding:12px 16px 28px 16px;
border-radius:20px;
font-size:14.5px;
line-height:1.65;
position:relative;
animation:msgIn .22s var(--spring) both;
word-break:break-word;
margin-bottom:2px;
/* NO display:block — must stay as flex child for align-self to work */
}
#chat-box>.b-out+.b-in,#chat-box>.b-in+.b-out{margin-top:10px}
@keyframes msgIn{from{opacity:0;transform:scale(.92) translateY(8px)}to{opacity:1;transform:scale(1) translateY(0)}}
.b-out{
background:linear-gradient(145deg,#1e4da0,#163a80);
align-self:flex-end;
border-bottom-right-radius:5px;
box-shadow:0 3px 24px rgba(20,55,140,.45),0 1px 3px rgba(0,0,0,.3),inset 0 1px 0 rgba(255,255,255,.10),inset 0 -1px 0 rgba(0,0,0,.15);
}
.b-in{
background:linear-gradient(145deg,#1c2236,#161d2e);
align-self:flex-start;
border-bottom-left-radius:5px;
border:1px solid rgba(255,255,255,.08);
box-shadow:0 3px 16px rgba(0,0,0,.35),inset 0 1px 0 rgba(255,255,255,.05);
}
.b-sender{font-size:11px;font-weight:700;margin-bottom:6px;opacity:.9;letter-spacing:.2px}
.b-text{display:block}
/* Timestamp — absolute inside bubble, padding-bottom:28px gives it room */
.b-meta{
position:absolute;
bottom:8px;right:12px;
font-size:10px;
color:rgba(255,255,255,.32);
display:flex;align-items:center;gap:4px;
font-family:'DM Mono',monospace;
white-space:nowrap;
pointer-events:none;
z-index:1;
}
.b-out .b-meta .ticks{color:#7eb8ff;font-size:11px}
.reply-quote{background:rgba(255,255,255,.06);border-left:3px solid var(--gold);border-radius:8px;padding:6px 10px;margin-bottom:8px;cursor:pointer;transition:background .15s}
.reply-quote:hover{background:rgba(255,255,255,.1)}
.reply-quote-name{font-size:10px;font-weight:700;color:var(--gold);margin-bottom:2px}
.reply-quote-text{font-size:12px;color:var(--tx2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:280px}
.b-img{max-width:260px;max-height:200px;border-radius:12px;display:block;margin-bottom:6px;cursor:pointer;transition:opacity .2s}
.b-img:hover{opacity:.9}
.b-file{display:inline-flex;align-items:center;gap:8px;background:rgba(255,255,255,.08);border-radius:10px;padding:8px 12px;margin-bottom:6px;text-decoration:none;color:var(--tx);font-size:13px;transition:background .15s}
.b-file:hover{background:rgba(255,255,255,.14)}
.b-file-icon{font-size:20px}
.b-file-name{font-size:12px;font-weight:600;max-width:160px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.b-file-size{font-size:10px;color:var(--tx3);margin-top:1px}
.reactions-bar{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px;margin-bottom:2px}
.reaction-pill{display:inline-flex;align-items:center;gap:3px;background:rgba(255,255,255,.07);border:1px solid rgba(255,255,255,.1);border-radius:99px;padding:3px 8px;cursor:pointer;font-size:13px;transition:all .15s;user-select:none}
.reaction-pill:hover{background:rgba(255,255,255,.14);transform:scale(1.06)}
.reaction-pill.mine{background:rgba(59,130,246,.2);border-color:rgba(59,130,246,.4)}
.reaction-pill .r-count{font-size:11px;color:var(--tx2);font-family:'DM Mono',monospace}
.reaction-picker{position:absolute;bottom:calc(100% + 6px);background:var(--raised);border:1px solid var(--border);border-radius:14px;padding:8px 10px;display:none;gap:4px;z-index:50;box-shadow:0 8px 32px rgba(0,0,0,.5);animation:popIn .18s var(--spring)}
.reaction-picker.show{display:flex}
.b-out .reaction-picker{right:0}
.b-in .reaction-picker{left:0}
@keyframes popIn{from{opacity:0;transform:scale(.85)}to{opacity:1;transform:scale(1)}}
.rp-emoji{font-size:22px;cursor:pointer;padding:4px;border-radius:8px;transition:transform .12s,background .1s}
.rp-emoji:hover{transform:scale(1.3);background:rgba(255,255,255,.1)}
.ctx-menu{position:fixed;background:var(--raised);border:1px solid var(--border);border-radius:14px;padding:6px;z-index:1000;min-width:160px;box-shadow:0 12px 40px rgba(0,0,0,.6);animation:popIn .16s var(--spring)}
.ctx-item{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:10px;cursor:pointer;font-size:13px;font-weight:500;color:var(--tx);transition:background .12s}
.ctx-item:hover{background:var(--hover)}
.ctx-item.danger{color:var(--danger)}
.ctx-item.danger:hover{background:rgba(239,68,68,.12)}
.ctx-sep{height:1px;background:var(--border);margin:4px 0}
.reply-bar{display:none;align-items:center;gap:10px;padding:8px 18px;background:var(--raised);border-top:1px solid var(--border);animation:slideDown .2s var(--expo)}
.reply-bar.show{display:flex}
.reply-bar-inner{flex:1;background:rgba(255,255,255,.05);border-left:3px solid var(--gold);border-radius:8px;padding:6px 10px}
.reply-bar-name{font-size:10px;font-weight:700;color:var(--gold)}
.reply-bar-text{font-size:12px;color:var(--tx2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.reply-bar-close{background:transparent;border:none;color:var(--tx2);cursor:pointer;font-size:18px;padding:4px;flex-shrink:0}
.typing-wrap{padding:0 28px 8px}
.typing-bub{display:none;align-items:center;gap:5px;background:var(--bin);border:1px solid var(--border);border-radius:18px;border-bottom-left-radius:5px;padding:12px 16px;width:fit-content;animation:msgIn .2s var(--spring)}
.typing-bub.show{display:flex}
.td{width:7px;height:7px;border-radius:50%;background:var(--tx3);animation:tdb 1.3s infinite}
.td:nth-child(2){animation-delay:.15s}.td:nth-child(3){animation-delay:.30s}
@keyframes tdb{0%,60%,100%{transform:translateY(0);opacity:.4}30%{transform:translateY(-7px);opacity:1}}
#emoji-picker{display:none;height:220px;overflow-y:auto;background:var(--surface);border-top:1px solid var(--border);padding:12px 16px;animation:slideUp .18s var(--expo)}
#emoji-picker.active{display:block}
@keyframes slideUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
.ej{font-size:23px;cursor:pointer;padding:4px;width:38px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;transition:background .1s,transform .12s}
.ej:hover{background:var(--hover);transform:scale(1.22)}
.input-area{padding:12px 18px;background:var(--surface);border-top:1px solid var(--border);display:flex;align-items:center;gap:8px;flex-shrink:0}
.iab{width:38px;height:38px;border-radius:11px;border:1px solid var(--border);background:var(--input);color:var(--tx2);cursor:pointer;font-size:17px;display:flex;align-items:center;justify-content:center;transition:all .15s;flex-shrink:0}
.iab:hover{border-color:var(--gold-edge);color:var(--tx);background:var(--hover)}.iab:active{transform:scale(.92)}
.msg-wrap{flex:1;background:var(--input);border:1.5px solid var(--border);border-radius:14px;display:flex;align-items:center;padding:0 14px;transition:border-color .2s,box-shadow .2s}
.msg-wrap:focus-within{border-color:var(--border-hi);box-shadow:0 0 0 3px var(--gold-glow)}
.msg-wrap input{flex:1;background:transparent;border:none;outline:none;color:var(--tx);font-size:14px;padding:11px 0;font-family:'DM Sans',sans-serif}
.msg-wrap input::placeholder{color:var(--tx3)}
.send-btn{width:44px;height:44px;border-radius:13px;border:none;flex-shrink:0;background:linear-gradient(135deg,var(--sap-dim),var(--sap));color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 20px var(--sap-glow);transition:transform .15s,box-shadow .15s}
.send-btn:hover{transform:scale(1.06);box-shadow:0 6px 28px var(--sap-glow-hi)}.send-btn:active{transform:scale(.93)}
.file-preview{padding:8px 18px;background:var(--raised);border-top:1px solid var(--border);display:none;align-items:center;gap:10px}
.file-preview.show{display:flex}
.file-preview-thumb{width:44px;height:44px;border-radius:10px;object-fit:cover;border:1px solid var(--border)}
.file-preview-info{flex:1;min-width:0}
.file-preview-name{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.file-preview-size{font-size:11px;color:var(--tx3)}
.file-preview-cancel{background:transparent;border:none;color:var(--tx2);cursor:pointer;font-size:18px}
.lightbox{position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:2000;display:none;align-items:center;justify-content:center;cursor:zoom-out;animation:fadeIn .2s}
.lightbox.show{display:flex}
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
.lightbox img{max-width:90vw;max-height:90vh;border-radius:16px;box-shadow:0 24px 80px rgba(0,0,0,.8)}
.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:500;display:none;align-items:center;justify-content:center;backdrop-filter:blur(4px);animation:fadeIn .2s}
.modal-bg.show{display:flex}
.modal{background:var(--surface);border:1px solid var(--border);border-radius:22px;padding:32px;width:100%;max-width:380px;box-shadow:0 32px 80px rgba(0,0,0,.6),inset 0 1px 0 rgba(255,255,255,.05);animation:cardIn .3s var(--spring);position:relative}
.modal-top{display:none;position:absolute;top:0;left:50%;transform:translateX(-50%);width:200px;height:1px;background:linear-gradient(90deg,transparent,var(--gold),transparent)}
@keyframes cardIn{from{opacity:0;transform:scale(.92) translateY(20px)}to{opacity:1;transform:scale(1) translateY(0)}}
.modal h2{font-size:18px;font-weight:700;margin-bottom:4px}
.modal p{font-size:13px;color:var(--tx2);margin-bottom:20px}
.modal-close{position:absolute;top:16px;right:16px;background:var(--hover);border:none;color:var(--tx2);cursor:pointer;width:30px;height:30px;border-radius:8px;font-size:16px;display:flex;align-items:center;justify-content:center;transition:all .15s}
.modal-close:hover{color:var(--tx);background:var(--overlay)}
.mf{margin-bottom:12px}
.mf label{display:block;font-size:11px;font-weight:700;letter-spacing:.8px;text-transform:uppercase;color:var(--tx3);margin-bottom:6px}
.mf input,.mf textarea,.mf select{width:100%;padding:11px 14px;background:var(--input);border:1.5px solid var(--border);border-radius:12px;color:var(--tx);font-size:13px;font-family:'DM Sans',sans-serif;outline:none;transition:border-color .2s,box-shadow .2s;resize:none}
.mf input:focus,.mf textarea:focus,.mf select:focus{border-color:var(--border-hi);box-shadow:0 0 0 3px var(--gold-glow)}
.mf input::placeholder,.mf textarea::placeholder{color:var(--tx3)}
.mf select option{background:var(--raised)}
.modal-btn{width:100%;padding:13px;background:linear-gradient(135deg,var(--sap-dim),var(--sap));border:none;border-radius:12px;color:#fff;font-size:14px;font-weight:600;cursor:pointer;font-family:'DM Sans',sans-serif;margin-top:4px;transition:transform .15s,box-shadow .15s;box-shadow:0 4px 20px var(--sap-glow)}
.modal-btn:hover{transform:translateY(-1px);box-shadow:0 8px 28px var(--sap-glow-hi)}
.modal-btn.danger{background:linear-gradient(135deg,var(--danger-dim),var(--danger));box-shadow:0 4px 20px rgba(239,68,68,.25)}
.modal-btn.danger:hover{box-shadow:0 8px 28px rgba(239,68,68,.4)}
.modal-btn.gold{background:linear-gradient(135deg,var(--gold-dim),var(--gold));color:var(--void);box-shadow:0 4px 20px var(--gold-glow)}
/* ADMIN DASHBOARD */
.admin-table{width:100%;border-collapse:collapse;font-size:13px;margin-top:8px}
.admin-table th{text-align:left;padding:8px 10px;font-size:10px;font-weight:700;letter-spacing:.8px;text-transform:uppercase;color:var(--tx3);border-bottom:1px solid var(--border)}
.admin-table td{padding:10px;border-bottom:1px solid var(--border);vertical-align:middle}
.admin-table tr:last-child td{border-bottom:none}
.admin-table tr:hover td{background:var(--hover)}
.admin-pill{display:inline-block;padding:2px 8px;border-radius:99px;font-size:10px;font-weight:700}
.admin-pill.online{background:rgba(16,185,129,.15);color:var(--success)}
.admin-pill.offline{background:rgba(255,255,255,.06);color:var(--tx3)}
.admin-pill.banned{background:rgba(239,68,68,.15);color:var(--danger)}
.login-screen{width:100%;height:100%;display:flex;align-items:center;justify-content:center;position:relative;z-index:1}
.login-card{background:var(--surface);border:1px solid var(--border);border-radius:26px;padding:48px 44px;width:100%;max-width:410px;box-shadow:0 0 0 1px var(--border),0 40px 100px rgba(0,0,0,.6),inset 0 1px 0 rgba(255,255,255,.05);animation:cardIn .55s var(--spring) both;text-align:center;position:relative;overflow:hidden}
.login-card::before{content:'';position:absolute;top:0;left:50%;transform:translateX(-50%);width:200px;height:1px;background:linear-gradient(90deg,transparent,var(--gold),transparent)}
.login-logo{width:70px;height:70px;border-radius:22px;margin:0 auto 24px;background:linear-gradient(135deg,var(--sap-dim),var(--sap));display:flex;align-items:center;justify-content:center;font-size:30px;box-shadow:0 0 0 1px rgba(59,130,246,.3),0 8px 40px var(--sap-glow),0 0 80px rgba(59,130,246,.1);animation:logoAura 3s ease-in-out infinite}
@keyframes logoAura{0%,100%{box-shadow:0 0 0 1px rgba(59,130,246,.3),0 8px 40px var(--sap-glow),0 0 80px rgba(59,130,246,.1)}50%{box-shadow:0 0 0 1px rgba(59,130,246,.5),0 8px 40px var(--sap-glow-hi),0 0 80px rgba(59,130,246,.2)}}
.login-wm{font-size:26px;font-weight:700;letter-spacing:-.5px;margin-bottom:6px}
.login-wm span{color:var(--gold)}
.login-tg{font-size:12.5px;color:var(--tx2);margin-bottom:34px;line-height:1.5}
.login-tg a{color:var(--gold);text-decoration:none}
.fw{margin-bottom:10px}
.fw input{width:100%;padding:13px 16px;background:var(--input);border:1.5px solid var(--border);border-radius:13px;color:var(--tx);font-size:14px;font-family:'DM Sans',sans-serif;outline:none;transition:border-color .2s,box-shadow .2s,background .2s}
.fw input:focus{border-color:var(--border-hi);background:var(--overlay);box-shadow:0 0 0 3px var(--gold-glow)}
.fw input::placeholder{color:var(--tx3)}
.fw.hidden{display:none}
.login-btn{width:100%;padding:14px;background:linear-gradient(135deg,var(--sap-dim),var(--sap));border:none;border-radius:13px;color:#fff;font-size:14.5px;font-weight:600;cursor:pointer;margin-top:6px;box-shadow:0 4px 24px var(--sap-glow);transition:transform .15s,box-shadow .15s,opacity .15s;font-family:'DM Sans',sans-serif;display:flex;align-items:center;justify-content:center;gap:8px}
.login-btn:hover{transform:translateY(-1px);box-shadow:0 8px 36px var(--sap-glow-hi)}.login-btn:active{transform:scale(.98)}.login-btn:disabled{opacity:.6;cursor:not-allowed}
.divider{display:flex;align-items:center;gap:14px;margin:22px 0;font-size:11px;color:var(--tx3)}
.divider::before,.divider::after{content:'';flex:1;height:1px;background:linear-gradient(90deg,transparent,var(--border),transparent)}
.login-foot{margin-top:28px;font-size:10px;color:var(--tx3);font-family:'DM Mono',monospace;letter-spacing:.5px}
.login-foot a{color:var(--gold);text-decoration:none}
.toast{position:fixed;bottom:24px;right:24px;z-index:10000;background:var(--raised);border:1px solid var(--border);border-top:1px solid var(--gold-edge);border-radius:14px;padding:13px 18px;font-size:13px;font-weight:500;box-shadow:0 12px 40px rgba(0,0,0,.5);display:flex;align-items:center;gap:10px;animation:toastIn .3s var(--spring);pointer-events:none;max-width:300px}
@keyframes toastIn{from{opacity:0;transform:scale(.8) translateY(20px)}to{opacity:1;transform:scale(1) translateY(0)}}
.toast-out{animation:toastOut .25s ease forwards}
@keyframes toastOut{to{opacity:0;transform:scale(.85) translateY(10px)}}
.toast-icon{font-size:18px;flex-shrink:0}
.bubble.b-deleted{
opacity:.6;
background:rgba(255,255,255,.025) !important;
border:1px solid rgba(255,255,255,.07) !important;
box-shadow:none !important;
padding:10px 14px !important;
min-width:unset;
border-radius:14px !important;
}
.bubble.b-deleted.b-out{ align-self:flex-end }
.bubble.b-deleted.b-in { align-self:flex-start }
.b-deleted-inner{
display:flex;align-items:center;gap:9px;
font-size:13px;font-style:italic;color:var(--tx3);line-height:1.45;
}
.b-deleted-inner .del-icon{font-size:14px;opacity:.55;flex-shrink:0;filter:grayscale(1)}
.b-deleted-inner strong{font-style:normal;font-weight:600;color:var(--tx2)}
.b-deleted .b-meta{display:none !important}
.b-deleted .reactions-bar,.b-deleted .reaction-picker{display:none !important}
/* 3-min delete countdown in context menu */
.ctx-timer{font-size:10px;color:var(--tx3);font-family:'DM Mono',monospace;padding:3px 12px 8px;letter-spacing:.3px}
ENDCSS;
$js = <<<'ENDJS'
'use strict';
let lastId=0,replyTo=null,pendingFile=null,ctxMenu=null,blockedUsers=new Set(JSON.parse(localStorage.getItem('blocked')||'[]'));
const REACTIONS=['👍','❤️','😂','😮','😢','🔥'];
const emojis=["😀","😃","😄","😁","😆","😅","😂","🤣","😊","😇","🙂","🙃","😉","😌","😍","🥰","😘","😗","😙","😚","😋","😛","😝","😜","🤪","🤨","🧐","🤓","😎","🤩","🥳","😏","😒","😞","😔","😟","😕","🙁","☹️","😣","😖","😫","😩","🥺","😢","😭","😤","😠","😡","🤬","🤯","😳","🥵","🥶","😱","😨","😰","😥","😓","🤗","🤔","🤭","🤫","🤥","😶","😐","😑","😬","🙄","😯","😦","😧","😮","😲","🥱","😴","🤤","😪","😵","🤐","🥴","🤢","🤮","🤧","😷","🤒","🤕","🤑","🤠","😈","👿","👻","💀","👽","👾","🤖","💩","👋","🤚","✋","🖖","👌","✌️","🤞","🤟","🤘","👆","👇","👍","👎","✊","👏","🙌","🤝","🙏","💪","👀","👅","👄","🐶","🐱","🐰","🦊","🐻","🐼","🐯","🦁","🐸","🐵","🐔","🐧","🐺","🐴","🦄","🦋","🐌","🐞","🐢","🐙","🦈","🐳","🌵","🌲","🌴","🌱","🌿","☘️","🍀","🌷","🌹","🌺","🌸","🌼","🌻","🌞","🌙","🌟","⭐","☀️","❄️","🌈","☔","⚡","🔥","💧","🌊","🍎","🍊","🍋","🍇","🍓","🍒","🍑","🍍","🥥","🥝","🍅","🥑","🍆","🥕","🌽","🧀","🥚","🍳","🥞","🥓","🥩","🍗","🍔","🍟","🍕","🥪","🌮","🌯","🥗","🍝","🍜","🍣","🧁","🍰","🎂","🍭","🍬","🍫","🍿","🍩","🍪","🍵","☕","🍺","🍻","🥂","🍷","🍸","🍹","🍾","🥄","🍴","🧂"];
let typingTimer,searchActive=false;
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')}
function fmtSize(b){return b<1024?b+'B':b<1048576?(b/1024).toFixed(1)+'KB':(b/1048576).toFixed(1)+'MB'}
function saveBlocked(){localStorage.setItem('blocked',JSON.stringify([...blockedUsers]))}
function showToast(icon,msg,dur=3200){
const old=document.querySelector('.toast');if(old)old.remove();
const t=document.createElement('div');t.className='toast';
t.innerHTML=`<span class="toast-icon">${icon}</span><span>${msg}</span>`;
document.body.appendChild(t);
setTimeout(()=>{t.classList.add('toast-out');setTimeout(()=>t.remove(),300)},dur);
}
function toggleAdmin(v){const f=document.getElementById('adm-p');if(f)f.classList.toggle('hidden',v.toLowerCase()!=='admin')}
async function login(){
const u=(document.getElementById('u')||{}).value?.trim();
const p=(document.getElementById('p')||{}).value||'';
const rEl=document.getElementById('r'); const r=rEl?.value.trim()||'Lobby';
const btn=document.querySelector('.login-btn');
if(!u){showToast('⚠️','Bitte Benutzernamen eingeben');return}
if(btn){btn.innerHTML='<span style="opacity:.6">Verbinde…</span>';btn.disabled=true}
try{
const res=await fetch('?action=login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:u,pass:p,room:r})});
const d=await res.json();
if(d.success){if(btn)btn.innerHTML='✓ Eingeloggt';setTimeout(()=>location.reload(),350)}
else{if(btn){btn.innerHTML='Jetzt einloggen →';btn.disabled=false}showToast('🔒',d.error||'Fehler')}
}catch(e){if(btn){btn.innerHTML='Jetzt einloggen →';btn.disabled=false}showToast('❌','Verbindungsfehler')}
}
function handleGoogleLogin(r){
try{const d=JSON.parse(atob(r.credential.split('.')[1]));fetch('?action=login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user:d.name,room:'Lobby'})}).then(()=>location.reload())}
catch(e){showToast('❌','Google Login fehlgeschlagen')}
}
async function sync(cU){
try{
const q=searchActive?'&nosync=1':'';
const res=await fetch('?action=sync&last_id='+lastId+q);
if(!res.ok)return;
const d=await res.json();
if(d.kicked){location.href='?logout=1';return}
const hn=document.getElementById('header-room-name'),hs=document.getElementById('header-status');
if(hn)hn.textContent=d.curRoom;
if(hs)hs.textContent=(d.rooms||[]).reduce((s,r)=>s+parseInt(r.cnt||0),0)+' online';
const box=document.getElementById('chat-box');
if(box&&!searchActive&&d.messages?.length){
d.messages.forEach(m=>{
if(blockedUsers.has(m.user))return;
appendMsg(m,cU,box);lastId=m.id;
if(m.user!==cU) {
playNotificationSound();
showBrowserNotif(m.user, m.encrypted?'🔒 Verschlüsselte Nachricht':(m.msg||'Neue Nachricht'), window._myAvatar||'');
}
});
box.scrollTop=box.scrollHeight;
}
renderRoomList(d.rooms,d.curRoom,d.dmList||[]);
}catch(e){}
}
function appendMsg(m,cU,box){
const own=m.user===cU;
const div=document.createElement('div');
div.dataset.id=m.id;div.dataset.user=m.user;div.dataset.msg=m.msg||'';
// Store the moment this message was SENT as client epoch ms
// age_seconds = how old the message is right now → sentAt = now - age
const ageMs = m.age_seconds ? parseInt(m.age_seconds)*1000 : 0;
div.dataset.sentat = String(Date.now() - ageMs);
if(m.type==='deleted'){
div.className='bubble b-deleted '+(own?'b-out':'b-in');
const delBy = m.deleted_by || m.user;
const isOwnDel = (delBy === cU);
const label = isOwnDel
? 'Du hast diese Nachricht gelöscht.'
: `<strong>${esc(delBy)}</strong> hat diese Nachricht gelöscht.`;
div.innerHTML=`<div class="b-deleted-inner"><span class="del-icon">🚫</span><span>${label}</span></div>`;
box.appendChild(div);
return;
}
div.className='bubble '+(own?'b-out':'b-in');
let html='';
if(!own)html+=`<div class="b-sender" style="color:${esc(m.color)}">${esc(m.user)}</div>`;
if(m.reply_to_user){
html+=`<div class="reply-quote" onclick="scrollToMsg(${esc(m.reply_to_id)})">
<div class="reply-quote-name">${esc(m.reply_to_user)}</div>
<div class="reply-quote-text">${esc(m.reply_to_text||'')}</div>
</div>`;
}
if(m.type==='image'){
html+=`<img class="b-img" src="${esc(m.file_url)}" alt="Bild" onclick="openLightbox('${esc(m.file_url)}')">`;
if(m.msg)html+=`<span class="b-text">${esc(m.msg)}</span>`;
}else if(m.type==='file'){
html+=`<a class="b-file" href="${esc(m.file_url)}" target="_blank" download>
<span class="b-file-icon">📎</span>
<div><div class="b-file-name">${esc(m.file_name||'Datei')}</div>
<div class="b-file-size">${esc(m.file_size||'')}</div></div>
</a>`;
}else{
html+=`<span class="b-text">${esc(m.msg)}</span>`;
}
html+=`<div class="reactions-bar" id="rb-${m.id}"></div>`;
html+=`<div class="reaction-picker" id="rp-${m.id}">${REACTIONS.map(r=>`<span class="rp-emoji" onclick="sendReaction(${m.id},'${r}')">${r}</span>`).join('')}</div>`;
html+=`<span class="b-meta">${m.time}${own?' <span class="ticks">✓✓</span>':''}</span>`;
div.innerHTML=html;
if(m.reactions)renderReactions(m.id,m.reactions,cU);
div.addEventListener('contextmenu',e=>{e.preventDefault();showCtx(e,m,own,cU)});
div.addEventListener('touchstart',()=>{let t=setTimeout(()=>showCtx({clientX:0,clientY:0},m,own,cU),600);div.addEventListener('touchend',()=>clearTimeout(t),{once:true})});
box.appendChild(div);
}
function renderReactions(msgId,reactions,cU){
const bar=document.getElementById('rb-'+msgId);if(!bar)return;
if(!reactions||!Object.keys(reactions).length){bar.innerHTML='';return}
bar.innerHTML=Object.entries(reactions).map(([emoji,users])=>{
const mine=users.includes(cU);
return `<span class="reaction-pill${mine?' mine':''}" onclick="sendReaction(${msgId},'${emoji}')" title="${users.join(', ')}">${emoji} <span class="r-count">${users.length}</span></span>`;
}).join('');
}
async function sendReaction(msgId,emoji){
closeReactionPicker();
await fetch('?action=react',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({msg_id:msgId,emoji})});
try{
const r=await fetch('?action=get_reactions&msg_id='+msgId);
const d=await r.json();
if(d.reactions)renderReactions(msgId,d.reactions,window._cU||'');
}catch(e){}
}
function closeReactionPicker(){document.querySelectorAll('.reaction-picker.show').forEach(p=>p.classList.remove('show'))}
function scrollToMsg(id){
const el=document.querySelector(`.bubble[data-id="${id}"]`);
if(el){el.scrollIntoView({behavior:'smooth',block:'center'});el.style.outline='2px solid var(--gold)';setTimeout(()=>el.style.outline='',1200)}
}
function showCtx(e,m,own,cU){
if(ctxMenu)ctxMenu.remove();
if(window.ctxTimerInterval){clearInterval(window.ctxTimerInterval);window.ctxTimerInterval=null}
let html='';
if(m.type!=='deleted'){
html+=`<div class="ctx-item" onclick="setReply(${m.id},'${esc(m.user)}','${esc(m.msg||'')}')">↩️ Antworten</div>`;
html+=`<div class="ctx-item" onclick="openReactionPicker(${m.id})">😊 Reaktion hinzufügen</div>`;
html+=`<div class="ctx-sep"></div>`;
}
if(own && m.type!=='deleted'){
const el=document.querySelector(`.bubble[data-id="${m.id}"]`);
const sentAt=el?parseInt(el.dataset.sentat||'0'):0;
const remainingMs=(sentAt+180000)-Date.now();
if(remainingMs>0){
html+=`<div class="ctx-item danger" id="ctx-del-btn-${m.id}" onclick="deleteMsg(${m.id})">🗑️ Nachricht löschen</div>`;
html+=`<div class="ctx-timer" id="ctx-timer-${m.id}">⏳ …</div>`;
} else {
html+=`<div class="ctx-item" style="opacity:.35;cursor:not-allowed;pointer-events:none">🗑️ Löschen nicht möglich</div>`;
html+=`<div class="ctx-timer">🔒 3-Minuten-Frist abgelaufen</div>`;
}
}
if(!own && m.type!=='deleted'){
const isBlocked=blockedUsers.has(m.user);
html+=`<div class="ctx-item danger" onclick="toggleBlock('${esc(m.user)}')">${isBlocked?'✅ Entblocken':'🚫 Blockieren'}</div>`;
html+=`<div class="ctx-item" onclick="reportMsg(${m.id},'${esc(m.user)}')">⚠️ Melden</div>`;
html+=`<div class="ctx-item" onclick="openDM('${esc(m.user)}')">💬 Direktnachricht senden</div>`;
}
if(!html.trim()) return;
const menu=document.createElement('div');
menu.className='ctx-menu';
menu.innerHTML=html;
document.body.appendChild(menu);
ctxMenu=menu;
const x=Math.min(e.clientX||window.innerWidth/2, window.innerWidth-190);
const y=Math.min(e.clientY||window.innerHeight/2, window.innerHeight-260);
menu.style.left=x+'px';
menu.style.top=y+'px';
if(own && m.type!=='deleted'){
const timerEl=document.getElementById(`ctx-timer-${m.id}`);
if(timerEl){
const el=document.querySelector(`.bubble[data-id="${m.id}"]`);
const sentAt=el?parseInt(el.dataset.sentat||'0'):0;
const deadline=sentAt+180000;
const tick=()=>{
const left=Math.max(0,deadline-Date.now());
if(left===0){
timerEl.textContent='🔒 Frist gerade abgelaufen';
const btn=document.getElementById(`ctx-del-btn-${m.id}`);
if(btn){btn.style.opacity='.35';btn.style.pointerEvents='none';btn.onclick=null;}
clearInterval(window.ctxTimerInterval);window.ctxTimerInterval=null;
return;
}
const s=Math.floor(left/1000);
const mm=Math.floor(s/60), ss=String(s%60).padStart(2,'0');
timerEl.textContent=`⏳ Noch ${mm>0?mm+'m ':''} ${ss}s zum Löschen`;
};
tick();
window.ctxTimerInterval=setInterval(tick,1000);
}
}
setTimeout(()=>document.addEventListener('click',closeCtx,{once:true}),50);
}
function closeCtx(){
if(window.ctxTimerInterval){clearInterval(window.ctxTimerInterval);window.ctxTimerInterval=null}
if(ctxMenu){ctxMenu.remove();ctxMenu=null}
}
function openReactionPicker(id){
closeReactionPicker();
const p=document.getElementById('rp-'+id);if(p)p.classList.add('show');
setTimeout(()=>document.addEventListener('click',closeReactionPicker,{once:true}),10);
}
function setReply(id,user,msg){
replyTo={id,user,msg};
const bar=document.getElementById('reply-bar');
document.getElementById('reply-bar-name').textContent=user;
document.getElementById('reply-bar-text').textContent=msg||'Anhang';
bar.classList.add('show');
document.getElementById('msg').focus();
closeCtx();
}
function clearReply(){replyTo=null;document.getElementById('reply-bar').classList.remove('show')}
// ── Block / Report
function toggleBlock(user){
if(blockedUsers.has(user)){blockedUsers.delete(user);showToast('✅',user+' entblockt')}
else{blockedUsers.add(user);showToast('🚫',user+' blockiert');document.querySelectorAll(`.bubble[data-user="${user}"]`).forEach(b=>b.remove())}
saveBlocked();closeCtx();
}
async function reportMsg(id,user){
await fetch('?action=report',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({msg_id:id,reported_user:user})});
showToast('⚠️','Nachricht wurde gemeldet');closeCtx();
}
// ── Delete message
async function deleteMsg(id){
closeCtx();
const res = await fetch('?action=delete_msg',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({msg_id:id})});
const d = await res.json();
if(d.ok){
const el = document.querySelector(`.bubble[data-id="${id}"]`);
if(el){
// Keep b-out for alignment, replace everything else
const wasOut = el.classList.contains('b-out');
// Reset animation so tombstone fades in
el.style.animation = 'none';
el.offsetHeight; // reflow
el.className = 'bubble b-deleted ' + (wasOut ? 'b-out' : 'b-in');
el.style.animation = '';
el.innerHTML = `<div class="b-deleted-inner"><span class="del-icon">🚫</span><span>Du hast diese Nachricht gelöscht.</span></div>`;
// Mark as deleted so sync won't re-render it as original
el.dataset.deleted = '1';
}
} else if(d.error === 'too_late'){
showToast('⏰','Die 3-Minuten-Frist ist abgelaufen — Löschen nicht mehr möglich.');
} else {
showToast('❌', d.error || 'Löschen fehlgeschlagen');
}
}
// ── DM
function openDM(user){
closeCtx();
document.getElementById('dm-target').textContent=user;
document.getElementById('dm-input').value='';
showModal('dm-modal');
}
async function sendDM(){
const target=document.getElementById('dm-target').textContent;
const msg=document.getElementById('dm-input').value.trim();
if(!msg)return;
await fetch('?action=send_dm',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({to:target,msg})});
showToast('💬','DM an '+target+' gesendet');hideModal('dm-modal');
}
async function switchToDM(user){
await fetch('?action=open_dm',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({with:user})});
location.reload();
}
// ── Send message
async function send(){
const m=document.getElementById('msg');
const text=m?.value.trim();
if(!text&&!pendingFile)return;
const payload={msg:text||''};
if(replyTo){payload.reply_to=replyTo.id;payload.reply_to_user=replyTo.user;payload.reply_to_text=replyTo.msg}
if(pendingFile){
// upload file first
const fd=new FormData();fd.append('file',pendingFile);
try{
const ur=await fetch('?action=upload',{method:'POST',body:fd});
const ud=await ur.json();
if(ud.url){payload.file_url=ud.url;payload.file_name=ud.name;payload.file_size=ud.size;payload.file_type=ud.ftype}
else{showToast('❌',ud.error||'Upload fehlgeschlagen');return}
}catch(e){showToast('❌','Upload Fehler');return}
clearFilePreview();
}
if(m)m.value='';
clearReply();stopTyping();
const btn=document.querySelector('.send-btn');
if(btn){btn.style.transform='scale(.85)';setTimeout(()=>btn.style.transform='',160)}
try{await fetch('?action=send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)})}
catch(e){showToast('❌','Senden fehlgeschlagen')}
}
// ── File upload
function triggerFileInput(){document.getElementById('file-input').click()}
function onFileSelect(e){
const f=e.target.files[0];if(!f)return;
pendingFile=f;
const prev=document.getElementById('file-preview');
document.getElementById('fp-name').textContent=f.name;
document.getElementById('fp-size').textContent=fmtSize(f.size);
const thumb=document.getElementById('fp-thumb');
if(f.type.startsWith('image/')){
const r=new FileReader();r.onload=ev=>thumb.src=ev.target.result;r.readAsDataURL(f);
thumb.style.display='block';
}else{thumb.style.display='none'}
prev.classList.add('show');
}
function clearFilePreview(){
pendingFile=null;
document.getElementById('file-preview').classList.remove('show');
document.getElementById('file-input').value='';
document.getElementById('fp-thumb').src='';
}
// ── Lightbox
function openLightbox(url){
const lb=document.getElementById('lightbox');
document.getElementById('lb-img').src=url;
lb.classList.add('show');
}
function closeLightbox(){document.getElementById('lightbox').classList.remove('show')}
// ── Message search
function toggleSearch(){
searchActive=!searchActive;
const bar=document.getElementById('msg-search-bar');
if(searchActive){bar.classList.add('show');document.getElementById('search-input').focus()}
else{bar.classList.remove('show');clearSearch()}
}
function clearSearch(){
document.getElementById('search-input').value='';
document.querySelectorAll('.bubble').forEach(b=>{b.style.display='';b.querySelectorAll('.msg-highlight').forEach(h=>{h.outerHTML=h.textContent})});
document.getElementById('search-count').textContent='';
searchActive=false;
document.getElementById('msg-search-bar').classList.remove('show');
}
function doSearch(){
const q=document.getElementById('search-input').value.trim().toLowerCase();
if(!q){clearSearch();return}
let count=0;
document.querySelectorAll('.bubble').forEach(b=>{
const span=b.querySelector('.b-text');if(!span)return;
const orig=span.textContent;
const lower=orig.toLowerCase();
if(lower.includes(q)){
b.style.display='';count++;
span.innerHTML=orig.replace(new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'),'gi'),m=>`<span class="msg-highlight">${m}</span>`);
}else{b.style.display='none'}
});
document.getElementById('search-count').textContent=count+' Treffer';
}
// ── Room list render
function renderRoomList(rooms,cur,dms){
const rL=document.getElementById('room-list');if(!rL)return;
let html='';
(rooms||[]).forEach(r=>{
const users=(r.users||'').split(',').map(u=>u.trim()).filter(Boolean);
const userPills=users.map(u=>
`<span class="room-user-pill" title="${esc(u)}">${esc(u[0]?.toUpperCase()||'?')}</span>`
).join('');
const userNames=users.map(u=>`<span class="room-user-name">${esc(u)}</span>`).join('');
html+=`<div onclick="switchRoom('${esc(r.room)}')" class="ri ${r.room===cur&&!window._inDM?'active':''}">
<div class="ri-ic">💬</div>
<div style="flex:1;min-width:0">
<div class="ri-name">${esc(r.room)}${r.pw?'<span class="ri-lock">🔒</span>':''}</div>
<div class="ri-meta"><span class="dot"></span>${parseInt(r.cnt)} online</div>
<div class="room-users-row">${userNames}</div>
</div>
</div>`;
});
if(dms&&dms.length){
html+='<div class="sec-lbl" style="padding-top:8px">Direktnachrichten</div>';
dms.forEach(dm=>{
html+=`<div onclick="switchToDM('${esc(dm.user)}')" class="ri ${dm.user===window._dmWith?'active':''}">
<div class="ri-dm-av">${esc(dm.user[0].toUpperCase())}</div>
<div style="flex:1;min-width:0">
<div class="ri-name">${esc(dm.user)}</div>
<div class="ri-meta">Direktnachricht</div>
</div>
${dm.unread?`<span class="ri-badge">${dm.unread}</span>`:''}
</div>`;
});
}
rL.innerHTML=html;
}
// ── Room actions
function filterRooms(q){
document.querySelectorAll('.ri').forEach(el=>{
const n=el.querySelector('.ri-name')?.textContent||'';
el.style.display=n.toLowerCase().includes(q.toLowerCase())?'':'none';
});
}
function createRoom(){showModal('room-modal')}
async function submitRoom(){
const name=document.getElementById('room-name-input').value.trim();
const pw=document.getElementById('room-pw-input').value.trim();
if(!name){showToast('⚠️','Bitte Raumname eingeben');return}
await fetch('?action=create_room',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({room:name,password:pw})});
hideModal('room-modal');switchRoom(name);
}
async function switchRoom(r){
const res=await fetch('?action=check_room',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({room:r})});
const d=await res.json();
if(d.pw_required){
const pw=prompt('Passwort für "'+r+'":');
if(!pw)return;
const v=await fetch('?action=switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({room:r,password:pw})});
const vd=await v.json();
if(!vd.success){showToast('🔒',vd.error||'Falsches Passwort');return}
}else{
await fetch('?action=switch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({room:r})});
}
location.reload();
}
// ── Typing
function onType(){const ti=document.getElementById('typing-indicator');if(ti)ti.classList.add('show');clearTimeout(typingTimer);typingTimer=setTimeout(stopTyping,2000)}
function stopTyping(){const ti=document.getElementById('typing-indicator');if(ti)ti.classList.remove('show');clearTimeout(typingTimer)}
// ── Emoji
function toggleEmoji(){document.getElementById('emoji-picker').classList.toggle('active')}
// ── Modals
function showModal(id){document.getElementById(id).classList.add('show')}
function hideModal(id){document.getElementById(id).classList.remove('show')}
// ── Admin dashboard
let _adminTab = 'users';
async function openAdmin(tab) {
_adminTab = tab || 'users';
showModal('admin-modal');
renderAdminTabs();
if (_adminTab === 'users') await loadAdminUsers();
if (_adminTab === 'reports') await loadAdminReports();
if (_adminTab === 'settings') await loadAdminSettings();
}
function renderAdminTabs() {
const tabs = ['users','reports','settings'];
const labels = {'users':'👥 User','reports':'⚠️ Meldungen','settings':'⚙️ Einstellungen'};
document.getElementById('admin-tabs').innerHTML = tabs.map(t =>
`<button onclick="openAdmin('${t}')" style="padding:8px 14px;border-radius:10px;border:none;cursor:pointer;font-size:13px;font-weight:600;font-family:inherit;transition:all .15s;${_adminTab===t?'background:var(--sap);color:#fff':'background:var(--hover);color:var(--tx2)'}">${labels[t]}</button>`
).join('');
}
async function loadAdminUsers() {
document.getElementById('admin-content').innerHTML = '<div style="color:var(--tx3);padding:20px;text-align:center">Lade…</div>';
const res = await fetch('?action=admin_data');
const d = await res.json();
if (!d.ok) { showToast('⛔','Kein Zugriff'); return; }
document.getElementById('admin-msg-count').textContent = (d.msg_count||0) + ' Nachrichten gesamt';
let rows = '';
(d.users||[]).forEach(u => {
rows += `<tr>
<td style="font-weight:600">${esc(u.user)}</td>
<td>${esc(u.room||'-')}</td>
<td><span class="admin-pill ${u.online?'online':u.banned?'banned':'offline'}">${u.online?'Online':u.banned?'Gebannt':'Offline'}</span></td>
<td style="display:flex;gap:6px;flex-wrap:wrap">
<button class="ib danger" onclick="adminBan('${esc(u.user)}')" title="Bannen">🚫</button>
<button class="ib danger" onclick="adminKick('${esc(u.user)}')" title="Kicken">👢</button>
<button class="ib danger" onclick="adminDeleteUser('${esc(u.user)}')" title="Löschen">🗑️</button>
</td>
</tr>`;
});
document.getElementById('admin-content').innerHTML = `
<table class="admin-table">
<thead><tr><th>Benutzer</th><th>Raum</th><th>Status</th><th>Aktionen</th></tr></thead>
<tbody>${rows||'<tr><td colspan="4" style="text-align:center;color:var(--tx3);padding:20px">Keine Benutzer</td></tr>'}</tbody>
</table>
<div style="margin-top:16px;display:flex;gap:10px">
<button class="modal-btn danger" style="flex:1" onclick="adminClearChat()">🗑️ Chat leeren</button>
</div>`;
}
async function loadAdminReports() {
document.getElementById('admin-content').innerHTML = '<div style="color:var(--tx3);padding:20px;text-align:center">Lade…</div>';
const res = await fetch('?action=admin_reports');
const d = await res.json();
if (!d.reports?.length) {
document.getElementById('admin-content').innerHTML = '<div style="color:var(--tx3);padding:30px;text-align:center">✓ Keine offenen Meldungen</div>';
return;
}
let rows = '';
d.reports.forEach(r => {
rows += `<tr>
<td style="font-weight:600">${esc(r.reported_user)}</td>
<td style="font-size:12px;color:var(--tx2);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(r.msg_text||'-')}</td>
<td style="font-size:11px;color:var(--tx3)">${esc(r.reported_by)}<br>${esc(r.date)}</td>
<td style="display:flex;gap:6px">
<button class="ib danger" onclick="adminBan('${esc(r.reported_user)}')" title="Bannen">🚫</button>
<button class="ib" onclick="adminDismissReport(${r.id})" title="Schließen">✓</button>
</td>
</tr>`;
});
document.getElementById('admin-content').innerHTML = `
<table class="admin-table">
<thead><tr><th>Gemeldet</th><th>Nachricht</th><th>Von / Datum</th><th>Aktion</th></tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
async function loadAdminSettings() {
document.getElementById('admin-content').innerHTML = '<div style="color:var(--tx3);padding:20px;text-align:center">Lade…</div>';
const res = await fetch('?action=admin_get_settings');
const d = await res.json();
const s = d.settings || {};
document.getElementById('admin-content').innerHTML = `
<div class="mf"><label>Site-Name</label><input type="text" id="as-sitename" value="${esc(s.site_name||'OmniChat OS')}" placeholder="OmniChat OS"></div>
<div class="mf"><label>Google Client ID (für Google Login)</label><input type="text" id="as-googleid" value="${esc(s.google_id||'')}" placeholder="xxx.apps.googleusercontent.com"></div>
<div class="mf"><label>Absender-E-Mail (für Passwort-Reset)</label><input type="email" id="as-mailfrom" value="${esc(s.mail_from||'')}" placeholder="noreply@deine-domain.de"></div>
<button class="modal-btn" onclick="adminSaveSettings()">Einstellungen speichern</button>`;
}
async function adminSaveSettings() {
const data = {
site_name: document.getElementById('as-sitename')?.value.trim(),
google_id: document.getElementById('as-googleid')?.value.trim(),
mail_from: document.getElementById('as-mailfrom')?.value.trim(),
};
const res = await fetch('?action=admin_save_settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)});
const d = await res.json();
if (d.ok) showToast('✅','Einstellungen gespeichert');
else showToast('❌','Fehler beim Speichern');
}
async function adminBan(user) {
await fetch('?action=admin_ban',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user})});
showToast('🚫',user+' gebannt/entbannt');openAdmin(_adminTab);
}
async function adminKick(user) {
await fetch('?action=admin_kick',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user})});
showToast('👢',user+' gekickt');openAdmin(_adminTab);
}
async function adminDeleteUser(user) {
if (!confirm(`"${user}" wirklich löschen? Alle Nachrichten werden als gelöscht markiert.`)) return;
await fetch('?action=admin_delete_user',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user})});
showToast('🗑️',user+' gelöscht');openAdmin(_adminTab);
}
async function adminDismissReport(id) {
await fetch('?action=admin_dismiss_report',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})});
showToast('✓','Meldung geschlossen');loadAdminReports();
}
async function adminClearChat() {
if(!confirm('Gesamten Chat löschen?'))return;
await fetch('?action=admin_clear',{method:'POST'});
hideModal('admin-modal');location.reload();
}
// ── Keyboard
document.addEventListener('keydown',e=>{
if(e.key==='Escape'){closeCtx();closeReactionPicker();clearReply();closeLightbox();document.querySelectorAll('.modal-bg.show').forEach(m=>m.classList.remove('show'))}
if(e.key==='Enter'&&!e.shiftKey&&document.activeElement.id==='msg'){e.preventDefault();send()}
if((e.ctrlKey||e.metaKey)&&e.key==='f'&&document.getElementById('msg-search-bar')){e.preventDefault();toggleSearch()}
});
// ── Close emoji on outside click
document.addEventListener('click',e=>{
const p=document.getElementById('emoji-picker');
if(p?.classList.contains('active')&&!p.contains(e.target)&&!e.target.closest('.iab'))p.classList.remove('active');
});
ENDJS;
if (!is_dir('uploads')) mkdir('uploads', 0755, true);
if (!is_dir('uploads/avatars')) mkdir('uploads/avatars', 0755, true);
$writes = [
'style.css' => $css,
'basis.js' => $js,
];
foreach ($writes as $fname => $content) {
if (file_put_contents($fname, $content) === false) {
throw new \RuntimeException("Kann '$fname' nicht schreiben — bitte Schreibrechte im Verzeichnis prüfen (chmod 755 oder Webserver-User hat Zugriff)");
}
}
// ── composer.json, sw.js, ws_server.php — silently skip if write fails
@file_put_contents('composer.json', json_encode([
'name' => 'dreamcodes/omnichat',
'description' => 'OmniChat OS v7.0 — Premium PHP Chat',
'require' => ['php' => '>=8.0', 'cboden/ratchet' => '^0.4'],
'autoload' => ['psr-4' => (object)[]]
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
// ── Service Worker
@file_put_contents('sw.js', <<<'ENDSW'
const CACHE='omnichat-v7';
self.addEventListener('install',e=>{e.waitUntil(caches.open(CACHE).then(c=>c.addAll(['/'])));self.skipWaiting()});
self.addEventListener('activate',e=>{e.waitUntil(caches.keys().then(ks=>Promise.all(ks.filter(k=>k!==CACHE).map(k=>caches.delete(k)))));self.clients.claim()});
self.addEventListener('push',e=>{
let d={title:'OmniChat',body:'Neue Nachricht',icon:'/favicon.ico'};
try{if(e.data)d={...d,...e.data.json()}}catch(ex){if(e.data)d.body=e.data.text()}
e.waitUntil(self.registration.showNotification(d.title,{body:d.body,icon:d.icon||'/favicon.ico',badge:d.badge||'/favicon.ico',tag:'omnichat-msg',renotify:true,vibrate:[200,100,200],data:{url:d.url||'/'}}));
});
self.addEventListener('notificationclick',e=>{
e.notification.close();
const u=e.notification.data?.url||'/';
e.waitUntil(clients.matchAll({type:'window',includeUncontrolled:true}).then(cl=>{
for(const c of cl){if(c.url===u&&'focus'in c)return c.focus()}
return clients.openWindow(u);
}));
});
self.addEventListener('fetch',e=>{
if(e.request.method!=='GET'||e.request.url.includes('?action=')||e.request.url.includes('/uploads/'))return;
e.respondWith(fetch(e.request).then(r=>{if(r.status===200){const cl=r.clone();caches.open(CACHE).then(c=>c.put(e.request,cl))}return r}).catch(()=>caches.match(e.request)));
});
ENDSW);
// ── WebSocket-Daemon (nur auf VPS/Root-Server nutzbar, Shared-Hosting = Polling)
file_put_contents('ws_server.php', <<<'ENDWS'
<?php
/**
* OmniChat OS — WebSocket-Server (Ratchet/ReactPHP)
* Installation: composer require cboden/ratchet
* Starten: php ws_server.php
* Stoppen: kill $(cat ws.pid)
*/
require __DIR__.'/vendor/autoload.php';
use Ratchet\MessageComponentInterface;use Ratchet\ConnectionInterface;
use Ratchet\Server\IoServer;use Ratchet\Http\HttpServer;use Ratchet\WebSocket\WsServer;
define('WS_PORT',8080);define('DB_FILE',__DIR__.'/omnichat.db');
file_put_contents(__DIR__.'/ws.pid',getmypid());
class OmniChatWS implements MessageComponentInterface {
protected $clients;protected $meta=[];protected $db;
public function __construct(){$this->clients=new \SplObjectStorage();$this->db=new \SQLite3(DB_FILE);$this->db->exec("PRAGMA journal_mode=WAL");echo "[WS] Gestartet auf Port ".WS_PORT."\n";}
public function onOpen(ConnectionInterface $c):void{$this->clients->attach($c);$this->meta[$c->resourceId]=['user'=>'','room'=>'Lobby','color'=>'#aaa'];echo "[+] #{$c->resourceId}\n";}
public function onMessage(ConnectionInterface $f,$raw):void{
$d=json_decode($raw,true);if(!$d||!isset($d['type']))return;
$rid=$f->resourceId;$m=&$this->meta[$rid];
switch($d['type']){
case 'auth':
$u=htmlspecialchars(trim($d['user']??''),ENT_QUOTES);$r=htmlspecialchars(trim($d['room']??'Lobby'),ENT_QUOTES);$col=htmlspecialchars($d['color']??'#aaa',ENT_QUOTES);
if(!$u){$f->send(json_encode(['type'=>'error','msg'=>'Kein User']));return;}
$m['user']=$u;$m['room']=$r;$m['color']=$col;
$uS=\SQLite3::escapeString($u);$rS=\SQLite3::escapeString($r);$ip=$f->remoteAddress??'0.0.0.0';
$this->db->exec("INSERT INTO presence(user,room,last_seen,ip)VALUES('$uS','$rS',DATETIME('now'),'$ip')ON CONFLICT(user)DO UPDATE SET room='$rS',last_seen=DATETIME('now'),ip='$ip'");
$f->send(json_encode(['type'=>'auth_ok','user'=>$u,'room'=>$r]));break;
case 'message':
$u=$m['user'];if(!$u)return;
if($this->db->querySingle("SELECT is_banned FROM presence WHERE user='".SQLite3::escapeString($u)."' AND is_banned=1")){$f->send(json_encode(['type'=>'kicked']));$f->close();return;}
$msg=htmlspecialchars($d['msg']??'',ENT_QUOTES);$enc=(int)($d['encrypted']??0);$iv=htmlspecialchars($d['iv']??'',ENT_QUOTES);$eph=htmlspecialchars($d['eph_pub']??'',ENT_QUOTES);
$col=$m['color'];$r=$m['room'];$uS=\SQLite3::escapeString($u);$rS=\SQLite3::escapeString($r);
$stmt=$this->db->prepare("INSERT INTO messages(room,user,msg,color,encrypted,iv,eph_pub_key)VALUES(:r,:u,:m,:c,:enc,:iv,:eph)");
$stmt->bindValue(':r',$r);$stmt->bindValue(':u',$u);$stmt->bindValue(':m',$msg);$stmt->bindValue(':c',$col);
$stmt->bindValue(':enc',$enc,SQLITE3_INTEGER);$stmt->bindValue(':iv',$iv);$stmt->bindValue(':eph',$eph);$stmt->execute();
$id=$this->db->lastInsertRowID();
$this->broadcastRoom($r,['type'=>'message','id'=>$id,'user'=>$u,'color'=>$col,'msg'=>$msg,'time'=>date('H:i'),'encrypted'=>(bool)$enc,'iv'=>$iv,'eph_pub'=>$eph,'age_seconds'=>0]);break;
case 'react':
$u=$m['user'];if(!$u)return;$mid=(int)($d['msg_id']??0);$emo=mb_substr($d['emoji']??'',0,4);
$uS=\SQLite3::escapeString($u);$eS=\SQLite3::escapeString($emo);
if($this->db->querySingle("SELECT 1 FROM reactions WHERE msg_id=$mid AND user='$uS' AND emoji='$eS'"))$this->db->exec("DELETE FROM reactions WHERE msg_id=$mid AND user='$uS' AND emoji='$eS'");
else $this->db->exec("INSERT OR IGNORE INTO reactions(msg_id,user,emoji)VALUES($mid,'$uS','$eS')");
$rm=[];$res=$this->db->query("SELECT emoji,user FROM reactions WHERE msg_id=$mid");
while($row=$res->fetchArray(SQLITE3_ASSOC))$rm[$row['emoji']][]=$row['user'];
$this->broadcastRoom($m['room'],['type'=>'reactions','msg_id'=>$mid,'reactions'=>$rm]);break;
case 'pub_key':
$u=$m['user'];if(!$u)return;$pk=\SQLite3::escapeString($d['pub_key']??'');$uS=\SQLite3::escapeString($u);
$this->db->exec("UPDATE presence SET pub_key='$pk' WHERE user='$uS'");
$this->broadcastRoom($m['room'],['type'=>'pub_key','user'=>$u,'pub_key'=>$d['pub_key']??'']);break;
case 'get_pub_keys':
$rS=\SQLite3::escapeString($m['room']);$keys=[];
$res=$this->db->query("SELECT user,pub_key FROM presence WHERE room='$rS' AND last_seen>DATETIME('now','-30 seconds') AND pub_key IS NOT NULL AND pub_key!=''");
while($row=$res->fetchArray(SQLITE3_ASSOC))$keys[$row['user']]=$row['pub_key'];
$f->send(json_encode(['type'=>'pub_keys','keys'=>$keys]));break;
case 'typing':$this->broadcastRoom($m['room'],['type'=>'typing','user'=>$m['user']],$f);break;
case 'ping':$f->send(json_encode(['type'=>'pong']));if($m['user']){$uS=\SQLite3::escapeString($m['user']);$this->db->exec("UPDATE presence SET last_seen=DATETIME('now') WHERE user='$uS'");}break;
}
}
public function onClose(ConnectionInterface $c):void{$this->clients->detach($c);unset($this->meta[$c->resourceId]);echo "[-] #{$c->resourceId}\n";}
public function onError(ConnectionInterface $c,\Exception $e):void{echo "[!] {$e->getMessage()}\n";$c->close();}
private function send(ConnectionInterface $c,array $d):void{try{$c->send(json_encode($d));}catch(\Exception $e){}}
private function broadcastRoom(string $r,array $d,?ConnectionInterface $ex=null):void{foreach($this->clients as $c){if($this->meta[$c->resourceId]['room']===$r&&$c!==$ex)$this->send($c,$d);}}
}
IoServer::factory(new HttpServer(new WsServer(new OmniChatWS())),WS_PORT,'0.0.0.0')->run();
ENDWS);
}
function _self_remove() {
$file = __FILE__;
if (!is_writable($file)) return; // Keine Schreibrechte → still überspringen
$src = file_get_contents($file);
if ($src === false) return;
// Alles zwischen ##BOOTSTRAP_START## und dem Ende der _bootstrap()-Funktion entfernen
$new = preg_replace(
'/\/\/ ##BOOTSTRAP_START##.+?^(?=function _self_remove)/ms',
'// [Assets generiert beim ersten Aufruf]' . "\n",
$src
);
if ($new && $new !== $src) {
@file_put_contents($file, $new);
}
}
// ══════════════════════════════════════════════
// DATABASE
// ══════════════════════════════════════════════
$db = new SQLite3('omnichat.db');
$db->exec("PRAGMA journal_mode=WAL");
$db->exec("CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
room TEXT NOT NULL,
user TEXT NOT NULL,
msg TEXT NOT NULL DEFAULT '',
color TEXT,
type TEXT DEFAULT 'chat',
reply_to_id INTEGER,
reply_to_user TEXT,
reply_to_text TEXT,
file_url TEXT,
file_name TEXT,
file_size TEXT,
deleted_at DATETIME DEFAULT NULL,
deleted_by TEXT DEFAULT NULL,
created_at DATETIME DEFAULT (DATETIME('now','localtime'))
)");
// Migrate existing DB — silently add new columns if not yet present
try { $db->exec("ALTER TABLE messages ADD COLUMN deleted_at DATETIME DEFAULT NULL"); } catch(Exception $e) {}
try { $db->exec("ALTER TABLE messages ADD COLUMN deleted_by TEXT DEFAULT NULL"); } catch(Exception $e) {}
try { $db->exec("ALTER TABLE messages ADD COLUMN encrypted INTEGER DEFAULT 0"); } catch(Exception $e) {}
try { $db->exec("ALTER TABLE messages ADD COLUMN iv TEXT DEFAULT NULL"); } catch(Exception $e) {}
try { $db->exec("ALTER TABLE messages ADD COLUMN eph_pub_key TEXT DEFAULT NULL"); } catch(Exception $e) {}
try { $db->exec("ALTER TABLE presence ADD COLUMN pub_key TEXT DEFAULT NULL"); } catch(Exception $e) {}
$db->exec("CREATE TABLE IF NOT EXISTS presence (
user TEXT PRIMARY KEY, room TEXT, last_seen DATETIME, ip TEXT, is_banned INTEGER DEFAULT 0
)");
$db->exec("CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
pass_hash TEXT NOT NULL,
email TEXT DEFAULT '',
color TEXT NOT NULL,
avatar TEXT DEFAULT '',
pub_key TEXT DEFAULT '',
created_at DATETIME DEFAULT (DATETIME('now'))
)");
$db->exec("CREATE TABLE IF NOT EXISTS reactions (
msg_id INTEGER, user TEXT, emoji TEXT,
PRIMARY KEY(msg_id, user, emoji)
)");
$db->exec("CREATE TABLE IF NOT EXISTS rooms (
name TEXT PRIMARY KEY, password TEXT, created_by TEXT, created_at DATETIME DEFAULT (DATETIME('now'))
)");
$db->exec("CREATE TABLE IF NOT EXISTS dms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_user TEXT, to_user TEXT, msg TEXT, color TEXT,
read_at DATETIME, created_at DATETIME DEFAULT (DATETIME('now','localtime'))
)");
$db->exec("CREATE TABLE IF NOT EXISTS reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
msg_id INTEGER, reported_user TEXT, reported_by TEXT,
created_at DATETIME DEFAULT (DATETIME('now'))
)");
$db->exec("CREATE TABLE IF NOT EXISTS pw_reset (
token TEXT PRIMARY KEY,
user TEXT NOT NULL,
expires_at DATETIME NOT NULL,
used INTEGER DEFAULT 0
)");
$db->exec("CREATE TABLE IF NOT EXISTS push_subs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user TEXT NOT NULL,
endpoint TEXT NOT NULL UNIQUE,
p256dh TEXT,
auth TEXT,
created_at DATETIME DEFAULT (DATETIME('now'))
)");
$db->exec("CREATE TABLE IF NOT EXISTS rate_limit (
ip TEXT NOT NULL,
action TEXT NOT NULL,
hits INTEGER DEFAULT 1,
window_start DATETIME DEFAULT (DATETIME('now')),
PRIMARY KEY(ip, action)
)");
// Migrations
try { $db->exec("ALTER TABLE presence ADD COLUMN pub_key TEXT DEFAULT NULL"); } catch(Exception $e) {}
try { $db->exec("ALTER TABLE presence ADD COLUMN email TEXT DEFAULT NULL"); } catch(Exception $e) {}
try { $db->exec("ALTER TABLE presence ADD COLUMN avatar TEXT DEFAULT NULL"); } catch(Exception $e) {}
// Ensure Lobby exists
$db->exec("INSERT OR IGNORE INTO rooms(name) VALUES('Lobby')");
// ── Settings-Tabelle (Admin-Passwort, etc.)
$db->exec("CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)");
// Admin-Passwort + alle konfigurierbaren Settings aus DB laden
$ADMIN_PASS = $db->querySingle("SELECT value FROM settings WHERE key='admin_pass'") ?: '';
$GOOGLE_ID = $db->querySingle("SELECT value FROM settings WHERE key='google_id'") ?: $GOOGLE_ID;
$SITE_NAME = $db->querySingle("SELECT value FROM settings WHERE key='site_name'") ?: $SITE_NAME;
$VAPID_PUB = $db->querySingle("SELECT value FROM settings WHERE key='vapid_pub'") ?: '';
$VAPID_PRIV = $db->querySingle("SELECT value FROM settings WHERE key='vapid_priv'") ?: '';
// Fehler im Browser anzeigen solange Setup nicht abgeschlossen — danach nur ins Log
if ($ADMIN_PASS === '') {
ini_set('display_errors', 1);
error_reporting(E_ALL);
}
// Ensure avatar dir
if (!is_dir($AVATAR_DIR)) @mkdir($AVATAR_DIR, 0755, true);
// ── Rate-Limiting helper ──────────────────────────────
function rateLimit(SQLite3 $db, string $action, int $limit): bool {
$ip = SQLite3::escapeString($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
$act = SQLite3::escapeString($action);
// Reset window if older than 1 minute
$db->exec("DELETE FROM rate_limit WHERE ip='$ip' AND action='$act' AND window_start < DATETIME('now','-1 minute')");
$row = $db->querySingle("SELECT hits FROM rate_limit WHERE ip='$ip' AND action='$act'", false);
if ($row === null) {
$db->exec("INSERT INTO rate_limit(ip,action,hits) VALUES('$ip','$act',1)");
return true;
}
if ((int)$row >= $limit) return false;
$db->exec("UPDATE rate_limit SET hits=hits+1 WHERE ip='$ip' AND action='$act'");
return true;
}
// ══════════════════════════════════════════════
// SETUP — Erster Aufruf: Admin-Passwort setzen
// ══════════════════════════════════════════════
$isSetupDone = ($ADMIN_PASS !== '');
// Setup-API
if (!$isSetupDone && isset($_GET['action']) && $_GET['action'] === 'setup') {
header('Content-Type: application/json; charset=utf-8');
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$pw1 = trim($input['pass1'] ?? '');
$pw2 = trim($input['pass2'] ?? '');
if (strlen($pw1) < 6) { echo json_encode(['ok'=>false,'error'=>'Mindestens 6 Zeichen']); exit; }
if ($pw1 !== $pw2) { echo json_encode(['ok'=>false,'error'=>'Passwörter stimmen nicht überein']); exit; }
$hash = password_hash($pw1, PASSWORD_BCRYPT);
$hashS = SQLite3::escapeString($hash);
$db->exec("INSERT INTO settings(key,value) VALUES('admin_pass','$hashS') ON CONFLICT(key) DO UPDATE SET value='$hashS'");
echo json_encode(['ok'=>true]); exit;
}
// Wenn Setup noch nicht abgeschlossen → nur Setup-Screen zeigen
if (!$isSetupDone && !isset($_GET['action'])) {
// Render setup screen and exit — no chat
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OmniChat OS — Einrichtung</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{min-height:100vh;display:flex;align-items:center;justify-content:center;
background:#07080d;color:#dde3ee;font-family:'DM Sans',sans-serif;
background-image:radial-gradient(ellipse at 20% 30%,rgba(59,130,246,.07),transparent 60%),
radial-gradient(ellipse at 80% 70%,rgba(201,168,76,.05),transparent 60%)}
.card{background:#0f1219;border:1px solid rgba(255,255,255,.07);border-radius:24px;
padding:44px 40px;width:100%;max-width:400px;text-align:center;
box-shadow:0 40px 80px rgba(0,0,0,.5);position:relative;overflow:hidden}
.card::before{content:'';position:absolute;top:0;left:50%;transform:translateX(-50%);
width:180px;height:1px;background:linear-gradient(90deg,transparent,#c9a84c,transparent)}
.logo{width:64px;height:64px;border-radius:18px;margin:0 auto 20px;
background:linear-gradient(135deg,#1d4ed8,#3b82f6);
display:flex;align-items:center;justify-content:center;font-size:28px;
box-shadow:0 8px 32px rgba(59,130,246,.3)}
h1{font-size:22px;font-weight:700;margin-bottom:6px}
h1 span{color:#c9a84c}
p{font-size:13px;color:#7a8499;margin-bottom:28px;line-height:1.6}
.step{font-size:10px;font-weight:700;letter-spacing:1.5px;text-transform:uppercase;
color:#3d4559;margin-bottom:18px}
input{width:100%;padding:13px 16px;background:#161b27;border:1.5px solid rgba(255,255,255,.07);
border-radius:13px;color:#dde3ee;font-size:14px;font-family:inherit;outline:none;
margin-bottom:10px;transition:border-color .2s,box-shadow .2s}
input:focus{border-color:rgba(201,168,76,.4);box-shadow:0 0 0 3px rgba(201,168,76,.12)}
input::placeholder{color:#3d4559}
button{width:100%;padding:14px;background:linear-gradient(135deg,#1d4ed8,#3b82f6);
border:none;border-radius:13px;color:#fff;font-size:14px;font-weight:600;
cursor:pointer;font-family:inherit;margin-top:4px;
box-shadow:0 4px 20px rgba(59,130,246,.3);transition:transform .15s,box-shadow .15s}
button:hover{transform:translateY(-1px);box-shadow:0 8px 32px rgba(59,130,246,.45)}
button:active{transform:scale(.98)}
button:disabled{opacity:.5;cursor:not-allowed}
.err{font-size:13px;color:#ef4444;margin-top:10px;display:none}
.ok{font-size:13px;color:#10b981;margin-top:10px;display:none}
.foot{font-size:10px;color:#3d4559;margin-top:24px}
</style>
</head>
<body>
<div class="card">
<div class="logo">💬</div>
<h1>Omni<span>Chat</span> OS</h1>
<p>Willkommen! Lege jetzt dein Admin-Passwort fest.<br>Das ist der einzige Einrichtungsschritt.</p>
<div class="step">Schritt 1 von 1 — Admin-Passwort</div>
<input type="password" id="p1" placeholder="Admin-Passwort wählen (min. 6 Zeichen)" autocomplete="new-password">
<input type="password" id="p2" placeholder="Passwort wiederholen" autocomplete="new-password">
<button id="btn" onclick="setup()">OmniChat starten →</button>
<div class="err" id="err"></div>
<div class="ok" id="ok">✓ Passwort gesetzt — einen Moment…</div>
<p class="foot">Made by Dreamcodes.NET</p>
</div>
<script>
async function setup(){
const p1=document.getElementById('p1').value;
const p2=document.getElementById('p2').value;
const btn=document.getElementById('btn');
const err=document.getElementById('err');
const ok=document.getElementById('ok');
err.style.display='none';
if(!p1||p1.length<6){err.textContent='Mindestens 6 Zeichen';err.style.display='block';return}
if(p1!==p2){err.textContent='Passwörter stimmen nicht überein';err.style.display='block';return}
btn.disabled=true;btn.textContent='Speichern…';
const res=await fetch('?action=setup',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({pass1:p1,pass2:p2})});
const d=await res.json();
if(d.ok){ok.style.display='block';setTimeout(()=>location.reload(),1200)}
else{err.textContent=d.error||'Fehler';err.style.display='block';btn.disabled=false;btn.textContent='OmniChat starten →'}
}
document.addEventListener('keydown',e=>{if(e.key==='Enter')setup()});
</script>
</body>
</html>
<?php exit;
}
if (isset($_GET['logout'])) {
session_unset(); session_destroy();
header('Location: ' . strtok($_SERVER['REQUEST_URI'], '?')); exit;
}
// Handle password reset link
$pwResetToken = '';
$pwResetValid = false;
if (isset($_GET['reset'])) {
$t = SQLite3::escapeString(trim($_GET['reset']));
$row = $db->querySingle("SELECT user,used FROM pw_reset WHERE token='$t' AND expires_at>DATETIME('now')", true);
if ($row && !$row['used']) { $pwResetToken = $_GET['reset']; $pwResetValid = true; }
}
// ══════════════════════════════════════════════
// UPLOAD HANDLER (chat files + avatars)
// ══════════════════════════════════════════════
if (isset($_GET['action']) && in_array($_GET['action'], ['upload','upload_avatar'])) {
header('Content-Type: application/json; charset=utf-8');
$u = $_SESSION['chat_user'] ?? '';
if (!$u) { echo json_encode(['error'=>'Nicht eingeloggt']); exit; }
// Rate-limit uploads
if (!rateLimit($db, 'upload', RATE_UPLOAD)) {
http_response_code(429);
echo json_encode(['error'=>'Zu viele Uploads — bitte kurz warten.']); exit;
}
if (!isset($_FILES['file'])) { echo json_encode(['error'=>'Keine Datei']); exit; }
$f = $_FILES['file'];
// ── Avatar upload
if ($_GET['action'] === 'upload_avatar') {
if ($f['size'] > $MAX_AVATAR) { echo json_encode(['error'=>'Max 2 MB für Avatare']); exit; }
$mime = mime_content_type($f['tmp_name']);
if (!in_array($mime, ['image/jpeg','image/png','image/webp'])) {
echo json_encode(['error'=>'Nur JPG, PNG, WebP erlaubt']); exit;
}
$ext = ['image/jpeg'=>'jpg','image/png'=>'png','image/webp'=>'webp'][$mime] ?? 'jpg';
$name = 'av_' . bin2hex(random_bytes(8)) . '.' . $ext;
$path = $AVATAR_DIR . $name;
if (!move_uploaded_file($f['tmp_name'], $path)) {
echo json_encode(['error'=>'Upload fehlgeschlagen']); exit;
}
// Delete old avatar
$uS = SQLite3::escapeString($u);
$oldAv = $db->querySingle("SELECT avatar FROM users WHERE username='$uS'");
if ($oldAv && file_exists($oldAv)) @unlink($oldAv);
$pathS = SQLite3::escapeString($path);
$db->exec("UPDATE users SET avatar='$pathS' WHERE username='$uS'");
echo json_encode(['ok'=>true,'url'=>$path]); exit;
}
// ── Chat file upload
if ($f['size'] > $MAX_UPLOAD) { echo json_encode(['error'=>'Max 8 MB erlaubt']); exit; }
$mime = mime_content_type($f['tmp_name']);
$allowed = ['image/jpeg','image/png','image/gif','image/webp','application/pdf','text/plain','application/zip','application/x-zip-compressed'];
if (!in_array($mime, $allowed)) { echo json_encode(['error'=>'Dateityp nicht erlaubt']); exit; }
$ext = pathinfo($f['name'], PATHINFO_EXTENSION);
$name = bin2hex(random_bytes(8)) . '.' . $ext;
$path = $UPLOAD_DIR . $name;
if (!move_uploaded_file($f['tmp_name'], $path)) { echo json_encode(['error'=>'Upload fehlgeschlagen']); exit; }
$ftype = strpos($mime, 'image/') === 0 ? 'image' : 'file';
echo json_encode(['url'=>$path,'name'=>htmlspecialchars($f['name'],ENT_QUOTES),'size'=>round($f['size']/1024,1).' KB','ftype'=>$ftype]);
exit;
}
// ══════════════════════════════════════════════
// JSON API
// ══════════════════════════════════════════════
if (isset($_GET['action'])) {
header('Content-Type: application/json; charset=utf-8');
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$u = $_SESSION['chat_user'] ?? '';
$room = $_SESSION['chat_room'] ?? 'Lobby';
$action = $_GET['action'];
// ── Global rate limit (except high-frequency or harmless actions)
if (!in_array($action, ['sync','get_reactions','check_user'])) {
$rlAction = ($action === 'login' || $action === 'register') ? 'login'
: (in_array($action, ['send','send_dm']) ? 'send' : 'api');
$rlLimit = $rlAction === 'login' ? RATE_LOGIN
: ($rlAction === 'send' ? RATE_SEND : RATE_API);
if (!rateLimit($db, $rlAction, $rlLimit)) {
http_response_code(429);
echo json_encode(['success'=>false,'error'=>'Zu viele Anfragen — bitte kurz warten.','rate_limited'=>true]);
exit;
}
}
switch ($action) {
// ── LOGIN / SWITCH
// ── CHECK USER: existiert der Name schon? (live beim Tippen)
case 'check_user':
$name = htmlspecialchars(trim($input['user']??''), ENT_QUOTES, 'UTF-8');
if (!$name) { echo json_encode(['exists'=>false]); exit; }
$nS = SQLite3::escapeString($name);
$exists = (bool)$db->querySingle("SELECT 1 FROM users WHERE username='$nS'");
$isAdm = (strtolower($name) === 'admin');
echo json_encode(['exists'=>$exists, 'is_admin'=>$isAdm]); exit;
// ── REGISTER: neuer User
case 'register':
$name = htmlspecialchars(trim($input['user']??''), ENT_QUOTES, 'UTF-8');
$pass = $input['pass'] ?? '';
$email = htmlspecialchars(trim($input['email']??''), ENT_QUOTES, 'UTF-8');
$newRoom = htmlspecialchars(trim($input['room']??'Lobby'), ENT_QUOTES, 'UTF-8');
if (!$name) { echo json_encode(['success'=>false,'error'=>'Benutzername fehlt']); exit; }
if (strtolower($name) === 'admin') { echo json_encode(['success'=>false,'error'=>'Dieser Name ist reserviert']); exit; }
if (strlen($pass) < 6){ echo json_encode(['success'=>false,'error'=>'Passwort: mindestens 6 Zeichen']); exit; }
$nS = SQLite3::escapeString($name);
if ($db->querySingle("SELECT 1 FROM users WHERE username='$nS'")) {
echo json_encode(['success'=>false,'error'=>'Name bereits vergeben — bitte einloggen']); exit;
}
$hash = password_hash($pass, PASSWORD_BCRYPT);
$color = 'hsl('.rand(190,260).',65%,62%)';
$hS = SQLite3::escapeString($hash);
$eS = SQLite3::escapeString($email);
$cS = SQLite3::escapeString($color);
$rS = SQLite3::escapeString($newRoom);
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$db->exec("INSERT INTO users(username,pass_hash,email,color) VALUES('$nS','$hS','$eS','$cS')");
$db->exec("INSERT INTO presence(user,room,last_seen,ip) VALUES('$nS','$rS',DATETIME('now'),'$ip')
ON CONFLICT(user) DO UPDATE SET room='$rS',last_seen=DATETIME('now'),ip='$ip'");
$_SESSION['chat_user'] = $name;
$_SESSION['chat_room'] = $newRoom;
$_SESSION['chat_color'] = $color;
$_SESSION['in_dm'] = false;
echo json_encode(['success'=>true,'avatar'=>'']); exit;
// ── LOGIN: bestehender User
case 'login':
$name = htmlspecialchars(trim($input['user']??''), ENT_QUOTES, 'UTF-8');
$pass = $input['pass'] ?? '';
$newRoom = htmlspecialchars(trim($input['room']??'Lobby'), ENT_QUOTES, 'UTF-8');
if (!$name) { echo json_encode(['success'=>false,'error'=>'Benutzername fehlt']); exit; }
$nS = SQLite3::escapeString($name);
// Admin: password from settings
if (strtolower($name) === 'admin') {
if (!password_verify($pass, $ADMIN_PASS)) {
echo json_encode(['success'=>false,'error'=>'Falsches Passwort']); exit;
}
$_SESSION['is_admin'] = true;
$_SESSION['chat_user'] = $name;
$_SESSION['chat_room'] = $newRoom;
$_SESSION['chat_color'] ??= 'hsl(220,65%,62%)';
$_SESSION['in_dm'] = false;
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$rS = SQLite3::escapeString($newRoom);
$db->exec("INSERT INTO presence(user,room,last_seen,ip) VALUES('$nS','$rS',DATETIME('now'),'$ip')
ON CONFLICT(user) DO UPDATE SET room='$rS',last_seen=DATETIME('now'),ip='$ip'");
echo json_encode(['success'=>true,'avatar'=>'']); exit;
}
// Regular user
$row = $db->querySingle("SELECT pass_hash,color,avatar FROM users WHERE username='$nS'", true);
if (!$row) { echo json_encode(['success'=>false,'error'=>'Unbekannter Benutzer — bitte registrieren']); exit; }
if (!password_verify($pass, $row['pass_hash'])) {
echo json_encode(['success'=>false,'error'=>'Falsches Passwort']); exit;
}
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$rS = SQLite3::escapeString($newRoom);
$db->exec("INSERT INTO presence(user,room,last_seen,ip) VALUES('$nS','$rS',DATETIME('now'),'$ip')
ON CONFLICT(user) DO UPDATE SET room='$rS',last_seen=DATETIME('now'),ip='$ip'");
$_SESSION['chat_user'] = $name;
$_SESSION['chat_room'] = $newRoom;
$_SESSION['chat_color'] = $row['color'];
$_SESSION['in_dm'] = false;
echo json_encode(['success'=>true,'avatar'=>$row['avatar']??'']); exit;
// ── SWITCH ROOM (authenticated users only)
case 'switch':
if (!$u) { echo json_encode(['success'=>false,'error'=>'Nicht eingeloggt']); exit; }
$newRoom = htmlspecialchars(trim($input['room']??$room), ENT_QUOTES, 'UTF-8');
$rPw = $db->querySingle("SELECT password FROM rooms WHERE name='".SQLite3::escapeString($newRoom)."'");
if ($rPw) {
$inPw = $input['password'] ?? '';
if ($inPw !== $rPw) { echo json_encode(['success'=>false,'error'=>'Falsches Raumpasswort']); exit; }
}
$uS = SQLite3::escapeString($u);
$rS = SQLite3::escapeString($newRoom);
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$db->exec("INSERT INTO presence(user,room,last_seen,ip) VALUES('$uS','$rS',DATETIME('now'),'$ip')
ON CONFLICT(user) DO UPDATE SET room='$rS',last_seen=DATETIME('now'),ip='$ip'");
$_SESSION['chat_room'] = $newRoom;
$_SESSION['in_dm'] = false;
echo json_encode(['success'=>true]); exit;
// ── SYNC
case 'sync':
if (!$u) { echo json_encode(['kicked'=>true]); exit; }
$last = max(0,(int)($_GET['last_id']??0));
$uS = SQLite3::escapeString($u);
$rS = SQLite3::escapeString($room);
$db->exec("UPDATE presence SET last_seen=DATETIME('now') WHERE user='$uS'");
// Check if banned
$banned = $db->querySingle("SELECT is_banned FROM presence WHERE user='$uS'");
if ($banned) { echo json_encode(['kicked'=>true]); exit; }
$msgs = [];
if (!isset($_GET['nosync'])) {
$isDM = $_SESSION['in_dm'] ?? false;
if ($isDM) {
$dmWith = SQLite3::escapeString($_SESSION['dm_with'] ?? '');
$res = $db->query("SELECT id, from_user AS user, msg, '' AS color, 'chat' AS type,
NULL AS reply_to_id, NULL AS reply_to_user, NULL AS reply_to_text,
NULL AS file_url, NULL AS file_name, NULL AS file_size,
strftime('%H:%M',created_at) AS time, NULL AS reactions
FROM dms WHERE ((from_user='$uS' AND to_user='$dmWith') OR (from_user='$dmWith' AND to_user='$uS')) AND id>$last ORDER BY id ASC LIMIT 80");
} else {
$res = $db->query("SELECT m.id, m.user, m.msg, m.color, m.type,
m.reply_to_id, m.reply_to_user, m.reply_to_text,
m.file_url, m.file_name, m.file_size,
m.deleted_at, m.deleted_by,
m.encrypted, m.iv, m.eph_pub_key,
strftime('%H:%M',m.created_at) AS time,
CAST(strftime('%s','now') AS INTEGER) - CAST(strftime('%s',datetime(m.created_at,'utc')) AS INTEGER) AS age_seconds,
GROUP_CONCAT(r.emoji||':'||r.user) AS raw_reactions
FROM messages m
LEFT JOIN reactions r ON r.msg_id=m.id
WHERE (m.room='$rS' OR m.room='GLOBAL') AND m.id>$last
GROUP BY m.id ORDER BY m.id ASC LIMIT 80");
}
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
// Parse reactions
if (!empty($row['raw_reactions'])) {
$rMap = [];
foreach (explode(',', $row['raw_reactions']) as $rStr) {
if (!strpos($rStr,':')) continue;
[$emoji,$usr] = explode(':', $rStr, 2);
$rMap[$emoji][] = $usr;
}
$row['reactions'] = $rMap;
} else $row['reactions'] = [];
unset($row['raw_reactions']);
$msgs[] = $row;
}
}
// Rooms — with user list per room
$rooms = [];
$r2 = $db->query("SELECT p.room, COUNT(*) AS cnt,
GROUP_CONCAT(p.user, ', ') AS users,
ro.password IS NOT NULL AND ro.password != '' AS pw
FROM presence p LEFT JOIN rooms ro ON ro.name=p.room
WHERE p.last_seen>DATETIME('now','-30 seconds')
GROUP BY p.room");
while ($row=$r2->fetchArray(SQLITE3_ASSOC)) $rooms[]=$row;
if (!$rooms) $rooms=[['room'=>$room,'cnt'=>1,'pw'=>false,'users'=>$u]];
// DM list
$dms=[];
$dr=$db->query("SELECT CASE WHEN from_user='$uS' THEN to_user ELSE from_user END AS dm_user,
MAX(id) AS last_id, SUM(CASE WHEN to_user='$uS' AND read_at IS NULL THEN 1 ELSE 0 END) AS unread
FROM dms WHERE from_user='$uS' OR to_user='$uS' GROUP BY dm_user ORDER BY last_id DESC LIMIT 10");
while($row=$dr->fetchArray(SQLITE3_ASSOC)) $dms[]=['user'=>$row['dm_user'],'unread'=>(int)$row['unread']];
echo json_encode(['messages'=>$msgs,'rooms'=>$rooms,'curRoom'=>$room,'isAdmin'=>(bool)($_SESSION['is_admin']??false),'dmList'=>$dms,'inDM'=>($_SESSION['in_dm']??false),'dmWith'=>($_SESSION['dm_with']??'')]);
exit;
// ── SEND
case 'send':
if (!$u) { echo json_encode(['ok'=>false]); exit; }
$msg = htmlspecialchars(trim($input['msg']??''), ENT_QUOTES, 'UTF-8');
$col = $_SESSION['chat_color']??'#aaa';
$rToId = (int)($input['reply_to']??0)?:(null);
$rToUser = htmlspecialchars($input['reply_to_user']??'', ENT_QUOTES, 'UTF-8');
$rToText = htmlspecialchars(substr($input['reply_to_text']??'',0,200), ENT_QUOTES, 'UTF-8');
$fUrl = htmlspecialchars($input['file_url']??'', ENT_QUOTES, 'UTF-8');
$fName = htmlspecialchars($input['file_name']??'', ENT_QUOTES, 'UTF-8');
$fSize = htmlspecialchars($input['file_size']??'', ENT_QUOTES, 'UTF-8');
$fType = in_array($input['file_type']??'',['image','file']) ? $input['file_type'] : 'chat';
$mType = $fUrl ? $fType : 'chat';
// E2E fields
$encrypted = isset($input['encrypted']) && $input['encrypted'] ? 1 : 0;
$iv = htmlspecialchars($input['iv']??'', ENT_QUOTES, 'UTF-8');
$ephPub = htmlspecialchars($input['eph_pub']??'', ENT_QUOTES, 'UTF-8');
if (!$msg && !$fUrl) { echo json_encode(['ok'=>false]); exit; }
$uS=$u; $rS=$room;
$stmt=$db->prepare("INSERT INTO messages(room,user,msg,color,type,reply_to_id,reply_to_user,reply_to_text,file_url,file_name,file_size,encrypted,iv,eph_pub_key) VALUES(:r,:u,:m,:c,:t,:ri,:ru,:rt,:fu,:fn,:fs,:enc,:iv,:eph)");
$stmt->bindValue(':r',SQLite3::escapeString($rS));$stmt->bindValue(':u',SQLite3::escapeString($u));
$stmt->bindValue(':m',$msg);$stmt->bindValue(':c',$col);$stmt->bindValue(':t',$mType);
$stmt->bindValue(':ri',$rToId,SQLITE3_INTEGER);$stmt->bindValue(':ru',$rToUser);$stmt->bindValue(':rt',$rToText);
$stmt->bindValue(':fu',$fUrl);$stmt->bindValue(':fn',$fName);$stmt->bindValue(':fs',$fSize);
$stmt->bindValue(':enc',$encrypted,SQLITE3_INTEGER);
$stmt->bindValue(':iv',$iv);
$stmt->bindValue(':eph',$ephPub);
$stmt->execute();
echo json_encode(['ok'=>true]); exit;
// ── REACT
case 'react':
if (!$u) { echo json_encode(['ok'=>false]); exit; }
$msgId = (int)($input['msg_id']??0);
$emoji = mb_substr($input['emoji']??'',0,4);
$uS = SQLite3::escapeString($u);
$eS = SQLite3::escapeString($emoji);
// Toggle: if exists remove, else add
$exists=$db->querySingle("SELECT 1 FROM reactions WHERE msg_id=$msgId AND user='$uS' AND emoji='$eS'");
if($exists) $db->exec("DELETE FROM reactions WHERE msg_id=$msgId AND user='$uS' AND emoji='$eS'");
else $db->exec("INSERT OR IGNORE INTO reactions(msg_id,user,emoji) VALUES($msgId,'$uS','$eS')");
echo json_encode(['ok'=>true]); exit;
case 'get_reactions':
$msgId=(int)($_GET['msg_id']??0);
$res=$db->query("SELECT emoji,user FROM reactions WHERE msg_id=$msgId");
$rMap=[];
while($row=$res->fetchArray(SQLITE3_ASSOC)) $rMap[$row['emoji']][]=$row['user'];
echo json_encode(['reactions'=>$rMap]); exit;
// ── DELETE MESSAGE
case 'delete_msg':
if (!$u) { echo json_encode(['ok'=>false,'error'=>'Nicht eingeloggt']); exit; }
$msgId = (int)($input['msg_id'] ?? 0);
$uS = SQLite3::escapeString($u);
$isAdmin = $_SESSION['is_admin'] ?? false;
// Use pure UTC on BOTH sides so there is no timezone offset skew
$row = $db->querySingle(
"SELECT user, deleted_at,
CAST(strftime('%s','now') AS INTEGER)
- CAST(strftime('%s', datetime(created_at, 'utc')) AS INTEGER) AS age_seconds
FROM messages WHERE id=$msgId",
true
);
if (!$row) { echo json_encode(['ok'=>false,'error'=>'Nachricht nicht gefunden']); exit; }
if ($row['deleted_at']) { echo json_encode(['ok'=>false,'error'=>'Bereits gelöscht']); exit; }
if (!$isAdmin) {
if ($row['user'] !== $u) {
echo json_encode(['ok'=>false,'error'=>'Keine Berechtigung']); exit;
}
// 180 seconds = 3 minutes
if ((int)$row['age_seconds'] > 180) {
echo json_encode(['ok'=>false,'error'=>'too_late']); exit;
}
}
$deletedBy = SQLite3::escapeString($u);
$db->exec("UPDATE messages SET
msg = '',
type = 'deleted',
file_url = NULL,
file_name = NULL,
file_size = NULL,
deleted_at = DATETIME('now'),
deleted_by = '$deletedBy'
WHERE id = $msgId");
echo json_encode(['ok'=>true]); exit;
// ── REPORT
case 'report':
$msgId=(int)($input['msg_id']??0);
$repUser=SQLite3::escapeString(htmlspecialchars($input['reported_user']??'',ENT_QUOTES));
$uS=SQLite3::escapeString($u);
$db->exec("INSERT INTO reports(msg_id,reported_user,reported_by) VALUES($msgId,'$repUser','$uS')");
echo json_encode(['ok'=>true]); exit;
// ── DM
case 'send_dm':
if (!$u) { echo json_encode(['ok'=>false]); exit; }
$to = SQLite3::escapeString(htmlspecialchars($input['to']??'',ENT_QUOTES));
$msg = htmlspecialchars(substr($input['msg']??'',0,2000),ENT_QUOTES);
$col = $_SESSION['chat_color']??'#aaa';
$uS = SQLite3::escapeString($u);
$db->exec("INSERT INTO dms(from_user,to_user,msg,color) VALUES('$uS','$to','$msg','$col')");
echo json_encode(['ok'=>true]); exit;
case 'open_dm':
$with = htmlspecialchars($input['with']??'',ENT_QUOTES,'UTF-8');
$_SESSION['in_dm'] = true;
$_SESSION['dm_with']= $with;
// Mark as read
$uS=SQLite3::escapeString($u);$wS=SQLite3::escapeString($with);
$db->exec("UPDATE dms SET read_at=DATETIME('now') WHERE to_user='$uS' AND from_user='$wS' AND read_at IS NULL");
echo json_encode(['ok'=>true]); exit;
// ── CREATE ROOM
case 'create_room':
$rName = htmlspecialchars(trim($input['room']??''),ENT_QUOTES,'UTF-8');
$rPw = $input['password']??'';
if (!$rName) { echo json_encode(['ok'=>false]); exit; }
$uS=SQLite3::escapeString($u);$rS=SQLite3::escapeString($rName);$rPwS=SQLite3::escapeString($rPw);
$db->exec("INSERT OR IGNORE INTO rooms(name,password,created_by) VALUES('$rS','$rPwS','$uS')");
echo json_encode(['ok'=>true]); exit;
case 'check_room':
$rName=SQLite3::escapeString(htmlspecialchars($input['room']??'',ENT_QUOTES));
$pw=$db->querySingle("SELECT password FROM rooms WHERE name='$rName'");
echo json_encode(['pw_required'=>($pw&&$pw!='')]); exit;
// ── ADMIN
case 'admin_data':
if (!($_SESSION['is_admin']??false)) { echo json_encode(['ok'=>false]); exit; }
$users=[];
$r=$db->query("SELECT p.user, p.room, p.is_banned, p.last_seen>DATETIME('now','-30 seconds') AS online FROM presence p ORDER BY p.last_seen DESC");
while($row=$r->fetchArray(SQLITE3_ASSOC)) $users[]=$row;
$cnt=$db->querySingle("SELECT COUNT(*) FROM messages");
echo json_encode(['ok'=>true,'users'=>$users,'msg_count'=>$cnt]); exit;
case 'admin_ban':
if (!($_SESSION['is_admin']??false)) { echo json_encode(['ok'=>false]); exit; }
$bu=SQLite3::escapeString(htmlspecialchars($input['user']??'',ENT_QUOTES));
$db->exec("UPDATE presence SET is_banned=NOT is_banned WHERE user='$bu'");
echo json_encode(['ok'=>true]); exit;
case 'admin_kick':
if (!($_SESSION['is_admin']??false)) { echo json_encode(['ok'=>false]); exit; }
$ku=SQLite3::escapeString(htmlspecialchars($input['user']??'',ENT_QUOTES));
$db->exec("UPDATE presence SET last_seen=DATETIME('1970-01-01') WHERE user='$ku'");
echo json_encode(['ok'=>true]); exit;
case 'admin_clear':
if (!($_SESSION['is_admin']??false)) { echo json_encode(['ok'=>false]); exit; }
$rS=SQLite3::escapeString($room);
$db->exec("DELETE FROM messages WHERE room='$rS'");
$db->exec("DELETE FROM reactions");
echo json_encode(['ok'=>true]); exit;
// ── PASSWORT-RECOVERY: Token anfordern
case 'pw_forgot':
$name = htmlspecialchars(trim($input['user']??''), ENT_QUOTES, 'UTF-8');
if (!$name) { echo json_encode(['ok'=>false,'error'=>'Benutzername fehlt']); exit; }
$uS = SQLite3::escapeString($name);
$row = $db->querySingle("SELECT email FROM presence WHERE user='$uS'", true);
// Always respond OK to prevent user enumeration
if (!$row || empty($row['email'])) { echo json_encode(['ok'=>true]); exit; }
$token = bin2hex(random_bytes(32));
$tokenS = SQLite3::escapeString($token);
$db->exec("DELETE FROM pw_reset WHERE user='$uS'");
$db->exec("INSERT INTO pw_reset(token,user,expires_at) VALUES('$tokenS','$uS',DATETIME('now','+15 minutes'))");
$link = $SITE_URL . '/?reset=' . urlencode($token);
$subject = $SITE_NAME . ' — Passwort zurücksetzen';
$body = "Hallo $name,\n\nDu hast eine Passwort-Rücksetzung angefordert.\n\nLink (15 Minuten gültig):\n$link\n\nFalls du das nicht warst, ignoriere diese E-Mail.\n\n— $SITE_NAME";
$headers = "From: $SITE_NAME <$MAIL_FROM>\r\nContent-Type: text/plain; charset=UTF-8";
@mail($row['email'], $subject, $body, $headers);
echo json_encode(['ok'=>true]); exit;
// ── PASSWORT-RECOVERY: Neues Passwort setzen
case 'pw_reset':
$token = trim($input['token']??'');
$newPass = trim($input['password']??'');
if (!$token || !$newPass) { echo json_encode(['ok'=>false,'error'=>'Fehlende Daten']); exit; }
$tokenS = SQLite3::escapeString($token);
$row = $db->querySingle("SELECT user,used FROM pw_reset WHERE token='$tokenS' AND expires_at>DATETIME('now')", true);
if (!$row || $row['used']) { echo json_encode(['ok'=>false,'error'=>'Token ungültig oder abgelaufen']); exit; }
$hashS = SQLite3::escapeString(password_hash($newPass, PASSWORD_BCRYPT));
// Only admin password is stored in settings; others don't have passwords
if (strtolower($row['user']) === 'admin') {
$db->exec("INSERT INTO settings(key,value) VALUES('admin_pass','$hashS') ON CONFLICT(key) DO UPDATE SET value='$hashS'");
}
$db->exec("UPDATE pw_reset SET used=1 WHERE token='$tokenS'");
echo json_encode(['ok'=>true]); exit;
// ── PUSH: Subscription speichern
case 'push_subscribe':
if (!$u) { echo json_encode(['ok'=>false]); exit; }
$endpoint = trim($input['endpoint']??'');
$p256dh = trim($input['p256dh']??'');
$auth = trim($input['auth']??'');
if (!$endpoint) { echo json_encode(['ok'=>false]); exit; }
$uS = SQLite3::escapeString($u);
$eS = SQLite3::escapeString($endpoint);
$pS = SQLite3::escapeString($p256dh);
$aS = SQLite3::escapeString($auth);
$db->exec("INSERT INTO push_subs(user,endpoint,p256dh,auth) VALUES('$uS','$eS','$pS','$aS')
ON CONFLICT(endpoint) DO UPDATE SET p256dh='$pS',auth='$aS'");
echo json_encode(['ok'=>true]); exit;
// ── PUSH: Subscription entfernen
case 'push_unsubscribe':
$endpoint = SQLite3::escapeString(trim($input['endpoint']??''));
$db->exec("DELETE FROM push_subs WHERE endpoint='$endpoint'");
echo json_encode(['ok'=>true]); exit;
// ── PUSH: Benachrichtigung senden (server-side, für Admin/System)
case 'push_send':
// Note: Full server-side Web Push requires web-push library
// Here we store the payload; client polls and shows via showNotification
echo json_encode(['ok'=>true,'info'=>'Push via Service Worker']); exit;
// ── PROFIL: Eigenes Profil laden
case 'get_profile':
if (!$u) { echo json_encode(['ok'=>false]); exit; }
$uS = SQLite3::escapeString($u);
$row = $db->querySingle("SELECT username AS user, email, avatar FROM users WHERE username='$uS'", true);
echo json_encode(['ok'=>true,'profile'=>$row?:['user'=>$u,'email'=>'','avatar'=>'']]); exit;
// ── PROFIL: E-Mail aktualisieren
case 'update_profile':
if (!$u) { echo json_encode(['ok'=>false]); exit; }
$email = htmlspecialchars(trim($input['email']??''), ENT_QUOTES, 'UTF-8');
$uS = SQLite3::escapeString($u);
$eS = SQLite3::escapeString($email);
$db->exec("UPDATE users SET email='$eS' WHERE username='$uS'");
echo json_encode(['ok'=>true]); exit;
// ── PROFIL: Passwort ändern
case 'change_password':
if (!$u) { echo json_encode(['ok'=>false]); exit; }
$oldPw = $input['old_pass'] ?? '';
$newPw = trim($input['new_pass'] ?? '');
$newPw2 = trim($input['new_pass2'] ?? '');
if (strlen($newPw) < 6) { echo json_encode(['ok'=>false,'error'=>'Neues Passwort: mindestens 6 Zeichen']); exit; }
if ($newPw !== $newPw2) { echo json_encode(['ok'=>false,'error'=>'Passwörter stimmen nicht überein']); exit; }
$uS = SQLite3::escapeString($u);
if (strtolower($u) === 'admin') {
if (!password_verify($oldPw, $ADMIN_PASS)) { echo json_encode(['ok'=>false,'error'=>'Altes Passwort falsch']); exit; }
$hashS = SQLite3::escapeString(password_hash($newPw, PASSWORD_BCRYPT));
$db->exec("INSERT INTO settings(key,value) VALUES('admin_pass','$hashS') ON CONFLICT(key) DO UPDATE SET value='$hashS'");
} else {
$row = $db->querySingle("SELECT pass_hash FROM users WHERE username='$uS'", true);
if (!$row || !password_verify($oldPw, $row['pass_hash'])) { echo json_encode(['ok'=>false,'error'=>'Altes Passwort falsch']); exit; }
$hashS = SQLite3::escapeString(password_hash($newPw, PASSWORD_BCRYPT));
$db->exec("UPDATE users SET pass_hash='$hashS' WHERE username='$uS'");
}
echo json_encode(['ok'=>true]); exit;
case 'admin_reports':
if (!($_SESSION['is_admin']??false)) { echo json_encode(['ok'=>false]); exit; }
$reports = [];
$res = $db->query("SELECT r.id, r.msg_id, r.reported_user, r.reported_by,
strftime('%d.%m.%Y %H:%M', r.created_at) AS date,
m.msg AS msg_text, m.room
FROM reports r LEFT JOIN messages m ON m.id=r.msg_id
ORDER BY r.created_at DESC LIMIT 50");
while ($row = $res->fetchArray(SQLITE3_ASSOC)) $reports[] = $row;
echo json_encode(['ok'=>true,'reports'=>$reports]); exit;
case 'admin_dismiss_report':
if (!($_SESSION['is_admin']??false)) { echo json_encode(['ok'=>false]); exit; }
$rid = (int)($input['id']??0);
$db->exec("DELETE FROM reports WHERE id=$rid");
echo json_encode(['ok'=>true]); exit;
case 'admin_delete_user':
if (!($_SESSION['is_admin']??false)) { echo json_encode(['ok'=>false]); exit; }
$du = SQLite3::escapeString(htmlspecialchars($input['user']??'', ENT_QUOTES));
$db->exec("DELETE FROM users WHERE username='$du'");
$db->exec("DELETE FROM presence WHERE user='$du'");
$db->exec("UPDATE messages SET msg='[Gelöschter Account]', type='deleted' WHERE user='$du'");
echo json_encode(['ok'=>true]); exit;
case 'admin_save_settings':
if (!($_SESSION['is_admin']??false)) { echo json_encode(['ok'=>false]); exit; }
$allowed = ['site_name','google_id','mail_from'];
foreach ($allowed as $key) {
if (isset($input[$key])) {
$kS = SQLite3::escapeString($key);
$vS = SQLite3::escapeString(htmlspecialchars(trim($input[$key]), ENT_QUOTES));
$db->exec("INSERT INTO settings(key,value) VALUES('$kS','$vS') ON CONFLICT(key) DO UPDATE SET value='$vS'");
}
}
echo json_encode(['ok'=>true]); exit;
case 'admin_get_settings':
if (!($_SESSION['is_admin']??false)) { echo json_encode(['ok'=>false]); exit; }
$s = [];
$res = $db->query("SELECT key,value FROM settings WHERE key IN ('site_name','google_id','mail_from')");
while ($row=$res->fetchArray(SQLITE3_ASSOC)) $s[$row['key']] = $row['value'];
echo json_encode(['ok'=>true,'settings'=>$s]); exit;
case 'pub_key':
if (!$u) { echo json_encode(['ok'=>false]); exit; }
$pubKey = trim($input['pub_key'] ?? '');
if (!$pubKey) { echo json_encode(['ok'=>false,'error'=>'Kein Key']); exit; }
$uS = SQLite3::escapeString($u);
$pkS = SQLite3::escapeString($pubKey);
$db->exec("UPDATE presence SET pub_key='$pkS' WHERE user='$uS'");
echo json_encode(['ok'=>true]); exit;
case 'get_pub_keys':
if (!$u) { echo json_encode(['ok'=>false]); exit; }
$rS = SQLite3::escapeString($room);
$keys = [];
$res = $db->query("SELECT user, pub_key FROM presence
WHERE room='$rS' AND last_seen>DATETIME('now','-30 seconds')
AND pub_key IS NOT NULL AND pub_key != ''");
while ($row = $res->fetchArray(SQLITE3_ASSOC)) {
$keys[$row['user']] = $row['pub_key'];
}
echo json_encode(['ok'=>true,'keys'=>$keys]); exit;
}
echo json_encode(['error'=>'unknown']); exit;
}
$loggedIn = !empty($_SESSION['chat_user']);
$currentUser = htmlspecialchars($_SESSION['chat_user']??'', ENT_QUOTES, 'UTF-8');
$avatarChar = $loggedIn ? mb_strtoupper(mb_substr($_SESSION['chat_user'],0,1)) : '';
$isAdmin = $_SESSION['is_admin'] ?? false;
$inDM = $_SESSION['in_dm'] ?? false;
$dmWith = htmlspecialchars($_SESSION['dm_with']??'', ENT_QUOTES, 'UTF-8');
$myAvatar = '';
if ($loggedIn) {
$uS = SQLite3::escapeString($_SESSION['chat_user']);
$myAvatar = $db->querySingle("SELECT avatar FROM users WHERE username='$uS'") ?: '';
}
$myAvatarHtml = $myAvatar
? '<img src="'.htmlspecialchars($myAvatar,ENT_QUOTES).'" alt="Avatar" style="width:100%;height:100%;object-fit:cover;border-radius:11px">'
: '<div class="av-letter">'.$avatarChar.'</div>';
?>
<!DOCTYPE html>
<html lang="de" prefix="og: https://ogp.me/ns#">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OmniChat — Premium Messenger | Dreamcodes.NET</title>
<meta name="description" content="OmniChat ist ein eleganter Premium-Messenger für Echtzeit-Kommunikation. Erstellt von Dreamcodes.NET.">
<meta name="keywords" content="OmniChat, Chat, Messenger, Premium Chat, Echtzeit Chat, Dreamcodes, PHP Chat">
<meta name="author" content="Dreamcodes.NET">
<meta name="robots" content="index, follow">
<meta name="theme-color" content="#0b0d14">
<meta name="color-scheme" content="dark">
<link rel="canonical" href="https://<?= $_SERVER['HTTP_HOST'] . strtok($_SERVER['REQUEST_URI'],'?') ?>">
<meta property="og:type" content="website">
<meta property="og:title" content="OmniChat — Premium Messenger">
<meta property="og:description" content="Eleganter Echtzeit-Messenger mit Premium-Design. Erstellt von Dreamcodes.NET.">
<meta property="og:url" content="https://<?= $_SERVER['HTTP_HOST'] . strtok($_SERVER['REQUEST_URI'],'?') ?>">
<meta property="og:image" content="https://<?= $_SERVER['HTTP_HOST'] ?>/og-image.png">
<meta property="og:locale" content="de_DE">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="OmniChat — Premium Messenger">
<meta name="twitter:image" content="https://<?= $_SERVER['HTTP_HOST'] ?>/og-image.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="OmniChat">
<script type="application/ld+json">{"@context":"https://schema.org","@type":"WebApplication","name":"OmniChat","applicationCategory":"CommunicationApplication","operatingSystem":"Any","url":"https://<?= $_SERVER['HTTP_HOST'] ?>","author":{"@type":"Organization","name":"Dreamcodes.NET","url":"https://www.dreamcodes.net"}}</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://accounts.google.com/gsi/client" async defer></script>
<link rel="stylesheet" href="style.css">
<style>
.toast.rate-limited { border-top-color: var(--danger); }
.u-av:hover { box-shadow: 0 0 0 2px var(--void), 0 0 0 4px var(--gold) !important; }
</style>
</head>
<body>
<div class="orb orb-1"></div><div class="orb orb-2"></div><div class="orb orb-3"></div>
<?php if (!$loggedIn): ?>
<div class="login-screen">
<?php if ($pwResetValid): ?>
<div class="login-card">
<div class="login-logo">🔑</div>
<h1 class="login-wm">Neues <span>Passwort</span></h1>
<p class="login-tg">Gib dein neues Admin-Passwort ein.</p>
<div class="fw"><input type="password" id="new-pw" placeholder="Neues Passwort" autocomplete="new-password"></div>
<div class="fw"><input type="password" id="new-pw2" placeholder="Passwort wiederholen" autocomplete="new-password"></div>
<button class="login-btn" onclick="submitPwReset()">Passwort speichern →</button>
<p class="login-foot"><a href="<?= $SITE_URL ?>">← Zurück zum Login</a></p>
</div>
<?php else: ?>
<div class="login-card">
<div class="login-logo">💬</div>
<h1 class="login-wm">Omni<span>Chat OS</span></h1>
<p class="login-tg">Der Messenger für alles</p>
<div id="step-name">
<div class="fw"><input type="text" id="u" placeholder="Benutzername eingeben…" autocomplete="username" oninput="onUsernameInput(this.value)" onkeydown="if(event.key==='Enter')checkUsername()"></div>
<div id="username-hint" style="font-size:11px;color:var(--tx3);margin:-4px 0 10px;min-height:16px;padding-left:4px"></div>
<button class="login-btn" id="btn-next" onclick="checkUsername()">Weiter →</button>
</div>
<div id="step-login" style="display:none">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
<div id="login-av" style="width:36px;height:36px;border-radius:10px;background:linear-gradient(135deg,var(--gold-dim),var(--gold));display:flex;align-items:center;justify-content:center;font-weight:700;font-size:15px;color:var(--void);flex-shrink:0"></div>
<div>
<div style="font-size:13px;font-weight:700" id="login-name-label"></div>
<div style="font-size:11px;color:var(--tx3)">Willkommen zurück!</div>
</div>
<button onclick="resetToStep1()" style="margin-left:auto;background:transparent;border:none;color:var(--tx3);cursor:pointer;font-size:18px;padding:4px" title="Anderen Nutzer">✕</button>
</div>
<div class="fw"><input type="password" id="pw-login" placeholder="Passwort" autocomplete="current-password" onkeydown="if(event.key==='Enter')doLogin()"></div>
<div class="fw"><input type="text" id="r-login" placeholder="Raum (Standard: Lobby)" style="color:var(--gold);font-weight:600"></div>
<button class="login-btn" onclick="doLogin()">Einloggen →</button>
<div style="text-align:center;margin-top:10px">
<a href="#" onclick="showModal('forgot-modal');return false" style="font-size:12px;color:var(--tx3);text-decoration:none" onmouseover="this.style.color='var(--gold)'" onmouseout="this.style.color='var(--tx3)'">Passwort vergessen?</a>
</div>
</div>
<div id="step-register" style="display:none">
<div style="font-size:12px;color:var(--online);font-weight:600;margin-bottom:14px;display:flex;align-items:center;gap:6px">
<span style="width:8px;height:8px;border-radius:50%;background:var(--online);display:inline-block"></span>
Neuer Account für <strong id="reg-name-label" style="margin-left:2px"></strong>
</div>
<div class="fw"><input type="password" id="pw-reg" placeholder="Passwort wählen (min. 6 Zeichen)" autocomplete="new-password"></div>
<div class="fw"><input type="password" id="pw-reg2" placeholder="Passwort wiederholen" autocomplete="new-password"></div>
<div class="fw"><input type="email" id="email-reg" placeholder="E-Mail (optional — für Passwort-Recovery)" autocomplete="email"></div>
<div id="email-note" style="font-size:11px;color:var(--tx3);margin:-4px 0 12px;padding-left:4px">💡 Ohne E-Mail kein Passwort-Reset möglich</div>
<div class="fw"><input type="text" id="r-reg" placeholder="Raum (Standard: Lobby)" style="color:var(--gold);font-weight:600"></div>
<button class="login-btn" id="btn-register" onclick="doRegister()">Account erstellen →</button>
<button onclick="resetToStep1()" style="width:100%;margin-top:8px;padding:10px;background:transparent;border:1px solid var(--border);border-radius:13px;color:var(--tx2);cursor:pointer;font-size:13px;font-family:inherit;transition:border-color .2s" onmouseover="this.style.borderColor='var(--border-hi)'" onmouseout="this.style.borderColor='var(--border)'">← Anderen Namen wählen</button>
</div>
<div class="divider" id="google-divider" style="display:none">oder anmelden mit</div>
<div id="g_id_onload" data-client_id="<?= $GOOGLE_ID ?>" data-callback="handleGoogleLogin"></div>
<div class="g_id_signin" id="google-signin" style="display:none;justify-content:center" data-type="standard" data-theme="filled_blue" data-size="large" data-shape="rectangular" data-width="322"></div>
<p class="login-foot">© 2026 <a href="https://www.dreamcodes.net" target="_blank">Dreamcodes</a> · Alle Rechte vorbehalten</p>
</div>
<div class="modal-bg" id="forgot-modal" style="position:fixed">
<div class="modal">
<button class="modal-close" onclick="hideModal('forgot-modal')">✕</button>
<h2>🔑 Passwort vergessen</h2>
<p>Gib deinen Benutzernamen ein. Falls eine E-Mail hinterlegt ist, erhältst du einen Reset-Link.</p>
<div class="mf"><input type="text" id="forgot-user" placeholder="Benutzername"></div>
<button class="modal-btn" onclick="submitForgot()">Reset-Link senden</button>
</div>
</div>
<?php endif; ?>
</div>
<?php else: ?>
<div class="app-wrap">
<div class="sidebar">
<div class="sb-head">
<div class="u-pill">
<div class="u-av" onclick="showModal('profile-modal')" title="Profil bearbeiten" style="cursor:pointer">
<?= $myAvatarHtml ?><span class="u-dot"></span>
</div>
<div><div class="u-name"><?= $currentUser ?></div><div class="u-stat">● Online</div></div>
</div>
<div class="hdr-acts">
<?php if ($isAdmin): ?><button class="ib" onclick="openAdmin()" title="Admin">⚙️</button><?php endif; ?>
<button class="ib" onclick="createRoom()" title="Neuer Raum">✚</button>
<button class="ib" onclick="location.href='?logout=1'" title="Abmelden">🚪</button>
</div>
</div>
<div class="sb-srch">
<div class="sb-srch-i"><span>🔍</span><input type="text" placeholder="Räume suchen…" oninput="filterRooms(this.value)"></div>
</div>
<div class="sec-lbl">Räume</div>
<div class="room-list" id="room-list"></div>
<div class="sb-foot"><a href="https://www.dreamcodes.net" target="_blank" class="dc-link">Powered by DREAMCODES<span class="dcdot">.</span>NET</a></div>
</div>
<div class="chat-view">
<div class="ch-hdr">
<div class="ch-ic"><?= $inDM ? '👤' : '💬' ?></div>
<div>
<div class="ch-name" id="header-room-name"><?= $inDM ? 'DM: '.$dmWith : htmlspecialchars($_SESSION['chat_room']??'Lobby',ENT_QUOTES) ?></div>
<div class="ch-status"><span id="header-status">0 online</span></div>
</div>
<div class="ch-acts">
<span id="ws-indicator" style="font-size:10px;font-family:'DM Mono',monospace;color:var(--tx3);padding:4px 8px;background:var(--raised);border:1px solid var(--border);border-radius:99px;cursor:default;transition:color .3s">Verbinde…</span>
<button class="ib" onclick="toggleSearch()" title="Suchen (Strg+F)">🔍</button>
<button class="ib" id="push-btn" onclick="togglePush()" title="Push-Benachrichtigungen">🔔</button>
</div>
</div>
<div class="msg-search-bar" id="msg-search-bar">
<input type="text" id="search-input" placeholder="Nachrichten durchsuchen…" oninput="doSearch()">
<span id="search-count"></span>
<button class="search-close" onclick="clearSearch()">✕</button>
</div>
<div id="chat-box"><div class="day-div">Heute</div></div>
<div class="typing-wrap"><div class="typing-bub" id="typing-indicator"><div class="td"></div><div class="td"></div><div class="td"></div></div></div>
<div class="file-preview" id="file-preview">
<img class="file-preview-thumb" id="fp-thumb" src="" alt="" style="display:none">
<div class="file-preview-info"><div class="file-preview-name" id="fp-name"></div><div class="file-preview-size" id="fp-size"></div></div>
<button class="file-preview-cancel" onclick="clearFilePreview()">✕</button>
</div>
<div class="reply-bar" id="reply-bar">
<div class="reply-bar-inner">
<div class="reply-bar-name" id="reply-bar-name"></div>
<div class="reply-bar-text" id="reply-bar-text"></div>
</div>
<button class="reply-bar-close" onclick="clearReply()">✕</button>
</div>
<div id="emoji-picker"></div>
<div class="input-area">
<button class="iab" onclick="toggleEmoji()" title="Emoji">😊</button>
<button class="iab" onclick="triggerFileInput()" title="Datei senden">📎</button>
<input type="file" id="file-input" style="display:none" accept="image/*,.pdf,.txt,.zip" onchange="onFileSelect(event)">
<div class="msg-wrap"><input type="text" id="msg" placeholder="Nachricht schreiben…" autocomplete="off" oninput="onType()"></div>
<button class="send-btn" onclick="send()" title="Senden">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
</div>
</div>
<div class="modal-bg" id="dm-modal">
<div class="modal">
<button class="modal-close" onclick="hideModal('dm-modal')">✕</button>
<h2>💬 Direktnachricht</h2>
<p>Sende eine private Nachricht an <strong id="dm-target"></strong></p>
<div class="mf"><textarea id="dm-input" rows="3" placeholder="Deine Nachricht…"></textarea></div>
<button class="modal-btn" onclick="sendDM()">Senden</button>
</div>
</div>
<div class="modal-bg" id="room-modal">
<div class="modal">
<button class="modal-close" onclick="hideModal('room-modal')">✕</button>
<h2>✚ Neuer Raum</h2>
<p>Erstelle einen neuen Chat-Raum — optional mit Passwortschutz.</p>
<div class="mf"><label>Raumname</label><input type="text" id="room-name-input" placeholder="z.B. Gaming, Arbeit…"></div>
<div class="mf"><label>Passwort (optional) 🔒</label><input type="password" id="room-pw-input" placeholder="Leer lassen = öffentlich"></div>
<button class="modal-btn gold" onclick="submitRoom()">Raum erstellen</button>
</div>
</div>
<div class="modal-bg" id="admin-modal" style="align-items:flex-start;padding-top:30px">
<div class="modal" style="max-width:660px">
<button class="modal-close" onclick="hideModal('admin-modal')">✕</button>
<h2>⚙️ Admin-Dashboard</h2>
<p id="admin-msg-count" style="margin-bottom:12px;font-size:12px;color:var(--tx3)"></p>
<div id="admin-tabs" style="display:flex;gap:6px;margin-bottom:16px"></div>
<div id="admin-content" style="min-height:120px"></div>
</div>
</div>
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
<img id="lb-img" src="" alt="Bild">
</div>
<div class="modal-bg" id="profile-modal">
<div class="modal">
<button class="modal-close" onclick="hideModal('profile-modal')">✕</button>
<h2>👤 Mein Profil</h2>
<p>Avatar hochladen und E-Mail für Passwort-Recovery hinterlegen.</p>
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px">
<div id="profile-av-preview" style="width:64px;height:64px;border-radius:16px;background:var(--overlay);border:2px solid var(--border);overflow:hidden;display:flex;align-items:center;justify-content:center;font-size:24px;font-weight:700;color:var(--gold);flex-shrink:0">
<?= $myAvatar ? '<img src="'.htmlspecialchars($myAvatar,ENT_QUOTES).'" style="width:100%;height:100%;object-fit:cover">' : $avatarChar ?>
</div>
<div style="flex:1">
<div style="font-size:13px;font-weight:600;margin-bottom:6px"><?= $currentUser ?></div>
<label style="display:inline-flex;align-items:center;gap:6px;background:var(--input);border:1px solid var(--border);border-radius:10px;padding:7px 12px;font-size:12px;cursor:pointer;transition:border-color .2s" onmouseover="this.style.borderColor='var(--gold-edge)'" onmouseout="this.style.borderColor='var(--border)'">
📷 Bild hochladen
<input type="file" id="avatar-input" accept="image/jpeg,image/png,image/webp" style="display:none" onchange="uploadAvatar(this)">
</label>
<div style="font-size:10px;color:var(--tx3);margin-top:4px">JPG · PNG · WebP · max. 2 MB</div>
</div>
</div>
<div class="mf">
<label>E-Mail (für Passwort-Recovery)</label>
<input type="email" id="profile-email" placeholder="deine@email.de" autocomplete="email">
</div>
<button class="modal-btn" onclick="saveProfile()">Profil speichern</button>
<div style="height:1px;background:var(--border);margin:20px 0"></div>
<div style="font-size:13px;font-weight:600;margin-bottom:12px;color:var(--tx2)">🔑 Passwort ändern</div>
<div class="mf"><input type="password" id="pw-old" placeholder="Aktuelles Passwort" autocomplete="current-password"></div>
<div class="mf"><input type="password" id="pw-new" placeholder="Neues Passwort (min. 6 Zeichen)" autocomplete="new-password"></div>
<div class="mf"><input type="password" id="pw-new2" placeholder="Neues Passwort wiederholen" autocomplete="new-password"></div>
<button class="modal-btn" style="background:linear-gradient(135deg,var(--gold-dim),var(--gold));color:var(--void)" onclick="changePassword()">Passwort ändern</button>
</div>
</div>
<?php endif; ?>
<script src="basis.js"></script>
<?php if (!$loggedIn): ?>
<script>
let _checkTimer = null;
function onUsernameInput(val) {
clearTimeout(_checkTimer);
const hint = document.getElementById('username-hint');
if (!val.trim()) { hint.textContent = ''; return; }
hint.textContent = '…';
_checkTimer = setTimeout(() => _previewUser(val.trim()), 450);
}
async function _previewUser(name) {
const hint = document.getElementById('username-hint');
try {
const res = await fetch('?action=check_user', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({user:name})});
const d = await res.json();
if (d.is_admin) { hint.style.color='var(--gold)'; hint.textContent='🔑 Admin-Login'; }
else if (d.exists) { hint.style.color='var(--online)'; hint.textContent='✓ Bekannter Nutzer — Passwort eingeben'; }
else { hint.style.color='var(--sap)'; hint.textContent='✦ Neuer Name — Account wird erstellt'; }
} catch(e) { hint.textContent = ''; }
}
async function checkUsername() {
const name = document.getElementById('u')?.value.trim();
if (!name) { showToast('⚠️', 'Bitte Benutzernamen eingeben'); return; }
const btn = document.getElementById('btn-next');
if (btn) { btn.innerHTML = '…'; btn.disabled = true; }
try {
const res = await fetch('?action=check_user', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({user:name})});
const d = await res.json();
if (btn) { btn.innerHTML = 'Weiter →'; btn.disabled = false; }
document.getElementById('step-name').style.display = 'none';
if (d.exists || d.is_admin) {
document.getElementById('login-name-label').textContent = name;
document.getElementById('login-av').textContent = name[0].toUpperCase();
document.getElementById('step-login').style.display = 'block';
document.getElementById('pw-login')?.focus();
} else {
document.getElementById('reg-name-label').textContent = name;
document.getElementById('step-register').style.display = 'block';
document.getElementById('pw-reg')?.focus();
}
} catch(e) {
if (btn) { btn.innerHTML = 'Weiter →'; btn.disabled = false; }
showToast('❌', 'Verbindungsfehler — Seite neu laden');
}
}
function resetToStep1() {
document.getElementById('step-name').style.display = 'block';
document.getElementById('step-login').style.display = 'none';
document.getElementById('step-register').style.display = 'none';
document.getElementById('username-hint').textContent = '';
document.getElementById('u')?.focus();
}
async function doLogin() {
const name = document.getElementById('u')?.value.trim();
const pass = document.getElementById('pw-login')?.value || '';
const room = document.getElementById('r-login')?.value.trim() || 'Lobby';
const btn = document.querySelector('#step-login .login-btn');
if (!pass) { showToast('⚠️', 'Passwort eingeben'); return; }
if (btn) { btn.innerHTML = '<span style="opacity:.6">Einloggen…</span>'; btn.disabled = true; }
try {
const res = await fetch('?action=login', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({user:name, pass, room})});
const d = await res.json();
if (d.rate_limited) {
if (btn) { btn.innerHTML = 'Einloggen →'; btn.disabled = false; }
showToast('🚫', 'Zu viele Versuche — kurz warten'); return;
}
if (d.success) { if (btn) btn.innerHTML = '✓'; setTimeout(() => location.reload(), 300); }
else { if (btn) { btn.innerHTML = 'Einloggen →'; btn.disabled = false; } showToast('🔒', d.error || 'Falsches Passwort'); }
} catch(e) {
if (btn) { btn.innerHTML = 'Einloggen →'; btn.disabled = false; }
showToast('❌', 'Verbindungsfehler');
}
}
async function doRegister() {
const name = document.getElementById('u')?.value.trim();
const pw1 = document.getElementById('pw-reg')?.value || '';
const pw2 = document.getElementById('pw-reg2')?.value || '';
const email = document.getElementById('email-reg')?.value.trim() || '';
const room = document.getElementById('r-reg')?.value.trim() || 'Lobby';
const btn = document.getElementById('btn-register');
if (pw1.length < 6) { showToast('⚠️', 'Passwort: mindestens 6 Zeichen'); return; }
if (pw1 !== pw2) { showToast('⚠️', 'Passwörter stimmen nicht überein'); return; }
if (btn) { btn.innerHTML = '<span style="opacity:.6">Erstelle Account…</span>'; btn.disabled = true; }
try {
const res = await fetch('?action=register', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({user:name, pass:pw1, email, room})});
const d = await res.json();
if (d.success) { if (btn) btn.innerHTML = '✓'; setTimeout(() => location.reload(), 300); }
else { if (btn) { btn.innerHTML = 'Account erstellen →'; btn.disabled = false; } showToast('❌', d.error || 'Fehler'); }
} catch(e) {
if (btn) { btn.innerHTML = 'Account erstellen →'; btn.disabled = false; }
showToast('❌', 'Verbindungsfehler');
}
}
async function submitForgot() {
const user = document.getElementById('forgot-user')?.value.trim();
if (!user) { showToast('⚠️', 'Benutzernamen eingeben'); return; }
await fetch('?action=pw_forgot', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({user})});
hideModal('forgot-modal');
showToast('📧', 'Falls eine E-Mail hinterlegt ist, wurde ein Reset-Link gesendet.');
}
async function submitPwReset() {
const pw1 = document.getElementById('new-pw')?.value || '';
const pw2 = document.getElementById('new-pw2')?.value || '';
const token = <?= json_encode($pwResetToken ?? '') ?>;
if (pw1.length < 6) { showToast('⚠️', 'Passwort: mindestens 6 Zeichen'); return; }
if (pw1 !== pw2) { showToast('⚠️', 'Passwörter stimmen nicht überein'); return; }
const res = await fetch('?action=pw_reset', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({token, password:pw1})});
const d = await res.json();
if (d.ok) { showToast('✅', 'Passwort gespeichert!'); setTimeout(() => location.href = '/', 1500); }
else showToast('❌', d.error || 'Fehler');
}
</script>
<?php endif; ?>
<?php if ($loggedIn): ?>
<script>
const currentUserName = <?= json_encode($_SESSION['chat_user'], JSON_UNESCAPED_UNICODE) ?>;
const WS_URL = <?= json_encode(WS_URL) ?>;
const VAPID_PUB = <?= json_encode($VAPID_PUB ?? '') ?>;
const E2E_ENABLED = true;
const PW_RESET_TOKEN = <?= json_encode($pwResetToken ?? '') ?>;
window._cU = currentUserName;
window._inDM = <?= json_encode($inDM) ?>;
window._dmWith = <?= json_encode($dmWith) ?>;
window._myAvatar = <?= json_encode($myAvatar) ?>;
(function(){
const p=document.getElementById('emoji-picker');
if(p)p.innerHTML=emojis.map(e=>`<span class="ej" onclick="document.getElementById('msg').value+='${e}';document.getElementById('msg').focus()">${e}</span>`).join('');
})();
let pushSubscription = null;
async function togglePush() {
const btn = document.getElementById('push-btn');
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
showToast('❌','Push-Benachrichtigungen werden von diesem Browser nicht unterstützt.');
return;
}
if (pushSubscription) {
await pushSubscription.unsubscribe();
await fetch('?action=push_unsubscribe',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({endpoint:pushSubscription.endpoint})});
pushSubscription = null;
if(btn) btn.textContent='🔔';
showToast('🔕','Push-Benachrichtigungen deaktiviert');
return;
}
const perm = await Notification.requestPermission();
if (perm !== 'granted') { showToast('⚠️','Benachrichtigungen wurden nicht erlaubt.'); return; }
try {
const reg = await navigator.serviceWorker.ready;
pushSubscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUB)
});
const sub = pushSubscription.toJSON();
await fetch('?action=push_subscribe',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({endpoint:sub.endpoint,p256dh:sub.keys?.p256dh||'',auth:sub.keys?.auth||''})});
if(btn) btn.textContent='🔔✓';
btn.style.color='var(--online)';
showToast('🔔','Push-Benachrichtigungen aktiviert!');
} catch(e) { showToast('❌','Push-Aktivierung fehlgeschlagen: '+e.message); }
}
function urlBase64ToUint8Array(base64String) {
const padding='='.repeat((4-base64String.length%4)%4);
const base64=(base64String+padding).replace(/-/g,'+').replace(/_/g,'/');
const raw=atob(base64);
return Uint8Array.from([...raw].map(c=>c.charCodeAt(0)));
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(()=>{});
}
function showBrowserNotif(title, body, icon) {
if (document.visibilityState === 'visible') return;
if (Notification.permission !== 'granted') return;
try {
navigator.serviceWorker.ready.then(reg => {
reg.showNotification(title, { body, icon: icon||'/favicon.ico', badge:'/favicon.ico', vibrate:[200,100,200] });
});
} catch(e) {}
}
async function uploadAvatar(input) {
const file = input.files[0]; if(!file) return;
if(file.size > 2*1024*1024) { showToast('❌','Max. 2 MB für Avatare'); return; }
const fd = new FormData(); fd.append('file', file);
showToast('⏳','Avatar wird hochgeladen…');
try {
const res = await fetch('?action=upload_avatar',{method:'POST',body:fd});
const d = await res.json();
if(d.ok && d.url) {
const prev = document.getElementById('profile-av-preview');
if(prev) prev.innerHTML=`<img src="${d.url}" style="width:100%;height:100%;object-fit:cover">`;
const av = document.querySelector('.u-av');
if(av) {
const dot = av.querySelector('.u-dot');
av.innerHTML=`<img src="${d.url}" style="width:100%;height:100%;object-fit:cover;border-radius:11px">`;
if(dot) av.appendChild(dot);
}
window._myAvatar = d.url;
showToast('✅','Avatar gespeichert!');
} else showToast('❌', d.error||'Upload fehlgeschlagen');
} catch(e) { showToast('❌','Upload-Fehler'); }
}
async function saveProfile() {
const email = document.getElementById('profile-email')?.value.trim();
await fetch('?action=update_profile',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email})});
showToast('✅','Profil gespeichert!');
hideModal('profile-modal');
}
async function changePassword() {
const oldPw = document.getElementById('pw-old')?.value || '';
const newPw = document.getElementById('pw-new')?.value || '';
const newPw2 = document.getElementById('pw-new2')?.value || '';
if (!oldPw) { showToast('⚠️','Altes Passwort eingeben'); return; }
if (newPw.length < 6) { showToast('⚠️','Neues Passwort: min. 6 Zeichen'); return; }
if (newPw !== newPw2) { showToast('⚠️','Neue Passwörter stimmen nicht überein'); return; }
const res = await fetch('?action=change_password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({old_pass:oldPw,new_pass:newPw,new_pass2:newPw2})});
const d = await res.json();
if (d.ok) {
showToast('✅','Passwort geändert!');
document.getElementById('pw-old').value = '';
document.getElementById('pw-new').value = '';
document.getElementById('pw-new2').value = '';
} else showToast('❌', d.error || 'Fehler');
}
async function openProfileModal() {
showModal('profile-modal');
try {
const res = await fetch('?action=get_profile');
const d = await res.json();
if(d.profile?.email) {
const ef = document.getElementById('profile-email');
if(ef) ef.value = d.profile.email;
}
} catch(e) {}
}
document.querySelector('.u-av')?.addEventListener('click', openProfileModal);
async function submitForgot() {
const user = document.getElementById('forgot-user')?.value.trim();
if(!user) { showToast('⚠️','Benutzername eingeben'); return; }
await fetch('?action=pw_forgot',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({user})});
hideModal('forgot-modal');
showToast('📧','Falls eine E-Mail hinterlegt ist, wurde ein Reset-Link gesendet.');
}
async function submitPwReset() {
const pw1 = document.getElementById('new-pw')?.value;
const pw2 = document.getElementById('new-pw2')?.value;
if(!pw1 || pw1.length < 6) { showToast('⚠️','Passwort muss mindestens 6 Zeichen haben'); return; }
if(pw1 !== pw2) { showToast('⚠️','Passwörter stimmen nicht überein'); return; }
const res = await fetch('?action=pw_reset',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:PW_RESET_TOKEN,password:pw1})});
const d = await res.json();
if(d.ok) { showToast('✅','Passwort gespeichert! Du kannst dich jetzt einloggen.'); setTimeout(()=>location.href='/',2000); }
else showToast('❌', d.error||'Fehler');
}
const _origFetch = window.fetch;
window.fetch = function(url, opts) {
return _origFetch(url, opts).then(async res => {
if(res.status === 429) {
const clone = res.clone();
try {
const d = await clone.json();
showToast('🚫', d.error || 'Zu viele Anfragen — bitte kurz warten.');
} catch(e) { showToast('🚫','Zu viele Anfragen — bitte kurz warten.'); }
}
return res;
});
};
const E2E = {
keyPair: null,
peerKeys: {},
sharedKeys: {},
async init() {
if (!E2E_ENABLED || !window.crypto?.subtle) return;
try {
E2E.keyPair = await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
true,
['deriveKey']
);
const rawPub = await crypto.subtle.exportKey('raw', E2E.keyPair.publicKey);
const b64 = btoa(String.fromCharCode(...new Uint8Array(rawPub)));
await fetch('?action=pub_key', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({pub_key: b64})
});
wsSend({type:'pub_key', pub_key: b64});
console.log('[E2E] Schlüsselpaar generiert & Public Key registriert');
await E2E.fetchPeerKeys();
} catch(e) { console.warn('[E2E] Init fehlgeschlagen:', e); }
},
async fetchPeerKeys() {
try {
const res = await fetch('?action=get_pub_keys');
const data = await res.json();
if (data.keys) {
for (const [user, b64] of Object.entries(data.keys)) {
if (user !== currentUserName) await E2E.importPeerKey(user, b64);
}
}
} catch(e) {}
},
async importPeerKey(user, b64) {
try {
if (!E2E.keyPair) return;
const raw = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
const peerPub = await crypto.subtle.importKey(
'raw', raw,
{ name: 'ECDH', namedCurve: 'P-256' },
true, []
);
const aesKey = await crypto.subtle.deriveKey(
{ name: 'ECDH', public: peerPub },
E2E.keyPair.privateKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
E2E.peerKeys[user] = peerPub;
E2E.sharedKeys[user] = aesKey;
} catch(e) { console.warn('[E2E] Key-Import fehlgeschlagen für', user, e); }
},
async encrypt(plaintext) {
if (!E2E_ENABLED || !E2E.keyPair) return { plain: plaintext, encrypted: false };
try {
const ephKP = await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey']
);
const aesKey = await crypto.subtle.deriveKey(
{ name: 'ECDH', public: ephKP.publicKey },
E2E.keyPair.privateKey,
{ name: 'AES-GCM', length: 256 }, false, ['encrypt','decrypt']
);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(plaintext);
const ciphertext = await crypto.subtle.encrypt({ name:'AES-GCM', iv }, aesKey, encoded);
const rawEph = await crypto.subtle.exportKey('raw', ephKP.publicKey);
return {
encrypted: true,
msg: btoa(String.fromCharCode(...new Uint8Array(ciphertext))),
iv: btoa(String.fromCharCode(...iv)),
eph_pub: btoa(String.fromCharCode(...new Uint8Array(rawEph)))
};
} catch(e) {
console.warn('[E2E] Verschlüsselung fehlgeschlagen:', e);
return { plain: plaintext, encrypted: false };
}
},
async decrypt(msg, ivB64, ephPubB64) {
if (!E2E_ENABLED || !E2E.keyPair) return msg;
try {
const ephRaw = Uint8Array.from(atob(ephPubB64), c => c.charCodeAt(0));
const ephPub = await crypto.subtle.importKey(
'raw', ephRaw,
{ name:'ECDH', namedCurve:'P-256' }, true, []
);
const aesKey = await crypto.subtle.deriveKey(
{ name:'ECDH', public: ephPub },
E2E.keyPair.privateKey,
{ name:'AES-GCM', length: 256 }, false, ['decrypt']
);
const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0));
const ciphertext = Uint8Array.from(atob(msg), c => c.charCodeAt(0));
const plain = await crypto.subtle.decrypt({ name:'AES-GCM', iv }, aesKey, ciphertext);
return new TextDecoder().decode(plain);
} catch(e) {
return '🔒 [Nachricht kann nicht entschlüsselt werden]';
}
}
};
let ws = null;
let wsConnected = false;
let wsReconnectT = null;
let wsPingT = null;
let pollInterval = null;
function wsUpdateIndicator(state) {
const el = document.getElementById('ws-indicator');
if (!el) return;
const map = {
connected: { color:'#10b981', text:'🔒 E2E · WebSocket', title:'Ende-zu-Ende verschlüsselt · Echtzeit-Verbindung aktiv' },
polling: { color:'#c9a84c', text:'🔒 E2E · Polling', title:'Ende-zu-Ende verschlüsselt · Polling-Modus (1,5s)' },
connecting: { color:'#7a8499', text:'Verbinde…', title:'WebSocket wird aufgebaut…' },
disconnected: { color:'#ef4444', text:'Getrennt', title:'Verbindung unterbrochen — Reconnect läuft' },
};
const s = map[state] || map.disconnected;
el.style.color = s.color;
el.textContent = s.text;
el.title = s.title;
}
function wsConnect() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
wsUpdateIndicator('connecting');
try {
ws = new WebSocket(WS_URL);
} catch(e) { startPolling(); return; }
ws.onopen = async () => {
wsConnected = true;
stopPolling();
clearTimeout(wsReconnectT);
wsUpdateIndicator('connected');
showToast('⚡', 'WebSocket verbunden — Echtzeit-Modus aktiv');
wsSend({ type:'auth', user: currentUserName, room: window._currentRoom || 'Lobby', color: window._myColor || '#aaa' });
if (E2E.keyPair) {
const rawPub = await crypto.subtle.exportKey('raw', E2E.keyPair.publicKey);
const b64 = btoa(String.fromCharCode(...new Uint8Array(rawPub)));
wsSend({ type:'pub_key', pub_key: b64 });
}
wsSend({ type:'get_pub_keys' });
wsPingT = setInterval(() => wsSend({type:'ping'}), 25000);
};
ws.onmessage = async (event) => {
try { await wsHandleMessage(JSON.parse(event.data)); }
catch(e) { console.warn('[WS] Nachricht-Fehler:', e); }
};
ws.onclose = () => {
wsConnected = false;
clearInterval(wsPingT);
wsUpdateIndicator('disconnected');
startPolling();
wsReconnectT = setTimeout(wsConnect, 5000); // Reconnect nach 5s
};
ws.onerror = () => {
wsUpdateIndicator('disconnected');
startPolling();
};
}
function wsSend(data) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
}
}
async function wsHandleMessage(data) {
const box = document.getElementById('chat-box');
switch(data.type) {
case 'auth_ok':
console.log('[WS] Authentifiziert als', data.user);
break;
case 'message':
if (!box) break;
if (blockedUsers.has(data.user)) break;
if (data.encrypted && data.iv && data.eph_pub) {
data.msg = await E2E.decrypt(data.msg, data.iv, data.eph_pub);
}
appendMsg(data, currentUserName, box);
lastId = Math.max(lastId, data.id || 0);
box.scrollTop = box.scrollHeight;
if (data.user !== currentUserName) {
playNotificationSound();
showBrowserNotif(data.user, data.encrypted ? '🔒 Verschlüsselte Nachricht' : (data.msg||'Neue Nachricht'), window._myAvatar||'');
}
break;
case 'reactions':
renderReactions(data.msg_id, data.reactions, currentUserName);
break;
case 'pub_key':
if (data.user !== currentUserName && data.pub_key) {
await E2E.importPeerKey(data.user, data.pub_key);
}
break;
case 'pub_keys':
if (data.keys) {
for (const [user, b64] of Object.entries(data.keys)) {
if (user !== currentUserName) await E2E.importPeerKey(user, b64);
}
}
break;
case 'typing':
if (data.user !== currentUserName) {
const ti = document.getElementById('typing-indicator');
if (ti) { ti.classList.add('show'); clearTimeout(ti._t); ti._t = setTimeout(()=>ti.classList.remove('show'), 2500); }
}
break;
case 'room_switched':
window._currentRoom = data.room;
break;
case 'kicked':
location.href = '?logout=1';
break;
case 'pong':
break;
}
}
function startPolling() {
if (pollInterval) return;
wsUpdateIndicator('polling');
pollInterval = setInterval(() => sync(currentUserName), 1500);
sync(currentUserName);
}
function stopPolling() {
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
}
function playNotificationSound() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain); gain.connect(ctx.destination);
osc.frequency.value = 880;
gain.gain.setValueAtTime(0.08, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.18);
osc.start(); osc.stop(ctx.currentTime + 0.18);
} catch(e) {}
}
window._currentRoom = <?= json_encode($_SESSION['chat_room'] ?? 'Lobby') ?>;
window._myColor = <?= json_encode($_SESSION['chat_color'] ?? '#aaa') ?>;
E2E.init().then(() => wsConnect());
startPolling();
setTimeout(() => { if (!wsConnected) wsUpdateIndicator('polling'); }, 2000);
setTimeout(() => showToast('👋', 'Willkommen zurück, <?= addslashes($currentUser) ?>!'), 700);
const _origOnType = window.onType;
window.onType = function() {
if (typeof _origOnType === 'function') _origOnType();
wsSend({ type: 'typing' });
};
const _origSend = window.send;
window.send = async function() {
const m = document.getElementById('msg');
const text = m?.value.trim();
if (!text && !pendingFile) return;
if (E2E_ENABLED && E2E.keyPair && text) {
const enc = await E2E.encrypt(text);
if (enc.encrypted) {
if (wsConnected) {
const payload = {
type: 'message', msg: enc.msg, encrypted: true,
iv: enc.iv, eph_pub: enc.eph_pub,
reply_to_id: replyTo?.id, reply_to_user: replyTo?.user, reply_to_text: replyTo?.msg
};
wsSend(payload);
m.value = ''; clearReply(); stopTyping();
const btn = document.querySelector('.send-btn');
if (btn) { btn.style.transform='scale(.85)'; setTimeout(()=>btn.style.transform='',160); }
return;
}
m.value = enc.msg;
const origReplyTo = replyTo;
await fetch('?action=send', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({
msg: enc.msg, encrypted: true, iv: enc.iv, eph_pub: enc.eph_pub,
reply_to: origReplyTo?.id, reply_to_user: origReplyTo?.user, reply_to_text: origReplyTo?.msg
})
});
m.value = ''; clearReply(); stopTyping();
return;
}
}
if (_origSend) _origSend();
};
const _origAppendMsg = window.appendMsg;
window.appendMsg = async function(m, cU, box) {
if (m.encrypted && m.iv && m.eph_pub_key) {
m.msg = await E2E.decrypt(m.msg, m.iv, m.eph_pub_key);
m.eph_pub = m.eph_pub_key;
}
_origAppendMsg(m, cU, box);
};
</script>
<?php endif; ?>
</body>
</html>

