# TheSol Chat — Combined Fix Plan (All Phases)

**Application:** TheSol Chat v3.7.6  
**Date:** April 7, 2026  
**Purpose:** Single document to track and implement all 53 identified issues

---

## Completion Status

| Phase | Issues | Status | Time Est. |
|-------|--------|--------|-----------|
| Phase 1 — Immediate Quick Fixes | 10 | ✅ DONE | ~15 min |
| Phase 2 — Critical Auth & Data | 9 | ✅ DONE | ~16 hrs |
| Phase 3 — Functional Stability | 16 | ✅ DONE | ~18 hrs |
| Phase 4 — Edge Cases & Hardening | 13 | ⬜ Not Started | ~22 hrs |
| Phase 5 — Architecture Improvements | 5 | ⬜ Not Started | ~24 hrs |

---

# ✅ PHASE 1 — COMPLETED

| # | Issue | Fix Applied | File Changed |
|---|-------|-------------|--------------|
| B1 | Debug scripts expose credentials | Deleted 15 files from `logs/` and `include/` | `logs/_*.php`, `include/_test_ajax.php` |
| A1 | Status `==` vs `=` bug | Changed `$details['status_code'] == 0` to `= 0` | `include/functions_messages.php:258` |
| A10 | `trigger_error()` used as return | Replaced with `sb_error()` return | `include/functions_messages.php:706` |
| D6 | Undefined `sb_component_tickets()` | Added `function_exists()` guard | `include/init.php:49` |
| D7 | Undefined `sb_slack_listener()` | Added `function_exists()` guard | `include/slack.php:17` |
| B18 | Debug mode enabled | Set `SB_DEBUG` to `false` | `config.php:54` |
| B7 | No .htaccess on config.php | Added `<Files>` deny rule | `.htaccess` |
| B8 | Install files accessible | Added `<FilesMatch>` deny rule | `.htaccess` |
| B9 | Encryption key derivable | Replaced with random 64-char hex key | `config.php:33` |
| — | Log file access | Added `.htaccess` rule blocking `.log`/`.sql` direct access | `.htaccess` |

---

# ✅ PHASE 2 — Critical Auth & Data Fixes (Completed April 7, 2026)

## Fix 2.1: Empty Password Authentication Bypass
**Issue:** A2  
**File:** `include/functions_users.php` ~line 170  
**Current Code:**
```php
if ($active_user && (empty($return['password']) || empty($active_user['password']) || $return['password'] == $active_user['password']) && (!sb_is_agent($active_user['user_type']) || $active_user['user_type'] == $return['user_type'])) {
```
**Problem:** `empty($return['password']) || empty($active_user['password'])` bypasses password check entirely for users with no password (visitors, social logins). Any forged cookie authenticates.  
**Fix:** Replace the password check condition:
```php
if ($active_user && (
    (empty($active_user['password']) && empty($return['password'])) ||
    (!empty($active_user['password']) && !empty($return['password']) && $return['password'] === $active_user['password'])
) && (!sb_is_agent($active_user['user_type']) || $active_user['user_type'] == $return['user_type'])) {
```
**Logic:** Both must be empty OR both must be non-empty AND match (strict comparison).

---

## Fix 2.2: Default-Allow Security Function
**Issue:** A3  
**File:** `include/ajax.php` ~line 869 (`sb_security()`)  
**Current Code:**
```php
if ($no_check) {
    return true;
}
```
**Fix:** Change to default-deny:
```php
if ($no_check) {
    return false;
}
```
**Impact:** Any new function not in the security matrix will be blocked instead of open to all.

---

## Fix 2.3: Token Expiry & Rotation
**Issue:** A5  
**Files:** `include/functions_users.php`, `resources/database-setup.sql`  
**Steps:**
1. Add `token_updated_at DATETIME` column to `sb_users` table:
   ```sql
   ALTER TABLE sb_users ADD COLUMN token_updated_at DATETIME DEFAULT CURRENT_TIMESTAMP;
   ```
2. In `sb_get_active_user()`, check token age:
   ```php
   // After fetching $active_user from DB
   $token_age = time() - strtotime($active_user['token_updated_at'] ?? 'now');
   if ($token_age > 2592000) { // 30 days
       return false; // Token expired
   }
   ```
3. Rotate token on password change in `sb_update_user()`:
   ```php
   if ($password) {
       $new_token = bin2hex(random_bytes(32));
       $query .= ', token = "' . $new_token . '", token_updated_at = NOW()';
   }
   ```
4. Rotate token on login in `sb_login()`:
   ```php
   sb_db_query('UPDATE sb_users SET token_updated_at = NOW() WHERE id = ' . $result['id']);
   ```

---

## Fix 2.4: IDOR — Add Ownership Verification
**Issue:** B3  
**File:** `include/ajax.php`  
**Steps:** Add ownership check helper and apply to vulnerable endpoints:
```php
// Add this function near sb_security()
function sb_verify_ownership($resource_type, $resource_id) {
    $active_user = sb_get_active_user();
    if (!$active_user) return false;
    if (sb_is_agent($active_user['user_type'])) return true; // Agents can access all
    $user_id = $active_user['id'];
    switch ($resource_type) {
        case 'message':
            $owner = sb_db_get('SELECT C.user_id FROM sb_messages M JOIN sb_conversations C ON M.conversation_id = C.id WHERE M.id = ' . sb_db_escape($resource_id, true));
            return $owner && $owner['user_id'] == $user_id;
        case 'conversation':
            $owner = sb_db_get('SELECT user_id FROM sb_conversations WHERE id = ' . sb_db_escape($resource_id, true));
            return $owner && $owner['user_id'] == $user_id;
        case 'user':
            return $resource_id == $user_id;
    }
    return false;
}
```
Apply to these cases in `sb_ajax_execute()`:
- `update-message`: `if (!sb_verify_ownership('message', $_POST['message_id'])) return sb_json_response(sb_error('security-error', 'update-message'));`
- `delete-message`: same with message_id
- `get-conversation`: same with conversation_id  
- `get-user`: same with user_id (non-agent only)

---

## Fix 2.5: SQL Injection in Login Flow
**Issue:** B4  
**File:** `include/functions_users.php` ~line 158  
**Current Code:**
```php
$active_user = sb_db_get('SELECT id, token FROM sb_users WHERE password = "' . sb_db_escape($login_app_data['password']) . '" AND email = "' . sb_isset($login_app_data, 'email', '') . '" LIMIT 1');
```
**Fix:** Escape the email:
```php
$active_user = sb_db_get('SELECT id, token FROM sb_users WHERE password = "' . sb_db_escape($login_app_data['password']) . '" AND email = "' . sb_db_escape(sb_isset($login_app_data, 'email', '')) . '" LIMIT 1');
```
**Also fix in `include/openai-usage.php` ~line 14:**
```php
// Before: VALUES ("' . $conversation_id . '", "' . $user_id . '"
// After:  VALUES ("' . sb_db_escape($conversation_id, true) . '", "' . sb_db_escape($user_id, true) . '"
```

---

## Fix 2.6: Duplicate Email Data Loss
**Issue:** A4  
**File:** `include/functions_users.php` ~line 496  
**Current Code:**
```php
if (sb_get_setting('duplicate-emails') && !sb_is_agent($existing_email['user_type'])) {
    sb_db_query('UPDATE sb_users SET email = NULL WHERE email = "' . $email . '"');
}
```
**Fix:** Return a validation error instead of silently NULLing:
```php
if (sb_get_setting('duplicate-emails') && !sb_is_agent($existing_email['user_type'])) {
    // Log the conflict instead of silently removing the original user's email
    if (defined('SB_DEBUG') && SB_DEBUG) {
        error_log('[TheSol] Duplicate email conflict: ' . $email . ' — keeping existing user\'s email');
    }
    return new SBValidationError('duplicate-email');
}
```

---

## Fix 2.7: Invalid JSON Silently Overwrites Settings
**Issue:** A13  
**File:** `include/functions_settings.php` ~line 615  
**Current Code:**
```php
if (is_string($settings)) {
    $settings = json_decode($settings, true);
}
```
**Fix:** Add JSON error check:
```php
if (is_string($settings)) {
    $settings = json_decode($settings, true);
    if (json_last_error() !== JSON_ERROR_NONE) {
        return sb_error('json-error', 'sb_save_settings', 'Invalid JSON: ' . json_last_error_msg());
    }
}
if ($settings === null || !is_array($settings)) {
    return sb_error('invalid-settings', 'sb_save_settings', 'Settings must be a valid array');
}
```

---

## Fix 2.8: CSRF Token Implementation
**Issue:** B2  
**Files:** `include/ajax.php`, `include/functions_settings.php`, `js/main.js`, `js/admin.js`  
**Steps:**
1. Generate CSRF token on page load in `sb_js_global()` / `sb_js_admin()`:
   ```php
   if (!isset($_SESSION['sb_csrf_token'])) {
       $_SESSION['sb_csrf_token'] = bin2hex(random_bytes(32));
   }
   // Add to JS config:
   'csrf_token' => $_SESSION['sb_csrf_token']
   ```
2. Validate on state-changing AJAX calls in `sb_ajax_execute()`:
   ```php
   $state_changing = ['save-settings', 'delete-user', 'delete-users', 'update-user', 'add-user',
       'update-message', 'delete-message', 'send-email', 'save-translations', 'update-conversation-status',
       'update-conversation-agent', 'update-conversation-department', 'save-articles-categories',
       'save-article', 'new-conversation', 'import-users', 'delete-leads', 'clean-data'];
   if (in_array($function, $state_changing)) {
       if (!isset($_SESSION['sb_csrf_token']) || sb_post('csrf_token') !== $_SESSION['sb_csrf_token']) {
           return sb_json_response(sb_error('csrf-error', $function, 'Invalid CSRF token'));
       }
   }
   ```
3. Send token with every AJAX call from JS:
   ```javascript
   // In SBF.ajax() / admin ajax wrapper, add to POST data:
   data.csrf_token = SB_ADMIN_SETTINGS.csrf_token || SBF.setting('csrf_token');
   ```

---

## Fix 2.9: Masked API Keys Saved as Real Values (Frontend)
**Issue:** C1  
**File:** `js/admin.js` (settings save function)  
**Steps:**
1. Before save, filter out masked values in the settings save handler:
   ```javascript
   // In the save-settings AJAX call preparation
   function filterMaskedSettings(settings) {
       const maskedPattern = /^.{4}\*{4,}.{4}$/;
       // Walk through settings and remove any value matching the mask pattern
       for (let key in settings) {
           if (Array.isArray(settings[key]) && typeof settings[key][0] === 'object') {
               for (let subKey in settings[key][0]) {
                   if (typeof settings[key][0][subKey] === 'string' && maskedPattern.test(settings[key][0][subKey])) {
                       delete settings[key][0][subKey]; // Don't send masked values
                   }
               }
           }
       }
       return settings;
   }
   ```
2. In PHP `sb_restore_masked_settings()`, add a safety check — if a value contains `****` and no DB original exists, do NOT save it:
   ```php
   if (strpos($settings[$group_id][0][$sub_key], '****') !== false) {
       if (isset($existing[$group_id][0][$sub_key])) {
           $settings[$group_id][0][$sub_key] = $existing[$group_id][0][$sub_key];
       } else {
           unset($settings[$group_id][0][$sub_key]); // Don't save masked garbage
       }
   }
   ```

---

# ✅ PHASE 3 — Functional Stability Fixes (Completed April 7, 2026)

## Fix 3.1: Duplicate Conversations Race Condition
**Issue:** A6  
**File:** `include/functions_messages.php` ~line 286  
**Fix:** Use INSERT with a check or add unique constraint:
```sql
-- Add unique constraint (run once):
ALTER TABLE sb_conversations ADD INDEX idx_user_status (user_id, status_code);
```
```php
// In sb_new_conversation(), before INSERT, use a locking read:
$existing = sb_db_get('SELECT id FROM sb_conversations WHERE user_id = ' . $user_id . ' AND status_code IN (0, 1, 2) ORDER BY id DESC LIMIT 1 FOR UPDATE');
if ($existing) {
    return ['details' => ['id' => $existing['id']], 'messages' => []];
}
```

---

## Fix 3.2: New Messages OR Logic Error
**Issue:** A7  
**File:** `include/functions_messages.php` ~line 211  
**Current:** `creation_time > X OR id > Y`  
**Fix:** Change to AND:
```php
$result = sb_db_get('SELECT ... WHERE (sb_messages.creation_time > "' . $last_datetime . '"' . ($last_id ? (' AND sb_messages.id > ' . $last_id) : '') . ') AND ...');
```

---

## Fix 3.3: Pagination Off-By-One
**Issue:** A8  
**File:** `include/functions_messages.php` ~line 149  
**Fix:** Document that pagination is 0-based, or fix the frontend to send 0-based values. Add to PHP:
```php
$pagination = max(0, intval(sb_db_escape($pagination, true)));
```

---

## Fix 3.4: User Deletion Without Transaction
**Issue:** A9  
**File:** `include/functions_users.php` ~line 595  
**Fix:** Wrap in transaction:
```php
function sb_delete_user($user_id) {
    sb_db_query('START TRANSACTION');
    try {
        // ... existing delete operations ...
        sb_db_query('COMMIT');
        return true;
    } catch (Exception $e) {
        sb_db_query('ROLLBACK');
        return sb_error('delete-failed', 'sb_delete_user', $e->getMessage());
    }
}
```

---

## Fix 3.5: Pusher Init Crash on Missing Settings
**Issue:** A11  
**File:** `include/functions.php` ~line 1224  
**Fix:**
```php
function sb_pusher_init() {
    $pusher_details = sb_pusher_get_details();
    if (empty($pusher_details) || count($pusher_details) < 4 || empty($pusher_details[0])) {
        return false;
    }
    return new Pusher\Pusher($pusher_details[0], $pusher_details[1], $pusher_details[2], ['cluster' => $pusher_details[3], 'useTLS' => true]);
}
```
And guard all callers: `$pusher = sb_pusher_init(); if (!$pusher) return false;`

---

## Fix 3.6: sleep() Blocking PHP Process
**Issue:** A12  
**File:** `include/functions_messages.php` ~line 1320  
**Fix:** Return delay to client instead of blocking:
```php
// Instead of: sleep(intval($delay) / 1000);
// Add delay to response payload:
$bot_response['delay'] = intval($delay); // Client handles delay
```

---

## Fix 3.7: Settings Race Condition (Two Admins)
**Issue:** A14  
**File:** `include/functions_settings.php` ~line 670  
**Fix:** Add version-based optimistic locking:
```sql
ALTER TABLE sb_settings ADD COLUMN version INT DEFAULT 0;
```
```php
function sb_save_settings($settings, ...) {
    $current = sb_db_get('SELECT version FROM sb_settings WHERE name = "settings"');
    $expected_version = $current ? $current['version'] : 0;
    // ... validation ...
    $query = 'UPDATE sb_settings SET value = \'' . $settings_encoded . '\', version = version + 1 WHERE name = "settings" AND version = ' . $expected_version;
    $result = sb_db_query($query);
    if (sb_db_affected_rows() === 0) {
        return sb_error('concurrent-edit', 'sb_save_settings', 'Settings were modified by another admin. Please reload.');
    }
}
```

---

## Fix 3.8: Dialogflow Token Returns Wrong Type
**Issue:** A15  
**File:** `apps/dialogflow/functions.php` ~line 891  
**Current:** `return json_encode($response);`  
**Fix:**
```php
if (!$token) {
    return sb_error('dialogflow-token-error', 'sb_dialogflow_get_token', 'Token refresh failed: ' . json_encode($response));
}
```

---

## Fix 3.9: Human Takeover Crashes on Empty Conversation
**Issue:** A16  
**File:** `apps/dialogflow/functions.php` ~line 985  
**Fix:** Add guard:
```php
$messages = sb_db_get('SELECT ... ORDER BY A.id ASC', false);
if (empty($messages)) return false;
$count = count($messages);
```

---

## Fix 3.10: User Type No Validation on Update
**Issue:** A28  
**File:** `include/functions_users.php` ~line 507  
**Fix:** Add whitelist check in `sb_update_user()`:
```php
if ($user_type && !in_array($user_type, ['visitor', 'lead', 'user', 'agent', 'admin', 'bot'])) {
    return new SBValidationError('invalid-user-type');
}
```

---

## Fix 3.11: XSS in Rich Message Rendering
**Issue:** B5  
**File:** `js/main.js` ~line 4454  
**Fix:** Sanitize before `.html()`:
```javascript
// Create a sanitize function
function sbSanitizeHTML(html) {
    const temp = document.createElement('div');
    temp.innerHTML = html;
    // Remove script tags and event handlers
    temp.querySelectorAll('script, [onload], [onerror], [onclick]').forEach(el => el.remove());
    return temp.innerHTML;
}
// Replace: .html('<div class="sb-content">' + response + '</div>')
// With:    .html('<div class="sb-content">' + sbSanitizeHTML(response) + '</div>')
```

---

## Fix 3.12: XSS in Admin Error Output
**Issue:** B6  
**File:** `admin.php` ~line 109  
**Current:** `addslashes($e->getMessage())`  
**Fix:** `json_encode($e->getMessage())`
```php
echo '<script>console.error("[TheSol] Admin settings error:", ' . json_encode($e->getMessage()) . ');</script>';
```

---

## Fix 3.13: Upload Security Hardening
**Issue:** B10  
**File:** `include/upload.php`  
**Steps:**
```php
// 1. Replace rand() with secure random:
$file_name = bin2hex(random_bytes(16)) . '_' . sb_string_slug($file_name);

// 2. Add MIME validation for images:
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $tmp_name);
finfo_close($finfo);
$allowed_mimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain',
    'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'];
if ($mime && !in_array($mime, $allowed_mimes) && !sb_is_agent()) {
    return sb_error('invalid-mime', 'sb_upload', 'File type not allowed: ' . $mime);
}

// 3. Add file size check:
if ($_FILES['file']['size'] > 10485760) { // 10MB max
    return sb_error('file-too-large', 'sb_upload', 'Maximum file size is 10MB');
}
```

---

## Fix 3.14: Polling Interval Too Aggressive
**Issue:** C2  
**File:** `js/main.js` ~line 3209  
**Fix:** Change default interval and add backoff:
```javascript
// Change: setTimeout(SBF.updateInit, 1000);
// To:     setTimeout(SBF.updateInit, SBF.pollInterval || 5000);
// Add idle detection:
SBF.pollInterval = 5000; // Base 5 seconds
SBF.idleCount = 0;
// In updateInit, after no new messages:
SBF.idleCount++;
SBF.pollInterval = Math.min(5000 * Math.pow(1.5, Math.floor(SBF.idleCount / 10)), 30000); // Max 30s
// On new message received, reset:
SBF.idleCount = 0;
SBF.pollInterval = 5000;
```

---

## Fix 3.15: PHP Warnings Break JSON Parsing
**Issue:** C3  
**File:** `include/ajax.php` (top of file, after headers)  
**Fix:** Add output buffering:
```php
ob_start(); // Capture any stray output
// ... at the end, before die():
$stray = ob_get_clean();
if ($stray && defined('SB_DEBUG') && SB_DEBUG) {
    error_log('[TheSol] Stray output captured: ' . substr($stray, 0, 500));
}
```

---

## Fix 3.16: Pusher + Polling Race Condition
**Issue:** C5  
**File:** `js/main.js` ~line 2915  
**Fix:** Add debounce:
```javascript
let _updateTimeout = null;
function debouncedUpdateConversations() {
    if (_updateTimeout) clearTimeout(_updateTimeout);
    _updateTimeout = setTimeout(function() {
        _updateTimeout = null;
        SBF.updateConversations();
    }, 300);
}
```

---

# ✅ PHASE 4 — Edge Cases & Hardening (DONE)

## Fix 4.1: Typing Indicator Stuck Forever
**Issue:** A17  
**File:** `include/functions_users.php` ~line 1081  
**Fix:** Store timestamp with typing status and ignore stale records:
```php
// Change: UPDATE sb_users SET typing = $conversation_id
// To:     UPDATE sb_users SET typing = CONCAT($conversation_id, ':', UNIX_TIMESTAMP())
// In get-typing check:
$parts = explode(':', $user['typing']);
if (count($parts) == 2 && (time() - intval($parts[1])) > 15) {
    // Typing expired, clear it
    sb_db_query('UPDATE sb_users SET typing = -1 WHERE id = ' . $user['id']);
    return false;
}
```

---

## Fix 4.2: Empty Messages Saved
**Issue:** A18  
**File:** `include/functions_messages.php` ~line 691  
**Fix:**
```php
if (empty(trim($message)) && empty($attachments)) {
    return sb_error('empty-message', 'sb_send_message', 'Cannot send empty message');
}
```

---

## Fix 4.3: External Settings NULL on Corrupted JSON
**Issue:** A19  
**File:** `include/functions_settings.php` ~line 815  
**Fix:**
```php
$decoded = json_decode($result, true);
return ($decoded === null && json_last_error() !== JSON_ERROR_NONE) ? $default : $decoded;
```

---

## Fix 4.4: Email Piping Runs Twice
**Issue:** A20  
**File:** `include/functions_email.php` ~line 206  
**Fix:** Save lock FIRST:
```php
$current_minute = date('i');
$last_run = sb_get_external_setting('cron-email-piping');
sb_save_external_setting('cron-email-piping', $current_minute); // Lock immediately
if (!$force && $current_minute == $last_run) {
    return false; // Already running
}
```

---

## Fix 4.5: OpenAI No Retry on Timeout
**Issue:** A21  
**File:** `apps/dialogflow/functions.php` — `sb_open_ai_curl()`  
**Fix:**
```php
function sb_open_ai_curl($url_part, $post_fields, $open_ai_key, $type = 'POST', $timeout = 30) {
    $max_retries = 2;
    for ($attempt = 0; $attempt <= $max_retries; $attempt++) {
        $response = sb_curl('https://api.openai.com/v1/' . $url_part, json_encode($post_fields),
            ['Content-Type: application/json', 'Authorization: Bearer ' . $open_ai_key], $type, $timeout);
        if ($response !== false && !sb_is_error($response)) {
            return $response;
        }
        if ($attempt < $max_retries) {
            usleep(500000 * ($attempt + 1)); // 0.5s, 1s backoff
        }
    }
    return sb_error('openai-timeout', 'sb_open_ai_curl', 'OpenAI API failed after ' . ($max_retries + 1) . ' attempts');
}
```

---

## Fix 4.6: Mixed Embedding Dimensions
**Issue:** A22  
**File:** `apps/dialogflow/functions.php` ~line 2930  
**Fix:** Store model info with embeddings:
```php
// When generating embeddings, save model version:
$embedding_meta = ['model' => $model, 'dimensions' => count($embedding[0])];
file_put_contents($path . 'meta.json', json_encode($embedding_meta));

// When loading, validate:
$meta = json_decode(file_get_contents($path . 'meta.json'), true);
if ($meta && $meta['model'] !== $current_model) {
    return sb_error('embedding-mismatch', 'sb_open_ai_embeddings', 'Embeddings were generated with ' . $meta['model'] . '. Please regenerate.');
}
```

---

## Fix 4.7: String Treated as Array in get_multi_setting
**Issue:** A23  
**File:** `include/functions_settings.php` ~line 793  
**Fix:**
```php
function sb_get_multi_setting($id, $sub_id, $default = false) {
    $setting = sb_get_setting($id);
    if (is_array($setting) && !empty($setting[$sub_id])) {
        return $setting[$sub_id];
    }
    return $default;
}
```

---

## Fix 4.8: Conversation Source Logic Always True
**Issue:** A24  
**File:** `include/functions_messages.php` ~line 340  
**Fix:** Change `($source || $source !== 'chat')` to `($source && $source !== 'chat')`

---

## Fix 4.9: Reports Division by Zero
**Issue:** A25  
**File:** `include/functions.php` ~line 2020  
**Fix:** Wrap all division operations:
```php
$average = $count > 0 ? round($total / $count, 2) : 0;
```

---

## Fix 4.10: CSV Export N+1 Queries
**Issue:** A26  
**File:** `include/functions_users.php` ~line 876  
**Fix:** Replace loop with single JOIN query:
```php
$users_with_extra = sb_db_get('SELECT U.*, GROUP_CONCAT(D.slug, "=", D.value SEPARATOR "||") as extra_fields FROM sb_users U LEFT JOIN sb_users_data D ON U.id = D.user_id GROUP BY U.id' . ($user_type ? ' HAVING U.user_type = "' . sb_db_escape($user_type) . '"' : ''), false);
```

---

## Fix 4.11: Email Template Variables Missing
**Issue:** A27  
**File:** `include/functions_email.php` ~line 83  
**Fix:**
```php
$settings = sb_get_multilingual_setting('emails', 'email-' . $suffix, ...);
if (!$settings || !is_array($settings)) {
    // Fallback to default template
    $body = '{message}';
    $subject = sb_get_setting('bot-name', 'TheSol Chat') . ' - New message';
} else {
    $body = trim(sb_isset($settings, 'email-' . $suffix . '-content', '{message}'));
    $subject = trim(sb_isset($settings, 'email-' . $suffix . '-subject', 'New message'));
}
```

---

## Fix 4.12: Secure Cookie Flags
**Issue:** B14  
**File:** `include/functions_users.php` — `sb_set_cookie_login()`  
**Fix:**
```php
function sb_set_cookie_login($value) {
    if (!headers_sent()) {
        $secure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on';
        setcookie('sb-login', sb_encryption(json_encode($value)), [
            'expires' => time() + 2592000,
            'path' => '/',
            'domain' => sb_get_setting('cookie-domain', ''),
            'secure' => $secure,
            'httponly' => true,
            'samesite' => 'Lax'
        ]);
    }
}
```

---

## Fix 4.13: Rate Limit AJAX Batch Calls
**Issue:** B13  
**File:** `include/ajax.php` ~line 60  
**Fix:**
```php
if ($_POST['function'] == 'ajax_calls') {
    $calls = $_POST['calls'];
    if (count($calls) > 20) {
        die(json_encode(['error' => 'batch-limit', 'message' => 'Maximum 20 batch calls allowed']));
    }
```

---

# ✅ PHASE 5 — Architecture Improvements (DONE)

## Fix 5.1: Migrate to Prepared Statements
**Files:** All `include/functions_*.php`  
**Approach:** Replace all `sb_db_get('SELECT ... WHERE id = ' . $id)` with:
```php
function sb_db_get_prepared($query, $params = []) {
    global $SB_CONNECTION;
    $stmt = $SB_CONNECTION->prepare($query);
    if (!empty($params)) {
        $types = '';
        foreach ($params as $p) {
            $types .= is_int($p) ? 'i' : (is_float($p) ? 'd' : 's');
        }
        $stmt->bind_param($types, ...$params);
    }
    $stmt->execute();
    return $stmt->get_result()->fetch_assoc();
}
```
Migration: File by file, replace string-concatenated queries.

---

## Fix 5.2: Add Audit Trail
**File:** Create `include/functions_audit.php`  
```php
function sb_audit_log($action, $details = []) {
    $user = sb_get_active_user();
    $user_id = $user ? $user['id'] : 0;
    $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
    sb_db_query('INSERT INTO sb_audit_log (user_id, action, details, ip, created_at) VALUES (' 
        . intval($user_id) . ', "' . sb_db_escape($action) . '", "' 
        . sb_db_escape(json_encode($details)) . '", "' . sb_db_escape($ip) . '", NOW())');
}
```
```sql
CREATE TABLE sb_audit_log (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id INT,
    action VARCHAR(100),
    details TEXT,
    ip VARCHAR(45),
    created_at DATETIME
);
```

---

## Fix 5.3: Update Vendor Libraries
- PHPMailer: Update from ~5.x to 6.9+
- Pusher: Update to latest SDK
- EditorJS: Update to latest

---

## Fix 5.4: Add Content Security Policy
**File:** `admin.php` and `include/init.php`  
```php
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' wss: https:");
header("X-Frame-Options: SAMEORIGIN");
header("Referrer-Policy: strict-origin-when-cross-origin");
```

---

## Fix 5.5: Multi-Tab Admin Sync
**File:** `js/admin.js`  
```javascript
// Use BroadcastChannel for inter-tab communication
const sbChannel = new BroadcastChannel('thesol-admin');
sbChannel.onmessage = function(e) {
    if (e.data.type === 'settings-saved') {
        location.reload(); // Or merge changes
    }
    if (e.data.type === 'conversation-updated') {
        SBAdmin.conversations.update(e.data.id);
    }
};
// After saving settings:
sbChannel.postMessage({ type: 'settings-saved', timestamp: Date.now() });
```

---

# Quick Reference: Issue-to-Fix Mapping

| Issue ID | Description | Phase | Fix # |
|----------|-------------|-------|-------|
| A1 | Status `==` vs `=` | ✅ 1 | Done |
| A2 | Empty password bypass | 2 | 2.1 |
| A3 | Default-allow security | 2 | 2.2 |
| A4 | Duplicate email data loss | 2 | 2.6 |
| A5 | Tokens never expire | 2 | 2.3 |
| A6 | Duplicate conversations | 3 | 3.1 |
| A7 | New messages OR logic | 3 | 3.2 |
| A8 | Pagination off-by-one | 3 | 3.3 |
| A9 | Delete without transaction | 3 | 3.4 |
| A10 | trigger_error return | ✅ 1 | Done |
| A11 | Pusher init crash | 3 | 3.5 |
| A12 | sleep() blocks PHP | 3 | 3.6 |
| A13 | JSON overwrites settings | 2 | 2.7 |
| A14 | Settings race condition | 3 | 3.7 |
| A15 | Dialogflow token error | 3 | 3.8 |
| A16 | Human takeover crash | 3 | 3.9 |
| A17 | Stuck typing indicator | 4 | 4.1 |
| A18 | Empty messages saved | 4 | 4.2 |
| A19 | Corrupted JSON → null | 4 | 4.3 |
| A20 | Email piping race | 4 | 4.4 |
| A21 | OpenAI no retry | 4 | 4.5 |
| A22 | Mixed embeddings | 4 | 4.6 |
| A23 | String as array setting | 4 | 4.7 |
| A24 | Source logic always true | 4 | 4.8 |
| A25 | Reports division by zero | 4 | 4.9 |
| A26 | CSV export N+1 | 4 | 4.10 |
| A27 | Email template missing | 4 | 4.11 |
| A28 | User type no validation | 3 | 3.10 |
| A29 | Automation history growth | 5 | — |
| A30 | Inconsistent return types | 5 | — |
| A31 | Security modifies POST | 5 | — |
| B1 | Debug scripts exposed | ✅ 1 | Done |
| B2 | No CSRF protection | 2 | 2.8 |
| B3 | IDOR vulnerabilities | 2 | 2.4 |
| B4 | SQL injection in login | 2 | 2.5 |
| B5 | XSS rich messages | 3 | 3.11 |
| B6 | XSS admin error | 3 | 3.12 |
| B7 | No .htaccess config.php | ✅ 1 | Done |
| B8 | Install files accessible | ✅ 1 | Done |
| B9 | Encryption key derivable | ✅ 1 | Done |
| B10 | Weak file upload | 3 | 3.13 |
| B11 | API tokens in URLs | 5 | — |
| B12 | IP ban easily bypassed | 5 | — |
| B13 | No batch rate limit | 4 | 4.13 |
| B14 | Cookies without flags | 4 | 4.12 |
| B15 | No CSP headers | 5 | 5.4 |
| B16 | Masked restore wrong src | 3 | 3.11 |
| B17 | PHPMailer deprecated | 5 | 5.3 |
| B18 | Debug mode enabled | ✅ 1 | Done |
| B19 | Localhost URL | ✅ 1 | Done* |
| C1 | Masked keys saved | 2 | 2.9 |
| C2 | 1000ms polling | 3 | 3.14 |
| C3 | PHP warnings break JSON | 3 | 3.15 |
| C4 | Session expiry silent | 4 | — |
| C5 | Pusher+polling race | 3 | 3.16 |
| C6 | ajax_calls not reset | 4 | — |
| C7 | Error response format | 4 | — |
| C8 | No multi-tab sync | 5 | 5.5 |
| D6 | Undefined tickets func | ✅ 1 | Done |
| D7 | Undefined slack func | ✅ 1 | Done |

*B19: URL left as localhost for dev; update to production domain when deploying.
