This commit is contained in:
Ty Clifford
2026-06-08 12:26:08 -04:00
commit 33a176f18e
19 changed files with 5342 additions and 0 deletions
+168
View File
@@ -0,0 +1,168 @@
<?php
// api/auth.php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
sendCorsHeaders();
header('Content-Type: application/json');
header('Cache-Control: no-store');
$action = $_POST['action'] ?? $_GET['action'] ?? '';
switch ($action) {
case 'register':
handleRegister();
break;
case 'login':
handleLogin();
break;
case 'logout':
handleLogout();
break;
case 'check':
handleCheck();
break;
default:
jsonResponse(['error' => 'Unknown action'], 400);
}
function handleRegister(): never {
$db = getDB();
$config = getConfig();
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
$maxUser = getConfigVal('chat.max_username_length', 12);
$minPass = getConfigVal('security.min_password_length', 4);
if (!$username || !$password) {
jsonResponse(['error' => 'Username and password required'], 400);
}
if (strlen($username) > $maxUser) {
jsonResponse(['error' => "Username max {$maxUser} characters"], 400);
}
if (!preg_match('/^[a-zA-Z0-9_\-]+$/', $username)) {
jsonResponse(['error' => 'Username: letters, numbers, _ - only'], 400);
}
if (strlen($password) < $minPass) {
jsonResponse(['error' => "Password min {$minPass} characters"], 400);
}
// Check if username exists
$stmt = $db->prepare("SELECT id FROM users WHERE username = ? COLLATE NOCASE");
$stmt->execute([$username]);
if ($stmt->fetch()) {
jsonResponse(['error' => 'Username already taken'], 409);
}
$colors = getConfigVal('colors.user_palette', ['#00ff9f']);
$color = $colors[array_rand($colors)];
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => getConfigVal('security.bcrypt_cost', 10)]);
$stmt = $db->prepare("INSERT INTO users (username, password_hash, color, created_at) VALUES (?, ?, ?, ?)");
$stmt->execute([$username, $hash, $color, time()]);
$userId = $db->lastInsertId();
createSession($db, $userId, $username, $color);
jsonResponse(['success' => true, 'username' => $username, 'color' => $color]);
}
function handleLogin(): never {
$db = getDB();
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
if (!$username || !$password) {
jsonResponse(['error' => 'Username and password required'], 400);
}
$stmt = $db->prepare("SELECT * FROM users WHERE username = ? COLLATE NOCASE");
$stmt->execute([$username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user || !password_verify($password, $user['password_hash'])) {
jsonResponse(['error' => 'Invalid username or password'], 401);
}
// Check for existing session (single login enforcement)
if (getConfigVal('chat.session_lock_ip') || getConfigVal('chat.session_lock_cookie')) {
$sessionTimeout = getConfigVal('security.session_timeout_hours', 24) * 3600;
$cutoff = time() - $sessionTimeout;
$stmt2 = $db->prepare("SELECT id, ip FROM sessions WHERE user_id = ? AND last_active > ?");
$stmt2->execute([$user['id'], $cutoff]);
$existing = $stmt2->fetch(PDO::FETCH_ASSOC);
if ($existing) {
$currentIP = clientIP();
// Allow same IP to re-login (refresh), block different IP
if (getConfigVal('chat.session_lock_ip') && $existing['ip'] !== $currentIP) {
jsonResponse(['error' => 'Already logged in from another location'], 403);
}
// Kill old session and create new
$db->prepare("DELETE FROM sessions WHERE user_id = ?")->execute([$user['id']]);
}
}
createSession($db, $user['id'], $user['username'], $user['color']);
jsonResponse(['success' => true, 'username' => $user['username'], 'color' => $user['color']]);
}
function createSession(PDO $db, int $userId, string $username, string $color): void {
$sid = bin2hex(random_bytes(32));
$ip = clientIP();
// Remove any existing sessions for this user
$db->prepare("DELETE FROM sessions WHERE user_id = ?")->execute([$userId]);
$stmt = $db->prepare("INSERT INTO sessions (id, user_id, ip, created_at, last_active) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$sid, $userId, $ip, time(), time()]);
// Update last_seen
$db->prepare("UPDATE users SET last_seen = ? WHERE id = ?")->execute([time(), $userId]);
$secure = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
setcookie('cyberchat_sid', $sid, [
'expires' => time() + (getConfigVal('security.session_timeout_hours', 24) * 3600),
'path' => '/',
'httponly' => true,
'samesite' => 'Strict',
'secure' => $secure,
]);
}
function handleLogout(): never {
$db = getDB();
$sid = $_COOKIE['cyberchat_sid'] ?? '';
if ($sid) {
$db->prepare("DELETE FROM sessions WHERE id = ?")->execute([$sid]);
}
setcookie('cyberchat_sid', '', time() - 3600, '/', '', false, true);
jsonResponse(['success' => true]);
}
function handleCheck(): never {
$db = getDB();
$sid = $_COOKIE['cyberchat_sid'] ?? '';
if (!$sid) jsonResponse(['authenticated' => false]);
$sessionTimeout = getConfigVal('security.session_timeout_hours', 24) * 3600;
$cutoff = time() - $sessionTimeout;
$stmt = $db->prepare("
SELECT s.*, u.username, u.color
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.id = ? AND s.last_active > ?
");
$stmt->execute([$sid, $cutoff]);
$session = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$session) jsonResponse(['authenticated' => false]);
if (getConfigVal('chat.session_lock_ip') && $session['ip'] !== clientIP()) {
jsonResponse(['authenticated' => false, 'reason' => 'ip_mismatch']);
}
jsonResponse(['authenticated' => true, 'username' => $session['username'], 'color' => $session['color']]);
}
+32
View File
@@ -0,0 +1,32 @@
<?php
// api/config.php - Serve safe config values to frontend
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
sendCorsHeaders();
header('Content-Type: application/json');
header('Cache-Control: no-store');
$config = getConfig();
// Only expose safe/needed values to frontend
$safe = [
'chat' => [
'max_message_length' => $config['chat']['max_message_length'] ?? 500,
'max_username_length' => $config['chat']['max_username_length'] ?? 12,
'poll_interval_ms' => $config['chat']['poll_interval_ms'] ?? 2000,
],
'ui' => $config['ui'] ?? [],
'security' => [
'min_password_length' => $config['security']['min_password_length'] ?? 4,
],
'colors' => $config['colors'] ?? [],
'voice' => [
'enabled' => $config['voice']['enabled'] ?? true,
'max_duration_seconds' => $config['voice']['max_duration_seconds'] ?? 60,
'max_upload_bytes' => $config['voice']['max_upload_bytes'] ?? 12582912,
'auto_play_default' => $config['voice']['auto_play_default'] ?? true,
],
];
echo json_encode($safe);
+234
View File
@@ -0,0 +1,234 @@
<?php
// api/messages.php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
sendCorsHeaders();
header('Content-Type: application/json');
header('Cache-Control: no-store');
// Run archiving silently
try { archiveYesterdayIfNeeded(); } catch (Throwable $e) { /* silent */ }
$action = $_POST['action'] ?? $_GET['action'] ?? '';
switch ($action) {
case 'send':
handleSend();
break;
case 'send_voice':
handleSendVoice();
break;
case 'poll':
handlePoll();
break;
case 'history':
handleHistory();
break;
default:
jsonResponse(['error' => 'Unknown action'], 400);
}
function messagePayload(array $row): array {
return [
'id' => (int)$row['id'],
'username' => $row['username'],
'color' => $row['color'],
'message' => $row['message'],
'message_type' => $row['message_type'] ?? 'text',
'voice_url' => !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null,
'voice_mime' => $row['voice_mime'] ?? null,
'voice_duration' => isset($row['voice_duration']) ? (float)$row['voice_duration'] : null,
'created_at' => (int)$row['created_at'],
'day_key' => $row['day_key'] ?? dayKey(),
];
}
function handleSend(): never {
$session = authRequired();
$db = getDB();
$message = trim($_POST['message'] ?? '');
$maxLen = getConfigVal('chat.max_message_length', 500);
if (!$message) jsonResponse(['error' => 'Empty message'], 400);
if (strlen($message) > $maxLen) {
jsonResponse(['error' => "Message too long (max {$maxLen} chars)"], 400);
}
$dayKey = dayKey();
$stmt = $db->prepare("
INSERT INTO messages (user_id, username, color, message, created_at, day_key)
VALUES (?, ?, ?, ?, ?, ?)
");
$stmt->execute([
$session['uid'],
$session['username'],
$session['color'],
$message,
time(),
$dayKey
]);
$id = $db->lastInsertId();
jsonResponse(['success' => true, 'message' => messagePayload([
'id' => $id, 'username' => $session['username'], 'color' => $session['color'],
'message' => $message, 'message_type' => 'text', 'created_at' => time(), 'day_key' => $dayKey,
])]);
}
function handleSendVoice(): never {
$session = authRequired();
if (!getConfigVal('voice.enabled', true)) {
jsonResponse(['error' => 'Voice clips are disabled'], 403);
}
if (empty($_FILES['voice']) || !is_array($_FILES['voice'])) {
jsonResponse(['error' => 'No voice clip uploaded'], 400);
}
$file = $_FILES['voice'];
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
jsonResponse(['error' => 'Voice upload failed'], 400);
}
$maxBytes = (int)getConfigVal('voice.max_upload_bytes', 12582912);
if (($file['size'] ?? 0) < 1 || $file['size'] > $maxBytes) {
jsonResponse(['error' => 'Voice clip exceeds the upload limit'], 400);
}
$duration = (float)($_POST['duration'] ?? 0);
$maxSeconds = max(1, (int)getConfigVal('voice.max_duration_seconds', 60));
if ($duration <= 0 || $duration > $maxSeconds + 1) {
jsonResponse(['error' => "Voice clip must be {$maxSeconds} seconds or less"], 400);
}
$mime = class_exists('finfo')
? (string)(new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name'])
: '';
$header = (string)file_get_contents($file['tmp_name'], false, null, 0, 12);
if (str_starts_with($header, "\x1A\x45\xDF\xA3")) {
$mime = 'audio/webm';
} elseif (str_starts_with($header, 'RIFF') && substr($header, 8, 4) === 'WAVE') {
$mime = 'audio/wav';
}
$allowed = [
'audio/webm' => 'webm',
'video/webm' => 'webm',
'audio/wav' => 'wav',
'audio/x-wav' => 'wav',
'audio/wave' => 'wav',
];
if (!isset($allowed[$mime])) {
jsonResponse(['error' => 'Voice clips must be WAV or WebM'], 400);
}
$dir = voiceUploadDir();
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
jsonResponse(['error' => 'Voice storage is unavailable'], 500);
}
$filename = bin2hex(random_bytes(20)) . '.' . $allowed[$mime];
if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) {
jsonResponse(['error' => 'Could not store voice clip'], 500);
}
$db = getDB();
$created = time();
$dayKey = dayKey();
try {
$stmt = $db->prepare("
INSERT INTO messages
(user_id, username, color, message, message_type, voice_file, voice_mime, voice_duration, created_at, day_key)
VALUES (?, ?, ?, ?, 'voice', ?, ?, ?, ?, ?)
");
$stmt->execute([
$session['uid'], $session['username'], $session['color'], '[Voice clip]',
$filename, $mime === 'video/webm' ? 'audio/webm' : $mime, $duration, $created, $dayKey
]);
} catch (Throwable $e) {
deleteVoiceFile($filename);
throw $e;
}
jsonResponse(['success' => true, 'message' => messagePayload([
'id' => $db->lastInsertId(), 'username' => $session['username'], 'color' => $session['color'],
'message' => '[Voice clip]', 'message_type' => 'voice', 'voice_file' => $filename,
'voice_mime' => $mime === 'video/webm' ? 'audio/webm' : $mime,
'voice_duration' => $duration, 'created_at' => $created, 'day_key' => $dayKey,
])]);
}
function handlePoll(): never {
$session = authRequired();
$db = getDB();
$since = (int)($_GET['since'] ?? 0);
$dayKey = dayKey();
$limit = getConfigVal('chat.messages_per_page', 100);
if ($since === 0) {
// Initial load — get last N messages for today
$stmt = $db->prepare("
SELECT id, username, color, message, message_type, voice_file, voice_mime, voice_duration, created_at, day_key
FROM messages
WHERE day_key = ?
ORDER BY created_at DESC, id DESC
LIMIT ?
");
$stmt->execute([$dayKey, $limit]);
$rows = array_reverse($stmt->fetchAll(PDO::FETCH_ASSOC));
} else {
// Poll for new messages since last id
$stmt = $db->prepare("
SELECT id, username, color, message, message_type, voice_file, voice_mime, voice_duration, created_at, day_key
FROM messages
WHERE day_key = ? AND id > ?
ORDER BY created_at ASC, id ASC
LIMIT ?
");
$stmt->execute([$dayKey, $since, $limit]);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
// Cast types
$messages = array_map('messagePayload', $rows);
// Get online user count (active in last 5 minutes)
$cutoff5min = time() - 300;
$onlineStmt = $db->prepare("SELECT COUNT(*) FROM sessions WHERE last_active > ?");
$onlineStmt->execute([$cutoff5min]);
$onlineCount = (int)$onlineStmt->fetchColumn();
jsonResponse([
'messages' => $messages,
'online' => $onlineCount,
'day_key' => $dayKey,
'server_time' => time(),
]);
}
function handleHistory(): never {
// Return list of archived days
$archiveDir = ROOT_DIR . '/' . getConfigVal('archive.archive_dir', 'archive');
$days = [];
if (is_dir($archiveDir)) {
$years = glob($archiveDir . '/*', GLOB_ONLYDIR);
foreach ($years as $yearDir) {
$year = basename($yearDir);
$months = glob($yearDir . '/*', GLOB_ONLYDIR);
foreach ($months as $monthDir) {
$month = basename($monthDir);
$files = glob($monthDir . '/*.sqlite');
foreach ($files as $file) {
$day = basename($file, '.sqlite');
$days[] = "$year-$month-$day";
}
}
}
}
rsort($days);
jsonResponse(['days' => $days]);
}
+80
View File
@@ -0,0 +1,80 @@
<?php
// api/ping.php — Server diagnostics endpoint
// Visit this URL directly in your browser to check if PHP + SQLite are working
// e.g. http://yourdomain.com/chat/api/ping.php
header('Content-Type: application/json');
header('Cache-Control: no-store');
header('Access-Control-Allow-Origin: *');
$results = [];
// 1. PHP version
$results['php_version'] = PHP_VERSION;
$results['php_ok'] = version_compare(PHP_VERSION, '8.0.0', '>=');
// 2. PDO SQLite
$results['pdo_sqlite'] = extension_loaded('pdo_sqlite');
// 3. ROOT_DIR and config.json
$root = dirname(__DIR__);
$configFile = $root . '/config.json';
$results['config_exists'] = file_exists($configFile);
if ($results['config_exists']) {
$raw = file_get_contents($configFile);
$cfg = json_decode($raw, true);
$results['config_valid_json'] = ($cfg !== null);
} else {
$results['config_valid_json'] = false;
}
// 4. db/ directory writable
$dbDir = $root . '/db';
if (!is_dir($dbDir)) {
@mkdir($dbDir, 0755, true);
}
$results['db_dir_exists'] = is_dir($dbDir);
$results['db_dir_writable'] = is_writable($dbDir);
// 5. archive/ directory writable
$archiveDir = $root . '/archive';
if (!is_dir($archiveDir)) {
@mkdir($archiveDir, 0755, true);
}
$results['archive_dir_exists'] = is_dir($archiveDir);
$results['archive_dir_writable'] = is_writable($archiveDir);
// 6. Try creating SQLite DB
$results['sqlite_create'] = false;
$results['sqlite_error'] = null;
if ($results['pdo_sqlite'] && $results['db_dir_writable']) {
try {
$testDb = new PDO('sqlite:' . $dbDir . '/chat.sqlite');
$testDb->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$testDb->exec('PRAGMA journal_mode=WAL');
$testDb->exec('CREATE TABLE IF NOT EXISTS _ping_test (id INTEGER PRIMARY KEY)');
$results['sqlite_create'] = true;
} catch (Exception $e) {
$results['sqlite_error'] = $e->getMessage();
}
}
// 7. Session support
$results['sessions_available'] = function_exists('session_start');
// 8. Overall status
$results['all_ok'] = (
$results['php_ok'] &&
$results['pdo_sqlite'] &&
$results['config_exists'] &&
$results['config_valid_json'] &&
$results['db_dir_writable'] &&
$results['sqlite_create']
);
$results['root_dir'] = $root;
$results['server_time'] = date('Y-m-d H:i:s T');
http_response_code($results['all_ok'] ? 200 : 500);
echo json_encode($results, JSON_PRETTY_PRINT);