Deploy: 2026-05-21 01:26:06

This commit is contained in:
Hamza-Ayed
2026-05-21 01:26:06 +03:00
parent 146ebd7200
commit 16d494b4e1
13 changed files with 816 additions and 32 deletions

View File

@@ -16,3 +16,12 @@ GEMINI_API_KEY=
# Messaging Gateway Settings
WHATSAPP_GATEWAY_URL=http://localhost:3722
# OWASP Security Settings
# Generate a secure 32-byte (256-bit) key for AES encryption
ENCRYPTION_KEY=d3b07384d113edec49eaa6238ad5ff00f898129dfdeca34289adcd11a00a89d1
# Secret key/salt for blind index hashes
HMAC_SALT=nabeh_secure_blind_index_salt_key_123
# Secret key for JWT signatures
JWT_SECRET=nabeh_jwt_secret_signature_key_987654

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
use App\Core\Security;
use App\Models\User;
use App\Models\Company;
class AuthController extends BaseController
{
/**
* Register a new company and admin user
*/
public function register(Request $request, Response $response): void
{
$errors = $this->validate($request, [
'company_name' => 'required|min:3',
'user_name' => 'required|min:3',
'email' => 'required|email',
'password' => 'required|strong_password'
]);
if (!empty($errors)) {
$response->json(['errors' => $errors], 400);
return;
}
$data = $request->getBody();
// Check if user already exists securely via Blind Index
$existingUser = User::findByEmail($data['email']);
if ($existingUser) {
$response->json(['errors' => ['email' => ['This email is already registered.']]], 409);
return;
}
try {
// Create Company
$companyId = Company::create([
'name' => htmlspecialchars(strip_tags($data['company_name']))
]);
// Create Admin User for this Company
$userId = User::createSecure([
'company_id' => $companyId,
'name' => htmlspecialchars(strip_tags($data['user_name'])),
'email' => strtolower(trim($data['email'])),
'password' => $data['password'],
'role' => 'admin'
]);
$response->json([
'message' => 'Company and Admin user registered successfully.',
'company_id' => $companyId,
'user_id' => $userId
], 201);
} catch (\Exception $e) {
error_log("Registration Error: " . $e->getMessage());
$response->json(['error' => 'An error occurred during registration.'], 500);
}
}
/**
* Login existing user and return JWT
*/
public function login(Request $request, Response $response): void
{
$errors = $this->validate($request, [
'email' => 'required|email',
'password' => 'required'
]);
if (!empty($errors)) {
$response->json(['errors' => $errors], 400);
return;
}
$data = $request->getBody();
// Find user by email blind index
$user = User::findByEmail($data['email']);
if (!$user) {
$response->json(['error' => 'Invalid email or password'], 401);
return;
}
// Verify password hash
if (!Security::verifyPassword($data['password'], $user['password'])) {
$response->json(['error' => 'Invalid email or password'], 401);
return;
}
if ($user['status'] !== 'active') {
$response->json(['error' => 'Your account is inactive or suspended.'], 403);
return;
}
// Generate standard JWT token with full required payload
$payload = [
'user_id' => $user['id'],
'company_id' => $user['company_id'],
'role' => $user['role']
];
$token = Security::generateJWT($payload);
$response->json([
'message' => 'Login successful',
'token' => $token,
'user' => [
'id' => $user['id'],
'company_id' => $user['company_id'],
'name' => $user['name'],
'role' => $user['role']
]
], 200);
}
/**
* Get current logged in user details
* (Protected by AuthMiddleware)
*/
public function me(Request $request, Response $response): void
{
$user = User::find($request->user_id);
if (!$user) {
$response->json(['error' => 'User not found'], 404);
return;
}
$response->json([
'user' => [
'id' => $user['id'],
'company_id' => $user['company_id'],
'name' => $user['name'],
'email' => Security::decrypt($user['email']), // Decrypt email before sending back
'role' => $user['role'],
'status' => $user['status'],
'created_at' => $user['created_at']
]
]);
}
}

View File

@@ -19,31 +19,8 @@ class BaseController
*/
protected function validate(Request $request, array $rules): array
{
$errors = [];
$data = $request->getBody();
foreach ($rules as $field => $constraints) {
$value = $data[$field] ?? null;
$constraintsArray = explode('|', $constraints);
foreach ($constraintsArray as $constraint) {
if ($constraint === 'required') {
if ($value === null || $value === '') {
$errors[$field][] = "The {$field} field is required.";
}
} elseif ($constraint === 'email') {
if ($value !== null && $value !== '' && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
$errors[$field][] = "The {$field} must be a valid email address.";
}
} elseif (strpos($constraint, 'min:') === 0) {
$min = (int) substr($constraint, 4);
if ($value !== null && strlen((string)$value) < $min) {
$errors[$field][] = "The {$field} must be at least {$min} characters.";
}
}
}
}
return $errors;
$validator = new \App\Core\Validator();
$validator->validate($request->getBody(), $rules);
return $validator->getErrors();
}
}

View File

@@ -52,6 +52,16 @@ class Request
return $this->bodyParams;
}
public function setBody(array $bodyParams): void
{
$this->bodyParams = $bodyParams;
}
public function setQueryParams(array $queryParams): void
{
$this->queryParams = $queryParams;
}
public function get(string $key, $default = null)
{
return $this->bodyParams[$key] ?? ($this->queryParams[$key] ?? $default);

View File

@@ -0,0 +1,198 @@
<?php
namespace App\Core;
/**
* Advanced OWASP Security Helper
* Handles AES-256-GCM encryption/decryption, HMAC Blind Indexing,
* Bcrypt password hashing, and JWT validation.
*/
class Security
{
/**
* Get the encryption key from environment (must be 32 bytes for AES-256)
*/
private static function getEncryptionKey(): string
{
$key = getenv('ENCRYPTION_KEY') ;
return substr(hash('sha256', $key, true), 0, 32);
}
/**
* Get HMAC Salt for Blind Indexing
*/
private static function getHmacSalt(): string
{
return getenv('HMAC_SALT');
}
/**
* Get JWT Secret
*/
private static function getJwtSecret(): string
{
return getenv('JWT_SECRET');
}
/**
* Encrypt text using AES-256-GCM
*/
public static function encrypt(string $plainText): string
{
if ($plainText === '') {
return '';
}
$key = self::getEncryptionKey();
$iv = openssl_random_pseudo_bytes(12); // GCM standard IV is 12 bytes
$tag = '';
$ciphertext = openssl_encrypt(
$plainText,
'aes-256-gcm',
$key,
OPENSSL_RAW_DATA,
$iv,
$tag
);
if ($ciphertext === false) {
return '';
}
// Return combined iv + tag + ciphertext base64 encoded
return base64_encode($iv . $tag . $ciphertext);
}
/**
* Decrypt text using AES-256-GCM
*/
public static function decrypt(string $encryptedText): string
{
if ($encryptedText === '') {
return '';
}
$key = self::getEncryptionKey();
$data = base64_decode($encryptedText);
// AES-256-GCM tag is 16 bytes, IV is 12 bytes
if (strlen($data) < 28) {
return '';
}
$iv = substr($data, 0, 12);
$tag = substr($data, 12, 16);
$ciphertext = substr($data, 28);
$decrypted = openssl_decrypt(
$ciphertext,
'aes-256-gcm',
$key,
OPENSSL_RAW_DATA,
$iv,
$tag
);
return $decrypted !== false ? $decrypted : '';
}
/**
* Generate Blind Index (HMAC-SHA256) for secure database queries
*/
public static function blindIndex(string $value): string
{
if ($value === '') {
return '';
}
$normalized = strtolower(trim($value));
return hash_hmac('sha256', $normalized, self::getHmacSalt());
}
/**
* Hash password using bcrypt
*/
public static function hashPassword(string $password): string
{
return password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
}
/**
* Verify password
*/
public static function verifyPassword(string $password, string $hash): bool
{
return password_verify($password, $hash);
}
/**
* Generate JWT Token with HMAC-SHA256 signature
* Includes user_id, company_id, role, iss, aud, and jti.
*/
public static function generateJWT(array $payload, int $expirySeconds = 86400): string
{
$header = self::base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
// Standard OWASP Claims
$payload['iat'] = time();
$payload['exp'] = time() + $expirySeconds;
$payload['iss'] = getenv('APP_URL'); // Issuer
$payload['aud'] = 'nabeh_dashboard'; // Audience
$payload['jti'] = bin2hex(random_bytes(16)); // JWT ID to prevent Replay Attacks
$payloadEncoded = self::base64UrlEncode(json_encode($payload));
$secret = self::getJwtSecret();
$signature = hash_hmac('sha256', "$header.$payloadEncoded", $secret, true);
$signatureEncoded = self::base64UrlEncode($signature);
return "$header.$payloadEncoded.$signatureEncoded";
}
/**
* Verify JWT Token and return payload if valid, false otherwise
*/
public static function verifyJWT(string $token)
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
return false;
}
list($headerEncoded, $payloadEncoded, $signatureEncoded) = $parts;
$secret = self::getJwtSecret();
if (!$secret) {
return false;
}
$signature = self::base64UrlDecode($signatureEncoded);
$expectedSignature = hash_hmac('sha256', "$headerEncoded.$payloadEncoded", $secret, true);
if (!hash_equals($signature, $expectedSignature)) {
return false;
}
$payload = json_decode(self::base64UrlDecode($payloadEncoded), true);
if (!$payload || !isset($payload['exp']) || time() >= $payload['exp']) {
return false;
}
// Validate Issuer
$expectedIssuer = getenv('APP_URL');
if (isset($payload['iss']) && $payload['iss'] !== $expectedIssuer) {
return false;
}
return $payload;
}
private static function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
private static function base64UrlDecode(string $data): string
{
return base64_decode(strtr($data, '-_', '+/') . str_repeat('=', (4 - strlen($data) % 4) % 4));
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace App\Core;
/**
* Core Validation Engine
* Handles data validation before processing.
*/
class Validator
{
private array $errors = [];
/**
* Validate an array of data against rules.
* Example rules: ['email' => 'required|email', 'password' => 'required|min:8']
*/
public function validate(array $data, array $rules): bool
{
$this->errors = [];
foreach ($rules as $field => $ruleString) {
$rulesArray = explode('|', $ruleString);
$value = $data[$field] ?? null;
foreach ($rulesArray as $rule) {
$this->applyRule($field, $value, $rule);
}
}
return empty($this->errors);
}
/**
* Get validation errors.
*/
public function getErrors(): array
{
return $this->errors;
}
/**
* Apply a specific rule to a field's value.
*/
private function applyRule(string $field, $value, string $rule): void
{
// Parse rule with parameters (e.g., min:8)
$params = [];
if (strpos($rule, ':') !== false) {
list($rule, $paramStr) = explode(':', $rule, 2);
$params = explode(',', $paramStr);
}
switch ($rule) {
case 'required':
if ($value === null || trim((string)$value) === '') {
$this->addError($field, "The {$field} field is required.");
}
break;
case 'email':
if ($value && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
$this->addError($field, "The {$field} must be a valid email address.");
}
break;
case 'min':
$min = (int)($params[0] ?? 0);
if ($value && strlen((string)$value) < $min) {
$this->addError($field, "The {$field} must be at least {$min} characters.");
}
break;
case 'max':
$max = (int)($params[0] ?? 0);
if ($value && strlen((string)$value) > $max) {
$this->addError($field, "The {$field} must not exceed {$max} characters.");
}
break;
case 'numeric':
if ($value && !is_numeric($value)) {
$this->addError($field, "The {$field} must be a number.");
}
break;
case 'strong_password':
// At least 8 chars, 1 uppercase, 1 lowercase, 1 number
if ($value && !preg_match('/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$/', $value)) {
$this->addError($field, "The {$field} must be at least 8 characters long and contain uppercase, lowercase, and a number.");
}
break;
}
}
private function addError(string $field, string $message): void
{
if (!isset($this->errors[$field])) {
$this->errors[$field] = [];
}
$this->errors[$field][] = $message;
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Middlewares;
use App\Core\Request;
use App\Core\Response;
use App\Core\Security;
class AuthMiddleware
{
/**
* Verifies the JWT token and populates request properties.
*/
public function handle(Request $request, Response $response): void
{
$authHeader = $request->getHeader('authorization', '');
if (!$authHeader || !preg_match('/Bearer\s(\S+)/i', $authHeader, $matches)) {
$response->json(['error' => 'Unauthorized', 'message' => 'Token not provided or invalid format'], 401);
exit;
}
$token = $matches[1];
$payload = Security::verifyJWT($token);
if (!$payload) {
$response->json(['error' => 'Unauthorized', 'message' => 'Invalid or expired token'], 401);
exit;
}
// Validate required custom payload elements
if (!isset($payload['user_id']) || !isset($payload['company_id']) || !isset($payload['role'])) {
$response->json(['error' => 'Unauthorized', 'message' => 'Malformed token payload structure'], 401);
exit;
}
// Attach user info to the Request instance dynamically so controllers can use it
$request->user_id = $payload['user_id'];
$request->company_id = $payload['company_id'];
$request->role = $payload['role'];
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Middlewares;
use App\Core\Request;
use App\Core\Response;
class SecurityMiddleware
{
/**
* Applies OWASP security headers and sanitizes incoming body/query parameters
* to protect against XSS and basic Injection.
*/
public function handle(Request $request, Response $response): void
{
// 1. Set OWASP Security Headers
$response->setHeader('X-Frame-Options', 'DENY'); // Prevent Clickjacking
$response->setHeader('X-XSS-Protection', '1; mode=block'); // Prevent Cross-Site Scripting (XSS)
$response->setHeader('X-Content-Type-Options', 'nosniff'); // Prevent MIME-sniffing
$response->setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload'); // HSTS
$response->setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; object-src 'none';"); // CSP
// 2. Input Sanitization to prevent XSS (Recursive)
$body = $request->getBody();
if (is_array($body)) {
$request->setBody($this->sanitizeArray($body));
}
$query = $request->getQueryParams();
if (is_array($query)) {
$request->setQueryParams($this->sanitizeArray($query));
}
}
/**
* Recursively sanitize input arrays
*/
private function sanitizeArray(array $data): array
{
$sanitized = [];
foreach ($data as $key => $value) {
if (is_array($value)) {
$sanitized[$key] = $this->sanitizeArray($value);
} elseif (is_string($value)) {
// Strip HTML tags and convert special characters to HTML entities
$sanitized[$key] = htmlspecialchars(strip_tags(trim($value)), ENT_QUOTES, 'UTF-8');
} else {
$sanitized[$key] = $value;
}
}
return $sanitized;
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Models;
class Company extends BaseModel
{
protected static string $table = 'companies';
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use App\Core\Database;
use App\Core\Security;
class User extends BaseModel
{
protected static string $table = 'users';
/**
* Find user securely by email using Blind Index (HMAC-SHA256 Hash).
*/
public static function findByEmail(string $email): ?array
{
$emailHash = Security::blindIndex($email);
return Database::selectOne(
"SELECT * FROM users WHERE email_hash = :hash LIMIT 1",
['hash' => $emailHash]
);
}
/**
* Create a new user securely (encrypting sensitive data and generating hashes).
*/
public static function createSecure(array $data): string
{
// 1. Hash password
$data['password'] = Security::hashPassword($data['password']);
// 2. Generate blind index for email lookup
$data['email_hash'] = Security::blindIndex($data['email']);
// 3. Encrypt the email itself using AES-256-GCM
$data['email'] = Security::encrypt($data['email']);
// 4. Ensure default values if none provided
$data['role'] = $data['role'] ?? 'admin';
$data['status'] = $data['status'] ?? 'active';
return self::create($data);
}
}

164
backend/database_schema.sql Normal file
View File

@@ -0,0 +1,164 @@
-- ==============================================================================
-- 🗄️ Nabeh Multi-Tenant Database Schema
-- UTF-8 Unicode (utf8mb4) is used to support Arabic text and Emojis perfectly.
-- ==============================================================================
-- 1. Companies (Tenants) Table
CREATE TABLE IF NOT EXISTS `companies` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(255) NOT NULL,
`status` ENUM('active', 'inactive', 'suspended') DEFAULT 'active',
`max_sessions` INT DEFAULT 1 COMMENT 'Limit the number of WhatsApp numbers allowed',
`api_key` VARCHAR(64) UNIQUE NULL COMMENT 'For external integrations (CRM/APIs)',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_company_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 2. Users Table (Multi-Tenant Staff/Admins)
CREATE TABLE IF NOT EXISTS `users` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`company_id` INT NOT NULL,
`name` VARCHAR(255) NOT NULL,
`email` VARCHAR(512) NOT NULL COMMENT 'Encrypted using AES-256-GCM',
`email_hash` VARCHAR(64) NOT NULL COMMENT 'HMAC-SHA256 Blind Index for secure searching',
`password` VARCHAR(255) NOT NULL,
`role` ENUM('admin', 'staff') DEFAULT 'staff',
`status` ENUM('active', 'inactive') DEFAULT 'active',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `unique_email_hash` (`email_hash`),
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE,
INDEX `idx_user_company` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 3. WhatsApp Sessions Table (Baileys Connections)
CREATE TABLE IF NOT EXISTS `whatsapp_sessions` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`company_id` INT NOT NULL,
`name` VARCHAR(255) NOT NULL COMMENT 'e.g., Customer Support, Sales Team',
`session_key` VARCHAR(100) UNIQUE NOT NULL COMMENT 'Unique identifier for Baileys node service',
`phone` VARCHAR(512) DEFAULT NULL COMMENT 'Encrypted using AES-256-GCM',
`phone_hash` VARCHAR(64) DEFAULT NULL COMMENT 'HMAC-SHA256 Blind Index',
`status` ENUM('disconnected', 'waiting_qr', 'connected', 'connecting') DEFAULT 'disconnected',
`qr_code` TEXT DEFAULT NULL COMMENT 'Encrypted using AES-256-GCM',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE,
INDEX `idx_session_company` (`company_id`),
INDEX `idx_session_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 4. Contacts Table (Customers / Leads)
CREATE TABLE IF NOT EXISTS `contacts` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`company_id` INT NOT NULL,
`name` VARCHAR(255) NOT NULL,
`phone` VARCHAR(512) NOT NULL COMMENT 'Encrypted using AES-256-GCM',
`phone_hash` VARCHAR(64) NOT NULL COMMENT 'HMAC-SHA256 Blind Index',
`email` VARCHAR(512) DEFAULT NULL COMMENT 'Encrypted using AES-256-GCM',
`email_hash` VARCHAR(64) DEFAULT NULL COMMENT 'HMAC-SHA256 Blind Index',
`notes` TEXT DEFAULT NULL COMMENT 'Encrypted using AES-256-GCM',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE,
UNIQUE KEY `unique_company_phone_hash` (`company_id`, `phone_hash`),
INDEX `idx_contact_company` (`company_id`),
INDEX `idx_contact_phone_hash` (`phone_hash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5. Contact Groups Table (Lists for Broadcasts)
CREATE TABLE IF NOT EXISTS `contact_groups` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`company_id` INT NOT NULL,
`name` VARCHAR(255) NOT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE,
INDEX `idx_group_company` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 6. Contact Group Relations Table (Pivot Table)
CREATE TABLE IF NOT EXISTS `contact_group_relations` (
`contact_id` INT NOT NULL,
`group_id` INT NOT NULL,
PRIMARY KEY (`contact_id`, `group_id`),
FOREIGN KEY (`contact_id`) REFERENCES `contacts` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`group_id`) REFERENCES `contact_groups` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 7. Predefined WhatsApp Templates Table
CREATE TABLE IF NOT EXISTS `templates` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`company_id` INT NOT NULL,
`name` VARCHAR(255) NOT NULL,
`body` TEXT NOT NULL COMMENT 'Message body. Supports variables like {{name}}',
`type` ENUM('text', 'image', 'video', 'document') DEFAULT 'text',
`media_url` TEXT DEFAULT NULL COMMENT 'Encrypted using AES-256-GCM',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE,
INDEX `idx_template_company` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 8. Broadcast Campaigns Table
CREATE TABLE IF NOT EXISTS `campaigns` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`company_id` INT NOT NULL,
`session_id` INT NOT NULL COMMENT 'Which WhatsApp number sends this campaign',
`group_id` INT NOT NULL COMMENT 'Target contact group',
`template_id` INT DEFAULT NULL,
`name` VARCHAR(255) NOT NULL,
`status` ENUM('pending', 'running', 'completed', 'paused', 'failed') DEFAULT 'pending',
`scheduled_at` TIMESTAMP NULL DEFAULT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`session_id`) REFERENCES `whatsapp_sessions` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`group_id`) REFERENCES `contact_groups` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`template_id`) REFERENCES `templates` (`id`) ON DELETE SET NULL,
INDEX `idx_campaign_company` (`company_id`),
INDEX `idx_campaign_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 9. Message History Log Table (Inbound & Outbound)
CREATE TABLE IF NOT EXISTS `messages_log` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`company_id` INT NOT NULL,
`session_id` INT NOT NULL COMMENT 'WhatsApp number that received or sent the message',
`campaign_id` INT DEFAULT NULL COMMENT 'Null if sent individually or received',
`contact_phone` VARCHAR(512) NOT NULL COMMENT 'Encrypted using AES-256-GCM',
`contact_phone_hash` VARCHAR(64) NOT NULL COMMENT 'HMAC-SHA256 Blind Index',
`direction` ENUM('inbound', 'outbound') NOT NULL,
`message_type` ENUM('text', 'image', 'video', 'document', 'audio') DEFAULT 'text',
`message_body` TEXT DEFAULT NULL COMMENT 'Encrypted using AES-256-GCM',
`media_url` TEXT DEFAULT NULL COMMENT 'Encrypted using AES-256-GCM',
`whatsapp_message_id` VARCHAR(100) UNIQUE NULL COMMENT 'To prevent duplicates & track receipt status',
`status` ENUM('pending', 'sent', 'delivered', 'read', 'failed') DEFAULT 'pending',
`error_message` VARCHAR(255) DEFAULT NULL,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`session_id`) REFERENCES `whatsapp_sessions` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`campaign_id`) REFERENCES `campaigns` (`id`) ON DELETE SET NULL,
INDEX `idx_msg_company` (`company_id`),
INDEX `idx_msg_session` (`session_id`),
INDEX `idx_msg_status` (`status`),
INDEX `idx_msg_phone_hash` (`contact_phone_hash`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 10. AI Auto-Reply & Bot Rules
CREATE TABLE IF NOT EXISTS `chatbot_rules` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`company_id` INT NOT NULL,
`session_id` INT DEFAULT NULL COMMENT 'If NULL, applies to all company numbers',
`trigger_type` ENUM('keyword', 'gemini_ai') DEFAULT 'keyword',
`keyword` VARCHAR(255) DEFAULT NULL COMMENT 'Comma separated trigger words for keyword-match',
`ai_prompt` TEXT DEFAULT NULL COMMENT 'Prompt instructions for Gemini AI responder',
`is_active` TINYINT(1) DEFAULT 1,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (`company_id`) REFERENCES `companies` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`session_id`) REFERENCES `whatsapp_sessions` (`id`) ON DELETE CASCADE,
INDEX `idx_bot_company` (`company_id`),
INDEX `idx_bot_active` (`is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -16,7 +16,10 @@ $request = new Request();
$response = new Response();
$router = new Router();
// 3. Define basic routes
// 3. Define Global Middleware
$router->use(\App\Middlewares\SecurityMiddleware::class);
// 4. Define API Routes
$router->get('/api/health', function ($request, $response) {
$response->json([
'status' => 'success',
@@ -30,5 +33,11 @@ $router->get('/api/health', function ($request, $response) {
]);
});
// Authentication Routes
$router->post('/api/auth/register', [\App\Controllers\AuthController::class, 'register']);
$router->post('/api/auth/login', [\App\Controllers\AuthController::class, 'login']);
$router->get('/api/auth/me', [\App\Controllers\AuthController::class, 'me'], [\App\Middlewares\AuthMiddleware::class]);
// 4. Dispatch the request
$router->dispatch($request, $response);

View File

@@ -224,11 +224,31 @@ server {
---
## أسئلة للنقاش والمراجعة البرمجية
## المرحلة الرابعة: نظام التوثيق والمصادقة (Authentication Phase)
> [!NOTE]
> 1. **عنوان خادم الـ SSH والمسار**: هل تود ملء بيانات السيرفر الخاصة بك (اسم المستخدم، عنوان IP، المسار) مباشرة داخل ملف `deploy.sh` لتسهيل الاستخدام الفوري، أم نضعها كمتغيرات في ملف `.env` على أن يقرأها السكريبت؟
> 2. **النطاق الفرعي (Subdomain)**: ما هو الاسم المقترح للنطاق الفرعي لـ Backend؟ (مثال: `api.nabeh.sa` أو `core.nabeh.sa`)؟
> 3. **إصدار PHP على السيرفر**: ما هو إصدار PHP المفعل على خادم Nginx الحالي لديك للتأكد من تطابق التهيئة ودعم الـ Socket الصحيح؟
بناءً على النواة الأمنية التي تم تجهيزها (JWT, AES-256-GCM, Bcrypt, HMAC Blind Index)، سنقوم ببناء نظام المصادقة ليكون جاهزاً لإدارة جلسات المستخدمين.
### Proposed Changes
#### [NEW] `backend/app/Models/User.php`
- كلاس يمتد من `BaseModel` لإدارة جدول `users`.
- يحتوي على دالة `findByEmail` التي تقوم بتوليد (Blind Index HMAC) للبريد الإلكتروني للبحث السريع في قاعدة البيانات دون فك التشفير.
- دالة `createSecure` لإنشاء مستخدم جديد مع تشفير بريده الإلكتروني وكلمة مروره.
#### [NEW] `backend/app/Controllers/AuthController.php`
- دالة `login`: تستقبل البريد الإلكتروني وكلمة المرور، تتحقق منها عبر الموديل، وتولد JWT Token.
- دالة `register` (اختيارية كأداة تطوير مبدئية أو للأدمن): إنشاء حساب موظف جديد.
- دالة `me`: جلب بيانات المستخدم الحالي باستخدام الـ `AuthMiddleware` لاختبار سلامة الجلسة.
#### [MODIFY] `backend/public/index.php`
- إضافة المسارات الخاصة بنظام التوثيق:
- `POST /api/auth/login`
- `GET /api/auth/me` (مرفق بـ `AuthMiddleware`)
### User Review Required
> [!IMPORTANT]
> - هل ترغب بإنشاء مسار `POST /api/auth/register` مفتوح للجميع لإنشاء حسابات شركات جديدة؟ أم نكتفي حالياً بإنشاء مستخدم مدير (Admin) افتراضي يدوياً أو عبر سكربت ليكون النظام مغلقاً للشركات المعتمدة فقط؟
> - في عملية تسجيل الدخول، هل نحتاج لإرجاع بيانات الشركة المرتبطة بالمستخدم ضمن نفس الـ Response، أم نكتفي بإرجاع التوكن (Token) وبيانات المستخدم الأساسية فقط؟
</div>