Dienstag, 26 August 2025

Top 5 diese Woche

Ähnliche Tutorials

InfinityChat

Der Dreamcodes InfinityChat ist eine leistungsstarke und zugleich leicht installierbare Messanger Komplettlösung für Echtzeit-Kommunikation. Entwickelt mit modernsten Web-Technologien bietet es eine sichere, flexible und optisch ansprechende Plattform, die sowohl für kleine Teams als auch für große Unternehmen geeignet ist.

Mit umfassenden Funktionen wie WebSocket-Echtzeitkommunikation, Chaträumen, Dateiuploads und Admin-Dashboard ermöglicht InfinityChat eine nahtlose und professionelle Chat-Erfahrung. Sicherheit wird großgeschrieben durch verschlüsselte Passwörter, Zwei-Faktor-Authentifizierung, CSRF-Schutz und strikte CORS-Regeln.

Die Plattform lässt sich einfach an Unternehmensumgebungen anpassen, unterstützt LDAP/SSO und bietet ein durchdachtes Rollen- und Rechtekonzept für Benutzer, Moderatoren und Administratoren.

Hauptfunktionen im Überblick

  • One-File-Installation
    Nach dem ersten Start erstellt das Script automatisch alle erforderlichen Dateien und Verzeichnisse. Keine komplizierte Einrichtung notwendig.
  • Modernes Design
    Professionelles UI/UX, responsive für Desktop, Tablet und Smartphone.
  • Sichere Anmeldung & Authentifizierung
    • Verschlüsselte Passwörter (bcrypt)
    • Zwei-Faktor-Authentifizierung (TOTP z. B. Google Authenticator)
    • E-Mail-gestützte Backup-Codes für Notfälle
    • Optional LDAP/SSO-Anbindung für Unternehmensumgebungen
  • Echtzeit-Kommunikation mit WebSockets
    Nachrichten werden sofort übertragen – keine Verzögerung, kein Polling.
    Unterstützt sowohl TCP (ws/wss) als auch Unix Domain Sockets für höchste Performance.
  • Chaträume & Gruppen
    • Öffentliche und private Räume
    • Direktnachrichten
    • Moderationsrechte für einzelne Räume
  • Dateiverwaltung & Uploads
    • Persistente Dateispeicherung
    • Automatische Thumbnail-Erstellung für Bilder
    • Upload-Filter (Typ & Größe) für maximale Sicherheit
  • Rollen- und Rechtekonzept
    • Benutzer: Chatten und Dateien austauschen
    • Moderatoren: Inhalte prüfen, Meldungen bearbeiten
    • Admins: Verwaltung von Nutzern, Räumen, Moderationsreports
  • Admin Dashboard
    • Übersichtliche Benutzerverwaltung
    • Live-Statistiken: aktive Nutzer, Nachrichtenanzahl, Speicherverbrauch
    • Reportsystem: gemeldete Nachrichten einsehen und bearbeiten
    • Automatische Moderation gegen verbotene Wörter / Spam
  • Sicherheit & Datenschutz
    • CSRF-Schutz bei Formularen
    • Strikte CORS-Regeln für Produktiv-Domains
    • Prepared Statements in allen SQL-Abfragen
    • HTTPS-Unterstützung für wss-Verbindungen

Einsatzmöglichkeiten

  • Unternehmen: Interne Kommunikation, Projektteams, schnelle Abstimmung
  • Communities: Eigene Chat-Server mit Gruppen- und Admin-Rechten
  • Kunden-Support: Direkter Support-Chat mit Rollen- und Rechteverwaltung
  • Bildungseinrichtungen: Virtuelle Arbeitsgruppen, Kurs-Chats, Projektkommunikation

Highlights für Unternehmen

  • Single-Sign-On & LDAP-Unterstützung
  • Erweiterbare API-Schnittstelle
  • Rollenbasiertes Berechtigungsmodell
  • Vollständige Kontrolle über Datenhaltung und Hosting
<?php
// configuration
$db_host = 'localhost';
$db_user = 'root';
$db_pass = '';
$db_name = 'chat_app';
$upload_max_filesize_bytes = 10 * 1024 * 1024; // 10MB
$allowed_upload_types = [
    'image/jpeg', 'image/png', 'image/gif',
    'application/pdf', 'text/plain'
];
$production_origins = [
    'https://example.com'
];
$site_footer = 'http://www.deinedomain.tld';
$ipc_socket = __DIR__ . '/tmp/chat_ipc.sock'; // unix domain socket path for IPC between PHP and WS server

// moderation config
$banned_words = ['badword', 'violation', 'spamword']; // einfache Liste, anpassen
$auto_flag_threshold = 1; // number of matches to auto flag

// helpers
function create_file($path, $content) {
    $dir = dirname($path);
    if (!is_dir($dir)) mkdir($dir, 0755, true);
    file_put_contents($path, $content);
}

// first run installer
if (!file_exists(__DIR__ . '/.installed')) {
    @mkdir(__DIR__ . '/json', 0755, true);
    @mkdir(__DIR__ . '/uploads', 0755, true);
    @mkdir(__DIR__ . '/tmp', 0755, true);

    if (!file_exists(__DIR__ . '/json/messages.json')) file_put_contents(__DIR__ . '/json/messages.json', json_encode([]));
    if (!file_exists(__DIR__ . '/json/users.json')) file_put_contents(__DIR__ . '/json/users.json', json_encode([]));

    // websocket server for CLI listens on TCP for browsers and on UNIX socket for IPC from PHP
    $ws = <<<'PHP'
<?php
// Starten via CLI: php ws_server.php
set_time_limit(0);
$tcpAddress = '0.0.0.0';
$tcpPort = 9000;
$unixPath = __DIR__ . '/tmp/chat_ipc.sock';
if (file_exists($unixPath)) @unlink($unixPath);

// create tcp socket for browser connections
$tcp = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_set_option($tcp, SOL_SOCKET, SO_REUSEADDR, 1);
socket_bind($tcp, $tcpAddress, $tcpPort);
socket_listen($tcp);

// create unix domain socket for IPC from PHP
$unix = socket_create(AF_UNIX, SOCK_STREAM, 0);
socket_bind($unix, $unixPath);
socket_listen($unix);

$clients = [$tcp, $unix];
$meta = []; // metadata for tcp client sockets keyed by resource id

function send_tcp($client, $msg){
    @socket_write($client, $msg . "
");
}

function broadcast_room($clientsMeta, $room, $msg){
    foreach ($clientsMeta as $cid => $info){
        if (($info['room'] ?? null) === $room && isset($info['socket'])){
            @socket_write($info['socket'], $msg . "
");
        }
    }
}

while (true){
    $read = $clients;
    // add client sockets to read array
    foreach ($clients as $c){ if (is_resource($c)) continue; }
    $read = $clients;
    $all = $read;
    // also add dynamic client sockets
    foreach ($all as $s){ $read[] = $s; }
    $write = null; $except = null;
    if (socket_select($read, $write, $except, 1) < 1) continue;
    foreach ($read as $sock){
        if ($sock === $tcp){
            $newsock = socket_accept($tcp);
            if ($newsock){
                $id = intval($newsock);
                $clients[] = $newsock;
                $meta[$id] = ['socket' => $newsock, 'room' => 'global', 'username' => null];
                send_tcp($newsock, json_encode(['type' => 'welcome', 'msg' => 'connected']));
            }
            continue;
        }
        if ($sock === $unix){
            $uconn = socket_accept($unix);
            // read one line json from PHP and process broadcast
            $data = '';
            while ($b = socket_read($uconn, 2048)){
                $data .= $b;
                if (substr($b, -1) === "
") break;
            }
            $data = trim($data);
            if ($data){
                $p = json_decode($data, true);
                if (is_array($p) && isset($p['type']) && $p['type'] === 'message'){
                    broadcast_room($meta, $p['room'] ?? 'global', json_encode(['type' => 'message', 'room' => $p['room'] ?? 'global', 'username' => $p['username'] ?? 'system', 'message' => $p['message'] ?? '', 'time' => date('c')]));
                }
            }
            socket_close($uconn);
            continue;
        }
        // existing tcp client sent data
        $id = intval($sock);
        $data = @socket_read($sock, 4096, PHP_NORMAL_READ);
        if ($data === false || $data === ''){
            if (isset($meta[$id]['socket'])) socket_close($meta[$id]['socket']);
            unset($meta[$id]);
            $key = array_search($sock, $clients, true);
            if ($key !== false) unset($clients[$key]);
            continue;
        }
        $data = trim($data);
        if (!$data) continue;
        $p = json_decode($data, true);
        if (!is_array($p)) continue;
        if ($p['type'] === 'join'){
            $meta[$id]['room'] = $p['room'] ?? 'global';
            $meta[$id]['username'] = $p['username'] ?? null;
            send_tcp($sock, json_encode(['type' => 'joined', 'room' => $meta[$id]['room']]));
        }
        if ($p['type'] === 'message'){
            $room = $p['room'] ?? 'global';
            broadcast_room($meta, $room, json_encode(['type' => 'message', 'room' => $room, 'username' => $meta[$id]['username'] ?? 'anon', 'message' => $p['message'] ?? '', 'time' => date('c')]));
        }
    }
}
PHP;
    create_file(__DIR__ . '/ws_server.php', $ws);

    // API stub
    create_file(__DIR__ . '/api.php', "<?php require_once __DIR__ . '/index.php'; exit; ");

    // create DB and tables with extra columns for TOTP, backup codes, reports
    $mysqli = @new mysqli($db_host, $db_user, $db_pass);
    if ($mysqli && !$mysqli->connect_error){
        $mysqli->query("CREATE DATABASE IF NOT EXISTS `" . $db_name . "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
        $mysqli->select_db($db_name);
        $mysqli->query("CREATE TABLE IF NOT EXISTS users (
            id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
            username VARCHAR(100) NOT NULL UNIQUE,
            displayname VARCHAR(150) NOT NULL,
            password_hash VARCHAR(255) NOT NULL,
            role ENUM('user','moderator','admin') NOT NULL DEFAULT 'user',
            totp_secret VARCHAR(64) NULL,
            totp_enabled TINYINT(1) DEFAULT 0,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

        $mysqli->query("CREATE TABLE IF NOT EXISTS rooms (
            id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
            name VARCHAR(150) NOT NULL UNIQUE,
            displayname VARCHAR(255) NULL,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

        $mysqli->query("CREATE TABLE IF NOT EXISTS messages (
            id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
            room VARCHAR(150) NOT NULL DEFAULT 'global',
            sender_id INT UNSIGNED NULL,
            sender_username VARCHAR(100) NULL,
            message TEXT NULL,
            attachment VARCHAR(255) NULL,
            flagged TINYINT(1) DEFAULT 0,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

        $mysqli->query("CREATE TABLE IF NOT EXISTS reports (
            id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
            message_id INT UNSIGNED NULL,
            reporter_id INT UNSIGNED NULL,
            reason VARCHAR(255) NULL,
            handled TINYINT(1) DEFAULT 0,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

        $mysqli->query("CREATE TABLE IF NOT EXISTS backup_codes (
            id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
            user_id INT UNSIGNED NOT NULL,
            code_hash VARCHAR(255) NOT NULL,
            used TINYINT(1) DEFAULT 0,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");

        $mysqli->query("INSERT IGNORE INTO rooms (name, displayname) VALUES ('global','Allgemeiner Raum')");
        $mysqli->close();
    }

    file_put_contents(__DIR__ . '/.installed', 'installed on ' . date('c'));
    header('Location: ' . strtok($_SERVER['REQUEST_URI'], '?'));
    exit;
}

session_start();

// CORS strict rules
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
if ($origin && in_array($origin, $production_origins, true)){
    header('Access-Control-Allow-Origin: ' . $origin);
    header('Access-Control-Allow-Credentials: true');
    header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
    header('Access-Control-Allow-Headers: Content-Type, X-CSRF-Token');
} else {
    // for local testing allow localhost
    if ($origin && (strpos($origin, 'localhost') !== false || strpos($origin, '127.0.0.1') !== false)){
        header('Access-Control-Allow-Origin: ' . $origin);
        header('Access-Control-Allow-Credentials: true');
        header('Access-Control-Allow-Methods: POST, GET, OPTIONS');
        header('Access-Control-Allow-Headers: Content-Type, X-CSRF-Token');
    }
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') exit;

$action = $_REQUEST['action'] ?? 'ui';

function db(){
    global $db_host, $db_user, $db_pass, $db_name;
    $m = new mysqli($db_host, $db_user, $db_pass, $db_name);
    if ($m->connect_error){ http_response_code(500); echo json_encode(['error' => 'Datenbankverbindung fehlgeschlagen']); exit; }
    $m->set_charset('utf8mb4');
    return $m;
}

// csrf helpers
function csrf_token(){
    if (empty($_SESSION['csrf_token'])){
        $_SESSION['csrf_token'] = bin2hex(random_bytes(16));
    }
    return $_SESSION['csrf_token'];
}
function csrf_check(){
    $header = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null;
    $post = $_POST['_csrf'] ?? null;
    $token = $header ?: $post;
    if (!$token || !hash_equals($_SESSION['csrf_token'] ?? '', $token)){
        http_response_code(403);
        echo json_encode(['ok' => false, 'msg' => 'Ungültiges CSRF Token']);
        exit;
    }
}

// sanitizers
function clean_username($s){ $s = trim($s); $s = preg_replace('/[^a-zA-Z0-9_\.\@-]/u', '', $s); return substr($s,0,100); }
function clean_text($s, $max = 5000){ $s = trim($s); $s = strip_tags($s); return substr($s,0,$max); }

// TOTP helpers base32 and TOTP algorithm
function base32_encode_secret($bytes){
    $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
    $bits = '';
    foreach (str_split($bytes) as $c) $bits .= str_pad(decbin(ord($c)), 8, '0', STR_PAD_LEFT);
    $out = '';
    while (strlen($bits) >= 5){ $chunk = substr($bits,0,5); $bits = substr($bits,5); $out .= $alphabet[bindec($chunk)]; }
    if (strlen($bits) > 0) $out .= $alphabet[bindec(str_pad($bits,5,'0'))];
    return $out;
}
function hotp($secret, $counter){
    $key = base32_decode($secret);
    $bin_counter = pack('N*', 0) . pack('N*', $counter);
    $hash = hash_hmac('sha1', $bin_counter, $key, true);
    $offset = ord($hash[19]) & 0xf;
    $code = (ord($hash[$offset]) & 0x7f) << 24 | (ord($hash[$offset+1]) & 0xff) << 16 | (ord($hash[$offset+2]) & 0xff) << 8 | (ord($hash[$offset+3]) & 0xff);
    return $code % 1000000;
}
function totp($secret, $timeSlice = null){
    if ($timeSlice === null) $timeSlice = floor(time() / 30);
    return str_pad(hotp($secret, $timeSlice), 6, '0', STR_PAD_LEFT);
}
function base32_decode($b32){
    $b32 = strtoupper($b32);
    $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
    $bits = '';
    foreach (str_split($b32) as $c){ $val = strpos($alphabet, $c); if ($val === false) continue; $bits .= str_pad(decbin($val),5,'0',STR_PAD_LEFT); }
    $bytes = '';
    while (strlen($bits) >= 8){ $chunk = substr($bits,0,8); $bits = substr($bits,8); $bytes .= chr(bindec($chunk)); }
    return $bytes;
}

// optional LDAP auth
$ldap_enabled = false;
$ldap_server = 'ldap://ldap.example.com';
$ldap_base_dn = 'ou=users,dc=example,dc=com';
function ldap_auth($user, $pass){
    global $ldap_server, $ldap_base_dn;
    if (!extension_loaded('ldap')) return false;
    $ldap = ldap_connect($ldap_server);
    ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
    $filter = "(uid=" . ldap_escape($user, '', LDAP_ESCAPE_FILTER) . ")";
    $res = ldap_search($ldap, $ldap_base_dn, $filter);
    $entries = ldap_get_entries($ldap, $res);
    if (empty($entries[0])) return false;
    $dn = $entries[0]['dn'];
    return @ldap_bind($ldap, $dn, $pass);
}

// helper: generate backup codes
function generate_backup_codes($count = 6){
    $codes = [];
    for ($i=0;$i<$count;$i++){
        $codes[] = substr(bin2hex(random_bytes(4)),0,8);
    }
    return $codes;
}
function store_backup_codes($user_id, $codes){
    $mysqli = db();
    $stmt = $mysqli->prepare('INSERT INTO backup_codes (user_id, code_hash) VALUES (?, ?)');
    foreach ($codes as $c){ $hash = password_hash($c, PASSWORD_DEFAULT); $stmt->bind_param('is', $user_id, $hash); $stmt->execute(); }
    $stmt->close(); $mysqli->close();
}
function consume_backup_code($user_id, $code){
    $mysqli = db();
    $res = $mysqli->query('SELECT id, code_hash FROM backup_codes WHERE user_id = ' . intval($user_id) . ' AND used = 0');
    while ($r = $res->fetch_assoc()){
        if (password_verify($code, $r['code_hash'])){
            $stmt = $mysqli->prepare('UPDATE backup_codes SET used = 1 WHERE id = ?'); $stmt->bind_param('i', $r['id']); $stmt->execute(); $stmt->close(); $mysqli->close(); return true;
        }
    }
    $mysqli->close(); return false;
}

// endpoints
if ($action === 'register'){
    csrf_check();
    $username = clean_username($_POST['username'] ?? '');
    $display = trim($_POST['display'] ?? $username);
    $password = $_POST['password'] ?? '';
    if ($username === '' || $password === ''){ echo json_encode(['ok' => false, 'msg' => 'Benutzername und Passwort erforderlich']); exit; }
    if (strlen($password) < 8){ echo json_encode(['ok' => false, 'msg' => 'Passwort zu kurz']); exit; }
    $hash = password_hash($password, PASSWORD_DEFAULT);
    $mysqli = db();
    $stmt = $mysqli->prepare('INSERT INTO users (username, displayname, password_hash) VALUES (?, ?, ?)');
    $stmt->bind_param('sss', $username, $display, $hash);
    $ok = $stmt->execute();
    if (!$ok){ echo json_encode(['ok' => false, 'msg' => 'Benutzer existiert bereits oder fehler']); $stmt->close(); $mysqli->close(); exit; }
    $userid = $mysqli->insert_id;
    $stmt->close(); $mysqli->close();
    $_SESSION['user_id'] = $userid; $_SESSION['username'] = $username; $_SESSION['displayname'] = $display; $_SESSION['role'] = 'user';
    echo json_encode(['ok' => true, 'username' => $username, 'display' => $display]); exit;
}

if ($action === 'login'){
    $username = clean_username($_POST['username'] ?? '');
    $password = $_POST['password'] ?? '';
    // sso header check
    $sso_user = $_SERVER['HTTP_X_SSO_USER'] ?? null;
    if ($sso_user){
        $username = clean_username($sso_user);
        // trust sso then create session if user exists
        $mysqli = db();
        $stmt = $mysqli->prepare('SELECT id, username, displayname, role, totp_enabled FROM users WHERE username = ? LIMIT 1');
        $stmt->bind_param('s', $username);
        $stmt->execute(); $res = $stmt->get_result();
        if ($row = $res->fetch_assoc()){
            $_SESSION['user_id'] = $row['id']; $_SESSION['username'] = $row['username']; $_SESSION['displayname'] = $row['displayname']; $_SESSION['role'] = $row['role'];
            echo json_encode(['ok' => true]); $stmt->close(); $mysqli->close(); exit;
        }
        $stmt->close(); $mysqli->close();
    }
    // ldap fallback
    global $ldap_enabled;
    if ($ldap_enabled){
        if (ldap_auth($username, $password)){
            // create local user if not exists
            $mysqli = db();
            $stmt = $mysqli->prepare('SELECT id, username, displayname, role FROM users WHERE username = ? LIMIT 1');
            $stmt->bind_param('s', $username); $stmt->execute(); $res = $stmt->get_result();
            if ($row = $res->fetch_assoc()){
                $_SESSION['user_id'] = $row['id']; $_SESSION['username'] = $row['username']; $_SESSION['displayname'] = $row['displayname']; $_SESSION['role'] = $row['role'];
                echo json_encode(['ok' => true]); $stmt->close(); $mysqli->close(); exit;
            } else {
                $display = $username;
                $pw = password_hash(random_bytes(16), PASSWORD_DEFAULT);
                $ins = $mysqli->prepare('INSERT INTO users (username, displayname, password_hash) VALUES (?, ?, ?)');
                $ins->bind_param('sss', $username, $display, $pw); $ins->execute();
                $_SESSION['user_id'] = $ins->insert_id; $_SESSION['username'] = $username; $_SESSION['displayname'] = $display; $_SESSION['role'] = 'user';
                echo json_encode(['ok' => true]); $ins->close(); $mysqli->close(); exit;
            }
        }
    }
    // local login
    $mysqli = db();
    $stmt = $mysqli->prepare('SELECT id, username, displayname, password_hash, role, totp_enabled, totp_secret FROM users WHERE username = ? LIMIT 1');
    $stmt->bind_param('s', $username); $stmt->execute(); $res = $stmt->get_result();
    if ($row = $res->fetch_assoc()){
        if (password_verify($password, $row['password_hash'])){
            // if user has totp enabled require code
            if (intval($row['totp_enabled']) === 1){
                // store temp flag and prompt for code
                $_SESSION['tmp_user_id'] = $row['id']; $_SESSION['tmp_username'] = $row['username'];
                echo json_encode(['ok' => true, 'require_totp' => true]);
            } else {
                $_SESSION['user_id'] = $row['id']; $_SESSION['username'] = $row['username']; $_SESSION['displayname'] = $row['displayname']; $_SESSION['role'] = $row['role'];
                echo json_encode(['ok' => true]);
            }
        } else echo json_encode(['ok' => false, 'msg' => 'Ungültige Zugangsdaten']);
    } else echo json_encode(['ok' => false, 'msg' => 'Ungültige Zugangsdaten']);
    $stmt->close(); $mysqli->close(); exit;
}

// verify totp code after password
if ($action === 'verify_totp'){
    $code = trim($_POST['code'] ?? '');
    if (empty($_SESSION['tmp_user_id'])){ echo json_encode(['ok' => false, 'msg' => 'Keine Authentifizierung ausstehend']); exit; }
    $uid = intval($_SESSION['tmp_user_id']);
    $mysqli = db();
    $stmt = $mysqli->prepare('SELECT id, username, totp_secret FROM users WHERE id = ? LIMIT 1');
    $stmt->bind_param('i', $uid); $stmt->execute(); $res = $stmt->get_result();
    if ($row = $res->fetch_assoc()){
        $secret = $row['totp_secret'];
        if ($secret && (totp($secret) === $code || consume_backup_code($uid, $code))){
            // complete login
            $_SESSION['user_id'] = $row['id']; $_SESSION['username'] = $row['username']; // load role
            $r2 = $mysqli->prepare('SELECT role, displayname FROM users WHERE id = ?'); $r2->bind_param('i', $uid); $r2->execute(); $rr = $r2->get_result()->fetch_assoc(); $_SESSION['role'] = $rr['role']; $_SESSION['displayname'] = $rr['displayname'];
            unset($_SESSION['tmp_user_id'], $_SESSION['tmp_username']);
            echo json_encode(['ok' => true]);
        } else echo json_encode(['ok' => false, 'msg' => 'Ungültiger Code oder Backup Code']);
    } else echo json_encode(['ok' => false, 'msg' => 'Benutzer nicht gefunden']);
    $stmt->close(); $mysqli->close(); exit;
}

// generate and email backup codes
if ($action === 'generate_backup_codes'){
    if (!isset($_SESSION['user_id'])){ echo json_encode(['ok' => false, 'msg' => 'Nicht angemeldet']); exit; }
    csrf_check();
    $uid = intval($_SESSION['user_id']);
    $codes = generate_backup_codes(8);
    store_backup_codes($uid, $codes);
    // try to send email to user - assumes users have email column; if not, admin can fetch codes
    $mysqli = db(); $stmt = $mysqli->prepare('SELECT displayname FROM users WHERE id = ?'); $stmt->bind_param('i', $uid); $stmt->execute(); $res = $stmt->get_result(); $emailtext = implode("
", $codes); $stmt->close(); $mysqli->close();
    // NOTE: mail() may not be configured. Adjust to your mail system.
    @mail('admin@example.com', 'Your Backup Codes', "Backup Codes:

" . $emailtext);
    echo json_encode(['ok' => true, 'codes' => $codes]); exit;
}

if ($action === 'logout'){
    session_unset(); session_destroy(); echo json_encode(['ok' => true]); exit;
}

// upload with thumbnail generation
if ($action === 'upload'){
    if (!isset($_SESSION['user_id'])){ echo json_encode(['ok' => false, 'msg' => 'Nicht angemeldet']); exit; }
    csrf_check();
    if (empty($_FILES['file'])){ echo json_encode(['ok' => false, 'msg' => 'Keine Datei']); exit; }
    $file = $_FILES['file']; if ($file['error'] !== UPLOAD_ERR_OK){ echo json_encode(['ok' => false, 'msg' => 'Upload Fehler']); exit; }
    global $upload_max_filesize_bytes, $allowed_upload_types;
    if ($file['size'] > $upload_max_filesize_bytes){ echo json_encode(['ok' => false, 'msg' => 'Datei zu groß']); exit; }
    $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo);
    if (!in_array($mime, $allowed_upload_types, true)){ echo json_encode(['ok' => false, 'msg' => 'Dateityp nicht erlaubt']); exit; }
    $ext = pathinfo($file['name'], PATHINFO_EXTENSION); $safe = bin2hex(random_bytes(12)) . '.' . $ext; $dest = __DIR__ . '/uploads/' . $safe;
    if (!move_uploaded_file($file['tmp_name'], $dest)){ echo json_encode(['ok' => false, 'msg' => 'Konnte Datei nicht speichern']); exit; }
    $thumb = null;
    if (strpos($mime, 'image/') === 0 && extension_loaded('gd')){
        $thumbname = 'thumb_' . $safe;
        $thumbpath = __DIR__ . '/uploads/' . $thumbname;
        list($w, $h) = getimagesize($dest);
        $nw = 240; $nh = intval($h * $nw / $w);
        $dst = imagecreatetruecolor($nw, $nh);
        if ($mime === 'image/jpeg'){ $src = imagecreatefromjpeg($dest); }
        elseif ($mime === 'image/png'){ $src = imagecreatefrompng($dest); }
        elseif ($mime === 'image/gif'){ $src = imagecreatefromgif($dest); }
        else $src = null;
        if ($src){ imagecopyresampled($dst, $src, 0,0,0,0, $nw, $nh, $w, $h); imagejpeg($dst, $thumbpath, 80); imagedestroy($dst); imagedestroy($src); $thumb = 'uploads/' . $thumbname; }
    }
    echo json_encode(['ok' => true, 'file' => 'uploads/' . $safe, 'thumb' => $thumb, 'mime' => $mime]); exit;
}

// automatic moderation check helper
function moderation_check($text){
    global $banned_words, $auto_flag_threshold;
    $found = 0;
    foreach ($banned_words as $w){ if (stripos($text, $w) !== false) $found++; }
    return $found >= $auto_flag_threshold;
}

// send message with IPC to websocket server and moderation & reporting
if ($action === 'send'){
    if (!isset($_SESSION['user_id'])){ echo json_encode(['ok' => false, 'msg' => 'Nicht angemeldet']); exit; }
    csrf_check();
    $room = clean_username($_POST['room'] ?? 'global');
    $msg = clean_text($_POST['msg'] ?? '');
    $attachment = null; if (!empty($_POST['attachment'])) $attachment = basename($_POST['attachment']);
    if ($msg === '' && $attachment === null){ echo json_encode(['ok' => false, 'msg' => 'Nachricht leer']); exit; }
    $flagged = moderation_check($msg) ? 1 : 0;
    $mysqli = db();
    $stmt = $mysqli->prepare('INSERT INTO messages (room, sender_id, sender_username, message, attachment, flagged) VALUES (?, ?, ?, ?, ?, ?)');
    $uid = intval($_SESSION['user_id']); $uname = $_SESSION['username'];
    $stmt->bind_param('sisssi', $room, $uid, $uname, $msg, $attachment, $flagged);
    $stmt->execute(); $id = $mysqli->insert_id; $stmt->close();
    if ($flagged){
        $rep = $mysqli->prepare('INSERT INTO reports (message_id, reporter_id, reason) VALUES (?, ?, ?)');
        $reason = 'Automatische Moderation'; $rep->bind_param('iis', $id, $uid, $reason); $rep->execute(); $rep->close();
    }
    $mysqli->close();
    // json backup
    $file = __DIR__ . '/json/messages.json'; $arr = json_decode(file_get_contents($file), true); if (!is_array($arr)) $arr = [];
    $arr[] = ['id' => $id, 'room' => $room, 'sender_id' => $uid, 'sender_username' => $uname, 'message' => $msg, 'attachment' => $attachment, 'flagged' => $flagged, 'created_at' => date('c')];
    file_put_contents($file, json_encode($arr, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
    // send notification to websocket server via unix domain socket if available
    global $ipc_socket;
    if (file_exists($ipc_socket)){
        $sock = socket_create(AF_UNIX, SOCK_STREAM, 0);
        if ($sock && @socket_connect($sock, $ipc_socket)){
            $payload = json_encode(['type' => 'message', 'room' => $room, 'username' => $uname, 'message' => $msg]);
            @socket_write($sock, $payload . "
");
            socket_close($sock);
        }
    }
    echo json_encode(['ok' => true, 'id' => $id, 'flagged' => $flagged]); exit;
}

// report message endpoint (user reports a message)
if ($action === 'report'){
    if (!isset($_SESSION['user_id'])){ echo json_encode(['ok' => false, 'msg' => 'Nicht angemeldet']); exit; }
    csrf_check(); $mid = intval($_POST['message_id'] ?? 0); $reason = clean_text($_POST['reason'] ?? ''); if (!$mid) { echo json_encode(['ok' => false]); exit; }
    $mysqli = db(); $stmt = $mysqli->prepare('INSERT INTO reports (message_id, reporter_id, reason) VALUES (?, ?, ?)'); $uid = intval($_SESSION['user_id']); $stmt->bind_param('iis', $mid, $uid, $reason); $stmt->execute(); $stmt->close(); $mysqli->close(); echo json_encode(['ok' => true]); exit;
}

// fetch messages
if ($action === 'fetch'){
    if (!isset($_SESSION['user_id'])){ echo json_encode(['ok' => false, 'msg' => 'Nicht angemeldet']); exit; }
    $room = clean_username($_GET['room'] ?? 'global'); $limit = intval($_GET['limit'] ?? 200);
    $mysqli = db();
    $stmt = $mysqli->prepare('SELECT id, room, sender_username, message, attachment, flagged, created_at FROM messages WHERE room = ? ORDER BY id DESC LIMIT ?');
    $stmt->bind_param('si', $room, $limit); $stmt->execute(); $res = $stmt->get_result(); $out = [];
    while ($r = $res->fetch_assoc()) $out[] = $r; $stmt->close(); $mysqli->close();
    echo json_encode(array_reverse($out)); exit;
}

// rooms
if ($action === 'rooms'){
    $mysqli = db(); $res = $mysqli->query('SELECT name, displayname FROM rooms ORDER BY displayname ASC'); $out = [];
    while ($r = $res->fetch_assoc()) $out[] = $r; $mysqli->close(); echo json_encode($out); exit;
}

// admin endpoints
function require_role($role){ if (!isset($_SESSION['role'])){ http_response_code(403); echo json_encode(['ok' => false, 'msg' => 'Forbidden']); exit; } if ($role === 'admin' && $_SESSION['role'] !== 'admin'){ http_response_code(403); echo json_encode(['ok' => false, 'msg' => 'Forbidden']); exit; } }
if ($action === 'admin_list_users'){
    require_role('admin'); $mysqli = db(); $res = $mysqli->query('SELECT id, username, displayname, role, totp_enabled, created_at FROM users ORDER BY created_at DESC LIMIT 1000'); $out = [];
    while ($r = $res->fetch_assoc()) $out[] = $r; $mysqli->close(); echo json_encode($out); exit;
}
if ($action === 'admin_delete_message'){
    require_role('admin'); csrf_check(); $id = intval($_POST['id'] ?? 0); if (!$id){ echo json_encode(['ok' => false]); exit; }
    $mysqli = db(); $stmt = $mysqli->prepare('DELETE FROM messages WHERE id = ?'); $stmt->bind_param('i', $id); $stmt->execute(); $stmt->close(); $mysqli->close(); echo json_encode(['ok' => true]); exit;
}
// admin set role
if ($action === 'admin_set_role'){
    require_role('admin'); csrf_check(); $uid = intval($_POST['id'] ?? 0); $role = $_POST['role'] ?? 'user'; if (!in_array($role, ['user','moderator','admin'])) $role = 'user'; $mysqli = db(); $stmt = $mysqli->prepare('UPDATE users SET role = ? WHERE id = ?'); $stmt->bind_param('si', $role, $uid); $stmt->execute(); $stmt->close(); $mysqli->close(); echo json_encode(['ok' => true]); exit;
}
// admin reports list
if ($action === 'admin_reports'){
    require_role('admin'); $mysqli = db(); $res = $mysqli->query('SELECT r.id, r.message_id, r.reporter_id, r.reason, r.handled, r.created_at, m.message, m.sender_username FROM reports r LEFT JOIN messages m ON r.message_id = m.id ORDER BY r.created_at DESC LIMIT 500'); $out = [];
    while ($r = $res->fetch_assoc()) $out[] = $r; $mysqli->close(); echo json_encode($out); exit;
}
// admin mark report handled
if ($action === 'admin_handle_report'){
    require_role('admin'); csrf_check(); $id = intval($_POST['id'] ?? 0); if (!$id){ echo json_encode(['ok' => false]); exit; }
    $mysqli = db(); $stmt = $mysqli->prepare('UPDATE reports SET handled = 1 WHERE id = ?'); $stmt->bind_param('i', $id); $stmt->execute(); $stmt->close(); $mysqli->close(); echo json_encode(['ok' => true]); exit;
}

// statistics endpoint for admin
if ($action === 'admin_stats'){
    require_role('admin'); $mysqli = db();
    $res1 = $mysqli->query('SELECT COUNT(*) AS users FROM users'); $users = $res1->fetch_assoc()['users'];
    $res2 = $mysqli->query("SELECT room, COUNT(*) AS cnt FROM messages GROUP BY room ORDER BY cnt DESC LIMIT 10"); $rooms = []; while ($r = $res2->fetch_assoc()) $rooms[] = $r;
    // storage usage
    $size = 0; foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator(__DIR__ . '/uploads', FilesystemIterator::SKIP_DOTS)) as $f){ $size += $f->getSize(); }
    $mysqli->close(); echo json_encode(['users' => intval($users), 'rooms' => $rooms, 'storage_bytes' => $size]); exit;
}

// ui main with admin dashboard embedded
if ($action === 'ui'){
    $me = $_SESSION['username'] ?? ''; $display = $_SESSION['displayname'] ?? $me; $csrf = csrf_token(); $role = $_SESSION['role'] ?? '';
    ?>
    <!doctype html>
    <html lang="de">
    <head>
        <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
        <title>Dreamcodes InfinityChat</title>
        <style>
            :root{ --bg:#f5f7fb; --accent:#0b64d4; --card:#fff; --muted:#6b7280 }
            html,body{height:100%;margin:0;font-family:Inter,system-ui,Arial;background:var(--bg)}
            .wrap{display:grid;grid-template-columns:320px 1fr;height:100vh;gap:28px;padding:28px}
            .panel{background:var(--card);border-radius:12px;box-shadow:0 6px 18px rgba(15,23,42,0.06);overflow:hidden}
            .sidebar{padding:18px;height:calc(100vh - 56px);box-sizing:border-box}
            .brand h1{margin:0;font-size:18px}
            .contacts{overflow:auto;max-height:calc(100% - 180px)}
            .contact{padding:10px;border-radius:8px;cursor:pointer;display:flex;justify-content:space-between;align-items:center}
            .header{padding:18px;display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #eef2ff}
            .chatbox{padding:18px;flex:1;overflow:auto}
            .composer{padding:14px;border-top:1px solid #eef2ff;display:flex;gap:10px;align-items:center}
            .msg{max-width:70%;padding:10px 12px;border-radius:10px;margin:8px 0}
            .msg.me{margin-left:auto;background:linear-gradient(180deg,#daf6ff,#bfefff)}
            .msg.they{margin-right:auto;background:#f7f8fb}
            .footer{padding:10px 0;text-align:center;color:var(--muted);font-size:13px}
            .admin-panel{padding:16px}
            table{width:100%;border-collapse:collapse}
            th,td{padding:8px;border-bottom:1px solid #eee;text-align:left}
            .btn{padding:6px 8px;border-radius:6px;border:1px solid #ddd;background:#fff;cursor:pointer}
            .stats{display:flex;gap:12px;margin-bottom:12px}
            .stat-card{background:#f8fafc;padding:12px;border-radius:8px;flex:1}
        </style>
    </head>
    <body>
    <div class="wrap">
        <div class="panel sidebar">
            <div class="sidebar">
                <div class="brand"><div><h1>Dreamcodes InfinityChat</h1><div style="font-size:13px;color:#6b7280">Angemeldet als <strong id="meName"><?php echo htmlspecialchars($display); ?></strong></div></div></div>
                <div style="margin:12px 0"><input id="roomFilter" placeholder="Raum wählen oder neu erstellen" style="width:100%;padding:10px;border-radius:8px;border:1px solid #e6eefc"></div>
                <div style="display:flex;gap:8px;margin-bottom:10px"><button id="btnNewRoom" class="btn">Neuen Raum</button><button id="btnUsers" class="btn">Benutzer</button></div>
                <div class="contacts" id="roomList"></div>
                <?php if ($role === 'admin'){ echo '<div style="margin-top:12px"><button id="adminBtn" class="btn">Admin Bereich</button></div>'; } ?>
                <div style="margin-top:12px"><a href="#" id="logoutBtn">Abmelden</a></div>
            </div>
        </div>
        <div class="panel main">
            <div class="header"><div><h2 id="roomTitle">Wähle einen Raum</h2><div style="font-size:13px;color:#6b7280" id="roomSub">Tippe auf einen Raum um zu starten</div></div><div><input id="fileInput" type="file" style="display:none"><button id="attachBtn" class="btn">Anhang</button></div></div>
            <div class="chatbox" id="chatWindow"><div style="font-size:13px;color:#6b7280">Noch keine Unterhaltung</div></div>
            <div class="composer"><input type="text" id="msgInput" placeholder="Nachricht eingeben" style="flex:1;padding:10px;border-radius:8px;border:1px solid #e6eefc"><button id="sendBtn" class="btn">Senden</button></div>
            <div class="footer">
    © <?php echo date("Y"); ?> 
    <a href="http://www.dreamcodes.net" target="_blank">Dreamcodes</a> — 
    Besuche auch <a href="<?php echo $site_footer; ?>" target="_blank"><?php echo $site_footer; ?></a>
</div>
        </div>
    </div>
    <script>
        const csrf = '<?php echo $csrf; ?>';
        let me = '<?php echo addslashes($me); ?>'; let room = 'global'; let attachment = null; let role = '<?php echo $role; ?>';
        async function api(path, data, method = 'GET'){
            if (method === 'GET'){
                const q = new URLSearchParams(data).toString(); const res = await fetch(path + '?' + q, { credentials: 'include' }); return res.json();
            } else {
                const fd = new FormData(); for (const k in data) fd.append(k, data[k]); fd.append('_csrf', csrf); const res = await fetch(path, { method: 'POST', body: fd, credentials: 'include' }); return res.json();
            }
        }
        async function loadRooms(){ const r = await api('?', { action: 'rooms' }); const list = document.getElementById('roomList'); list.innerHTML = ''; r.forEach(x => { const el = document.createElement('div'); el.className = 'contact'; el.textContent = x.displayname + ' (' + x.name + ')'; el.onclick = () => { selectRoom(x.name, x.displayname); }; list.appendChild(el); }); }
        async function selectRoom(name, display){ room = name; document.getElementById('roomTitle').textContent = display; fetchMessages(); joinWsRoom(); }
        async function fetchMessages(){ const res = await api('?', { action: 'fetch', room: room, limit: 200 }); const w = document.getElementById('chatWindow'); w.innerHTML = ''; res.forEach(m => { const d = document.createElement('div'); d.className = 'msg ' + ((m.sender_username === me) ? 'me' : 'they'); let html = '<div style="font-size:0.85rem;margin-bottom:6px"><strong>' + m.sender_username + '</strong></div>'; html += '<div>' + escapeHtml(m.message || '') + '</div>'; if (m.attachment) html += '<div><a href="' + m.attachment + '" target="_blank">Anhang öffnen</a></div>'; if (m.flagged) html += '<div style="color:#b91c1c;font-weight:600">Diese Nachricht wurde markiert</div>'; html += '<div style="font-size:12px;color:#8b93a7">' + m.created_at + '</div>'; d.innerHTML = html; w.appendChild(d); }); w.scrollTop = w.scrollHeight; }
        function escapeHtml(s){ return s.replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;'); }
        document.getElementById('sendBtn').onclick = async function(){ const msg = document.getElementById('msgInput').value.trim(); if (!msg && !attachment) return; const res = await api('?', { action: 'send', room: room, msg: msg, attachment: attachment }, 'POST'); if (res.ok){ document.getElementById('msgInput').value = ''; attachment = null; fetchMessages(); sendWsMessage({ type: 'message', room: room, message: msg }); if (res.flagged) alert('Hinweis: Ihre Nachricht wurde automatisch markiert und wird überprüft.'); } else alert(res.msg || 'Fehler'); };
        document.getElementById('attachBtn').onclick = function(){ document.getElementById('fileInput').click(); };
        document.getElementById('fileInput').addEventListener('change', async function(e){ const f = e.target.files[0]; if (!f) return; if (f.size > <?php echo $upload_max_filesize_bytes; ?>) { alert('Datei zu groß'); return; } const fd = new FormData(); fd.append('action','upload'); fd.append('file', f); fd.append('_csrf', csrf); const res = await fetch('?', { method: 'POST', body: fd, credentials: 'include' }); const j = await res.json(); if (j.ok) { attachment = j.file; alert('Upload erfolgreich'); } else alert(j.msg || 'Upload fehlgeschlagen'); });
        document.getElementById('btnNewRoom').onclick = async function(){ const name = prompt('Name des neuen raums'); if (!name) return; const dn = prompt('Anzeigename'); const fd = new FormData(); fd.append('action','create_room'); fd.append('name', name); fd.append('displayname', dn || name); fd.append('_csrf', csrf); const res = await fetch('?', { method: 'POST', body: fd, credentials: 'include' }); const j = await res.json(); if (j.ok) loadRooms(); else alert(j.msg || 'Fehler'); };
        document.getElementById('logoutBtn').onclick = async function(){ const r = await api('?', { action: 'logout' }); if (r.ok) location.reload(); };
        // admin button
        document.getElementById('adminBtn')?.addEventListener('click', async function(){ if (role !== 'admin') return; const main = document.querySelector('.panel.main'); main.innerHTML = '<div class="admin-panel"><h2>Admin Dashboard</h2><div class="stats"><div class="stat-card" id="statUsers">Lade...</div><div class="stat-card" id="statStorage">Lade...</div><div class="stat-card" id="statRooms">Lade...</div></div><div id="adminContent">Lade Berichte...</div></div>'; const s = await api('?', { action: 'admin_stats' }); document.getElementById('statUsers').innerHTML = '<strong>Benutzer</strong><div>' + s.users + '</div>'; document.getElementById('statStorage').innerHTML = '<strong>Speicher</strong><div>' + Math.round(s.storage_bytes/1024) + ' KB</div>'; let roomsHtml = '<strong>Top Räume</strong><ul>'; s.rooms.forEach(r => { roomsHtml += '<li>' + r.room + ': ' + r.cnt + '</li>'; }); roomsHtml += '</ul>'; document.getElementById('statRooms').innerHTML = roomsHtml; const reports = await api('?', { action: 'admin_reports' }); let html = '<h3>Meldungen</h3><table><tr><th>ID</th><th>Nachricht</th><th>Grund</th><th>Reporter</th><th>Aktion</th></tr>'; reports.forEach(r => { html += '<tr><td>' + r.id + '</td><td>' + (r.message ? r.message.substring(0,80) : '') + '</td><td>' + r.reason + '</td><td>' + r.reporter_id + '</td><td><button class="handleBtn" data-id="'+r.id+'">Erledigt</button></td></tr>'; }); html += '</table>'; document.getElementById('adminContent').innerHTML = html; document.querySelectorAll('.handleBtn').forEach(b => b.addEventListener('click', async function(){ const id = this.getAttribute('data-id'); const res = await api('?', { action: 'admin_handle_report', id: id }, 'POST'); if (res.ok) this.parentElement.parentElement.style.opacity = 0.5; else alert('Fehler'); })); });
        
        // websocket client
        let ws = null;
        function connectWs(){
            const proto = location.protocol === 'https:' ? 'wss' : 'ws';
            ws = new WebSocket(proto + '://' + location.hostname + ':9000');
            ws.onopen = function(){ console.log('ws open'); joinWsRoom(); };
            ws.onmessage = function(e){ try { const d = JSON.parse(e.data); if (d.type === 'message') fetchMessages(); } catch(err){} };
            ws.onclose = function(){ setTimeout(connectWs, 3000); };
        }
        function joinWsRoom(){ if (!ws || ws.readyState !== 1) return; ws.send(JSON.stringify({ type: 'join', room: room, username: me })); }
        function sendWsMessage(obj){ if (!ws || ws.readyState !== 1) return; ws.send(JSON.stringify(obj)); }
        connectWs();
        setInterval(function(){ if (room) fetchMessages(); }, 5000);
        loadRooms();
    </script>
    </body>
    </html>
    <?php
    exit;
}

// create room
if ($action === 'create_room'){
    if (!isset($_SESSION['user_id'])){ echo json_encode(['ok' => false, 'msg' => 'Nicht angemeldet']); exit; }
    csrf_check(); $name = clean_username($_POST['name'] ?? ''); $displayname = clean_text($_POST['displayname'] ?? $name, 200); if ($name === ''){ echo json_encode(['ok' => false, 'msg' => 'Name erforderlich']); exit; }
    $mysqli = db(); $stmt = $mysqli->prepare('INSERT IGNORE INTO rooms (name, displayname) VALUES (?, ?)'); $stmt->bind_param('ss', $name, $displayname); $ok = $stmt->execute(); if (!$ok) echo json_encode(['ok' => false, 'msg' => 'Fehler']); else echo json_encode(['ok' => true]); $stmt->close(); $mysqli->close(); exit;
}

// admin delete user endpoint
if ($action === 'admin_delete_user'){
    require_role('admin'); csrf_check(); $id = intval($_POST['id'] ?? 0); if (!$id){ echo json_encode(['ok' => false]); exit; }
    $mysqli = db(); $stmt = $mysqli->prepare('DELETE FROM users WHERE id = ?'); $stmt->bind_param('i', $id); $stmt->execute(); $stmt->close(); $mysqli->close(); echo json_encode(['ok' => true]); exit;
}

http_response_code(404); echo json_encode(['ok' => false, 'msg' => 'Nicht gefunden']);

?>
Vorheriges Tutorial
Nächstes Tutorial

Hier etwas für dich dabei?