Merge branch 'main' into codex/implement-new-chat-age-restrictions-b4p9qa
This commit is contained in:
@@ -0,0 +1,469 @@
|
|||||||
|
<?php
|
||||||
|
$host = $_GET['host'] ?? '';
|
||||||
|
$traceData = [];
|
||||||
|
$error = '';
|
||||||
|
$rawOutput = '';
|
||||||
|
|
||||||
|
if ($host !== '') {
|
||||||
|
$sanitizedHost = preg_replace('/[^A-Za-z0-9\-\.:]/', '', $host);
|
||||||
|
if ($sanitizedHost === '') {
|
||||||
|
$error = 'Bitte geben Sie einen gültigen Hostnamen oder eine IP-Adresse ein.';
|
||||||
|
} else {
|
||||||
|
$command = 'traceroute -n ' . escapeshellarg($sanitizedHost) . ' 2>&1';
|
||||||
|
$rawOutput = shell_exec($command);
|
||||||
|
if ($rawOutput === null) {
|
||||||
|
$error = 'Traceroute konnte nicht ausgeführt werden. Ist das Kommando verfügbar?';
|
||||||
|
} else {
|
||||||
|
$traceData = parseTraceroute($rawOutput);
|
||||||
|
if (empty($traceData)) {
|
||||||
|
$error = 'Keine Hops gefunden. Prüfen Sie den Hostnamen oder versuchen Sie es später erneut.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($traceData)) {
|
||||||
|
$traceData = getSampleTrace();
|
||||||
|
if ($error === '') {
|
||||||
|
$error = 'Es werden Beispieldaten angezeigt. Starten Sie eine Abfrage, um echte Traceroute-Daten zu sehen.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTraceroute(string $raw): array
|
||||||
|
{
|
||||||
|
$lines = preg_split('/\r?\n/', trim($raw));
|
||||||
|
if (!$lines) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$hops = [];
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if (preg_match('/^\s*\d+\s+/', $line) !== 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
preg_match_all('/(\d+\.\d+)\s+ms/', $line, $latencyMatches);
|
||||||
|
$latencies = array_map('floatval', $latencyMatches[1] ?? []);
|
||||||
|
$avgLatency = !empty($latencies) ? array_sum($latencies) / count($latencies) : null;
|
||||||
|
|
||||||
|
if (preg_match('/^\s*(\d+)\s+([0-9\.\*]+)/', $line, $parts) !== 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hopNumber = (int) $parts[1];
|
||||||
|
$ip = $parts[2];
|
||||||
|
if ($ip === '*') {
|
||||||
|
$ip = 'Zeitüberschreitung';
|
||||||
|
}
|
||||||
|
|
||||||
|
$hops[] = [
|
||||||
|
'hop' => $hopNumber,
|
||||||
|
'ip' => $ip,
|
||||||
|
'avgLatency' => $avgLatency,
|
||||||
|
'raw' => trim($line),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $hops;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSampleTrace(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['hop' => 1, 'ip' => '192.168.0.1', 'avgLatency' => 1.2, 'raw' => '1 192.168.0.1 1.123 ms 1.234 ms 1.301 ms'],
|
||||||
|
['hop' => 2, 'ip' => '10.12.34.1', 'avgLatency' => 9.4, 'raw' => '2 10.12.34.1 9.123 ms 9.567 ms 9.400 ms'],
|
||||||
|
['hop' => 3, 'ip' => '172.16.5.4', 'avgLatency' => 18.7, 'raw' => '3 172.16.5.4 18.432 ms 18.913 ms 18.787 ms'],
|
||||||
|
['hop' => 4, 'ip' => '203.0.113.5', 'avgLatency' => 32.9, 'raw' => '4 203.0.113.5 32.113 ms 33.441 ms 33.212 ms'],
|
||||||
|
['hop' => 5, 'ip' => '93.184.216.34', 'avgLatency' => 48.2, 'raw' => '5 93.184.216.34 48.112 ms 48.501 ms 48.032 ms'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePositions(array $trace): array
|
||||||
|
{
|
||||||
|
$positions = [];
|
||||||
|
$radius = 25;
|
||||||
|
$spacing = 10;
|
||||||
|
foreach ($trace as $index => $hop) {
|
||||||
|
$angle = $index * 0.9;
|
||||||
|
$positions[] = [
|
||||||
|
'x' => cos($angle) * $radius,
|
||||||
|
'y' => $index * $spacing,
|
||||||
|
'z' => sin($angle) * $radius,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
$positions = generatePositions($traceData);
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>3D Traceroute Visualisierung</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: "Segoe UI", Roboto, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
background: linear-gradient(135deg, #1d4ed8, #312e81);
|
||||||
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.6);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#canvas-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
#scene {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
#panel {
|
||||||
|
width: 320px;
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
border-left: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
padding: 1.5rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
input[type="text"] {
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
background: rgba(15, 23, 42, 0.4);
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
background: linear-gradient(135deg, #22d3ee, #3b82f6);
|
||||||
|
color: #0f172a;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 12px 24px rgba(34, 211, 238, 0.3);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
background: rgba(248, 113, 113, 0.1);
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||||
|
color: #fecaca;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.info-box {
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid rgba(94, 234, 212, 0.3);
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(15, 118, 110, 0.15);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.hop-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.hop-card {
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||||
|
background: rgba(30, 41, 59, 0.7);
|
||||||
|
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.hop-card.active {
|
||||||
|
border-color: rgba(59, 130, 246, 0.8);
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
.hop-card h3 {
|
||||||
|
margin: 0 0 0.3rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.hop-card p {
|
||||||
|
margin: 0.2rem 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #cbd5f5;
|
||||||
|
}
|
||||||
|
#camera-progress {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.slider-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-top: -0.25rem;
|
||||||
|
}
|
||||||
|
.search-wrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.search-wrapper input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>3D Traceroute Explorer</h1>
|
||||||
|
<p>Visualisieren Sie Netzwerkpfade im dreidimensionalen Raum und erkunden Sie die einzelnen Hops.</p>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<div id="canvas-container">
|
||||||
|
<canvas id="scene"></canvas>
|
||||||
|
</div>
|
||||||
|
<aside id="panel">
|
||||||
|
<form method="get">
|
||||||
|
<div>
|
||||||
|
<label for="host">Zielhost</label>
|
||||||
|
<input id="host" name="host" type="text" placeholder="z.B. example.com oder 8.8.8.8" value="<?= htmlspecialchars($host, ENT_QUOTES) ?>" />
|
||||||
|
</div>
|
||||||
|
<button type="submit">Traceroute starten</button>
|
||||||
|
</form>
|
||||||
|
<?php if ($error !== ''): ?>
|
||||||
|
<div class="error"><?= htmlspecialchars($error, ENT_QUOTES) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="info-box">
|
||||||
|
<strong>Interaktive Steuerung</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Drehen: Linke Maustaste</li>
|
||||||
|
<li>Schwenken: Rechte Maustaste</li>
|
||||||
|
<li>Zoom: Mausrad</li>
|
||||||
|
<li>Knoten anklicken für Details</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="search-wrapper">
|
||||||
|
<input type="text" id="search" placeholder="Hop oder IP suchen..." list="node-list" />
|
||||||
|
<button type="button" id="search-btn">Suchen</button>
|
||||||
|
</div>
|
||||||
|
<datalist id="node-list">
|
||||||
|
<?php foreach ($traceData as $hop): ?>
|
||||||
|
<option value="Hop <?= $hop['hop'] ?>"></option>
|
||||||
|
<option value="<?= $hop['ip'] ?>"></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</datalist>
|
||||||
|
<div style="margin: 1.5rem 0 0.5rem;">
|
||||||
|
<label for="camera-progress">Pfad erkunden</label>
|
||||||
|
<input id="camera-progress" type="range" min="0" max="<?= max(count($traceData) - 1, 1) ?>" step="0.01" value="0" />
|
||||||
|
<div class="slider-label">
|
||||||
|
<span>Start</span>
|
||||||
|
<span>Ende</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section class="hop-list" id="hop-list">
|
||||||
|
<?php foreach ($traceData as $index => $hop): ?>
|
||||||
|
<article class="hop-card" data-hop-index="<?= $index ?>">
|
||||||
|
<h3>Hop <?= $hop['hop'] ?></h3>
|
||||||
|
<p><strong>IP:</strong> <?= htmlspecialchars($hop['ip'], ENT_QUOTES) ?></p>
|
||||||
|
<p><strong>Ø Latenz:</strong> <?= $hop['avgLatency'] !== null ? number_format($hop['avgLatency'], 2) . ' ms' : 'Keine Daten' ?></p>
|
||||||
|
<p class="raw"><?= htmlspecialchars($hop['raw'], ENT_QUOTES) ?></p>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const traceData = <?= json_encode($traceData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ?>;
|
||||||
|
const positions = <?= json_encode($positions, JSON_PRETTY_PRINT) ?>;
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.157.0/build/three.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/three@0.157.0/examples/js/controls/OrbitControls.js"></script>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const canvas = document.getElementById('scene');
|
||||||
|
const renderer = new THREE.WebGLRenderer({canvas, antialias: true});
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
|
renderer.setSize(canvas.clientWidth, canvas.clientHeight, false);
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x020617);
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(60, canvas.clientWidth / canvas.clientHeight, 0.1, 1000);
|
||||||
|
camera.position.set(60, 60, 60);
|
||||||
|
|
||||||
|
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
|
||||||
|
const light = new THREE.PointLight(0xffffff, 1.2);
|
||||||
|
light.position.set(50, 50, 50);
|
||||||
|
scene.add(light);
|
||||||
|
scene.add(new THREE.AmbientLight(0x4c566a, 0.6));
|
||||||
|
|
||||||
|
const nodeMaterial = new THREE.MeshStandardMaterial({color: 0x38bdf8, emissive: 0x0f172a});
|
||||||
|
const targetMaterial = new THREE.MeshStandardMaterial({color: 0x22d3ee, emissive: 0x164e63});
|
||||||
|
const startMaterial = new THREE.MeshStandardMaterial({color: 0x60a5fa, emissive: 0x312e81});
|
||||||
|
|
||||||
|
const nodeGeometry = new THREE.SphereGeometry(1.5, 32, 32);
|
||||||
|
const nodes = [];
|
||||||
|
|
||||||
|
const lineMaterial = new THREE.LineBasicMaterial({color: 0x1d4ed8, linewidth: 2});
|
||||||
|
const linePoints = [];
|
||||||
|
|
||||||
|
positions.forEach((pos, index) => {
|
||||||
|
const material = index === 0 ? startMaterial : (index === positions.length - 1 ? targetMaterial : nodeMaterial);
|
||||||
|
const mesh = new THREE.Mesh(nodeGeometry, material.clone());
|
||||||
|
mesh.position.set(pos.x, pos.y, pos.z);
|
||||||
|
mesh.userData = {...traceData[index], index};
|
||||||
|
scene.add(mesh);
|
||||||
|
nodes.push(mesh);
|
||||||
|
linePoints.push(new THREE.Vector3(pos.x, pos.y, pos.z));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (linePoints.length > 1) {
|
||||||
|
const lineGeometry = new THREE.BufferGeometry().setFromPoints(linePoints);
|
||||||
|
const line = new THREE.Line(lineGeometry, lineMaterial);
|
||||||
|
scene.add(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
const particleCount = 2000;
|
||||||
|
const particleGeometry = new THREE.BufferGeometry();
|
||||||
|
const particlePositions = new Float32Array(particleCount * 3);
|
||||||
|
for (let i = 0; i < particleCount; i++) {
|
||||||
|
particlePositions[i * 3] = (Math.random() - 0.5) * 400;
|
||||||
|
particlePositions[i * 3 + 1] = Math.random() * 400;
|
||||||
|
particlePositions[i * 3 + 2] = (Math.random() - 0.5) * 400;
|
||||||
|
}
|
||||||
|
particleGeometry.setAttribute('position', new THREE.BufferAttribute(particlePositions, 3));
|
||||||
|
const particleMaterial = new THREE.PointsMaterial({color: 0x1e293b, size: 1.2});
|
||||||
|
const particles = new THREE.Points(particleGeometry, particleMaterial);
|
||||||
|
scene.add(particles);
|
||||||
|
|
||||||
|
const raycaster = new THREE.Raycaster();
|
||||||
|
const pointer = new THREE.Vector2();
|
||||||
|
const hopList = document.getElementById('hop-list');
|
||||||
|
|
||||||
|
function resizeRendererToDisplaySize() {
|
||||||
|
const width = canvas.clientWidth;
|
||||||
|
const height = canvas.clientHeight;
|
||||||
|
const needResize = canvas.width !== width || canvas.height !== height;
|
||||||
|
if (needResize) {
|
||||||
|
renderer.setSize(width, height, false);
|
||||||
|
camera.aspect = width / height;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
}
|
||||||
|
return needResize;
|
||||||
|
}
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
controls.update();
|
||||||
|
resizeRendererToDisplaySize();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
animate();
|
||||||
|
|
||||||
|
const infoCards = Array.from(document.querySelectorAll('.hop-card'));
|
||||||
|
|
||||||
|
function setActiveHop(index) {
|
||||||
|
infoCards.forEach(card => card.classList.toggle('active', Number(card.dataset.hopIndex) === index));
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusOnNode(index) {
|
||||||
|
const node = nodes[index];
|
||||||
|
if (!node) return;
|
||||||
|
const offset = new THREE.Vector3(15, 10, 15);
|
||||||
|
const targetPosition = node.position.clone().add(offset);
|
||||||
|
camera.position.lerp(targetPosition, 0.3);
|
||||||
|
controls.target.copy(node.position);
|
||||||
|
setActiveHop(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', () => resizeRendererToDisplaySize());
|
||||||
|
|
||||||
|
renderer.domElement.addEventListener('click', event => {
|
||||||
|
const rect = renderer.domElement.getBoundingClientRect();
|
||||||
|
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
raycaster.setFromCamera(pointer, camera);
|
||||||
|
const intersects = raycaster.intersectObjects(nodes);
|
||||||
|
if (intersects.length > 0) {
|
||||||
|
const index = intersects[0].object.userData.index;
|
||||||
|
focusOnNode(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
infoCards.forEach(card => {
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
const index = Number(card.dataset.hopIndex);
|
||||||
|
focusOnNode(index);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const cameraProgress = document.getElementById('camera-progress');
|
||||||
|
cameraProgress.addEventListener('input', () => {
|
||||||
|
const value = parseFloat(cameraProgress.value);
|
||||||
|
const lower = Math.floor(value);
|
||||||
|
const upper = Math.ceil(value);
|
||||||
|
const alpha = value - lower;
|
||||||
|
const startPos = nodes[lower]?.position;
|
||||||
|
const endPos = nodes[upper]?.position || startPos;
|
||||||
|
if (!startPos || !endPos) return;
|
||||||
|
const interpolated = startPos.clone().lerp(endPos, alpha);
|
||||||
|
const offset = new THREE.Vector3(12, 8, 12);
|
||||||
|
camera.position.copy(interpolated.clone().add(offset));
|
||||||
|
controls.target.copy(interpolated);
|
||||||
|
setActiveHop(alpha < 0.5 ? lower : upper);
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchInput = document.getElementById('search');
|
||||||
|
const searchButton = document.getElementById('search-btn');
|
||||||
|
|
||||||
|
function searchNodes() {
|
||||||
|
const query = searchInput.value.trim().toLowerCase();
|
||||||
|
if (!query) return;
|
||||||
|
const foundIndex = traceData.findIndex(hop =>
|
||||||
|
`hop ${hop.hop}`.toLowerCase() === query ||
|
||||||
|
hop.ip.toLowerCase() === query
|
||||||
|
);
|
||||||
|
if (foundIndex >= 0) {
|
||||||
|
focusOnNode(foundIndex);
|
||||||
|
cameraProgress.value = foundIndex;
|
||||||
|
} else {
|
||||||
|
alert('Kein passender Hop gefunden.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searchButton.addEventListener('click', searchNodes);
|
||||||
|
searchInput.addEventListener('keydown', event => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
searchNodes();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -30,8 +30,6 @@ define('LOG_RETENTION_MONTHS', 6);
|
|||||||
define('ONLINE_TIMEOUT_SECONDS', 30);
|
define('ONLINE_TIMEOUT_SECONDS', 30);
|
||||||
define('SSE_RETRY_MS', 500);
|
define('SSE_RETRY_MS', 500);
|
||||||
define('MAX_MESSAGES_PER_FETCH', 200);
|
define('MAX_MESSAGES_PER_FETCH', 200);
|
||||||
define('MAX_ATTACHMENT_SIZE', 200 * 1024); // 200 KB
|
|
||||||
define('UPLOAD_DIR', __DIR__ . '/uploads');
|
|
||||||
|
|
||||||
// Rate Limiting
|
// Rate Limiting
|
||||||
define('MAX_MESSAGES_PER_MINUTE', 10);
|
define('MAX_MESSAGES_PER_MINUTE', 10);
|
||||||
@@ -73,152 +71,129 @@ $PROFANITY_FILTER = [
|
|||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
function getDB() {
|
function getDB() {
|
||||||
static $db = null;
|
$db = new SQLite3(DB_FILE);
|
||||||
static $initialized = false;
|
$db->busyTimeout(5000);
|
||||||
|
|
||||||
|
// Users Table (mit Geburtsdatum und User-ID)
|
||||||
|
$db->exec('
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
user_id TEXT UNIQUE NOT NULL,
|
||||||
|
birthdate DATE NOT NULL,
|
||||||
|
age_group TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_banned INTEGER DEFAULT 0,
|
||||||
|
ban_reason TEXT
|
||||||
|
)
|
||||||
|
');
|
||||||
|
|
||||||
|
// Messages Table
|
||||||
|
$db->exec('
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
from_user_id INTEGER NOT NULL,
|
||||||
|
to_user_id INTEGER NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_read INTEGER DEFAULT 0,
|
||||||
|
is_flagged INTEGER DEFAULT 0,
|
||||||
|
flag_reason TEXT,
|
||||||
|
FOREIGN KEY (from_user_id) REFERENCES users(id),
|
||||||
|
FOREIGN KEY (to_user_id) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
');
|
||||||
|
|
||||||
|
// Online Status Table
|
||||||
|
$db->exec('
|
||||||
|
CREATE TABLE IF NOT EXISTS online_status (
|
||||||
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
last_ping DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
');
|
||||||
|
|
||||||
if ($db === null) {
|
// User Sessions Table
|
||||||
if (!is_dir(UPLOAD_DIR)) {
|
$db->exec('
|
||||||
@mkdir(UPLOAD_DIR, 0755, true);
|
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||||
}
|
user_id INTEGER PRIMARY KEY,
|
||||||
|
session_token TEXT NOT NULL,
|
||||||
|
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
');
|
||||||
|
|
||||||
$db = new SQLite3(DB_FILE);
|
// Reports Table
|
||||||
$db->busyTimeout(5000);
|
$db->exec('
|
||||||
}
|
CREATE TABLE IF NOT EXISTS reports (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
if (!$initialized) {
|
reporter_id INTEGER NOT NULL,
|
||||||
// Users Table (mit Geburtsdatum und User-ID)
|
reported_user_id INTEGER NOT NULL,
|
||||||
$db->exec('
|
reason TEXT NOT NULL,
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
message_id INTEGER,
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
username TEXT NOT NULL,
|
status TEXT DEFAULT "pending",
|
||||||
user_id TEXT UNIQUE NOT NULL,
|
FOREIGN KEY (reporter_id) REFERENCES users(id),
|
||||||
birthdate DATE NOT NULL,
|
FOREIGN KEY (reported_user_id) REFERENCES users(id),
|
||||||
age_group TEXT NOT NULL,
|
FOREIGN KEY (message_id) REFERENCES messages(id)
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
)
|
||||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
');
|
||||||
is_banned INTEGER DEFAULT 0,
|
|
||||||
ban_reason TEXT
|
// Blocks Table
|
||||||
)
|
$db->exec('
|
||||||
');
|
CREATE TABLE IF NOT EXISTS blocks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
// Messages Table
|
blocker_id INTEGER NOT NULL,
|
||||||
$db->exec('
|
blocked_id INTEGER NOT NULL,
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
FOREIGN KEY (blocker_id) REFERENCES users(id),
|
||||||
from_user_id INTEGER NOT NULL,
|
FOREIGN KEY (blocked_id) REFERENCES users(id),
|
||||||
to_user_id INTEGER NOT NULL,
|
UNIQUE(blocker_id, blocked_id)
|
||||||
message TEXT NOT NULL,
|
)
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
');
|
||||||
is_read INTEGER DEFAULT 0,
|
|
||||||
is_flagged INTEGER DEFAULT 0,
|
// Rate Limiting Table
|
||||||
flag_reason TEXT,
|
$db->exec('
|
||||||
attachment_path TEXT,
|
CREATE TABLE IF NOT EXISTS rate_limits (
|
||||||
attachment_type TEXT,
|
user_id INTEGER NOT NULL,
|
||||||
attachment_size INTEGER,
|
action_type TEXT NOT NULL,
|
||||||
FOREIGN KEY (from_user_id) REFERENCES users(id),
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (to_user_id) REFERENCES users(id)
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
)
|
)
|
||||||
');
|
');
|
||||||
|
|
||||||
// Ensure attachment columns exist for older installations
|
// Logs Table (für Behörden)
|
||||||
$messagesInfo = $db->query('PRAGMA table_info(messages)');
|
$db->exec('
|
||||||
$messageColumns = [];
|
CREATE TABLE IF NOT EXISTS security_logs (
|
||||||
while ($column = $messagesInfo->fetchArray(SQLITE3_ASSOC)) {
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
$messageColumns[$column['name']] = true;
|
user_id INTEGER,
|
||||||
}
|
action TEXT NOT NULL,
|
||||||
if (!isset($messageColumns['attachment_path'])) {
|
details TEXT,
|
||||||
$db->exec('ALTER TABLE messages ADD COLUMN attachment_path TEXT');
|
ip_address TEXT,
|
||||||
}
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
if (!isset($messageColumns['attachment_type'])) {
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
$db->exec('ALTER TABLE messages ADD COLUMN attachment_type TEXT');
|
)
|
||||||
}
|
');
|
||||||
if (!isset($messageColumns['attachment_size'])) {
|
|
||||||
$db->exec('ALTER TABLE messages ADD COLUMN attachment_size INTEGER');
|
// Admin Table
|
||||||
}
|
$db->exec('
|
||||||
|
CREATE TABLE IF NOT EXISTS admins (
|
||||||
// Online Status Table
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
$db->exec('
|
username TEXT UNIQUE NOT NULL,
|
||||||
CREATE TABLE IF NOT EXISTS online_status (
|
password_hash TEXT NOT NULL,
|
||||||
user_id INTEGER PRIMARY KEY,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
last_ping DATETIME DEFAULT CURRENT_TIMESTAMP,
|
)
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
');
|
||||||
)
|
|
||||||
');
|
// Create default admin if not exists
|
||||||
|
$stmt = $db->prepare('SELECT COUNT(*) as count FROM admins WHERE username = :username');
|
||||||
// User Sessions Table
|
$stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT);
|
||||||
$db->exec('
|
$result = $stmt->execute();
|
||||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||||
user_id INTEGER PRIMARY KEY,
|
|
||||||
session_token TEXT NOT NULL,
|
if ($row['count'] == 0) {
|
||||||
last_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
|
$stmt = $db->prepare('INSERT INTO admins (username, password_hash) VALUES (:username, :password)');
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
||||||
)
|
|
||||||
');
|
|
||||||
|
|
||||||
// Reports Table
|
|
||||||
$db->exec('
|
|
||||||
CREATE TABLE IF NOT EXISTS reports (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
reporter_id INTEGER NOT NULL,
|
|
||||||
reported_user_id INTEGER NOT NULL,
|
|
||||||
reason TEXT NOT NULL,
|
|
||||||
message_id INTEGER,
|
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
status TEXT DEFAULT "pending",
|
|
||||||
FOREIGN KEY (reporter_id) REFERENCES users(id),
|
|
||||||
FOREIGN KEY (reported_user_id) REFERENCES users(id),
|
|
||||||
FOREIGN KEY (message_id) REFERENCES messages(id)
|
|
||||||
)
|
|
||||||
');
|
|
||||||
|
|
||||||
// Blocks Table
|
|
||||||
$db->exec('
|
|
||||||
CREATE TABLE IF NOT EXISTS blocks (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
blocker_id INTEGER NOT NULL,
|
|
||||||
blocked_id INTEGER NOT NULL,
|
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (blocker_id) REFERENCES users(id),
|
|
||||||
FOREIGN KEY (blocked_id) REFERENCES users(id),
|
|
||||||
UNIQUE(blocker_id, blocked_id)
|
|
||||||
)
|
|
||||||
');
|
|
||||||
|
|
||||||
// Rate Limiting Table
|
|
||||||
$db->exec('
|
|
||||||
CREATE TABLE IF NOT EXISTS rate_limits (
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
action_type TEXT NOT NULL,
|
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
||||||
)
|
|
||||||
');
|
|
||||||
|
|
||||||
// Logs Table (für Behörden)
|
|
||||||
$db->exec('
|
|
||||||
CREATE TABLE IF NOT EXISTS security_logs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER,
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
details TEXT,
|
|
||||||
ip_address TEXT,
|
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
|
||||||
)
|
|
||||||
');
|
|
||||||
|
|
||||||
// Admin Table
|
|
||||||
$db->exec('
|
|
||||||
CREATE TABLE IF NOT EXISTS admins (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username TEXT UNIQUE NOT NULL,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
');
|
|
||||||
|
|
||||||
// Create default admin if not exists
|
|
||||||
$stmt = $db->prepare('SELECT COUNT(*) as count FROM admins WHERE username = :username');
|
|
||||||
$stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT);
|
$stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT);
|
||||||
$result = $stmt->execute();
|
$result = $stmt->execute();
|
||||||
$row = $result->fetchArray(SQLITE3_ASSOC);
|
$row = $result->fetchArray(SQLITE3_ASSOC);
|
||||||
@@ -240,6 +215,14 @@ function getDB() {
|
|||||||
|
|
||||||
$initialized = true;
|
$initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_messages_users ON messages(from_user_id, to_user_id)');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp)');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_users_age_group ON users(age_group)');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_blocks ON blocks(blocker_id, blocked_id)');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status)');
|
||||||
|
$db->exec('CREATE INDEX IF NOT EXISTS idx_user_sessions_last_seen ON user_sessions(last_seen)');
|
||||||
|
|
||||||
return $db;
|
return $db;
|
||||||
}
|
}
|
||||||
@@ -503,7 +486,7 @@ function generateSessionToken() {
|
|||||||
return bin2hex(random_bytes(32));
|
return bin2hex(random_bytes(32));
|
||||||
}
|
}
|
||||||
|
|
||||||
function startUserSession($userId, $force = false) {
|
function startUserSession($userId) {
|
||||||
if (!$userId) {
|
if (!$userId) {
|
||||||
return ['allowed' => false, 'error' => 'Ungültige Benutzer-ID'];
|
return ['allowed' => false, 'error' => 'Ungültige Benutzer-ID'];
|
||||||
}
|
}
|
||||||
@@ -514,24 +497,17 @@ function startUserSession($userId, $force = false) {
|
|||||||
$result = $stmt->execute();
|
$result = $stmt->execute();
|
||||||
$existing = $result->fetchArray(SQLITE3_ASSOC);
|
$existing = $result->fetchArray(SQLITE3_ASSOC);
|
||||||
|
|
||||||
if ($existing && !$force && !empty($existing['last_seen'])) {
|
if ($existing && !empty($existing['last_seen'])) {
|
||||||
$secondsSinceLastSeen = time() - strtotime($existing['last_seen']);
|
$secondsSinceLastSeen = time() - strtotime($existing['last_seen']);
|
||||||
|
|
||||||
if ($secondsSinceLastSeen < ONLINE_TIMEOUT_SECONDS) {
|
if ($secondsSinceLastSeen < ONLINE_TIMEOUT_SECONDS) {
|
||||||
return [
|
return [
|
||||||
'allowed' => false,
|
'allowed' => false,
|
||||||
'error' => 'Du bist bereits auf einem anderen Gerät eingeloggt. Übernimm die Sitzung nur, wenn du wirklich ausgeloggt bist.',
|
'error' => 'Du bist bereits auf einem anderen Gerät eingeloggt. Bitte dort zuerst ausloggen oder kurz warten.'
|
||||||
'can_force' => true
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($force && $existing) {
|
|
||||||
$stmt = $db->prepare('DELETE FROM user_sessions WHERE user_id = :user_id');
|
|
||||||
$stmt->bindValue(':user_id', $userId, SQLITE3_INTEGER);
|
|
||||||
$stmt->execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
$token = generateSessionToken();
|
$token = generateSessionToken();
|
||||||
|
|
||||||
$stmt = $db->prepare('
|
$stmt = $db->prepare('
|
||||||
@@ -1006,9 +982,6 @@ if (isset($_POST['action']) || isset($_GET['action'])) {
|
|||||||
m.timestamp,
|
m.timestamp,
|
||||||
m.is_read,
|
m.is_read,
|
||||||
m.is_flagged,
|
m.is_flagged,
|
||||||
m.attachment_path,
|
|
||||||
m.attachment_type,
|
|
||||||
m.attachment_size,
|
|
||||||
u.username as from_username,
|
u.username as from_username,
|
||||||
u.user_id as from_display_id
|
u.user_id as from_display_id
|
||||||
FROM messages m
|
FROM messages m
|
||||||
@@ -1959,7 +1932,6 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') {
|
|||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||||
background: linear-gradient(135deg, #fef08a 0%, #f97316 100%);
|
background: linear-gradient(135deg, #fef08a 0%, #f97316 100%);
|
||||||
background-color: #fff9db;
|
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -3647,136 +3619,6 @@ const chatStateMessageEl = document.getElementById('chatStateMessage');
|
|||||||
const chatMessagesHeaderEl = document.getElementById('chatMessagesHeader');
|
const chatMessagesHeaderEl = document.getElementById('chatMessagesHeader');
|
||||||
const chatInputEl = document.getElementById('chatInput');
|
const chatInputEl = document.getElementById('chatInput');
|
||||||
const sendButtonEl = document.getElementById('sendButton');
|
const sendButtonEl = document.getElementById('sendButton');
|
||||||
const attachmentButtonEl = document.getElementById('attachmentButton');
|
|
||||||
const attachmentInputEl = document.getElementById('attachmentInput');
|
|
||||||
const attachmentInfoEl = document.getElementById('attachmentInfo');
|
|
||||||
const attachmentFileNameEl = document.getElementById('attachmentFileName');
|
|
||||||
const attachmentClearBtnEl = document.getElementById('attachmentClearBtn');
|
|
||||||
const attachmentWarningEl = document.getElementById('attachmentWarning');
|
|
||||||
const ATTACHMENT_MAX_SIZE = 200 * 1024;
|
|
||||||
let messageAbortController = null;
|
|
||||||
let sseErrorCount = 0;
|
|
||||||
let usePollingFallback = false;
|
|
||||||
let pollingTimerId = null;
|
|
||||||
let isPollingUpdates = false;
|
|
||||||
const POLLING_INTERVAL_MS = 5000;
|
|
||||||
|
|
||||||
function processIncomingMessages(messages) {
|
|
||||||
if (!Array.isArray(messages) || messages.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let shouldRender = false;
|
|
||||||
const markReadFor = new Set();
|
|
||||||
|
|
||||||
messages.forEach(msg => {
|
|
||||||
const messageId = Number(msg.id);
|
|
||||||
if (Number.isFinite(messageId) && messageId > state.lastMessageId) {
|
|
||||||
state.lastMessageId = messageId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRelevantChat = state.selectedUserId && (
|
|
||||||
(msg.from_user_id === state.selectedUserId && msg.to_user_id === state.currentUserId) ||
|
|
||||||
(msg.from_user_id === state.currentUserId && msg.to_user_id === state.selectedUserId)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isRelevantChat) {
|
|
||||||
const alreadyExists = state.messages.some(existing => Number(existing.id) === messageId);
|
|
||||||
|
|
||||||
if (!alreadyExists) {
|
|
||||||
state.messages.push(msg);
|
|
||||||
shouldRender = true;
|
|
||||||
|
|
||||||
if (msg.to_user_id === state.currentUserId) {
|
|
||||||
markReadFor.add(msg.from_user_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (shouldRender) {
|
|
||||||
renderMessages();
|
|
||||||
}
|
|
||||||
|
|
||||||
markReadFor.forEach(userId => markAsRead(userId));
|
|
||||||
|
|
||||||
if (messages.length > 0) {
|
|
||||||
loadUsers();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPollingUpdates() {
|
|
||||||
if (pollingTimerId) {
|
|
||||||
clearInterval(pollingTimerId);
|
|
||||||
pollingTimerId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startPollingUpdates() {
|
|
||||||
stopPollingUpdates();
|
|
||||||
pollMessages();
|
|
||||||
pollingTimerId = setInterval(pollMessages, POLLING_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollMessages() {
|
|
||||||
if (isPollingUpdates) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isPollingUpdates = true;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('action', 'poll_updates');
|
|
||||||
formData.append('last_message_id', state.lastMessageId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await postFormData(formData);
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result && result.success) {
|
|
||||||
const messages = Array.isArray(result.messages) ? result.messages : [];
|
|
||||||
if (typeof result.last_message_id === 'number') {
|
|
||||||
const newest = Number(result.last_message_id);
|
|
||||||
if (Number.isFinite(newest)) {
|
|
||||||
state.lastMessageId = Math.max(state.lastMessageId, newest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
processIncomingMessages(messages);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Polling fehlgeschlagen:', error);
|
|
||||||
} finally {
|
|
||||||
isPollingUpdates = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function enablePollingFallback() {
|
|
||||||
if (usePollingFallback) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
usePollingFallback = true;
|
|
||||||
|
|
||||||
if (state.eventSource) {
|
|
||||||
state.eventSource.close();
|
|
||||||
state.eventSource = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.connectionErrorShown && state.selectedUserId && !state.isLoadingMessages) {
|
|
||||||
updateChatState('error', 'Live-Verbindung blockiert. Wechsel auf sichere Aktualisierung…');
|
|
||||||
state.connectionErrorShown = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
startPollingUpdates();
|
|
||||||
}
|
|
||||||
|
|
||||||
function startRealtime() {
|
|
||||||
if (usePollingFallback) {
|
|
||||||
startPollingUpdates();
|
|
||||||
} else {
|
|
||||||
startSSE();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
if (!userListEl) {
|
if (!userListEl) {
|
||||||
@@ -3787,7 +3629,7 @@ async function loadUsers() {
|
|||||||
renderUserList();
|
renderUserList();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(buildUrl({ action: 'get_users' }), { credentials: 'same-origin' });
|
const response = await fetch('?action=get_users');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('NETZWERK_FEHLER');
|
throw new Error('NETZWERK_FEHLER');
|
||||||
}
|
}
|
||||||
@@ -3836,23 +3678,9 @@ function renderUserList() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const offlineLimit = 5;
|
|
||||||
const onlineUsers = [];
|
|
||||||
const offlineUsers = [];
|
|
||||||
|
|
||||||
filtered.forEach(user => {
|
|
||||||
if (user.is_online) {
|
|
||||||
onlineUsers.push(user);
|
|
||||||
} else {
|
|
||||||
offlineUsers.push(user);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const limitedUsers = onlineUsers.concat(offlineUsers.slice(0, offlineLimit));
|
|
||||||
|
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
limitedUsers.forEach(user => {
|
filtered.forEach(user => {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
item.type = 'button';
|
item.type = 'button';
|
||||||
item.className = 'user-item' + (Number(user.id) === Number(state.selectedUserId) ? ' active' : '');
|
item.className = 'user-item' + (Number(user.id) === Number(state.selectedUserId) ? ' active' : '');
|
||||||
@@ -3900,15 +3728,6 @@ function renderUserList() {
|
|||||||
|
|
||||||
userListEl.innerHTML = '';
|
userListEl.innerHTML = '';
|
||||||
userListEl.appendChild(fragment);
|
userListEl.appendChild(fragment);
|
||||||
|
|
||||||
if (offlineUsers.length > offlineLimit) {
|
|
||||||
const hint = document.createElement('div');
|
|
||||||
hint.className = 'user-status';
|
|
||||||
hint.style.textAlign = 'center';
|
|
||||||
hint.style.marginTop = '12px';
|
|
||||||
hint.textContent = 'Weitere Offline-Nutzer werden ausgeblendet.';
|
|
||||||
userListEl.appendChild(hint);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChatHeader(displayName) {
|
function renderChatHeader(displayName) {
|
||||||
@@ -3966,9 +3785,6 @@ function selectUser(userId, displayName) {
|
|||||||
state.selectedUserId = userId;
|
state.selectedUserId = userId;
|
||||||
state.messages = [];
|
state.messages = [];
|
||||||
|
|
||||||
clearAttachmentSelection();
|
|
||||||
clearAttachmentWarning();
|
|
||||||
|
|
||||||
if (chatWelcomeEl) {
|
if (chatWelcomeEl) {
|
||||||
chatWelcomeEl.style.display = 'none';
|
chatWelcomeEl.style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -3993,21 +3809,11 @@ async function loadMessages(userId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messageAbortController) {
|
|
||||||
messageAbortController.abort();
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentController = new AbortController();
|
|
||||||
messageAbortController = currentController;
|
|
||||||
|
|
||||||
state.isLoadingMessages = true;
|
state.isLoadingMessages = true;
|
||||||
updateChatState('loading', 'Nachrichten werden geladen…');
|
updateChatState('loading', 'Nachrichten werden geladen…');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(buildUrl({ action: 'get_messages', user_id: userId }), {
|
const response = await fetch(`?action=get_messages&user_id=${userId}`);
|
||||||
signal: currentController.signal,
|
|
||||||
credentials: 'same-origin'
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('NETZWERK_FEHLER');
|
throw new Error('NETZWERK_FEHLER');
|
||||||
}
|
}
|
||||||
@@ -4032,9 +3838,6 @@ async function loadMessages(userId) {
|
|||||||
updateChatState('empty', 'Noch keine Nachrichten. Starte das Gespräch!');
|
updateChatState('empty', 'Noch keine Nachrichten. Starte das Gespräch!');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error && error.name === 'AbortError') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.error('Nachrichten konnten nicht geladen werden:', error);
|
console.error('Nachrichten konnten nicht geladen werden:', error);
|
||||||
state.messages = [];
|
state.messages = [];
|
||||||
if (chatMessagesEl) {
|
if (chatMessagesEl) {
|
||||||
@@ -4045,10 +3848,7 @@ async function loadMessages(userId) {
|
|||||||
: 'Nachrichten konnten nicht geladen werden. Bitte versuche es erneut.';
|
: 'Nachrichten konnten nicht geladen werden. Bitte versuche es erneut.';
|
||||||
updateChatState('error', errorMessage);
|
updateChatState('error', errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
if (messageAbortController === currentController) {
|
state.isLoadingMessages = false;
|
||||||
messageAbortController = null;
|
|
||||||
state.isLoadingMessages = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4089,51 +3889,12 @@ function renderMessages() {
|
|||||||
updateChatState(null);
|
updateChatState(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAttachmentSelection() {
|
|
||||||
if (attachmentInputEl) {
|
|
||||||
attachmentInputEl.value = '';
|
|
||||||
}
|
|
||||||
if (attachmentInfoEl) {
|
|
||||||
attachmentInfoEl.classList.add('hidden');
|
|
||||||
}
|
|
||||||
if (attachmentFileNameEl) {
|
|
||||||
attachmentFileNameEl.textContent = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showAttachmentWarning(message) {
|
|
||||||
if (attachmentWarningEl) {
|
|
||||||
attachmentWarningEl.textContent = message;
|
|
||||||
attachmentWarningEl.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
alert(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAttachmentWarning() {
|
|
||||||
if (attachmentWarningEl) {
|
|
||||||
attachmentWarningEl.textContent = '';
|
|
||||||
attachmentWarningEl.classList.add('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
if (!chatInputEl) {
|
if (!chatInputEl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.selectedUserId) {
|
|
||||||
showAttachmentWarning('Bitte wähle zuerst einen Chat aus.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = chatInputEl.value.trim();
|
const message = chatInputEl.value.trim();
|
||||||
const attachmentFile = attachmentInputEl?.files?.[0] || null;
|
|
||||||
|
|
||||||
if (!message && !attachmentFile) {
|
|
||||||
showAttachmentWarning('Bitte gib eine Nachricht ein oder hänge ein JPG-Bild an.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearAttachmentWarning();
|
clearAttachmentWarning();
|
||||||
|
|
||||||
@@ -4164,21 +3925,11 @@ async function sendMessage() {
|
|||||||
formData.append('attachment', attachmentFile);
|
formData.append('attachment', attachmentFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (result.success) {
|
||||||
const response = await postFormData(formData);
|
chatInputEl.value = '';
|
||||||
const result = await response.json();
|
chatInputEl.dispatchEvent(new Event('input'));
|
||||||
|
} else {
|
||||||
if (result.success) {
|
alert(result.error);
|
||||||
chatInputEl.value = '';
|
|
||||||
chatInputEl.dispatchEvent(new Event('input'));
|
|
||||||
clearAttachmentSelection();
|
|
||||||
clearAttachmentWarning();
|
|
||||||
} else {
|
|
||||||
showAttachmentWarning(result.error || 'Nachricht konnte nicht gesendet werden.');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Nachricht konnte nicht gesendet werden:', error);
|
|
||||||
showAttachmentWarning('Nachricht konnte nicht gesendet werden.');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4192,25 +3943,14 @@ async function markAsRead(userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startSSE() {
|
function startSSE() {
|
||||||
stopPollingUpdates();
|
|
||||||
|
|
||||||
if (state.eventSource) {
|
if (state.eventSource) {
|
||||||
state.eventSource.close();
|
state.eventSource.close();
|
||||||
state.eventSource = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = buildUrl({ stream: 'events', last_message_id: state.lastMessageId, t: Date.now() });
|
const url = `?stream=events&last_message_id=${state.lastMessageId}&t=${Date.now()}`;
|
||||||
|
state.eventSource = new EventSource(url);
|
||||||
try {
|
|
||||||
state.eventSource = new EventSource(url);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('SSE kann nicht gestartet werden, wechsle auf Polling:', error);
|
|
||||||
enablePollingFallback();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.eventSource.onopen = () => {
|
state.eventSource.onopen = () => {
|
||||||
sseErrorCount = 0;
|
|
||||||
state.connectionErrorShown = false;
|
state.connectionErrorShown = false;
|
||||||
|
|
||||||
if (!state.selectedUserId) {
|
if (!state.selectedUserId) {
|
||||||
@@ -4230,10 +3970,30 @@ function startSSE() {
|
|||||||
|
|
||||||
state.eventSource.onmessage = (event) => {
|
state.eventSource.onmessage = (event) => {
|
||||||
state.connectionErrorShown = false;
|
state.connectionErrorShown = false;
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
if (!event.data) {
|
if (data.type === 'messages' && data.messages) {
|
||||||
return;
|
data.messages.forEach(msg => {
|
||||||
}
|
const messageId = Number(msg.id);
|
||||||
|
|
||||||
|
if (messageId > state.lastMessageId) {
|
||||||
|
state.lastMessageId = messageId;
|
||||||
|
|
||||||
|
if (state.selectedUserId &&
|
||||||
|
((msg.from_user_id === state.selectedUserId && msg.to_user_id === state.currentUserId) ||
|
||||||
|
(msg.from_user_id === state.currentUserId && msg.to_user_id === state.selectedUserId))) {
|
||||||
|
|
||||||
|
if (!state.messages.find(m => Number(m.id) === messageId)) {
|
||||||
|
state.messages.push(msg);
|
||||||
|
renderMessages();
|
||||||
|
|
||||||
|
if (msg.to_user_id === state.currentUserId) {
|
||||||
|
markAsRead(msg.from_user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
@@ -4246,13 +4006,6 @@ function startSSE() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
state.eventSource.onerror = () => {
|
state.eventSource.onerror = () => {
|
||||||
if (state.eventSource) {
|
|
||||||
state.eventSource.close();
|
|
||||||
state.eventSource = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
sseErrorCount += 1;
|
|
||||||
|
|
||||||
if (!state.connectionErrorShown) {
|
if (!state.connectionErrorShown) {
|
||||||
state.connectionErrorShown = true;
|
state.connectionErrorShown = true;
|
||||||
console.warn('SSE-Verbindung unterbrochen, versuche Neuverbindung.');
|
console.warn('SSE-Verbindung unterbrochen, versuche Neuverbindung.');
|
||||||
@@ -4261,16 +4014,11 @@ function startSSE() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (usePollingFallback || sseErrorCount >= 3) {
|
if (state.eventSource) {
|
||||||
enablePollingFallback();
|
state.eventSource.close();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(startSSE, 1500);
|
||||||
if (!usePollingFallback) {
|
|
||||||
startSSE();
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4280,56 +4028,6 @@ function escapeHtml(text) {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeAttribute(value) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = value ?? '';
|
|
||||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
attachmentButtonEl?.addEventListener('click', () => {
|
|
||||||
attachmentInputEl?.click();
|
|
||||||
});
|
|
||||||
|
|
||||||
attachmentInputEl?.addEventListener('change', () => {
|
|
||||||
clearAttachmentWarning();
|
|
||||||
|
|
||||||
if (!attachmentInputEl.files || attachmentInputEl.files.length === 0) {
|
|
||||||
clearAttachmentSelection();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = attachmentInputEl.files[0];
|
|
||||||
const fileType = (file.type || '').toLowerCase();
|
|
||||||
const fileName = file.name || '';
|
|
||||||
const isJpeg = /^image\/jpe?g$/.test(fileType) || /\.jpe?g$/i.test(fileName);
|
|
||||||
|
|
||||||
if (!isJpeg) {
|
|
||||||
showAttachmentWarning('Nur JPG-Bilder sind erlaubt.');
|
|
||||||
clearAttachmentSelection();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (file.size > ATTACHMENT_MAX_SIZE) {
|
|
||||||
showAttachmentWarning('Bild ist zu groß (max. 200 KB).');
|
|
||||||
clearAttachmentSelection();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attachmentInfoEl) {
|
|
||||||
attachmentInfoEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attachmentFileNameEl) {
|
|
||||||
const sizeKb = Math.max(1, Math.round(file.size / 1024));
|
|
||||||
attachmentFileNameEl.textContent = `${file.name} (${sizeKb} KB)`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
attachmentClearBtnEl?.addEventListener('click', () => {
|
|
||||||
clearAttachmentSelection();
|
|
||||||
clearAttachmentWarning();
|
|
||||||
});
|
|
||||||
|
|
||||||
sendButtonEl?.addEventListener('click', sendMessage);
|
sendButtonEl?.addEventListener('click', sendMessage);
|
||||||
|
|
||||||
chatInputEl?.addEventListener('keypress', (e) => {
|
chatInputEl?.addEventListener('keypress', (e) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user