Merge pull request #8 from metacube2/codex/implement-new-chat-age-restrictions-vvodw9

Fix chat join flow and add JPG attachments
This commit is contained in:
2025-11-03 19:23:05 +01:00
committed by GitHub
+248 -14
View File
@@ -192,6 +192,12 @@ function getDB() {
$result = $stmt->execute(); $result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC); $row = $result->fetchArray(SQLITE3_ASSOC);
if ($row['count'] == 0) {
$stmt = $db->prepare('INSERT INTO admins (username, password_hash) VALUES (:username, :password)');
$stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT);
$result = $stmt->execute();
$row = $result->fetchArray(SQLITE3_ASSOC);
if ($row['count'] == 0) { if ($row['count'] == 0) {
$stmt = $db->prepare('INSERT INTO admins (username, password_hash) VALUES (:username, :password)'); $stmt = $db->prepare('INSERT INTO admins (username, password_hash) VALUES (:username, :password)');
$stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT); $stmt->bindValue(':username', ADMIN_USERNAME, SQLITE3_TEXT);
@@ -207,6 +213,17 @@ function getDB() {
$db->exec('CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status)'); $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)'); $db->exec('CREATE INDEX IF NOT EXISTS idx_user_sessions_last_seen ON user_sessions(last_seen)');
$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;
} }
@@ -386,6 +403,24 @@ function cleanupOldData() {
// Delete old messages // Delete old messages
$hours = MESSAGE_RETENTION_HOURS; $hours = MESSAGE_RETENTION_HOURS;
$attachmentResult = $db->query("SELECT attachment_path FROM messages WHERE attachment_path IS NOT NULL AND timestamp < datetime('now', '-{$hours} hours')");
while ($attachmentRow = $attachmentResult->fetchArray(SQLITE3_ASSOC)) {
$relativePath = $attachmentRow['attachment_path'] ?? '';
if (!$relativePath) {
continue;
}
$normalizedPath = str_replace('\\', '/', $relativePath);
if (strpos($normalizedPath, 'uploads/') !== 0) {
continue;
}
$fullPath = __DIR__ . '/' . $normalizedPath;
if (is_file($fullPath)) {
@unlink($fullPath);
}
}
$db->exec("DELETE FROM messages WHERE timestamp < datetime('now', '-{$hours} hours')"); $db->exec("DELETE FROM messages WHERE timestamp < datetime('now', '-{$hours} hours')");
// Delete old rate limits // Delete old rate limits
@@ -899,6 +934,9 @@ if (isset($_POST['action']) || isset($_GET['action'])) {
'timestamp' => $row['timestamp'], 'timestamp' => $row['timestamp'],
'is_read' => $row['is_read'], 'is_read' => $row['is_read'],
'is_flagged' => $row['is_flagged'], 'is_flagged' => $row['is_flagged'],
'attachment_url' => $row['attachment_path'] ?: null,
'attachment_type' => $row['attachment_type'] ?: null,
'attachment_size' => $row['attachment_size'] !== null ? (int)$row['attachment_size'] : null,
'from_username' => $row['from_username'], 'from_username' => $row['from_username'],
'from_display_name' => $row['from_username'] . '#' . $row['from_display_id'] 'from_display_name' => $row['from_username'] . '#' . $row['from_display_id']
]; ];
@@ -914,14 +952,15 @@ if (isset($_POST['action']) || isset($_GET['action'])) {
if ($action === 'send_message') { if ($action === 'send_message') {
$toUserId = intval($_POST['to_user_id'] ?? 0); $toUserId = intval($_POST['to_user_id'] ?? 0);
$message = trim($_POST['message'] ?? ''); $message = trim($_POST['message'] ?? '');
$hasAttachment = isset($_FILES['attachment']) && ($_FILES['attachment']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE;
if ($toUserId <= 0) { if ($toUserId <= 0) {
echo json_encode(['success' => false, 'error' => 'Ungültige User-ID']); echo json_encode(['success' => false, 'error' => 'Ungültige User-ID']);
exit; exit;
} }
if (empty($message)) { if (!$hasAttachment && $message === '') {
echo json_encode(['success' => false, 'error' => 'Nachricht darf nicht leer sein']); echo json_encode(['success' => false, 'error' => 'Nachricht oder Bild erforderlich']);
exit; exit;
} }
@@ -930,6 +969,58 @@ if (isset($_POST['action']) || isset($_GET['action'])) {
exit; exit;
} }
$attachmentFile = $hasAttachment ? $_FILES['attachment'] : null;
$attachmentMime = null;
$attachmentSize = null;
if ($hasAttachment && $attachmentFile) {
if (!is_uploaded_file($attachmentFile['tmp_name'])) {
echo json_encode(['success' => false, 'error' => 'Ungültiger Datei-Upload']);
exit;
}
if ($attachmentFile['error'] !== UPLOAD_ERR_OK) {
echo json_encode(['success' => false, 'error' => 'Bild konnte nicht hochgeladen werden']);
exit;
}
if ($attachmentFile['size'] > MAX_ATTACHMENT_SIZE) {
echo json_encode(['success' => false, 'error' => 'Bild ist zu groß (max. 200 KB)']);
exit;
}
$attachmentSize = (int)$attachmentFile['size'];
$mime = null;
if (function_exists('finfo_open')) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
if ($finfo) {
$mime = finfo_file($finfo, $attachmentFile['tmp_name']);
finfo_close($finfo);
}
}
if (!$mime && function_exists('mime_content_type')) {
$mime = mime_content_type($attachmentFile['tmp_name']);
}
if (!$mime && isset($attachmentFile['type'])) {
$mime = $attachmentFile['type'];
}
$mime = strtolower((string)$mime);
if (!in_array($mime, ['image/jpeg', 'image/pjpeg', 'image/jpg'], true)) {
echo json_encode(['success' => false, 'error' => 'Nur JPG-Bilder sind erlaubt']);
exit;
}
$imageInfo = @getimagesize($attachmentFile['tmp_name']);
if ($imageInfo === false || !in_array($imageInfo[2], [IMAGETYPE_JPEG], true)) {
echo json_encode(['success' => false, 'error' => 'Bilddatei konnte nicht verifiziert werden']);
exit;
}
$attachmentMime = 'image/jpeg';
}
// Check if blocked // Check if blocked
if (isBlocked(getCurrentUserId(), $toUserId)) { if (isBlocked(getCurrentUserId(), $toUserId)) {
echo json_encode(['success' => false, 'error' => 'Nachricht kann nicht gesendet werden']); echo json_encode(['success' => false, 'error' => 'Nachricht kann nicht gesendet werden']);
@@ -964,6 +1055,7 @@ if (isset($_POST['action']) || isset($_GET['action'])) {
exit; exit;
} }
if ($message !== '') {
// Keyword Blacklist // Keyword Blacklist
$keywordCheck = checkKeywordBlacklist($message); $keywordCheck = checkKeywordBlacklist($message);
if ($keywordCheck['blocked']) { if ($keywordCheck['blocked']) {
@@ -999,40 +1091,62 @@ if (isset($_POST['action']) || isset($_GET['action'])) {
]); ]);
exit; exit;
} }
}
// Auto-Flagging (verdächtige Muster) // Auto-Flagging (verdächtige Muster)
$isFlagged = 0; $isFlagged = 0;
$flagReason = ''; $flagReason = '';
// Check for repeated characters (AAAAAAA) if ($message !== '') {
if (preg_match('/(.)\1{5,}/', $message)) { if (preg_match('/(.)\1{5,}/', $message)) {
$isFlagged = 1; $isFlagged = 1;
$flagReason = 'Repeated characters'; $flagReason = 'Repeated characters';
} }
// Check for all caps (min 20 chars)
if (strlen($message) > 20 && $message === strtoupper($message)) { if (strlen($message) > 20 && $message === strtoupper($message)) {
$isFlagged = 1; $isFlagged = 1;
$flagReason = 'All caps'; $flagReason = 'All caps';
} }
// Check for excessive emojis
$emojiCount = preg_match_all('/[\x{1F600}-\x{1F64F}]/u', $message); $emojiCount = preg_match_all('/[\x{1F600}-\x{1F64F}]/u', $message);
if ($emojiCount > 10) { if ($emojiCount > 10) {
$isFlagged = 1; $isFlagged = 1;
$flagReason = 'Excessive emojis'; $flagReason = 'Excessive emojis';
} }
}
$attachmentPath = null;
if ($hasAttachment && $attachmentFile) {
$randomName = bin2hex(random_bytes(16)) . '.jpg';
$destination = rtrim(UPLOAD_DIR, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $randomName;
if (!move_uploaded_file($attachmentFile['tmp_name'], $destination)) {
echo json_encode(['success' => false, 'error' => 'Bild konnte nicht gespeichert werden']);
exit;
}
$attachmentPath = 'uploads/' . $randomName;
}
// Insert message // Insert message
$stmt = $db->prepare(' $stmt = $db->prepare('
INSERT INTO messages (from_user_id, to_user_id, message, is_flagged, flag_reason) INSERT INTO messages (from_user_id, to_user_id, message, is_flagged, flag_reason, attachment_path, attachment_type, attachment_size)
VALUES (:from_user_id, :to_user_id, :message, :is_flagged, :flag_reason) VALUES (:from_user_id, :to_user_id, :message, :is_flagged, :flag_reason, :attachment_path, :attachment_type, :attachment_size)
'); ');
$stmt->bindValue(':from_user_id', $currentUserId, SQLITE3_INTEGER); $stmt->bindValue(':from_user_id', $currentUserId, SQLITE3_INTEGER);
$stmt->bindValue(':to_user_id', $toUserId, SQLITE3_INTEGER); $stmt->bindValue(':to_user_id', $toUserId, SQLITE3_INTEGER);
$stmt->bindValue(':message', $message, SQLITE3_TEXT); $stmt->bindValue(':message', $message, SQLITE3_TEXT);
$stmt->bindValue(':is_flagged', $isFlagged, SQLITE3_INTEGER); $stmt->bindValue(':is_flagged', $isFlagged, SQLITE3_INTEGER);
$stmt->bindValue(':flag_reason', $flagReason, SQLITE3_TEXT); $stmt->bindValue(':flag_reason', $flagReason, SQLITE3_TEXT);
if ($attachmentPath) {
$stmt->bindValue(':attachment_path', $attachmentPath, SQLITE3_TEXT);
$stmt->bindValue(':attachment_type', $attachmentMime, SQLITE3_TEXT);
$stmt->bindValue(':attachment_size', $attachmentSize, SQLITE3_INTEGER);
} else {
$stmt->bindValue(':attachment_path', null, SQLITE3_NULL);
$stmt->bindValue(':attachment_type', null, SQLITE3_NULL);
$stmt->bindValue(':attachment_size', null, SQLITE3_NULL);
}
$stmt->execute(); $stmt->execute();
$messageId = $db->lastInsertRowID(); $messageId = $db->lastInsertRowID();
@@ -1044,10 +1158,15 @@ if (isset($_POST['action']) || isset($_GET['action'])) {
logSecurityEvent($currentUserId, 'MESSAGE_FLAGGED', "Reason: $flagReason, Message ID: $messageId"); logSecurityEvent($currentUserId, 'MESSAGE_FLAGGED', "Reason: $flagReason, Message ID: $messageId");
} }
if ($attachmentPath) {
logSecurityEvent($currentUserId, 'ATTACHMENT_UPLOADED', "Message ID: $messageId, Size: $attachmentSize");
}
echo json_encode([ echo json_encode([
'success' => true, 'success' => true,
'message_id' => $messageId, 'message_id' => $messageId,
'timestamp' => date('Y-m-d H:i:s') 'timestamp' => date('Y-m-d H:i:s'),
'attachment_url' => $attachmentPath
]); ]);
exit; exit;
} }
@@ -1559,6 +1678,9 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') {
m.to_user_id, m.to_user_id,
m.message, m.message,
m.timestamp, m.timestamp,
m.attachment_path,
m.attachment_type,
m.attachment_size,
uf.username as from_username, uf.username as from_username,
uf.user_id as from_display_id, uf.user_id as from_display_id,
uf.age_group as from_age_group, uf.age_group as from_age_group,
@@ -1593,6 +1715,9 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') {
'to_user_id' => $row['to_user_id'], 'to_user_id' => $row['to_user_id'],
'message' => $row['message'], 'message' => $row['message'],
'timestamp' => $row['timestamp'], 'timestamp' => $row['timestamp'],
'attachment_url' => $row['attachment_path'] ?: null,
'attachment_type' => $row['attachment_type'] ?: null,
'attachment_size' => $row['attachment_size'] !== null ? (int)$row['attachment_size'] : null,
'from_username' => $row['from_username'], 'from_username' => $row['from_username'],
'from_display_name' => $row['from_username'] . '#' . $row['from_display_id'] 'from_display_name' => $row['from_username'] . '#' . $row['from_display_id']
]; ];
@@ -2403,11 +2528,12 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') {
border-top: 1px solid rgba(188, 118, 0, 0.18); border-top: 1px solid rgba(188, 118, 0, 0.18);
display: flex; display: flex;
gap: 12px; gap: 12px;
align-items: center; align-items: flex-end;
flex-wrap: wrap;
} }
.chat-input { .chat-input {
flex: 1; flex: 1 1 auto;
padding: 12px 16px; padding: 12px 16px;
border: 1px solid rgba(188, 118, 0, 0.28); border: 1px solid rgba(188, 118, 0, 0.28);
border-radius: 28px; border-radius: 28px;
@@ -2425,6 +2551,54 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') {
box-shadow: 0 0 0 3px rgba(240, 180, 0, 0.18); box-shadow: 0 0 0 3px rgba(240, 180, 0, 0.18);
} }
.chat-input-tools {
display: flex;
align-items: center;
gap: 10px;
}
.attach-button {
background: white;
border: 1px dashed rgba(240, 180, 0, 0.6);
color: var(--sun-700);
padding: 10px 16px;
border-radius: 24px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.attach-button:hover {
transform: translateY(-1px);
border-color: var(--sun-700);
box-shadow: 0 10px 18px rgba(240, 180, 0, 0.18);
}
.attachment-info {
display: flex;
align-items: center;
gap: 8px;
background: rgba(251, 191, 36, 0.18);
border: 1px solid rgba(240, 180, 0, 0.35);
border-radius: 20px;
padding: 6px 12px;
font-size: 13px;
color: var(--sun-800);
}
.attachment-remove {
background: none;
border: none;
color: #b91c1c;
font-size: 14px;
cursor: pointer;
padding: 0;
}
.attachment-remove:hover {
color: #7f1d1d;
}
.send-button { .send-button {
background: linear-gradient(135deg, var(--sun-500) 0%, var(--sun-700) 100%); background: linear-gradient(135deg, var(--sun-500) 0%, var(--sun-700) 100%);
color: white; color: white;
@@ -2448,6 +2622,30 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') {
box-shadow: none; box-shadow: none;
} }
.attachment-warning {
margin: 0 24px 12px;
color: #b91c1c;
font-size: 13px;
}
.message-attachment {
margin-top: 6px;
}
.message-attachment a {
display: inline-block;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 6px 18px rgba(60, 42, 0, 0.22);
background: rgba(255, 255, 255, 0.85);
}
.message-attachment img {
display: block;
max-width: 220px;
height: auto;
}
.empty-user-list, .empty-user-list,
.empty-messages, .empty-messages,
.loading-state, .loading-state,
@@ -2637,9 +2835,18 @@ if (isset($_GET['stream']) && $_GET['stream'] === 'events') {
</div> </div>
<div class="chat-input-container"> <div class="chat-input-container">
<div class="chat-input-tools">
<button type="button" class="attach-button" id="attachmentButton" title="Bild anhängen">📎 Bild</button>
<input type="file" id="attachmentInput" accept="image/jpeg" class="hidden" />
<div class="attachment-info hidden" id="attachmentInfo">
<span id="attachmentFileName"></span>
<button type="button" class="attachment-remove" id="attachmentClearBtn" aria-label="Anhang entfernen">✕</button>
</div>
</div>
<textarea class="chat-input" id="chatInput" placeholder="Nachricht schreiben..." rows="1" maxlength="1000"></textarea> <textarea class="chat-input" id="chatInput" placeholder="Nachricht schreiben..." rows="1" maxlength="1000"></textarea>
<button class="send-button" id="sendButton">Senden</button> <button class="send-button" id="sendButton">Senden</button>
</div> </div>
<div class="attachment-warning hidden" id="attachmentWarning"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -3263,10 +3470,18 @@ function renderMessages() {
container.innerHTML = state.messages.map(msg => { container.innerHTML = state.messages.map(msg => {
const isSent = msg.from_user_id === state.currentUserId; const isSent = msg.from_user_id === state.currentUserId;
const time = new Date(msg.timestamp).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); const time = new Date(msg.timestamp).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
const hasText = typeof msg.message === 'string' && msg.message.trim() !== '';
const attachmentUrl = msg.attachment_url;
const textHtml = hasText ? `<div class="message-text">${escapeHtml(msg.message)}</div>` : '';
const attachmentHtml = attachmentUrl
? `<div class="message-attachment"><a href="${escapeAttribute(attachmentUrl)}" target="_blank" rel="noopener"><img src="${escapeAttribute(attachmentUrl)}" alt="Gesendetes Bild"></a></div>`
: '';
return ` return `
<div class="message ${isSent ? 'message-sent' : 'message-received'}"> <div class="message ${isSent ? 'message-sent' : 'message-received'}">
<div class="message-text">${escapeHtml(msg.message)}</div> ${attachmentHtml}
${textHtml}
<div class="message-time">${time}</div> <div class="message-time">${time}</div>
</div> </div>
`; `;
@@ -3284,15 +3499,34 @@ async function sendMessage() {
const message = chatInputEl.value.trim(); const message = chatInputEl.value.trim();
if (!message || !state.selectedUserId) return; clearAttachmentWarning();
if (attachmentFile) {
const fileType = (attachmentFile.type || '').toLowerCase();
const fileName = attachmentFile.name || '';
const isJpeg = /^image\/jpe?g$/.test(fileType) || /\.jpe?g$/i.test(fileName);
if (!isJpeg) {
showAttachmentWarning('Nur JPG-Bilder sind erlaubt.');
clearAttachmentSelection();
return;
}
if (attachmentFile.size > ATTACHMENT_MAX_SIZE) {
showAttachmentWarning('Bild ist zu groß (max. 200 KB).');
clearAttachmentSelection();
return;
}
}
const formData = new FormData(); const formData = new FormData();
formData.append('action', 'send_message'); formData.append('action', 'send_message');
formData.append('to_user_id', state.selectedUserId); formData.append('to_user_id', state.selectedUserId);
formData.append('message', message); formData.append('message', message);
const response = await fetch('', { method: 'POST', body: formData }); if (attachmentFile) {
const result = await response.json(); formData.append('attachment', attachmentFile);
}
if (result.success) { if (result.success) {
chatInputEl.value = ''; chatInputEl.value = '';