- Voice enhancements, user management, payment method

This commit is contained in:
Ty Clifford
2026-06-08 15:44:15 -04:00
parent 9e3ff61eb7
commit 063b8dc3e8
26 changed files with 2832 additions and 927 deletions
+63
View File
@@ -0,0 +1,63 @@
<?php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
require_once ROOT_DIR . '/lib/mailer.php';
sendCorsHeaders();
$user = authRequired();
$action = $_POST['action'] ?? $_GET['action'] ?? 'profile';
switch ($action) {
case 'profile':
jsonResponse(['user' => userPublicPayload($user)]);
case 'request_email_verification':
$email = strtolower(trim((string)($_POST['email'] ?? '')));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) jsonResponse(['error' => 'Enter a valid email'], 400);
$stmt = getDB()->prepare("SELECT id FROM users WHERE email=? AND id!=?");
$stmt->execute([$email, $user['id']]);
if ($stmt->fetchColumn()) jsonResponse(['error' => 'That email is already in use'], 409);
try {
sendVerificationEmail($user, $email);
} catch (Throwable $e) {
jsonResponse(['error' => 'Verification email could not be sent: ' . $e->getMessage()], 503);
}
jsonResponse(['success' => true, 'message' => 'Verification email sent']);
case 'verify_email':
$token = trim((string)($_POST['token'] ?? $_GET['token'] ?? ''));
$code = trim((string)($_POST['code'] ?? ''));
if (!consumeEmailVerification($user, $token, $code)) {
jsonResponse(['error' => 'Invalid or expired verification code'], 400);
}
$fresh = userById((int)$user['id']);
if (!empty($fresh['stripe_customer_id'])) {
try {
require_once ROOT_DIR . '/lib/stripe.php';
stripeRequest('POST', 'customers/' . rawurlencode($fresh['stripe_customer_id']), ['email' => $fresh['email']]);
} catch (Throwable $e) {
error_log('Stripe email update failed: ' . $e->getMessage());
}
}
jsonResponse(['success' => true, 'user' => userPublicPayload($fresh)]);
case 'set_color':
if (!featureValue($user, 'username_color', false)) jsonResponse(['error' => 'Your plan does not include custom colors'], 403);
$color = trim((string)($_POST['color'] ?? ''));
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) jsonResponse(['error' => 'Invalid color'], 400);
getDB()->beginTransaction();
getDB()->prepare("UPDATE users SET color=? WHERE id=?")->execute([$color, $user['id']]);
getDB()->prepare("UPDATE messages SET color=? WHERE user_id=?")->execute([$color, $user['id']]);
getDB()->commit();
jsonResponse(['success' => true, 'user' => userPublicPayload(userById((int)$user['id']))]);
case 'set_2fa':
if (!featureValue($user, 'email_2fa', false)) jsonResponse(['error' => 'Your plan does not include email 2FA'], 403);
if (empty($user['email_verified_at'])) jsonResponse(['error' => 'Verify an email first'], 400);
$enabled = filter_var($_POST['enabled'] ?? false, FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
getDB()->prepare("UPDATE users SET two_factor_enabled=? WHERE id=?")->execute([$enabled, $user['id']]);
jsonResponse(['success' => true, 'user' => userPublicPayload(userById((int)$user['id']))]);
default:
jsonResponse(['error' => 'Unknown action'], 400);
}
+59
View File
@@ -0,0 +1,59 @@
<?php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
sendCorsHeaders();
$user = authRequired();
$action = $_POST['action'] ?? $_GET['action'] ?? 'list';
$search = trim((string)($_GET['search'] ?? $_POST['search'] ?? ''));
$rows = personalArchiveRows($user, $search, min(1000, max(1, (int)($_GET['limit'] ?? 500))));
if ($action === 'export') {
if (!featureValue($user, 'text_export', false)) jsonResponse(['error' => 'Your plan does not include exports'], 403);
$canVoiceExport = (bool)featureValue($user, 'voice_export', false);
$format = strtolower((string)($_GET['format'] ?? 'json'));
$export = array_map(function(array $row) use ($canVoiceExport): array {
$item = [
'id' => (int)$row['id'], 'group_id' => $row['group_id'] ? (int)$row['group_id'] : null,
'username' => $row['username'], 'message' => $row['message'],
'type' => $row['message_type'], 'created_at' => date(DATE_ATOM, (int)$row['created_at']),
];
if ($row['message_type'] === 'voice' && $canVoiceExport && !empty($row['voice_file'])) {
$item['voice_url'] = appBaseUrl() . '/' . voicePublicPath($row['voice_file']);
}
return $item;
}, $rows);
$filename = 'cyberchat-archive-' . date('Y-m-d');
if ($format === 'csv') {
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '.csv"');
$out = fopen('php://output', 'w');
fputcsv($out, ['id', 'group_id', 'username', 'message', 'type', 'created_at', 'voice_url']);
foreach ($export as $row) fputcsv($out, [
$row['id'], $row['group_id'], $row['username'], $row['message'], $row['type'],
$row['created_at'], $row['voice_url'] ?? '',
]);
fclose($out);
exit;
}
header('Content-Type: application/json; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '.json"');
echo json_encode(['exported_at' => date(DATE_ATOM), 'messages' => $export], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
exit;
}
$payload = array_map(function(array $row): array {
return [
'id' => (int)$row['id'], 'group_id' => $row['group_id'] ? (int)$row['group_id'] : null,
'message' => $row['message'], 'message_type' => $row['message_type'],
'voice_url' => $row['message_type'] === 'voice' && !empty($row['voice_file']) ? voicePublicPath($row['voice_file']) : null,
'voice_duration' => $row['voice_duration'] !== null ? (float)$row['voice_duration'] : null,
'created_at' => (int)$row['created_at'], 'day_key' => $row['day_key'],
];
}, $rows);
jsonResponse([
'messages' => $payload,
'retention_days' => max(30, (int)featureValue($user, 'text_archive_days', 30)),
'can_export' => (bool)featureValue($user, 'text_export', false),
'voice_included' => (bool)featureValue($user, 'voice_archive', false),
]);
+102 -145
View File
@@ -1,168 +1,125 @@
<?php
// api/auth.php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
require_once ROOT_DIR . '/lib/mailer.php';
sendCorsHeaders();
header('Content-Type: application/json');
header('Cache-Control: no-store');
$action = $_POST['action'] ?? $_GET['action'] ?? '';
match ($action) {
'register' => registerUser(),
'login' => loginUser(),
'verify_2fa' => verifyTwoFactor(),
'logout' => logoutUser(),
'check' => checkSession(),
default => jsonResponse(['error' => 'Unknown action'], 400),
};
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 registerUser(): never {
$db = getDB();
$username = trim((string)($_POST['username'] ?? ''));
$password = (string)($_POST['password'] ?? '');
$maxUser = (int)getConfigVal('chat.max_username_length', 12);
$minPass = (int)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, _ and - only'], 400);
}
if (strlen($password) < $minPass) jsonResponse(['error' => "Password min $minPass characters"], 400);
$check = $db->prepare("SELECT 1 FROM users WHERE username=? COLLATE NOCASE");
$check->execute([$username]);
if ($check->fetchColumn()) jsonResponse(['error' => 'Username already taken'], 409);
$palette = (array)getConfigVal('colors.user_palette', ['#00ff9f']);
$color = $palette[array_rand($palette)];
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => (int)getConfigVal('security.bcrypt_cost', 10)]);
$freeId = (int)$db->query("SELECT id FROM plans WHERE slug='free'")->fetchColumn();
$db->prepare("INSERT INTO users (username,password_hash,color,plan_id,created_at) VALUES (?,?,?,?,?)")
->execute([$username, $hash, $color, $freeId, time()]);
$userId = (int)$db->lastInsertId();
createUserSession($db, $userId);
$user = userById($userId);
trackEvent('registration', '/api/auth.php', $userId);
jsonResponse(['success' => true, 'user' => userPublicPayload($user)]);
}
function handleRegister(): never {
function loginUser(): 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");
$username = trim((string)($_POST['username'] ?? ''));
$password = (string)($_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]);
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);
$user = $stmt->fetch();
if (!$user || !password_verify($password, $user['password_hash'])) {
trackEvent('login_failed', '/api/auth.php');
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']]);
if (getConfigVal('chat.session_lock_ip', true) || getConfigVal('chat.session_lock_cookie', true)) {
$cutoff = time() - ((int)getConfigVal('security.session_timeout_hours', 24) * 3600);
$existingStmt = $db->prepare("SELECT id,ip FROM sessions WHERE user_id=? AND last_active>?");
$existingStmt->execute([$user['id'], $cutoff]);
$existing = $existingStmt->fetch();
if ($existing && getConfigVal('chat.session_lock_ip', true) && $existing['ip'] !== clientIP()) {
jsonResponse(['error' => 'Already logged in from another location'], 403);
}
}
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]);
if (!empty($user['two_factor_enabled']) && featureValue($user, 'email_2fa', false)) {
if (empty($user['email_verified_at']) || empty($user['email'])) {
jsonResponse(['error' => 'Two-factor authentication requires a verified email'], 403);
}
$challengeId = bin2hex(random_bytes(24));
$code = (string)random_int(100000, 999999);
$db->prepare("DELETE FROM login_challenges WHERE user_id=?")->execute([$user['id']]);
$db->prepare("INSERT INTO login_challenges (id,user_id,code_hash,expires_at,created_at) VALUES (?,?,?,?,?)")
->execute([$challengeId, $user['id'], password_hash($code, PASSWORD_DEFAULT), time() + 600, time()]);
try {
sendTwoFactorCode($user, $challengeId, $code);
} catch (Throwable $e) {
$db->prepare("DELETE FROM login_challenges WHERE id=?")->execute([$challengeId]);
jsonResponse(['error' => 'Could not send the login code: ' . $e->getMessage()], 503);
}
jsonResponse(['success' => true, 'requires_2fa' => true, 'challenge' => $challengeId]);
}
setcookie('cyberchat_sid', '', time() - 3600, '/', '', false, true);
createUserSession($db, (int)$user['id']);
trackEvent('login', '/api/auth.php', (int)$user['id']);
jsonResponse(['success' => true, 'user' => userPublicPayload(userById((int)$user['id']))]);
}
function verifyTwoFactor(): never {
$challenge = trim((string)($_POST['challenge'] ?? ''));
$code = trim((string)($_POST['code'] ?? ''));
if ($challenge === '' || !preg_match('/^\d{6}$/', $code)) jsonResponse(['error' => 'Enter the six-digit code'], 400);
$db = getDB();
$stmt = $db->prepare("SELECT * FROM login_challenges WHERE id=? AND expires_at>?");
$stmt->execute([$challenge, time()]);
$row = $stmt->fetch();
if (!$row || (int)$row['attempts'] >= 5 || !password_verify($code, $row['code_hash'])) {
if ($row) $db->prepare("UPDATE login_challenges SET attempts=attempts+1 WHERE id=?")->execute([$challenge]);
jsonResponse(['error' => 'Invalid or expired login code'], 401);
}
$db->prepare("DELETE FROM login_challenges WHERE id=?")->execute([$challenge]);
createUserSession($db, (int)$row['user_id']);
trackEvent('login_2fa', '/api/auth.php', (int)$row['user_id']);
jsonResponse(['success' => true, 'user' => userPublicPayload(userById((int)$row['user_id']))]);
}
function logoutUser(): never {
$sid = $_COOKIE['cyberchat_sid'] ?? '';
if ($sid !== '') getDB()->prepare("DELETE FROM sessions WHERE id=?")->execute([$sid]);
setcookie('cyberchat_sid', '', [
'expires' => time() - 3600, 'path' => '/', 'httponly' => true, 'samesite' => 'Strict',
'secure' => !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off',
]);
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']]);
function checkSession(): never {
$user = authUser(false);
jsonResponse($user
? ['authenticated' => true, 'user' => userPublicPayload($user)]
: ['authenticated' => false]);
}
+69
View File
@@ -0,0 +1,69 @@
<?php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
require_once ROOT_DIR . '/lib/stripe.php';
sendCorsHeaders();
$user = authRequired();
$action = $_POST['action'] ?? $_GET['action'] ?? 'status';
$subscriptionsEnabled = (bool)getConfigVal('subscriptions.enabled', true);
$hasExistingBilling = !empty($user['stripe_customer_id']) || !in_array($user['subscription_status'] ?? 'free', ['free', 'canceled'], true);
switch ($action) {
case 'status':
$availablePlans = [];
if ($subscriptionsEnabled) {
foreach (plans(true) as $plan) {
if ($plan['slug'] === 'free') continue;
$availablePlans[] = [
'id' => $plan['id'], 'slug' => $plan['slug'], 'name' => $plan['name'],
'description' => $plan['description'], 'price_cents' => $plan['price_cents'],
'currency' => $plan['currency'], 'interval' => $plan['billing_interval'],
'features' => $plan['features'], 'checkout_ready' => !empty($plan['stripe_price_id']),
];
}
}
jsonResponse([
'subscriptions_enabled' => $subscriptionsEnabled,
'can_manage' => $hasExistingBilling,
'plans' => $availablePlans,
'user' => userPublicPayload($user),
]);
case 'checkout':
if (!$subscriptionsEnabled) jsonResponse(['error' => 'New subscriptions are not currently available'], 403);
if (in_array($user['subscription_status'] ?? 'free', ['active', 'trialing', 'past_due'], true)) {
jsonResponse(['error' => 'Use Manage in Stripe to change an existing subscription'], 409);
}
$planId = (int)($_POST['plan_id'] ?? 0);
$plan = planById($planId);
if (!$plan || !$plan['active'] || $plan['slug'] === 'free') jsonResponse(['error' => 'Plan not available'], 404);
try {
jsonResponse(['success' => true, 'url' => createStripeCheckout($user, $plan)]);
} catch (Throwable $e) {
jsonResponse(['error' => $e->getMessage()], 400);
}
case 'portal':
if (!$hasExistingBilling) jsonResponse(['error' => 'No subscription is connected'], 404);
try {
jsonResponse(['success' => true, 'url' => createStripePortal($user)]);
} catch (Throwable $e) {
jsonResponse(['error' => $e->getMessage()], 400);
}
case 'cancel':
if (empty($user['stripe_subscription_id'])) jsonResponse(['error' => 'No active subscription'], 404);
try {
$subscription = stripeRequest('POST', 'subscriptions/' . rawurlencode($user['stripe_subscription_id']), [
'cancel_at_period_end' => 'true',
]);
syncSubscriptionFromStripe($subscription);
jsonResponse(['success' => true, 'user' => userPublicPayload(userById((int)$user['id']))]);
} catch (Throwable $e) {
jsonResponse(['error' => $e->getMessage()], 400);
}
default:
jsonResponse(['error' => 'Unknown action'], 400);
}
+6 -15
View File
@@ -1,32 +1,23 @@
<?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 = [
jsonResponse([
'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,
'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'] ?? [],
'security' => ['min_password_length' => $config['security']['min_password_length'] ?? 4],
'colors' => ['user_palette' => $config['colors']['user_palette'] ?? []],
'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);
'subscriptions' => ['enabled' => $config['subscriptions']['enabled'] ?? true],
]);
+98
View File
@@ -0,0 +1,98 @@
<?php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
sendCorsHeaders();
$user = authRequired();
$action = $_POST['action'] ?? $_GET['action'] ?? 'list';
switch ($action) {
case 'list':
jsonResponse(['groups' => userGroups((int)$user['id']), 'member_limit' => (int)featureValue($user, 'group_member_limit', 2)]);
case 'create':
$name = trim((string)($_POST['name'] ?? ''));
$names = array_values(array_unique(array_filter(array_map('trim', explode(',', (string)($_POST['members'] ?? ''))))));
if ($name === '' || strlen($name) > 40) jsonResponse(['error' => 'Group name must be 1-40 characters'], 400);
$limit = max(2, (int)featureValue($user, 'group_member_limit', 2));
if (count($names) + 1 > $limit) jsonResponse(['error' => "Your plan allows $limit group members including you"], 403);
$memberIds = [];
$lookup = getDB()->prepare("SELECT id FROM users WHERE username=? COLLATE NOCASE");
foreach ($names as $username) {
if (strcasecmp($username, $user['username']) === 0) continue;
$lookup->execute([$username]);
$id = (int)$lookup->fetchColumn();
if (!$id) jsonResponse(['error' => "User '$username' was not found"], 404);
$memberIds[$id] = $id;
}
if (!$memberIds) jsonResponse(['error' => 'Add at least one other user to create a group'], 400);
if (count($memberIds) + 1 > $limit) jsonResponse(['error' => "Your plan allows $limit group members including you"], 403);
$db = getDB();
$db->beginTransaction();
$db->prepare("INSERT INTO chat_groups (owner_id,name,created_at,updated_at) VALUES (?,?,?,?)")
->execute([$user['id'], $name, time(), time()]);
$groupId = (int)$db->lastInsertId();
$insert = $db->prepare("INSERT INTO group_members (group_id,user_id,role,joined_at) VALUES (?,?,?,?)");
$insert->execute([$groupId, $user['id'], 'owner', time()]);
foreach ($memberIds as $id) $insert->execute([$groupId, $id, 'member', time()]);
$db->commit();
jsonResponse(['success' => true, 'groups' => userGroups((int)$user['id'])]);
case 'add_member':
$groupId = normalizeGroupId($_POST['group_id'] ?? null);
$username = trim((string)($_POST['username'] ?? ''));
$groupStmt = getDB()->prepare("SELECT * FROM chat_groups WHERE id=? AND owner_id=?");
$groupStmt->execute([$groupId, $user['id']]);
$group = $groupStmt->fetch();
if (!$group) jsonResponse(['error' => 'Only the group owner can add members'], 403);
$limit = max(2, (int)featureValue($user, 'group_member_limit', 2));
$countStmt = getDB()->prepare("SELECT COUNT(*) FROM group_members WHERE group_id=?");
$countStmt->execute([$groupId]);
if ((int)$countStmt->fetchColumn() >= $limit) jsonResponse(['error' => "This group has reached your $limit-member limit"], 403);
$lookup = getDB()->prepare("SELECT id FROM users WHERE username=? COLLATE NOCASE");
$lookup->execute([$username]);
$memberId = (int)$lookup->fetchColumn();
if (!$memberId) jsonResponse(['error' => 'User not found'], 404);
getDB()->prepare("INSERT OR IGNORE INTO group_members (group_id,user_id,role,joined_at) VALUES (?,?,'member',?)")
->execute([$groupId, $memberId, time()]);
getDB()->prepare("UPDATE chat_groups SET updated_at=? WHERE id=?")->execute([time(), $groupId]);
jsonResponse(['success' => true, 'groups' => userGroups((int)$user['id'])]);
case 'remove_member':
$groupId = normalizeGroupId($_POST['group_id'] ?? null);
$memberId = (int)($_POST['user_id'] ?? 0);
$stmt = getDB()->prepare("SELECT owner_id FROM chat_groups WHERE id=?");
$stmt->execute([$groupId]);
$ownerId = (int)$stmt->fetchColumn();
if (!$ownerId) jsonResponse(['error' => 'Group not found'], 404);
if ($ownerId !== (int)$user['id'] && $memberId !== (int)$user['id']) jsonResponse(['error' => 'Not allowed'], 403);
if ($memberId === $ownerId) jsonResponse(['error' => 'The owner must delete the group instead'], 400);
getDB()->prepare("DELETE FROM group_members WHERE group_id=? AND user_id=?")->execute([$groupId, $memberId]);
jsonResponse(['success' => true, 'groups' => userGroups((int)$user['id'])]);
case 'delete':
$groupId = normalizeGroupId($_POST['group_id'] ?? null);
$db = getDB();
$owner = $db->prepare("SELECT 1 FROM chat_groups WHERE id=? AND owner_id=?");
$owner->execute([$groupId, $user['id']]);
if (!$owner->fetchColumn()) jsonResponse(['error' => 'Only the group owner can delete this group'], 403);
$voice = $db->prepare("SELECT voice_file FROM messages WHERE group_id=? AND voice_file IS NOT NULL");
$voice->execute([$groupId]);
$voiceFiles = $voice->fetchAll();
$db->beginTransaction();
try {
$db->prepare("DELETE FROM recording_status WHERE group_key=?")->execute([groupKey($groupId)]);
$db->prepare("DELETE FROM messages WHERE group_id=?")->execute([$groupId]);
$db->prepare("DELETE FROM group_members WHERE group_id=?")->execute([$groupId]);
$db->prepare("DELETE FROM chat_groups WHERE id=?")->execute([$groupId]);
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) $db->rollBack();
throw $e;
}
foreach ($voiceFiles as $row) deleteVoiceFile($row['voice_file'] ?? null);
jsonResponse(['success' => true, 'groups' => userGroups((int)$user['id'])]);
default:
jsonResponse(['error' => 'Unknown action'], 400);
}
+116 -171
View File
@@ -1,37 +1,24 @@
<?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 */ }
try { archiveYesterdayIfNeeded(); } catch (Throwable $e) { error_log($e->getMessage()); }
$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);
case 'send': sendTextMessage();
case 'send_voice': sendVoiceMessage();
case 'recording': setRecordingStatus();
case 'poll': pollMessages();
case 'history': archiveDays();
default: jsonResponse(['error' => 'Unknown action'], 400);
}
function messagePayload(array $row): array {
return [
'id' => (int)$row['id'],
'group_id' => isset($row['group_id']) && $row['group_id'] !== null ? (int)$row['group_id'] : null,
'username' => $row['username'],
'color' => $row['color'],
'message' => $row['message'],
@@ -44,191 +31,149 @@ function messagePayload(array $row): array {
];
}
function handleSend(): never {
$session = authRequired();
function requestedGroup(array $user): ?int {
$groupId = normalizeGroupId($_POST['group_id'] ?? $_GET['group_id'] ?? null);
requireGroupAccess($user, $groupId);
return $groupId;
}
function sendTextMessage(): never {
$user = authRequired();
$groupId = requestedGroup($user);
$message = trim((string)($_POST['message'] ?? ''));
$max = (int)getConfigVal('chat.max_message_length', 500);
if ($message === '') jsonResponse(['error' => 'Empty message'], 400);
if (strlen($message) > $max) jsonResponse(['error' => "Message too long (max $max chars)"], 400);
$created = time();
$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();
$db->prepare("INSERT INTO messages (user_id,group_id,username,color,message,created_at,day_key)
VALUES (?,?,?,?,?,?,?)")->execute([
$user['id'], $groupId, $user['username'], $user['color'], $message, $created, dayKey(),
]);
trackEvent('message_text', '/api/messages.php', (int)$user['id']);
jsonResponse(['success' => true, 'message' => messagePayload([
'id' => $id, 'username' => $session['username'], 'color' => $session['color'],
'message' => $message, 'message_type' => 'text', 'created_at' => time(), 'day_key' => $dayKey,
'id' => $db->lastInsertId(), 'group_id' => $groupId, 'username' => $user['username'],
'color' => $user['color'], 'message' => $message, 'message_type' => 'text',
'created_at' => $created, 'day_key' => dayKey(),
])]);
}
function handleSendVoice(): never {
$session = authRequired();
if (!getConfigVal('voice.enabled', true)) {
jsonResponse(['error' => 'Voice clips are disabled'], 403);
function sendVoiceMessage(): never {
$user = authRequired();
$groupId = requestedGroup($user);
if (!getConfigVal('voice.enabled', true) || !featureValue($user, 'voice_messages', true)) {
jsonResponse(['error' => 'Your plan does not include voice messages'], 403);
}
if (empty($_FILES['voice']) || !is_array($_FILES['voice'])) {
jsonResponse(['error' => 'No voice clip uploaded'], 400);
}
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);
}
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);
}
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);
}
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'])
: '';
$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);
}
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);
}
ensureWritableDirectory($dir, 'Voice storage');
$filename = bin2hex(random_bytes(20)) . '.' . $allowed[$mime];
if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) {
jsonResponse(['error' => 'Could not store voice clip'], 500);
}
if (!move_uploaded_file($file['tmp_name'], $dir . '/' . $filename)) jsonResponse(['error' => 'Could not store voice clip'], 500);
$db = getDB();
$created = time();
$dayKey = dayKey();
$storedMime = $mime === 'video/webm' ? 'audio/webm' : $mime;
$db = getDB();
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
]);
$db->prepare("INSERT INTO messages
(user_id,group_id,username,color,message,message_type,voice_file,voice_mime,voice_duration,created_at,day_key)
VALUES (?,?,?,?,?,'voice',?,?,?,?,?)")->execute([
$user['id'], $groupId, $user['username'], $user['color'], '[Voice clip]',
$filename, $storedMime, $duration, $created, dayKey(),
]);
} catch (Throwable $e) {
deleteVoiceFile($filename);
throw $e;
}
getDB()->prepare("DELETE FROM recording_status WHERE group_key=? AND user_id=?")
->execute([groupKey($groupId), $user['id']]);
trackEvent('message_voice', '/api/messages.php', (int)$user['id']);
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,
'id' => $db->lastInsertId(), 'group_id' => $groupId, 'username' => $user['username'],
'color' => $user['color'], 'message' => '[Voice clip]', 'message_type' => 'voice',
'voice_file' => $filename, 'voice_mime' => $storedMime, 'voice_duration' => $duration,
'created_at' => $created, 'day_key' => dayKey(),
])]);
}
function handlePoll(): never {
$session = authRequired();
function setRecordingStatus(): never {
$user = authRequired();
if (!featureValue($user, 'recording_status', false)) {
jsonResponse(['error' => 'Your plan does not include recording indicators'], 403);
}
$groupId = requestedGroup($user);
$active = filter_var($_POST['active'] ?? false, FILTER_VALIDATE_BOOLEAN);
$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));
if ($active) {
$db->prepare("INSERT INTO recording_status (group_key,user_id,expires_at) VALUES (?,?,?)
ON CONFLICT(group_key,user_id) DO UPDATE SET expires_at=excluded.expires_at")
->execute([groupKey($groupId), $user['id'], time() + 10]);
} 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);
$db->prepare("DELETE FROM recording_status WHERE group_key=? AND user_id=?")
->execute([groupKey($groupId), $user['id']]);
}
jsonResponse(['success' => true]);
}
function pollMessages(): never {
$user = authRequired();
$groupId = requestedGroup($user);
$since = max(0, (int)($_GET['since'] ?? 0));
$limit = max(1, min(250, (int)getConfigVal('chat.messages_per_page', 100)));
$db = getDB();
$whereGroup = $groupId === null ? 'group_id IS NULL' : 'group_id = ?';
$params = $groupId === null ? [dayKey()] : [dayKey(), $groupId];
if ($since === 0) {
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? AND $whereGroup ORDER BY id DESC LIMIT ?");
$params[] = $limit;
$stmt->execute($params);
$rows = array_reverse($stmt->fetchAll());
} else {
$stmt = $db->prepare("SELECT * FROM messages WHERE day_key=? AND $whereGroup AND id>? ORDER BY id LIMIT ?");
$params[] = $since;
$params[] = $limit;
$stmt->execute($params);
$rows = $stmt->fetchAll();
}
// 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();
$cutoff = time() - 300;
$onlineStmt = $db->prepare("SELECT COUNT(*) FROM sessions WHERE last_active>?");
$onlineStmt->execute([$cutoff]);
$recording = [];
$db->prepare("DELETE FROM recording_status WHERE expires_at<=?")->execute([time()]);
if (featureValue($user, 'recording_status', false)) {
$recordStmt = $db->prepare("SELECT u.id,u.username,u.color FROM recording_status r
JOIN users u ON u.id=r.user_id WHERE r.group_key=? AND r.expires_at>? AND r.user_id!=?");
$recordStmt->execute([groupKey($groupId), time(), $user['id']]);
$recording = $recordStmt->fetchAll();
}
jsonResponse([
'messages' => $messages,
'online' => $onlineCount,
'day_key' => $dayKey,
'messages' => array_map('messagePayload', $rows),
'online' => (int)$onlineStmt->fetchColumn(),
'recording' => $recording,
'group_id' => $groupId,
'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";
}
}
}
}
function archiveDays(): never {
$user = authRequired();
$rows = personalArchiveRows($user, '', 1000);
$days = array_values(array_unique(array_column($rows, 'day_key')));
rsort($days);
jsonResponse(['days' => $days]);
}
+13 -8
View File
@@ -7,19 +7,22 @@ header('Content-Type: application/json');
header('Cache-Control: no-store');
header('Access-Control-Allow-Origin: *');
require_once dirname(__DIR__) . '/bootstrap.php';
$results = [];
// 1. PHP version
$results['php_version'] = PHP_VERSION;
$results['php_ok'] = version_compare(PHP_VERSION, '8.0.0', '>=');
$results['php_ok'] = version_compare(PHP_VERSION, '8.1.0', '>=');
// 2. PDO SQLite
$results['pdo_sqlite'] = extension_loaded('pdo_sqlite');
// 3. ROOT_DIR and config.json
$root = dirname(__DIR__);
$root = ROOT_DIR;
$configFile = $root . '/config.json';
$results['config_exists'] = file_exists($configFile);
$results['config_writable'] = is_writable($configFile);
if ($results['config_exists']) {
$raw = file_get_contents($configFile);
@@ -30,17 +33,19 @@ if ($results['config_exists']) {
}
// 4. db/ directory writable
$dbDir = $root . '/db';
$dbPath = storagePath((string)getConfigVal('database.main_db', 'db/chat.sqlite'));
$dbDir = dirname($dbPath);
if (!is_dir($dbDir)) {
@mkdir($dbDir, 0755, true);
@mkdir($dbDir, 0775, true);
}
$results['db_dir_exists'] = is_dir($dbDir);
$results['db_dir_writable'] = is_writable($dbDir);
$results['db_file_writable'] = !is_file($dbPath) || is_writable($dbPath);
// 5. archive/ directory writable
$archiveDir = $root . '/archive';
$archiveDir = storagePath((string)getConfigVal('archive.archive_dir', 'archive'));
if (!is_dir($archiveDir)) {
@mkdir($archiveDir, 0755, true);
@mkdir($archiveDir, 0775, true);
}
$results['archive_dir_exists'] = is_dir($archiveDir);
$results['archive_dir_writable'] = is_writable($archiveDir);
@@ -50,7 +55,7 @@ $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 = new PDO('sqlite:' . $dbPath);
$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)');
@@ -70,10 +75,10 @@ $results['all_ok'] = (
$results['config_exists'] &&
$results['config_valid_json'] &&
$results['db_dir_writable'] &&
$results['db_file_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);
+10
View File
@@ -0,0 +1,10 @@
<?php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
sendCorsHeaders();
$user = authUser(false);
$type = preg_replace('/[^a-z0-9_.-]/i', '', (string)($_POST['event'] ?? 'visit')) ?: 'visit';
$path = (string)($_POST['path'] ?? '');
trackEvent($type, $path, $user ? (int)$user['id'] : null);
jsonResponse(['success' => true]);
+36
View File
@@ -0,0 +1,36 @@
<?php
define('CYBERCHAT_API', true);
require_once __DIR__ . '/../bootstrap.php';
require_once ROOT_DIR . '/lib/stripe.php';
$payload = (string)file_get_contents('php://input');
$signature = (string)($_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '');
if (!verifyStripeSignature($payload, $signature)) jsonResponse(['error' => 'Invalid signature'], 400);
$event = json_decode($payload, true);
if (!is_array($event) || empty($event['id']) || empty($event['type'])) jsonResponse(['error' => 'Invalid event'], 400);
$db = getDB();
$exists = $db->prepare("SELECT 1 FROM stripe_events WHERE event_id=?");
$exists->execute([$event['id']]);
if ($exists->fetchColumn()) jsonResponse(['received' => true, 'duplicate' => true]);
$object = $event['data']['object'] ?? [];
if ($event['type'] === 'checkout.session.completed') {
$userId = (int)($object['metadata']['user_id'] ?? $object['client_reference_id'] ?? 0);
if ($userId > 0) {
$db->prepare("UPDATE users SET stripe_customer_id=?,stripe_subscription_id=?,subscription_status='active' WHERE id=?")
->execute([$object['customer'] ?? null, $object['subscription'] ?? null, $userId]);
}
} elseif (str_starts_with($event['type'], 'customer.subscription.')) {
syncSubscriptionFromStripe($object);
} elseif (in_array($event['type'], ['invoice.payment_failed', 'invoice.paid'], true)) {
$customer = $object['customer'] ?? '';
if ($customer !== '') {
$status = $event['type'] === 'invoice.paid' ? 'active' : 'past_due';
$db->prepare("UPDATE users SET subscription_status=? WHERE stripe_customer_id=?")->execute([$status, $customer]);
$db->prepare("UPDATE subscriptions SET status=?,updated_at=? WHERE stripe_customer_id=?")
->execute([$status, time(), $customer]);
}
}
$db->prepare("INSERT OR IGNORE INTO stripe_events (event_id,event_type,received_at) VALUES (?,?,?)")
->execute([$event['id'], $event['type'], time()]);
jsonResponse(['received' => true]);